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);