✨ feat: let a component retain a resource at its invocation site - #214
Merged
Conversation
4 tasks
PR #214: ✨ feat: let a component retain a resource at its invocation site12 files, +782 / -28 Scope🔴 PR has 810 lines changed. Split into focused PRs. 🟡 810 lines changed. PRs under 400 receive more thorough review. Structural✅ No structural bloat detected. Slop✅ Slop indicators look low. Static Analysis✅ Oxlint found no issues. CorrectnessNo extraneous code patterns detected. |
taras
force-pushed
the
feat/component-retain
branch
from
July 29, 2026 12:39
6846b76 to
b97cd30
Compare
taras
force-pushed
the
feat/component-retain
branch
from
July 29, 2026 12:42
b97cd30 to
080662b
Compare
A standalone component hands back a value the caller uses after the invocation is over. Invocation lifetime releases the resource behind it first, so the caller gets a name for something that no longer exists — the gap the guide chapter observes today. `retain()` closes it. Each call opens an isolated child of the scope that invoked the component and runs the factory there, so the resource lives as long as that scope does and is released when it succeeds, fails, or is cancelled. The child is the point, not an accident of nesting. A factory is arbitrary code: run directly on the site it could set a context value or install middleware — the same mechanism that lets a `persist` block install a provider for the rest of its invocation — and every later sibling expanding in that scope would observe it. Inside the child those writes stop at the child, and only the provided value crosses back. Neither scope is handed out. `retain()` is an operation of component execution. Eval is durable — a replay restores a block's values without entering the executor — so a block that retained a resource would produce a restored value naming something nothing re-created. Eval execution refuses the call: scoped to the block for an ordinary one, and on the eval-scope loop task without a nested scope for a `persist` block, whose work and middleware must outlive it.
taras
force-pushed
the
feat/component-retain
branch
from
July 29, 2026 12:45
080662b to
165a969
Compare
This was referenced Jul 29, 2026
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.
Stacked on #213.
Why
A standalone component hands back a value the caller uses after the invocation is over. Invocation lifetime releases the resource behind it first, so the caller gets a name for something that no longer exists — exactly what #213's guide chapter asserts today (
released).<TempDir />(#189) is that shape: it returns a path a downstream sibling has to be able to read.What changes
Before: a component's resources always died with its invocation. It could hand back a value but not keep the thing alive.
After:
yield* retain(() => useThing())gives the resource invocation-site lifetime, and #213'sreleasedassertion becomeslive.How it works
The engine reads
yield* evalScopebefore enteringwithInvocation()— at that point it is still the caller's. Eachretain()then opens a child of that scope and runs the factory inside the child:The child's loop task is spawned in the site's, so it dies with the site — the resource's lifetime. The factory runs one level down, where its own scope writes land.
Which scope the site is falls out of §4.4's nesting. An element inside another component's projected content retains into that component's content scope, released by stage 1 of the enclosing teardown. An element at the root retains into the document scope.
Why the child, and not the site directly
A factory is arbitrary code. Run directly on the site's loop task it can call
Context.set()orComponent.around(), and because projected content expands in a task that scope owns, every later sibling would observe it. This is not hypothetical — it is the same mechanism that lets apersistblock install a provider for the rest of its invocation.RT18 demonstrates it: a factory that sets a context value and installs an
applyModifiersoverride. Against a directsite.eval(resource)the downstream sibling readsfrom-factory; with the child it readsfrom-caller. Retention is a lifetime, not authority over the caller, and neither scope is ever handed out.Where retention is not available
retain()is an operation of TypeScript component execution, which runs in full on every execution — that is what makes invocation-site lifetime meaningful.An eval block does not: a replay restores its exported values from the journal without entering the executor, so a retained resource would have nothing to re-establish it. Eval execution refuses the call, and where the refusal goes is load-bearing:
persistblock is refused on the eval-scope loop task without a nested scope. An earlier revision wrapped it inscoped()and broke exactly the thingpersistexists for — caught by the smoke document, whose provider components installSample.around()from apersistblock.Replay-safe eval retention is a durability question this contract deliberately does not answer.
Review guide
Start with:
provideRetaininpackages/core/src/expand.tsThen review:
specs/executable-mdx-spec.md§4.4 — Retained resources and Retention is component execution, not eval.packages/core/src/eval-handler.ts—rejectRetain/runBlockand the two install sites.packages/core/tests/retain.test.ts— Tier RT, starting at RT18.smoke-test/Guide/ResourceLifetime.md— the same contract as a document.Look carefully at:
site.eval(resource).persistinstall has noscoped()— see above.What must stay true
retain()keeps invocation lifetime — RT7.persiststill retains work and middleware past its block — the smoke document's provider components andPersist keeps spawned tasks alive across blocks.How to verify it
asand has a downstream sibling resolve the binding while the resource is still live.retainanchored on the invocation scope.start:outer, start:inner-retained, stop:inner-retained, stop:outer.retain()is refused and the block produces no value.livefor both "is anything alive" and "is my handle alive", and the paired scenarios still show a resource confined to its invocation.Verified locally on Deno 2.9.1: lint 0 errors, check clean,
185 passed (1442 steps) | 0 failed, JSR dry run complete, site check + build clean, and the full compiled smoke job green.Scope
Included
Component.retain, its wrapper, and the isolated site-owned provider.Intentionally unchanged
retainis not added toSTANDARD_IMPORTS. A block importing it explicitly gets a clear rejection.<TempDir>in this diff — it lands after this merges.New abstractions
smoke-test/thing-registry.tsholds the live-handle set the guide's components share. Lowercase, so no document can invoke it as a component.Risks and limitations
retain()call creates a child eval scope, so a component making many calls creates many. In practice a component retains once or twice; if that changes, the child could be created per invocation instead of per call.Scope confirmation