diff --git a/packages/@ember/-internals/glimmer/index.ts b/packages/@ember/-internals/glimmer/index.ts index abb5506b78f..e0ce66b3054 100644 --- a/packages/@ember/-internals/glimmer/index.ts +++ b/packages/@ember/-internals/glimmer/index.ts @@ -518,6 +518,8 @@ export { renderComponent, type View, } from './lib/renderer'; +// RFC #1200 -- render-tree-scoped context (provide/consume) +export { createContext, type Context } from './lib/create-context'; export { getTemplate, setTemplate, diff --git a/packages/@ember/-internals/glimmer/lib/create-context.ts b/packages/@ember/-internals/glimmer/lib/create-context.ts new file mode 100644 index 00000000000..1ef1487e823 --- /dev/null +++ b/packages/@ember/-internals/glimmer/lib/create-context.ts @@ -0,0 +1,194 @@ +/** + * @module @ember/helper + */ +import { precompileTemplate } from '@ember/template-compilation'; +import { + lookupRenderContext, + lookupRenderContextFor, + 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 `createContext`. Use `Provide` in templates to bind a + * value into the render tree, read `value` to get the nearest enclosing + * provided value, or call `consume(this)` to read it from a component + * instance's position in the render tree (e.g. in an event handler). + */ +export interface Context { + Provide: OpaqueInternalComponentConstructor; + get value(): T; + consume(consumer?: object): T; +} + +/** + * Creates a render-tree-scoped context per [RFC #1200][rfc]. + * + * [rfc]: https://github.com/emberjs/rfcs/pull/1200 + * + * `createContext` 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 `value` getter. The value is supplied at render time + * through ``. + * + * @example + * + * ```gjs + * import { createContext } from '@ember/helper'; + * + * class Theme { + * color = 'dark'; + * } + * + * const theme = createContext(); + * + * + * ``` + * + * 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. + * + * `value` throws if it is read outside of rendering, or if no matching + * `` exists higher in the render tree. This is intentional + * harm-reduction: 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. + * + * The returned object also has a `consume()` method. With no argument it + * behaves exactly like reading `value`. Passed a component instance, it + * resolves the context from that component's position in the render tree + * instead -- the way to read a context from event handlers and other code + * that runs outside of rendering: + * + * ```gjs + * import Component from '@glimmer/component'; + * import { createContext } from '@ember/helper'; + * import { on } from '@ember/modifier'; + * + * const theme = createContext(); + * + * class ThemedButton extends Component { + * onClick = () => { + * // Outside of rendering, `theme.value` would throw -- but passing the + * // component instance resolves the nearest above it. + * console.log(theme.consume(this).color); + * }; + * + * + * } + * ``` + * + * Any rendered component instance works with every context -- the instance + * identifies a position in the render tree, not any one context's value. + * Resolution happens at `consume` time, so the value read is the provider's + * current `@value`, not a snapshot from render time. + * + * @method createContext + * @static + * @for @ember/helper + * @returns {Object} An object with `Provide` (a component that takes a + * `@value`), `value` (a getter that reads the nearest provided value), and + * `consume` (reads ambiently like `value`, or from a component instance's + * position). + * @public + */ +export function createContext(): Context { + // This context's identity token: unique to this `createContext()` call and + // stable, so the matching `` and `value` reads find each other on + // a shared render-scope node without colliding with other contexts. Held in + // the closure -- not exported. + const key = {}; + + 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` (`value` is undefined rather than throwing). + const valueRef = this.args.named['value']; + const read = (): unknown => (valueRef === undefined ? undefined : valueForRef(valueRef)); + + provideRenderContext(key, read); + } + } + + // The ambient read backing both the `value` getter and a no-argument + // `consume()` call: resolve the nearest provider at the currently-rendering + // position. + function resolveAmbient(): T { + let read = lookupRenderContext(key); + if (read === undefined) { + throw new Error( + 'A context `value` was read outside of rendering. The render-tree scope is only available during rendering -- there is nothing to read. To read a context from an event handler or other async callback, pass the component instance: `context.consume(this)`.' + ); + } + if (read === null) { + throw new Error( + 'No matching `` was found in the render tree. Wrap consumers in `...`, or provide a default at the application root.' + ); + } + return read() as T; + } + + return { + Provide: opaquify(Provide, PROVIDE_TEMPLATE), + + get value(): T { + return resolveAmbient(); + }, + + consume(consumer?: object): T { + if (consumer === undefined) { + return resolveAmbient(); + } + + let read = lookupRenderContextFor(consumer, key); + if (read === undefined) { + throw new Error( + '`consume(component)` expects a component instance that has been rendered -- the instance is how the context finds its position in the render tree. Pass the component itself (usually `this`), not another object, and only after the component has rendered.' + ); + } + if (read === null) { + throw new Error( + 'No matching `` was found above the component passed to `consume`. Wrap the component in `...`, or provide a default at the application root.' + ); + } + return read() as T; + }, + }; +} + +// All Provide components share the same template: yield to the block. +// Per-instance behavior is parameterized via the closure in createContext. +const PROVIDE_TEMPLATE = precompileTemplate('{{yield}}'); diff --git a/packages/@ember/-internals/glimmer/tests/integration/create-context-test.ts b/packages/@ember/-internals/glimmer/tests/integration/create-context-test.ts new file mode 100644 index 00000000000..17b3ae4f592 --- /dev/null +++ b/packages/@ember/-internals/glimmer/tests/integration/create-context-test.ts @@ -0,0 +1,1166 @@ +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 { on } from '@ember/modifier'; +import { renderComponent } from '../../lib/renderer'; +import { createContext } from '../../lib/create-context'; +import { tracked } from '@glimmer/tracking'; +import type Owner from '@ember/owner'; + +/** + * Coverage for `createContext` (the user-facing API discussed in + * https://github.com/emberjs/rfcs/pull/1200 -- NullVoxPopuli's + * `createContext` proposal returning `{ Provide, value }`). + * + * The API: + * + * - `createContext()` takes no value -- the type parameter declares the + * shape, and the value is supplied at render time via ``. + * - `` provides a value to descendants. + * - `{{myContext.value}}` (a template path) or `myContext.value` in JS + * reads the nearest provided value; `value` is a getter. + * + * 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 createContext throws per NVP's "reduce harm" clarification), the + * test asserts the createContext behavior. + */ + +class CreateContextTestCase 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 #1200 -- createContext: smoke test', + class extends CreateContextTestCase { + afterEach() { + runDestroy(this); + } + + '@test provide a value, consume it, and it renders'(assert: QUnit['assert']) { + class Theme { + color = 'dark'; + } + const theme = createContext(); + const value = new Theme(); + + let Root = setComponentTemplate( + precompileTemplate( + '{{#let theme.value 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 #1200 -- createContext: API surface', + class extends CreateContextTestCase { + afterEach() { + runDestroy(this); + } + + '@test value throws if read outside of rendering'(assert: QUnit['assert']) { + const theme = createContext<{ color: string }>(); + + assert.throws( + () => theme.value, + /outside of rendering/, + 'a value read outside a render is rejected' + ); + } + + '@test value throws when no exists in the tree'(assert: QUnit['assert']) { + const theme = createContext<{ color: string }>(); + + let error: Error | undefined; + class Reader extends GlimmerishComponent { + constructor(owner: Owner, args: Record) { + super(owner, args); + try { + void theme.value; + } catch (e) { + error = e as Error; + } + } + } + setComponentTemplate(precompileTemplate(''), Reader); + + let Root = setComponentTemplate( + precompileTemplate('', { + strictMode: true, + scope: () => ({ Reader }), + }), + templateOnly() + ); + + this.renderComponent(Root); + + assert.ok(error, 'the value read raised'); + assert.ok( + /No matching ``/.test(error?.message ?? ''), + `error mentions missing provider, got: ${error?.message}` + ); + } + + '@test context.value is usable as a template path'(assert: QUnit['assert']) { + const theme = createContext<{ color: string }>(); + const value = { color: 'dark' }; + + // The getter composes with paths: both `{{theme.value.color}}` and + // going through a `{{#let}}` binding read the nearest provided value. + let Root = setComponentTemplate( + precompileTemplate( + '{{theme.value.color}}-{{#let theme.value as |t|}}{{t.color}}{{/let}}', + { + strictMode: true, + scope: () => ({ theme, value }), + } + ), + templateOnly() + ); + + this.renderComponent(Root); + assertHTML('dark-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 #1200 -- createContext: behavior ported from ember-provide-consume-context', + class extends CreateContextTestCase { + afterEach() { + runDestroy(this); + } + + '@test a consumer can read context'(assert: QUnit['assert']) { + const ctx = createContext(); + + let Root = setComponentTemplate( + precompileTemplate( + '{{#let ctx.value 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 = createContext(); + + let Root = setComponentTemplate( + precompileTemplate( + ` + {{#let ctx.value as |v|}}
{{v}}
{{/let}} + + {{#let ctx.value 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 = createContext(); + + let Root = setComponentTemplate( + precompileTemplate( + '{{#let ctx.value 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 = createContext(); + const ctxB = createContext(); + + let error: Error | undefined; + class Reader extends GlimmerishComponent { + constructor(owner: Owner, args: Record) { + super(owner, args); + try { + void ctxA.value; + } 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 ctxA.value read should throw -- they don't bleed. + let Root = setComponentTemplate( + precompileTemplate( + '', + { strictMode: true, scope: () => ({ ctxA, ctxB, Reader }) } + ), + templateOnly() + ); + + this.renderComponent(Root); + + assert.ok(error, 'the value read 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 = createContext(); + + let Root = setComponentTemplate( + precompileTemplate( + `{{#let ctx.value as |v|}}
{{v}}
{{/let}}
+ {{#let ctx.value 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 = createContext(); + + let Root = setComponentTemplate( + precompileTemplate( + ` + {{#unless state.hidden}} + {{#let ctx.value 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 = createContext(); + + let Root = setComponentTemplate( + precompileTemplate( + `{{#unless state.hidden}} + {{#let ctx.value 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 = createContext(); + + // 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.value 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 = createContext(); + const ctxTwo = createContext(); + + let Root = setComponentTemplate( + precompileTemplate( + ` + + {{#let ctxOne.value as |a|}}
{{a}}
{{/let}} + {{#let ctxTwo.value 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 = createContext(); + const instance = new Counter(); + + let Root = setComponentTemplate( + precompileTemplate( + '{{#let counter.value 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 = createContext(); + + let observed: string | undefined; + class Reader extends GlimmerishComponent { + constructor(owner: Owner, args: Record) { + super(owner, args); + observed = ctx.value; + } + } + 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 = createContext<{ id: number }>(); + + let observed: object[] = []; + class Reader extends GlimmerishComponent { + constructor(owner: Owner, args: Record) { + super(owner, args); + observed.push(ctx.value); + } + } + 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: + * + * - a value read from a plain function helper + * - a value read from a modifier + * - explicit @value={{undefined}} / @value={{null}}, and omitting @value + * - cross-renderComponent isolation + * - multiple value reads in the same template return the same identity + */ +import { defineSimpleHelper, defineSimpleModifier } from 'internal-test-helpers'; + +moduleFor( + 'RFC #1200 -- createContext: extra coverage', + class extends CreateContextTestCase { + afterEach() { + runDestroy(this); + } + + '@test value works inside a plain function helper'(assert: QUnit['assert']) { + const ctx = createContext(); + + // A genuine helper -- not just `ctx.value` in a let-binding. + // This exercises that value can be read from a function whose + // identity is wrapped by the helper manager, which the RFC explicitly + // allows ("in a plain function helper's body"). + const readContext = defineSimpleHelper(() => ctx.value); + + 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: a value read 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 reading + // value 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 #1200 documents this limitation explicitly. + const ctx = createContext(); + + let caught: Error | undefined; + const stash = defineSimpleModifier((_element: Element) => { + try { + void ctx.value; + } catch (e) { + caught = e as Error; + } + }); + + let Root = setComponentTemplate( + precompileTemplate( + '
x
', + { strictMode: true, scope: () => ({ ctx, stash }) } + ), + templateOnly() + ); + + this.renderComponent(Root); + assert.ok(caught, 'the value read 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 = createContext(); + + let observed: unknown = 'NOT_SET'; + class Reader extends GlimmerishComponent { + constructor(owner: Owner, args: Record) { + super(owner, args); + observed = ctx.value; + } + } + 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 = createContext(); + + let observed: unknown = 'NOT_SET'; + class Reader extends GlimmerishComponent { + constructor(owner: Owner, args: Record) { + super(owner, args); + observed = ctx.value; + } + } + setComponentTemplate(precompileTemplate(''), Reader); + + // No @value at all -- the Provide is still in the tree, so value + // is 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 = createContext(); + + let observed: unknown = 'NOT_SET'; + class Reader extends GlimmerishComponent { + constructor(owner: Owner, args: Record) { + super(owner, args); + observed = ctx.value; + } + } + 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 value reads in the same template return the same identity'( + assert: QUnit['assert'] + ) { + // Each value read 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 = createContext(); + 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.value; + } + } + class SecondReader extends GlimmerishComponent { + constructor(owner: Owner, args: Record) { + super(owner, args); + secondSeen = ctx.value; + } + } + 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'); + } + + '@test a value read works across {{#in-element}}'(assert: QUnit['assert']) { + // {{#in-element}} moves where the DOM lands, not where the block sits + // in the render tree. Scoping follows the render tree, so a consumer + // portaled into an unrelated element still sees the provider that + // encloses it in the template -- even though, in the DOM, its output + // lands outside the provider's, in the sibling div above it. + const ctx = createContext(); + + let observedInConstructor: string | undefined; + class Reader extends GlimmerishComponent { + constructor(owner: Owner, args: Record) { + super(owner, args); + observedInConstructor = ctx.value; + } + } + setComponentTemplate(precompileTemplate(''), Reader); + + const findPortal = defineSimpleHelper(() => document.querySelector('#portal-target')); + + let Root = setComponentTemplate( + precompileTemplate( + `
+ + {{#in-element (findPortal)}} + {{#let ctx.value as |v|}}
{{v}}
{{/let}} + + {{/in-element}} +
`, + { strictMode: true, scope: () => ({ ctx, findPortal, Reader }) } + ), + templateOnly() + ); + + this.renderComponent(Root); + assert.strictEqual( + this.element.querySelector('#portal-target #content')?.textContent, + 'through-the-portal', + 'template path read inside the portal saw the outside provider' + ); + assert.strictEqual( + observedInConstructor, + 'through-the-portal', + 'component constructor inside the portal saw the outside provider' + ); + } + } +); + +/** + * 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 #1200 -- createContext: cross-renderComponent isolation', + class extends CreateContextTestCase { + afterEach() { + runDestroy(this); + } + + "@test separate renderComponent calls do not see each other's providers"( + assert: QUnit['assert'] + ) { + const ctx = createContext(); + + let bareError: Error | undefined; + class BareReader extends GlimmerishComponent { + constructor(owner: Owner, args: Record) { + super(owner, args); + try { + void ctx.value; + } 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.value; + } + } + 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, the value read threw'); + assert.ok( + /No matching ``/.test(bareError?.message ?? ''), + `error mentions missing provider, got: ${bareError?.message}` + ); + assert.strictEqual(providedSeen, 'B', 'tree B: own visible'); + } + } +); + +/** + * Coverage for `consume()`: + * + * - `consume()` with no argument behaves exactly like reading `value` + * (same result, same error cases). + * - `consume(componentInstance)` resolves the context from that component's + * position in the render tree, *outside* of rendering (the event-handler + * use case). The instance -> render-node association is made by the VM + * when the component renders -- an implementation detail; user code just + * passes `this`. + */ +moduleFor( + 'RFC #1200 -- createContext: consume()', + class extends CreateContextTestCase { + afterEach() { + runDestroy(this); + } + + '@test consume() with no argument reads like value'(assert: QUnit['assert']) { + const ctx = createContext(); + + let observed: string | undefined; + class Reader extends GlimmerishComponent { + constructor(owner: Owner, args: Record) { + super(owner, args); + observed = ctx.consume(); + } + } + setComponentTemplate(precompileTemplate(''), Reader); + + let Root = setComponentTemplate( + precompileTemplate('', { + strictMode: true, + scope: () => ({ ctx, Reader }), + }), + templateOnly() + ); + + this.renderComponent(Root); + assert.strictEqual(observed, 'ambient', 'consume() saw the provided value'); + } + + '@test consume() with no argument throws outside of rendering'(assert: QUnit['assert']) { + const ctx = createContext(); + + assert.throws( + () => ctx.consume(), + /outside of rendering/, + 'an ambient consume() outside a render is rejected' + ); + } + + '@test consume() with no argument throws when no exists'(assert: QUnit['assert']) { + const ctx = createContext(); + + let error: Error | undefined; + class Reader extends GlimmerishComponent { + constructor(owner: Owner, args: Record) { + super(owner, args); + try { + ctx.consume(); + } catch (e) { + error = e as Error; + } + } + } + setComponentTemplate(precompileTemplate(''), Reader); + + let Root = setComponentTemplate( + precompileTemplate('', { strictMode: true, scope: () => ({ Reader }) }), + templateOnly() + ); + + this.renderComponent(Root); + assert.ok( + /No matching ``/.test(error?.message ?? ''), + `error mentions missing provider, got: ${error?.message}` + ); + } + + '@test consume(this) resolves the context from an event handler'(assert: QUnit['assert']) { + const theme = createContext<{ color: string }>(); + const value = { color: 'rebeccapurple' }; + + let observed: { color: string } | undefined; + let ambientError: Error | undefined; + class Button extends GlimmerishComponent { + onClick = () => { + // Prove we really are outside of rendering: the ambient read throws... + try { + void theme.value; + } catch (e) { + ambientError = e as Error; + } + // ...but the component instance's position still resolves. + observed = theme.consume(this); + }; + } + setComponentTemplate( + precompileTemplate('', { + strictMode: true, + scope: () => ({ on }), + }), + Button + ); + + let Root = setComponentTemplate( + precompileTemplate('