Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 10 additions & 5 deletions packages/@ember/-internals/glimmer/lib/create-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -78,10 +82,11 @@ export interface Context<T> {
*/
export function createContext<T>(): Context<T> {
// This context's identity token: unique to this `createContext()` call and
// stable, so the matching `<Provide>` 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 `<Provide>` 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 {
Expand Down
4 changes: 3 additions & 1 deletion packages/@glimmer/interfaces/lib/runtime/environment.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
42 changes: 26 additions & 16 deletions packages/@glimmer/runtime/lib/compiled/opcodes/component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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));
}
Expand Down Expand Up @@ -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)) {
Expand Down Expand Up @@ -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();
}
}
184 changes: 136 additions & 48 deletions packages/@glimmer/runtime/lib/render-scope.ts
Original file line number Diff line number Diff line change
@@ -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<RenderScopeNode>;
// key -> read, lazily allocated (most render nodes never provide a context).
contexts: Nullable<Map<object, ContextRead>>;
/**
* 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<RenderScopeNode>();
// bucket (component instance) -> node, so updating frames can re-push it.
private nodes = new WeakMap<object, RenderScopeNode>();
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<object, [RenderContextKey, ContextRead][]>();
// 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 `<Provide>`, 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 `<Provide>`'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<RenderScopeNode> = 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 });
}
}

Expand All @@ -86,8 +174,8 @@ export function setCurrentRenderScopeTracker(tracker: RenderScopeTracker | undef
CURRENT = tracker;
}

/** Provide `key`'s value at the current render node (createContext's `<Provide>`). */
export function provideRenderContext(key: object, read: ContextRead): void {
/** Provide `key`'s value at the current render frame (createContext's `<Provide>`). */
export function provideRenderContext(key: RenderContextKey, read: ContextRead): void {
if (CURRENT === undefined) {
throw new Error('A context can only be provided while rendering.');
}
Expand All @@ -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);
}
Loading