Skip to content

createContext: frame-epoch shallow binding for the render scope tracker#21486

Draft
NullVoxPopuli-ai-agent wants to merge 1 commit into
emberjs:nvp/implement-contextfrom
NullVoxPopuli-ai-agent:context-render-scope-frame-epoch
Draft

createContext: frame-epoch shallow binding for the render scope tracker#21486
NullVoxPopuli-ai-agent wants to merge 1 commit into
emberjs:nvp/implement-contextfrom
NullVoxPopuli-ai-agent:context-render-scope-frame-epoch

Conversation

@NullVoxPopuli-ai-agent

Copy link
Copy Markdown
Contributor

Alternative implementation of the RenderScopeTracker for consideration on #21450, replacing the retained node tree + stack with frame-epoch shallow binding. Same observable semantics — all 24 createContext tests and the full test suite pass unchanged — but the bookkeeping is cheaper on every axis the always-on hooks touch.

Caveats with the current approach (what this replaces)

The node-tree design pays a per-component cost for a per-provider feature:

  1. One retained node + WeakMap entry per component instance. Every component — provider or not — allocates a RenderScopeNode at create and registers it in the nodes WeakMap for its whole lifetime, so that update passes can re-push it.
  2. Two retained updating opcodes per component instance. RenderScopeUpdateOpcode + RenderScopeExitOpcode are emitted unconditionally and live in the updating tree for the component's lifetime. For an app with N live components that's 2N opcode objects plus 2N evaluate() calls (each with a WeakMap get) on every traversal that visits them — almost all doing nothing, since almost no component provides a context.
  3. O(ancestor-depth) lookup. Every context.value read walks the parent chain until it finds the key or hits the root.
  4. begin() drains the stack with a loop to recover from errored renders.

What this PR does instead

  • frameEpoch[d] holds a globally-unique id for the frame currently open at depth d. At any instant the open frames are exactly the ancestors of the render cursor — one per depth — so "is this provider entry still an ancestor of me" is two integer comparisons.
  • create/exit are integer ops (increment + id stamp / bare decrement) with no allocation, for every component. Closing a frame restores nothing: entries stamped with a closed frame's id are dead forever (ids are never reused), and get popped lazily.
  • Each context key owns its provider-entry stack (held in the createContext closure), so lookups touch only the consulted context's entries and there is no global map. lookup is O(1): skim dead tops, peek.
  • Update-pass opcodes are emitted only for providers. By the end of VM_CREATE_COMPONENT_OP the constructor has run, so provider status is known; non-providers get no updating opcodes at all. Update-pass depth then counts provider frames rather than tree depth, which is fine — correctness needs LIFO open/close discipline, not true depth.
  • begin() is two assignments — frames abandoned by an errored render become unreachable ids; the depth check kills their entries.

The only remaining loops (skim) pop dead entries exactly once each, amortized against the provides that pushed them, and run 0–1 iterations in the steady state. Because frames close LIFO and every push skims first, dead entries always form a contiguous top segment — a live entry is never buried under a dead one — so each key's stack stays at O(live entries).

Caveats with this approach (documented so they're weighed, not discovered)

  1. It leans on the traversal invariant that whenever a consumer's read re-runs during an update pass, every provider ancestor's enter opcode already fired in that same pass. This holds because cache-guard skips (JumpIfNotModifiedOpcode) jump over contiguous subtree ranges — a skip that excluded a provider's enter would exclude its whole subtree, consumer included — and resumed appends ({{#if}} flips) are reached via that same traversal. The node-tree design is insensitive to this invariant (nodes remember their parents from creation); shallow binding depends on it. It genuinely holds today, but a future VM change that re-evaluates a reference outside its enclosing component's bracketing would break shallow binding silently rather than loudly.
  2. One tri-state edge changes. With non-providers unbracketed during updates, a read during an update pass with no provider anywhere above it reports "outside of rendering" (undefined) rather than "no provider" (null). Both are errors either way, and initial render already throws the right one; only the re-render error message differs. The commit-phase modifier test still passes (that path is depth === 0 in both designs).
  3. provide() infers the providing component from "constructor runs immediately after its own create" (currentBucket). True for everything the VM can express today, but it's an ordering assumption rather than an explicit handoff.
  4. Dead entries linger until the next touch of that key's stack (a handful of objects at most, bounded by the skim-on-push discipline) rather than being freed eagerly at exit.
  5. Frame ids are a module-level monotonic counter shared across environments — that's what makes cross-environment false-liveness impossible, but it is module-level mutable state.

Perf shape (why this matters for an always-on hook)

node tree (#21450) frame epochs (this PR)
create/exit, per component node alloc + WeakMap set + stack push/pop ++/-- + id stamp
retained, per component node + WeakMap entry + 2 updating opcodes nothing
update pass, per non-provider 2 opcode evaluates + WeakMap get nothing (no opcodes emitted)
update pass, per provider 2 opcode evaluates + WeakMap get 2 opcode evaluates + WeakMap get
context.value read O(ancestor depth) walk O(1) peek
retained, per provider contexts Map on its node provisions WeakMap entry + 1 live stack entry per key

Verified: type-check:internals, eslint, prettier, the 24-test create-context module, and the component / render / in-element / outlet filtered suites (3,634 tests, 0 failures) — the paths these opcodes touch. Full CI should confirm the rest.

🤖 Generated with Claude Code

Same observable semantics, cheaper bookkeeping:

- create/exit are integer ops for every component (no node allocation,
  no WeakMap traffic, no stack push of an object)
- update-pass opcodes are emitted only for components that actually
  provided a context; non-providers contribute zero updating opcodes
- lookup is O(1) (peek + two integer compares) instead of walking the
  parent chain
- dead provider entries are skimmed lazily off each context's own
  stack, amortized against the provides that pushed them

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