From 01a5df4c2fc56c28023bbf1f80983fe6ad9cc0fb Mon Sep 17 00:00:00 2001 From: NullVoxPopuli <199018+NullVoxPopuli@users.noreply.github.com> Date: Mon, 6 Jul 2026 14:27:06 -0400 Subject: [PATCH] Replace render-scope node tree with frame-epoch shallow binding Same observable semantics, cheaper bookkeeping: - create/exit are integer ops for every component (no node allocation, no WeakMap traffic, no stack push of an object) - update-pass opcodes are emitted only for components that actually provided a context; non-providers contribute zero updating opcodes - lookup is O(1) (peek + two integer compares) instead of walking the parent chain - dead provider entries are skimmed lazily off each context's own stack, amortized against the provides that pushed them Co-Authored-By: Claude Fable 5 --- .../-internals/glimmer/lib/create-context.ts | 15 +- .../interfaces/lib/runtime/environment.d.ts | 4 +- .../runtime/lib/compiled/opcodes/component.ts | 42 ++-- packages/@glimmer/runtime/lib/render-scope.ts | 184 +++++++++++++----- 4 files changed, 175 insertions(+), 70 deletions(-) diff --git a/packages/@ember/-internals/glimmer/lib/create-context.ts b/packages/@ember/-internals/glimmer/lib/create-context.ts index 545fe615dcb..e6961606ed4 100644 --- a/packages/@ember/-internals/glimmer/lib/create-context.ts +++ b/packages/@ember/-internals/glimmer/lib/create-context.ts @@ -2,7 +2,11 @@ * @module @ember/helper */ import { precompileTemplate } from '@ember/template-compilation'; -import { lookupRenderContext, provideRenderContext } from '@glimmer/runtime/lib/render-scope'; +import { + createRenderContextKey, + lookupRenderContext, + provideRenderContext, +} from '@glimmer/runtime/lib/render-scope'; import { valueForRef } from '@glimmer/reference/lib/reference'; import InternalComponent, { type OpaqueInternalComponentConstructor, @@ -78,10 +82,11 @@ export interface Context { */ export function createContext(): Context { // This context's identity token: unique to this `createContext()` call and - // stable, so the matching `` and `value` reads find each other on - // a shared render-scope node without colliding with other contexts. Held in - // the closure -- not exported. - const key = {}; + // stable, so the matching `` and `value` reads find each other + // without colliding with other contexts. It also owns this context's + // provider-entry stack (see render-scope.ts). Held in the closure -- not + // exported. + const key = createRenderContextKey(); class Provide extends InternalComponent { static override toString(): string { diff --git a/packages/@glimmer/interfaces/lib/runtime/environment.d.ts b/packages/@glimmer/interfaces/lib/runtime/environment.d.ts index 6d692a8c04a..60c40976633 100644 --- a/packages/@glimmer/interfaces/lib/runtime/environment.d.ts +++ b/packages/@glimmer/interfaces/lib/runtime/environment.d.ts @@ -58,8 +58,10 @@ export interface Environment { export interface RenderScopeTracker { create(bucket: object): void; - enter(bucket: object): void; exit(): void; + isProvider(bucket: object): boolean; + enterUpdate(bucket: object): void; + exitUpdate(): void; } export interface RuntimeOptions { diff --git a/packages/@glimmer/runtime/lib/compiled/opcodes/component.ts b/packages/@glimmer/runtime/lib/compiled/opcodes/component.ts index 961ec280cd8..aa8fd094f14 100644 --- a/packages/@glimmer/runtime/lib/compiled/opcodes/component.ts +++ b/packages/@glimmer/runtime/lib/compiled/opcodes/component.ts @@ -387,13 +387,11 @@ APPEND_OPCODES.add(VM_CREATE_COMPONENT_OP, (vm, { op1: flags }) => { let instance = check(vm.fetchValue($s0), CheckComponentInstance); let { definition, manager, capabilities } = instance; - // RFC #1154 -- push this component's render-tree scope before the user-land + // RFC #1200 -- open this component's render-scope frame before the user-land // constructor runs, so that provide/consume (createContext) inside the - // constructor see the new scope (and its parent chain). Always-on. Mirroring - // debugRenderTree would be too late: the user constructor runs in - // manager.create() below. + // constructor see the new frame. Always-on, but O(1) and allocation-free: + // a depth increment plus a frame-id stamp. vm.env.renderScope.create(instance); - vm.updateWith(new RenderScopeUpdateOpcode(instance)); if (!managerHasCapability(manager, capabilities, InternalComponentCapabilities.createInstance)) { // TODO: Closure and Main components are always invoked dynamically, so this @@ -433,6 +431,14 @@ APPEND_OPCODES.add(VM_CREATE_COMPONENT_OP, (vm, { op1: flags }) => { // only transition at exactly one place. instance.state = state; + // The constructor has run (in manager.create above), so provider status is + // known and final. Only providers need update-pass bracketing -- everyone + // else contributes nothing to context scope and gets no updating opcodes. + // Emitted before UpdateComponentOpcode so enterUpdate precedes manager.update. + if (vm.env.renderScope.isProvider(instance)) { + vm.updateWith(new RenderScopeEnterOpcode(instance)); + } + if (managerHasCapability(manager, capabilities, InternalComponentCapabilities.updateHook)) { vm.updateWith(new UpdateComponentOpcode(state, manager, dynamicScope)); } @@ -897,11 +903,14 @@ APPEND_OPCODES.add(VM_DID_RENDER_LAYOUT_OP, (vm, { op1: register }) => { let { manager, state, capabilities } = instance; let bounds = vm.tree().popBlock(); - // RFC #1154 -- pop the render scope stack to match the create() done in - // VM_CREATE_COMPONENT_OP. This must happen unconditionally and outside - // the debugRenderTree branch below. + // RFC #1200 -- close the frame opened in VM_CREATE_COMPONENT_OP. The exit + // itself must happen unconditionally (and outside the debugRenderTree + // branch below), but the update-pass exit opcode is only emitted for + // providers, matching the enter opcode emitted at create. vm.env.renderScope.exit(); - vm.updateWith(new RenderScopeExitOpcode()); + if (vm.env.renderScope.isProvider(instance)) { + vm.updateWith(new RenderScopeExitOpcode()); + } if (vm.env.debugRenderTree !== undefined) { if (hasCustomDebugRenderTreeLifecycle(manager)) { @@ -985,20 +994,21 @@ class DebugRenderTreeDidRenderOpcode implements UpdatingOpcode { } } -// RFC #1154 -- render-tree scope lifecycle during updating frames. We have to -// push the render node back onto the scope stack at the start of its update -// and pop it back off at the end, so that any descendants which read a -// context `value` during their own update see the correct parent chain. -class RenderScopeUpdateOpcode implements UpdatingOpcode { +// RFC #1200 -- render-scope lifecycle during update passes. A provider's +// frame must be re-opened at the start of its update and closed at the end, +// so descendants that read a context `value` during their own update see it +// as live. Emitted ONLY for components that provided something at +// construction; everyone else pays nothing during updates. +class RenderScopeEnterOpcode implements UpdatingOpcode { constructor(private bucket: object) {} evaluate(vm: UpdatingVM) { - vm.env.renderScope.enter(this.bucket); + vm.env.renderScope.enterUpdate(this.bucket); } } class RenderScopeExitOpcode implements UpdatingOpcode { evaluate(vm: UpdatingVM) { - vm.env.renderScope.exit(); + vm.env.renderScope.exitUpdate(); } } diff --git a/packages/@glimmer/runtime/lib/render-scope.ts b/packages/@glimmer/runtime/lib/render-scope.ts index bf6eab315f3..be36088daa7 100644 --- a/packages/@glimmer/runtime/lib/render-scope.ts +++ b/packages/@glimmer/runtime/lib/render-scope.ts @@ -1,79 +1,167 @@ -import type { Nullable } from '@glimmer/interfaces'; -import { StackImpl as Stack } from '@glimmer/util/lib/collections'; - // createContext (RFC #1200) is the only consumer. A `read` returns the // currently-provided value for a context key, evaluated lazily so that // auto-tracking inside it makes consumers reactive to the provided `@value`. type ContextRead = () => unknown; -interface RenderScopeNode { - parent: Nullable; - // key -> read, lazily allocated (most render nodes never provide a context). - contexts: Nullable>; +/** + * A context's identity token, created once per `createContext()` call. The + * key owns the stack of provider entries for that context, so lookups touch + * only the consulted context's own entries -- there is no global map of + * contexts, and a context that is never provided costs nothing. + */ +export interface RenderContextKey { + stack: ProviderEntry[]; +} + +interface ProviderEntry { + /** Depth of the frame this value was provided in. */ + depth: number; + /** Id of the frame this value was provided in (see `nextFrameId`). */ + frame: number; + read: ContextRead; +} + +export function createRenderContextKey(): RenderContextKey { + return { stack: [] }; } +// Frame ids are globally unique and never reused, across all trackers +// (environments). A closed frame's (depth, id) pair can therefore never be +// mistaken for an open one -- not later in the same render, not in a later +// render, and not by a different environment sharing a module-level context. +let nextFrameId = 0; + /** - * Tracks the render-tree node hierarchy so a consumer can find the nearest - * provider above it. Mirrors `DebugRenderTree`'s stack management, but is - * always-on because it backs a real feature. + * Tracks where the render cursor is so a context consumer can find the + * nearest provider above it, using *shallow binding* rather than a retained + * node tree: + * + * - `frameEpoch[d]` holds the id of the frame currently open at depth `d`. + * At any instant the open frames are exactly the ancestors of the render + * cursor, one per depth, so "is this provider entry still an ancestor of + * me" is two integer comparisons (`isLive`). + * - Closing a frame is a bare `depth--`. Nothing is restored or scanned: + * entries stamped with a closed frame's id are dead forever, and get + * popped lazily by the next `provide`/`lookup` on that context's own + * stack (`skim`), amortized against the provides that pushed them. + * + * Per component this costs an integer increment on create and a decrement on + * exit -- no allocation, no retained node, and (for non-providers) no + * updating opcodes. Only components that actually provide a context pay for + * anything beyond that. */ export class RenderScopeTracker { - private stack = new Stack(); - // bucket (component instance) -> node, so updating frames can re-push it. - private nodes = new WeakMap(); + private depth = 0; + private frameEpoch: number[] = [0]; + // bucket (component instance) -> what it provided, recorded at construction + // so update passes can re-activate it without re-running the constructor. + // Only providers ever get an entry. + private provisions = new WeakMap(); + // The most recently created bucket. Only read by `provide`, which is only + // called from a component constructor, which always runs immediately after + // its own `create` -- so this is always the providing component. + private currentBucket: object | null = null; - // Drop any nodes left on the stack by a render that errored mid-flight. + // Reset the cursor in case a previous render errored mid-flight and never + // unwound. Frames left open by such a render become unreachable ids; the + // depth check in `isLive` kills their entries. begin(): void { - while (!this.stack.isEmpty()) { - this.stack.pop(); - } + this.depth = 0; + this.currentBucket = null; } - // Push a fresh node when a component is created (before its constructor runs). + // Open a fresh frame when a component is created (before its constructor + // runs). Called for every component during the append pass. create(bucket: object): void { - let node: RenderScopeNode = { parent: this.stack.current ?? null, contexts: null }; - this.nodes.set(bucket, node); - this.stack.push(node); + this.frameEpoch[++this.depth] = ++nextFrameId; + this.currentBucket = bucket; + } + + // Close the current frame. Called for every component during the append + // pass. Dead entries are cleaned up lazily elsewhere -- see class docs. + exit(): void { + this.depth--; } - // Re-push an existing node when its component re-renders. - enter(bucket: object): void { - let node = this.nodes.get(bucket); - if (node !== undefined) { - this.stack.push(node); + // Whether this component provided anything at construction. Stable by the + // time the component's layout has rendered, so the append opcodes use it + // to skip update-pass bracketing for non-providers entirely. + isProvider(bucket: object): boolean { + return this.provisions.has(bucket); + } + + // Re-open a provider's frame during an update pass, re-activating what its + // constructor provided. Only providers get the updating opcodes that call + // this, so update-pass depth counts provider frames (plus any frames opened + // by appends resumed within the pass) -- correctness needs LIFO open/close + // discipline, not true tree depth. + enterUpdate(bucket: object): void { + this.frameEpoch[++this.depth] = ++nextFrameId; + let provisions = this.provisions.get(bucket); + if (provisions !== undefined) { + for (let [key, read] of provisions) { + this.push(key, read); + } } } - exit(): void { - this.stack.pop(); + exitUpdate(): void { + this.depth--; } - // Provide `key`'s value at the current node (called from ``, which is - // always the rendering node, so `current` is guaranteed to be set). - provide(key: object, read: ContextRead): void { - let node = this.stack.current; - if (node) { - (node.contexts ??= new Map()).set(key, read); + // Provide `key`'s value at the current frame (called from ``'s + // constructor, which always runs inside its own frame). + provide(key: RenderContextKey, read: ContextRead): void { + this.push(key, read); + + let bucket = this.currentBucket; + if (bucket !== null) { + let provisions = this.provisions.get(bucket); + if (provisions === undefined) { + this.provisions.set(bucket, (provisions = [])); + } + provisions.push([key, read]); } } // The nearest provider of `key`: - // `undefined` -> not inside a render frame (e.g. a modifier installing - // during commit, after the render stack has unwound), + // `undefined` -> the cursor is not inside any render frame (e.g. a + // modifier installing during commit, after the render + // frames have all closed), // `null` -> inside a frame, but no provider for `key`, // a function -> the nearest provider's read fn. - lookup(key: object): ContextRead | null | undefined { - let current = this.stack.current; - if (current === null) { + lookup(key: RenderContextKey): ContextRead | null | undefined { + if (this.depth === 0) { return undefined; } - for (let node: Nullable = current; node !== null; node = node.parent) { - let read = node.contexts?.get(key); - if (read !== undefined) { - return read; - } + let { stack } = key; + this.skim(stack); + let top = stack[stack.length - 1]; + return top === undefined ? null : top.read; + } + + // Pop dead entries off the top of a context's stack. Because frames close + // in LIFO order and every push skims first, dead entries always form a + // contiguous top segment -- a live entry is never buried under a dead one, + // and each dead entry is popped exactly once (amortized O(1) against the + // provide that pushed it). In the steady state this loop runs 0-1 times. + private skim(stack: ProviderEntry[]): void { + let top: ProviderEntry | undefined; + while ((top = stack[stack.length - 1]) !== undefined && !this.isLive(top)) { + stack.pop(); } - return null; + } + + private isLive(entry: ProviderEntry): boolean { + return entry.depth <= this.depth && this.frameEpoch[entry.depth] === entry.frame; + } + + private push(key: RenderContextKey, read: ContextRead): void { + let { stack } = key; + this.skim(stack); + // `frameEpoch[depth]` is always populated: every path that increments + // `depth` stamps it first. + stack.push({ depth: this.depth, frame: this.frameEpoch[this.depth] as number, read }); } } @@ -86,8 +174,8 @@ export function setCurrentRenderScopeTracker(tracker: RenderScopeTracker | undef CURRENT = tracker; } -/** Provide `key`'s value at the current render node (createContext's ``). */ -export function provideRenderContext(key: object, read: ContextRead): void { +/** Provide `key`'s value at the current render frame (createContext's ``). */ +export function provideRenderContext(key: RenderContextKey, read: ContextRead): void { if (CURRENT === undefined) { throw new Error('A context can only be provided while rendering.'); } @@ -100,6 +188,6 @@ export function provideRenderContext(key: object, read: ContextRead): void { * `null` -> rendering, but no provider for `key`, * a function -> the nearest provider's read fn. */ -export function lookupRenderContext(key: object): ContextRead | null | undefined { +export function lookupRenderContext(key: RenderContextKey): ContextRead | null | undefined { return CURRENT === undefined ? undefined : CURRENT.lookup(key); }