diff --git a/.changeset/chilly-cameras-greet.md b/.changeset/chilly-cameras-greet.md new file mode 100644 index 0000000..38745ba --- /dev/null +++ b/.changeset/chilly-cameras-greet.md @@ -0,0 +1,5 @@ +--- +"ember-provide-consume-context": minor +--- + +Register contexts at construct time diff --git a/ember-provide-consume-context/src/-private/@glimmer/opcodes.ts b/ember-provide-consume-context/src/-private/@glimmer/opcodes.ts index d1808c9..8ad9d55 100644 --- a/ember-provide-consume-context/src/-private/@glimmer/opcodes.ts +++ b/ember-provide-consume-context/src/-private/@glimmer/opcodes.ts @@ -7,6 +7,7 @@ // This is safe, because the opcodes should be stable. // https://github.com/glimmerjs/glimmer-vm/blob/68d371bdccb41bc239b8f70d832e956ce6c349d8/packages/%40glimmer/vm/lib/opcodes.ts#L196 export const enum Op { + CreateComponent = 87, GetComponentSelf = 90, DidRenderLayout = 100, } diff --git a/ember-provide-consume-context/src/-private/override-glimmer-runtime-classes.ts b/ember-provide-consume-context/src/-private/override-glimmer-runtime-classes.ts index ea27e07..2b4a7e3 100644 --- a/ember-provide-consume-context/src/-private/override-glimmer-runtime-classes.ts +++ b/ember-provide-consume-context/src/-private/override-glimmer-runtime-classes.ts @@ -31,6 +31,13 @@ function overrideVM(runtime: any) { try { const { type, op1 } = opcode; + if (type === Op.CreateComponent) { + // Let the container know we're instantiating a new component + this.env.provideConsumeContextContainer?.createComponent(); + // No need to register "updateWith", a component only instantiates + // once, and we don't need to run any further updates + } + if (type === Op.GetComponentSelf) { // Get the component instance from the VM // (that's the VM's component instance, not the Glimmer Component one) diff --git a/ember-provide-consume-context/src/-private/provide-consume-context-container.ts b/ember-provide-consume-context/src/-private/provide-consume-context-container.ts index ec1be13..b92663e 100644 --- a/ember-provide-consume-context/src/-private/provide-consume-context-container.ts +++ b/ember-provide-consume-context/src/-private/provide-consume-context-container.ts @@ -58,6 +58,8 @@ export class ProvideConsumeContextContainer { // providing context values in tests. #globalContexts: Contexts | null = null; + #isCreatingComponent = false; + begin(): void { this.reset(); } @@ -103,6 +105,11 @@ export class ProvideConsumeContextContainer { }; enter(instance: ComponentInstance): void { + // When "enter" is called, a component instance has already been created. + // Update the flag to reflect that. + // See the "contextsFor" method below for how this flag is used. + this.#isCreatingComponent = false; + const actualComponentInstance = (instance?.state as any)?.component; if (actualComponentInstance != null) { @@ -128,25 +135,11 @@ export class ProvideConsumeContextContainer { } private registerProvider(provider: any) { - const { current } = this; - - let providerContexts: Contexts = {}; - - // If global contexts are defined, make sure providers can read them - if (this.#globalContexts != null) { - providerContexts = { ...this.#globalContexts }; - } - - if (this.contexts.has(current)) { - // If a provider is nested within another provider, we merge their - // contexts - const context = this.contexts.get(current); - if (context != null) { - providerContexts = { ...providerContexts, ...context }; - } - } + const providerContexts: Contexts = this.currentContexts(); const registeredContexts = provider[EMBER_PROVIDE_CONSUME_CONTEXT_KEY]; + // If the provider has registered contexts, store references + // to them on the current contexts object if (registeredContexts != null) { Object.entries( registeredContexts as Record, @@ -164,15 +157,45 @@ export class ProvideConsumeContextContainer { } private registerComponent(component: any) { + const currentContexts = this.currentContexts(); + + // If a current context reference or global contexts exist, register them to the component + if (Object.keys(currentContexts).length > 0) { + this.contexts.set(component, currentContexts); + } + } + + currentContexts() { const { current } = this; const globalContexts = this.#globalContexts ?? {}; - // If a current context reference or global contexts exist, register them to the component if (this.contexts.has(current) || Object.keys(globalContexts).length > 0) { const context = this.contexts.get(current); - const mergedContexts = { ...globalContexts, ...context }; - this.contexts.set(component, mergedContexts); + return { ...globalContexts, ...context }; + } + + return {}; + } + + contextsFor(component: any) { + if (this.contexts.has(component)) { + return this.contexts.get(component); + } + + // If a context for this component is not yet registered, but + // we're in the phase of initializing a component, return + // the current contexts, so that the values can be read in constructors. + if (this.#isCreatingComponent) { + return this.currentContexts(); } + + return null; + } + + createComponent() { + // Indicates that a component instance is being created, see + // "contextsFor" above for how we use this. + this.#isCreatingComponent = true; } } diff --git a/ember-provide-consume-context/src/-private/utils.ts b/ember-provide-consume-context/src/-private/utils.ts index 6bdc1d0..bf08d7d 100644 --- a/ember-provide-consume-context/src/-private/utils.ts +++ b/ember-provide-consume-context/src/-private/utils.ts @@ -29,7 +29,7 @@ export function getProvider(owner: any, contextKey: keyof ContextRegistry) { return null; } - const contextsObject = provideConsumeContextContainer.contexts.get(owner); + const contextsObject = provideConsumeContextContainer.contextsFor(owner); return contextsObject?.[contextKey]; } diff --git a/test-app/tests/integration/components/decorators-test.ts b/test-app/tests/integration/components/decorators-test.ts index 628ccd5..5747d28 100644 --- a/test-app/tests/integration/components/decorators-test.ts +++ b/test-app/tests/integration/components/decorators-test.ts @@ -1,6 +1,6 @@ import { module, test } from 'qunit'; import { setupRenderingTest } from 'ember-qunit'; -import { render, click } from '@ember/test-helpers'; +import { render, click, settled } from '@ember/test-helpers'; import { hbs } from 'ember-cli-htmlbars'; import Component from '@glimmer/component'; import { setComponentTemplate } from '@ember/component'; @@ -89,6 +89,212 @@ module('Integration | Decorators', function (hooks) { assert.dom('#content').hasText('1'); }); + test('providing a class instance', async function (assert) { + class MyTestClass { + @tracked value = 1; + } + + const testInstance = new MyTestClass(); + + class TestProviderComponent extends Component<{ + Element: HTMLDivElement; + Blocks: { + default: []; + }; + }> { + @provide('my-test-context') + contextState = testInstance; + } + + setComponentTemplate( + // @ts-ignore + hbs`{{! @glint-ignore }} +
{{yield}}
+ `, + TestProviderComponent, + ); + + class TestConsumerComponent extends Component<{ + Element: HTMLDivElement; + }> { + @consume('my-test-context') contextValue!: string; + } + + setComponentTemplate( + // @ts-ignore + hbs`{{! @glint-ignore }} +
{{this.contextValue.value}}
+ `, + TestConsumerComponent, + ); + + interface TestContext { + TestProviderComponent: typeof TestProviderComponent; + TestConsumerComponent: typeof TestConsumerComponent; + } + (this as unknown as TestContext).TestProviderComponent = + TestProviderComponent; + (this as unknown as TestContext).TestConsumerComponent = + TestConsumerComponent; + + await render(hbs` + + + + `); + + assert.dom('#content').hasText('1'); + + testInstance.value = 2; + await settled(); + assert.dom('#content').hasText('2'); + }); + + test('a consumer can read context during initialization', async function (assert) { + class TestProviderComponent extends Component<{ + Element: HTMLDivElement; + Blocks: { + default: []; + }; + }> { + @provide('my-test-context') + get myState() { + return '1'; + } + } + + setComponentTemplate( + // @ts-ignore + hbs`{{! @glint-ignore }} +
{{yield}}
+ `, + TestProviderComponent, + ); + + class TestConsumerComponent extends Component<{ + Element: HTMLDivElement; + }> { + @consume('my-test-context') _contextValue!: string; + + contextValue = this._contextValue; + } + + setComponentTemplate( + // @ts-ignore + hbs`{{! @glint-ignore }} +
{{this.contextValue}}
+ `, + TestConsumerComponent, + ); + + interface TestContext { + TestProviderComponent: typeof TestProviderComponent; + TestConsumerComponent: typeof TestConsumerComponent; + } + (this as unknown as TestContext).TestProviderComponent = + TestProviderComponent; + (this as unknown as TestContext).TestConsumerComponent = + TestConsumerComponent; + + await render(hbs` + + + + `); + + assert.dom('#content').hasText('1'); + }); + + test('a provider can read parent provider with same key in constructor', async function (assert) { + class TestProviderComponent extends Component<{ + Element: HTMLDivElement; + Blocks: { + default: []; + }; + }> { + @provide('my-test-context') + get myState() { + return '1'; + } + } + + setComponentTemplate( + // @ts-ignore + hbs`{{! @glint-ignore }} +
{{yield}}
+ `, + TestProviderComponent, + ); + + class TestProviderComponent2 extends Component<{ + Element: HTMLDivElement; + Blocks: { + default: []; + }; + }> { + @consume('my-test-context') _parentContextValue!: string; + parentContextValue = this._parentContextValue; + + @provide('my-test-context') + get myState() { + return '2'; + } + + @provide('my-test-parent-context') + get myParentState() { + return this.parentContextValue; + } + } + + setComponentTemplate( + // @ts-ignore + hbs`{{! @glint-ignore }} +
{{yield}}
+ `, + TestProviderComponent2, + ); + + class TestConsumerComponent extends Component<{ + Element: HTMLDivElement; + }> { + @consume('my-test-context') contextValue!: string; + @consume('my-test-parent-context') parentContextValue!: string; + } + + setComponentTemplate( + // @ts-ignore + hbs`{{! @glint-ignore }} +
{{this.contextValue}}
+ {{! @glint-ignore }} +
{{this.parentContextValue}}
+ `, + TestConsumerComponent, + ); + + interface TestContext { + TestProviderComponent: typeof TestProviderComponent; + TestProviderComponent2: typeof TestProviderComponent2; + TestConsumerComponent: typeof TestConsumerComponent; + } + (this as unknown as TestContext).TestProviderComponent = + TestProviderComponent; + (this as unknown as TestContext).TestProviderComponent2 = + TestProviderComponent2; + (this as unknown as TestContext).TestConsumerComponent = + TestConsumerComponent; + + await render(hbs` + + + + + + `); + + assert.dom('#content').hasText('2'); + assert.dom('#parent-content').hasText('1'); + }); + test('a component can provide multiple contexts', async function (assert) { class TestProviderComponent extends Component<{ Element: HTMLDivElement; @@ -429,6 +635,247 @@ module('Integration | Decorators', function (hooks) { assert.dom('#content-2').hasText(''); }); + test('consuming context in conditional', async function (assert) { + class TestProviderComponent extends Component<{ + Element: HTMLDivElement; + Blocks: { + default: []; + }; + }> { + @tracked count = 1; + + @provide('my-test-context') + get myState() { + return this.count; + } + + increment = () => { + this.count++; + }; + } + + setComponentTemplate( + // @ts-ignore + hbs`{{! @glint-ignore }} +
+ {{! @glint-ignore }} + + {{! @glint-ignore }} + {{yield}} +
+ `, + TestProviderComponent, + ); + + class TestConsumerComponent extends Component<{ + Element: HTMLDivElement; + }> { + @consume('my-test-context') contextValue!: string; + } + + setComponentTemplate( + // @ts-ignore + hbs`{{! @glint-ignore }} +
{{this.contextValue}}
+ `, + TestConsumerComponent, + ); + + class TrackedContainer { + @tracked hidden = false; + } + + const trackedContainer = new TrackedContainer(); + + interface TestContext { + TestProviderComponent: typeof TestProviderComponent; + TestConsumerComponent: typeof TestConsumerComponent; + trackedContainer: TrackedContainer; + } + (this as unknown as TestContext).TestProviderComponent = + TestProviderComponent; + (this as unknown as TestContext).TestConsumerComponent = + TestConsumerComponent; + (this as unknown as TestContext).trackedContainer = trackedContainer; + + await render(hbs` + + {{#unless this.trackedContainer.hidden}} + + {{/unless}} + + `); + + assert.dom('#content').exists(); + assert.dom('#content').hasText('1'); + + trackedContainer.hidden = true; + await settled(); + assert.dom('#content').doesNotExist(); + + trackedContainer.hidden = false; + await settled(); + assert.dom('#content').exists(); + assert.dom('#content').hasText('1'); + + trackedContainer.hidden = true; + await settled(); + await click('#increment'); + trackedContainer.hidden = false; + await settled(); + assert.dom('#content').exists(); + assert.dom('#content').hasText('2'); + }); + + test('context provider in conditional', async function (assert) { + class TestProviderComponent extends Component<{ + Element: HTMLDivElement; + Blocks: { + default: []; + }; + }> { + @provide('my-test-context') + get myState() { + return '1'; + } + } + + setComponentTemplate( + // @ts-ignore + hbs`{{! @glint-ignore }} +
{{yield}}
+ `, + TestProviderComponent, + ); + + class TestConsumerComponent extends Component<{ + Element: HTMLDivElement; + }> { + @consume('my-test-context') contextValue!: string; + } + + setComponentTemplate( + // @ts-ignore + hbs`{{! @glint-ignore }} +
{{this.contextValue}}
+ `, + TestConsumerComponent, + ); + + class TrackedContainer { + @tracked hidden = false; + } + + const trackedContainer = new TrackedContainer(); + + interface TestContext { + TestProviderComponent: typeof TestProviderComponent; + TestConsumerComponent: typeof TestConsumerComponent; + trackedContainer: TrackedContainer; + } + (this as unknown as TestContext).TestProviderComponent = + TestProviderComponent; + (this as unknown as TestContext).TestConsumerComponent = + TestConsumerComponent; + (this as unknown as TestContext).trackedContainer = trackedContainer; + + await render(hbs` + {{#unless this.trackedContainer.hidden}} + + + + {{/unless}} + `); + + assert.dom('#content').exists(); + assert.dom('#content').hasText('1'); + + trackedContainer.hidden = true; + await settled(); + assert.dom('#content').doesNotExist(); + + trackedContainer.hidden = false; + await settled(); + assert.dom('#content').exists(); + assert.dom('#content').hasText('1'); + }); + + test('consuming context with another provider in conditional sibling', async function (assert) { + class TestProviderComponent extends Component<{ + Args: { + value: string; + }; + Element: HTMLDivElement; + Blocks: { + default: []; + }; + }> { + @provide('my-test-context') + get myState() { + return this.args.value; + } + } + + setComponentTemplate( + // @ts-ignore + hbs`{{! @glint-ignore }} +
{{yield}}
+ `, + TestProviderComponent, + ); + + class TestConsumerComponent extends Component<{ + Element: HTMLDivElement; + }> { + @consume('my-test-context') contextValue!: string; + } + + setComponentTemplate( + // @ts-ignore + hbs`{{! @glint-ignore }} +
{{this.contextValue}}
+ `, + TestConsumerComponent, + ); + + class TrackedContainer { + @tracked hidden = true; + } + + const trackedContainer = new TrackedContainer(); + + interface TestContext { + TestProviderComponent: typeof TestProviderComponent; + TestConsumerComponent: typeof TestConsumerComponent; + trackedContainer: TrackedContainer; + } + (this as unknown as TestContext).TestProviderComponent = + TestProviderComponent; + (this as unknown as TestContext).TestConsumerComponent = + TestConsumerComponent; + (this as unknown as TestContext).trackedContainer = trackedContainer; + + await render(hbs` + + {{#unless this.trackedContainer.hidden}} + + {{/unless}} + + + + `); + + assert.dom('#content').hasText('1'); + + trackedContainer.hidden = false; + await settled(); + assert.dom('#content').hasText('1'); + + trackedContainer.hidden = true; + await settled(); + assert.dom('#content').hasText('1'); + }); + test('nesting different contexts', async function (assert) { class TestProviderComponent1 extends Component<{ Element: HTMLDivElement;