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/4] 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/4] 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/4] 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/4] 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(); - } - } -}