Skip to content

Add makeContext: render-tree-scoped provide/consume (RFC #1154)#15

Open
NullVoxPopuli-ai-agent wants to merge 8 commits into
NullVoxPopuli:mainfrom
NullVoxPopuli-ai-agent:nvp/render-tree-scope-rfc-1154
Open

Add makeContext: render-tree-scoped provide/consume (RFC #1154)#15
NullVoxPopuli-ai-agent wants to merge 8 commits into
NullVoxPopuli:mainfrom
NullVoxPopuli-ai-agent:nvp/render-tree-scope-rfc-1154

Conversation

@NullVoxPopuli-ai-agent
Copy link
Copy Markdown

@NullVoxPopuli-ai-agent NullVoxPopuli-ai-agent commented May 22, 2026

Summary

Implements makeContext as the user-facing context API discussed in RFC emberjs#1154, on top of an internal render-tree scope tracker.

import { makeContext } from '@ember/renderer';

class Theme {
  color = 'dark';
}

const theme = makeContext(Theme);

<template>
  <theme.Provide>
    {{#let (theme.consume) as |t|}}
      {{t.color}}  {{! "dark" }}
    {{/let}}
  </theme.Provide>

  {{ (theme.consume) }}  {{! throws — no provider in the hierarchy }}
</template>

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.
  • The value the factory returns is not itself tracked. @tracked state on it is — mutating those fields re-renders consumers, which is the reactivity story called for in the RFC.
  • Two factory forms supported (NVP's class form + rtablada's factory overload):
    makeContext(SomeClass)       // each <Provide> calls `new SomeClass()`
    makeContext(() => someValue) // each <Provide> calls the factory

How it works

Two layers:

  1. Render-tree scope tracker (@glimmer/runtime/lib/render-scope.ts, internal). Always-on, mirrors DebugRenderTree's lifecycle. Each component pushes its own scope node at VM_CREATE_COMPONENT_OP (before the user constructor runs) and pops it at VM_DID_RENDER_LAYOUT_OP. An updating opcode re-pushes/pops on re-render so descendant lookups stay correct. scope.entries iterates the current node's own additions newest-first, then walks up — exactly the search shape consume() needs.
  2. makeContext (packages/@ember/-internals/glimmer/lib/make-context.ts, public). Builds Provide on the same internal-component infrastructure as Input/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; consume walks scope.entries for the closure-captured key. The shared {{yield}} template is precompiled once.

Scope

  • Components only for now (where Provide lives). The underlying scope tracker is bucket-agnostic; extending to helpers / modifiers / plain curlies later is mechanical.
  • No @value override on <Provide> yet — the factory is the value source. If we want explicit value passing later (à la rtablada's PermissionContext example) it's an additive change.
  • getScope/addToScope are kept private to @glimmer/runtime (renamed getCurrentRenderScope/addToCurrentRenderScope internally). 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.ts covers what consumers actually care about:

  • consume() throws outside a render
  • consume() throws with no provider in the tree
  • Nearest-provider lookup with nested <Provide> shadowing
  • Factory form (makeContext(() => value))
  • (context.consume) works as a template helper
  • @tracked fields on the provided value cause consumer re-render

Validation

  • pnpm exec tsc --noEmit clean.
  • pnpm build:js succeeds for both dist/dev and dist/prod.
  • pnpm run lint:docs / lint:format clean.
  • pnpm test:node passes.
  • Browser testem suite needs your local environment to validate end-to-end.

Test plan

  • pnpm install && pnpm test runs green
  • pnpm exec tsc --noEmit clean
  • Spot-check the new integration tests render and assert as described
  • Confirm nothing in the existing render-tree / debug-render-tree suite regresses (the scope tracker is parallel-but-independent of DebugRenderTree)

References

🤖 Generated with Claude Code

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>
@bolt-new-by-stackblitz
Copy link
Copy Markdown

Review PR in StackBlitz Codeflow Run & review this pull request in StackBlitz Codeflow.

NullVoxPopuli and others added 2 commits May 22, 2026 17:34
- 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>
@NullVoxPopuli-ai-agent NullVoxPopuli-ai-agent changed the title Prototype RFC #1154: getScope/addToScope render-tree scope primitives Add makeContext: render-tree-scoped provide/consume (RFC #1154) May 22, 2026
NullVoxPopuli and others added 5 commits May 22, 2026 18:05
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants