Add makeContext: render-tree-scoped provide/consume (RFC #1154)#15
Open
NullVoxPopuli-ai-agent wants to merge 8 commits into
Open
Conversation
Adds a public, always-on render-tree scope tracker that powers the component-tree provide/consume pattern that the Ember community has been asking for in RFC emberjs#975 / emberjs#1154. Public API (exported from @ember/renderer): import { getScope, addToScope, type Scope } from '@ember/renderer'; // Inside any code that runs during rendering: let scope = getScope(); // current scope, or undefined addToScope({ key: 'theme', value: 'dark' }); // Walk up the render tree: for (let entry of scope.entries) { ... } Implementation notes: - `RenderScopeTracker` lives in @glimmer/runtime parallel to `DebugRenderTree`, but is always-on because this is part of the public surface area (not a debug-only tool). - Component lifecycle wires the tracker into: VM_CREATE_COMPONENT_OP -> push scope before manager.create() so user-land constructors can call addToScope against their own scope. VM_DID_RENDER_LAYOUT_OP -> pop scope on initial render and on every updating frame. Updating opcodes (RenderScopeUpdateOpcode / RenderScopeExitOpcode) re-push and pop on re-renders so descendant scope reads stay correct. - The Scope's `entries` iterator walks the current node's own additions newest-first, then up through each ancestor. This is the exact shape a userland `consume(key)` needs to find the nearest provider. - Begin/commit reset the stack and the module-level "active tracker" pointer, so getScope() correctly returns undefined outside of render. Userland provide/consume is included as an integration test (in packages/@ember/-internals/glimmer/tests/integration/render-tree-scope-test.ts): a `<Reader/>` nested inside multiple `<Provide/>` components consumes the nearest provider's value, exactly matching the "How we teach this" example in RFC emberjs#1154. Scope of this prototype: components only. Helpers, modifiers, and plain-curly functions are not yet wired, which matches the immediate provide/consume use case. Extending to other invokables is straightforward once the shape lands -- the tracker doesn't care what the bucket is. Refs: - emberjs/rfcs#1154 - emberjs/rfcs#975 - https://github.com/customerio/ember-provide-consume-context (prior art) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
|
- Run prettier on render-scope.ts. - Register getScope and addToScope in tests/docs/expected.cjs so the docs-coverage test recognises the new @ember/renderer exports. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…emberjs#1154 comments NVP proposed in emberjs/rfcs#1154 (comment) that the actual user-facing primitive should be `makeContext`: const foo = makeContext(Foo) <foo.Provide> {{#let (foo.consume) as |f|}}{{f.bar}}{{/let}} </foo.Provide> {{ (foo.consume) }} <-- throws This commit pivots the public API in @ember/renderer from the lower-level getScope/addToScope primitives (which stay as internal infrastructure) to the higher-level makeContext returning `{ Provide, consume }`. Behavior matches NVP's clarifications: - consume() throws when no <Provide> is found in the render tree. - consume() throws when called outside a render (the scope is render-time only; an undefined scope is never legitimate for context). - The value returned by the factory is not itself tracked, but @Tracked state on it remains reactive -- consumers re-render when those fields change. - Two forms supported, per NVP's example and rtablada's extension: makeContext(Klass) // each <Provide> calls `new Klass()` makeContext(() => value) // each <Provide> calls the factory Detected via a Function.prototype.toString sniff (`/^class[\s{]/`). Implementation notes: - `<Provide>` is built on the same internal-component infrastructure as Input / Textarea / LinkTo (lib/components/internal.ts + `opaquify`), so it ships inside ember-source without taking a dep on @glimmer/component. - Each `<Provide>` constructor instantiates the factory and pushes [key, value] onto the current render-tree scope; consume walks scope.entries looking for a matching key. The closure-captured `key` identity isolates contexts from each other. - The `<Provide>` template is the static `{{yield}}`, precompiled once and shared across all Provide classes. Tests (packages/@ember/-internals/glimmer/tests/integration/render-tree-scope-test.ts) cover the five things real consumers care about: throws outside render, throws with no provider, nearest-provider lookup, factory form, and @tracked-reactivity through the provided value. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…pture component
The previous test used a Capture component with an empty template to grab
the instance via its constructor. An empty template renders as `<!---->`
in the DOM, which polluted assertHTML('0') -> actual was `<!---->0`.
Move the capture into the factory closure itself -- the factory runs
exactly once per <Provide>, so it's a clean place to grab the instance
without adding any DOM artifacts.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Extends `<Provide>` with an optional `@value` arg (matching rtablada's RFC emberjs#1154 example) and ports the substantive cases from customerio/ember-provide-consume-context's built-in-components-test.ts. @value support: - `args.named.value` is stored as a lazy `read()` thunk in the scope entry. `valueForRef` consumes the tracking tag when called inside the consumer's tracking frame, so consumers re-render automatically when the arg updates. - When `@value` is not passed, the factory runs once per <Provide> and the cached result is returned (preserves identity across re-renders, which downstream code -- ref tracking, caching -- relies on). The scope-entry shape changes from `[key, value]` to a typed `{ key, read }` record (with an `isContextEntry` guard) so that future extensions don't have to overload the array form. Tests ported / adapted (in the new "behavior ported from ember-provide-consume-context" module): - a consumer can read context - a consumer reads from the closest provider - consumer's value updates when @value changes - a consumer can't access a context it isn't nested in - sibling Provides with the same context do not bleed - consumer is reactive across an {{#if}} that toggles it on and off - a conditional <Provide> tears down and re-instates correctly - a conditional sibling <Provide> does not override an outer one - multiple distinct contexts can be nested - @Tracked state on a factory-provided class instance is reactive - consumer at component-instance init time sees the nearest provider - factory-provided value is stable across the same Provide re-render EPCC tests that did NOT port: - "reading a context that does not exist returns undefined" -- the makeContext API throws instead, per NVP's "reduce harm" clarification. Already covered by the "consume() throws when no <Provide>" test. - @provide / @consume decorator tests -- decorators are a separate API paradigm not in scope for the makeContext primitive. - test-support helpers (`setupRenderWrapper`, `provide` in beforeEach) -- test-support is a separate concern that should be addressed once the primary API lands. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
babel-plugin-ember-template-compilation requires the first argument to
precompileTemplate to be a literal string. The .join('\n') array form
broke the build for the ported EPCC test cases. Switch them to template
literals.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replace the `/^class[\s{]/` toString sniff with the prototype-based
`isNewable` pattern lifted from ember-primitives
(ember-primitives/src/utils.ts):
proto !== undefined && proto.constructor === fn
Arrow functions have no `prototype` and fail this check; classes (and
old-style constructor functions) pass. Robust under transpilation, where
the toString check would silently regress.
Adds two new test modules covering the previously-identified gaps:
extra-coverage:
- class-form (`makeContext(SomeClass)`) is actually invoked with `new`
(guarded by an in-constructor `new.target === undefined` check, so any
regression to plain invocation fails the test)
- consume() works inside a plain function helper (`defineSimpleHelper`)
- consume() works inside a modifier (`defineSimpleModifier`)
- explicit `@value={{undefined}}` provides undefined (does NOT throw
"no provider")
- explicit `@value={{null}}` provides null
- multiple consume() calls in the same template return the same instance
cross-renderComponent isolation:
- two independent `renderComponent` calls into separate sub-elements
do not share scope state: a <Provide> in one tree is invisible from
the other
Engine / `{{outlet}}` boundaries remain explicitly out of scope -- they
need design discussion, not just a test.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…sion) The "consume() inside modifier" test asserted the wrong thing. Modifier install runs during `transaction.commit()`, which fires AFTER the render frame has popped its scope stack -- so consume() inside a modifier callback legitimately throws "outside of rendering". Rewrite the test to assert that throw and document it as a known limitation. This pins down the current behavior so a future fix (e.g. re-pushing the enclosing component's scope for the duration of modifier install) doesn't break silently. RFC emberjs#1154 motivates "all invokables" -- modifier support is a follow-up worth its own design discussion, since it interacts with the transaction model. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Implements
makeContextas the user-facing context API discussed in RFC emberjs#1154, on top of an internal render-tree scope tracker.Behavior (matching the RFC clarifications)
consume()throws when no<Provide>is in the hierarchy. A missing provider is a bug, not a "fall back to undefined" state.consume()throws when called outside of rendering.@trackedstate on it is — mutating those fields re-renders consumers, which is the reactivity story called for in the RFC.How it works
Two layers:
@glimmer/runtime/lib/render-scope.ts, internal). Always-on, mirrorsDebugRenderTree's lifecycle. Each component pushes its own scope node atVM_CREATE_COMPONENT_OP(before the user constructor runs) and pops it atVM_DID_RENDER_LAYOUT_OP. An updating opcode re-pushes/pops on re-render so descendant lookups stay correct.scope.entriesiterates the current node's own additions newest-first, then walks up — exactly the search shapeconsume()needs.makeContext(packages/@ember/-internals/glimmer/lib/make-context.ts, public). BuildsProvideon the same internal-component infrastructure asInput/Textarea/LinkTo(so ember-source doesn't take a dep on@glimmer/component). Each<Provide>constructor instantiates the factory and pushes[key, value]onto the scope;consumewalksscope.entriesfor the closure-captured key. The shared{{yield}}template is precompiled once.Scope
@valueoverride on<Provide>yet — the factory is the value source. If we want explicit value passing later (à la rtablada'sPermissionContextexample) it's an additive change.getScope/addToScopeare kept private to@glimmer/runtime(renamedgetCurrentRenderScope/addToCurrentRenderScopeinternally). If a future RFC wants those public, exposing them is a one-line re-export.Tests
packages/@ember/-internals/glimmer/tests/integration/render-tree-scope-test.tscovers what consumers actually care about:consume()throws outside a renderconsume()throws with no provider in the tree<Provide>shadowingmakeContext(() => value))(context.consume)works as a template helper@trackedfields on the provided value cause consumer re-renderValidation
pnpm exec tsc --noEmitclean.pnpm build:jssucceeds for bothdist/devanddist/prod.pnpm run lint:docs/lint:formatclean.pnpm test:nodepasses.Test plan
pnpm install && pnpm testruns greenpnpm exec tsc --noEmitcleanDebugRenderTree)References
🤖 Generated with Claude Code