Skip to content

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

Merged
NullVoxPopuli merged 15 commits into
NullVoxPopuli:nvp/implement-contextfrom
NullVoxPopuli-ai-agent:nvp/render-tree-scope-rfc-1154
Jun 9, 2026
Merged

NullVoxPopuli merged 15 commits into
NullVoxPopuli:nvp/implement-contextfrom
NullVoxPopuli-ai-agent:nvp/render-tree-scope-rfc-1154

Conversation

@NullVoxPopuli-ai-agent

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

Copy link
Copy Markdown

Summary

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

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

class Theme {
  color = 'dark';
}

const theme = makeContext<Theme>();

<template>
  <theme.Provide @value={{this.theme}}>
    {{#let (theme.consume) as |t|}}
      {{t.color}}
    {{/let}}
  </theme.Provide>

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

API

  • makeContext<T>() takes no value — the type parameter declares the shape, and it returns { Provide, consume }.
  • <context.Provide @value={{...}}> provides a value to everything in its block. Nested <Provide>s shadow; siblings don't bleed.
  • (context.consume) (a function helper) or context.consume() (in JS, e.g. a component constructor) reads the nearest provided value.
  • Exported from @ember/helper (it returns a consume usable as a template helper, so it sits with the other helpers).

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 binding is reactive: when the argument updates, consumers re-render. If @value is a stable object, mutating its @tracked fields likewise re-renders consumers.
  • Omitting @value (or passing undefined/null) provides that value — the provider is in the tree, so consume() returns it rather than throwing.

How it works

Two layers:

  1. Render-tree scope tracker (@glimmer/runtime/lib/render-scope.ts, internal). Always-on, mirrors DebugRenderTree's stack lifecycle. Each component pushes a node at VM_CREATE_COMPONENT_OP (before the user constructor runs) and pops it at VM_DID_RENDER_LAYOUT_OP; matched updating opcodes re-push/pop on re-render so a consume() re-evaluated mid-update still finds the right parent chain. Each node holds a lazily-allocated Map<key, read>; lookup walks the node up its parents for the nearest provider. The two context operations are exposed as module-level helpers (provideRenderContext / lookupRenderContext) so a plain consume() — which has no handle to the VM — can reach the active tracker.
  2. makeContext (packages/@ember/-internals/glimmer/lib/make-context.ts). 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> stores a lazy read for its @value reference on the current scope node; consume walks up for the nearest. The closure-created consume function doubles as the context's identity key (unique per makeContext() call), so there's no separate token object. The shared {{yield}} template is precompiled once.

Why an always-on tracker (and is it free?)

A reactive, render-tree-scoped, ambiently-callable consume() can't be built from today's public APIs — there's no public "after my block rendered" hook, and a plain consume() has no scope handle. (This is exactly what the RFC proposes to add; ember-provide-consume-context ships its own component manager for the same reason.) Glimmer's built-in dynamic scope was evaluated as an alternative but is more invasive (it's hard-wired to outletState, the shared InternalManager can't selectively opt in, and the JS-imperative consume() would still need an ambient) — a lateral-to-worse move, so the contained tracker stays.

Performance: tracerbench compare (krausest, fidelity 20, control = main) shows no difference across all 23 markers — render, re-render, update, select, swap, remove, clear, paint — even though that workload renders/updates thousands of components and none use context. The per-component lifecycle + updating opcodes are below the measurement noise floor, so the always-on design needs no "gate on first use."

Scope / notes

  • Components only for now (where <Provide> lives). The tracker is bucket-agnostic; extending to helpers / modifiers later is mechanical. Known limitation pinned by a test: consume() inside a modifier install throws "outside of rendering" (modifier install runs during commit, after the render stack has unwound) — a follow-up, not a regression.
  • The render-scope primitives are private to @glimmer/runtime. makeContext is the only public surface in this PR.

Tests

packages/@ember/-internals/glimmer/tests/integration/render-tree-scope-test.ts (the substantive scenarios are ported from ember-provide-consume-context, the prior art NVP called out):

  • consume() throws outside a render, and with no provider in the tree
  • nearest-provider lookup with nested <Provide> shadowing; sibling isolation
  • reactivity when @value changes, across {{#if}} toggles, and via @tracked fields on a provided object
  • (context.consume) as a template helper, from a plain function helper, and (known-limitation) from a modifier
  • explicit @value={{undefined}} / @value={{null}} and omitted @value
  • multiple consume() calls return the same identity; cross-renderComponent isolation

Plus:

  • End-to-end smoke test in smoke-tests/scenarios/basic-test.ts (make-context-test.gjs) exercising the published @ember/helper export in a real built app.
  • Type smoke test in type-tests/@ember/helper-tests.ts pinning the generic-only signature (makeContext<T>() → Context<T>, consume() → T, and makeContext(SomeClass) is a type error).

Validation

  • pnpm run type-check:internals / type-check:types clean.
  • pnpm build:js succeeds for dist/dev and dist/prod.
  • pnpm run lint:docs / lint:eslint / lint:format clean.
  • The 23 makeContext integration tests pass in headless Chrome.
  • tracerbench compare vs main: no regression (see above).

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>
@NullVoxPopuli
NullVoxPopuli changed the base branch from main to nvp/implement-context June 7, 2026 03:14
NullVoxPopuli and others added 7 commits June 7, 2026 00:05
`makeContext` no longer takes a class/factory. It takes a type parameter
only (`makeContext<T>()`) and the value is supplied at render time through
`<Provide @value={{...}}>`. This removes the dual class/factory forms (and
the `isNewable` detection + `ContextFactory` type) in favor of a single,
explicit way to provide a value.

- `consume()` still throws outside rendering and when no provider exists.
- Omitting `@value` (or passing undefined/null) provides that value rather
  than throwing -- the provider is in the tree, it just has no value.
- The `@value` binding stays reactive via `valueForRef`.

Rewrote the integration suite to the `@value` API and added an explicit
smoke-test module. All 23 tests pass in headless Chrome; tsc, eslint,
prettier, and docs coverage are clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
makeContext is a helper-style API (it returns a `consume` usable as a
template helper), so it belongs alongside the other helpers. Move the
public export from @ember/renderer to @ember/helper and update the
`@module`/`@for`/import-example docs accordingly.

Add a type smoke test in type-tests/@ember/helper-tests.ts that pins the
generic-only signature: `makeContext<T>()` returns `Context<T>`,
`consume()` returns `T`, and passing a class is now a type error
(`@ts-expect-error makeContext(Theme)`).

type-check:internals, type-check:types, eslint, prettier, and docs
coverage are clean; the 23 makeContext browser tests still pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds `make-context-test.gjs` to the basic smoke-test app (run across the
classic / embroider-webpack / embroider-vite scenarios). Unlike the
@glimmer/runtime QUnit tests, this exercises makeContext through the
published `@ember/helper` export in a real built app:

- provide a value via `<Provide @value>` and consume it
- nearest-provider lookup with nested `<Provide>`
- consumer re-renders when `@value` changes (tracked)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…/addToScope

makeContext is the only consumer of the render-tree scope, and the only
public API in this PR -- so the generic `getScope`/`addToScope` surface
(the `RenderScope` view object, its `entries` iterable, the lazy view
caching, and the `ContextEntry` type guard in make-context) was more
machinery than the feature needs.

Replace it with two context-specific helpers in @glimmer/runtime:

  provideRenderContext(key, read)   // <Provide> stores key -> lazy read
  lookupRenderContext(key)          // consume() walks up for the nearest
                                    //   undefined = outside rendering
                                    //   null      = no provider
                                    //   fn        = nearest read

Each render node now holds a lazily-allocated `Map<key, read>` instead of
an untyped entry array, and `consume()` is a direct walk-up + read rather
than iterating an `unknown` entry stream and type-guarding each item. The
RenderScopeTracker lifecycle (create/enter/exit/willDestroy + the
updating opcodes) is unchanged, as is all observable behavior.

The public `RenderScope` interface and the `getCurrentScope`/
`addToCurrentScope` members are removed from @glimmer/interfaces; the
tracker interface now exposes only the render-node lifecycle.

tsc clean; all 23 makeContext browser tests still pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ject key

The render-scope lookup needs a stable, unique-per-context identity token.
That was a freshly-allocated `{}`, but `consume` already is one: it is
created once per `makeContext()` call and is in scope for both the
`consume()` reader and the `<Provide>` constructor. Reusing it as the Map
key removes the extra object (and the "why is there an empty object?"
question) with no behavior change.

All 23 makeContext browser tests still pass; tsc clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Audited every method against what actually runs:

- Drop `commit()` -- it only called `reset()`, which `begin()` already does
  at the start of the next transaction. Removed its call in environment.ts.
- Drop `willDestroy()` (and the `associateDestroyable` + `registerDestructor`
  wiring in the create opcode). `lookup` only ever sees nodes via the live
  stack, and a destroyed component is never re-entered, so this was pure
  eager cleanup -- the `nodes` WeakMap collects on GC regardless.
- Drop the `isRendering` getter and the private `reset()`; fold both into
  `lookup` (returns `undefined` when there is no current frame) and an
  inline loop in `begin()`.
- Pare the `RenderScopeTracker` interface in @glimmer/interfaces down to the
  three lifecycle methods the opcodes actually call (`create`/`enter`/`exit`).

What's left is the irreducible set: begin (error recovery), create/enter/exit
(stack lifecycle, proven load-bearing), and provide/lookup (the two real ops).

All 23 makeContext browser tests pass; tsc clean. Net -66 lines.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…age list

Comment-only corrections surfaced by an audit after the API/internals
changes landed:

- make-context.ts: `@for @ember/renderer` -> `@ember/helper` (makeContext is
  exported from @ember/helper now; matches the canonical block in
  @ember/helper/index.ts).
- component.ts: the VM_DID_RENDER_LAYOUT_OP exit comment referenced
  `VM_GET_COMPONENT_SELF_OP`; the matching `create()` is in
  `VM_CREATE_COMPONENT_OP`.
- render-tree-scope-test.ts: note the "omitting @value" case in the
  extra-coverage suite summary.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@NullVoxPopuli
NullVoxPopuli merged commit e50051f into NullVoxPopuli:nvp/implement-context Jun 9, 2026
42 checks passed
@NullVoxPopuli
NullVoxPopuli deleted the nvp/render-tree-scope-rfc-1154 branch June 9, 2026 03:18
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