Skip to content

✨ feat: let a component retain a resource at its invocation site - #214

Merged
taras merged 1 commit into
mainfrom
feat/component-retain
Jul 29, 2026
Merged

✨ feat: let a component retain a resource at its invocation site#214
taras merged 1 commit into
mainfrom
feat/component-retain

Conversation

@taras

@taras taras commented Jul 29, 2026

Copy link
Copy Markdown
Owner

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's released assertion becomes live.

How it works

expandComponent → capture ambient evalScope → withInvocation → provideRetain(site) → body

The engine reads yield* evalScope before entering withInvocation() — at that point it is still the caller's. Each retain() then opens a child of that scope and runs the factory inside the child:

const child = unbox(yield* site.eval(useEvalScope));
return unbox(yield* child.eval(resource));

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() or Component.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 a persist block install a provider for the rest of its invocation.

RT18 demonstrates it: a factory that sets a context value and installs an applyModifiers override. Against a direct site.eval(resource) the downstream sibling reads from-factory; with the child it reads from-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:

  • an ordinary block is refused for the length of the block, so content projected later in the same invocation still reaches the invocation's own provider;
  • a persist block is refused on the eval-scope loop task without a nested scope. An earlier revision wrapped it in scoped() and broke exactly the thing persist exists for — caught by the smoke document, whose provider components install Sample.around() from a persist block.

Replay-safe eval retention is a durability question this contract deliberately does not answer.

Review guide

Start with: provideRetain in packages/core/src/expand.ts

Then review:

  1. specs/executable-mdx-spec.md §4.4 — Retained resources and Retention is component execution, not eval.
  2. packages/core/src/eval-handler.tsrejectRetain / runBlock and the two install sites.
  3. packages/core/tests/retain.test.ts — Tier RT, starting at RT18.
  4. smoke-test/Guide/ResourceLifetime.md — the same contract as a document.

Look carefully at:

  • RT18 — the isolation regression. It fails against a direct site.eval(resource).
  • RT8/RT8b — an invocation with no site installs an explicit rejecting provider rather than nothing. Falling back to invocation lifetime would return a resource about to disappear; deferring to an inherited provider would create it in an unrelated scope.
  • The persist install has no scoped() — see above.

What must stay true

  • A component that does not call retain() keeps invocation lifetime — RT7.
  • Retained resources stay inside structured concurrency — RT4 (site errors), RT5 (site cancelled), RT10 (halt mid-expansion).
  • persist still retains work and middleware past its block — the smoke document's provider components and Persist keeps spawned tasks alive across blocks.
  • Tier O and Tier IS are unchanged.

How to verify it

  • RT1 captures with as and has a downstream sibling resolve the binding while the resource is still live.
  • RT2 — a later sibling invocation starts and stops inside the window where the retained probe is alive. Fails if retain anchored on the invocation scope.
  • RT6start:outer, start:inner-retained, stop:inner-retained, stop:outer.
  • RT15 runs a retained resource through O22's partial replay: the durable executor runs once and replays, while the retained resource is acquired and released on each execution.
  • RT16 — an eval block's retain() is refused and the block produces no value.
  • RT18 — the isolation regression above.
  • The guide, on the compiled binary: the standalone scenario now asserts live for 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.
  • The eval refusal and its regression.
  • Tier RT, the spec sections, the website's retention documentation, and the guide's standalone scenario.

Intentionally unchanged

  • Replay-safe eval retention is not attempted.
  • retain is not added to STANDARD_IMPORTS. A block importing it explicitly gets a clear rejection.
  • No <TempDir> in this diff — it lands after this merges.

New abstractions

  • smoke-test/thing-registry.ts holds the live-handle set the guide's components share. Lowercase, so no document can invoke it as a component.

Risks and limitations

  • Each 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

  • Every changed file supports the purpose described above.
  • Unrelated cleanup and formatting changes are excluded.
  • The description matches the final diff and test results.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Found 3 redundant comments. Inline suggestions to remove them below.

Comment thread packages/core/src/eval-handler.ts
Comment thread smoke-test/Thing.ts Outdated
Comment thread smoke-test/Thing.ts Outdated
@github-actions

github-actions Bot commented Jul 29, 2026

Copy link
Copy Markdown

PR #214: ✨ feat: let a component retain a resource at its invocation site

12 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.

Correctness

No extraneous code patterns detected.

@taras
taras force-pushed the feat/component-retain branch from 6846b76 to b97cd30 Compare July 29, 2026 12:39
@taras
taras force-pushed the feat/component-retain branch from b97cd30 to 080662b Compare July 29, 2026 12:42
Base automatically changed from feat/has-content to main July 29, 2026 12:45
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
taras force-pushed the feat/component-retain branch from 080662b to 165a969 Compare July 29, 2026 12:45
@taras
taras merged commit d692325 into main Jul 29, 2026
9 checks passed
@taras
taras deleted the feat/component-retain branch July 29, 2026 12:50
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.

1 participant