From e50051fb8bc0185ef1103156806d9ea912e04bd9 Mon Sep 17 00:00:00 2001 From: "@NullVoxPopuli's reduced-access machine account for AI usage" Date: Mon, 8 Jun 2026 23:17:44 -0400 Subject: [PATCH] Add makeContext: render-tree-scoped provide/consume (RFC #1154) (#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) * 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) * 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) * 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) * 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) * 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) * 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) * 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) * 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) * 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) * 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) * 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) * 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) * 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) * 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) --------- Co-authored-by: NullVoxPopuli <199018+NullVoxPopuli@users.noreply.github.com> Co-authored-by: Claude Opus 4.7 (1M context) --- packages/@ember/-internals/glimmer/index.ts | 2 + .../-internals/glimmer/lib/make-context.ts | 129 +++ .../integration/render-tree-scope-test.ts | 845 ++++++++++++++++++ packages/@ember/helper/index.ts | 51 ++ .../interfaces/lib/runtime/environment.d.ts | 11 + .../runtime/lib/compiled/opcodes/component.ts | 32 + packages/@glimmer/runtime/lib/environment.ts | 5 + packages/@glimmer/runtime/lib/render-scope.ts | 105 +++ smoke-tests/scenarios/basic-test.ts | 72 ++ tests/docs/expected.cjs | 1 + type-tests/@ember/helper-tests.ts | 14 + 11 files changed, 1267 insertions(+) create mode 100644 packages/@ember/-internals/glimmer/lib/make-context.ts 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..d804c61ac40 100644 --- a/packages/@ember/-internals/glimmer/index.ts +++ b/packages/@ember/-internals/glimmer/index.ts @@ -474,6 +474,8 @@ export { renderComponent, type View, } from './lib/renderer'; +// RFC #1154 -- render-tree-scoped context (provide/consume) +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 new file mode 100644 index 00000000000..3b4f75d7a0e --- /dev/null +++ b/packages/@ember/-internals/glimmer/lib/make-context.ts @@ -0,0 +1,129 @@ +/** + * @module @ember/helper + */ +import { precompileTemplate } from '@ember/template-compilation'; +import { lookupRenderContext, provideRenderContext } from '@glimmer/runtime/lib/render-scope'; +import { valueForRef } from '@glimmer/reference/lib/reference'; +import InternalComponent, { + type OpaqueInternalComponentConstructor, + opaquify, +} from './components/internal'; + +/** + * The shape returned by `makeContext`. Use `Provide` in templates to bind a + * value into the render tree and `consume` to read the nearest enclosing + * value. + */ +export interface Context { + Provide: OpaqueInternalComponentConstructor; + consume: () => T; +} + +/** + * Creates a render-tree-scoped context per [RFC #1154][rfc] discussion. + * + * [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 + * import { makeContext } from '@ember/helper'; + * + * class Theme { + * color = 'dark'; + * } + * + * const theme = makeContext(); + * + * + * ``` + * + * 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 + * 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/helper + * @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(): Context { + // `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(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.' + ); + } + if (read === null) { + throw new Error( + 'No matching `` was found in the render tree. Wrap consumers in `...`, or provide a default at the application root.' + ); + } + return read() as T; + } + + class Provide extends InternalComponent { + static override toString(): string { + return 'Provide'; + } + + constructor(...args: ConstructorParameters) { + super(...args); + + // 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']; + const read = (): unknown => (valueRef === undefined ? undefined : valueForRef(valueRef)); + + provideRenderContext(consume, read); + } + } + + 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}}'); 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..d3528a6690b --- /dev/null +++ b/packages/@ember/-internals/glimmer/tests/integration/render-tree-scope-test.ts @@ -0,0 +1,845 @@ +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 { makeContext } from '../../lib/make-context'; +import { tracked } from '@glimmer/tracking'; +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` proposal returning `{ Provide, consume }`). + * + * The API: + * + * - `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. + * + * 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 { + 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 -- makeContext: smoke test', + class extends MakeContextTestCase { + afterEach() { + runDestroy(this); + } + + '@test provide a value, consume it, and it renders'(assert: QUnit['assert']) { + class Theme { + color = 'dark'; + } + 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(), + /outside of rendering/, + 'consume() outside a render is rejected' + ); + } + + '@test consume() throws when no exists in the tree'(assert: QUnit['assert']) { + const theme = makeContext<{ color: string }>(); + + let error: Error | undefined; + class Reader extends GlimmerishComponent { + constructor(owner: Owner, args: Record) { + super(owner, args); + try { + theme.consume(); + } catch (e) { + error = e as Error; + } + } + } + setComponentTemplate(precompileTemplate(''), Reader); + + let Root = setComponentTemplate( + precompileTemplate('', { + strictMode: true, + scope: () => ({ Reader }), + }), + templateOnly() + ); + + this.renderComponent(Root); + + assert.ok(error, 'consume() raised'); + assert.ok( + /No matching ``/.test(error?.message ?? ''), + `error mentions missing provider, got: ${error?.message}` + ); + } + + '@test (context.consume) is usable as a template helper'(assert: QUnit['assert']) { + const theme = makeContext<{ color: string }>(); + const value = { color: 'dark' }; + + let Root = setComponentTemplate( + precompileTemplate( + '{{#let (theme.consume) as |t|}}{{t.color}}{{/let}}', + { + strictMode: true, + scope: () => ({ theme, value }), + } + ), + templateOnly() + ); + + this.renderComponent(Root); + 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(); + + 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(); + + let Root = setComponentTemplate( + precompileTemplate( + ` + {{#let (ctx.consume) as |v|}}
{{v}}
{{/let}} + + {{#let (ctx.consume) as |v|}}
{{v}}
{{/let}} +
+
`, + { 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's value updates when @value changes"(assert: QUnit['assert']) { + class State { + @tracked count = 1; + } + const state = new State(); + const ctx = makeContext(); + + 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(); + const ctxB = makeContext(); + + 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(); + + let Root = setComponentTemplate( + precompileTemplate( + `{{#let (ctx.consume) as |v|}}
{{v}}
{{/let}}
+ {{#let (ctx.consume) as |v|}}
{{v}}
{{/let}}
`, + { 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(); + + let Root = setComponentTemplate( + precompileTemplate( + ` + {{#unless state.hidden}} + {{#let (ctx.consume) as |v|}}
{{v}}
{{/let}} + {{/unless}} +
`, + { 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(); + + let Root = setComponentTemplate( + precompileTemplate( + `{{#unless state.hidden}} + {{#let (ctx.consume) as |v|}}
{{v}}
{{/let}}
+ {{/unless}}`, + { 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(); + + // 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}} +
`, + { 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(); + const ctxTwo = makeContext(); + + let Root = setComponentTemplate( + precompileTemplate( + ` + + {{#let (ctxOne.consume) as |a|}}
{{a}}
{{/let}} + {{#let (ctxTwo.consume) as |b|}}
{{b}}
{{/let}} +
+
`, + { 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 provided class instance is reactive'(assert: QUnit['assert']) { + class Counter { + @tracked count = 0; + } + const counter = makeContext(); + const instance = new Counter(); + + let Root = setComponentTemplate( + precompileTemplate( + '{{#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'); + + run(() => { + instance.count = 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(); + + let observed: string | undefined; + class Reader extends GlimmerishComponent { + constructor(owner: Owner, args: Record) { + super(owner, args); + observed = ctx.consume(); + } + } + setComponentTemplate(precompileTemplate('done'), Reader); + + let Root = setComponentTemplate( + precompileTemplate('', { + strictMode: true, + scope: () => ({ ctx, Reader }), + }), + templateOnly() + ); + + this.renderComponent(Root); + assert.strictEqual(observed, 'provided'); + } + + '@test a provided object identity is stable across the same Provide re-render'( + assert: QUnit['assert'] + ) { + // 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(); + + 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()); + } + } + 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. + '{{state.tick}}', + { strictMode: true, scope: () => ({ ctx, state, value, Reader }) } + ), + templateOnly() + ); + + this.renderComponent(Root); + const first = observed[0]; + 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 -- 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'); + } + } +); + +/** + * Extra-coverage suite for behaviors not exercised by the EPCC port: + * + * - consume() from a plain function helper + * - consume() from a modifier + * - explicit @value={{undefined}} / @value={{null}}, and omitting @value + * - 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 consume() works inside a plain function helper'(assert: QUnit['assert']) { + 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 + // 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 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(); + + let caught: Error | undefined; + const stash = defineSimpleModifier((_element: Element) => { + try { + ctx.consume(); + } catch (e) { + caught = e as Error; + } + }); + + let Root = setComponentTemplate( + precompileTemplate( + '
x
', + { strictMode: true, scope: () => ({ ctx, stash }) } + ), + templateOnly() + ); + + this.renderComponent(Root); + 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")'( + 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); + + // 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 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(); + + 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 -- strict-equal + // identity for a provided object. + class State { + marker = Symbol('state'); + } + const ctx = makeContext(); + const instance = new 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, instance, 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(); + + 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(); + } + } + 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'); + } + } +); 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/@glimmer/interfaces/lib/runtime/environment.d.ts b/packages/@glimmer/interfaces/lib/runtime/environment.d.ts index c47f1c98abb..8be2d5557cf 100644 --- a/packages/@glimmer/interfaces/lib/runtime/environment.d.ts +++ b/packages/@glimmer/interfaces/lib/runtime/environment.d.ts @@ -47,10 +47,21 @@ export interface Environment { isInteractive: boolean; debugRenderTree?: DebugRenderTree | undefined; + // 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 RenderScopeTracker { + create(bucket: object): void; + enter(bucket: object): void; + exit(): 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..73d1b055a40 100644 --- a/packages/@glimmer/runtime/lib/compiled/opcodes/component.ts +++ b/packages/@glimmer/runtime/lib/compiled/opcodes/component.ts @@ -387,6 +387,14 @@ 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 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.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 +897,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_CREATE_COMPONENT_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 +984,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 +// `consume()` 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..2544d58f05a 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,7 @@ export class EnvironmentImpl implements Environment { transaction.commit(); this.debugRenderTree?.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..b5283c8e945 --- /dev/null +++ b/packages/@glimmer/runtime/lib/render-scope.ts @@ -0,0 +1,105 @@ +import type { Nullable } from '@glimmer/interfaces'; +import { StackImpl as Stack } from '@glimmer/util/lib/collections'; + +// 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, lazily allocated (most render nodes never provide a context). + contexts: Nullable>; +} + +/** + * 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 { + private stack = new Stack(); + // 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 { + while (!this.stack.isEmpty()) { + this.stack.pop(); + } + } + + // 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 }; + this.nodes.set(bucket, node); + this.stack.push(node); + } + + // Re-push an existing node when its component re-renders. + enter(bucket: object): void { + let node = this.nodes.get(bucket); + if (node !== undefined) { + this.stack.push(node); + } + } + + exit(): void { + this.stack.pop(); + } + + // 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) { + (node.contexts ??= new Map()).set(key, read); + } + } + + // The nearest provider of `key`: + // `undefined` -> not inside a render frame (e.g. a modifier installing + // during commit, after the render stack has unwound), + // `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; + } + } + return null; + } +} + +// 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; +} + +/** Provide `key`'s value at the current render node (makeContext's ``). */ +export function provideRenderContext(key: object, read: ContextRead): void { + if (CURRENT === undefined) { + throw new Error('A context can only be provided while rendering.'); + } + CURRENT.provide(key, read); +} + +/** + * 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 { + return CURRENT === undefined ? undefined : CURRENT.lookup(key); +} 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'; diff --git a/tests/docs/expected.cjs b/tests/docs/expected.cjs index 275e5cbd6d3..f6c02adae44 100644 --- a/tests/docs/expected.cjs +++ b/tests/docs/expected.cjs @@ -315,6 +315,7 @@ module.exports = { 'lt', 'lte', 'makeArray', + 'makeContext', 'makeToString', 'map', 'mapBy', 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);