createContext: frame-epoch shallow binding for the render scope tracker#21486
Draft
NullVoxPopuli-ai-agent wants to merge 1 commit into
Draft
Conversation
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>
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.
Alternative implementation of the
RenderScopeTrackerfor consideration on #21450, replacing the retained node tree + stack with frame-epoch shallow binding. Same observable semantics — all 24createContexttests 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:
RenderScopeNodeat create and registers it in thenodesWeakMap for its whole lifetime, so that update passes can re-push it.RenderScopeUpdateOpcode+RenderScopeExitOpcodeare 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 2Nevaluate()calls (each with a WeakMapget) on every traversal that visits them — almost all doing nothing, since almost no component provides a context.context.valueread walks the parent chain until it finds the key or hits the root.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 depthd. 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/exitare 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.createContextclosure), so lookups touch only the consulted context's entries and there is no global map.lookupis O(1): skim dead tops, peek.VM_CREATE_COMPONENT_OPthe 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)
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.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 isdepth === 0in both designs).provide()infers the providing component from "constructor runs immediately after its owncreate" (currentBucket). True for everything the VM can express today, but it's an ordering assumption rather than an explicit handoff.Perf shape (why this matters for an always-on hook)
++/--+ id stampcontext.valuereadVerified:
type-check:internals, eslint, prettier, the 24-testcreate-contextmodule, and thecomponent/render/in-element/outletfiltered suites (3,634 tests, 0 failures) — the paths these opcodes touch. Full CI should confirm the rest.🤖 Generated with Claude Code