From e5ccc7ebd850a907682f96e453700029cd886745 Mon Sep 17 00:00:00 2001 From: NullVoxPopuli <199018+NullVoxPopuli@users.noreply.github.com> Date: Mon, 6 Jul 2026 15:06:19 -0400 Subject: [PATCH 1/2] Add consume() and captureContext() to createContext `context.consume()` with no argument behaves exactly like reading `context.value`. Passed a handle from the new `captureContext()` export, it resolves the nearest `` above the captured render-tree position instead -- which works outside of rendering, where the ambient reads throw. This is the way to read a context from event handlers, timers, and other async callbacks that run after the render stack has unwound. A capture is an opaque frozen handle validated through a WeakMap in the render-scope layer (plus a type-only brand), so it can't be forged or introspected. One handle works with every context: it captures the render-tree position, not any one context's value, and resolution happens at consume time so the value read is the provider's current @value rather than a snapshot. Co-Authored-By: Claude Fable 5 --- packages/@ember/-internals/glimmer/index.ts | 7 +- .../-internals/glimmer/lib/create-context.ts | 134 ++++++++- .../tests/integration/create-context-test.ts | 283 +++++++++++++++++- packages/@ember/helper/index.ts | 61 +++- packages/@glimmer/runtime/lib/render-scope.ts | 65 +++- tests/docs/expected.cjs | 1 + type-tests/@ember/helper-tests.ts | 18 ++ 7 files changed, 552 insertions(+), 17 deletions(-) diff --git a/packages/@ember/-internals/glimmer/index.ts b/packages/@ember/-internals/glimmer/index.ts index e0ce66b3054..a8e9b4a781c 100644 --- a/packages/@ember/-internals/glimmer/index.ts +++ b/packages/@ember/-internals/glimmer/index.ts @@ -519,7 +519,12 @@ export { type View, } from './lib/renderer'; // RFC #1200 -- render-tree-scoped context (provide/consume) -export { createContext, type Context } from './lib/create-context'; +export { + createContext, + captureContext, + type Context, + type CapturedContext, +} from './lib/create-context'; export { getTemplate, setTemplate, diff --git a/packages/@ember/-internals/glimmer/lib/create-context.ts b/packages/@ember/-internals/glimmer/lib/create-context.ts index 545fe615dcb..8951209e4fc 100644 --- a/packages/@ember/-internals/glimmer/lib/create-context.ts +++ b/packages/@ember/-internals/glimmer/lib/create-context.ts @@ -2,21 +2,44 @@ * @module @ember/helper */ import { precompileTemplate } from '@ember/template-compilation'; -import { lookupRenderContext, provideRenderContext } from '@glimmer/runtime/lib/render-scope'; +import { + captureRenderScope, + lookupCapturedRenderContext, + lookupRenderContext, + provideRenderContext, +} from '@glimmer/runtime/lib/render-scope'; import { valueForRef } from '@glimmer/reference/lib/reference'; import InternalComponent, { type OpaqueInternalComponentConstructor, opaquify, } from './components/internal'; +declare const CAPTURED_CONTEXT: unique symbol; + +/** + * An opaque handle to a position in the render tree, returned by + * `captureContext()`. Pass it to a context's `consume(captured)` to resolve + * that context from the captured position later -- e.g. inside an event + * handler, after the render stack has unwound and ambient reads like `value` + * would throw. + */ +export interface CapturedContext { + // A type-only brand so arbitrary objects don't typecheck as a capture. The + // only way to obtain one is `captureContext()`; at runtime validity is + // checked against the render-scope layer's WeakMap, not this symbol. + readonly [CAPTURED_CONTEXT]: never; +} + /** * The shape returned by `createContext`. Use `Provide` in templates to bind a - * value into the render tree and read `value` to get the nearest enclosing - * provided value. + * value into the render tree, read `value` to get the nearest enclosing + * provided value, or call `consume(captured)` to read it from a previously + * captured render-tree position (e.g. in an event handler). */ export interface Context { Provide: OpaqueInternalComponentConstructor; get value(): T; + consume(captured?: CapturedContext): T; } /** @@ -69,11 +92,19 @@ export interface Context { * legitimate "fall back to undefined" state. If you want a default, provide * one at the application root. * + * The returned object also has a `consume()` method. With no argument it + * behaves exactly like reading `value`. Passed a handle from + * `captureContext()`, it resolves the context from that captured + * render-tree position instead -- the way to read a context from event + * handlers and other code that runs outside of rendering (see + * `captureContext`). + * * @method createContext * @static * @for @ember/helper * @returns {Object} An object with `Provide` (a component that takes a - * `@value`) and `value` (a getter that reads the nearest provided value). + * `@value`), `value` (a getter that reads the nearest provided value), and + * `consume` (reads ambiently like `value`, or from a captured position). * @public */ export function createContext(): Context { @@ -104,19 +135,45 @@ export function createContext(): Context { } } + // The ambient read backing both the `value` getter and a no-argument + // `consume()` call: resolve the nearest provider at the currently-rendering + // position. + function resolveAmbient(): T { + let read = lookupRenderContext(key); + if (read === undefined) { + throw new Error( + 'A context `value` was read outside of rendering. The render-tree scope is only available during rendering -- there is nothing to read. To read a context from an event handler or other async callback, capture a handle during rendering with `captureContext()` and pass it to `consume(captured)`.' + ); + } + if (read === null) { + throw new Error( + 'No matching `` was found in the render tree. Wrap consumers in `...`, or provide a default at the application root.' + ); + } + return read() as T; + } + return { Provide: opaquify(Provide, PROVIDE_TEMPLATE), get value(): T { - let read = lookupRenderContext(key); + return resolveAmbient(); + }, + + consume(captured?: CapturedContext): T { + if (captured === undefined) { + return resolveAmbient(); + } + + let read = lookupCapturedRenderContext(captured, key); if (read === undefined) { throw new Error( - 'A context `value` was read outside of rendering. The render-tree scope is only available during rendering -- there is nothing to read.' + '`consume(captured)` expects the handle returned from `captureContext()`, but received some other value.' ); } if (read === null) { throw new Error( - 'No matching `` was found in the render tree. Wrap consumers in `...`, or provide a default at the application root.' + 'No matching `` was found above the captured render-tree position. Wrap the component that called `captureContext()` in `...`, or provide a default at the application root.' ); } return read() as T; @@ -124,6 +181,69 @@ export function createContext(): Context { }; } +/** + * Captures the current render-tree position as an opaque handle that can be + * passed to a context's `consume(captured)` later. + * + * The ambient reads -- `context.value` or `context.consume()` -- only work + * while rendering, because they resolve against the currently-rendering + * node. Event handlers, timers, and other async callbacks run after the + * render stack has unwound, so they have no ambient position to read from. + * `captureContext()` bridges that gap: call it while rendering (a component + * constructor or a field initializer is the natural spot), keep the handle, + * and resolve any context from that position later. + * + * @example + * + * ```gjs + * import Component from '@glimmer/component'; + * import { createContext, captureContext } from '@ember/helper'; + * import { on } from '@ember/modifier'; + * + * const theme = createContext(); + * + * class ThemedButton extends Component { + * // Field initializers run in the constructor, during rendering, so this + * // captures the position of in the render tree. + * context = captureContext(); + * + * onClick = () => { + * // Outside of rendering, `theme.value` would throw -- but the captured + * // handle still resolves the nearest above this + * // component. + * console.log(theme.consume(this.context).color); + * }; + * + * + * } + * ``` + * + * A single captured handle works with every context -- it captures the + * render-tree position, not any one context's value. Resolution happens at + * `consume` time, so the value read is the provider's current `@value`, not + * a snapshot from when the handle was captured. + * + * `captureContext()` throws if called outside of rendering -- there is no + * position to capture. + * + * @method captureContext + * @static + * @for @ember/helper + * @returns {Object} An opaque handle for `consume(captured)`. + * @public + */ +export function captureContext(): CapturedContext { + let handle = captureRenderScope(); + if (handle === undefined) { + throw new Error( + '`captureContext()` was called outside of rendering -- there is no render-tree position to capture. Capture while rendering (e.g. in a component constructor or field initializer) and pass the handle to `consume(captured)` later.' + ); + } + return handle as CapturedContext; +} + // All Provide components share the same template: yield to the block. // Per-instance behavior is parameterized via the closure in createContext. const PROVIDE_TEMPLATE = precompileTemplate('{{yield}}'); diff --git a/packages/@ember/-internals/glimmer/tests/integration/create-context-test.ts b/packages/@ember/-internals/glimmer/tests/integration/create-context-test.ts index 39d59861e8a..d0558b1b03a 100644 --- a/packages/@ember/-internals/glimmer/tests/integration/create-context-test.ts +++ b/packages/@ember/-internals/glimmer/tests/integration/create-context-test.ts @@ -13,8 +13,9 @@ import GlimmerishComponent from '../utils/glimmerish-component'; import { run } from '@ember/runloop'; import { associateDestroyableChild, registerDestructor } from '@glimmer/destroyable'; +import { on } from '@ember/modifier'; import { renderComponent } from '../../lib/renderer'; -import { createContext } from '../../lib/create-context'; +import { captureContext, createContext, type CapturedContext } from '../../lib/create-context'; import { tracked } from '@glimmer/tracking'; import type Owner from '@ember/owner'; @@ -892,3 +893,283 @@ moduleFor( } } ); + +/** + * Coverage for `consume()` and `captureContext()`: + * + * - `consume()` with no argument behaves exactly like reading `value` + * (same result, same error cases). + * - `captureContext()` captures the current render-tree position; passing + * the handle to `consume(captured)` resolves the context from that + * position *outside* of rendering (the event-handler use case). + */ +moduleFor( + 'RFC #1200 -- createContext: consume() and captureContext()', + class extends CreateContextTestCase { + afterEach() { + runDestroy(this); + } + + '@test consume() with no argument reads like value'(assert: QUnit['assert']) { + const ctx = createContext(); + + let observed: string | undefined; + class Reader extends GlimmerishComponent { + constructor(owner: Owner, args: Record) { + super(owner, args); + observed = ctx.consume(); + } + } + setComponentTemplate(precompileTemplate(''), Reader); + + let Root = setComponentTemplate( + precompileTemplate('', { + strictMode: true, + scope: () => ({ ctx, Reader }), + }), + templateOnly() + ); + + this.renderComponent(Root); + assert.strictEqual(observed, 'ambient', 'consume() saw the provided value'); + } + + '@test consume() with no argument throws outside of rendering'(assert: QUnit['assert']) { + const ctx = createContext(); + + assert.throws( + () => ctx.consume(), + /outside of rendering/, + 'an ambient consume() outside a render is rejected' + ); + } + + '@test consume() with no argument throws when no exists'(assert: QUnit['assert']) { + const ctx = createContext(); + + let error: Error | undefined; + class Reader extends GlimmerishComponent { + constructor(owner: Owner, args: Record) { + super(owner, args); + try { + ctx.consume(); + } catch (e) { + error = e as Error; + } + } + } + setComponentTemplate(precompileTemplate(''), Reader); + + let Root = setComponentTemplate( + precompileTemplate('', { strictMode: true, scope: () => ({ Reader }) }), + templateOnly() + ); + + this.renderComponent(Root); + assert.ok( + /No matching ``/.test(error?.message ?? ''), + `error mentions missing provider, got: ${error?.message}` + ); + } + + '@test captureContext() throws outside of rendering'(assert: QUnit['assert']) { + assert.throws( + () => captureContext(), + /outside of rendering/, + 'a capture outside a render is rejected' + ); + } + + '@test consume(captured) resolves the context from an event handler'(assert: QUnit['assert']) { + const theme = createContext<{ color: string }>(); + const value = { color: 'rebeccapurple' }; + + let observed: { color: string } | undefined; + let ambientError: Error | undefined; + class Button extends GlimmerishComponent { + // Field initializers run in the constructor, during rendering. + context = captureContext(); + + onClick = () => { + // Prove we really are outside of rendering: the ambient read throws... + try { + void theme.value; + } catch (e) { + ambientError = e as Error; + } + // ...but the captured position still resolves. + observed = theme.consume(this.context); + }; + } + setComponentTemplate( + precompileTemplate('', { + strictMode: true, + scope: () => ({ on }), + }), + Button + ); + + let Root = setComponentTemplate( + precompileTemplate('