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