Skip to content

Add consume() and captureContext() to createContext#21488

Open
NullVoxPopuli-ai-agent wants to merge 2 commits into
emberjs:nvp/implement-contextfrom
NullVoxPopuli-ai-agent:context-consume-captured
Open

Add consume() and captureContext() to createContext#21488
NullVoxPopuli-ai-agent wants to merge 2 commits into
emberjs:nvp/implement-contextfrom
NullVoxPopuli-ai-agent:context-consume-captured

Conversation

@NullVoxPopuli-ai-agent

@NullVoxPopuli-ai-agent NullVoxPopuli-ai-agent commented Jul 6, 2026

Copy link
Copy Markdown
Contributor

Targets #21450's branch (nvp/implement-context), adding the escape hatch for reading a context outside of rendering.

The problem

The ambient reads — context.value, and any code path that runs during rendering — resolve against the currently-rendering node. Event handlers, timers, and other async callbacks run after the render stack has unwound, so there is no ambient position to resolve from, and value (correctly) throws. But an event handler is exactly the kind of place you want the app's nearest ThemeContext/SessionContext/etc.

The API

One addition to the object returned by createContext:

  • context.consume(component?) — with no argument it is identical to reading value (same resolution, same harm-reduction errors). Passed a component instance, it resolves the nearest <Provide> above that component's position in the render tree — no ambient render state required.
import Component from '@glimmer/component';
import { createContext } from '@ember/helper';
import { on } from '@ember/modifier';

const theme = createContext<Theme>();

class ThemedButton extends Component {
  onClick = () => {
    // theme.value would throw here (outside of rendering) -- but passing
    // the component instance resolves the nearest <theme.Provide> above it
    console.log(theme.consume(this).color);
  };

  <template>
    <button {{on 'click' this.onClick}}>{{yield}}</button>
  </template>
}

How the instance maps to a render-tree position is an implementation detail: the VM associates the user-facing component instance with its render-scope node when the component's self becomes known (VM_GET_COMPONENT_SELF_OP), via a WeakMap in the render-scope layer. User code never captures anything by hand. (An earlier revision exposed an explicit captureContext() step; removed per review.)

Semantics

  • 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. The value read is the provider's current @value, not a snapshot from render time (covered by a test that flips a @tracked value between reads).
  • Errors. consume(component) with an object that never rendered throws a pointed error (the association WeakMap doesn't know it); a rendered component with no matching provider above it throws the same harm-reduction style error as value. The "outside of rendering" error for ambient reads now points at consume(this) as the fix.
  • Coverage. Glimmer components, classic components, and internal components all get the association (their self refs are const refs wrapping the instance, so the eager read consumes no tracking tags). Template-only components have a NULL_REFERENCE self and are skipped — there is no instance for user code to pass.

Implementation notes

This rides on the base branch's retained node tree: RenderScopeTracker keeps parent links per node, so an associated node stays resolvable after the stack unwinds. The render-scope layer gains associateRenderScope() / lookupRenderContextFor() and a shared lookupInChain() walk; the association lives exactly as long as the component instance (WeakMap). Worth noting for the design comparison: the #21486 frame-epoch variant deliberately does not retain per-node parent chains, so this API would need a different mechanism there.

Testing

  • 9 integration tests, including an end-to-end event-handler flow: a real {{on "click"}} dispatch asserting the ambient read throws while consume(this) resolves; nearest-provider at different depths; one-instance-many-contexts; current-value-not-snapshot; missing-provider and never-rendered-object errors.
  • Type-tests for consume() / consume(component); docs coverage.
  • Filtered suites: createContext 33/33, component 1683 pass / 0 fail, render 1878 pass / 0 fail.

🤖 Generated with Claude Code

`context.consume()` with no argument behaves exactly like reading
`context.value`. Passed a handle from the new `captureContext()` export,
it resolves the nearest `<Provide>` above the captured render-tree
position instead -- which works outside of rendering, where the ambient
reads throw. This is the way to read a context from event handlers,
timers, and other async callbacks that run after the render stack has
unwound.

A capture is an opaque frozen handle validated through a WeakMap in the
render-scope layer (plus a type-only brand), so it can't be forged or
introspected. One handle works with every context: it captures the
render-tree position, not any one context's value, and resolution
happens at consume time so the value read is the provider's current
@value rather than a snapshot.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Comment thread packages/@ember/helper/index.ts Outdated
* class ThemedButton extends Component {
* // Field initializers run in the constructor, during rendering, so this
* // captures the position of <ThemedButton/> in the render tree.
* context = captureContext();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we don't want this -- a user should not need to worry about this and it should be an implementation detail.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed in 4fae99ccaptureContext() is gone from the public API. The VM now associates the component instance with its render-scope node automatically when the component's self is read (VM_GET_COMPONENT_SELF_OP), so user code just passes the component itself: theme.consume(this). The association is a WeakMap in the render-scope layer; self refs are const refs, so the eager read consumes no tracking tags, and template-only components (NULL_REFERENCE self) are skipped since there's no instance to pass.

Review feedback: users should not need to capture a handle themselves.
Drop the public captureContext() export entirely. Instead, the VM
associates the user-facing component instance with its render-scope
node when the component's self becomes known (VM_GET_COMPONENT_SELF_OP,
via a WeakMap in the render-scope layer), so an event handler can just
pass the component itself:

    onClick = () => theme.consume(this);

Component self refs are const refs wrapping the instance, so the eager
read consumes no tracking tags; template-only components have a
NULL_REFERENCE self and are skipped (there is no instance to pass).

Co-Authored-By: Claude Fable 5 <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