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/@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]; 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 92981cc0531..364fabf9883 100644 --- a/packages/@glimmer/runtime/lib/vm/update.ts +++ b/packages/@glimmer/runtime/lib/vm/update.ts @@ -16,14 +16,15 @@ 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'; 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'; @@ -36,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; @@ -69,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(); + 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) { - this.frameStack.push(new UpdatingVMFrame(ops, handler)); + try(ops: UpdatingOpcode[], handler: Nullable, finalizer?: (didError: boolean) => void) { + 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(); + this.#handlers[this.#depth]?.handleException(); + this.#pop(true); } } +const EMPTY_OPS: UpdatingOpcode[] = []; + export interface VMState { readonly pc: number; readonly scope: Scope; @@ -178,6 +207,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 +227,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 +282,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[]; @@ -228,20 +322,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; } @@ -249,6 +353,72 @@ export class ListBlockOpcode extends BlockOpcode { super.evaluate(vm); } + /** + * 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 matched = 0; + + while (true) { + let item = + iterator.nextInto !== undefined ? iterator.nextInto(SCRATCH_ITEM) : iterator.next(); + + if (item === null) { + if (matched === children.length) return null; + + // the list shrank; replay the matched prefix through full sync + return this.reconstructPrefix(matched, null); + } + + let opcode = children[matched]; + + if (opcode === undefined || opcode.key !== item.key) { + return this.reconstructPrefix(matched, { + key: item.key, + value: item.value, + memo: item.memo, + }); + } + + updateRef(opcode.memo, item.memo); + updateRef(opcode.value, item.value); + matched++; + } + } + + /** + * 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[] = []; + + 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) { let { opcodeMap: itemMap, children } = this; @@ -423,25 +593,29 @@ export class ListBlockOpcode extends BlockOpcode { } } -class UpdatingVMFrame { - private current = 0; +/** 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; constructor( - private ops: UpdatingOpcode[], - private exceptionHandler: Nullable + private prefix: OpaqueIterationItem[], + private inner: OpaqueIterator ) {} - goto(index: number) { - this.current = index; - } - - nextStatement(): UpdatingOpcode | undefined { - return this.ops[this.current++]; + isEmpty(): boolean { + return this.index >= this.prefix.length && this.inner.isEmpty(); } - handleException() { - if (this.exceptionHandler) { - this.exceptionHandler.handleException(); + 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(); } } + 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; } }