@abide/abide 0.35.0 → 0.36.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,153 +1,159 @@
1
1
  # abide
2
2
 
3
- **Write one function. Get a typed HTTP endpoint, a CLI, an MCP tool, and an OpenAPI operation — from the same line of code.**
3
+ **One typed RPC declaration fans out to an HTTP route, a typed browser proxy, a CLI subcommand, an MCP tool, and an OpenAPI operation — built for humans and machines.**
4
4
 
5
- abide is a type-safe isomorphic framework built on web standards and Bun. You declare a verb once; the bundler swaps the runtime per side and projects that one declaration onto every surface humans hit it over HTTP or the CLI, machines hit it over MCP, and you control which surfaces each verb exposes. It's a framework built for humans *and* machines.
5
+ abide is an isomorphic framework on Bun: the same callable has the same name and behaviour on both sides, and the bundler swaps the runtime per side a server handler in `src/server`, a `fetch` proxy in the browser. Server-render and hydrate the same `.abide` components, broadcast over multiplexed sockets, and expose every read to an agent all in a single Bun runtime.
6
6
 
7
- - Zero runtime dependencies
8
- - A single runtime (Bun) in every mode dev, build, and compiled binary
7
+ - One dependency (`typescript`, for the type-checking shadow); `tailwindcss` + `bun-plugin-tailwind` are optional peers.
8
+ - Runs on Bun `>= 1.3.0`. No bundler config, no server framework, no client router to wire up.
9
9
 
10
10
  ## Quick start
11
11
 
12
12
  ```sh
13
- bunx abide scaffold my-app # scaffolds, installs, and starts the dev server
13
+ bunx abide scaffold my-app # scaffolds, installs deps, and (in a TTY) starts dev
14
14
  ```
15
15
 
16
- To see every surface live, clone the repo and run the kitchen-sink example — each page is runnable and documented:
16
+ Or read the exhaustive demo:
17
17
 
18
18
  ```sh
19
19
  git clone https://github.com/briancray/abide
20
20
  cd abide && bun install
21
- cd examples/kitchen-sink && bun run dev
21
+ cd examples/kitchen-sink && bun dev
22
22
  ```
23
23
 
24
+ The whole public surface — every export, CLI command, route, env var, and the `.abide` grammar — is mapped in [`AGENTS.md`](./AGENTS.md).
25
+
24
26
  ## RPCs
25
27
 
26
- An RPC is a handler wrapped by its HTTP method — one export per file under `src/server/rpc/`. The file path is the URL; the schema validates args and projects the MCP tool, CLI flags, and OpenAPI operation. Standard Schema is the contract: zod, valibot, and arktype work unadapted.
28
+ An RPC is one export per file under `src/server/rpc/`. The file path is the URL (`getMessages.ts` `/rpc/getMessages`), and a [Standard Schema](https://standardschema.dev) (zod / valibot / arktype, unadapted) validates the args and projects every other surface.
27
29
 
28
30
  ```ts
29
31
  // src/server/rpc/getMessages.ts
30
32
  import { GET } from '@abide/abide/server/GET'
31
33
  import { json } from '@abide/abide/server/json'
32
34
  import { z } from 'zod'
35
+ import { history } from '../../store.ts'
33
36
 
34
- // query args arrive as strings — coerce in the schema
35
- const inputSchema = z.object({ room: z.string(), limit: z.coerce.number().default(50) })
37
+ const inputSchema = z.object({ room: z.string() })
36
38
 
37
- export const getMessages = GET(
38
- async ({ room, limit }) => json(await db.recentMessages(room, limit)),
39
- { inputSchema },
40
- )
39
+ export const getMessages = GET(({ room }) => json({ messages: history(room) }), { inputSchema })
41
40
  ```
42
41
 
43
- One declared verb fans out to every surface — this is the whole premise:
42
+ That one declaration fans out:
44
43
 
45
44
  ```text
46
- export const getMessages = GET(fn, { inputSchema })
47
-
48
- ┌───────────────┬────────────┼──────────────┬────────────────┐
49
- SSR call browser fetch MCP tool CLI subcommand OpenAPI op
50
- cache(fn)() fetch /rpc/... getMessages app get-messages /openapi.json
51
- (in-process) (typed proxy) (read-only+schema) (schema→flags) (described)
45
+ getMessages (one declaration)
46
+
47
+ ┌──────────┬─────────┼─────────┬──────────────┐
48
+ ▼ ▼ ▼ ▼ ▼
49
+ SSR call browser MCP tool CLI sub- OpenAPI
50
+ cache(fn)() fetch (read-only) command operation
52
51
  ```
53
52
 
54
- The same callable is consumed differently per side `cache(getMessages)({ room })` in-process, the swapped `fetch` in the browser, `.raw(args)` for a `Response`, `.stream(args)` for `tail()`. A schema gates the machine surfaces: it unlocks the CLI and (for read-only verbs) MCP; a mutating verb never auto-exposes to MCP it needs an explicit `clients: { mcp: true }`. Per-verb `timeout` (504, on every surface) is distinct from the client-side `ABIDE_CLIENT_TIMEOUT`.
53
+ The `inputSchema` unlocks the CLI and because a `GET`/`HEAD` is read-only an MCP tool. A mutating verb (`POST`/`PUT`/`PATCH`/`DELETE`) never auto-exposes to an agent; opt in with `clients: { mcp: true }`. Consume the verb four ways, all typed against the declaration: `cache(getMessages)({ room })` for an SSR-memoized in-process read, the bundler-swapped `getMessages({ room })` `fetch` proxy in the browser, `.raw(args)` for the untouched `Response`, and `.stream(args)` for a frame-by-frame `jsonl`/`sse` body.
55
54
 
56
- > Query args travel as strings — use `z.coerce.*` so numbers and booleans validate.
55
+ > Query args travel as strings — use `z.coerce.*` for numbers and booleans. The per-verb `timeout` option fires a `504` on every surface and is distinct from the client-wide `ABIDE_CLIENT_TIMEOUT`.
57
56
 
58
57
  ## Sockets
59
58
 
60
- One broadcast topic per file under `src/server/sockets/`. A `Socket<T>` is an isomorphic `AsyncIterable<T>` — every socket multiplexes onto one ws at `/__abide/sockets`. A `schema` validates publishes, infers the frame type, and flips on the MCP/CLI read faces.
59
+ A socket is one broadcast topic per file under `src/server/sockets/`. A `Socket<T>` is an isomorphic `AsyncIterable<T>` — `for await` it on the server, or read it reactively with `tail()` in a component and every socket multiplexes onto one WebSocket at `/__abide/sockets`.
61
60
 
62
61
  ```ts
63
- // src/server/sockets/chat.ts
62
+ // src/server/sockets/messages.ts
64
63
  import { socket } from '@abide/abide/server/socket'
65
64
  import { z } from 'zod'
66
65
 
67
- const schema = z.object({ id: z.string(), from: z.string(), text: z.string(), at: z.number() })
68
-
69
- // retain the last 100 frames, evict any older than an hour
70
- export const chat = socket({ schema, tail: 100, ttl: 3_600_000 })
71
- export type ChatMessage = z.infer<typeof schema>
66
+ const schema = z.object({
67
+ id: z.string(),
68
+ room: z.string(),
69
+ from: z.string(),
70
+ text: z.string(),
71
+ at: z.number(),
72
+ })
73
+
74
+ // retain the last 100 frames; lazily evict any older than an hour
75
+ export const messages = socket({ schema, tail: 100, ttl: 3_600_000 })
76
+ export type Message = z.infer<typeof schema>
72
77
  ```
73
78
 
74
- For clients that can't speak the ws multiplex, each socket has an HTTP face at `/__abide/sockets/<name>` `GET` returns the retained tail, `POST` publishes (gated by `clientPublish`, off by default).
79
+ The schema validates every publish and flips on the read surfaces (a `messages-tail` MCP tool / CLI command). For clients that can't speak the ws multiplex, each socket has an HTTP face at `/__abide/sockets/messages`: `GET` returns the retained tail, `POST` publishes gated by `clientPublish` (default `false`, so browsers publish through a validating verb instead).
75
80
 
76
81
  ## Components
77
82
 
78
- Components are `.abide` files valid HTML with `<script>`, native `<template>` control flow, `{expr}` bindings, and a component-scoped `<style>`. Reactive state is reached through `scope()` (`scope().state(v)`, `scope().computed(fn)`); props are read by destructuring `props()` (`const { room } = props()`), and `effect` is in scope without import. This page reads the verb above through `cache()`, tails the socket live, and exercises most of the template grammar:
83
+ A component is an `.abide` file: valid HTML with a `<script>`, native `<template>` control flow, `{expr}` bindings, and a component-scoped `<style>`. `scope()` is the sole reactive surface; `props`, `effect`, and `html` are in scope without import. This one imports the verb and socket above:
79
84
 
80
85
  ```html
81
86
  <script>
82
87
  import { cache } from '@abide/abide/shared/cache'
83
88
  import { tail } from '@abide/abide/ui/tail'
84
89
  import { getMessages } from '$server/rpc/getMessages.ts'
85
- import { publishChat } from '$server/rpc/publishChat.ts'
86
- import { chat } from '$server/sockets/chat.ts'
90
+ import { sendMessage } from '$server/rpc/sendMessage.ts'
91
+ import { messages } from '$server/sockets/messages.ts'
87
92
  import Avatar from '$ui/Avatar.abide'
88
93
 
89
- const { room } = props() // typed via src/.abide/routes.d.ts
90
- const history = scope().computed(() => cache(getMessages)({ room })) // SSR snapshot + reactive refetch
91
- const latest = scope().computed(() => tail(chat)) // re-renders on every new frame
94
+ const { room } = props<{ room: string }>()
92
95
 
93
- let from = scope().state('alice')
94
- let text = scope().state('')
95
- let filter = scope().state('all') // all | mine | others
96
- let onlyUnread = scope().state(false)
96
+ let from = scope().state('alice') // a writable cell — read/assign as a plain var
97
+ let draft = scope().state('')
98
+ let pinned = scope().state(false)
99
+ let filter = scope().state('all')
100
+ const live = scope().computed(() => tail(messages)) // re-renders on every new frame
97
101
 
98
102
  async function send(event: SubmitEvent) {
99
103
  event.preventDefault()
100
- if (text.trim() === '') return
101
- await publishChat({ from, text }) // server validates, then broadcasts
102
- text = ''
104
+ if (draft.trim() === '') return
105
+ await sendMessage({ room, from, text: draft }) // a mutating verb
106
+ draft = ''
103
107
  }
104
108
  </script>
105
109
 
106
- <template name="message" args={m}> <!-- snippet: a reusable builder -->
107
- <li class="row"><Avatar name={m.from} /> <strong>{m.from}</strong>: {m.text}</li>
110
+ <!-- a named snippet: a reusable builder, rendered like a function -->
111
+ <template name="bubble" args={msg}>
112
+ <li><Avatar name={msg.from} /> <b>{msg.from}</b>: {msg.text}</li>
108
113
  </template>
109
114
 
115
+ <h1>#{room}</h1>
116
+
110
117
  <form onsubmit={send}>
111
- <input bind:value={from} placeholder="you" />
112
- <input bind:value={text} placeholder="say something…" />
113
- <label><input type="checkbox" bind:checked={onlyUnread} /> unread only</label>
114
- <fieldset>
115
- <label><input type="radio" bind:group={filter} value="all" /> all</label>
116
- <label><input type="radio" bind:group={filter} value="mine" /> mine</label>
117
- </fieldset>
118
- <button type="submit" disabled={text.trim() === ''}>send</button>
118
+ <input bind:value={from} />
119
+ <input bind:value={draft} placeholder="message" />
120
+ <label><input type="checkbox" bind:checked={pinned} /> pin</label>
121
+ <label><input type="radio" bind:group={filter} value="all" /> all</label>
122
+ <label><input type="radio" bind:group={filter} value="mine" /> mine</label>
123
+ <button type="submit" disabled={draft.trim() === ''}>send</button>
119
124
  </form>
120
125
 
121
- <template if={latest}>
122
- <p class="ping">latest from <strong>{latest.from}</strong></p>
126
+ <template if={live}>
127
+ <p class="live">latest: {live.text}</p>
123
128
  <template else>
124
- <p class="ping muted">no live messages yet</p>
129
+ <p>no messages yet</p>
125
130
  </template>
126
131
  </template>
127
132
 
128
133
  <template switch={filter}>
129
- <template case={'mine'}><p>showing your messages</p></template>
130
- <template default><p>showing every message</p></template>
134
+ <template case={'all'}><p>showing everyone</p></template>
135
+ <template case={'mine'}><p>showing {from}</p></template>
136
+ <template default><p>—</p></template>
131
137
  </template>
132
138
 
133
- <template await={history}>
134
- <p>loading history…</p>
135
- <template then="messages">
139
+ <template await={cache(getMessages)({ room })}>
140
+ <p>loading…</p>
141
+ <template then="history">
136
142
  <ul>
137
- <template each={messages} as="m" key="m.id">
138
- {message(m)} <!-- render the snippet -->
143
+ <template each={history.messages} as="msg" key="msg.id">
144
+ {bubble(msg)}
139
145
  </template>
140
146
  </ul>
141
147
  </template>
142
- <template catch="err">
143
- <p class="error">{err.message}</p>
148
+ <template catch="error">
149
+ <p>failed: {error.message}</p>
144
150
  </template>
145
151
  </template>
146
152
 
147
153
  <style>
148
- .row { display: flex; gap: 0.5rem; }
149
- .muted { color: #94a3b8; }
150
- .error { color: #dc2626; }
154
+ .live {
155
+ font-weight: 600;
156
+ }
151
157
  </style>
152
158
  ```
153
159
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@abide/abide",
3
- "version": "0.35.0",
3
+ "version": "0.36.0",
4
4
  "type": "module",
5
5
  "sideEffects": false,
6
6
  "description": "Isomorphic multimodal HTTP framework built for humans and machines in a single Bun runtime",
@@ -24,6 +24,7 @@ import type { CacheStore } from './types/CacheStore.ts'
24
24
  import type { RawRemoteFunction } from './types/RawRemoteFunction.ts'
25
25
  import type { RemoteFunction } from './types/RemoteFunction.ts'
26
26
  import type { Subscribable } from './types/Subscribable.ts'
27
+ import { withCacheManaged } from './withCacheManaged.ts'
27
28
 
28
29
  type AnyRemote<Args, Return> = RemoteFunction<Args, Return> | RawRemoteFunction<Args>
29
30
  type Producer<Args, Return> = (args?: Args) => Promise<Return>
@@ -309,8 +310,12 @@ function invokeProducer<Args, Return>(
309
310
  }
310
311
  /* Miss: time the producer run — where a request's time actually goes (an
311
312
  external fetch, a computation). Producer path only; the remote path must
312
- keep its own promise so getRemoteMeta can read the recorded Request. */
313
- const promise = cacheLog.trace<Return>(`cache ${key}`, () => producer(args))
313
+ keep its own promise so getRemoteMeta can read the recorded Request. The
314
+ producer runs cache-managed so a bare RPC inside it isn't scope-bound — the
315
+ cache coalesces and owns this flight. */
316
+ const promise = cacheLog.trace<Return>(`cache ${key}`, () =>
317
+ withCacheManaged(() => producer(args)),
318
+ )
314
319
  registerEntry(store, key, promise, options, undefined, () => producer(args))
315
320
  return promise
316
321
  }
@@ -326,7 +331,9 @@ function invokeRemote<Args>(
326
331
  if (existing) {
327
332
  return shareable(existing.promise as Promise<Response>)
328
333
  }
329
- const promise = rawFn(args as Args)
334
+ /* Cache-managed: the shared flight isn't bound to the reader that triggered the
335
+ miss, so its scope disposing can't abort a request other readers still join. */
336
+ const promise = withCacheManaged(() => rawFn(args as Args))
330
337
  const request = getRemoteMeta(promise)
331
338
  if (!request) {
332
339
  throw new Error(
@@ -0,0 +1,10 @@
1
+ /*
2
+ Set while cache() synchronously invokes the underlying remote/producer, so the
3
+ client's currentAbortSignal skips scope-binding for cache-managed calls. The cache
4
+ coalesces one in-flight request across every reader and owns its lifetime (it
5
+ evicts the entry when the request rejects), so a single reader navigating away must
6
+ not abort a flight the others still depend on. A plain boolean — cache invokes the
7
+ underlying call synchronously and never re-enters across an await. Harmless on the
8
+ server, where there is no reactive observer to bind anyway.
9
+ */
10
+ export const cacheManagedSlot: { active: boolean } = { active: false }
@@ -0,0 +1,18 @@
1
+ import { cacheManagedSlot } from './cacheManagedSlot.ts'
2
+
3
+ /*
4
+ Runs `invoke` with cacheManagedSlot flagged, so any RPC fired synchronously inside
5
+ it (the cache's underlying remote/producer call) skips reactive scope-binding — the
6
+ cache, not the calling reader, owns the shared flight's lifetime. Save/restore keeps
7
+ it correct under nesting (a producer that itself reads cache). The fetch is fired
8
+ synchronously and the flag clears before the await, so it never spans the network.
9
+ */
10
+ export function withCacheManaged<T>(invoke: () => T): T {
11
+ const previous = cacheManagedSlot.active
12
+ cacheManagedSlot.active = true
13
+ try {
14
+ return invoke()
15
+ } finally {
16
+ cacheManagedSlot.active = previous
17
+ }
18
+ }
@@ -3,6 +3,7 @@ import { claimChild } from '../runtime/claimChild.ts'
3
3
  import { RENDER } from '../runtime/RENDER.ts'
4
4
  import { RESUME } from '../runtime/RESUME.ts'
5
5
  import { scope } from '../runtime/scope.ts'
6
+ import { scopeGroup } from '../runtime/scopeGroup.ts'
6
7
  import { discardBoundary } from './discardBoundary.ts'
7
8
  import { enterNamespace } from './enterNamespace.ts'
8
9
 
@@ -41,6 +42,9 @@ export function awaitBlock(
41
42
  before: Node | null = null,
42
43
  ): void {
43
44
  const hydration = RENDER.hydration
45
+ /* The live branch's scope, registered with the owner so it disposes on owner
46
+ teardown — not only when a settle/re-run swaps branches via detach. */
47
+ const group = scopeGroup()
44
48
  let active: { nodes: Node[]; dispose: () => void } | undefined
45
49
  let anchor: Node | undefined
46
50
  let first = true
@@ -67,8 +71,8 @@ export function awaitBlock(
67
71
  const place = (build: (parent: Node) => void): void => {
68
72
  detach()
69
73
  const fragment = document.createDocumentFragment()
70
- const dispose = enterNamespace(anchor?.parentNode ?? parent, () =>
71
- scope(() => build(fragment)),
74
+ const dispose = group.track(
75
+ enterNamespace(anchor?.parentNode ?? parent, () => scope(() => build(fragment))),
72
76
  )
73
77
  const nodes = [...fragment.childNodes]
74
78
  ;(anchor?.parentNode ?? parent).insertBefore(fragment, anchor ?? null)
@@ -112,7 +116,7 @@ export function awaitBlock(
112
116
  const adopt = (open: Node | null, build: (parent: Node) => void): void => {
113
117
  const cursor = hydration as NonNullable<typeof hydration>
114
118
  cursor.next.set(parent, open?.nextSibling ?? null)
115
- const dispose = scope(() => build(parent))
119
+ const dispose = group.track(scope(() => build(parent)))
116
120
  const close = claimChild(cursor, parent)
117
121
  cursor.next.set(parent, close?.nextSibling ?? null)
118
122
  const nodes: Node[] = []
@@ -1,9 +1,9 @@
1
1
  import { effect } from '../effect.ts'
2
2
  import { claimChild } from '../runtime/claimChild.ts'
3
3
  import { claimExpected } from '../runtime/claimExpected.ts'
4
- import { OWNER } from '../runtime/OWNER.ts'
5
4
  import { RENDER } from '../runtime/RENDER.ts'
6
5
  import { scope } from '../runtime/scope.ts'
6
+ import { scopeGroup } from '../runtime/scopeGroup.ts'
7
7
  import { enterNamespace } from './enterNamespace.ts'
8
8
  import { moveRange } from './moveRange.ts'
9
9
  import { removeRange } from './removeRange.ts'
@@ -36,6 +36,9 @@ export function each<T>(
36
36
  before: Node | null = null,
37
37
  ): void {
38
38
  const rows = new Map<string, EachRow>()
39
+ /* Each row's scope, registered with the owner so every live row disposes on owner
40
+ teardown (the effect's own disposer only unsubscribes it from `items()`). */
41
+ const group = scopeGroup()
39
42
 
40
43
  /* Build a row's range. Hydrate mode (only while the claim cursor is active —
41
44
  read fresh, since a row built by a post-hydration reconcile must create, not
@@ -47,7 +50,7 @@ export function each<T>(
47
50
  if (hydration !== undefined) {
48
51
  const start = claimExpected(hydration, parent, 'each row start marker')
49
52
  hydration.next.set(parent, start.nextSibling)
50
- const dispose = scope(() => render(parent, item))
53
+ const dispose = group.track(scope(() => render(parent, item)))
51
54
  const end = claimExpected(hydration, parent, 'each row end marker')
52
55
  hydration.next.set(parent, end.nextSibling)
53
56
  return { start, end, dispose }
@@ -58,7 +61,9 @@ export function each<T>(
58
61
  pending.appendChild(start)
59
62
  /* Build under `parent`'s foreign namespace so foreign row elements (svg/math)
60
63
  built into the detached fragment are namespaced, not built as HTML. */
61
- const dispose = enterNamespace(parent, () => scope(() => render(pending, item)))
64
+ const dispose = group.track(
65
+ enterNamespace(parent, () => scope(() => render(pending, item))),
66
+ )
62
67
  pending.appendChild(end)
63
68
  return { start, end, dispose, pending }
64
69
  }
@@ -146,16 +151,4 @@ export function each<T>(
146
151
  RENDER.hydration = previousHydration
147
152
  }
148
153
  })
149
-
150
- /* Dispose every row still live when the enclosing scope tears down (the effect's
151
- own disposer only unsubscribes it from `items()`). The host's DOM is cleared by
152
- `mount`, so disposal need not remove the nodes. */
153
- if (OWNER.current !== undefined) {
154
- OWNER.current.push(() => {
155
- for (const row of rows.values()) {
156
- row.dispose()
157
- }
158
- rows.clear()
159
- })
160
- }
161
154
  }
@@ -3,6 +3,7 @@ import { claimChild } from '../runtime/claimChild.ts'
3
3
  import { OWNER } from '../runtime/OWNER.ts'
4
4
  import { RENDER } from '../runtime/RENDER.ts'
5
5
  import { scope } from '../runtime/scope.ts'
6
+ import { scopeGroup } from '../runtime/scopeGroup.ts'
6
7
  import { enterNamespace } from './enterNamespace.ts'
7
8
  import { removeRange } from './removeRange.ts'
8
9
  import type { EachRow } from './types/EachRow.ts'
@@ -32,6 +33,9 @@ export function eachAsync<T>(
32
33
  before: Node | null = null,
33
34
  ): void {
34
35
  const rows = new Map<string, EachRow>()
36
+ /* Each row's (and the error branch's) scope, registered with the owner so they
37
+ dispose on owner teardown; the block's own teardown only stops the stream. */
38
+ const group = scopeGroup()
35
39
  const hydration = RENDER.hydration
36
40
  const anchor = document.createTextNode('')
37
41
  if (hydration !== undefined) {
@@ -46,8 +50,8 @@ export function eachAsync<T>(
46
50
  const end = document.createComment(']')
47
51
  const fragment = document.createDocumentFragment()
48
52
  fragment.appendChild(start)
49
- const dispose = enterNamespace(anchor.parentNode ?? parent, () =>
50
- scope(() => build(fragment)),
53
+ const dispose = group.track(
54
+ enterNamespace(anchor.parentNode ?? parent, () => scope(() => build(fragment))),
51
55
  )
52
56
  fragment.appendChild(end)
53
57
  /* Insert via the anchor's LIVE parent: when this `each` is a bare child of a
@@ -127,18 +131,13 @@ export function eachAsync<T>(
127
131
  })
128
132
 
129
133
  /* Stop the live stream when the enclosing scope tears down: bump the generation so
130
- the drain abandons its loop, `return()` the iterator to release the source, drop
131
- the error branch, and dispose every surviving row. */
134
+ the drain abandons its loop and `return()` the iterator to release the source. The
135
+ rows and error branch are disposed by the group (their scopes were tracked). */
132
136
  if (OWNER.current !== undefined) {
133
137
  OWNER.current.push(() => {
134
138
  generation += 1
135
139
  iterator?.return?.(undefined)?.catch(() => undefined)
136
140
  iterator = undefined
137
- clearError()
138
- for (const row of rows.values()) {
139
- row.dispose()
140
- }
141
- rows.clear()
142
141
  })
143
142
  }
144
143
  }
@@ -1,6 +1,7 @@
1
1
  import { effect } from '../effect.ts'
2
2
  import { RENDER } from '../runtime/RENDER.ts'
3
3
  import { scope } from '../runtime/scope.ts'
4
+ import { scopeGroup } from '../runtime/scopeGroup.ts'
4
5
  import { clearBetween } from './clearBetween.ts'
5
6
  import { fillBefore } from './fillBefore.ts'
6
7
  import { openMarker } from './openMarker.ts'
@@ -26,6 +27,9 @@ export function switchBlock(
26
27
  before: Node | null = null,
27
28
  ): void {
28
29
  const hydration = RENDER.hydration
30
+ /* The live case's scope, registered with the owner so it disposes on owner
31
+ teardown — not only when the subject switches cases via clearBetween. */
32
+ const group = scopeGroup()
29
33
  let dispose: (() => void) | undefined
30
34
  let activeIndex: number
31
35
  let end: Comment
@@ -46,7 +50,7 @@ export function switchBlock(
46
50
  activeIndex = select(subject())
47
51
  const chosen = caseAt(activeIndex)
48
52
  if (chosen !== undefined) {
49
- dispose = scope(() => chosen.render(parent)) // claim the SSR nodes in place
53
+ dispose = group.track(scope(() => chosen.render(parent))) // claim the SSR nodes in place
50
54
  }
51
55
  end = openMarker(parent, ']')
52
56
  } else {
@@ -54,7 +58,7 @@ export function switchBlock(
54
58
  activeIndex = select(subject())
55
59
  const chosen = caseAt(activeIndex)
56
60
  if (chosen !== undefined) {
57
- dispose = fillBefore(end, (p) => chosen.render(p))
61
+ dispose = group.track(fillBefore(end, (p) => chosen.render(p)))
58
62
  }
59
63
  }
60
64
 
@@ -68,7 +72,7 @@ export function switchBlock(
68
72
  activeIndex = index
69
73
  const chosen = caseAt(index)
70
74
  if (chosen !== undefined) {
71
- dispose = fillBefore(end, (p) => chosen.render(p))
75
+ dispose = group.track(fillBefore(end, (p) => chosen.render(p)))
72
76
  }
73
77
  })
74
78
  }
@@ -1,6 +1,7 @@
1
1
  import { claimChild } from '../runtime/claimChild.ts'
2
2
  import { OWNER } from '../runtime/OWNER.ts'
3
3
  import { RENDER } from '../runtime/RENDER.ts'
4
+ import { scopeGroup } from '../runtime/scopeGroup.ts'
4
5
  import { discardBoundary } from './discardBoundary.ts'
5
6
  import { enterNamespace } from './enterNamespace.ts'
6
7
 
@@ -28,20 +29,30 @@ export function tryBlock(
28
29
  renderCatch?: (parent: Node, error: unknown) => void,
29
30
  before: Node | null = null,
30
31
  ): void {
31
- /* Run a void build under a fresh ownership scope; on throw, tear down the partial
32
- effects/listeners it registered and rethrow so the caller can fall back. */
32
+ /* The guarded subtree's scope, registered with the owner so it disposes on owner
33
+ teardown. The block renders once, so there is at most one tracked subtree (the
34
+ try branch, or the catch branch if try threw). */
35
+ const group = scopeGroup()
36
+ /* Run a void build under a fresh ownership scope. On success, hand its disposers to
37
+ the group so the subtree tears down with the owner (they were previously dropped —
38
+ the leak). On throw, tear down the partial build now and rethrow so the caller can
39
+ fall back to the catch branch. */
33
40
  const guard = (build: () => void): void => {
34
41
  const previous = OWNER.current
35
42
  const disposers: Array<() => void> = []
36
43
  OWNER.current = disposers
44
+ const disposeAll = (): void => {
45
+ for (let index = disposers.length - 1; index >= 0; index -= 1) {
46
+ disposers[index]?.()
47
+ }
48
+ }
37
49
  try {
38
50
  build()
39
51
  OWNER.current = previous
52
+ group.track(disposeAll)
40
53
  } catch (error) {
41
54
  OWNER.current = previous
42
- for (let index = disposers.length - 1; index >= 0; index -= 1) {
43
- disposers[index]?.()
44
- }
55
+ disposeAll()
45
56
  throw error
46
57
  }
47
58
  }
@@ -1,6 +1,7 @@
1
1
  import { effect } from '../effect.ts'
2
2
  import { RENDER } from '../runtime/RENDER.ts'
3
3
  import { scope } from '../runtime/scope.ts'
4
+ import { scopeGroup } from '../runtime/scopeGroup.ts'
4
5
  import { clearBetween } from './clearBetween.ts'
5
6
  import { fillBefore } from './fillBefore.ts'
6
7
  import { openMarker } from './openMarker.ts'
@@ -28,6 +29,9 @@ export function when(
28
29
  ): void {
29
30
  const hydration = RENDER.hydration
30
31
  const chosenFor = (branch: 'then' | 'else') => (branch === 'then' ? render : renderElse)
32
+ /* The live branch's scope, registered with the owner so it disposes on owner
33
+ teardown — not only on a branch flip via clearBetween. */
34
+ const group = scopeGroup()
31
35
  let dispose: (() => void) | undefined
32
36
  let activeBranch: 'then' | 'else'
33
37
  let end: Comment
@@ -40,7 +44,7 @@ export function when(
40
44
  activeBranch = condition() ? 'then' : 'else'
41
45
  const chosen = chosenFor(activeBranch)
42
46
  if (chosen !== undefined) {
43
- dispose = scope(() => chosen(parent)) // content claims the SSR nodes in place
47
+ dispose = group.track(scope(() => chosen(parent))) // content claims the SSR nodes in place
44
48
  }
45
49
  end = openMarker(parent, ']')
46
50
  } else {
@@ -48,7 +52,7 @@ export function when(
48
52
  activeBranch = condition() ? 'then' : 'else'
49
53
  const chosen = chosenFor(activeBranch)
50
54
  if (chosen !== undefined) {
51
- dispose = fillBefore(end, chosen)
55
+ dispose = group.track(fillBefore(end, chosen))
52
56
  }
53
57
  }
54
58
 
@@ -62,7 +66,7 @@ export function when(
62
66
  activeBranch = branch
63
67
  const chosen = chosenFor(branch)
64
68
  if (chosen !== undefined) {
65
- dispose = fillBefore(end, chosen)
69
+ dispose = group.track(fillBefore(end, chosen))
66
70
  }
67
71
  })
68
72
  }