diff --git a/packages/@ember/-internals/glimmer/lib/create-context.ts b/packages/@ember/-internals/glimmer/lib/create-context.ts index 545fe615dcb..1ef1487e823 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 { + lookupRenderContext, + lookupRenderContextFor, + provideRenderContext, +} from '@glimmer/runtime/lib/render-scope'; import { valueForRef } from '@glimmer/reference/lib/reference'; import InternalComponent, { type OpaqueInternalComponentConstructor, @@ -11,12 +15,14 @@ import InternalComponent, { /** * 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(this)` to read it from a component + * instance's position in the render tree (e.g. in an event handler). */ export interface Context { Provide: OpaqueInternalComponentConstructor; get value(): T; + consume(consumer?: object): T; } /** @@ -69,11 +75,44 @@ 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 component instance, it + * resolves the context from that component's position in the render tree + * instead -- the way to read a context from event handlers and other code + * that runs outside of rendering: + * + * ```gjs + * import Component from '@glimmer/component'; + * import { createContext } from '@ember/helper'; + * import { on } from '@ember/modifier'; + * + * const theme = createContext(); + * + * class ThemedButton extends Component { + * onClick = () => { + * // Outside of rendering, `theme.value` would throw -- but passing the + * // component instance resolves the nearest above it. + * console.log(theme.consume(this).color); + * }; + * + * + * } + * ``` + * + * Any rendered component instance works with every context -- the instance + * identifies a position in the render tree, 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 render time. + * * @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 component instance's + * position). * @public */ export function createContext(): Context { @@ -104,19 +143,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, pass the component instance: `context.consume(this)`.' + ); + } + 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(consumer?: object): T { + if (consumer === undefined) { + return resolveAmbient(); + } + + let read = lookupRenderContextFor(consumer, 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(component)` expects a component instance that has been rendered -- the instance is how the context finds its position in the render tree. Pass the component itself (usually `this`), not another object, and only after the component has rendered.' ); } 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 component passed to `consume`. Wrap the component in `...`, or provide a default at the application root.' ); } return read() as T; 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..17b3ae4f592 100644 --- a/packages/@ember/-internals/glimmer/tests/integration/create-context-test.ts +++ b/packages/@ember/-internals/glimmer/tests/integration/create-context-test.ts @@ -13,6 +13,7 @@ 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 { tracked } from '@glimmer/tracking'; @@ -892,3 +893,274 @@ moduleFor( } } ); + +/** + * Coverage for `consume()`: + * + * - `consume()` with no argument behaves exactly like reading `value` + * (same result, same error cases). + * - `consume(componentInstance)` resolves the context from that component's + * position in the render tree, *outside* of rendering (the event-handler + * use case). The instance -> render-node association is made by the VM + * when the component renders -- an implementation detail; user code just + * passes `this`. + */ +moduleFor( + 'RFC #1200 -- createContext: consume()', + 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 consume(this) 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 { + onClick = () => { + // Prove we really are outside of rendering: the ambient read throws... + try { + void theme.value; + } catch (e) { + ambientError = e as Error; + } + // ...but the component instance's position still resolves. + observed = theme.consume(this); + }; + } + setComponentTemplate( + precompileTemplate('', { + strictMode: true, + scope: () => ({ on }), + }), + Button + ); + + let Root = setComponentTemplate( + precompileTemplate('