From 37b03396fada0b31602f86d222ed701c1645ac93 Mon Sep 17 00:00:00 2001 From: NullVoxPopuli <199018+NullVoxPopuli@users.noreply.github.com> Date: Fri, 22 May 2026 17:24:38 -0400 Subject: [PATCH 01/15] Prototype RFC #1154: getScope/addToScope render-tree primitives Adds a public, always-on render-tree scope tracker that powers the component-tree provide/consume pattern that the Ember community has been asking for in RFC #975 / #1154. Public API (exported from @ember/renderer): import { getScope, addToScope, type Scope } from '@ember/renderer'; // Inside any code that runs during rendering: let scope = getScope(); // current scope, or undefined addToScope({ key: 'theme', value: 'dark' }); // Walk up the render tree: for (let entry of scope.entries) { ... } Implementation notes: - `RenderScopeTracker` lives in @glimmer/runtime parallel to `DebugRenderTree`, but is always-on because this is part of the public surface area (not a debug-only tool). - Component lifecycle wires the tracker into: VM_CREATE_COMPONENT_OP -> push scope before manager.create() so user-land constructors can call addToScope against their own scope. VM_DID_RENDER_LAYOUT_OP -> pop scope on initial render and on every updating frame. Updating opcodes (RenderScopeUpdateOpcode / RenderScopeExitOpcode) re-push and pop on re-renders so descendant scope reads stay correct. - The Scope's `entries` iterator walks the current node's own additions newest-first, then up through each ancestor. This is the exact shape a userland `consume(key)` needs to find the nearest provider. - Begin/commit reset the stack and the module-level "active tracker" pointer, so getScope() correctly returns undefined outside of render. Userland provide/consume is included as an integration test (in packages/@ember/-internals/glimmer/tests/integration/render-tree-scope-test.ts): a `` nested inside multiple `` components consumes the nearest provider's value, exactly matching the "How we teach this" example in RFC #1154. Scope of this prototype: components only. Helpers, modifiers, and plain-curly functions are not yet wired, which matches the immediate provide/consume use case. Extending to other invokables is straightforward once the shape lands -- the tracker doesn't care what the bucket is. Refs: - https://github.com/emberjs/rfcs/pull/1154 - https://github.com/emberjs/rfcs/pull/975 - https://github.com/customerio/ember-provide-consume-context (prior art) Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/@ember/-internals/glimmer/index.ts | 6 + .../integration/render-tree-scope-test.ts | 222 ++++++++++++++++++ packages/@ember/renderer/index.ts | 40 ++++ packages/@ember/renderer/package.json | 3 +- .../interfaces/lib/runtime/environment.d.ts | 17 ++ .../runtime/lib/compiled/opcodes/component.ts | 35 +++ packages/@glimmer/runtime/lib/environment.ts | 6 + packages/@glimmer/runtime/lib/render-scope.ts | 179 ++++++++++++++ pnpm-lock.yaml | 3 + 9 files changed, 510 insertions(+), 1 deletion(-) create mode 100644 packages/@ember/-internals/glimmer/tests/integration/render-tree-scope-test.ts create mode 100644 packages/@glimmer/runtime/lib/render-scope.ts diff --git a/packages/@ember/-internals/glimmer/index.ts b/packages/@ember/-internals/glimmer/index.ts index 66afa93812e..e3a9c72951e 100644 --- a/packages/@ember/-internals/glimmer/index.ts +++ b/packages/@ember/-internals/glimmer/index.ts @@ -474,6 +474,12 @@ export { renderComponent, type View, } from './lib/renderer'; +// RFC #1154 -- public render-tree scope API +export { + getCurrentRenderScope as getScope, + addToCurrentRenderScope as addToScope, + type RenderScope as Scope, +} from '@glimmer/runtime/lib/render-scope'; export { getTemplate, setTemplate, diff --git a/packages/@ember/-internals/glimmer/tests/integration/render-tree-scope-test.ts b/packages/@ember/-internals/glimmer/tests/integration/render-tree-scope-test.ts new file mode 100644 index 00000000000..faf7b042b19 --- /dev/null +++ b/packages/@ember/-internals/glimmer/tests/integration/render-tree-scope-test.ts @@ -0,0 +1,222 @@ +import { + AbstractStrictTestCase, + assertHTML, + buildOwner, + moduleFor, + runDestroy, +} from 'internal-test-helpers'; + +import { precompileTemplate } from '@ember/template-compilation'; +import { setComponentTemplate } from '@glimmer/manager'; +import templateOnly from '@ember/component/template-only'; +import GlimmerishComponent from '../utils/glimmerish-component'; + +import { run } from '@ember/runloop'; +import { associateDestroyableChild, registerDestructor } from '@glimmer/destroyable'; +import { renderComponent } from '../../lib/renderer'; +import { + addToCurrentRenderScope as addToScope, + getCurrentRenderScope as getScope, +} from '@glimmer/runtime/lib/render-scope'; +import type Owner from '@ember/owner'; + +/** + * Coverage for the render-tree scope primitives proposed in RFC #1154 + * ( https://github.com/emberjs/rfcs/pull/1154 ). + * + * These tests pin down the two things consumers actually rely on: + * + * 1. `getScope()` is only defined while rendering, and returns a Scope whose + * `entries` walks from the current render node up through each ancestor. + * 2. A userland `provide` / `consume` built on `addToScope` + `getScope` + * finds the nearest provider, which is the building block for any + * component-tree context implementation. + */ + +class RenderTreeScopeTestCase extends AbstractStrictTestCase { + owner: Owner; + + constructor(assert: QUnit['assert']) { + super(assert); + this.owner = buildOwner({}); + associateDestroyableChild(this, this.owner); + } + + get element() { + return document.querySelector('#qunit-fixture')!; + } + + renderComponent(component: object) { + let { owner } = this; + run(() => { + const result = renderComponent(component, { + owner, + env: { document: document, isInteractive: true, hasDOM: true }, + into: this.element, + }); + registerDestructor(this, () => result.destroy()); + }); + } +} + +moduleFor( + 'RFC #1154 -- render-tree scope primitives', + class extends RenderTreeScopeTestCase { + afterEach() { + runDestroy(this); + } + + '@test getScope() returns undefined outside of rendering'(assert: QUnit['assert']) { + assert.strictEqual(getScope(), undefined, 'no active scope before render'); + + let Foo = setComponentTemplate(precompileTemplate('hi'), templateOnly()); + this.renderComponent(Foo); + + assertHTML('hi'); + assert.strictEqual(getScope(), undefined, 'no active scope after commit'); + } + + '@test addToScope() throws when called outside of rendering'(assert: QUnit['assert']) { + assert.throws( + () => addToScope('nope'), + /addToScope/, + 'addToScope rejects calls made outside a render' + ); + } + + "@test a function captured at render time sees the caller's scope entries via getScope()"( + assert: QUnit['assert'] + ) { + let collected: unknown[] = []; + + class Reader extends GlimmerishComponent { + constructor(owner: Owner, args: Record) { + super(owner, args); + let scope = getScope(); + assert.ok(scope, 'scope is defined during component construction'); + if (scope) { + for (let entry of scope.entries) { + collected.push(entry); + } + } + } + } + setComponentTemplate(precompileTemplate('done'), Reader); + + class Provider extends GlimmerishComponent { + constructor(owner: Owner, args: Record) { + super(owner, args); + addToScope({ kind: 'theme', value: 'dark' }); + addToScope({ kind: 'locale', value: 'en' }); + } + } + setComponentTemplate( + precompileTemplate('', { + strictMode: true, + scope: () => ({ Reader }), + }), + Provider + ); + + let Root = setComponentTemplate( + precompileTemplate('', { + strictMode: true, + scope: () => ({ Provider }), + }), + templateOnly() + ); + + this.renderComponent(Root); + assertHTML('done'); + + // Entries iterate most-recent-first within a node, then walk up. + // Reader's own scope is empty, so we should see the two entries from + // Provider in reverse insertion order. + assert.deepEqual( + collected, + [ + { kind: 'locale', value: 'en' }, + { kind: 'theme', value: 'dark' }, + ], + 'consumer iterates its parent provider entries newest-first' + ); + } + + '@test consumer finds the nearest provider (component-tree context pattern)'( + assert: QUnit['assert'] + ) { + let observed: string[] = []; + + class ThemeKey {} + const THEME = new ThemeKey(); + + function provideTheme(value: string) { + addToScope([THEME, value]); + } + + function consumeTheme(): string | undefined { + let scope = getScope(); + if (!scope) return undefined; + for (let entry of scope.entries) { + if (Array.isArray(entry) && entry[0] === THEME) { + return entry[1] as string; + } + } + return undefined; + } + + class Reader extends GlimmerishComponent { + constructor(owner: Owner, args: Record) { + super(owner, args); + observed.push(consumeTheme() ?? 'none'); + } + } + setComponentTemplate(precompileTemplate('r'), Reader); + + class Inner extends GlimmerishComponent { + constructor(owner: Owner, args: Record) { + super(owner, args); + provideTheme('dark'); + } + } + setComponentTemplate( + precompileTemplate('', { + strictMode: true, + scope: () => ({ Reader }), + }), + Inner + ); + + class Outer extends GlimmerishComponent { + constructor(owner: Owner, args: Record) { + super(owner, args); + provideTheme('light'); + } + } + setComponentTemplate( + precompileTemplate('', { + strictMode: true, + scope: () => ({ Inner, Reader }), + }), + Outer + ); + + let Root = setComponentTemplate( + precompileTemplate('', { + strictMode: true, + scope: () => ({ Outer, Reader }), + }), + templateOnly() + ); + + this.renderComponent(Root); + assertHTML('rrrr'); + + assert.deepEqual( + observed, + ['none', 'light', 'dark', 'light'], + 'each Reader sees the nearest enclosing provider, falling back to undefined at the root' + ); + } + } +); diff --git a/packages/@ember/renderer/index.ts b/packages/@ember/renderer/index.ts index a8c5366ba5a..c8adbe46734 100644 --- a/packages/@ember/renderer/index.ts +++ b/packages/@ember/renderer/index.ts @@ -80,3 +80,43 @@ export { renderSettled } from '@ember/-internals/glimmer/lib/renderer'; * @public */ export { renderComponent } from '@ember/-internals/glimmer/lib/renderer'; + +/** + * Returns the current render-tree scope, or `undefined` if called outside of + * rendering. + * + * See [RFC #1154](https://github.com/emberjs/rfcs/pull/1154) for the motivation + * and the userland patterns this primitive enables (notably component-tree + * `provide` / `consume`). + * + * The returned `Scope` exposes `entries`, an iterable that walks the current + * scope's own additions, then up through each ancestor render node. Anything + * pushed onto the scope via `addToScope` becomes visible here. + * + * `getScope()` is synchronous and is only valid during render. After an + * `await`, you must capture the scope (or the specific entries you need) + * before the microtask boundary. + * + * @method getScope + * @static + * @for @ember/renderer + * @returns {Scope | undefined} the current scope, or `undefined` when called outside of rendering. + * @public + */ +export { getCurrentRenderScope as getScope } from '@glimmer/runtime/lib/render-scope'; + +/** + * Adds an entry to the current render-tree scope so descendants can find it + * via `getScope()`. Throws when called outside of rendering. + * + * See [RFC #1154](https://github.com/emberjs/rfcs/pull/1154). + * + * @method addToScope + * @static + * @for @ember/renderer + * @param {unknown} entry the value to expose to descendants. + * @public + */ +export { addToCurrentRenderScope as addToScope } from '@glimmer/runtime/lib/render-scope'; + +export type { RenderScope as Scope } from '@glimmer/runtime/lib/render-scope'; diff --git a/packages/@ember/renderer/package.json b/packages/@ember/renderer/package.json index a4843dd6fae..35b090f46c5 100644 --- a/packages/@ember/renderer/package.json +++ b/packages/@ember/renderer/package.json @@ -7,6 +7,7 @@ "./*": "./*.ts" }, "dependencies": { - "@ember/-internals": "workspace:*" + "@ember/-internals": "workspace:*", + "@glimmer/runtime": "workspace:*" } } \ No newline at end of file diff --git a/packages/@glimmer/interfaces/lib/runtime/environment.d.ts b/packages/@glimmer/interfaces/lib/runtime/environment.d.ts index c47f1c98abb..85defc876d6 100644 --- a/packages/@glimmer/interfaces/lib/runtime/environment.d.ts +++ b/packages/@glimmer/interfaces/lib/runtime/environment.d.ts @@ -47,10 +47,27 @@ export interface Environment { isInteractive: boolean; debugRenderTree?: DebugRenderTree | undefined; + // Public render-tree scope tracker (RFC #1154). Unlike debugRenderTree this + // is always present, because it backs the user-facing + // `getScope` / `addToScope` API. + renderScope: RenderScopeTracker; // eslint-disable-next-line @typescript-eslint/no-explicit-any isArgumentCaptureError?: ((error: any) => boolean) | undefined; } +export interface RenderScope { + readonly entries: Iterable; +} + +export interface RenderScopeTracker { + create(bucket: object): void; + enter(bucket: object): void; + exit(): void; + willDestroy(bucket: object): void; + getCurrentScope(): RenderScope | undefined; + addToCurrentScope(entry: unknown): void; +} + export interface RuntimeOptions { readonly env: Environment; readonly program: Program; diff --git a/packages/@glimmer/runtime/lib/compiled/opcodes/component.ts b/packages/@glimmer/runtime/lib/compiled/opcodes/component.ts index ec5b2c206d1..1964c82b04c 100644 --- a/packages/@glimmer/runtime/lib/compiled/opcodes/component.ts +++ b/packages/@glimmer/runtime/lib/compiled/opcodes/component.ts @@ -387,6 +387,17 @@ 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 + // constructor runs, so that addToScope/getScope 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. + vm.env.renderScope.create(instance); + vm.associateDestroyable(instance); + registerDestructor(instance, () => { + vm.env.renderScope.willDestroy(instance); + }); + vm.updateWith(new RenderScopeUpdateOpcode(instance)); + if (!managerHasCapability(manager, capabilities, InternalComponentCapabilities.createInstance)) { // TODO: Closure and Main components are always invoked dynamically, so this // opcode may run even if this capability is not enabled. In the future we @@ -889,6 +900,12 @@ 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_GET_COMPONENT_SELF_OP. This must happen unconditionally and outside + // the debugRenderTree branch below. + vm.env.renderScope.exit(); + vm.updateWith(new RenderScopeExitOpcode()); + if (vm.env.debugRenderTree !== undefined) { if (hasCustomDebugRenderTreeLifecycle(manager)) { let nodes = manager.getDebugCustomRenderTree(instance.definition.state, state, EMPTY_ARGS); @@ -970,3 +987,21 @@ class DebugRenderTreeDidRenderOpcode implements UpdatingOpcode { vm.env.debugRenderTree?.didRender(this.bucket, this.bounds); } } + +// 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 call +// `getScope()` during their own update see the correct parent chain. +class RenderScopeUpdateOpcode implements UpdatingOpcode { + constructor(private bucket: object) {} + + evaluate(vm: UpdatingVM) { + vm.env.renderScope.enter(this.bucket); + } +} + +class RenderScopeExitOpcode implements UpdatingOpcode { + evaluate(vm: UpdatingVM) { + vm.env.renderScope.exit(); + } +} diff --git a/packages/@glimmer/runtime/lib/environment.ts b/packages/@glimmer/runtime/lib/environment.ts index bafbefe2f82..9c0b4aee3a3 100644 --- a/packages/@glimmer/runtime/lib/environment.ts +++ b/packages/@glimmer/runtime/lib/environment.ts @@ -21,6 +21,7 @@ import { UPDATE_TAG as updateTag } from '@glimmer/validator/lib/validators'; import DebugRenderTree from './debug-render-tree'; import { DOMChangesImpl, DOMTreeConstruction } from './dom/helper'; +import { RenderScopeTracker, setCurrentRenderScopeTracker } from './render-scope'; import { isArgumentError } from './vm/arguments'; export const TRANSACTION: TransactionSymbol = Symbol('TRANSACTION') as TransactionSymbol; @@ -108,6 +109,7 @@ export class EnvironmentImpl implements Environment { // eslint-disable-next-line @typescript-eslint/no-explicit-any isArgumentCaptureError: ((error: any) => boolean) | undefined; debugRenderTree: DebugRenderTree | undefined; + renderScope: RenderScopeTracker = new RenderScopeTracker(); constructor( options: EnvironmentOptions, @@ -145,6 +147,8 @@ export class EnvironmentImpl implements Environment { ); this.debugRenderTree?.begin(); + this.renderScope.begin(); + setCurrentRenderScopeTracker(this.renderScope); this[TRANSACTION] = new TransactionImpl(); } @@ -179,6 +183,8 @@ export class EnvironmentImpl implements Environment { transaction.commit(); this.debugRenderTree?.commit(); + this.renderScope.commit(); + setCurrentRenderScopeTracker(undefined); this.delegate.onTransactionCommit(); } diff --git a/packages/@glimmer/runtime/lib/render-scope.ts b/packages/@glimmer/runtime/lib/render-scope.ts new file mode 100644 index 00000000000..e4ea1e85408 --- /dev/null +++ b/packages/@glimmer/runtime/lib/render-scope.ts @@ -0,0 +1,179 @@ +import type { Nullable } from '@glimmer/interfaces'; +import { StackImpl as Stack } from '@glimmer/util/lib/collections'; + +/** + * The publicly observable shape of a render-tree scope. See RFC #1154. + * + * `entries` walks the scope's own entries first, then up the parent chain -- + * which matches the user-visible "look up the render tree" semantics that + * provide/consume needs. + * + * Reactivity is intentionally _not_ part of this object: access happens during + * rendering, and entries only change when the surrounding render node itself + * re-renders (or is torn down), so consumers do not need to subscribe. + */ +export interface RenderScope { + readonly entries: Iterable; +} + +interface RenderScopeNode { + parent: Nullable; + // Lazily allocated -- most render nodes never call addToScope, and we want + // iteration to skip them cheaply. + data: Nullable; + // Cached view; lazily created the first time getCurrentScope() is observed + // for this node so the same identity is returned for repeated calls. + view: Nullable; +} + +function* iterateUp(start: Nullable): Iterator { + let node = start; + while (node !== null) { + if (node.data !== null) { + // Most recent additions first -- consumers searching for "the nearest + // provider" can short-circuit as soon as they find a match. + for (let i = node.data.length - 1; i >= 0; i--) { + yield node.data[i]; + } + } + node = node.parent; + } +} + +function viewFor(node: RenderScopeNode): RenderScope { + if (node.view === null) { + node.view = { + get entries() { + return { + [Symbol.iterator]: () => iterateUp(node), + }; + }, + }; + } + return node.view; +} + +/** + * Tracks the render-tree scope hierarchy for the public `getScope` / + * `addToScope` API proposed in RFC #1154. + * + * This mirrors the stack management of `DebugRenderTree`, but is always-on + * because it is part of the framework's public surface area (not a debug-only + * tool). + */ +export class RenderScopeTracker { + // Stack of currently-rendering scope nodes; the top is the "current" scope. + private stack = new Stack(); + // bucket -> scope node, so we can re-enter on update without losing entries. + private nodes = new WeakMap(); + + begin(): void { + this.reset(); + } + + commit(): void { + this.reset(); + } + + /** + * Called when a render node is first created during rendering. The bucket + * is whatever stable object the caller wants to associate with the scope + * (e.g. the component instance). + */ + create(bucket: object): void { + let node: RenderScopeNode = { + parent: this.stack.current ?? null, + data: null, + view: null, + }; + this.nodes.set(bucket, node); + this.stack.push(node); + } + + /** + * Called for the bucket on re-render. Re-pushes the existing scope node so + * descendants link back to the same parent chain. + */ + enter(bucket: object): void { + let node = this.nodes.get(bucket); + if (node !== undefined) { + this.stack.push(node); + } + } + + /** + * Called once the bucket has finished rendering for this tick. + */ + exit(): void { + this.stack.pop(); + } + + /** + * Called when the render node is torn down. The WeakMap will collect the + * node naturally; we just drop the explicit reference so the node's + * `data` (which may hold user state) can be released eagerly. + */ + willDestroy(bucket: object): void { + let node = this.nodes.get(bucket); + if (node !== undefined) { + node.data = null; + node.parent = null; + node.view = null; + this.nodes.delete(bucket); + } + } + + /** + * Returns the publicly observable scope for the top of the stack, or + * `undefined` when called outside of rendering. + */ + getCurrentScope(): RenderScope | undefined { + let node = this.stack.current; + if (node === undefined || node === null) { + return undefined; + } + return viewFor(node); + } + + addToCurrentScope(entry: unknown): void { + let node = this.stack.current; + if (node === undefined || node === null) { + throw new Error( + 'addToScope() may only be called while rendering -- there is no active scope.' + ); + } + if (node.data === null) { + node.data = []; + } + node.data.push(entry); + } + + private reset(): void { + while (!this.stack.isEmpty()) { + this.stack.pop(); + } + } +} + +// Module-level reference to the active tracker. The renderer assigns this +// before invoking the render loop and clears it on commit, so that the public +// `getScope` / `addToScope` helpers can be called from anywhere within a +// rendering tick. +let CURRENT_TRACKER: RenderScopeTracker | undefined; + +export function setCurrentRenderScopeTracker(tracker: RenderScopeTracker | undefined): void { + CURRENT_TRACKER = tracker; +} + +export function getCurrentRenderScope(): RenderScope | undefined { + return CURRENT_TRACKER?.getCurrentScope(); +} + +export function addToCurrentRenderScope(entry: unknown): void { + if (CURRENT_TRACKER === undefined) { + throw new Error( + 'addToScope() may only be called while rendering -- there is no active scope.' + ); + } + CURRENT_TRACKER.addToCurrentScope(entry); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 652dd7d501e..54d6fefb5ac 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -963,6 +963,9 @@ importers: '@ember/-internals': specifier: workspace:* version: link:../-internals + '@glimmer/runtime': + specifier: workspace:* + version: link:../../@glimmer/runtime packages/@ember/routing: dependencies: From efd4d04ce461b9d5f54af6c19bc72b5c8c9edd66 Mon Sep 17 00:00:00 2001 From: NullVoxPopuli <199018+NullVoxPopuli@users.noreply.github.com> Date: Fri, 22 May 2026 17:34:16 -0400 Subject: [PATCH 02/15] Fix lint: prettier format + docs coverage for getScope/addToScope - Run prettier on render-scope.ts. - Register getScope and addToScope in tests/docs/expected.cjs so the docs-coverage test recognises the new @ember/renderer exports. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/@glimmer/runtime/lib/render-scope.ts | 4 +--- tests/docs/expected.cjs | 2 ++ 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/@glimmer/runtime/lib/render-scope.ts b/packages/@glimmer/runtime/lib/render-scope.ts index e4ea1e85408..aeac66b5e45 100644 --- a/packages/@glimmer/runtime/lib/render-scope.ts +++ b/packages/@glimmer/runtime/lib/render-scope.ts @@ -171,9 +171,7 @@ export function getCurrentRenderScope(): RenderScope | undefined { export function addToCurrentRenderScope(entry: unknown): void { if (CURRENT_TRACKER === undefined) { - throw new Error( - 'addToScope() may only be called while rendering -- there is no active scope.' - ); + throw new Error('addToScope() may only be called while rendering -- there is no active scope.'); } CURRENT_TRACKER.addToCurrentScope(entry); } diff --git a/tests/docs/expected.cjs b/tests/docs/expected.cjs index 275e5cbd6d3..fd777734611 100644 --- a/tests/docs/expected.cjs +++ b/tests/docs/expected.cjs @@ -58,6 +58,7 @@ module.exports = { 'addObject', 'addObjects', 'addObserver', + 'addToScope', 'advanceReadiness', 'afterModel', 'alias', @@ -225,6 +226,7 @@ module.exports = { 'getRecordKeywords', 'getRecords', 'getRootViews', + 'getScope', 'getURL', 'getValue', 'getViewBoundingClientRect', From 0fef648e0eb3942f5d27e9bab2567c9a8dbc48c0 Mon Sep 17 00:00:00 2001 From: NullVoxPopuli <199018+NullVoxPopuli@users.noreply.github.com> Date: Fri, 22 May 2026 17:59:32 -0400 Subject: [PATCH 03/15] Add makeContext(): user-facing provide/consume per NullVoxPopuli's RFC #1154 comments NVP proposed in https://github.com/emberjs/rfcs/pull/1154#issuecomment-3564417100 that the actual user-facing primitive should be `makeContext`: const foo = makeContext(Foo) {{#let (foo.consume) as |f|}}{{f.bar}}{{/let}} {{ (foo.consume) }} <-- throws This commit pivots the public API in @ember/renderer from the lower-level getScope/addToScope primitives (which stay as internal infrastructure) to the higher-level makeContext returning `{ Provide, consume }`. Behavior matches NVP's clarifications: - consume() throws when no is found in the render tree. - consume() throws when called outside a render (the scope is render-time only; an undefined scope is never legitimate for context). - The value returned by the factory is not itself tracked, but @tracked state on it remains reactive -- consumers re-render when those fields change. - Two forms supported, per NVP's example and rtablada's extension: makeContext(Klass) // each calls `new Klass()` makeContext(() => value) // each calls the factory Detected via a Function.prototype.toString sniff (`/^class[\s{]/`). Implementation notes: - `` is built on the same internal-component infrastructure as Input / Textarea / LinkTo (lib/components/internal.ts + `opaquify`), so it ships inside ember-source without taking a dep on @glimmer/component. - Each `` constructor instantiates the factory and pushes [key, value] onto the current render-tree scope; consume walks scope.entries looking for a matching key. The closure-captured `key` identity isolates contexts from each other. - The `` template is the static `{{yield}}`, precompiled once and shared across all Provide classes. Tests (packages/@ember/-internals/glimmer/tests/integration/render-tree-scope-test.ts) cover the five things real consumers care about: throws outside render, throws with no provider, nearest-provider lookup, factory form, and @tracked-reactivity through the provided value. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/@ember/-internals/glimmer/index.ts | 8 +- .../-internals/glimmer/lib/make-context.ts | 125 +++++++++ .../integration/render-tree-scope-test.ts | 252 ++++++++++-------- packages/@ember/renderer/index.ts | 67 +++-- packages/@ember/renderer/package.json | 3 +- pnpm-lock.yaml | 3 - tests/docs/expected.cjs | 3 +- 7 files changed, 309 insertions(+), 152 deletions(-) create mode 100644 packages/@ember/-internals/glimmer/lib/make-context.ts diff --git a/packages/@ember/-internals/glimmer/index.ts b/packages/@ember/-internals/glimmer/index.ts index e3a9c72951e..a3d20e47d35 100644 --- a/packages/@ember/-internals/glimmer/index.ts +++ b/packages/@ember/-internals/glimmer/index.ts @@ -474,12 +474,8 @@ export { renderComponent, type View, } from './lib/renderer'; -// RFC #1154 -- public render-tree scope API -export { - getCurrentRenderScope as getScope, - addToCurrentRenderScope as addToScope, - type RenderScope as Scope, -} from '@glimmer/runtime/lib/render-scope'; +// RFC #1154 -- render-tree-scoped context (provide/consume) +export { makeContext, type Context, type ContextFactory } from './lib/make-context'; export { getTemplate, setTemplate, diff --git a/packages/@ember/-internals/glimmer/lib/make-context.ts b/packages/@ember/-internals/glimmer/lib/make-context.ts new file mode 100644 index 00000000000..d02c2dc775a --- /dev/null +++ b/packages/@ember/-internals/glimmer/lib/make-context.ts @@ -0,0 +1,125 @@ +/** + * @module @ember/renderer + */ +import { precompileTemplate } from '@ember/template-compilation'; +import { addToCurrentRenderScope, getCurrentRenderScope } from '@glimmer/runtime/lib/render-scope'; +import InternalComponent, { + type OpaqueInternalComponentConstructor, + opaquify, +} from './components/internal'; + +/** + * The shape returned by `makeContext`. Use `Provide` in templates to bind a + * fresh value into the render tree and `consume` to read the nearest enclosing + * value. + */ +export interface Context { + Provide: OpaqueInternalComponentConstructor; + consume: () => T; +} + +export type ContextFactory = (new () => T) | (() => T); + +/** + * Creates a render-tree-scoped context per [RFC #1154][rfc] discussion. + * + * [rfc]: https://github.com/emberjs/rfcs/pull/1154 + * + * @example + * + * ```gjs + * import { makeContext } from '@ember/renderer'; + * + * class Theme { + * color = 'dark'; + * } + * + * const theme = makeContext(Theme); + * + * + * ``` + * + * Both forms are supported for the factory: + * - A class (`makeContext(SomeClass)`) — each `` constructs a fresh + * instance via `new SomeClass()`. + * - A factory function (`makeContext(() => value)`) — each `` + * invokes the factory to produce a fresh value. + * + * Reactivity rules: the *value* returned by the factory is not itself + * tracked. Consumers see the same identity across re-renders of their + * Provide. To get reactivity, put `@tracked` state on the value object — + * mutating that state will invalidate consumers as expected. + * + * `consume()` throws if it is called outside of rendering, or if no + * matching `` exists higher in the render tree. This is + * intentional: a missing provider is almost always a bug, not a + * legitimate "fall back to undefined" state. If you need a default, + * provide one at the application root. + * + * @method makeContext + * @static + * @for @ember/renderer + * @param {Function} factory A zero-arg class or factory function that + * produces a fresh value each time `` is rendered. + * @returns {Object} An object with `Provide` (a component) and `consume` + * (a function/helper that reads the nearest provided value). + * @public + */ +export function makeContext(factory: ContextFactory): Context { + // Identity-based key, so multiple contexts can coexist on the same scope + // without name collisions. Held in the closure -- not exported. + const key = {}; + + function consume(): T { + let scope = getCurrentRenderScope(); + if (scope === undefined) { + throw new Error( + '`consume()` was called outside of rendering. The render-tree scope is only available during rendering -- there is nothing to read.' + ); + } + for (let entry of scope.entries) { + if (Array.isArray(entry) && entry[0] === key) { + return entry[1] as T; + } + } + throw new Error( + 'No matching `` was found in the render tree. Wrap consumers in `...`, or provide a default at the application root.' + ); + } + + class Provide extends InternalComponent { + static override toString(): string { + return 'Provide'; + } + + constructor(...args: ConstructorParameters) { + super(...args); + const value = isClassConstructor(factory) + ? new (factory as new () => T)() + : (factory as () => T)(); + addToCurrentRenderScope([key, value]); + } + } + + return { Provide: opaquify(Provide, PROVIDE_TEMPLATE), consume }; +} + +// All Provide components share the same template: yield to the block. +// Per-instance behavior is parameterized via the closure in makeContext. +const PROVIDE_TEMPLATE = precompileTemplate('{{yield}}'); + +function isClassConstructor(fn: unknown): boolean { + if (typeof fn !== 'function') return false; + // ES classes serialize starting with `class`; arrow / regular functions do + // not. This is the standard "is class" sniff and is good enough for our + // dual-overload accepting either form. + return /^class[\s{]/.test(Function.prototype.toString.call(fn)); +} diff --git a/packages/@ember/-internals/glimmer/tests/integration/render-tree-scope-test.ts b/packages/@ember/-internals/glimmer/tests/integration/render-tree-scope-test.ts index faf7b042b19..af773dcc1ef 100644 --- a/packages/@ember/-internals/glimmer/tests/integration/render-tree-scope-test.ts +++ b/packages/@ember/-internals/glimmer/tests/integration/render-tree-scope-test.ts @@ -14,26 +14,29 @@ import GlimmerishComponent from '../utils/glimmerish-component'; import { run } from '@ember/runloop'; import { associateDestroyableChild, registerDestructor } from '@glimmer/destroyable'; import { renderComponent } from '../../lib/renderer'; -import { - addToCurrentRenderScope as addToScope, - getCurrentRenderScope as getScope, -} from '@glimmer/runtime/lib/render-scope'; +import { makeContext } from '../../lib/make-context'; +import { tracked } from '@glimmer/tracking'; import type Owner from '@ember/owner'; /** - * Coverage for the render-tree scope primitives proposed in RFC #1154 - * ( https://github.com/emberjs/rfcs/pull/1154 ). + * Coverage for `makeContext` (the user-facing API discussed in + * https://github.com/emberjs/rfcs/pull/1154 -- NullVoxPopuli's + * `makeContext(Klass)` proposal returning `{ Provide, consume }`). * - * These tests pin down the two things consumers actually rely on: + * The tests below pin down the semantics that real consumers care about: * - * 1. `getScope()` is only defined while rendering, and returns a Scope whose - * `entries` walks from the current render node up through each ancestor. - * 2. A userland `provide` / `consume` built on `addToScope` + `getScope` - * finds the nearest provider, which is the building block for any - * component-tree context implementation. + * 1. `` produces a fresh instance per render, which + * descendants can read via `context.consume()` or `(context.consume)`. + * 2. `consume()` finds the *nearest* enclosing ``. + * 3. `consume()` throws when called outside a render OR when no provider + * is in the tree -- a missing provider is a bug, not a default. + * 4. Each `` has its own instance: providing the same class + * twice does not share state across providers. + * 5. `@tracked` state on the provided value flows reactively to + * consumers. */ -class RenderTreeScopeTestCase extends AbstractStrictTestCase { +class MakeContextTestCase extends AbstractStrictTestCase { owner: Owner; constructor(assert: QUnit['assert']) { @@ -60,163 +63,188 @@ class RenderTreeScopeTestCase extends AbstractStrictTestCase { } moduleFor( - 'RFC #1154 -- render-tree scope primitives', - class extends RenderTreeScopeTestCase { + 'RFC #1154 -- makeContext: render-tree-scoped context', + class extends MakeContextTestCase { afterEach() { runDestroy(this); } - '@test getScope() returns undefined outside of rendering'(assert: QUnit['assert']) { - assert.strictEqual(getScope(), undefined, 'no active scope before render'); - - let Foo = setComponentTemplate(precompileTemplate('hi'), templateOnly()); - this.renderComponent(Foo); - - assertHTML('hi'); - assert.strictEqual(getScope(), undefined, 'no active scope after commit'); - } + '@test consume() throws if called outside of rendering'(assert: QUnit['assert']) { + class Theme { + color = 'dark'; + } + const theme = makeContext(Theme); - '@test addToScope() throws when called outside of rendering'(assert: QUnit['assert']) { assert.throws( - () => addToScope('nope'), - /addToScope/, - 'addToScope rejects calls made outside a render' + () => theme.consume(), + /outside of rendering/, + 'consume() outside a render is rejected' ); } - "@test a function captured at render time sees the caller's scope entries via getScope()"( - assert: QUnit['assert'] - ) { - let collected: unknown[] = []; + '@test consume() throws when no exists in the tree'(assert: QUnit['assert']) { + class Theme { + color = 'dark'; + } + const theme = makeContext(Theme); + let error: Error | undefined; class Reader extends GlimmerishComponent { constructor(owner: Owner, args: Record) { super(owner, args); - let scope = getScope(); - assert.ok(scope, 'scope is defined during component construction'); - if (scope) { - for (let entry of scope.entries) { - collected.push(entry); - } + try { + theme.consume(); + } catch (e) { + error = e as Error; } } } - setComponentTemplate(precompileTemplate('done'), Reader); + setComponentTemplate(precompileTemplate(''), Reader); - class Provider extends GlimmerishComponent { - constructor(owner: Owner, args: Record) { - super(owner, args); - addToScope({ kind: 'theme', value: 'dark' }); - addToScope({ kind: 'locale', value: 'en' }); - } - } - setComponentTemplate( + let Root = setComponentTemplate( precompileTemplate('', { strictMode: true, scope: () => ({ Reader }), }), - Provider - ); - - let Root = setComponentTemplate( - precompileTemplate('', { - strictMode: true, - scope: () => ({ Provider }), - }), templateOnly() ); this.renderComponent(Root); - assertHTML('done'); - // Entries iterate most-recent-first within a node, then walk up. - // Reader's own scope is empty, so we should see the two entries from - // Provider in reverse insertion order. - assert.deepEqual( - collected, - [ - { kind: 'locale', value: 'en' }, - { kind: 'theme', value: 'dark' }, - ], - 'consumer iterates its parent provider entries newest-first' + assert.ok(error, 'consume() raised'); + assert.ok( + /No matching ``/.test(error?.message ?? ''), + `error mentions missing provider, got: ${error?.message}` ); } - '@test consumer finds the nearest provider (component-tree context pattern)'( - assert: QUnit['assert'] - ) { - let observed: string[] = []; - - class ThemeKey {} - const THEME = new ThemeKey(); - - function provideTheme(value: string) { - addToScope([THEME, value]); + '@test + consume() returns the nearest enclosing instance'(assert: QUnit['assert']) { + let id = 0; + class Counter { + id = id++; } + const counter = makeContext(Counter); - function consumeTheme(): string | undefined { - let scope = getScope(); - if (!scope) return undefined; - for (let entry of scope.entries) { - if (Array.isArray(entry) && entry[0] === THEME) { - return entry[1] as string; - } - } - return undefined; - } + let seen: number[] = []; class Reader extends GlimmerishComponent { constructor(owner: Owner, args: Record) { super(owner, args); - observed.push(consumeTheme() ?? 'none'); + seen.push(counter.consume().id); } } setComponentTemplate(precompileTemplate('r'), Reader); - class Inner extends GlimmerishComponent { + let Root = setComponentTemplate( + precompileTemplate( + // Inner shadows the outer one. Three readers, three + // distinct provider scopes -- each should see the nearest. + '', + { + strictMode: true, + scope: () => ({ Reader, counter }), + } + ), + templateOnly() + ); + + this.renderComponent(Root); + assertHTML('rrr'); + + assert.deepEqual( + seen, + [0, 1, 0], + 'each Reader saw its nearest : outer (id 0), inner (id 1), outer again (id 0)' + ); + } + + '@test factory form: makeContext(() => value)'(assert: QUnit['assert']) { + // Plain factory -- no class. + let made = 0; + const cfg = makeContext(() => { + made++; + return { label: 'hello' }; + }); + + let observed: string | undefined; + class Reader extends GlimmerishComponent { constructor(owner: Owner, args: Record) { super(owner, args); - provideTheme('dark'); + observed = cfg.consume().label; } } - setComponentTemplate( - precompileTemplate('', { + setComponentTemplate(precompileTemplate('r'), Reader); + + let Root = setComponentTemplate( + precompileTemplate('', { strictMode: true, - scope: () => ({ Reader }), + scope: () => ({ Reader, cfg }), }), - Inner + templateOnly() ); - class Outer extends GlimmerishComponent { + this.renderComponent(Root); + + assert.strictEqual(made, 1, 'factory ran exactly once'); + assert.strictEqual(observed, 'hello', 'consumer saw the factory-produced value'); + } + + '@test (context.consume) is usable as a template helper'(assert: QUnit['assert']) { + class Theme { + color = 'dark'; + } + const theme = makeContext(Theme); + + let Root = setComponentTemplate( + precompileTemplate( + '{{#let (theme.consume) as |t|}}{{t.color}}{{/let}}', + { + strictMode: true, + scope: () => ({ theme }), + } + ), + templateOnly() + ); + + this.renderComponent(Root); + assertHTML('dark'); + assert.ok(true); + } + + '@test @tracked state on the provided value is reactive'(assert: QUnit['assert']) { + class Counter { + @tracked count = 0; + } + const counter = makeContext(Counter); + + let captured: Counter | undefined; + class Capture extends GlimmerishComponent { constructor(owner: Owner, args: Record) { super(owner, args); - provideTheme('light'); + captured = counter.consume(); } } - setComponentTemplate( - precompileTemplate('', { - strictMode: true, - scope: () => ({ Inner, Reader }), - }), - Outer - ); + setComponentTemplate(precompileTemplate(''), Capture); let Root = setComponentTemplate( - precompileTemplate('', { - strictMode: true, - scope: () => ({ Outer, Reader }), - }), + precompileTemplate( + '{{#let (counter.consume) as |c|}}{{c.count}}{{/let}}', + { + strictMode: true, + scope: () => ({ Capture, counter }), + } + ), templateOnly() ); this.renderComponent(Root); - assertHTML('rrrr'); + assertHTML('0'); - assert.deepEqual( - observed, - ['none', 'light', 'dark', 'light'], - 'each Reader sees the nearest enclosing provider, falling back to undefined at the root' - ); + assert.ok(captured, 'Capture observed the instance'); + run(() => { + captured!.count = 5; + }); + assertHTML('5'); } } ); diff --git a/packages/@ember/renderer/index.ts b/packages/@ember/renderer/index.ts index c8adbe46734..5fa5eedb203 100644 --- a/packages/@ember/renderer/index.ts +++ b/packages/@ember/renderer/index.ts @@ -82,41 +82,54 @@ export { renderSettled } from '@ember/-internals/glimmer/lib/renderer'; export { renderComponent } from '@ember/-internals/glimmer/lib/renderer'; /** - * Returns the current render-tree scope, or `undefined` if called outside of - * rendering. + * Creates a render-tree-scoped context (provide/consume) for sharing values + * with descendant components without prop drilling. * - * See [RFC #1154](https://github.com/emberjs/rfcs/pull/1154) for the motivation - * and the userland patterns this primitive enables (notably component-tree - * `provide` / `consume`). + * See [RFC #1154](https://github.com/emberjs/rfcs/pull/1154) and the original + * [Context RFC #975](https://github.com/emberjs/rfcs/pull/975). * - * The returned `Scope` exposes `entries`, an iterable that walks the current - * scope's own additions, then up through each ancestor render node. Anything - * pushed onto the scope via `addToScope` becomes visible here. + * `makeContext` returns an object with: * - * `getScope()` is synchronous and is only valid during render. After an - * `await`, you must capture the scope (or the specific entries you need) - * before the microtask boundary. + * - `Provide`: a component that, on every render, produces a fresh value + * from the given class or factory and exposes it to every descendant in + * the block. + * - `consume()`: a function (also usable as a template helper) that returns + * the nearest enclosing provided value. **Throws** if there is no + * matching provider higher in the render tree, or if called outside of + * rendering. * - * @method getScope - * @static - * @for @ember/renderer - * @returns {Scope | undefined} the current scope, or `undefined` when called outside of rendering. - * @public - */ -export { getCurrentRenderScope as getScope } from '@glimmer/runtime/lib/render-scope'; - -/** - * Adds an entry to the current render-tree scope so descendants can find it - * via `getScope()`. Throws when called outside of rendering. + * ```gjs + * import { makeContext } from '@ember/renderer'; + * + * class Theme { + * color = 'dark'; + * } + * + * const theme = makeContext(Theme); + * + * + * ``` * - * See [RFC #1154](https://github.com/emberjs/rfcs/pull/1154). + * Reactivity: the *value* returned by the factory is not itself tracked, + * but `@tracked` state on it is — mutating tracked fields invalidates + * consumers as expected. * - * @method addToScope + * @method makeContext * @static * @for @ember/renderer - * @param {unknown} entry the value to expose to descendants. + * @param {Function} factory A zero-arg class or factory function that + * produces a fresh value each time `` is rendered. + * @returns {Object} `{ Provide, consume }` * @public */ -export { addToCurrentRenderScope as addToScope } from '@glimmer/runtime/lib/render-scope'; +export { makeContext } from '@ember/-internals/glimmer/lib/make-context'; -export type { RenderScope as Scope } from '@glimmer/runtime/lib/render-scope'; +export type { Context, ContextFactory } from '@ember/-internals/glimmer/lib/make-context'; diff --git a/packages/@ember/renderer/package.json b/packages/@ember/renderer/package.json index 35b090f46c5..a4843dd6fae 100644 --- a/packages/@ember/renderer/package.json +++ b/packages/@ember/renderer/package.json @@ -7,7 +7,6 @@ "./*": "./*.ts" }, "dependencies": { - "@ember/-internals": "workspace:*", - "@glimmer/runtime": "workspace:*" + "@ember/-internals": "workspace:*" } } \ No newline at end of file diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 54d6fefb5ac..652dd7d501e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -963,9 +963,6 @@ importers: '@ember/-internals': specifier: workspace:* version: link:../-internals - '@glimmer/runtime': - specifier: workspace:* - version: link:../../@glimmer/runtime packages/@ember/routing: dependencies: diff --git a/tests/docs/expected.cjs b/tests/docs/expected.cjs index fd777734611..f6c02adae44 100644 --- a/tests/docs/expected.cjs +++ b/tests/docs/expected.cjs @@ -58,7 +58,6 @@ module.exports = { 'addObject', 'addObjects', 'addObserver', - 'addToScope', 'advanceReadiness', 'afterModel', 'alias', @@ -226,7 +225,6 @@ module.exports = { 'getRecordKeywords', 'getRecords', 'getRootViews', - 'getScope', 'getURL', 'getValue', 'getViewBoundingClientRect', @@ -317,6 +315,7 @@ module.exports = { 'lt', 'lte', 'makeArray', + 'makeContext', 'makeToString', 'map', 'mapBy', From bd8064cce675a135a317ee1bd1c094cd1789d8f2 Mon Sep 17 00:00:00 2001 From: NullVoxPopuli <199018+NullVoxPopuli@users.noreply.github.com> Date: Fri, 22 May 2026 18:05:38 -0400 Subject: [PATCH 04/15] Fix reactive-context test: capture instance via factory, not empty Capture component The previous test used a Capture component with an empty template to grab the instance via its constructor. An empty template renders as `` in the DOM, which polluted assertHTML('0') -> actual was `0`. Move the capture into the factory closure itself -- the factory runs exactly once per , so it's a clean place to grab the instance without adding any DOM artifacts. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../integration/render-tree-scope-test.ts | 23 +++++++++---------- 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/packages/@ember/-internals/glimmer/tests/integration/render-tree-scope-test.ts b/packages/@ember/-internals/glimmer/tests/integration/render-tree-scope-test.ts index af773dcc1ef..a30338cae81 100644 --- a/packages/@ember/-internals/glimmer/tests/integration/render-tree-scope-test.ts +++ b/packages/@ember/-internals/glimmer/tests/integration/render-tree-scope-test.ts @@ -215,23 +215,22 @@ moduleFor( class Counter { @tracked count = 0; } - const counter = makeContext(Counter); - + // Capture the instance via the factory itself -- that runs exactly + // once per , so there is no need for a separate "Capture" + // component (which would add an empty comment-marker to the output). let captured: Counter | undefined; - class Capture extends GlimmerishComponent { - constructor(owner: Owner, args: Record) { - super(owner, args); - captured = counter.consume(); - } - } - setComponentTemplate(precompileTemplate(''), Capture); + const counter = makeContext(() => { + const c = new Counter(); + captured = c; + return c; + }); let Root = setComponentTemplate( precompileTemplate( - '{{#let (counter.consume) as |c|}}{{c.count}}{{/let}}', + '{{#let (counter.consume) as |c|}}{{c.count}}{{/let}}', { strictMode: true, - scope: () => ({ Capture, counter }), + scope: () => ({ counter }), } ), templateOnly() @@ -240,7 +239,7 @@ moduleFor( this.renderComponent(Root); assertHTML('0'); - assert.ok(captured, 'Capture observed the instance'); + assert.ok(captured, 'factory produced the instance'); run(() => { captured!.count = 5; }); From b0902512a6b1a6dfeeb50942311d2ee41fff9077 Mon Sep 17 00:00:00 2001 From: NullVoxPopuli <199018+NullVoxPopuli@users.noreply.github.com> Date: Fri, 22 May 2026 22:34:13 -0400 Subject: [PATCH 05/15] Add + port ember-provide-consume-context test scenarios Extends `` with an optional `@value` arg (matching rtablada's RFC #1154 example) and ports the substantive cases from customerio/ember-provide-consume-context's built-in-components-test.ts. @value support: - `args.named.value` is stored as a lazy `read()` thunk in the scope entry. `valueForRef` consumes the tracking tag when called inside the consumer's tracking frame, so consumers re-render automatically when the arg updates. - When `@value` is not passed, the factory runs once per and the cached result is returned (preserves identity across re-renders, which downstream code -- ref tracking, caching -- relies on). The scope-entry shape changes from `[key, value]` to a typed `{ key, read }` record (with an `isContextEntry` guard) so that future extensions don't have to overload the array form. Tests ported / adapted (in the new "behavior ported from ember-provide-consume-context" module): - a consumer can read context - a consumer reads from the closest provider - consumer's value updates when @value changes - a consumer can't access a context it isn't nested in - sibling Provides with the same context do not bleed - consumer is reactive across an {{#if}} that toggles it on and off - a conditional tears down and re-instates correctly - a conditional sibling does not override an outer one - multiple distinct contexts can be nested - @tracked state on a factory-provided class instance is reactive - consumer at component-instance init time sees the nearest provider - factory-provided value is stable across the same Provide re-render EPCC tests that did NOT port: - "reading a context that does not exist returns undefined" -- the makeContext API throws instead, per NVP's "reduce harm" clarification. Already covered by the "consume() throws when no " test. - @provide / @consume decorator tests -- decorators are a separate API paradigm not in scope for the makeContext primitive. - test-support helpers (`setupRenderWrapper`, `provide` in beforeEach) -- test-support is a separate concern that should be addressed once the primary API lands. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../-internals/glimmer/lib/make-context.ts | 78 ++- .../integration/render-tree-scope-test.ts | 454 +++++++++++++++--- 2 files changed, 454 insertions(+), 78 deletions(-) diff --git a/packages/@ember/-internals/glimmer/lib/make-context.ts b/packages/@ember/-internals/glimmer/lib/make-context.ts index d02c2dc775a..4207af66095 100644 --- a/packages/@ember/-internals/glimmer/lib/make-context.ts +++ b/packages/@ember/-internals/glimmer/lib/make-context.ts @@ -3,6 +3,7 @@ */ import { precompileTemplate } from '@ember/template-compilation'; import { addToCurrentRenderScope, getCurrentRenderScope } from '@glimmer/runtime/lib/render-scope'; +import { valueForRef } from '@glimmer/reference/lib/reference'; import InternalComponent, { type OpaqueInternalComponentConstructor, opaquify, @@ -20,6 +21,25 @@ export interface Context { export type ContextFactory = (new () => T) | (() => T); +// Internal entry shape stored in the render-tree scope. The key identifies the +// context (closure-captured by makeContext); `read` returns the current +// provided value -- evaluated lazily so that, when `@value` is passed, the +// auto-tracking inside `valueForRef` makes consumers reactive to it. +interface ContextEntry { + key: object; + read: () => unknown; +} + +function isContextEntry(entry: unknown): entry is ContextEntry { + return ( + typeof entry === 'object' && + entry !== null && + 'key' in entry && + 'read' in entry && + typeof (entry as ContextEntry).read === 'function' + ); +} + /** * Creates a render-tree-scoped context per [RFC #1154][rfc] discussion. * @@ -43,32 +63,46 @@ export type ContextFactory = (new () => T) | (() => T); * {{/let}} * * + * {{! Override the value at this provider: }} + * + * {{#let (theme.consume) as |t|}} + * {{t.color}} {{! "light" }} + * {{/let}} + * + * * {{ (theme.consume) }} {{! throws -- no provider in the hierarchy }} * * ``` * - * Both forms are supported for the factory: + * Both factory forms are supported: * - A class (`makeContext(SomeClass)`) — each `` constructs a fresh * instance via `new SomeClass()`. * - A factory function (`makeContext(() => value)`) — each `` * invokes the factory to produce a fresh value. * - * Reactivity rules: the *value* returned by the factory is not itself - * tracked. Consumers see the same identity across re-renders of their - * Provide. To get reactivity, put `@tracked` state on the value object — - * mutating that state will invalidate consumers as expected. + * `` also accepts an optional `@value` argument. When passed, that + * value is provided to descendants instead of the factory's output, and + * consumers re-render automatically when the argument updates. + * + * Reactivity rules: + * - For factory-provided values, the *value* returned by the factory is not + * itself tracked. Put `@tracked` state on it for reactivity. + * - For `@value`-provided values, the binding is reactive to argument + * updates as you'd expect. * * `consume()` throws if it is called outside of rendering, or if no * matching `` exists higher in the render tree. This is - * intentional: a missing provider is almost always a bug, not a - * legitimate "fall back to undefined" state. If you need a default, - * provide one at the application root. + * intentional (matching NullVoxPopuli's "reduce harm" clarification on the + * RFC): a missing provider is almost always a bug, not a legitimate + * "fall back to undefined" state. If you want a default, provide one at the + * application root. * * @method makeContext * @static * @for @ember/renderer * @param {Function} factory A zero-arg class or factory function that - * produces a fresh value each time `` is rendered. + * produces a fresh value each time `` is rendered (and `@value` + * was not passed). * @returns {Object} An object with `Provide` (a component) and `consume` * (a function/helper that reads the nearest provided value). * @public @@ -86,8 +120,8 @@ export function makeContext(factory: ContextFactory): Context { ); } for (let entry of scope.entries) { - if (Array.isArray(entry) && entry[0] === key) { - return entry[1] as T; + if (isContextEntry(entry) && entry.key === key) { + return entry.read() as T; } } throw new Error( @@ -102,10 +136,24 @@ export function makeContext(factory: ContextFactory): Context { constructor(...args: ConstructorParameters) { super(...args); - const value = isClassConstructor(factory) - ? new (factory as new () => T)() - : (factory as () => T)(); - addToCurrentRenderScope([key, value]); + + // If `@value` was passed, store a lazy read that pulls the current + // value from the argument reference. `valueForRef` consumes tracking + // tags when called inside a tracking frame, so consumers re-render + // automatically when the argument updates. + const valueRef = this.args.named['value']; + let read: () => unknown; + if (valueRef !== undefined) { + read = () => valueForRef(valueRef); + } else { + const factoryValue: T = isClassConstructor(factory) + ? new (factory as new () => T)() + : (factory as () => T)(); + read = () => factoryValue; + } + + const entry: ContextEntry = { key, read }; + addToCurrentRenderScope(entry); } } diff --git a/packages/@ember/-internals/glimmer/tests/integration/render-tree-scope-test.ts b/packages/@ember/-internals/glimmer/tests/integration/render-tree-scope-test.ts index a30338cae81..728f73dd2ec 100644 --- a/packages/@ember/-internals/glimmer/tests/integration/render-tree-scope-test.ts +++ b/packages/@ember/-internals/glimmer/tests/integration/render-tree-scope-test.ts @@ -23,17 +23,23 @@ import type Owner from '@ember/owner'; * https://github.com/emberjs/rfcs/pull/1154 -- NullVoxPopuli's * `makeContext(Klass)` proposal returning `{ Provide, consume }`). * - * The tests below pin down the semantics that real consumers care about: + * The bulk of the substantive scenarios here are ported from + * `customerio/ember-provide-consume-context`'s test suite -- the prior-art + * implementation that NullVoxPopuli called out in the RFC. The intent is + * to pin down the *same behaviors* that production users of that library + * rely on (sibling isolation, conditionals, reactivity to value changes, + * etc.), translated to the makeContext API where: * - * 1. `` produces a fresh instance per render, which - * descendants can read via `context.consume()` or `(context.consume)`. - * 2. `consume()` finds the *nearest* enclosing ``. - * 3. `consume()` throws when called outside a render OR when no provider - * is in the tree -- a missing provider is a bug, not a default. - * 4. Each `` has its own instance: providing the same class - * twice does not share state across providers. - * 5. `@tracked` state on the provided value flows reactively to - * consumers. + * - The string `@key=` becomes a closure-captured `makeContext` identity. + * - `` becomes `` (optionally with + * `@value=`). + * - `` becomes `(myContext.consume)` (a function + * helper) or `myContext.consume()` in JS. + * + * Where the two APIs intentionally diverge (e.g. EPCC's `getContext` + * returns `undefined` for missing context, whereas makeContext throws per + * NVP's "reduce harm" clarification), the test is rewritten to assert the + * makeContext behavior. */ class MakeContextTestCase extends AbstractStrictTestCase { @@ -63,7 +69,7 @@ class MakeContextTestCase extends AbstractStrictTestCase { } moduleFor( - 'RFC #1154 -- makeContext: render-tree-scoped context', + 'RFC #1154 -- makeContext: API surface', class extends MakeContextTestCase { afterEach() { runDestroy(this); @@ -118,46 +124,6 @@ moduleFor( ); } - '@test + consume() returns the nearest enclosing instance'(assert: QUnit['assert']) { - let id = 0; - class Counter { - id = id++; - } - const counter = makeContext(Counter); - - let seen: number[] = []; - - class Reader extends GlimmerishComponent { - constructor(owner: Owner, args: Record) { - super(owner, args); - seen.push(counter.consume().id); - } - } - setComponentTemplate(precompileTemplate('r'), Reader); - - let Root = setComponentTemplate( - precompileTemplate( - // Inner shadows the outer one. Three readers, three - // distinct provider scopes -- each should see the nearest. - '', - { - strictMode: true, - scope: () => ({ Reader, counter }), - } - ), - templateOnly() - ); - - this.renderComponent(Root); - assertHTML('rrr'); - - assert.deepEqual( - seen, - [0, 1, 0], - 'each Reader saw its nearest : outer (id 0), inner (id 1), outer again (id 0)' - ); - } - '@test factory form: makeContext(() => value)'(assert: QUnit['assert']) { // Plain factory -- no class. let made = 0; @@ -210,14 +176,301 @@ moduleFor( assertHTML('dark'); assert.ok(true); } + } +); + +/** + * The "real-world scenarios" suite, ported from + * ember-provide-consume-context's + * tests/integration/components/built-in-components-test.ts. + */ +moduleFor( + 'RFC #1154 -- makeContext: behavior ported from ember-provide-consume-context', + class extends MakeContextTestCase { + afterEach() { + runDestroy(this); + } + + '@test a consumer can read context'(assert: QUnit['assert']) { + const ctx = makeContext(() => '5'); + + let Root = setComponentTemplate( + precompileTemplate( + '{{#let (ctx.consume) as |v|}}
{{v}}
{{/let}}
', + { strictMode: true, scope: () => ({ ctx }) } + ), + templateOnly() + ); + + this.renderComponent(Root); + assert.strictEqual(this.element.querySelector('#content')?.textContent, '5'); + } + + '@test a consumer reads from the closest provider'(assert: QUnit['assert']) { + const ctx = makeContext(() => '0'); + + let Root = setComponentTemplate( + precompileTemplate( + [ + '', + ' {{#let (ctx.consume) as |v|}}
{{v}}
{{/let}}', + ' ', + ' {{#let (ctx.consume) as |v|}}
{{v}}
{{/let}}', + '
', + '
', + ].join('\n'), + { strictMode: true, scope: () => ({ ctx }) } + ), + templateOnly() + ); + + this.renderComponent(Root); + assert.strictEqual(this.element.querySelector('#content-1')?.textContent, '1'); + assert.strictEqual(this.element.querySelector('#content-2')?.textContent, '2'); + } - '@test @tracked state on the provided value is reactive'(assert: QUnit['assert']) { + "@test consumer's value updates when @value changes"(assert: QUnit['assert']) { + class State { + @tracked count = 1; + } + const state = new State(); + const ctx = makeContext(() => 0); + + let Root = setComponentTemplate( + precompileTemplate( + '{{#let (ctx.consume) as |v|}}
{{v}}
{{/let}}
', + { strictMode: true, scope: () => ({ ctx, state }) } + ), + templateOnly() + ); + + this.renderComponent(Root); + assert.strictEqual(this.element.querySelector('#content')?.textContent, '1'); + + run(() => { + state.count = 2; + }); + assert.strictEqual(this.element.querySelector('#content')?.textContent, '2'); + } + + "@test a consumer can't access a context it isn't nested in"(assert: QUnit['assert']) { + const ctxA = makeContext(() => 'missing'); + const ctxB = makeContext(() => 'missing'); + + let error: Error | undefined; + class Reader extends GlimmerishComponent { + constructor(owner: Owner, args: Record) { + super(owner, args); + try { + ctxA.consume(); + } catch (e) { + error = e as Error; + } + } + } + setComponentTemplate(precompileTemplate('done'), Reader); + + // Outer is ctxA (left subtree) and ctxB (right subtree); Reader is + // under ctxB, so a consume for ctxA should throw -- they don't bleed. + let Root = setComponentTemplate( + precompileTemplate( + '', + { strictMode: true, scope: () => ({ ctxA, ctxB, Reader }) } + ), + templateOnly() + ); + + this.renderComponent(Root); + + assert.ok(error, 'consume() raised for non-enclosing context'); + assert.ok( + /No matching ``/.test(error?.message ?? ''), + `error mentions missing provider, got: ${error?.message}` + ); + } + + '@test sibling Provides with the same context do not bleed'(assert: QUnit['assert']) { + const ctx = makeContext(() => 'default'); + + let Root = setComponentTemplate( + precompileTemplate( + [ + '{{#let (ctx.consume) as |v|}}
{{v}}
{{/let}}
', + '{{#let (ctx.consume) as |v|}}
{{v}}
{{/let}}
', + ].join('\n'), + { strictMode: true, scope: () => ({ ctx }) } + ), + templateOnly() + ); + + this.renderComponent(Root); + assert.strictEqual(this.element.querySelector('#content-1')?.textContent, '1'); + assert.strictEqual(this.element.querySelector('#content-2')?.textContent, '2'); + } + + '@test consumer is reactive across an {{#if}} that toggles it on and off'( + assert: QUnit['assert'] + ) { + class State { + @tracked count = 1; + @tracked hidden = false; + } + const state = new State(); + const ctx = makeContext(() => 0); + + let Root = setComponentTemplate( + precompileTemplate( + [ + '', + ' {{#unless state.hidden}}', + ' {{#let (ctx.consume) as |v|}}
{{v}}
{{/let}}', + ' {{/unless}}', + '
', + ].join('\n'), + { strictMode: true, scope: () => ({ ctx, state }) } + ), + templateOnly() + ); + + this.renderComponent(Root); + assert.strictEqual(this.element.querySelector('#content')?.textContent, '1', 'initial'); + + run(() => { + state.hidden = true; + }); + assert.strictEqual(this.element.querySelector('#content'), null, 'hidden'); + + run(() => { + state.hidden = false; + }); + assert.strictEqual(this.element.querySelector('#content')?.textContent, '1', 'back to "1"'); + + run(() => { + state.hidden = true; + }); + run(() => { + state.count = 2; + }); + run(() => { + state.hidden = false; + }); + assert.strictEqual( + this.element.querySelector('#content')?.textContent, + '2', + 'consumer reflects updated count when toggled back on' + ); + } + + '@test a conditional tears down and re-instates correctly'(assert: QUnit['assert']) { + class State { + @tracked hidden = false; + } + const state = new State(); + const ctx = makeContext(() => 'default'); + + let Root = setComponentTemplate( + precompileTemplate( + [ + '{{#unless state.hidden}}', + ' {{#let (ctx.consume) as |v|}}
{{v}}
{{/let}}
', + '{{/unless}}', + ].join('\n'), + { strictMode: true, scope: () => ({ ctx, state }) } + ), + templateOnly() + ); + + this.renderComponent(Root); + assert.strictEqual(this.element.querySelector('#content')?.textContent, '1'); + + run(() => { + state.hidden = true; + }); + assert.strictEqual(this.element.querySelector('#content'), null); + + run(() => { + state.hidden = false; + }); + assert.strictEqual(this.element.querySelector('#content')?.textContent, '1'); + } + + '@test a conditional sibling does not override an outer one'( + assert: QUnit['assert'] + ) { + class State { + @tracked hidden = true; + } + const state = new State(); + const ctx = makeContext(() => 'default'); + + // The inner ctx.Provide @value="2" is in a sibling subtree of the + // consumer, so it must never override the outer @value="1". + let Root = setComponentTemplate( + precompileTemplate( + [ + '', + ' {{#unless state.hidden}}', + ' ', + ' {{/unless}}', + ' {{#let (ctx.consume) as |v|}}
{{v}}
{{/let}}', + '
', + ].join('\n'), + { strictMode: true, scope: () => ({ ctx, state }) } + ), + templateOnly() + ); + + this.renderComponent(Root); + assert.strictEqual(this.element.querySelector('#content')?.textContent, '1'); + + run(() => { + state.hidden = false; + }); + assert.strictEqual( + this.element.querySelector('#content')?.textContent, + '1', + 'sibling provider does not override outer' + ); + + run(() => { + state.hidden = true; + }); + assert.strictEqual(this.element.querySelector('#content')?.textContent, '1'); + } + + '@test multiple distinct contexts can be nested'(assert: QUnit['assert']) { + const ctxOne = makeContext(() => '0'); + const ctxTwo = makeContext(() => '0'); + + let Root = setComponentTemplate( + precompileTemplate( + [ + '', + ' ', + ' {{#let (ctxOne.consume) as |a|}}
{{a}}
{{/let}}', + ' {{#let (ctxTwo.consume) as |b|}}
{{b}}
{{/let}}', + '
', + '
', + ].join('\n'), + { strictMode: true, scope: () => ({ ctxOne, ctxTwo }) } + ), + templateOnly() + ); + + this.renderComponent(Root); + assert.strictEqual(this.element.querySelector('#content-1')?.textContent, '1'); + assert.strictEqual(this.element.querySelector('#content-2')?.textContent, '2'); + } + + '@test @tracked state on a factory-provided class instance is reactive'( + assert: QUnit['assert'] + ) { class Counter { @tracked count = 0; } - // Capture the instance via the factory itself -- that runs exactly - // once per , so there is no need for a separate "Capture" - // component (which would add an empty comment-marker to the output). + // Capture the instance via the factory itself -- the factory runs + // exactly once per , so this avoids needing a separate + // capturing component. let captured: Counter | undefined; const counter = makeContext(() => { const c = new Counter(); @@ -227,23 +480,98 @@ moduleFor( let Root = setComponentTemplate( precompileTemplate( - '{{#let (counter.consume) as |c|}}{{c.count}}{{/let}}', - { - strictMode: true, - scope: () => ({ counter }), - } + '{{#let (counter.consume) as |c|}}
{{c.count}}
{{/let}}
', + { strictMode: true, scope: () => ({ counter }) } ), templateOnly() ); this.renderComponent(Root); - assertHTML('0'); - + assert.strictEqual(this.element.querySelector('#content')?.textContent, '0'); assert.ok(captured, 'factory produced the instance'); + run(() => { captured!.count = 5; }); - assertHTML('5'); + assert.strictEqual(this.element.querySelector('#content')?.textContent, '5'); + } + + '@test consumer at component-instance init time sees the nearest provider'( + assert: QUnit['assert'] + ) { + // Mirrors EPCC's "a consumer can read context during initialization": + // when the consumer is a class component, its constructor should see + // the enclosing provider's value (not throw, not see a stale one). + const ctx = makeContext(() => 'wrong'); + + let observed: string | undefined; + class Reader extends GlimmerishComponent { + constructor(owner: Owner, args: Record) { + super(owner, args); + observed = ctx.consume() as string; + } + } + setComponentTemplate(precompileTemplate('done'), Reader); + + let Root = setComponentTemplate( + precompileTemplate('', { + strictMode: true, + scope: () => ({ ctx, Reader }), + }), + templateOnly() + ); + + this.renderComponent(Root); + assert.strictEqual(observed, 'provided'); + } + + '@test factory-provided value is stable across the same Provide re-render'( + assert: QUnit['assert'] + ) { + // EPCC analogue: providing a class instance preserves identity. If a + // sibling tracked re-render happens, the same instance should be + // re-yielded -- not a new one. This is important for downstream code + // that uses identity (e.g. caching, refs). + class State { + @tracked tick = 0; + } + const state = new State(); + + let count = 0; + const ctx = makeContext(() => ({ id: count++ })); + + let observed: object[] = []; + class Reader extends GlimmerishComponent { + constructor(owner: Owner, args: Record) { + super(owner, args); + observed.push(ctx.consume() as object); + } + } + setComponentTemplate(precompileTemplate(''), Reader); + + let Root = setComponentTemplate( + precompileTemplate( + // The bare {{state.tick}} consumes the tracked tag so toggling it + // forces the surrounding region to re-render, but the + // itself doesn't re-instantiate the factory. + '{{state.tick}}', + { strictMode: true, scope: () => ({ ctx, state, Reader }) } + ), + templateOnly() + ); + + this.renderComponent(Root); + const first = observed[0]; + assert.ok(first, 'reader observed the value once'); + + run(() => { + state.tick = 1; + }); + + // Reader's constructor only fires once -- so we don't get a second + // observed entry. The real guarantee here is that the factory only + // ran once: `count` must be 1. + assert.strictEqual(count, 1, 'factory was not re-invoked on parent re-render'); } } ); From 286c446fa5dba8bffbaaaa9fcd0424da22afd8b4 Mon Sep 17 00:00:00 2001 From: NullVoxPopuli <199018+NullVoxPopuli@users.noreply.github.com> Date: Fri, 22 May 2026 22:41:02 -0400 Subject: [PATCH 06/15] Fix template build: inline multi-line precompileTemplate strings babel-plugin-ember-template-compilation requires the first argument to precompileTemplate to be a literal string. The .join('\n') array form broke the build for the ported EPCC test cases. Switch them to template literals. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../integration/render-tree-scope-test.ts | 68 ++++++++----------- 1 file changed, 28 insertions(+), 40 deletions(-) diff --git a/packages/@ember/-internals/glimmer/tests/integration/render-tree-scope-test.ts b/packages/@ember/-internals/glimmer/tests/integration/render-tree-scope-test.ts index 728f73dd2ec..b7934eb8797 100644 --- a/packages/@ember/-internals/glimmer/tests/integration/render-tree-scope-test.ts +++ b/packages/@ember/-internals/glimmer/tests/integration/render-tree-scope-test.ts @@ -211,14 +211,12 @@ moduleFor( let Root = setComponentTemplate( precompileTemplate( - [ - '', - ' {{#let (ctx.consume) as |v|}}
{{v}}
{{/let}}', - ' ', - ' {{#let (ctx.consume) as |v|}}
{{v}}
{{/let}}', - '
', - '
', - ].join('\n'), + ` + {{#let (ctx.consume) as |v|}}
{{v}}
{{/let}} + + {{#let (ctx.consume) as |v|}}
{{v}}
{{/let}} +
+
`, { strictMode: true, scope: () => ({ ctx }) } ), templateOnly() @@ -294,10 +292,8 @@ moduleFor( let Root = setComponentTemplate( precompileTemplate( - [ - '{{#let (ctx.consume) as |v|}}
{{v}}
{{/let}}
', - '{{#let (ctx.consume) as |v|}}
{{v}}
{{/let}}
', - ].join('\n'), + `{{#let (ctx.consume) as |v|}}
{{v}}
{{/let}}
+ {{#let (ctx.consume) as |v|}}
{{v}}
{{/let}}
`, { strictMode: true, scope: () => ({ ctx }) } ), templateOnly() @@ -320,13 +316,11 @@ moduleFor( let Root = setComponentTemplate( precompileTemplate( - [ - '', - ' {{#unless state.hidden}}', - ' {{#let (ctx.consume) as |v|}}
{{v}}
{{/let}}', - ' {{/unless}}', - '
', - ].join('\n'), + ` + {{#unless state.hidden}} + {{#let (ctx.consume) as |v|}}
{{v}}
{{/let}} + {{/unless}} +
`, { strictMode: true, scope: () => ({ ctx, state }) } ), templateOnly() @@ -370,11 +364,9 @@ moduleFor( let Root = setComponentTemplate( precompileTemplate( - [ - '{{#unless state.hidden}}', - ' {{#let (ctx.consume) as |v|}}
{{v}}
{{/let}}
', - '{{/unless}}', - ].join('\n'), + `{{#unless state.hidden}} + {{#let (ctx.consume) as |v|}}
{{v}}
{{/let}}
+ {{/unless}}`, { strictMode: true, scope: () => ({ ctx, state }) } ), templateOnly() @@ -407,14 +399,12 @@ moduleFor( // consumer, so it must never override the outer @value="1". let Root = setComponentTemplate( precompileTemplate( - [ - '', - ' {{#unless state.hidden}}', - ' ', - ' {{/unless}}', - ' {{#let (ctx.consume) as |v|}}
{{v}}
{{/let}}', - '
', - ].join('\n'), + ` + {{#unless state.hidden}} + + {{/unless}} + {{#let (ctx.consume) as |v|}}
{{v}}
{{/let}} +
`, { strictMode: true, scope: () => ({ ctx, state }) } ), templateOnly() @@ -444,14 +434,12 @@ moduleFor( let Root = setComponentTemplate( precompileTemplate( - [ - '', - ' ', - ' {{#let (ctxOne.consume) as |a|}}
{{a}}
{{/let}}', - ' {{#let (ctxTwo.consume) as |b|}}
{{b}}
{{/let}}', - '
', - '
', - ].join('\n'), + ` + + {{#let (ctxOne.consume) as |a|}}
{{a}}
{{/let}} + {{#let (ctxTwo.consume) as |b|}}
{{b}}
{{/let}} +
+
`, { strictMode: true, scope: () => ({ ctxOne, ctxTwo }) } ), templateOnly() From 44566b9a5861774c57bb21e6428e97b80b3afc13 Mon Sep 17 00:00:00 2001 From: NullVoxPopuli <199018+NullVoxPopuli@users.noreply.github.com> Date: Sat, 23 May 2026 00:12:03 -0400 Subject: [PATCH 07/15] makeContext: use isNewable check; add 7 gap tests Replace the `/^class[\s{]/` toString sniff with the prototype-based `isNewable` pattern lifted from ember-primitives (ember-primitives/src/utils.ts): proto !== undefined && proto.constructor === fn Arrow functions have no `prototype` and fail this check; classes (and old-style constructor functions) pass. Robust under transpilation, where the toString check would silently regress. Adds two new test modules covering the previously-identified gaps: extra-coverage: - class-form (`makeContext(SomeClass)`) is actually invoked with `new` (guarded by an in-constructor `new.target === undefined` check, so any regression to plain invocation fails the test) - consume() works inside a plain function helper (`defineSimpleHelper`) - consume() works inside a modifier (`defineSimpleModifier`) - explicit `@value={{undefined}}` provides undefined (does NOT throw "no provider") - explicit `@value={{null}}` provides null - multiple consume() calls in the same template return the same instance cross-renderComponent isolation: - two independent `renderComponent` calls into separate sub-elements do not share scope state: a in one tree is invisible from the other Engine / `{{outlet}}` boundaries remain explicitly out of scope -- they need design discussion, not just a test. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../-internals/glimmer/lib/make-context.ts | 15 +- .../integration/render-tree-scope-test.ts | 288 ++++++++++++++++++ 2 files changed, 297 insertions(+), 6 deletions(-) diff --git a/packages/@ember/-internals/glimmer/lib/make-context.ts b/packages/@ember/-internals/glimmer/lib/make-context.ts index 4207af66095..2d2ade7fc21 100644 --- a/packages/@ember/-internals/glimmer/lib/make-context.ts +++ b/packages/@ember/-internals/glimmer/lib/make-context.ts @@ -146,7 +146,7 @@ export function makeContext(factory: ContextFactory): Context { if (valueRef !== undefined) { read = () => valueForRef(valueRef); } else { - const factoryValue: T = isClassConstructor(factory) + const factoryValue: T = isNewable(factory) ? new (factory as new () => T)() : (factory as () => T)(); read = () => factoryValue; @@ -164,10 +164,13 @@ export function makeContext(factory: ContextFactory): Context { // Per-instance behavior is parameterized via the closure in makeContext. const PROVIDE_TEMPLATE = precompileTemplate('{{yield}}'); -function isClassConstructor(fn: unknown): boolean { +function isNewable(fn: unknown): boolean { + // From ember-primitives' `isNewable` (see ember-primitives/src/utils.ts): + // arrow functions have no `prototype` at all, so they fail this check; + // classes (and old-style constructor functions) have a `prototype` whose + // `constructor` points back to themselves. This is robust under + // transpilation, unlike a `Function.prototype.toString` sniff. if (typeof fn !== 'function') return false; - // ES classes serialize starting with `class`; arrow / regular functions do - // not. This is the standard "is class" sniff and is good enough for our - // dual-overload accepting either form. - return /^class[\s{]/.test(Function.prototype.toString.call(fn)); + const proto = (fn as { prototype?: { constructor?: unknown } }).prototype; + return proto !== undefined && proto !== null && proto.constructor === fn; } diff --git a/packages/@ember/-internals/glimmer/tests/integration/render-tree-scope-test.ts b/packages/@ember/-internals/glimmer/tests/integration/render-tree-scope-test.ts index b7934eb8797..a1182810ef7 100644 --- a/packages/@ember/-internals/glimmer/tests/integration/render-tree-scope-test.ts +++ b/packages/@ember/-internals/glimmer/tests/integration/render-tree-scope-test.ts @@ -563,3 +563,291 @@ moduleFor( } } ); + +/** + * Extra-coverage suite for behaviors not exercised by the EPCC port: + * + * - class-form detection (`makeContext(SomeClass)` invokes via `new`) + * - consume() from a plain function helper + * - consume() from a modifier + * - explicit @value={{undefined}} / @value={{null}} + * - cross-renderComponent isolation + * - multiple consume() calls in the same template return the same identity + */ +import { defineSimpleHelper, defineSimpleModifier } from 'internal-test-helpers'; + +moduleFor( + 'RFC #1154 -- makeContext: extra coverage', + class extends MakeContextTestCase { + afterEach() { + runDestroy(this); + } + + '@test class-form: makeContext(SomeClass) invokes via `new`'(assert: QUnit['assert']) { + let constructed = 0; + class Counter { + n: number; + constructor() { + constructed++; + this.n = 42; + // `new.target` is only defined when called via `new`. If the + // detection regressed to plain invocation, this would be undefined. + if (new.target === undefined) { + throw new Error('Counter was invoked without `new`'); + } + } + } + const counter = makeContext(Counter); + + let observed: number | undefined; + class Reader extends GlimmerishComponent { + constructor(owner: Owner, args: Record) { + super(owner, args); + observed = counter.consume().n; + } + } + setComponentTemplate(precompileTemplate(''), Reader); + + let Root = setComponentTemplate( + precompileTemplate('', { + strictMode: true, + scope: () => ({ counter, Reader }), + }), + templateOnly() + ); + + this.renderComponent(Root); + assert.strictEqual(constructed, 1, 'Counter constructed once'); + assert.strictEqual(observed, 42, 'consumer saw the constructed instance'); + } + + '@test consume() works inside a plain function helper'(assert: QUnit['assert']) { + const ctx = makeContext(() => 'default'); + + // A genuine helper -- not just `(ctx.consume)` in a let-binding. + // This exercises that consume() can be called from a function whose + // identity is wrapped by the helper manager, which is the case NVP + // explicitly motivates in the RFC ("helpers, modifiers, etc."). + const readContext = defineSimpleHelper(() => ctx.consume()); + + let Root = setComponentTemplate( + precompileTemplate( + '
{{(readContext)}}
', + { strictMode: true, scope: () => ({ ctx, readContext }) } + ), + templateOnly() + ); + + this.renderComponent(Root); + assert.strictEqual(this.element.querySelector('#content')?.textContent, 'from-helper'); + } + + '@test consume() works inside a modifier'(assert: QUnit['assert']) { + const ctx = makeContext(() => 'default'); + + let observed: string | undefined; + const stash = defineSimpleModifier((_element: Element) => { + observed = ctx.consume() as string; + }); + + let Root = setComponentTemplate( + precompileTemplate( + '
x
', + { strictMode: true, scope: () => ({ ctx, stash }) } + ), + templateOnly() + ); + + this.renderComponent(Root); + assert.strictEqual(observed, 'from-modifier', 'modifier saw the enclosing context'); + } + + '@test explicit @value={{undefined}} provides undefined (not "no provider")'( + assert: QUnit['assert'] + ) { + const ctx = makeContext(() => 'factory-default'); + + let observed: unknown = 'NOT_SET'; + class Reader extends GlimmerishComponent { + constructor(owner: Owner, args: Record) { + super(owner, args); + observed = ctx.consume(); + } + } + setComponentTemplate(precompileTemplate(''), Reader); + + // Explicit @value=undefined -- the consumer should see undefined, + // NOT throw "no provider" (the Provide *is* in the tree, it just + // chose to provide an undefined value). + let Root = setComponentTemplate( + precompileTemplate('', { + strictMode: true, + scope: () => ({ ctx, Reader }), + }), + templateOnly() + ); + + this.renderComponent(Root); + assert.strictEqual(observed, undefined, 'consumer saw the explicit undefined value'); + } + + '@test explicit @value={{null}} provides null'(assert: QUnit['assert']) { + const ctx = makeContext(() => 'factory-default'); + + let observed: unknown = 'NOT_SET'; + 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, null, 'consumer saw the explicit null value'); + } + + '@test multiple consume() calls in the same template return the same identity'( + assert: QUnit['assert'] + ) { + // Each consume() walks up the scope chain. They should both find the + // same provider entry and return the same value. For class-instance + // factories that means strict-equal identity. + class State { + marker = Symbol('state'); + } + const ctx = makeContext(State); + + let firstSeen: State | undefined; + let secondSeen: State | undefined; + class FirstReader extends GlimmerishComponent { + constructor(owner: Owner, args: Record) { + super(owner, args); + firstSeen = ctx.consume(); + } + } + class SecondReader extends GlimmerishComponent { + constructor(owner: Owner, args: Record) { + super(owner, args); + secondSeen = ctx.consume(); + } + } + setComponentTemplate(precompileTemplate(''), FirstReader); + setComponentTemplate(precompileTemplate(''), SecondReader); + + let Root = setComponentTemplate( + precompileTemplate('', { + strictMode: true, + scope: () => ({ ctx, FirstReader, SecondReader }), + }), + templateOnly() + ); + + this.renderComponent(Root); + assert.ok(firstSeen, 'first reader observed'); + assert.ok(secondSeen, 'second reader observed'); + assert.strictEqual(firstSeen, secondSeen, 'both consumers see the same instance'); + } + } +); + +/** + * Independent renderComponent trees must not share scope state. This sits + * in its own module so each test's `renderComponent` call is independent + * (the base class wires `into: #qunit-fixture`, so we render two trees + * into separate sub-elements within the same fixture). + */ +moduleFor( + 'RFC #1154 -- makeContext: cross-renderComponent isolation', + class extends MakeContextTestCase { + afterEach() { + runDestroy(this); + } + + "@test separate renderComponent calls do not see each other's providers"( + assert: QUnit['assert'] + ) { + const ctx = makeContext(() => 'factory-default'); + + let bareError: Error | undefined; + class BareReader extends GlimmerishComponent { + constructor(owner: Owner, args: Record) { + super(owner, args); + try { + ctx.consume(); + } catch (e) { + bareError = e as Error; + } + } + } + setComponentTemplate(precompileTemplate(''), BareReader); + + let providedSeen: string | undefined; + class ProvidedReader extends GlimmerishComponent { + constructor(owner: Owner, args: Record) { + super(owner, args); + providedSeen = ctx.consume() as string; + } + } + setComponentTemplate(precompileTemplate(''), ProvidedReader); + + // Two independent component trees, both rendered into the fixture + // but in separate `renderComponent` calls. The first has no + // ; the second is wrapped in one. The presence of a + // in tree #2 must not bleed into tree #1. + const fixture = this.element; + const slotA = document.createElement('div'); + const slotB = document.createElement('div'); + fixture.appendChild(slotA); + fixture.appendChild(slotB); + + let TreeA = setComponentTemplate( + precompileTemplate('', { + strictMode: true, + scope: () => ({ BareReader }), + }), + templateOnly() + ); + let TreeB = setComponentTemplate( + precompileTemplate('', { + strictMode: true, + scope: () => ({ ctx, ProvidedReader }), + }), + templateOnly() + ); + + run(() => { + const { owner } = this; + const a = renderComponent(TreeA, { + owner, + env: { document, isInteractive: true, hasDOM: true }, + into: slotA, + }); + const b = renderComponent(TreeB, { + owner, + env: { document, isInteractive: true, hasDOM: true }, + into: slotB, + }); + registerDestructor(this, () => { + a.destroy(); + b.destroy(); + }); + }); + + assert.ok(bareError, 'tree A: no provider, consume() threw'); + assert.ok( + /No matching ``/.test(bareError?.message ?? ''), + `error mentions missing provider, got: ${bareError?.message}` + ); + assert.strictEqual(providedSeen, 'B', 'tree B: own visible'); + } + } +); From ed04b4d46baab9f91e4229ea0aab6e3add142be2 Mon Sep 17 00:00:00 2001 From: NullVoxPopuli <199018+NullVoxPopuli@users.noreply.github.com> Date: Sat, 23 May 2026 00:17:44 -0400 Subject: [PATCH 08/15] makeContext: pin down modifier-install scope limitation (not a regression) The "consume() inside modifier" test asserted the wrong thing. Modifier install runs during `transaction.commit()`, which fires AFTER the render frame has popped its scope stack -- so consume() inside a modifier callback legitimately throws "outside of rendering". Rewrite the test to assert that throw and document it as a known limitation. This pins down the current behavior so a future fix (e.g. re-pushing the enclosing component's scope for the duration of modifier install) doesn't break silently. RFC #1154 motivates "all invokables" -- modifier support is a follow-up worth its own design discussion, since it interacts with the transaction model. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../integration/render-tree-scope-test.ts | 25 ++++++++++++++++--- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/packages/@ember/-internals/glimmer/tests/integration/render-tree-scope-test.ts b/packages/@ember/-internals/glimmer/tests/integration/render-tree-scope-test.ts index a1182810ef7..5dcea58c19e 100644 --- a/packages/@ember/-internals/glimmer/tests/integration/render-tree-scope-test.ts +++ b/packages/@ember/-internals/glimmer/tests/integration/render-tree-scope-test.ts @@ -642,12 +642,25 @@ moduleFor( assert.strictEqual(this.element.querySelector('#content')?.textContent, 'from-helper'); } - '@test consume() works inside a modifier'(assert: QUnit['assert']) { + '@test KNOWN LIMITATION: consume() inside a modifier install throws'(assert: QUnit['assert']) { + // Modifier install runs during `transaction.commit()`, which fires + // *after* the render frame has popped its scope stack. So calling + // consume() inside a modifier callback sees an empty scope and + // throws "outside of rendering". + // + // This pins down the current behavior so a future fix (e.g. wrapping + // modifier install in the enclosing component's scope) doesn't break + // silently. RFC #1154 motivates "all invokables" -- modifiers are + // an extension worth its own follow-up. const ctx = makeContext(() => 'default'); - let observed: string | undefined; + let caught: Error | undefined; const stash = defineSimpleModifier((_element: Element) => { - observed = ctx.consume() as string; + try { + ctx.consume(); + } catch (e) { + caught = e as Error; + } }); let Root = setComponentTemplate( @@ -659,7 +672,11 @@ moduleFor( ); this.renderComponent(Root); - assert.strictEqual(observed, 'from-modifier', 'modifier saw the enclosing context'); + assert.ok(caught, 'consume() in modifier install threw'); + assert.ok( + /outside of rendering/.test(caught?.message ?? ''), + `error mentions outside-of-rendering, got: ${caught?.message}` + ); } '@test explicit @value={{undefined}} provides undefined (not "no provider")'( From 7b3be711e9b39180e8158ea5312ff76ffbd5b18b Mon Sep 17 00:00:00 2001 From: NullVoxPopuli <199018+NullVoxPopuli@users.noreply.github.com> Date: Sun, 7 Jun 2026 00:05:46 -0400 Subject: [PATCH 09/15] makeContext: drop the factory arg, provide value via `makeContext` no longer takes a class/factory. It takes a type parameter only (`makeContext()`) and the value is supplied at render time through ``. This removes the dual class/factory forms (and the `isNewable` detection + `ContextFactory` type) in favor of a single, explicit way to provide a value. - `consume()` still throws outside rendering and when no provider exists. - Omitting `@value` (or passing undefined/null) provides that value rather than throwing -- the provider is in the tree, it just has no value. - The `@value` binding stays reactive via `valueForRef`. Rewrote the integration suite to the `@value` API and added an explicit smoke-test module. All 23 tests pass in headless Chrome; tsc, eslint, prettier, and docs coverage are clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/@ember/-internals/glimmer/index.ts | 2 +- .../-internals/glimmer/lib/make-context.ts | 93 ++---- .../integration/render-tree-scope-test.ts | 293 ++++++++---------- packages/@ember/renderer/index.ts | 24 +- 4 files changed, 180 insertions(+), 232 deletions(-) diff --git a/packages/@ember/-internals/glimmer/index.ts b/packages/@ember/-internals/glimmer/index.ts index a3d20e47d35..d804c61ac40 100644 --- a/packages/@ember/-internals/glimmer/index.ts +++ b/packages/@ember/-internals/glimmer/index.ts @@ -475,7 +475,7 @@ export { type View, } from './lib/renderer'; // RFC #1154 -- render-tree-scoped context (provide/consume) -export { makeContext, type Context, type ContextFactory } from './lib/make-context'; +export { makeContext, type Context } from './lib/make-context'; export { getTemplate, setTemplate, diff --git a/packages/@ember/-internals/glimmer/lib/make-context.ts b/packages/@ember/-internals/glimmer/lib/make-context.ts index 2d2ade7fc21..718bc07d8da 100644 --- a/packages/@ember/-internals/glimmer/lib/make-context.ts +++ b/packages/@ember/-internals/glimmer/lib/make-context.ts @@ -11,7 +11,7 @@ import InternalComponent, { /** * The shape returned by `makeContext`. Use `Provide` in templates to bind a - * fresh value into the render tree and `consume` to read the nearest enclosing + * value into the render tree and `consume` to read the nearest enclosing * value. */ export interface Context { @@ -19,12 +19,10 @@ export interface Context { consume: () => T; } -export type ContextFactory = (new () => T) | (() => T); - // Internal entry shape stored in the render-tree scope. The key identifies the // context (closure-captured by makeContext); `read` returns the current -// provided value -- evaluated lazily so that, when `@value` is passed, the -// auto-tracking inside `valueForRef` makes consumers reactive to it. +// provided value -- evaluated lazily so that the auto-tracking inside +// `valueForRef` makes consumers reactive to the `@value` argument. interface ContextEntry { key: object; read: () => unknown; @@ -45,6 +43,11 @@ function isContextEntry(entry: unknown): entry is ContextEntry { * * [rfc]: https://github.com/emberjs/rfcs/pull/1154 * + * `makeContext` takes no value of its own -- it only establishes the + * *type* of the value (via a type parameter) and returns a `Provide` + * component plus a `consume` reader. The value is supplied at render time + * through ``. + * * @example * * ```gjs @@ -54,41 +57,32 @@ function isContextEntry(entry: unknown): entry is ContextEntry { * color = 'dark'; * } * - * const theme = makeContext(Theme); + * const theme = makeContext(); * * * ``` * - * Both factory forms are supported: - * - A class (`makeContext(SomeClass)`) — each `` constructs a fresh - * instance via `new SomeClass()`. - * - A factory function (`makeContext(() => value)`) — each `` - * invokes the factory to produce a fresh value. - * - * `` also accepts an optional `@value` argument. When passed, that - * value is provided to descendants instead of the factory's output, and - * consumers re-render automatically when the argument updates. - * - * Reactivity rules: - * - For factory-provided values, the *value* returned by the factory is not - * itself tracked. Put `@tracked` state on it for reactivity. - * - For `@value`-provided values, the binding is reactive to argument - * updates as you'd expect. + * Reactivity: the `@value` binding is reactive. When the argument passed to + * `` updates, consumers re-render automatically. If `@value` is a + * stable object, mutating its `@tracked` fields likewise re-renders + * consumers. * * `consume()` throws if it is called outside of rendering, or if no * matching `` exists higher in the render tree. This is @@ -100,14 +94,12 @@ function isContextEntry(entry: unknown): entry is ContextEntry { * @method makeContext * @static * @for @ember/renderer - * @param {Function} factory A zero-arg class or factory function that - * produces a fresh value each time `` is rendered (and `@value` - * was not passed). - * @returns {Object} An object with `Provide` (a component) and `consume` - * (a function/helper that reads the nearest provided value). + * @returns {Object} An object with `Provide` (a component that takes a + * `@value`) and `consume` (a function/helper that reads the nearest + * provided value). * @public */ -export function makeContext(factory: ContextFactory): Context { +export function makeContext(): Context { // Identity-based key, so multiple contexts can coexist on the same scope // without name collisions. Held in the closure -- not exported. const key = {}; @@ -125,7 +117,7 @@ export function makeContext(factory: ContextFactory): Context { } } 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 in the render tree. Wrap consumers in `...`, or provide a default at the application root.' ); } @@ -137,20 +129,14 @@ export function makeContext(factory: ContextFactory): Context { constructor(...args: ConstructorParameters) { super(...args); - // If `@value` was passed, store a lazy read that pulls the current - // value from the argument reference. `valueForRef` consumes tracking - // tags when called inside a tracking frame, so consumers re-render - // automatically when the argument updates. + // The provided value comes from `@value`. Store a lazy read that pulls + // the current value from the argument reference. `valueForRef` consumes + // tracking tags when called inside a tracking frame, so consumers + // re-render automatically when the argument updates. If `@value` was + // omitted, the provider still exists in the tree and provides + // `undefined` (consume() returns undefined rather than throwing). const valueRef = this.args.named['value']; - let read: () => unknown; - if (valueRef !== undefined) { - read = () => valueForRef(valueRef); - } else { - const factoryValue: T = isNewable(factory) - ? new (factory as new () => T)() - : (factory as () => T)(); - read = () => factoryValue; - } + const read = (): unknown => (valueRef === undefined ? undefined : valueForRef(valueRef)); const entry: ContextEntry = { key, read }; addToCurrentRenderScope(entry); @@ -163,14 +149,3 @@ export function makeContext(factory: ContextFactory): Context { // All Provide components share the same template: yield to the block. // Per-instance behavior is parameterized via the closure in makeContext. const PROVIDE_TEMPLATE = precompileTemplate('{{yield}}'); - -function isNewable(fn: unknown): boolean { - // From ember-primitives' `isNewable` (see ember-primitives/src/utils.ts): - // arrow functions have no `prototype` at all, so they fail this check; - // classes (and old-style constructor functions) have a `prototype` whose - // `constructor` points back to themselves. This is robust under - // transpilation, unlike a `Function.prototype.toString` sniff. - if (typeof fn !== 'function') return false; - const proto = (fn as { prototype?: { constructor?: unknown } }).prototype; - return proto !== undefined && proto !== null && proto.constructor === fn; -} diff --git a/packages/@ember/-internals/glimmer/tests/integration/render-tree-scope-test.ts b/packages/@ember/-internals/glimmer/tests/integration/render-tree-scope-test.ts index 5dcea58c19e..78a25ced4da 100644 --- a/packages/@ember/-internals/glimmer/tests/integration/render-tree-scope-test.ts +++ b/packages/@ember/-internals/glimmer/tests/integration/render-tree-scope-test.ts @@ -21,25 +21,24 @@ import type Owner from '@ember/owner'; /** * Coverage for `makeContext` (the user-facing API discussed in * https://github.com/emberjs/rfcs/pull/1154 -- NullVoxPopuli's - * `makeContext(Klass)` proposal returning `{ Provide, consume }`). + * `makeContext` proposal returning `{ Provide, consume }`). * - * The bulk of the substantive scenarios here are ported from - * `customerio/ember-provide-consume-context`'s test suite -- the prior-art - * implementation that NullVoxPopuli called out in the RFC. The intent is - * to pin down the *same behaviors* that production users of that library - * rely on (sibling isolation, conditionals, reactivity to value changes, - * etc.), translated to the makeContext API where: + * The API: * - * - The string `@key=` becomes a closure-captured `makeContext` identity. - * - `` becomes `` (optionally with - * `@value=`). - * - `` becomes `(myContext.consume)` (a function - * helper) or `myContext.consume()` in JS. + * - `makeContext()` takes no value -- the type parameter declares the + * shape, and the value is supplied at render time via ``. + * - `` provides a value to descendants. + * - `(myContext.consume)` (a function helper) or `myContext.consume()` in + * JS reads the nearest provided value. * - * Where the two APIs intentionally diverge (e.g. EPCC's `getContext` - * returns `undefined` for missing context, whereas makeContext throws per - * NVP's "reduce harm" clarification), the test is rewritten to assert the - * makeContext behavior. + * The substantive scenarios here are ported from + * `customerio/ember-provide-consume-context`'s test suite -- the prior-art + * implementation that NullVoxPopuli called out in the RFC -- to pin down the + * same behaviors production users rely on (sibling isolation, conditionals, + * reactivity to value changes, etc.). Where the two APIs intentionally + * diverge (EPCC's `getContext` returns `undefined` for missing context, + * whereas makeContext throws per NVP's "reduce harm" clarification), the + * test asserts the makeContext behavior. */ class MakeContextTestCase extends AbstractStrictTestCase { @@ -69,17 +68,49 @@ class MakeContextTestCase extends AbstractStrictTestCase { } moduleFor( - 'RFC #1154 -- makeContext: API surface', + 'RFC #1154 -- makeContext: smoke test', class extends MakeContextTestCase { afterEach() { runDestroy(this); } - '@test consume() throws if called outside of rendering'(assert: QUnit['assert']) { + '@test provide a value, consume it, and it renders'(assert: QUnit['assert']) { class Theme { color = 'dark'; } - const theme = makeContext(Theme); + const theme = makeContext(); + const value = new Theme(); + + let Root = setComponentTemplate( + precompileTemplate( + '{{#let (theme.consume) as |t|}}
{{t.color}}
{{/let}}
', + { + strictMode: true, + scope: () => ({ theme, value }), + } + ), + templateOnly() + ); + + this.renderComponent(Root); + assert.strictEqual( + this.element.querySelector('#content')?.textContent, + 'dark', + 'consumer rendered the provided value' + ); + } + } +); + +moduleFor( + 'RFC #1154 -- makeContext: API surface', + class extends MakeContextTestCase { + afterEach() { + runDestroy(this); + } + + '@test consume() throws if called outside of rendering'(assert: QUnit['assert']) { + const theme = makeContext<{ color: string }>(); assert.throws( () => theme.consume(), @@ -89,10 +120,7 @@ moduleFor( } '@test consume() throws when no exists in the tree'(assert: QUnit['assert']) { - class Theme { - color = 'dark'; - } - const theme = makeContext(Theme); + const theme = makeContext<{ color: string }>(); let error: Error | undefined; class Reader extends GlimmerishComponent { @@ -124,49 +152,16 @@ moduleFor( ); } - '@test factory form: makeContext(() => value)'(assert: QUnit['assert']) { - // Plain factory -- no class. - let made = 0; - const cfg = makeContext(() => { - made++; - return { label: 'hello' }; - }); - - let observed: string | undefined; - class Reader extends GlimmerishComponent { - constructor(owner: Owner, args: Record) { - super(owner, args); - observed = cfg.consume().label; - } - } - setComponentTemplate(precompileTemplate('r'), Reader); - - let Root = setComponentTemplate( - precompileTemplate('', { - strictMode: true, - scope: () => ({ Reader, cfg }), - }), - templateOnly() - ); - - this.renderComponent(Root); - - assert.strictEqual(made, 1, 'factory ran exactly once'); - assert.strictEqual(observed, 'hello', 'consumer saw the factory-produced value'); - } - '@test (context.consume) is usable as a template helper'(assert: QUnit['assert']) { - class Theme { - color = 'dark'; - } - const theme = makeContext(Theme); + const theme = makeContext<{ color: string }>(); + const value = { color: 'dark' }; let Root = setComponentTemplate( precompileTemplate( - '{{#let (theme.consume) as |t|}}{{t.color}}{{/let}}', + '{{#let (theme.consume) as |t|}}{{t.color}}{{/let}}', { strictMode: true, - scope: () => ({ theme }), + scope: () => ({ theme, value }), } ), templateOnly() @@ -192,11 +187,11 @@ moduleFor( } '@test a consumer can read context'(assert: QUnit['assert']) { - const ctx = makeContext(() => '5'); + const ctx = makeContext(); let Root = setComponentTemplate( precompileTemplate( - '{{#let (ctx.consume) as |v|}}
{{v}}
{{/let}}
', + '{{#let (ctx.consume) as |v|}}
{{v}}
{{/let}}
', { strictMode: true, scope: () => ({ ctx }) } ), templateOnly() @@ -207,7 +202,7 @@ moduleFor( } '@test a consumer reads from the closest provider'(assert: QUnit['assert']) { - const ctx = makeContext(() => '0'); + const ctx = makeContext(); let Root = setComponentTemplate( precompileTemplate( @@ -232,7 +227,7 @@ moduleFor( @tracked count = 1; } const state = new State(); - const ctx = makeContext(() => 0); + const ctx = makeContext(); let Root = setComponentTemplate( precompileTemplate( @@ -252,8 +247,8 @@ moduleFor( } "@test a consumer can't access a context it isn't nested in"(assert: QUnit['assert']) { - const ctxA = makeContext(() => 'missing'); - const ctxB = makeContext(() => 'missing'); + const ctxA = makeContext(); + const ctxB = makeContext(); let error: Error | undefined; class Reader extends GlimmerishComponent { @@ -288,7 +283,7 @@ moduleFor( } '@test sibling Provides with the same context do not bleed'(assert: QUnit['assert']) { - const ctx = makeContext(() => 'default'); + const ctx = makeContext(); let Root = setComponentTemplate( precompileTemplate( @@ -312,7 +307,7 @@ moduleFor( @tracked hidden = false; } const state = new State(); - const ctx = makeContext(() => 0); + const ctx = makeContext(); let Root = setComponentTemplate( precompileTemplate( @@ -360,7 +355,7 @@ moduleFor( @tracked hidden = false; } const state = new State(); - const ctx = makeContext(() => 'default'); + const ctx = makeContext(); let Root = setComponentTemplate( precompileTemplate( @@ -393,7 +388,7 @@ moduleFor( @tracked hidden = true; } const state = new State(); - const ctx = makeContext(() => 'default'); + const ctx = makeContext(); // The inner ctx.Provide @value="2" is in a sibling subtree of the // consumer, so it must never override the outer @value="1". @@ -429,8 +424,8 @@ moduleFor( } '@test multiple distinct contexts can be nested'(assert: QUnit['assert']) { - const ctxOne = makeContext(() => '0'); - const ctxTwo = makeContext(() => '0'); + const ctxOne = makeContext(); + const ctxTwo = makeContext(); let Root = setComponentTemplate( precompileTemplate( @@ -450,36 +445,26 @@ moduleFor( assert.strictEqual(this.element.querySelector('#content-2')?.textContent, '2'); } - '@test @tracked state on a factory-provided class instance is reactive'( - assert: QUnit['assert'] - ) { + '@test @tracked state on a provided class instance is reactive'(assert: QUnit['assert']) { class Counter { @tracked count = 0; } - // Capture the instance via the factory itself -- the factory runs - // exactly once per , so this avoids needing a separate - // capturing component. - let captured: Counter | undefined; - const counter = makeContext(() => { - const c = new Counter(); - captured = c; - return c; - }); + const counter = makeContext(); + const instance = new Counter(); let Root = setComponentTemplate( precompileTemplate( - '{{#let (counter.consume) as |c|}}
{{c.count}}
{{/let}}
', - { strictMode: true, scope: () => ({ counter }) } + '{{#let (counter.consume) as |c|}}
{{c.count}}
{{/let}}
', + { strictMode: true, scope: () => ({ counter, instance }) } ), templateOnly() ); this.renderComponent(Root); assert.strictEqual(this.element.querySelector('#content')?.textContent, '0'); - assert.ok(captured, 'factory produced the instance'); run(() => { - captured!.count = 5; + instance.count = 5; }); assert.strictEqual(this.element.querySelector('#content')?.textContent, '5'); } @@ -490,13 +475,13 @@ moduleFor( // Mirrors EPCC's "a consumer can read context during initialization": // when the consumer is a class component, its constructor should see // the enclosing provider's value (not throw, not see a stale one). - const ctx = makeContext(() => 'wrong'); + const ctx = makeContext(); let observed: string | undefined; class Reader extends GlimmerishComponent { constructor(owner: Owner, args: Record) { super(owner, args); - observed = ctx.consume() as string; + observed = ctx.consume(); } } setComponentTemplate(precompileTemplate('done'), Reader); @@ -513,26 +498,26 @@ moduleFor( assert.strictEqual(observed, 'provided'); } - '@test factory-provided value is stable across the same Provide re-render'( + '@test a provided object identity is stable across the same Provide re-render'( assert: QUnit['assert'] ) { - // EPCC analogue: providing a class instance preserves identity. If a - // sibling tracked re-render happens, the same instance should be - // re-yielded -- not a new one. This is important for downstream code - // that uses identity (e.g. caching, refs). + // Providing a stable object preserves identity. If a sibling tracked + // re-render happens, the same instance should be re-yielded -- not a + // new one. This matters for downstream code that uses identity (e.g. + // caching, refs). class State { @tracked tick = 0; } const state = new State(); - let count = 0; - const ctx = makeContext(() => ({ id: count++ })); + const value = { id: 1 }; + const ctx = makeContext<{ id: number }>(); let observed: object[] = []; class Reader extends GlimmerishComponent { constructor(owner: Owner, args: Record) { super(owner, args); - observed.push(ctx.consume() as object); + observed.push(ctx.consume()); } } setComponentTemplate(precompileTemplate(''), Reader); @@ -540,26 +525,25 @@ moduleFor( let Root = setComponentTemplate( precompileTemplate( // The bare {{state.tick}} consumes the tracked tag so toggling it - // forces the surrounding region to re-render, but the - // itself doesn't re-instantiate the factory. - '{{state.tick}}', - { strictMode: true, scope: () => ({ ctx, state, Reader }) } + // forces the surrounding region to re-render. + '{{state.tick}}', + { strictMode: true, scope: () => ({ ctx, state, value, Reader }) } ), templateOnly() ); this.renderComponent(Root); const first = observed[0]; - assert.ok(first, 'reader observed the value once'); + assert.strictEqual(first, value, 'reader observed the provided object'); run(() => { state.tick = 1; }); - // Reader's constructor only fires once -- so we don't get a second - // observed entry. The real guarantee here is that the factory only - // ran once: `count` must be 1. - assert.strictEqual(count, 1, 'factory was not re-invoked on parent re-render'); + // Reader's constructor only fires once, so we don't get a second + // observed entry -- but the value it saw was the stable instance. + assert.strictEqual(observed.length, 1, 'reader constructed once'); + assert.strictEqual(first, value, 'provided object identity is stable'); } } ); @@ -567,7 +551,6 @@ moduleFor( /** * Extra-coverage suite for behaviors not exercised by the EPCC port: * - * - class-form detection (`makeContext(SomeClass)` invokes via `new`) * - consume() from a plain function helper * - consume() from a modifier * - explicit @value={{undefined}} / @value={{null}} @@ -583,46 +566,8 @@ moduleFor( runDestroy(this); } - '@test class-form: makeContext(SomeClass) invokes via `new`'(assert: QUnit['assert']) { - let constructed = 0; - class Counter { - n: number; - constructor() { - constructed++; - this.n = 42; - // `new.target` is only defined when called via `new`. If the - // detection regressed to plain invocation, this would be undefined. - if (new.target === undefined) { - throw new Error('Counter was invoked without `new`'); - } - } - } - const counter = makeContext(Counter); - - let observed: number | undefined; - class Reader extends GlimmerishComponent { - constructor(owner: Owner, args: Record) { - super(owner, args); - observed = counter.consume().n; - } - } - setComponentTemplate(precompileTemplate(''), Reader); - - let Root = setComponentTemplate( - precompileTemplate('', { - strictMode: true, - scope: () => ({ counter, Reader }), - }), - templateOnly() - ); - - this.renderComponent(Root); - assert.strictEqual(constructed, 1, 'Counter constructed once'); - assert.strictEqual(observed, 42, 'consumer saw the constructed instance'); - } - '@test consume() works inside a plain function helper'(assert: QUnit['assert']) { - const ctx = makeContext(() => 'default'); + const ctx = makeContext(); // A genuine helper -- not just `(ctx.consume)` in a let-binding. // This exercises that consume() can be called from a function whose @@ -652,7 +597,7 @@ moduleFor( // modifier install in the enclosing component's scope) doesn't break // silently. RFC #1154 motivates "all invokables" -- modifiers are // an extension worth its own follow-up. - const ctx = makeContext(() => 'default'); + const ctx = makeContext(); let caught: Error | undefined; const stash = defineSimpleModifier((_element: Element) => { @@ -682,7 +627,7 @@ moduleFor( '@test explicit @value={{undefined}} provides undefined (not "no provider")'( assert: QUnit['assert'] ) { - const ctx = makeContext(() => 'factory-default'); + const ctx = makeContext(); let observed: unknown = 'NOT_SET'; class Reader extends GlimmerishComponent { @@ -708,8 +653,34 @@ moduleFor( assert.strictEqual(observed, undefined, 'consumer saw the explicit undefined value'); } + '@test omitting @value provides undefined (not "no provider")'(assert: QUnit['assert']) { + const ctx = makeContext(); + + let observed: unknown = 'NOT_SET'; + class Reader extends GlimmerishComponent { + constructor(owner: Owner, args: Record) { + super(owner, args); + observed = ctx.consume(); + } + } + setComponentTemplate(precompileTemplate(''), Reader); + + // No @value at all -- the Provide is still in the tree, so consume() + // returns undefined rather than throwing. + let Root = setComponentTemplate( + precompileTemplate('', { + strictMode: true, + scope: () => ({ ctx, Reader }), + }), + templateOnly() + ); + + this.renderComponent(Root); + assert.strictEqual(observed, undefined, 'consumer saw undefined when @value omitted'); + } + '@test explicit @value={{null}} provides null'(assert: QUnit['assert']) { - const ctx = makeContext(() => 'factory-default'); + const ctx = makeContext(); let observed: unknown = 'NOT_SET'; class Reader extends GlimmerishComponent { @@ -736,12 +707,13 @@ moduleFor( assert: QUnit['assert'] ) { // Each consume() walks up the scope chain. They should both find the - // same provider entry and return the same value. For class-instance - // factories that means strict-equal identity. + // same provider entry and return the same value -- strict-equal + // identity for a provided object. class State { marker = Symbol('state'); } - const ctx = makeContext(State); + const ctx = makeContext(); + const instance = new State(); let firstSeen: State | undefined; let secondSeen: State | undefined; @@ -761,10 +733,13 @@ moduleFor( setComponentTemplate(precompileTemplate(''), SecondReader); let Root = setComponentTemplate( - precompileTemplate('', { - strictMode: true, - scope: () => ({ ctx, FirstReader, SecondReader }), - }), + precompileTemplate( + '', + { + strictMode: true, + scope: () => ({ ctx, instance, FirstReader, SecondReader }), + } + ), templateOnly() ); @@ -792,7 +767,7 @@ moduleFor( "@test separate renderComponent calls do not see each other's providers"( assert: QUnit['assert'] ) { - const ctx = makeContext(() => 'factory-default'); + const ctx = makeContext(); let bareError: Error | undefined; class BareReader extends GlimmerishComponent { @@ -811,7 +786,7 @@ moduleFor( class ProvidedReader extends GlimmerishComponent { constructor(owner: Owner, args: Record) { super(owner, args); - providedSeen = ctx.consume() as string; + providedSeen = ctx.consume(); } } setComponentTemplate(precompileTemplate(''), ProvidedReader); diff --git a/packages/@ember/renderer/index.ts b/packages/@ember/renderer/index.ts index 5fa5eedb203..cd5fff5d172 100644 --- a/packages/@ember/renderer/index.ts +++ b/packages/@ember/renderer/index.ts @@ -88,11 +88,11 @@ export { renderComponent } from '@ember/-internals/glimmer/lib/renderer'; * See [RFC #1154](https://github.com/emberjs/rfcs/pull/1154) and the original * [Context RFC #975](https://github.com/emberjs/rfcs/pull/975). * - * `makeContext` returns an object with: + * `makeContext` takes no value of its own — it only establishes the *type* + * of the value (via a type parameter) and returns an object with: * - * - `Provide`: a component that, on every render, produces a fresh value - * from the given class or factory and exposes it to every descendant in - * the block. + * - `Provide`: a component that exposes its `@value` argument to every + * descendant in the block. * - `consume()`: a function (also usable as a template helper) that returns * the nearest enclosing provided value. **Throws** if there is no * matching provider higher in the render tree, or if called outside of @@ -105,12 +105,12 @@ export { renderComponent } from '@ember/-internals/glimmer/lib/renderer'; * color = 'dark'; * } * - * const theme = makeContext(Theme); + * const theme = makeContext(); * * * ``` * - * Reactivity: the *value* returned by the factory is not itself tracked, - * but `@tracked` state on it is — mutating tracked fields invalidates - * consumers as expected. + * Reactivity: the `@value` binding is reactive. When the argument updates, + * consumers re-render; mutating `@tracked` fields on a stable provided + * object likewise invalidates consumers. * * @method makeContext * @static * @for @ember/renderer - * @param {Function} factory A zero-arg class or factory function that - * produces a fresh value each time `` is rendered. * @returns {Object} `{ Provide, consume }` * @public */ export { makeContext } from '@ember/-internals/glimmer/lib/make-context'; -export type { Context, ContextFactory } from '@ember/-internals/glimmer/lib/make-context'; +export type { Context } from '@ember/-internals/glimmer/lib/make-context'; From c8d0efa21506f369859698b4b5f200e3e7089678 Mon Sep 17 00:00:00 2001 From: NullVoxPopuli <199018+NullVoxPopuli@users.noreply.github.com> Date: Sun, 7 Jun 2026 00:57:00 -0400 Subject: [PATCH 10/15] Re-export makeContext from @ember/helper (not @ember/renderer) makeContext is a helper-style API (it returns a `consume` usable as a template helper), so it belongs alongside the other helpers. Move the public export from @ember/renderer to @ember/helper and update the `@module`/`@for`/import-example docs accordingly. Add a type smoke test in type-tests/@ember/helper-tests.ts that pins the generic-only signature: `makeContext()` returns `Context`, `consume()` returns `T`, and passing a class is now a type error (`@ts-expect-error makeContext(Theme)`). type-check:internals, type-check:types, eslint, prettier, and docs coverage are clean; the 23 makeContext browser tests still pass. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../-internals/glimmer/lib/make-context.ts | 4 +- packages/@ember/helper/index.ts | 51 +++++++++++++++++++ packages/@ember/renderer/index.ts | 51 ------------------- type-tests/@ember/helper-tests.ts | 14 +++++ 4 files changed, 67 insertions(+), 53 deletions(-) diff --git a/packages/@ember/-internals/glimmer/lib/make-context.ts b/packages/@ember/-internals/glimmer/lib/make-context.ts index 718bc07d8da..34533e96646 100644 --- a/packages/@ember/-internals/glimmer/lib/make-context.ts +++ b/packages/@ember/-internals/glimmer/lib/make-context.ts @@ -1,5 +1,5 @@ /** - * @module @ember/renderer + * @module @ember/helper */ import { precompileTemplate } from '@ember/template-compilation'; import { addToCurrentRenderScope, getCurrentRenderScope } from '@glimmer/runtime/lib/render-scope'; @@ -51,7 +51,7 @@ function isContextEntry(entry: unknown): entry is ContextEntry { * @example * * ```gjs - * import { makeContext } from '@ember/renderer'; + * import { makeContext } from '@ember/helper'; * * class Theme { * color = 'dark'; diff --git a/packages/@ember/helper/index.ts b/packages/@ember/helper/index.ts index b68f226ce9f..06073299c33 100644 --- a/packages/@ember/helper/index.ts +++ b/packages/@ember/helper/index.ts @@ -730,3 +730,54 @@ export const not = glimmerNot as unknown as NotHelper; export interface NotHelper extends Opaque<'helper:not'> {} /* eslint-enable @typescript-eslint/no-empty-object-type */ + +/** + * Creates a render-tree-scoped context (provide/consume) for sharing values + * with descendant components without prop drilling. + * + * See [RFC #1154](https://github.com/emberjs/rfcs/pull/1154) and the original + * [Context RFC #975](https://github.com/emberjs/rfcs/pull/975). + * + * `makeContext` takes no value of its own — it only establishes the *type* + * of the value (via a type parameter) and returns an object with: + * + * - `Provide`: a component that exposes its `@value` argument to every + * descendant in the block. + * - `consume()`: a function (also usable as a template helper) that returns + * the nearest enclosing provided value. **Throws** if there is no + * matching provider higher in the render tree, or if called outside of + * rendering. + * + * ```gjs + * import { makeContext } from '@ember/helper'; + * + * class Theme { + * color = 'dark'; + * } + * + * const theme = makeContext(); + * + * + * ``` + * + * Reactivity: the `@value` binding is reactive. When the argument updates, + * consumers re-render; mutating `@tracked` fields on a stable provided + * object likewise invalidates consumers. + * + * @method makeContext + * @static + * @for @ember/helper + * @returns {Object} `{ Provide, consume }` + * @public + */ +export { makeContext } from '@ember/-internals/glimmer/lib/make-context'; + +export type { Context } from '@ember/-internals/glimmer/lib/make-context'; diff --git a/packages/@ember/renderer/index.ts b/packages/@ember/renderer/index.ts index cd5fff5d172..a8c5366ba5a 100644 --- a/packages/@ember/renderer/index.ts +++ b/packages/@ember/renderer/index.ts @@ -80,54 +80,3 @@ export { renderSettled } from '@ember/-internals/glimmer/lib/renderer'; * @public */ export { renderComponent } from '@ember/-internals/glimmer/lib/renderer'; - -/** - * Creates a render-tree-scoped context (provide/consume) for sharing values - * with descendant components without prop drilling. - * - * See [RFC #1154](https://github.com/emberjs/rfcs/pull/1154) and the original - * [Context RFC #975](https://github.com/emberjs/rfcs/pull/975). - * - * `makeContext` takes no value of its own — it only establishes the *type* - * of the value (via a type parameter) and returns an object with: - * - * - `Provide`: a component that exposes its `@value` argument to every - * descendant in the block. - * - `consume()`: a function (also usable as a template helper) that returns - * the nearest enclosing provided value. **Throws** if there is no - * matching provider higher in the render tree, or if called outside of - * rendering. - * - * ```gjs - * import { makeContext } from '@ember/renderer'; - * - * class Theme { - * color = 'dark'; - * } - * - * const theme = makeContext(); - * - * - * ``` - * - * Reactivity: the `@value` binding is reactive. When the argument updates, - * consumers re-render; mutating `@tracked` fields on a stable provided - * object likewise invalidates consumers. - * - * @method makeContext - * @static - * @for @ember/renderer - * @returns {Object} `{ Provide, consume }` - * @public - */ -export { makeContext } from '@ember/-internals/glimmer/lib/make-context'; - -export type { Context } from '@ember/-internals/glimmer/lib/make-context'; diff --git a/type-tests/@ember/helper-tests.ts b/type-tests/@ember/helper-tests.ts index 5a75c993384..59b72b6b9fc 100644 --- a/type-tests/@ember/helper-tests.ts +++ b/type-tests/@ember/helper-tests.ts @@ -9,6 +9,8 @@ import { type GetHelper, hash, type HashHelper, + makeContext, + type Context, uniqueId, type UniqueIdHelper, } from '@ember/helper'; @@ -20,3 +22,15 @@ expectTypeOf(fn).toEqualTypeOf(); expectTypeOf(get).toEqualTypeOf(); expectTypeOf(hash).toEqualTypeOf(); expectTypeOf(uniqueId).toEqualTypeOf(); + +// makeContext takes a type parameter, not a value -- the value is supplied +// at render time via ``. +class Theme { + color = 'dark'; +} +const theme = makeContext(); +expectTypeOf(theme).toEqualTypeOf>(); +expectTypeOf(theme.consume()).toEqualTypeOf(); + +// @ts-expect-error makeContext no longer accepts a class (or any value) argument +makeContext(Theme); From dd9de3504430b3740ec156e6efe14234a7129783 Mon Sep 17 00:00:00 2001 From: NullVoxPopuli <199018+NullVoxPopuli@users.noreply.github.com> Date: Sun, 7 Jun 2026 01:28:20 -0400 Subject: [PATCH 11/15] Add end-to-end makeContext smoke test to the app scenarios Adds `make-context-test.gjs` to the basic smoke-test app (run across the classic / embroider-webpack / embroider-vite scenarios). Unlike the @glimmer/runtime QUnit tests, this exercises makeContext through the published `@ember/helper` export in a real built app: - provide a value via `` and consume it - nearest-provider lookup with nested `` - consumer re-renders when `@value` changes (tracked) Co-Authored-By: Claude Opus 4.8 (1M context) --- smoke-tests/scenarios/basic-test.ts | 72 +++++++++++++++++++++++++++++ 1 file changed, 72 insertions(+) diff --git a/smoke-tests/scenarios/basic-test.ts b/smoke-tests/scenarios/basic-test.ts index dbe3ac0effd..25ee167725d 100644 --- a/smoke-tests/scenarios/basic-test.ts +++ b/smoke-tests/scenarios/basic-test.ts @@ -233,6 +233,78 @@ function basicTest(scenarios: Scenarios, appName: string) { }); }); `, + 'make-context-test.gjs': ` + import { module, test } from 'qunit'; + import { render, rerender } from '@ember/test-helpers'; + import { setupRenderingTest } from 'ember-qunit'; + import { makeContext } from '@ember/helper'; + import { tracked } from '@glimmer/tracking'; + + module('Integration | makeContext (RFC #1154)', function (hooks) { + setupRenderingTest(hooks); + + test('provide a value via @value and consume it', async function (assert) { + class Theme { + color = 'dark'; + } + const theme = makeContext(); + const value = new Theme(); + + await render( + + ); + + assert.dom('[data-test="color"]').hasText('dark'); + }); + + test('consumer reads the nearest provider', async function (assert) { + const ctx = makeContext(); + + await render( + + ); + + assert.dom('[data-test="outer"]').hasText('outer'); + assert.dom('[data-test="inner"]').hasText('inner'); + }); + + test('consumer re-renders when @value changes', async function (assert) { + class State { + @tracked count = 1; + } + const state = new State(); + const ctx = makeContext(); + + await render( + + ); + + assert.dom('[data-test="count"]').hasText('1'); + + state.count = 2; + await rerender(); + + assert.dom('[data-test="count"]').hasText('2'); + }); + }); + `, 'interactive-example-test.js': ` import { module, test } from 'qunit'; import { setupRenderingTest } from 'ember-qunit'; From e7202d95ae7c874c570473cf3347427e21701389 Mon Sep 17 00:00:00 2001 From: NullVoxPopuli <199018+NullVoxPopuli@users.noreply.github.com> Date: Sun, 7 Jun 2026 02:08:44 -0400 Subject: [PATCH 12/15] Simplify render-scope: context-specific provide/lookup, drop getScope/addToScope makeContext is the only consumer of the render-tree scope, and the only public API in this PR -- so the generic `getScope`/`addToScope` surface (the `RenderScope` view object, its `entries` iterable, the lazy view caching, and the `ContextEntry` type guard in make-context) was more machinery than the feature needs. Replace it with two context-specific helpers in @glimmer/runtime: provideRenderContext(key, read) // stores key -> lazy read lookupRenderContext(key) // consume() walks up for the nearest // undefined = outside rendering // null = no provider // fn = nearest read Each render node now holds a lazily-allocated `Map` instead of an untyped entry array, and `consume()` is a direct walk-up + read rather than iterating an `unknown` entry stream and type-guarding each item. The RenderScopeTracker lifecycle (create/enter/exit/willDestroy + the updating opcodes) is unchanged, as is all observable behavior. The public `RenderScope` interface and the `getCurrentScope`/ `addToCurrentScope` members are removed from @glimmer/interfaces; the tracker interface now exposes only the render-node lifecycle. tsc clean; all 23 makeContext browser tests still pass. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../-internals/glimmer/lib/make-context.ts | 40 ++--- .../interfaces/lib/runtime/environment.d.ts | 13 +- .../runtime/lib/compiled/opcodes/component.ts | 9 +- packages/@glimmer/runtime/lib/render-scope.ts | 146 ++++++++---------- 4 files changed, 85 insertions(+), 123 deletions(-) diff --git a/packages/@ember/-internals/glimmer/lib/make-context.ts b/packages/@ember/-internals/glimmer/lib/make-context.ts index 34533e96646..c9808ae679b 100644 --- a/packages/@ember/-internals/glimmer/lib/make-context.ts +++ b/packages/@ember/-internals/glimmer/lib/make-context.ts @@ -2,7 +2,7 @@ * @module @ember/helper */ import { precompileTemplate } from '@ember/template-compilation'; -import { addToCurrentRenderScope, getCurrentRenderScope } from '@glimmer/runtime/lib/render-scope'; +import { lookupRenderContext, provideRenderContext } from '@glimmer/runtime/lib/render-scope'; import { valueForRef } from '@glimmer/reference/lib/reference'; import InternalComponent, { type OpaqueInternalComponentConstructor, @@ -19,25 +19,6 @@ export interface Context { consume: () => T; } -// Internal entry shape stored in the render-tree scope. The key identifies the -// context (closure-captured by makeContext); `read` returns the current -// provided value -- evaluated lazily so that the auto-tracking inside -// `valueForRef` makes consumers reactive to the `@value` argument. -interface ContextEntry { - key: object; - read: () => unknown; -} - -function isContextEntry(entry: unknown): entry is ContextEntry { - return ( - typeof entry === 'object' && - entry !== null && - 'key' in entry && - 'read' in entry && - typeof (entry as ContextEntry).read === 'function' - ); -} - /** * Creates a render-tree-scoped context per [RFC #1154][rfc] discussion. * @@ -105,20 +86,18 @@ export function makeContext(): Context { const key = {}; function consume(): T { - let scope = getCurrentRenderScope(); - if (scope === undefined) { + let read = lookupRenderContext(key); + if (read === undefined) { throw new Error( '`consume()` was called outside of rendering. The render-tree scope is only available during rendering -- there is nothing to read.' ); } - for (let entry of scope.entries) { - if (isContextEntry(entry) && entry.key === key) { - return entry.read() as T; - } + 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.' + ); } - 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; } class Provide extends InternalComponent { @@ -138,8 +117,7 @@ export function makeContext(): Context { const valueRef = this.args.named['value']; const read = (): unknown => (valueRef === undefined ? undefined : valueForRef(valueRef)); - const entry: ContextEntry = { key, read }; - addToCurrentRenderScope(entry); + provideRenderContext(key, read); } } diff --git a/packages/@glimmer/interfaces/lib/runtime/environment.d.ts b/packages/@glimmer/interfaces/lib/runtime/environment.d.ts index 85defc876d6..6b828750194 100644 --- a/packages/@glimmer/interfaces/lib/runtime/environment.d.ts +++ b/packages/@glimmer/interfaces/lib/runtime/environment.d.ts @@ -47,25 +47,20 @@ export interface Environment { isInteractive: boolean; debugRenderTree?: DebugRenderTree | undefined; - // Public render-tree scope tracker (RFC #1154). Unlike debugRenderTree this - // is always present, because it backs the user-facing - // `getScope` / `addToScope` API. + // Render-tree scope tracker backing `makeContext` (RFC #1154). Unlike + // debugRenderTree this is always present, because it backs a real feature. + // Only the render-node lifecycle is part of this interface; provide/lookup + // happen through the module-level helpers in `@glimmer/runtime`. renderScope: RenderScopeTracker; // eslint-disable-next-line @typescript-eslint/no-explicit-any isArgumentCaptureError?: ((error: any) => boolean) | undefined; } -export interface RenderScope { - readonly entries: Iterable; -} - export interface RenderScopeTracker { create(bucket: object): void; enter(bucket: object): void; exit(): void; willDestroy(bucket: object): void; - getCurrentScope(): RenderScope | undefined; - addToCurrentScope(entry: unknown): 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 1964c82b04c..2ff61c786c3 100644 --- a/packages/@glimmer/runtime/lib/compiled/opcodes/component.ts +++ b/packages/@glimmer/runtime/lib/compiled/opcodes/component.ts @@ -388,9 +388,10 @@ APPEND_OPCODES.add(VM_CREATE_COMPONENT_OP, (vm, { op1: flags }) => { let { definition, manager, capabilities } = instance; // RFC #1154 -- push this component's render-tree scope before the user-land - // constructor runs, so that addToScope/getScope 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 runs, so that provide/consume (makeContext) 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. vm.env.renderScope.create(instance); vm.associateDestroyable(instance); registerDestructor(instance, () => { @@ -991,7 +992,7 @@ 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 call -// `getScope()` during their own update see the correct parent chain. +// `consume()` during their own update see the correct parent chain. class RenderScopeUpdateOpcode implements UpdatingOpcode { constructor(private bucket: object) {} diff --git a/packages/@glimmer/runtime/lib/render-scope.ts b/packages/@glimmer/runtime/lib/render-scope.ts index aeac66b5e45..a43a2abe516 100644 --- a/packages/@glimmer/runtime/lib/render-scope.ts +++ b/packages/@glimmer/runtime/lib/render-scope.ts @@ -2,64 +2,35 @@ import type { Nullable } from '@glimmer/interfaces'; import { StackImpl as Stack } from '@glimmer/util/lib/collections'; /** - * The publicly observable shape of a render-tree scope. See RFC #1154. + * Render-tree scope tracking that backs `makeContext` (RFC #1154). * - * `entries` walks the scope's own entries first, then up the parent chain -- - * which matches the user-visible "look up the render tree" semantics that - * provide/consume needs. + * This is intentionally narrow: the only thing it supports is "provide a value + * for a key at the current render node" and "look up the nearest provider of a + * key walking up the render tree". `makeContext` is the sole consumer, so there + * is no general-purpose `getScope`/`addToScope` surface -- just the two + * context-specific helpers exported at the bottom of this file. * - * Reactivity is intentionally _not_ part of this object: access happens during - * rendering, and entries only change when the surrounding render node itself + * Reactivity is intentionally not modeled here: lookups happen during + * rendering, and the providers visible at a node only change when that node * re-renders (or is torn down), so consumers do not need to subscribe. */ -export interface RenderScope { - readonly entries: Iterable; -} + +// A read function returns the currently-provided value for a context key. It is +// evaluated lazily so that auto-tracking inside it (e.g. reading `@value`) makes +// consumers reactive to the provided value. +type ContextRead = () => unknown; interface RenderScopeNode { parent: Nullable; - // Lazily allocated -- most render nodes never call addToScope, and we want - // iteration to skip them cheaply. - data: Nullable; - // Cached view; lazily created the first time getCurrentScope() is observed - // for this node so the same identity is returned for repeated calls. - view: Nullable; -} - -function* iterateUp(start: Nullable): Iterator { - let node = start; - while (node !== null) { - if (node.data !== null) { - // Most recent additions first -- consumers searching for "the nearest - // provider" can short-circuit as soon as they find a match. - for (let i = node.data.length - 1; i >= 0; i--) { - yield node.data[i]; - } - } - node = node.parent; - } -} - -function viewFor(node: RenderScopeNode): RenderScope { - if (node.view === null) { - node.view = { - get entries() { - return { - [Symbol.iterator]: () => iterateUp(node), - }; - }, - }; - } - return node.view; + // key -> read fn for the contexts provided at this node. Lazily allocated -- + // the overwhelming majority of render nodes never provide a context. + contexts: Nullable>; } /** - * Tracks the render-tree scope hierarchy for the public `getScope` / - * `addToScope` API proposed in RFC #1154. - * - * This mirrors the stack management of `DebugRenderTree`, but is always-on - * because it is part of the framework's public surface area (not a debug-only - * tool). + * Tracks the render-tree scope hierarchy. This mirrors the stack management of + * `DebugRenderTree`, but is always-on because it backs a real (non-debug) + * feature. */ export class RenderScopeTracker { // Stack of currently-rendering scope nodes; the top is the "current" scope. @@ -83,8 +54,7 @@ export class RenderScopeTracker { create(bucket: object): void { let node: RenderScopeNode = { parent: this.stack.current ?? null, - data: null, - view: null, + contexts: null, }; this.nodes.set(bucket, node); this.stack.push(node); @@ -110,42 +80,46 @@ export class RenderScopeTracker { /** * Called when the render node is torn down. The WeakMap will collect the - * node naturally; we just drop the explicit reference so the node's - * `data` (which may hold user state) can be released eagerly. + * node naturally; we just drop the explicit references so the provided + * values (which may hold user state) can be released eagerly. */ willDestroy(bucket: object): void { let node = this.nodes.get(bucket); if (node !== undefined) { - node.data = null; + node.contexts = null; node.parent = null; - node.view = null; this.nodes.delete(bucket); } } - /** - * Returns the publicly observable scope for the top of the stack, or - * `undefined` when called outside of rendering. - */ - getCurrentScope(): RenderScope | undefined { + get isRendering(): boolean { let node = this.stack.current; - if (node === undefined || node === null) { - return undefined; - } - return viewFor(node); + return node !== undefined && node !== null; } - addToCurrentScope(entry: unknown): void { + /** Provide `key`'s value (via the lazy `read`) at the current render node. */ + provide(key: object, read: ContextRead): void { let node = this.stack.current; if (node === undefined || node === null) { - throw new Error( - 'addToScope() may only be called while rendering -- there is no active scope.' - ); + throw new Error('Cannot provide a context value -- there is no active render scope.'); } - if (node.data === null) { - node.data = []; + (node.contexts ??= new Map()).set(key, read); + } + + /** + * Walk from the current node up the parent chain for the nearest provider of + * `key`. Returns its read fn, or `null` if no provider exists in the tree. + */ + lookup(key: object): Nullable { + let node = this.stack.current ?? null; + while (node !== null) { + let read = node.contexts?.get(key); + if (read !== undefined) { + return read; + } + node = node.parent; } - node.data.push(entry); + return null; } private reset(): void { @@ -156,22 +130,36 @@ export class RenderScopeTracker { } // Module-level reference to the active tracker. The renderer assigns this -// before invoking the render loop and clears it on commit, so that the public -// `getScope` / `addToScope` helpers can be called from anywhere within a -// rendering tick. +// before invoking the render loop and clears it on commit, so that the +// context helpers below can be called from anywhere within a rendering tick +// (e.g. a `consume()` helper that has no handle to the VM). let CURRENT_TRACKER: RenderScopeTracker | undefined; export function setCurrentRenderScopeTracker(tracker: RenderScopeTracker | undefined): void { CURRENT_TRACKER = tracker; } -export function getCurrentRenderScope(): RenderScope | undefined { - return CURRENT_TRACKER?.getCurrentScope(); +/** + * Provide `key`'s value (via the lazy `read`) at the current render node. Used + * by `makeContext`'s ``. Throws if called outside of rendering. + */ +export function provideRenderContext(key: object, read: ContextRead): void { + if (CURRENT_TRACKER === undefined) { + throw new Error('Cannot provide a context value -- there is no active render scope.'); + } + CURRENT_TRACKER.provide(key, read); } -export function addToCurrentRenderScope(entry: unknown): void { - if (CURRENT_TRACKER === undefined) { - throw new Error('addToScope() may only be called while rendering -- there is no active scope.'); +/** + * Look up the nearest provider of `key` in the render tree. Returns: + * + * - `undefined` when called outside of rendering, + * - `null` when rendering but no provider for `key` exists, + * - the nearest provider's read fn otherwise. + */ +export function lookupRenderContext(key: object): ContextRead | null | undefined { + if (CURRENT_TRACKER === undefined || !CURRENT_TRACKER.isRendering) { + return undefined; } - CURRENT_TRACKER.addToCurrentScope(entry); + return CURRENT_TRACKER.lookup(key); } From d465059b6f0f5f97b017aa485d6005d6e92b5eea Mon Sep 17 00:00:00 2001 From: NullVoxPopuli <199018+NullVoxPopuli@users.noreply.github.com> Date: Sun, 7 Jun 2026 15:32:08 -0400 Subject: [PATCH 13/15] makeContext: use `consume` as the context identity, drop the empty-object key The render-scope lookup needs a stable, unique-per-context identity token. That was a freshly-allocated `{}`, but `consume` already is one: it is created once per `makeContext()` call and is in scope for both the `consume()` reader and the `` constructor. Reusing it as the Map key removes the extra object (and the "why is there an empty object?" question) with no behavior change. All 23 makeContext browser tests still pass; tsc clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../@ember/-internals/glimmer/lib/make-context.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/@ember/-internals/glimmer/lib/make-context.ts b/packages/@ember/-internals/glimmer/lib/make-context.ts index c9808ae679b..1e852df6a88 100644 --- a/packages/@ember/-internals/glimmer/lib/make-context.ts +++ b/packages/@ember/-internals/glimmer/lib/make-context.ts @@ -81,12 +81,12 @@ export interface Context { * @public */ export function makeContext(): Context { - // Identity-based key, so multiple contexts can coexist on the same scope - // without name collisions. Held in the closure -- not exported. - const key = {}; - + // `consume` doubles as this context's identity token: it is unique to this + // `makeContext()` call and stable, so the matching `` and + // `consume()` find each other on a shared render-scope node without + // colliding with other contexts. Held in the closure -- not exported. function consume(): T { - let read = lookupRenderContext(key); + let read = lookupRenderContext(consume); if (read === undefined) { throw new Error( '`consume()` was called outside of rendering. The render-tree scope is only available during rendering -- there is nothing to read.' @@ -117,7 +117,7 @@ export function makeContext(): Context { const valueRef = this.args.named['value']; const read = (): unknown => (valueRef === undefined ? undefined : valueForRef(valueRef)); - provideRenderContext(key, read); + provideRenderContext(consume, read); } } From a4a89f19134597cb07f2a555203cca01d1682624 Mon Sep 17 00:00:00 2001 From: NullVoxPopuli <199018+NullVoxPopuli@users.noreply.github.com> Date: Mon, 8 Jun 2026 21:17:55 -0400 Subject: [PATCH 14/15] Trim the render-scope tracker to only the methods that are reachable Audited every method against what actually runs: - Drop `commit()` -- it only called `reset()`, which `begin()` already does at the start of the next transaction. Removed its call in environment.ts. - Drop `willDestroy()` (and the `associateDestroyable` + `registerDestructor` wiring in the create opcode). `lookup` only ever sees nodes via the live stack, and a destroyed component is never re-entered, so this was pure eager cleanup -- the `nodes` WeakMap collects on GC regardless. - Drop the `isRendering` getter and the private `reset()`; fold both into `lookup` (returns `undefined` when there is no current frame) and an inline loop in `begin()`. - Pare the `RenderScopeTracker` interface in @glimmer/interfaces down to the three lifecycle methods the opcodes actually call (`create`/`enter`/`exit`). What's left is the irreducible set: begin (error recovery), create/enter/exit (stack lifecycle, proven load-bearing), and provide/lookup (the two real ops). All 23 makeContext browser tests pass; tsc clean. Net -66 lines. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../interfaces/lib/runtime/environment.d.ts | 1 - .../runtime/lib/compiled/opcodes/component.ts | 4 - packages/@glimmer/runtime/lib/environment.ts | 1 - packages/@glimmer/runtime/lib/render-scope.ts | 148 ++++++------------ 4 files changed, 44 insertions(+), 110 deletions(-) diff --git a/packages/@glimmer/interfaces/lib/runtime/environment.d.ts b/packages/@glimmer/interfaces/lib/runtime/environment.d.ts index 6b828750194..8be2d5557cf 100644 --- a/packages/@glimmer/interfaces/lib/runtime/environment.d.ts +++ b/packages/@glimmer/interfaces/lib/runtime/environment.d.ts @@ -60,7 +60,6 @@ export interface RenderScopeTracker { create(bucket: object): void; enter(bucket: object): void; exit(): void; - willDestroy(bucket: object): 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 2ff61c786c3..41fc834fedc 100644 --- a/packages/@glimmer/runtime/lib/compiled/opcodes/component.ts +++ b/packages/@glimmer/runtime/lib/compiled/opcodes/component.ts @@ -393,10 +393,6 @@ APPEND_OPCODES.add(VM_CREATE_COMPONENT_OP, (vm, { op1: flags }) => { // debugRenderTree would be too late: the user constructor runs in // manager.create() below. vm.env.renderScope.create(instance); - vm.associateDestroyable(instance); - registerDestructor(instance, () => { - vm.env.renderScope.willDestroy(instance); - }); vm.updateWith(new RenderScopeUpdateOpcode(instance)); if (!managerHasCapability(manager, capabilities, InternalComponentCapabilities.createInstance)) { diff --git a/packages/@glimmer/runtime/lib/environment.ts b/packages/@glimmer/runtime/lib/environment.ts index 9c0b4aee3a3..2544d58f05a 100644 --- a/packages/@glimmer/runtime/lib/environment.ts +++ b/packages/@glimmer/runtime/lib/environment.ts @@ -183,7 +183,6 @@ export class EnvironmentImpl implements Environment { transaction.commit(); this.debugRenderTree?.commit(); - this.renderScope.commit(); setCurrentRenderScopeTracker(undefined); this.delegate.onTransactionCommit(); diff --git a/packages/@glimmer/runtime/lib/render-scope.ts b/packages/@glimmer/runtime/lib/render-scope.ts index a43a2abe516..b5283c8e945 100644 --- a/packages/@glimmer/runtime/lib/render-scope.ts +++ b/packages/@glimmer/runtime/lib/render-scope.ts @@ -1,69 +1,42 @@ import type { Nullable } from '@glimmer/interfaces'; import { StackImpl as Stack } from '@glimmer/util/lib/collections'; -/** - * Render-tree scope tracking that backs `makeContext` (RFC #1154). - * - * This is intentionally narrow: the only thing it supports is "provide a value - * for a key at the current render node" and "look up the nearest provider of a - * key walking up the render tree". `makeContext` is the sole consumer, so there - * is no general-purpose `getScope`/`addToScope` surface -- just the two - * context-specific helpers exported at the bottom of this file. - * - * Reactivity is intentionally not modeled here: lookups happen during - * rendering, and the providers visible at a node only change when that node - * re-renders (or is torn down), so consumers do not need to subscribe. - */ - -// A read function returns the currently-provided value for a context key. It is -// evaluated lazily so that auto-tracking inside it (e.g. reading `@value`) makes -// consumers reactive to the provided value. +// makeContext (RFC #1154) 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 fn for the contexts provided at this node. Lazily allocated -- - // the overwhelming majority of render nodes never provide a context. + // key -> read, lazily allocated (most render nodes never provide a context). contexts: Nullable>; } /** - * Tracks the render-tree scope hierarchy. This mirrors the stack management of - * `DebugRenderTree`, but is always-on because it backs a real (non-debug) - * feature. + * 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. */ export class RenderScopeTracker { - // Stack of currently-rendering scope nodes; the top is the "current" scope. private stack = new Stack(); - // bucket -> scope node, so we can re-enter on update without losing entries. + // bucket (component instance) -> node, so updating frames can re-push it. private nodes = new WeakMap(); + // Drop any nodes left on the stack by a render that errored mid-flight. begin(): void { - this.reset(); - } - - commit(): void { - this.reset(); + while (!this.stack.isEmpty()) { + this.stack.pop(); + } } - /** - * Called when a render node is first created during rendering. The bucket - * is whatever stable object the caller wants to associate with the scope - * (e.g. the component instance). - */ + // Push a fresh node when a component is created (before its constructor runs). create(bucket: object): void { - let node: RenderScopeNode = { - parent: this.stack.current ?? null, - contexts: null, - }; + let node: RenderScopeNode = { parent: this.stack.current ?? null, contexts: null }; this.nodes.set(bucket, node); this.stack.push(node); } - /** - * Called for the bucket on re-render. Re-pushes the existing scope node so - * descendants link back to the same parent chain. - */ + // Re-push an existing node when its component re-renders. enter(bucket: object): void { let node = this.nodes.get(bucket); if (node !== undefined) { @@ -71,95 +44,62 @@ export class RenderScopeTracker { } } - /** - * Called once the bucket has finished rendering for this tick. - */ exit(): void { this.stack.pop(); } - /** - * Called when the render node is torn down. The WeakMap will collect the - * node naturally; we just drop the explicit references so the provided - * values (which may hold user state) can be released eagerly. - */ - willDestroy(bucket: object): void { - let node = this.nodes.get(bucket); - if (node !== undefined) { - node.contexts = null; - node.parent = null; - this.nodes.delete(bucket); - } - } - - get isRendering(): boolean { - let node = this.stack.current; - return node !== undefined && node !== null; - } - - /** Provide `key`'s value (via the lazy `read`) at the current render node. */ + // 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 === undefined || node === null) { - throw new Error('Cannot provide a context value -- there is no active render scope.'); + if (node) { + (node.contexts ??= new Map()).set(key, read); } - (node.contexts ??= new Map()).set(key, read); } - /** - * Walk from the current node up the parent chain for the nearest provider of - * `key`. Returns its read fn, or `null` if no provider exists in the tree. - */ - lookup(key: object): Nullable { - let node = this.stack.current ?? null; - while (node !== null) { + // The nearest provider of `key`: + // `undefined` -> not inside a render frame (e.g. a modifier installing + // during commit, after the render stack has unwound), + // `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) { + return undefined; + } + for (let node: Nullable = current; node !== null; node = node.parent) { let read = node.contexts?.get(key); if (read !== undefined) { return read; } - node = node.parent; } return null; } - - private reset(): void { - while (!this.stack.isEmpty()) { - this.stack.pop(); - } - } } -// Module-level reference to the active tracker. The renderer assigns this -// before invoking the render loop and clears it on commit, so that the -// context helpers below can be called from anywhere within a rendering tick -// (e.g. a `consume()` helper that has no handle to the VM). -let CURRENT_TRACKER: RenderScopeTracker | undefined; +// The renderer points this at the active tracker for the duration of a render, +// so the helpers below work from anywhere in a tick (e.g. a `consume()` that has +// no handle to the VM). Cleared between renders. +let CURRENT: RenderScopeTracker | undefined; export function setCurrentRenderScopeTracker(tracker: RenderScopeTracker | undefined): void { - CURRENT_TRACKER = tracker; + CURRENT = tracker; } -/** - * Provide `key`'s value (via the lazy `read`) at the current render node. Used - * by `makeContext`'s ``. Throws if called outside of rendering. - */ +/** Provide `key`'s value at the current render node (makeContext's ``). */ export function provideRenderContext(key: object, read: ContextRead): void { - if (CURRENT_TRACKER === undefined) { - throw new Error('Cannot provide a context value -- there is no active render scope.'); + if (CURRENT === undefined) { + throw new Error('A context can only be provided while rendering.'); } - CURRENT_TRACKER.provide(key, read); + CURRENT.provide(key, read); } /** - * Look up the nearest provider of `key` in the render tree. Returns: - * - * - `undefined` when called outside of rendering, - * - `null` when rendering but no provider for `key` exists, - * - the nearest provider's read fn otherwise. + * The nearest provider of `key`: + * `undefined` -> called outside of rendering, + * `null` -> rendering, but no provider for `key`, + * a function -> the nearest provider's read fn. */ export function lookupRenderContext(key: object): ContextRead | null | undefined { - if (CURRENT_TRACKER === undefined || !CURRENT_TRACKER.isRendering) { - return undefined; - } - return CURRENT_TRACKER.lookup(key); + return CURRENT === undefined ? undefined : CURRENT.lookup(key); } From ffee66d416688d68718a7e53c989722f6f1c0587 Mon Sep 17 00:00:00 2001 From: NullVoxPopuli <199018+NullVoxPopuli@users.noreply.github.com> Date: Mon, 8 Jun 2026 21:56:57 -0400 Subject: [PATCH 15/15] Fix stale doc comments: @for tag, create-opcode reference, test coverage list Comment-only corrections surfaced by an audit after the API/internals changes landed: - make-context.ts: `@for @ember/renderer` -> `@ember/helper` (makeContext is exported from @ember/helper now; matches the canonical block in @ember/helper/index.ts). - component.ts: the VM_DID_RENDER_LAYOUT_OP exit comment referenced `VM_GET_COMPONENT_SELF_OP`; the matching `create()` is in `VM_CREATE_COMPONENT_OP`. - render-tree-scope-test.ts: note the "omitting @value" case in the extra-coverage suite summary. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/@ember/-internals/glimmer/lib/make-context.ts | 2 +- .../glimmer/tests/integration/render-tree-scope-test.ts | 2 +- packages/@glimmer/runtime/lib/compiled/opcodes/component.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/@ember/-internals/glimmer/lib/make-context.ts b/packages/@ember/-internals/glimmer/lib/make-context.ts index 1e852df6a88..3b4f75d7a0e 100644 --- a/packages/@ember/-internals/glimmer/lib/make-context.ts +++ b/packages/@ember/-internals/glimmer/lib/make-context.ts @@ -74,7 +74,7 @@ export interface Context { * * @method makeContext * @static - * @for @ember/renderer + * @for @ember/helper * @returns {Object} An object with `Provide` (a component that takes a * `@value`) and `consume` (a function/helper that reads the nearest * provided value). diff --git a/packages/@ember/-internals/glimmer/tests/integration/render-tree-scope-test.ts b/packages/@ember/-internals/glimmer/tests/integration/render-tree-scope-test.ts index 78a25ced4da..d3528a6690b 100644 --- a/packages/@ember/-internals/glimmer/tests/integration/render-tree-scope-test.ts +++ b/packages/@ember/-internals/glimmer/tests/integration/render-tree-scope-test.ts @@ -553,7 +553,7 @@ moduleFor( * * - consume() from a plain function helper * - consume() from a modifier - * - explicit @value={{undefined}} / @value={{null}} + * - explicit @value={{undefined}} / @value={{null}}, and omitting @value * - cross-renderComponent isolation * - multiple consume() calls in the same template return the same identity */ diff --git a/packages/@glimmer/runtime/lib/compiled/opcodes/component.ts b/packages/@glimmer/runtime/lib/compiled/opcodes/component.ts index 41fc834fedc..73d1b055a40 100644 --- a/packages/@glimmer/runtime/lib/compiled/opcodes/component.ts +++ b/packages/@glimmer/runtime/lib/compiled/opcodes/component.ts @@ -898,7 +898,7 @@ APPEND_OPCODES.add(VM_DID_RENDER_LAYOUT_OP, (vm, { op1: register }) => { let bounds = vm.tree().popBlock(); // RFC #1154 -- pop the render scope stack to match the create() done in - // VM_GET_COMPONENT_SELF_OP. This must happen unconditionally and outside + // VM_CREATE_COMPONENT_OP. This must happen unconditionally and outside // the debugRenderTree branch below. vm.env.renderScope.exit(); vm.updateWith(new RenderScopeExitOpcode());