@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/AGENTS.md +196 -215
- package/CHANGELOG.md +10 -0
- package/README.md +75 -69
- package/package.json +1 -1
- package/src/lib/shared/cache.ts +10 -3
- package/src/lib/shared/cacheManagedSlot.ts +10 -0
- package/src/lib/shared/withCacheManaged.ts +18 -0
- package/src/lib/ui/dom/awaitBlock.ts +7 -3
- package/src/lib/ui/dom/each.ts +8 -15
- package/src/lib/ui/dom/eachAsync.ts +8 -9
- package/src/lib/ui/dom/switchBlock.ts +7 -3
- package/src/lib/ui/dom/tryBlock.ts +16 -5
- package/src/lib/ui/dom/when.ts +7 -3
- package/src/lib/ui/remoteProxy.ts +32 -6
- package/src/lib/ui/router.ts +26 -10
- package/src/lib/ui/runtime/REQUEST_SUPERSEDED.ts +8 -0
- package/src/lib/ui/runtime/abortNode.ts +22 -0
- package/src/lib/ui/runtime/currentAbortSignal.ts +26 -0
- package/src/lib/ui/runtime/reactiveAbortState.ts +15 -0
- package/src/lib/ui/runtime/runNode.ts +9 -0
- package/src/lib/ui/runtime/scopeGroup.ts +40 -0
- package/src/lib/ui/runtime/unlinkDeps.ts +8 -0
package/README.md
CHANGED
|
@@ -1,153 +1,159 @@
|
|
|
1
1
|
# abide
|
|
2
2
|
|
|
3
|
-
**
|
|
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
|
|
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
|
-
-
|
|
8
|
-
-
|
|
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
|
|
13
|
+
bunx abide scaffold my-app # scaffolds, installs deps, and (in a TTY) starts dev
|
|
14
14
|
```
|
|
15
15
|
|
|
16
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
42
|
+
That one declaration fans out:
|
|
44
43
|
|
|
45
44
|
```text
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
(
|
|
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
|
|
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.*`
|
|
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
|
-
|
|
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/
|
|
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({
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
|
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
|
-
|
|
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 {
|
|
86
|
-
import {
|
|
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
|
|
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
|
|
95
|
-
let
|
|
96
|
-
let
|
|
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 (
|
|
101
|
-
await
|
|
102
|
-
|
|
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
|
-
|
|
107
|
-
|
|
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}
|
|
112
|
-
<input bind:value={
|
|
113
|
-
<label><input type="checkbox" bind:checked={
|
|
114
|
-
<
|
|
115
|
-
|
|
116
|
-
|
|
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={
|
|
122
|
-
<p class="
|
|
126
|
+
<template if={live}>
|
|
127
|
+
<p class="live">latest: {live.text}</p>
|
|
123
128
|
<template else>
|
|
124
|
-
<p
|
|
129
|
+
<p>no messages yet</p>
|
|
125
130
|
</template>
|
|
126
131
|
</template>
|
|
127
132
|
|
|
128
133
|
<template switch={filter}>
|
|
129
|
-
<template case={'
|
|
130
|
-
<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={
|
|
134
|
-
<p>loading
|
|
135
|
-
<template then="
|
|
139
|
+
<template await={cache(getMessages)({ room })}>
|
|
140
|
+
<p>loading…</p>
|
|
141
|
+
<template then="history">
|
|
136
142
|
<ul>
|
|
137
|
-
<template each={messages} as="
|
|
138
|
-
{
|
|
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="
|
|
143
|
-
<p
|
|
148
|
+
<template catch="error">
|
|
149
|
+
<p>failed: {error.message}</p>
|
|
144
150
|
</template>
|
|
145
151
|
</template>
|
|
146
152
|
|
|
147
153
|
<style>
|
|
148
|
-
.
|
|
149
|
-
|
|
150
|
-
|
|
154
|
+
.live {
|
|
155
|
+
font-weight: 600;
|
|
156
|
+
}
|
|
151
157
|
</style>
|
|
152
158
|
```
|
|
153
159
|
|
package/package.json
CHANGED
package/src/lib/shared/cache.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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 =
|
|
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[] = []
|
package/src/lib/ui/dom/each.ts
CHANGED
|
@@ -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 =
|
|
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 =
|
|
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
|
|
131
|
-
|
|
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
|
-
/*
|
|
32
|
-
|
|
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
|
-
|
|
43
|
-
disposers[index]?.()
|
|
44
|
-
}
|
|
55
|
+
disposeAll()
|
|
45
56
|
throw error
|
|
46
57
|
}
|
|
47
58
|
}
|
package/src/lib/ui/dom/when.ts
CHANGED
|
@@ -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
|
}
|