✨ 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

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* evalScopebefore 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:

constchild=unbox(yield*site.eval(useEvalScope));returnunbox(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-actionsgithub-actionsBot 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 threadpackages/core/src/eval-handler.ts
Comment threadsmoke-test/Thing.ts Outdated
Comment threadsmoke-test/Thing.ts Outdated
@github-actions

github-actionsBot 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
tarasforce-pushed the feat/component-retain branch from 6846b76 to b97cd30CompareJuly 29, 2026 12:39
@taras
tarasforce-pushed the feat/component-retain branch from b97cd30 to 080662bCompareJuly 29, 2026 12:42
Base automatically changed from feat/has-content to mainJuly 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
tarasforce-pushed the feat/component-retain branch from 080662b to 165a969CompareJuly 29, 2026 12:45
@taras
taras merged commit d692325 into mainJul 29, 2026
9 checks passed
@taras
taras deleted the feat/component-retain branch July 29, 2026 12:50
Sign up for freeto 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

@taras
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
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

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* evalScopebefore 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:

constchild=unbox(yield*site.eval(useEvalScope));returnunbox(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-actionsgithub-actionsBot 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 threadpackages/core/src/eval-handler.ts
Comment threadsmoke-test/Thing.ts Outdated
Comment threadsmoke-test/Thing.ts Outdated
@github-actions

github-actionsBot 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
tarasforce-pushed the feat/component-retain branch from 6846b76 to b97cd30CompareJuly 29, 2026 12:39
@taras
tarasforce-pushed the feat/component-retain branch from b97cd30 to 080662bCompareJuly 29, 2026 12:42
Base automatically changed from feat/has-content to mainJuly 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
tarasforce-pushed the feat/component-retain branch from 080662b to 165a969CompareJuly 29, 2026 12:45
@taras
taras merged commit d692325 into mainJul 29, 2026
9 checks passed
@taras
taras deleted the feat/component-retain branch July 29, 2026 12:50
Sign up for freeto 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

@taras
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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

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* evalScopebefore 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:

constchild=unbox(yield*site.eval(useEvalScope));returnunbox(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-actionsgithub-actionsBot 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 threadpackages/core/src/eval-handler.ts
Comment threadsmoke-test/Thing.ts Outdated
Comment threadsmoke-test/Thing.ts Outdated
@github-actions

github-actionsBot 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
tarasforce-pushed the feat/component-retain branch from 6846b76 to b97cd30CompareJuly 29, 2026 12:39
@taras
tarasforce-pushed the feat/component-retain branch from b97cd30 to 080662bCompareJuly 29, 2026 12:42
Base automatically changed from feat/has-content to mainJuly 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
tarasforce-pushed the feat/component-retain branch from 080662b to 165a969CompareJuly 29, 2026 12:45
@taras
taras merged commit d692325 into mainJul 29, 2026
9 checks passed
@taras
taras deleted the feat/component-retain branch July 29, 2026 12:50
Sign up for freeto 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

@taras
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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

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* evalScopebefore 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:

constchild=unbox(yield*site.eval(useEvalScope));returnunbox(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-actionsgithub-actionsBot 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 threadpackages/core/src/eval-handler.ts
Comment threadsmoke-test/Thing.ts Outdated
Comment threadsmoke-test/Thing.ts Outdated
@github-actions

github-actionsBot 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
tarasforce-pushed the feat/component-retain branch from 6846b76 to b97cd30CompareJuly 29, 2026 12:39
@taras
tarasforce-pushed the feat/component-retain branch from b97cd30 to 080662bCompareJuly 29, 2026 12:42
Base automatically changed from feat/has-content to mainJuly 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
tarasforce-pushed the feat/component-retain branch from 080662b to 165a969CompareJuly 29, 2026 12:45
@taras
taras merged commit d692325 into mainJul 29, 2026
9 checks passed
@taras
taras deleted the feat/component-retain branch July 29, 2026 12:50
Sign up for freeto 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

@taras
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
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

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* evalScopebefore 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:

constchild=unbox(yield*site.eval(useEvalScope));returnunbox(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-actionsgithub-actionsBot 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 threadpackages/core/src/eval-handler.ts
Comment threadsmoke-test/Thing.ts Outdated
Comment threadsmoke-test/Thing.ts Outdated
@github-actions

github-actionsBot 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
tarasforce-pushed the feat/component-retain branch from 6846b76 to b97cd30CompareJuly 29, 2026 12:39
@taras
tarasforce-pushed the feat/component-retain branch from b97cd30 to 080662bCompareJuly 29, 2026 12:42
Base automatically changed from feat/has-content to mainJuly 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
tarasforce-pushed the feat/component-retain branch from 080662b to 165a969CompareJuly 29, 2026 12:45
@taras
taras merged commit d692325 into mainJul 29, 2026
9 checks passed
@taras
taras deleted the feat/component-retain branch July 29, 2026 12:50
Sign up for freeto 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

@taras
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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

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* evalScopebefore 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:

constchild=unbox(yield*site.eval(useEvalScope));returnunbox(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-actionsgithub-actionsBot 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 threadpackages/core/src/eval-handler.ts
Comment threadsmoke-test/Thing.ts Outdated
Comment threadsmoke-test/Thing.ts Outdated
@github-actions

github-actionsBot 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
tarasforce-pushed the feat/component-retain branch from 6846b76 to b97cd30CompareJuly 29, 2026 12:39
@taras
tarasforce-pushed the feat/component-retain branch from b97cd30 to 080662bCompareJuly 29, 2026 12:42
Base automatically changed from feat/has-content to mainJuly 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
tarasforce-pushed the feat/component-retain branch from 080662b to 165a969CompareJuly 29, 2026 12:45
@taras
taras merged commit d692325 into mainJul 29, 2026
9 checks passed
@taras
taras deleted the feat/component-retain branch July 29, 2026 12:50
Sign up for freeto 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

@taras
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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

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* evalScopebefore 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:

constchild=unbox(yield*site.eval(useEvalScope));returnunbox(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-actionsgithub-actionsBot 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 threadpackages/core/src/eval-handler.ts
Comment threadsmoke-test/Thing.ts Outdated
Comment threadsmoke-test/Thing.ts Outdated
@github-actions

github-actionsBot 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
tarasforce-pushed the feat/component-retain branch from 6846b76 to b97cd30CompareJuly 29, 2026 12:39
@taras
tarasforce-pushed the feat/component-retain branch from b97cd30 to 080662bCompareJuly 29, 2026 12:42
Base automatically changed from feat/has-content to mainJuly 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
tarasforce-pushed the feat/component-retain branch from 080662b to 165a969CompareJuly 29, 2026 12:45
@taras
taras merged commit d692325 into mainJul 29, 2026
9 checks passed
@taras
taras deleted the feat/component-retain branch July 29, 2026 12:50
Sign up for freeto 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

@taras
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
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

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* evalScopebefore 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:

constchild=unbox(yield*site.eval(useEvalScope));returnunbox(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-actionsgithub-actionsBot 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 threadpackages/core/src/eval-handler.ts
Comment threadsmoke-test/Thing.ts Outdated
Comment threadsmoke-test/Thing.ts Outdated
@github-actions

github-actionsBot 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
tarasforce-pushed the feat/component-retain branch from 6846b76 to b97cd30CompareJuly 29, 2026 12:39
@taras
tarasforce-pushed the feat/component-retain branch from b97cd30 to 080662bCompareJuly 29, 2026 12:42
Base automatically changed from feat/has-content to mainJuly 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
tarasforce-pushed the feat/component-retain branch from 080662b to 165a969CompareJuly 29, 2026 12:45
@taras
taras merged commit d692325 into mainJul 29, 2026
9 checks passed
@taras
taras deleted the feat/component-retain branch July 29, 2026 12:50
Sign up for freeto 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

@taras