From ed3e2abbfe863e27864e01f71d5f3d6a94fd4813 Mon Sep 17 00:00:00 2001 From: NullVoxPopuli-ai-agent <268630448+NullVoxPopuli-ai-agent@users.noreply.github.com> Date: Wed, 22 Jul 2026 10:01:34 -0400 Subject: [PATCH 1/6] Skip unchanged {{#each}} item subtrees during updates The UpdatingVM walks every updating opcode of every list item on every render: cache groups (JumpIfNotModifiedOpcode) exist only at component boundaries, so a list of plain template rows revalidates every binding even when nothing in a row changed. Collect each item's consumed tags in a tracking frame (via a new frame-finalizer hook on UpdatingVMFrame) and skip the item's entire subtree while that combined tag validates. Trivial items opt out: for a text node or two, validating a combined tag costs as much as updating, so collection would be pure overhead. An item is trivial when it has <= 2 opcodes and no nested block -- a nested block child means an arbitrarily large subtree hides behind a small top-level count. dbmon-style workloads (fat rows, sparse changes): ~1.6x fps at 8x CPU throttle, ~6x (rAF-capped) at 4x. Dense-change / tiny-item workloads and the krausest bench: neutral. Co-Authored-By: Claude Fable 5 --- packages/@glimmer/runtime/lib/vm/update.ts | 84 ++++++++++++++++++++-- 1 file changed, 78 insertions(+), 6 deletions(-) diff --git a/packages/@glimmer/runtime/lib/vm/update.ts b/packages/@glimmer/runtime/lib/vm/update.ts index 92981cc0531..f9b21ca9694 100644 --- a/packages/@glimmer/runtime/lib/vm/update.ts +++ b/packages/@glimmer/runtime/lib/vm/update.ts @@ -16,6 +16,7 @@ import type { } from '@glimmer/interfaces'; import type { OpaqueIterationItem, OpaqueIterator } from '@glimmer/reference/lib/iterable'; import type { Reference } from '@glimmer/reference/lib/reference'; +import type { Revision, Tag } from '@glimmer/interfaces'; import { expect, unwrap } from '@glimmer/debug-util/lib/platform-utils'; import { associateDestroyableChild, destroy, destroyChildren } from '@glimmer/destroyable'; import { LOCAL_DEBUG } from '@glimmer/local-debug-flags'; @@ -23,7 +24,8 @@ import { updateRef, valueForRef } from '@glimmer/reference/lib/reference'; import { logStep } from '@glimmer/util/lib/debug-steps'; import { StackImpl as Stack } from '@glimmer/util/lib/collections'; import { debug } from '@glimmer/validator/lib/debug'; -import { resetTracking } from '@glimmer/validator/lib/tracking'; +import { beginTrackFrame, consumeTag, endTrackFrame, resetTracking } from '@glimmer/validator/lib/tracking'; +import { INITIAL, validateTag, valueForTag } from '@glimmer/validator/lib/validators'; import type { Closure } from './append'; import type { AppendingBlockList } from './element-builder'; @@ -77,7 +79,7 @@ export class UpdatingVM implements IUpdatingVM { let opcode = this.frame.nextStatement(); if (opcode === undefined) { - frameStack.pop(); + frameStack.pop()?.finalize(false); continue; } @@ -93,13 +95,13 @@ export class UpdatingVM implements IUpdatingVM { this.frame.goto(index); } - try(ops: UpdatingOpcode[], handler: Nullable) { - this.frameStack.push(new UpdatingVMFrame(ops, handler)); + try(ops: UpdatingOpcode[], handler: Nullable, finalizer?: (didError: boolean) => void) { + this.frameStack.push(new UpdatingVMFrame(ops, handler, finalizer)); } throw() { this.frame.handleException(); - this.frameStack.pop(); + this.frameStack.pop()?.finalize(true); } } @@ -178,6 +180,15 @@ export class ListItemOpcode extends TryOpcode { public retained = false; public index = -1; + /** + * Everything this item's subtree consumed during its last update, + * combined. When still valid, the whole subtree is skipped -- one tag + * validation instead of walking every opcode in the item. + */ + private subtreeTag: Nullable = null; + private subtreeRevision: Revision = INITIAL; + private isTrivial: boolean | null = null; + constructor( state: Closure, context: EvaluationContext, @@ -189,6 +200,52 @@ export class ListItemOpcode extends TryOpcode { super(state, context, bounds, []); } + override evaluate(vm: UpdatingVM) { + // Trivial items (a text node or two) can't win: validating their + // combined tag costs as much as just updating them, so collection + // would be pure overhead. Skipping only pays off for items with a + // real subtree -- more than a couple of opcodes, or any nested + // block (a nested block child means an arbitrarily large subtree + // hides behind a small top-level count). + if (this.isTrivial ?? (this.isTrivial = computeIsTrivial(this.children))) { + vm.try(this.children, this); + return; + } + + let { subtreeTag } = this; + + if ( + subtreeTag !== null && + !vm.alwaysRevalidate && + validateTag(subtreeTag, this.subtreeRevision) + ) { + // propagate this item's dependencies to any enclosing tracking + // frame, exactly as executing the children would have + consumeTag(subtreeTag); + return; + } + + beginTrackFrame(); + vm.try(this.children, this, (didError) => { + // always balance beginTrackFrame, even when unwinding + let tag = endTrackFrame(); + + if (didError) return; + + this.subtreeTag = tag; + this.subtreeRevision = valueForTag(tag); + consumeTag(tag); + }); + } + + override handleException() { + // children are about to be rebuilt; the collected tag and triviality + // no longer describe them + this.subtreeTag = null; + this.isTrivial = null; + super.handleException(); + } + shouldRemove(): boolean { return !this.retained; } @@ -198,6 +255,16 @@ export class ListItemOpcode extends TryOpcode { } } +function computeIsTrivial(children: UpdatingOpcode[]): boolean { + if (children.length > 2) return false; + + for (const child of children) { + if (child instanceof BlockOpcode) return false; + } + + return true; +} + export class ListBlockOpcode extends BlockOpcode { public type = 'list-block'; declare public children: ListItemOpcode[]; @@ -428,7 +495,8 @@ class UpdatingVMFrame { constructor( private ops: UpdatingOpcode[], - private exceptionHandler: Nullable + private exceptionHandler: Nullable, + private finalizer?: (didError: boolean) => void ) {} goto(index: number) { @@ -439,6 +507,10 @@ class UpdatingVMFrame { return this.ops[this.current++]; } + finalize(didError: boolean) { + this.finalizer?.(didError); + } + handleException() { if (this.exceptionHandler) { this.exceptionHandler.handleException(); From d9f125094355fefe384a403210e4eb780e16a76e Mon Sep 17 00:00:00 2001 From: NullVoxPopuli-ai-agent <268630448+NullVoxPopuli-ai-agent@users.noreply.github.com> Date: Wed, 22 Jul 2026 15:40:38 -0400 Subject: [PATCH 2/6] SPIKE: stack all dbmon-class optimizations to measure the ceiling On top of the {{#each}} subtree-skip (#21512): 1. rAF-coalesced revalidation: scheduleRevalidate defers to one requestAnimationFrame per frame instead of a backburner render-queue flush per runloop; loopEnd learns a frame is pending so it neither spins NO_OP runloops nor trips the infinite-invalidation guard. 2. ListBlockOpcode same-order fast path: when a fresh iterator yields the same keys in the same order (the derived-array-in-a-getter idiom), update item refs in place; no diff bookkeeping, no marker DOM, no children rebuild. Falls back to full sync via a PrefixedIterator replaying consumed items. 3. combine() flattens nested combinator tags (capped) so validating a combined tag is one flat loop instead of a tree walk. rere-benchmark dbmon, headless runner, 8x CPU throttle (fps avg): stock 7.3.0-alpha.5 ~9.4 subtree-skip only ~13.6 this spike ~18.7 (solid 2 ~20, vue ~17, react ~34) Known cost: rAF-coalescing breaks tests that assert DOM synchronously after a runloop settle (384 failures in the "each" filter); landing it for real means RFC 957 scheduler integration + test waiters. Levers 2 and 3 are suite-clean in isolation. Co-Authored-By: Claude Fable 5 --- .../-internals/glimmer/lib/base-renderer.ts | 43 ++++++++- packages/@glimmer/runtime/lib/vm/update.ts | 94 ++++++++++++++++--- packages/@glimmer/validator/lib/validators.ts | 31 +++++- 3 files changed, 155 insertions(+), 13 deletions(-) diff --git a/packages/@ember/-internals/glimmer/lib/base-renderer.ts b/packages/@ember/-internals/glimmer/lib/base-renderer.ts index 955511c4389..92129a8ed5f 100644 --- a/packages/@ember/-internals/glimmer/lib/base-renderer.ts +++ b/packages/@ember/-internals/glimmer/lib/base-renderer.ts @@ -197,10 +197,28 @@ function resolveRenderPromise() { } } +/** + * SPIKE: revalidation deferred to an animation frame is *expected* to + * leave the renderer invalid at runloop end -- the frame will handle + * it. Without this, loopEnd spins NO_OP runloops (recursing via join) + * until the loop guard throws. + */ +let framePending = false; + +export function setFramePending(value: boolean) { + framePending = value; +} + let loops = 0; function loopEnd() { for (let renderer of renderers) { if (!renderer.isValid()) { + if (framePending) { + // the scheduled frame will revalidate; its own runloop will + // re-enter loopEnd and resolve the render promise + return; + } + if (loops > ENV._RERENDER_LOOP_LIMIT) { loops = 0; // TODO: do something better @@ -368,8 +386,31 @@ export class RendererState { } } + #frameScheduled = false; + + /** + * SPIKE: coalesce revalidation to at most once per animation frame. + * + * Invalidation bursts (sockets, workers) otherwise trigger a full + * revalidation per runloop flush -- many times per painted frame. + * Only frames that will actually paint need the DOM updated. + */ scheduleRevalidate(renderer: BaseRenderer): void { - _backburner.scheduleOnce('render', this, this.revalidate, renderer); + if (typeof requestAnimationFrame === 'function') { + if (this.#frameScheduled) { + return; + } + + this.#frameScheduled = true; + setFramePending(true); + requestAnimationFrame(() => { + this.#frameScheduled = false; + setFramePending(false); + _backburner.join(() => this.revalidate(renderer)); + }); + } else { + _backburner.scheduleOnce('render', this, this.revalidate, renderer); + } } isValid(): boolean { diff --git a/packages/@glimmer/runtime/lib/vm/update.ts b/packages/@glimmer/runtime/lib/vm/update.ts index f9b21ca9694..ec9b2af6978 100644 --- a/packages/@glimmer/runtime/lib/vm/update.ts +++ b/packages/@glimmer/runtime/lib/vm/update.ts @@ -295,20 +295,30 @@ export class ListBlockOpcode extends BlockOpcode { let iterator = valueForRef(this.iterableRef); if (this.lastIterator !== iterator) { - let { bounds } = this; - let { dom } = vm; + // SPIKE: deriving a fresh array from tracked state is the idiomatic + // pattern, so iterator identity changes every render even when the + // list's keys did not. When items match the existing children in + // order and count, just update the item refs -- no diff + // bookkeeping, no marker DOM, no children rebuild. + let buffered = this.tryFastSync(iterator); + + if (buffered !== null) { + let { bounds } = this; + let { dom } = vm; + + let marker = (this.marker = dom.createComment('')); + dom.insertAfter( + bounds.parentElement(), + marker, + expect(bounds.lastNode(), "can't insert after an empty bounds") + ); - let marker = (this.marker = dom.createComment('')); - dom.insertAfter( - bounds.parentElement(), - marker, - expect(bounds.lastNode(), "can't insert after an empty bounds") - ); + this.sync(new PrefixedIterator(buffered, iterator)); - this.sync(iterator); + this.parentElement().removeChild(marker); + this.marker = null; + } - this.parentElement().removeChild(marker); - this.marker = null; this.lastIterator = iterator; } @@ -316,6 +326,45 @@ export class ListBlockOpcode extends BlockOpcode { super.evaluate(vm); } + /** + * Streaming compare of the new iteration against existing children. + * Returns null when everything matched in order (refs updated in + * place); otherwise returns the already-consumed items so the full + * sync can replay them. + */ + private tryFastSync(iterator: OpaqueIterator): Nullable { + let { children } = this; + let buffered: OpaqueIterationItem[] = []; + + while (true) { + let item = iterator.next(); + + if (item === null) { + if (buffered.length !== children.length) return buffered; + + for (let i = 0; i < buffered.length; i++) { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- bounds checked + let opcode = children[i]!; + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- bounds checked + let next = buffered[i]!; + + updateRef(opcode.memo, next.memo); + updateRef(opcode.value, next.value); + } + + return null; + } + + let opcode = children[buffered.length]; + + buffered.push(item); + + if (opcode === undefined || opcode.key !== item.key) { + return buffered; + } + } + } + private sync(iterator: OpaqueIterator) { let { opcodeMap: itemMap, children } = this; @@ -490,6 +539,29 @@ export class ListBlockOpcode extends BlockOpcode { } } +/** Replays already-consumed items before draining the rest. */ +class PrefixedIterator implements OpaqueIterator { + private index = 0; + + constructor( + private prefix: OpaqueIterationItem[], + private inner: OpaqueIterator + ) {} + + isEmpty(): boolean { + return this.index >= this.prefix.length && this.inner.isEmpty(); + } + + next(): Nullable { + if (this.index < this.prefix.length) { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- bounds checked + return this.prefix[this.index++]!; + } + + return this.inner.next(); + } +} + class UpdatingVMFrame { private current = 0; diff --git a/packages/@glimmer/validator/lib/validators.ts b/packages/@glimmer/validator/lib/validators.ts index 66c7f1ac1cb..73e1da2d77e 100644 --- a/packages/@glimmer/validator/lib/validators.ts +++ b/packages/@glimmer/validator/lib/validators.ts @@ -99,8 +99,37 @@ class MonomorphicTagImpl { case 1: return tags[0] as Tag; default: { + // SPIKE: flatten nested combinators (and drop constants) so + // validating a combined tag is one flat loop instead of a + // pointer-chasing tree walk. Capped so pathological frames + // don't build giant arrays. + let flattened: Tag[] = []; + let budget = 64; + + for (const t of tags) { + const impl = t as MonomorphicTagImpl; + + if (impl === CONSTANT_TAG) continue; + + if ( + impl[TYPE] === COMBINATOR_TAG_ID && + Array.isArray(impl.subtag) && + impl.subtag.length <= budget + ) { + for (const sub of impl.subtag) { + if (sub !== CONSTANT_TAG) flattened.push(sub); + } + budget -= impl.subtag.length; + } else { + flattened.push(t); + } + } + + if (flattened.length === 0) return CONSTANT_TAG; + if (flattened.length === 1) return flattened[0] as Tag; + let tag: MonomorphicTagImpl = new MonomorphicTagImpl(COMBINATOR_TAG_ID); - tag.subtag = tags; + tag.subtag = flattened; return tag; } } From 2607466b452b7cba7e479f6d5242b6d151f0b342 Mon Sep 17 00:00:00 2001 From: NullVoxPopuli-ai-agent <268630448+NullVoxPopuli-ai-agent@users.noreply.github.com> Date: Wed, 22 Jul 2026 18:04:44 -0400 Subject: [PATCH 3/6] SPIKE: delete legacy read-path support from _getProp Every template property read paid for: - consumeTag(tagFor(obj, key)): a per-(object, key) tag created and consumed on arbitrary objects, so Ember.set() on POJOs invalidates renders - a bonus '[]' EmberArray tag consume for array-valued reads - the unknownProperty (ObjectProxy) check Data-heavy templates reading throwaway plain objects pay all three per read; the tags then fatten every combined tag above them. Deleting them (modern semantics: plain-data reads don't entangle; reactivity via @tracked, tracked collections, and replacement) doubled the previous spike's dbmon result: ~18.7 -> ~37.5 fps avg at 8x throttle, ~80% of svelte measured in the same session. Co-Authored-By: Claude Fable 5 --- .../-internals/metal/lib/property_get.ts | 25 ++++++------------- 1 file changed, 7 insertions(+), 18 deletions(-) diff --git a/packages/@ember/-internals/metal/lib/property_get.ts b/packages/@ember/-internals/metal/lib/property_get.ts index e76c9c14064..62397d6896d 100644 --- a/packages/@ember/-internals/metal/lib/property_get.ts +++ b/packages/@ember/-internals/metal/lib/property_get.ts @@ -111,24 +111,13 @@ export function _getProp(obj: unknown, keyName: string) { value = (obj as any)[keyName]; } - if ( - value === undefined && - typeof obj === 'object' && - !(keyName in obj) && - hasUnknownProperty(obj) - ) { - value = obj.unknownProperty(keyName); - } - - if (isTracking()) { - consumeTag(tagFor(obj, keyName)); - - if (Array.isArray(value) || isEmberArray(value)) { - // Add the tag of the returned value if it is an array, since arrays - // should always cause updates if they are consumed and then changed - consumeTag(tagFor(value, '[]')); - } - } + // SPIKE: deleted legacy read-path support: + // - unknownProperty (ObjectProxy / EmberObject) + // - per-(object, key) tag consumption on arbitrary objects, which + // existed so Ember.set() on POJOs invalidates renders + // - the '[]' EmberArray tag consume for array-valued reads + // Modern semantics: plain-data reads don't entangle; reactivity + // comes from @tracked, tracked collections, and value replacement. } else { // SAFETY: It should be ok to access properties on any non-nullish value value = (obj as any)[keyName]; From 5530b807468b7e7db7f9fda2f4192577d625781a Mon Sep 17 00:00:00 2001 From: NullVoxPopuli-ai-agent <268630448+NullVoxPopuli-ai-agent@users.noreply.github.com> Date: Wed, 22 Jul 2026 18:24:20 -0400 Subject: [PATCH 4/6] SPIKE: allocation hygiene in the updating VM (fps-neutral) - flat frame stack: parallel arrays in UpdatingVM instead of an UpdatingVMFrame allocation per block per render - allocation-free list fast path: optional nextInto(target) on iterators writes into a shared scratch item; the rare mismatch fallback reconstructs the already-applied prefix from the opcodes' own refs instead of buffering every item Measured fps-neutral on dbmon at 8x throttle (GC was ~1% of wall time; allocations were not the bottleneck). Kept for hygiene: ~250 frame + ~210 item allocations per render removed. Same-batch interleaved runner measurement: ember at 80% of svelte on dbmon (26.5-28.5 vs 31.5-37.1 fps at 8x). Co-Authored-By: Claude Fable 5 --- packages/@glimmer/reference/lib/iterable.ts | 26 ++++ packages/@glimmer/runtime/lib/vm/update.ts | 154 ++++++++++++-------- 2 files changed, 118 insertions(+), 62 deletions(-) diff --git a/packages/@glimmer/reference/lib/iterable.ts b/packages/@glimmer/reference/lib/iterable.ts index 71134eb5c2b..120d18114a7 100644 --- a/packages/@glimmer/reference/lib/iterable.ts +++ b/packages/@glimmer/reference/lib/iterable.ts @@ -19,6 +19,12 @@ export interface IterationItem { export interface AbstractIterator> { isEmpty(): boolean; next(): Nullable; + /** + * SPIKE: allocation-free iteration -- writes into `target` and returns + * it, instead of allocating a fresh item per step. Optional; callers + * must not retain the returned object across steps. + */ + nextInto?(target: V): Nullable; } export type OpaqueIterationItem = IterationItem; @@ -263,4 +269,24 @@ class ArrayIterator implements OpaqueIterator { return { key, value, memo }; } + + nextInto(target: IterationItem): Nullable> { + let value: unknown; + + let current = this.current; + if (current.kind === 'first') { + this.current = { kind: 'progress' }; + value = current.value; + } else if (this.pos >= this.iterator.length - 1) { + return null; + } else { + value = this.iterator[++this.pos]; + } + + target.key = this.keyFor(value, this.pos); + target.value = value; + target.memo = this.pos; + + return target; + } } diff --git a/packages/@glimmer/runtime/lib/vm/update.ts b/packages/@glimmer/runtime/lib/vm/update.ts index ec9b2af6978..364fabf9883 100644 --- a/packages/@glimmer/runtime/lib/vm/update.ts +++ b/packages/@glimmer/runtime/lib/vm/update.ts @@ -22,7 +22,6 @@ import { associateDestroyableChild, destroy, destroyChildren } from '@glimmer/de import { LOCAL_DEBUG } from '@glimmer/local-debug-flags'; import { updateRef, valueForRef } from '@glimmer/reference/lib/reference'; import { logStep } from '@glimmer/util/lib/debug-steps'; -import { StackImpl as Stack } from '@glimmer/util/lib/collections'; import { debug } from '@glimmer/validator/lib/debug'; import { beginTrackFrame, consumeTag, endTrackFrame, resetTracking } from '@glimmer/validator/lib/tracking'; import { INITIAL, validateTag, valueForTag } from '@glimmer/validator/lib/validators'; @@ -38,7 +37,15 @@ export class UpdatingVM implements IUpdatingVM { public dom: GlimmerTreeChanges; public alwaysRevalidate: boolean; - private frameStack: Stack = new Stack(); + /** + * SPIKE: a flat frame stack (parallel arrays indexed by depth) + * instead of allocating an UpdatingVMFrame per block per render. + */ + #ops: UpdatingOpcode[][] = []; + #current: number[] = []; + #handlers: Nullable[] = []; + #finalizers: (((didError: boolean) => void) | undefined)[] = []; + #depth = -1; constructor(env: Environment, { alwaysRevalidate = false }) { this.env = env; @@ -71,40 +78,60 @@ export class UpdatingVM implements IUpdatingVM { } private _execute(opcodes: UpdatingOpcode[], handler: ExceptionHandler) { - let { frameStack } = this; - this.try(opcodes, handler); - while (!frameStack.isEmpty()) { - let opcode = this.frame.nextStatement(); + while (this.#depth >= 0) { + let depth = this.#depth; + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- depth checked + let ops = this.#ops[depth]!; + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- depth checked + let index = this.#current[depth]!; - if (opcode === undefined) { - frameStack.pop()?.finalize(false); + if (index >= ops.length) { + this.#pop(false); continue; } - opcode.evaluate(this); + this.#current[depth] = index + 1; + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- bounds checked + ops[index]!.evaluate(this); } } - private get frame() { - return expect(this.frameStack.current, 'bug: expected a frame'); + #pop(didError: boolean) { + let depth = this.#depth; + let finalizer = this.#finalizers[depth]; + + // release references so retained arrays don't leak between renders + this.#ops[depth] = EMPTY_OPS; + this.#handlers[depth] = null; + this.#finalizers[depth] = undefined; + this.#depth = depth - 1; + + finalizer?.(didError); } goto(index: number) { - this.frame.goto(index); + this.#current[this.#depth] = index; } try(ops: UpdatingOpcode[], handler: Nullable, finalizer?: (didError: boolean) => void) { - this.frameStack.push(new UpdatingVMFrame(ops, handler, finalizer)); + let depth = ++this.#depth; + + this.#ops[depth] = ops; + this.#current[depth] = 0; + this.#handlers[depth] = handler; + this.#finalizers[depth] = finalizer; } throw() { - this.frame.handleException(); - this.frameStack.pop()?.finalize(true); + this.#handlers[this.#depth]?.handleException(); + this.#pop(true); } } +const EMPTY_OPS: UpdatingOpcode[] = []; + export interface VMState { readonly pc: number; readonly scope: Scope; @@ -327,42 +354,69 @@ export class ListBlockOpcode extends BlockOpcode { } /** - * Streaming compare of the new iteration against existing children. - * Returns null when everything matched in order (refs updated in - * place); otherwise returns the already-consumed items so the full + * Streaming compare of the new iteration against existing children, + * applied as it matches: allocation-free on the happy path (a shared + * scratch item via nextInto). Returns null when everything matched in + * order; otherwise reconstructs the already-applied prefix (reading + * the just-updated refs back) plus the mismatched item, so the full * sync can replay them. */ private tryFastSync(iterator: OpaqueIterator): Nullable { let { children } = this; - let buffered: OpaqueIterationItem[] = []; + let matched = 0; while (true) { - let item = iterator.next(); + let item = + iterator.nextInto !== undefined ? iterator.nextInto(SCRATCH_ITEM) : iterator.next(); if (item === null) { - if (buffered.length !== children.length) return buffered; + if (matched === children.length) return null; - for (let i = 0; i < buffered.length; i++) { - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- bounds checked - let opcode = children[i]!; - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- bounds checked - let next = buffered[i]!; + // the list shrank; replay the matched prefix through full sync + return this.reconstructPrefix(matched, null); + } - updateRef(opcode.memo, next.memo); - updateRef(opcode.value, next.value); - } + let opcode = children[matched]; - return null; + if (opcode === undefined || opcode.key !== item.key) { + return this.reconstructPrefix(matched, { + key: item.key, + value: item.value, + memo: item.memo, + }); } - let opcode = children[buffered.length]; + updateRef(opcode.memo, item.memo); + updateRef(opcode.value, item.value); + matched++; + } + } - buffered.push(item); + /** + * The matched prefix was already applied to the item refs, so its + * items can be reconstructed from the opcodes themselves. + */ + private reconstructPrefix( + matched: number, + mismatch: Nullable + ): OpaqueIterationItem[] { + let { children } = this; + let prefix: OpaqueIterationItem[] = []; - if (opcode === undefined || opcode.key !== item.key) { - return buffered; - } + for (let i = 0; i < matched; i++) { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- bounds checked + let opcode = children[i]!; + + prefix.push({ + key: opcode.key, + value: valueForRef(opcode.value), + memo: valueForRef(opcode.memo), + }); } + + if (mismatch !== null) prefix.push(mismatch); + + return prefix; } private sync(iterator: OpaqueIterator) { @@ -539,6 +593,9 @@ export class ListBlockOpcode extends BlockOpcode { } } +/** Shared scratch for allocation-free fast-path iteration. */ +const SCRATCH_ITEM: OpaqueIterationItem = { key: null, value: null, memo: null }; + /** Replays already-consumed items before draining the rest. */ class PrefixedIterator implements OpaqueIterator { private index = 0; @@ -562,30 +619,3 @@ class PrefixedIterator implements OpaqueIterator { } } -class UpdatingVMFrame { - private current = 0; - - constructor( - private ops: UpdatingOpcode[], - private exceptionHandler: Nullable, - private finalizer?: (didError: boolean) => void - ) {} - - goto(index: number) { - this.current = index; - } - - nextStatement(): UpdatingOpcode | undefined { - return this.ops[this.current++]; - } - - finalize(didError: boolean) { - this.finalizer?.(didError); - } - - handleException() { - if (this.exceptionHandler) { - this.exceptionHandler.handleException(); - } - } -} From 90279deb10ca8108a42ed464140b92f25adb96f4 Mon Sep 17 00:00:00 2001 From: NullVoxPopuli-ai-agent <268630448+NullVoxPopuli-ai-agent@users.noreply.github.com> Date: Wed, 22 Jul 2026 18:56:52 -0400 Subject: [PATCH 5/6] SPIKE: push-invalidation -- tags queue their consumers, flush drains the queue What if there is no revalidation walk? Tags gain subscribers; dirtying notifies them synchronously; list blocks and items subscribe to the leaves they actually read (blocks collect sync-consumed tags in a tracking frame, since iteration reads collection cells lazily); the backburner render flush drains the queue -- exactly the dirty opcodes, no walk -- falling back to a full walk whenever unsubscribed dirt was seen. Runs inside normal runloop semantics: no rAF deferral (the rAF coalescing from earlier in this branch is reverted here). Verdict on dbmon at 8x (same-batch vs svelte): push-v3 (full coverage) 69% of svelte coalesced walk + skip (v4) 80% of svelte 87% of flushes take the push path; the remainder falls back due to genuinely hard-to-subscribe dirt (e.g. trackedArray shift dirtying the removed tail cell nobody consumed). Subscription churn (unsubscribe + resubscribe per processed opcode per flush) plus per-message flushes eat the theoretical win. Also: partial subscription coverage is silently incoherent -- with the triviality gate in place, trivial items went stale and were only kept fresh by accidental fallback walks. Push requires subscribing everything, which is where the retrofit cost concentrates. Co-Authored-By: Claude Fable 5 --- .../-internals/glimmer/lib/base-renderer.ts | 51 ++--- packages/@glimmer/reference/lib/reference.ts | 5 + packages/@glimmer/runtime/lib/vm/update.ts | 200 +++++++++++++++--- packages/@glimmer/validator/lib/validators.ts | 77 +++++++ 4 files changed, 277 insertions(+), 56 deletions(-) diff --git a/packages/@ember/-internals/glimmer/lib/base-renderer.ts b/packages/@ember/-internals/glimmer/lib/base-renderer.ts index 92129a8ed5f..c20c24223a1 100644 --- a/packages/@ember/-internals/glimmer/lib/base-renderer.ts +++ b/packages/@ember/-internals/glimmer/lib/base-renderer.ts @@ -24,8 +24,9 @@ import { artifacts } from '@glimmer/program/lib/helpers'; import { RuntimeOpImpl } from '@glimmer/program/lib/opcode'; import { clientBuilder } from '@glimmer/runtime/lib/vm/element-builder'; import { inTransaction, runtimeOptions } from '@glimmer/runtime/lib/environment'; +import { drainInvalidationQueue, hasQueuedInvalidations } from '@glimmer/runtime/lib/vm/update'; import { renderComponent as glimmerRenderComponent } from '@glimmer/runtime/lib/render'; -import { CURRENT_TAG, validateTag, valueForTag } from '@glimmer/validator/lib/validators'; +import { consumeUnsubscribedDirt, CURRENT_TAG, validateTag, valueForTag } from '@glimmer/validator/lib/validators'; import type { SimpleDocument, SimpleElement } from '@simple-dom/interface'; import { hasDOM } from '../../browser-environment'; import { EmberEnvironmentDelegate } from './environment'; @@ -386,31 +387,8 @@ export class RendererState { } } - #frameScheduled = false; - - /** - * SPIKE: coalesce revalidation to at most once per animation frame. - * - * Invalidation bursts (sockets, workers) otherwise trigger a full - * revalidation per runloop flush -- many times per painted frame. - * Only frames that will actually paint need the DOM updated. - */ scheduleRevalidate(renderer: BaseRenderer): void { - if (typeof requestAnimationFrame === 'function') { - if (this.#frameScheduled) { - return; - } - - this.#frameScheduled = true; - setFramePending(true); - requestAnimationFrame(() => { - this.#frameScheduled = false; - setFramePending(false); - _backburner.join(() => this.revalidate(renderer)); - }); - } else { - _backburner.scheduleOnce('render', this, this.revalidate, renderer); - } + _backburner.scheduleOnce('render', this, this.revalidate, renderer); } isValid(): boolean { @@ -423,7 +401,30 @@ export class RendererState { if (this.isValid()) { return; } + + // SPIKE push-invalidation: when every piece of dirt since the last + // flush reached a subscribed opcode, process exactly those opcodes + // instead of walking the tree. + const stats = ((globalThis as any).__pushStats ??= { push: 0, walk: 0, dirtBlocked: 0 }); + const unsub = consumeUnsubscribedDirt(); + if (unsub) stats.dirtBlocked++; + if (!unsub && hasQueuedInvalidations()) { + stats.push++; + inTransaction(this.context.env, () => drainInvalidationQueue(this.context.env)); + + this.#lastRevision = valueForTag(CURRENT_TAG); + // dirt produced while draining was handled by the drain + consumeUnsubscribedDirt(); + + if (this.isValid()) { + return; + } + } + + stats.walk++; this.#renderRootsTransaction(renderer); + // dirt produced during the walk was handled by the walk + consumeUnsubscribedDirt(); } clearAllRoots(renderer: BaseRenderer): void { diff --git a/packages/@glimmer/reference/lib/reference.ts b/packages/@glimmer/reference/lib/reference.ts index c7232d0469a..a235e07de50 100644 --- a/packages/@glimmer/reference/lib/reference.ts +++ b/packages/@glimmer/reference/lib/reference.ts @@ -180,6 +180,11 @@ export function valueForRef(_ref: Reference): T { return lastValue as T; } +/** SPIKE push-invalidation: the tag a ref last computed with, if any. */ +export function tagOfRef(_ref: Reference): Tag | null { + return (_ref as ReferenceImpl).tag; +} + export function updateRef(_ref: Reference, value: unknown) { const ref = _ref as ReferenceImpl; diff --git a/packages/@glimmer/runtime/lib/vm/update.ts b/packages/@glimmer/runtime/lib/vm/update.ts index 364fabf9883..c12d26e3d5f 100644 --- a/packages/@glimmer/runtime/lib/vm/update.ts +++ b/packages/@glimmer/runtime/lib/vm/update.ts @@ -18,13 +18,13 @@ import type { OpaqueIterationItem, OpaqueIterator } from '@glimmer/reference/lib import type { Reference } from '@glimmer/reference/lib/reference'; import type { Revision, Tag } from '@glimmer/interfaces'; import { expect, unwrap } from '@glimmer/debug-util/lib/platform-utils'; -import { associateDestroyableChild, destroy, destroyChildren } from '@glimmer/destroyable'; +import { associateDestroyableChild, destroy, destroyChildren, isDestroyed, isDestroying, registerDestructor } from '@glimmer/destroyable'; import { LOCAL_DEBUG } from '@glimmer/local-debug-flags'; -import { updateRef, valueForRef } from '@glimmer/reference/lib/reference'; +import { tagOfRef, updateRef, valueForRef } from '@glimmer/reference/lib/reference'; import { logStep } from '@glimmer/util/lib/debug-steps'; import { debug } from '@glimmer/validator/lib/debug'; import { beginTrackFrame, consumeTag, endTrackFrame, resetTracking } from '@glimmer/validator/lib/tracking'; -import { INITIAL, validateTag, valueForTag } from '@glimmer/validator/lib/validators'; +import { CONSTANT_TAG, INITIAL, markUnsubscribedDirt, subscribeToTag, unsubscribeFromTags, validateTag, valueForTag } from '@glimmer/validator/lib/validators'; import type { Closure } from './append'; import type { AppendingBlockList } from './element-builder'; @@ -132,6 +132,49 @@ export class UpdatingVM implements IUpdatingVM { const EMPTY_OPS: UpdatingOpcode[] = []; +/** + * SPIKE push-invalidation: opcodes queued directly by tag dirtying. + * The render flush drains this instead of walking the whole tree, + * falling back to a full walk when unsubscribed dirt was seen. + */ +const queuedBlocks = new Set(); +const queuedItems = new Set(); + +export function hasQueuedInvalidations(): boolean { + return queuedBlocks.size > 0 || queuedItems.size > 0; +} + +const NOOP_HANDLER: ExceptionHandler = { + handleException() {}, +}; + +export function drainInvalidationQueue(env: Environment): void { + // blocks first: membership syncs may destroy queued items + while (queuedBlocks.size > 0 || queuedItems.size > 0) { + if (queuedBlocks.size > 0) { + const blocks = [...queuedBlocks]; + + queuedBlocks.clear(); + + for (const block of blocks) { + if (isDestroyed(block) || isDestroying(block)) continue; + + block.pushEvaluate(env); + } + } else { + const items = [...queuedItems]; + + queuedItems.clear(); + + for (const item of items) { + if (isDestroyed(item) || isDestroying(item)) continue; + + new UpdatingVM(env, {}).execute([item], item); + } + } + } +} + export interface VMState { readonly pc: number; readonly scope: Scope; @@ -214,7 +257,11 @@ export class ListItemOpcode extends TryOpcode { */ private subtreeTag: Nullable = null; private subtreeRevision: Revision = INITIAL; - private isTrivial: boolean | null = null; + /** SPIKE push-invalidation */ + private subscribedLeaves: Nullable = null; + private enqueue = () => { + queuedItems.add(this); + }; constructor( state: Closure, @@ -225,20 +272,20 @@ export class ListItemOpcode extends TryOpcode { public value: Reference ) { super(state, context, bounds, []); + + registerDestructor(this, () => { + if (this.subscribedLeaves !== null) { + unsubscribeFromTags(this.subscribedLeaves, this.enqueue); + this.subscribedLeaves = null; + } + queuedItems.delete(this); + }); } override evaluate(vm: UpdatingVM) { - // Trivial items (a text node or two) can't win: validating their - // combined tag costs as much as just updating them, so collection - // would be pure overhead. Skipping only pays off for items with a - // real subtree -- more than a couple of opcodes, or any nested - // block (a nested block child means an arbitrarily large subtree - // hides behind a small top-level count). - if (this.isTrivial ?? (this.isTrivial = computeIsTrivial(this.children))) { - vm.try(this.children, this); - return; - } - + // SPIKE push-invalidation: every item collects and subscribes (the + // triviality gate is incompatible with push -- unsubscribed items + // would silently go stale). let { subtreeTag } = this; if ( @@ -262,14 +309,26 @@ export class ListItemOpcode extends TryOpcode { this.subtreeTag = tag; this.subtreeRevision = valueForTag(tag); consumeTag(tag); + + // keep the push-invalidation subscription pointing at the leaves + // this subtree actually read + if (this.subscribedLeaves !== null) { + unsubscribeFromTags(this.subscribedLeaves, this.enqueue); + } + this.subscribedLeaves = subscribeToTag(tag, this.enqueue); }); } override handleException() { - // children are about to be rebuilt; the collected tag and triviality - // no longer describe them + // children are about to be rebuilt; the collected tag and + // subscriptions no longer describe them this.subtreeTag = null; - this.isTrivial = null; + + if (this.subscribedLeaves !== null) { + unsubscribeFromTags(this.subscribedLeaves, this.enqueue); + this.subscribedLeaves = null; + } + super.handleException(); } @@ -282,16 +341,6 @@ export class ListItemOpcode extends TryOpcode { } } -function computeIsTrivial(children: UpdatingOpcode[]): boolean { - if (children.length > 2) return false; - - for (const child of children) { - if (child instanceof BlockOpcode) return false; - } - - return true; -} - export class ListBlockOpcode extends BlockOpcode { public type = 'list-block'; declare public children: ListItemOpcode[]; @@ -300,6 +349,12 @@ export class ListBlockOpcode extends BlockOpcode { private marker: SimpleComment | null = null; private lastIterator: OpaqueIterator; + /** SPIKE push-invalidation */ + private subscribedLeaves: Nullable = null; + private enqueueBlock = () => { + queuedBlocks.add(this); + }; + declare protected readonly bounds: AppendingBlockList; constructor( @@ -311,6 +366,28 @@ export class ListBlockOpcode extends BlockOpcode { ) { super(state, context, bounds, children); this.lastIterator = valueForRef(iterableRef); + this.resubscribeTo(CONSTANT_TAG); + + registerDestructor(this, () => { + if (this.subscribedLeaves !== null) { + unsubscribeFromTags(this.subscribedLeaves, this.enqueueBlock); + this.subscribedLeaves = null; + } + queuedBlocks.delete(this); + }); + } + + private resubscribeTo(syncTag: Tag) { + if (this.subscribedLeaves !== null) { + unsubscribeFromTags(this.subscribedLeaves, this.enqueueBlock); + } + + let leaves = subscribeToTag(syncTag, this.enqueueBlock); + let refTag = tagOfRef(this.iterableRef); + + if (refTag !== null) subscribeToTag(refTag, this.enqueueBlock, leaves); + + this.subscribedLeaves = leaves; } initializeChild(opcode: ListItemOpcode) { @@ -318,7 +395,71 @@ export class ListBlockOpcode extends BlockOpcode { this.opcodeMap.set(opcode.key, opcode); } + /** + * SPIKE push-invalidation: membership sync only -- children are NOT + * walked; changed items enqueue themselves via their own + * subscriptions and are processed by the drain. Falls back to a full + * walk (via the unsubscribed-dirt flag) when the list transitions + * between empty and non-empty, because the surrounding Enter/Assert + * opcodes this path skips are what rebuild that region. + */ + pushEvaluate(env: Environment) { + let wasEmpty = this.children.length === 0; + + beginTrackFrame(); + + try { + let iterator = valueForRef(this.iterableRef); + + if (this.lastIterator !== iterator) { + if (wasEmpty !== iterator.isEmpty()) { + markUnsubscribedDirt(); + return; + } + + let buffered = this.tryFastSync(iterator); + + if (buffered !== null) { + let { bounds } = this; + let dom = env.getDOM(); + + let marker = (this.marker = dom.createComment('')); + dom.insertAfter( + bounds.parentElement(), + marker, + expect(bounds.lastNode(), "can't insert after an empty bounds") + ); + + this.sync(new PrefixedIterator(buffered, iterator)); + + this.parentElement().removeChild(marker); + this.marker = null; + } + + this.lastIterator = iterator; + } + } finally { + // iteration consumes collection cell tags lazily (e.g. a tracked + // array proxy read during next()), so the subscription must come + // from what the sync actually read, not just the iterable ref + this.resubscribeTo(endTrackFrame()); + } + } + override evaluate(vm: UpdatingVM) { + beginTrackFrame(); + + try { + this.evaluateSync(vm); + } finally { + this.resubscribeTo(endTrackFrame()); + } + + // Run now-updated updating opcodes + super.evaluate(vm); + } + + private evaluateSync(vm: UpdatingVM) { let iterator = valueForRef(this.iterableRef); if (this.lastIterator !== iterator) { @@ -348,9 +489,6 @@ export class ListBlockOpcode extends BlockOpcode { this.lastIterator = iterator; } - - // Run now-updated updating opcodes - super.evaluate(vm); } /** diff --git a/packages/@glimmer/validator/lib/validators.ts b/packages/@glimmer/validator/lib/validators.ts index 73e1da2d77e..5f4966a4e6c 100644 --- a/packages/@glimmer/validator/lib/validators.ts +++ b/packages/@glimmer/validator/lib/validators.ts @@ -76,6 +76,10 @@ export function validateTag(tag: Tag, snapshot: Revision): boolean { const TYPE: TagTypeSymbol = Symbol('TAG_TYPE') as TagTypeSymbol; +// SPIKE push-invalidation (declared early: the module warm-up below +// dirties tags during evaluation) +let sawUnsubscribedDirt = false; + // this is basically a const export let ALLOW_CYCLES: WeakMap | undefined; @@ -139,6 +143,9 @@ class MonomorphicTagImpl { private lastChecked = INITIAL; private lastValue = INITIAL; + /** SPIKE push-invalidation: callbacks to run when this tag dirties. */ + subscribers: Set<() => void> | null = null; + private isUpdating = false; public subtag: Tag | Tag[] | null = null; private subtagBufferCache: Revision | null = null; @@ -252,6 +259,18 @@ class MonomorphicTagImpl { (tag as MonomorphicTagImpl).revision = ++$REVISION; + // SPIKE push-invalidation: notify subscribers right here; dirt on + // an unsubscribed tag means the next flush cannot use the push + // path and must fall back to a full revalidation walk. + let subscribers = (tag as MonomorphicTagImpl).subscribers; + + if (subscribers !== null && subscribers.size > 0) { + for (const callback of subscribers) callback(); + } else { + sawUnsubscribedDirt = true; + (globalThis as any).__dirtHook?.(); + } + scheduleRevalidate(); } } @@ -326,3 +345,61 @@ UPDATE_TAG(tag1, tag3); valueForTag(tag1); DIRTY_TAG(tag3); valueForTag(tag1); + +////////// +// SPIKE push-invalidation + +export function markUnsubscribedDirt(): void { + sawUnsubscribedDirt = true; +} + +export function consumeUnsubscribedDirt(): boolean { + let saw = sawUnsubscribedDirt; + sawUnsubscribedDirt = false; + return saw; +} + +/** + * Attach `callback` to every dirtyable leaf reachable from `tag`. + * Combinators are walked; constants are skipped. Returns the leaves so + * the caller can unsubscribe the same set later (a combined tag is an + * immutable snapshot, so the set is stable). + */ +export function subscribeToTag(tag: Tag, callback: () => void, leaves: Tag[] = []): Tag[] { + const impl = tag as MonomorphicTagImpl; + + if (impl === (CONSTANT_TAG as unknown as MonomorphicTagImpl)) return leaves; + + const type = impl[TYPE]; + + if (type === COMBINATOR_TAG_ID) { + const subtag = impl.subtag; + + if (Array.isArray(subtag)) { + for (const sub of subtag as Tag[]) subscribeToTag(sub, callback, leaves); + } else if (subtag !== null) { + subscribeToTag(subtag, callback, leaves); + } + + return leaves; + } + + if (type === DIRYTABLE_TAG_ID || type === UPDATABLE_TAG_ID) { + (impl.subscribers ??= new Set()).add(callback); + leaves.push(tag); + + // updatable tags can be re-pointed at another tag (UPDATE_TAG); + // walk the current target too so its leaves notify as well + if (type === UPDATABLE_TAG_ID && impl.subtag !== null && !Array.isArray(impl.subtag)) { + subscribeToTag(impl.subtag, callback, leaves); + } + } + + return leaves; +} + +export function unsubscribeFromTags(leaves: Tag[], callback: () => void): void { + for (const leaf of leaves) { + (leaf as MonomorphicTagImpl).subscribers?.delete(callback); + } +} From 3b921baab202d7b3018e40b6e77f5bba41afa904 Mon Sep 17 00:00:00 2001 From: NullVoxPopuli-ai-agent <268630448+NullVoxPopuli-ai-agent@users.noreply.github.com> Date: Wed, 22 Jul 2026 19:07:25 -0400 Subject: [PATCH 6/6] SPIKE: walk-free push-invalidation + rAF-coalesced drains Answers "why ever full-walk?" and "we need rAF deferral": - subscription coverage completed with ROOTS: each render root renders inside a tracking frame and subscribes to what it read; items and blocks no longer propagate their deps upward (or the root would fire on every item change), so a root re-render happens only when the root's own non-delegated deps change -- the minimal correct response, not a fallback. Unsubscribed dirt is now deliberately ignored. - item bootstrap without walks: blocks enqueue any child item that has never collected (fresh inserts, initial flush); the item queue walks exactly those once. - drains coalesce to one requestAnimationFrame via the earlier framePending machinery. dbmon same-batch at 8x: 66% of svelte average (21.8/36.8 vs 42.3/46.3, high variance; best run ~79% -- parity with the far simpler coalesced-walk+skip at 80%). Costly discovery: rAF deferral breaks render-latency-coupled patterns -- incrementing-render-effect (set -> effect -> advance) goes 0.4s -> 33.6s because every iteration waits a frame. Applies to any rAF-deferred design, including the earlier coalescing lever. Co-Authored-By: Claude Fable 5 --- .../-internals/glimmer/lib/base-renderer.ts | 106 ++++++++++++++---- packages/@glimmer/runtime/lib/vm/update.ts | 20 +++- 2 files changed, 98 insertions(+), 28 deletions(-) diff --git a/packages/@ember/-internals/glimmer/lib/base-renderer.ts b/packages/@ember/-internals/glimmer/lib/base-renderer.ts index c20c24223a1..824ebee8f20 100644 --- a/packages/@ember/-internals/glimmer/lib/base-renderer.ts +++ b/packages/@ember/-internals/glimmer/lib/base-renderer.ts @@ -24,9 +24,11 @@ import { artifacts } from '@glimmer/program/lib/helpers'; import { RuntimeOpImpl } from '@glimmer/program/lib/opcode'; import { clientBuilder } from '@glimmer/runtime/lib/vm/element-builder'; import { inTransaction, runtimeOptions } from '@glimmer/runtime/lib/environment'; -import { drainInvalidationQueue, hasQueuedInvalidations } from '@glimmer/runtime/lib/vm/update'; +import { drainInvalidationQueue } from '@glimmer/runtime/lib/vm/update'; +import { beginTrackFrame, endTrackFrame } from '@glimmer/validator/lib/tracking'; +import type { Tag } from '@glimmer/interfaces'; import { renderComponent as glimmerRenderComponent } from '@glimmer/runtime/lib/render'; -import { consumeUnsubscribedDirt, CURRENT_TAG, validateTag, valueForTag } from '@glimmer/validator/lib/validators'; +import { consumeUnsubscribedDirt, CURRENT_TAG, subscribeToTag, unsubscribeFromTags, validateTag, valueForTag } from '@glimmer/validator/lib/validators'; import type { SimpleDocument, SimpleElement } from '@simple-dom/interface'; import { hasDOM } from '../../browser-environment'; import { EmberEnvironmentDelegate } from './environment'; @@ -135,6 +137,13 @@ export class ComponentRootState implements RendererRoot { } } +/** SPIKE push-invalidation: roots whose own deps changed. */ +const queuedRoots = new Set(); +const rootSubscriptions = new WeakMap< + RendererRoot, + { leaves: Tag[]; enqueue: () => void } +>(); + const renderers: BaseRenderer[] = []; export function _resetRenderers() { @@ -367,7 +376,7 @@ export class RendererState { continue; } - root.render(); + this.#renderRootSubscribed(root); } this.#lastRevision = valueForTag(CURRENT_TAG); @@ -387,8 +396,25 @@ export class RendererState { } } + #frameScheduled = false; + + /** SPIKE: coalesce all invalidation delivery to one drain per frame. */ scheduleRevalidate(renderer: BaseRenderer): void { - _backburner.scheduleOnce('render', this, this.revalidate, renderer); + if (typeof requestAnimationFrame === 'function') { + if (this.#frameScheduled) { + return; + } + + this.#frameScheduled = true; + setFramePending(true); + requestAnimationFrame(() => { + this.#frameScheduled = false; + setFramePending(false); + _backburner.join(() => this.revalidate(renderer)); + }); + } else { + _backburner.scheduleOnce('render', this, this.revalidate, renderer); + } } isValid(): boolean { @@ -397,36 +423,68 @@ export class RendererState { ); } + /** + * SPIKE push-invalidation v5, walk-free: subscription coverage is + * complete (roots + blocks + items), so unsubscribed dirt affects + * nothing rendered and is deliberately ignored. The only "walk" is + * re-rendering a root whose own (non-delegated) deps changed -- + * which is the minimal correct response, not a fallback. + */ revalidate(renderer: BaseRenderer): void { if (this.isValid()) { return; } - // SPIKE push-invalidation: when every piece of dirt since the last - // flush reached a subscribed opcode, process exactly those opcodes - // instead of walking the tree. - const stats = ((globalThis as any).__pushStats ??= { push: 0, walk: 0, dirtBlocked: 0 }); - const unsub = consumeUnsubscribedDirt(); - if (unsub) stats.dirtBlocked++; - if (!unsub && hasQueuedInvalidations()) { - stats.push++; - inTransaction(this.context.env, () => drainInvalidationQueue(this.context.env)); - - this.#lastRevision = valueForTag(CURRENT_TAG); - // dirt produced while draining was handled by the drain - consumeUnsubscribedDirt(); - - if (this.isValid()) { - return; + const stats = ((globalThis as any).__pushStats ??= { drains: 0, rootRenders: 0 }); + + stats.drains++; + consumeUnsubscribedDirt(); + + inTransaction(this.context.env, () => { + if (queuedRoots.size > 0) { + const roots = [...queuedRoots]; + + queuedRoots.clear(); + + for (const root of roots) { + if (root.destroyed) continue; + + stats.rootRenders++; + this.#renderRootSubscribed(root); + } } - } - stats.walk++; - this.#renderRootsTransaction(renderer); - // dirt produced during the walk was handled by the walk + drainInvalidationQueue(this.context.env); + }); + + this.#lastRevision = valueForTag(CURRENT_TAG); consumeUnsubscribedDirt(); } + /** + * Render one root inside a tracking frame and keep its subscription + * pointed at what it actually read. Items and blocks do not + * propagate their deps upward, so this collects only the root's own + * non-delegated dependencies. + */ + #renderRootSubscribed(root: RendererRoot): void { + beginTrackFrame(); + + try { + root.render(); + } finally { + const tag = endTrackFrame(); + const previous = rootSubscriptions.get(root); + const enqueue = previous?.enqueue ?? (() => queuedRoots.add(root)); + + if (previous !== undefined) { + unsubscribeFromTags(previous.leaves, previous.enqueue); + } + + rootSubscriptions.set(root, { leaves: subscribeToTag(tag, enqueue), enqueue }); + } + } + clearAllRoots(renderer: BaseRenderer): void { let roots = this.#roots; for (let root of roots) { diff --git a/packages/@glimmer/runtime/lib/vm/update.ts b/packages/@glimmer/runtime/lib/vm/update.ts index c12d26e3d5f..ce5a20b7f8d 100644 --- a/packages/@glimmer/runtime/lib/vm/update.ts +++ b/packages/@glimmer/runtime/lib/vm/update.ts @@ -293,9 +293,9 @@ export class ListItemOpcode extends TryOpcode { !vm.alwaysRevalidate && validateTag(subtreeTag, this.subtreeRevision) ) { - // propagate this item's dependencies to any enclosing tracking - // frame, exactly as executing the children would have - consumeTag(subtreeTag); + // push-invalidation owns delivery: do NOT propagate item deps + // upward, or enclosing subscriptions (the root's) would fire on + // every item change and re-walk everything return; } @@ -308,7 +308,6 @@ export class ListItemOpcode extends TryOpcode { this.subtreeTag = tag; this.subtreeRevision = valueForTag(tag); - consumeTag(tag); // keep the push-invalidation subscription pointing at the leaves // this subtree actually read @@ -332,6 +331,11 @@ export class ListItemOpcode extends TryOpcode { super.handleException(); } + /** push bootstrap: items that never collected must be walked once */ + get needsCollection(): boolean { + return this.subtreeTag === null; + } + shouldRemove(): boolean { return !this.retained; } @@ -443,6 +447,13 @@ export class ListBlockOpcode extends BlockOpcode { // array proxy read during next()), so the subscription must come // from what the sync actually read, not just the iterable ref this.resubscribeTo(endTrackFrame()); + this.enqueueUncollectedItems(); + } + } + + private enqueueUncollectedItems() { + for (const item of this.children) { + if (item.needsCollection) queuedItems.add(item); } } @@ -453,6 +464,7 @@ export class ListBlockOpcode extends BlockOpcode { this.evaluateSync(vm); } finally { this.resubscribeTo(endTrackFrame()); + this.enqueueUncollectedItems(); } // Run now-updated updating opcodes