@abide/abide 0.40.2 → 0.41.1

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.
Files changed (44) hide show
  1. package/AGENTS.md +327 -326
  2. package/CHANGELOG.md +62 -0
  3. package/README.md +87 -78
  4. package/package.json +1 -1
  5. package/src/lib/ui/compile/SSR_ESCAPE.ts +3 -1
  6. package/src/lib/ui/compile/compileShadow.ts +5 -0
  7. package/src/lib/ui/compile/destructureBindingNames.ts +38 -0
  8. package/src/lib/ui/compile/generateBuild.ts +96 -12
  9. package/src/lib/ui/compile/generateSSR.ts +26 -9
  10. package/src/lib/ui/compile/lowerContext.ts +29 -9
  11. package/src/lib/ui/compile/parseTemplate.ts +2 -0
  12. package/src/lib/ui/compile/renameSignalRefs.ts +19 -2
  13. package/src/lib/ui/compile/skeletonContext.ts +67 -79
  14. package/src/lib/ui/compile/templateAnchorAdapter.ts +61 -0
  15. package/src/lib/ui/compile/templateElementAdapter.ts +44 -0
  16. package/src/lib/ui/compile/types/TemplateNode.ts +2 -0
  17. package/src/lib/ui/compile/walkAnchorOrder.ts +57 -0
  18. package/src/lib/ui/compile/walkElementOrder.ts +60 -0
  19. package/src/lib/ui/dom/appendSnippet.ts +9 -8
  20. package/src/lib/ui/dom/applyResolved.ts +3 -4
  21. package/src/lib/ui/dom/awaitBlock.ts +106 -46
  22. package/src/lib/ui/dom/buildDetachedRange.ts +30 -0
  23. package/src/lib/ui/dom/depthZeroNodes.ts +34 -0
  24. package/src/lib/ui/dom/domAnchorAdapter.ts +29 -0
  25. package/src/lib/ui/dom/domElementAdapter.ts +20 -0
  26. package/src/lib/ui/dom/each.ts +33 -17
  27. package/src/lib/ui/dom/eachAsync.ts +34 -26
  28. package/src/lib/ui/dom/isElement.ts +6 -0
  29. package/src/lib/ui/dom/markerDepthDelta.ts +19 -0
  30. package/src/lib/ui/dom/mountRange.ts +4 -3
  31. package/src/lib/ui/dom/mountSlot.ts +4 -3
  32. package/src/lib/ui/dom/on.ts +6 -1
  33. package/src/lib/ui/dom/replaceRange.ts +24 -0
  34. package/src/lib/ui/dom/skeleton.ts +35 -92
  35. package/src/lib/ui/dom/switchBlock.ts +13 -10
  36. package/src/lib/ui/dom/types/EachRow.ts +9 -2
  37. package/src/lib/ui/dom/when.ts +13 -10
  38. package/src/lib/ui/runtime/RANGE_MARKER.ts +16 -0
  39. package/src/lib/ui/runtime/batch.ts +22 -0
  40. package/src/lib/ui/runtime/clientPage.ts +3 -8
  41. package/src/lib/ui/runtime/createDoc.ts +9 -13
  42. package/src/lib/ui/seedResolved.ts +28 -0
  43. package/src/lib/ui/startClient.ts +6 -4
  44. package/src/lib/ui/types/ResolvedFrame.ts +15 -0
package/CHANGELOG.md CHANGED
@@ -1,5 +1,67 @@
1
1
  # abide
2
2
 
3
+ ## 0.41.1
4
+
5
+ ### Patch Changes
6
+
7
+ - [`74f5312`](https://github.com/briancray/abide/commit/74f531260e17f48162397142fb19b3df9e081b2c) - shared replaceRange region-update seam ([`184cf41`](https://github.com/briancray/abide/commit/184cf41157ef0a5340a4a916d6cfc4f4e09b6c71))
8
+
9
+ - [`74f5312`](https://github.com/briancray/abide/commit/74f531260e17f48162397142fb19b3df9e081b2c) - single-source the marker-depth and isElement helpers ([`188c6ff`](https://github.com/briancray/abide/commit/188c6ff29fcaff2c9c206b971a2f04daf9a18cb3))
10
+
11
+ - [`74f5312`](https://github.com/briancray/abide/commit/74f531260e17f48162397142fb19b3df9e081b2c) - guard against double-dispose on reentrant branch swap ([`40600c7`](https://github.com/briancray/abide/commit/40600c7921c9fee9b6be947472c770af795f60c6))
12
+
13
+ - [`74f5312`](https://github.com/briancray/abide/commit/74f531260e17f48162397142fb19b3df9e081b2c) - await block on marker ranges, not a node array ([`5d12e83`](https://github.com/briancray/abide/commit/5d12e83b7313a04a925df3464ef5c7669bcbc1e6))
14
+
15
+ - [`74f5312`](https://github.com/briancray/abide/commit/74f531260e17f48162397142fb19b3df9e081b2c) - refresh AGENTS surface map, README, and kitchen-sink example ([`6cd7a3f`](https://github.com/briancray/abide/commit/6cd7a3f5686a69f4dd5faf87026d7f1ab0362bdb))
16
+
17
+ - [`74f5312`](https://github.com/briancray/abide/commit/74f531260e17f48162397142fb19b3df9e081b2c) - single client warm-seed intake (seedResolved) ([`8c3c19e`](https://github.com/briancray/abide/commit/8c3c19ed0a4d4244cb2634c025ef9ff88b7c9670))
18
+
19
+ - [`74f5312`](https://github.com/briancray/abide/commit/74f531260e17f48162397142fb19b3df9e081b2c) - centralize comment-marker sentinels and detached-range builder ([`99d4499`](https://github.com/briancray/abide/commit/99d44991c499b96fc016c3d204cb4dafa340cbe1))
20
+
21
+ - [`74f5312`](https://github.com/briancray/abide/commit/74f531260e17f48162397142fb19b3df9e081b2c) - unify skeleton positional model on shared walks ([`cbb9daa`](https://github.com/briancray/abide/commit/cbb9daaa6f31532331df44136dd029f3e32cdfd7))
22
+
23
+ - [`f3e0a13`](https://github.com/briancray/abide/commit/f3e0a132f9f9fbb020a8c783a0b54d3b37457e39) - Coalesce reactive writes inside event handlers
24
+
25
+ Event handlers now batch their writes: a handler that sets several signals re-runs
26
+ each dependent effect/computed/DOM-binding once on the handler's exit instead of once
27
+ per write (the previous default flushed eagerly per write). The change stays fully
28
+ synchronous — the flush runs at handler-end before it returns — so the causal stack
29
+ (`dispatch → handler → effects`) is intact and server/client scheduling stays identical.
30
+
31
+ Factored the batch idiom `createDoc`/`clientPage` inlined into a shared, nesting-safe
32
+ `batch()` (flushes only on the depth-0 exit) and migrated both onto it, so a handler
33
+ that triggers a doc patch now coalesces end-to-end rather than flushing mid-handler.
34
+
35
+ A handler writing N fields cuts dependent re-runs N× (bench: an 8-field form handler
36
+ drops aggregate re-runs 8× and runs ~4.8× faster). Single-write handlers and navigation
37
+ (already batched) are unchanged. New contract, pinned by tests: a handler that writes
38
+ then synchronously reads the bound DOM sees the pre-write value until it returns; signal
39
+ reads stay current.
40
+
41
+ - [`f26cba2`](https://github.com/briancray/abide/commit/f26cba22b92683f7316cc7212c5fe10500bdad13) - `abide check`: declare the `each` `index` binding in the type-check shadow
42
+
43
+ `index="i"` (0.41.0) was bound in the build and SSR passes but not in the shadow the
44
+ type-checker reads, so `{i}` in a row body false-positived "Cannot find name 'i'". The
45
+ shadow now declares the index as a `number` inside the loop body, matching the runtime.
46
+
47
+ ## 0.41.0
48
+
49
+ ### Minor Changes
50
+
51
+ - [`ba08127`](https://github.com/briancray/abide/commit/ba081270c4721d64655457681e4ebcc79681f1d4) - `index="i"` on `<template each>` — bind the row's reactive position
52
+
53
+ `<template each={list} as="item" key="item.id" index="i">` binds the iteration index to a name. In a keyed `each` the index is reactive: a reorder/insert/remove that shifts a surviving row repaints its `{i}` in place (same DOM, no rebuild), riding the same `Object.is` cell-write path as the item binding. SSR renders the index via `entries()` so hydration stays congruent; async `each await` carries the stream arrival ordinal.
54
+
55
+ - [`ba08127`](https://github.com/briancray/abide/commit/ba081270c4721d64655457681e4ebcc79681f1d4) - In-place reactive updates for `<template await>` `then` values and keyed `each` items
56
+
57
+ A re-settling `await` block and a re-keyed `each` row no longer rebuild their subtree — they update through a reactive value cell instead, so a live cache patch updates only what changed and never flashes the surrounding DOM.
58
+
59
+ - `awaitBlock` keeps the mounted `then`-branch across a re-run, setting a reactive value cell rather than detaching + rebuilding. A revalidation now keeps the stale branch visible and patches in place; the branch is rebuilt only across a pending/catch ↔ then kind change.
60
+ - The `then` binding is lowered to read that cell reactively — both a single identifier (`then="value"`) and a destructure (`then="[a, b]"` / `{ x, y }`), where each leaf is derived per-read so only the leaves whose value changed propagate.
61
+ - Keyed `each` holds each row's item in a reactive cell; a re-key with a changed value (same key, new object) writes the cell through `Object.is`, re-running only that row's effects with no DOM rebuild. The row `render` now receives `State<T>` (the compiler binds the `as` name to read it).
62
+ - A destructuring `each … as="[a, b]"` (or `{ x, y }`) with no explicit `key` now defaults the key to the row's raw item identity, like a plain `as`. Previously the default key re-emitted the destructure pattern, allocating a fresh array/object per reconcile, so keys never matched and every row rebuilt on any list change.
63
+ - A block value binding (`then="x"` / `each … as="x"`) now correctly shadows a same-named component `state`/`computed`/`derived`: it is a nearer lexical scope, so `{x}` in the block reads the resolved/row cell rather than the component signal. A destructure pattern's default/computed-key initializer is also lowered, so `then="{ id, label = fallback }"` resolves `fallback` against the component scope instead of emitting it raw.
64
+
3
65
  ## 0.40.2
4
66
 
5
67
  ### Patch Changes
package/README.md CHANGED
@@ -1,24 +1,24 @@
1
1
  # abide
2
2
 
3
- **One typed declaration fans out to HTTP, a CLI, an MCP tool, and an OpenAPI
4
- spec — and the bundler swaps the runtime per side.**
3
+ **One typed declaration fans out to HTTP, a CLI, an MCP tool, and an
4
+ OpenAPI spec — the bundler swaps the runtime per side.**
5
5
 
6
- abide is an isomorphic framework on Bun and web standards: you declare a verb,
7
- a socket, or a component once, and the same callable runs server-side, in the
8
- browser, from the terminal, and from an agent the bundler decides which
9
- runtime each side gets. Built for humans *and* machines.
6
+ abide is an isomorphic framework on Bun where you write a function once and
7
+ it serves every consumer: a browser fetch, an in-process SSR call, a CLI
8
+ subcommand, an MCP tool, an OpenAPI operation. The same callable keeps its
9
+ name and behaviour on both sides the bundler decides whether it runs the
10
+ real handler or a network proxy. Built for humans _and_ machines.
10
11
 
11
- - One direct dependency the TypeScript compiler, used by `abide check`.
12
- Tailwind is an optional peer. Everything runs on a single Bun runtime
13
- (≥ 1.3.0); no second toolchain.
12
+ - One direct dependency (`typescript`); `tailwindcss` + `bun-plugin-tailwind`
13
+ are optional peers. Single runtime: Bun 1.3.0.
14
14
 
15
15
  ## Quick start
16
16
 
17
17
  ```sh
18
- bunx abide scaffold my-app # copies the template, installs, starts dev
18
+ bunx abide scaffold my-app # scaffolds, installs deps, and starts dev
19
19
  ```
20
20
 
21
- Or clone the kitchen-sink and run it:
21
+ Or read the full feature tour in the kitchen-sink example:
22
22
 
23
23
  ```sh
24
24
  git clone https://github.com/briancray/abide
@@ -29,134 +29,143 @@ bun run dev
29
29
 
30
30
  ## RPCs
31
31
 
32
- An RPC is one export per file under `src/server/rpc/`. **The file path is the
33
- URL.** The schema validates arguments and projects the MCP tool, the CLI flags,
34
- and the OpenAPI operation. The contract is Standard Schema zod, valibot, or
35
- arktype, unadapted.
32
+ An RPC is one export per file under `src/server/rpc/`. The file path is the
33
+ URL; the export name is the verb. A Standard Schema (zod / valibot / arktype,
34
+ unadapted) validates the args and projects the same shape into the MCP tool,
35
+ the CLI flags, and the OpenAPI operation.
36
36
 
37
37
  ```ts
38
- // src/server/rpc/getMessages.ts — file path is the route: GET /getMessages
38
+ // src/server/rpc/getMessages.ts
39
39
  import { GET } from '@abide/abide/server/GET'
40
40
  import { json } from '@abide/abide/server/json'
41
41
  import { z } from 'zod'
42
+ import { recent } from '../../chatState.ts'
42
43
 
43
- const inputSchema = z.object({ room: z.string() })
44
+ const inputSchema = z.object({ room: z.string(), limit: z.coerce.number().default(20) })
44
45
 
45
- export const getMessages = GET(({ room }) => json(board(room)), { inputSchema })
46
+ export const getMessages = GET(({ room, limit }) => json(recent(room).slice(-limit)), {
47
+ inputSchema,
48
+ })
46
49
  ```
47
50
 
48
- One declaration, five surfaces:
51
+ One declaration, every surface:
49
52
 
50
53
  ```text
51
54
  getMessages = GET(fn, { inputSchema })
52
55
 
53
- ┌───────────┬─────────────┼────────────┬──────────────┐
54
- SSR call browser fetch MCP tool CLI command OpenAPI op
55
- cache(fn)() typed proxy() (read-only) abide ... cli /openapi.json
56
+ ┌─────────────┬─────────┼──────────┬──────────────┐
57
+ ▼ ▼ ▼ ▼ ▼
58
+ SSR call browser MCP tool CLI sub- OpenAPI
59
+ cache(fn)() fetch (read) command operation
60
+ proxy
56
61
  ```
57
62
 
58
- A schema unlocks the CLI everywhere and MCP for read-only verbs; a mutating
59
- verb never auto-exposes to MCP — it needs explicit `clients: { mcp: true }`.
60
- Consume the verb four ways: `cache(getMessages)({ room })` resolves in-process
61
- during SSR, the same call hits a swapped `fetch` in the browser,
62
- `getMessages.raw(args)` returns the undecoded `Response`, and
63
- `getMessages.stream(args)` is an iterable view of the body.
63
+ A schema unlocks the CLI for every verb and MCP for read-only verbs (`GET` /
64
+ `HEAD`); a mutating verb never auto-exposes to MCP — it needs an explicit
65
+ `clients: { mcp: true }`. Consume the verb four ways: `cache(getMessages)(args)`
66
+ in-process (warm SSR hydration), the swapped `fetch` proxy in the browser,
67
+ `getMessages.raw(args)` for the untouched `Response`, and
68
+ `getMessages.stream(args)` to iterate a `jsonl`/`sse` body.
64
69
 
65
- > Query args travel as strings — validate with `z.coerce.*`. The per-verb
66
- > `timeout` (504 on every surface) is distinct from `ABIDE_CLIENT_TIMEOUT`.
70
+ > Query args arrive as strings — wrap numeric/boolean fields in `z.coerce.*`.
71
+ > The per-verb `timeout` (504 on every surface) is distinct from the
72
+ > client-side `ABIDE_CLIENT_TIMEOUT`.
67
73
 
68
74
  ## Sockets
69
75
 
70
76
  A socket is one broadcast topic per file under `src/server/sockets/`. A
71
- `Socket<T>` is an isomorphic `AsyncIterable<T>`, and every socket multiplexes
72
- onto one websocket at `/__abide/sockets`.
77
+ `Socket<T>` is an isomorphic `AsyncIterable<T>`; every socket multiplexes onto
78
+ one WebSocket at `/__abide/sockets`.
73
79
 
74
80
  ```ts
75
- // src/server/sockets/messages.ts — topic name = file name: `messages`
81
+ // src/server/sockets/chat.ts
76
82
  import { socket } from '@abide/abide/server/socket'
77
83
  import { z } from 'zod'
78
84
 
79
- const schema = z.object({ id: z.string(), room: z.string(), from: z.string(), text: z.string() })
85
+ const schema = z.object({ id: z.string(), from: z.string(), text: z.string(), at: z.number() })
80
86
 
81
- // retain the last 50 frames; evict any older than an hour
82
- export const messages = socket({ schema, tail: 50, ttl: 3_600_000 })
83
- export type Message = z.infer<typeof schema>
87
+ // retain the last 100 frames; evict any older than an hour
88
+ export const chat = socket({ schema, tail: 100, ttl: 3_600_000 })
89
+ export type ChatMessage = z.infer<typeof schema>
84
90
  ```
85
91
 
86
- The socket also has an HTTP face at `/__abide/sockets/messages`, for clients
87
- that can't speak the ws multiplex: `GET` returns the retained tail, `POST`
88
- publishes — gated by `clientPublish` (off by default, so the POST 403s).
92
+ It also has an HTTP face for clients that can't speak the multiplex (the CLI
93
+ and MCP): `GET /__abide/sockets/chat` returns the retained tail, and
94
+ `POST /__abide/sockets/chat` publishes — gated by `clientPublish` (default off,
95
+ so browsers publish through a validating verb instead).
89
96
 
90
- ## Components
97
+ ## Components — the full template
91
98
 
92
- A `.abide` component is the payoff: it imports the verb and the socket above
93
- and ties them together with the native `<template>` grammar. Reactive state is
94
- reached only through `scope()` `scope().state()` is writable,
95
- `scope().computed()` is read-only. `props()` and `effect()` are in-scope, no
96
- import.
99
+ A `.abide` component pulls the verb and the socket above into one page and
100
+ exercises the template grammar. `scope`, `props`, `effect`, `html`, and
101
+ `snippet` are ambientno import needed.
97
102
 
103
+ <!-- prettier-ignore -->
98
104
  ```html
99
105
  <script>
100
106
  import { cache } from '@abide/abide/shared/cache'
101
107
  import { tail } from '@abide/abide/ui/tail'
102
108
  import { getMessages } from '$server/rpc/getMessages.ts'
103
- import { postMessage } from '$server/rpc/postMessage.ts'
104
- import { messages } from '$server/sockets/messages.ts'
109
+ import { publishChat } from '$server/rpc/publishChat.ts'
110
+ import { chat } from '$server/sockets/chat.ts'
105
111
  import Avatar from '$ui/Avatar.abide'
106
112
 
107
- const { room = 'lobby' } = props<{ room?: string }>()
113
+ const { room } = props()
108
114
 
109
- // SSR-warm history; live frames then stream over the ws
110
- const seed = cache(getMessages)({ room })
111
- const live = scope().computed(() => tail(messages, { last: 50 }))
115
+ // warm on the server, live on the client
116
+ const history = scope().computed(() => cache(getMessages)({ room }))
117
+ const latest = scope().computed(() => tail(chat))
112
118
 
113
119
  let from = scope().state('alice')
114
120
  let text = scope().state('')
115
121
  let pinned = scope().state(false)
116
- let sort = scope().state('newest')
122
+ let view = scope().state('all')
117
123
 
118
124
  async function send() {
119
- await postMessage({ room, from, text })
125
+ await publishChat({ from, text })
120
126
  text = ''
121
127
  }
122
128
  </script>
123
129
 
124
- <template name="bubble" args={msg}>
125
- <li class="flex gap-2"><Avatar name={msg.from} /> <b>{msg.from}</b> {msg.text}</li>
130
+ <template name="line" args={message}>
131
+ <li><Avatar name={message.from} /> <b>{message.from}</b>: {message.text}</li>
126
132
  </template>
127
133
 
128
- <form onsubmit={send} class="flex gap-2">
129
- <input bind:value={from} class="border px-2" />
130
- <input bind:value={text} placeholder="message" class="flex-1 border px-2" />
134
+ <form onsubmit={send}>
135
+ <input bind:value={from} placeholder="name" />
136
+ <input bind:value={text} placeholder="message" />
131
137
  <label><input type="checkbox" bind:checked={pinned} /> pin</label>
132
- <label><input type="radio" bind:group={sort} value="newest" /> newest</label>
133
- <button disabled={!text} class="border px-3">send</button>
138
+ <label><input type="radio" bind:group={view} value="all" /> all</label>
139
+ <label><input type="radio" bind:group={view} value="mine" /> mine</label>
140
+ <button disabled={!text}>send</button>
134
141
  </form>
135
142
 
136
- <template if={pinned}>
137
- <p class="text-xs text-amber-700">room pinned</p>
138
- <template else><p class="text-xs text-slate-400">not pinned</p></template>
143
+ <template if={latest}>
144
+ <p>latest from {latest.from}</p>
145
+ <template else>
146
+ <p>no messages yet</p>
139
147
  </template>
140
148
 
141
- <template switch={sort}>
142
- <template case={'newest'}><p class="text-xs">newest first</p></template>
143
- <template default><p class="text-xs">oldest first</p></template>
149
+ <template switch={view}>
150
+ <template case="mine"><p>showing your messages</p></template>
151
+ <template default><p>showing every message</p></template>
144
152
  </template>
145
153
 
146
- <template await={seed}>
147
- <p class="text-xs text-slate-500">loading…</p>
148
- <template then="history">
149
- <ul class="mt-3 space-y-1">
150
- <template each={live ?? history} as="msg" key="msg.id">
151
- {bubble(msg)}
152
- </template>
153
- </ul>
154
- </template>
155
- <template catch="err"><p class="text-rose-700">{err.message}</p></template>
154
+ <template await={history}>
155
+ <p>loading…</p>
156
+ <template then="data">
157
+ <ul>
158
+ <template each={data} as="message" key="message.id" index="i">
159
+ {i}. {line(message)}
160
+ </template>
161
+ </ul>
162
+ <template catch="reason">
163
+ <p>failed: {reason.message}</p>
164
+ </template>
156
165
  </template>
157
166
 
158
167
  <style>
159
- li { font-size: 0.875rem; }
168
+ form { display: flex; gap: 0.5rem; }
160
169
  </style>
161
170
  ```
162
171
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@abide/abide",
3
- "version": "0.40.2",
3
+ "version": "0.41.1",
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",
@@ -1,3 +1,5 @@
1
+ import { SNIPPET_CLOSE, SNIPPET_OPEN } from '../runtime/RANGE_MARKER.ts'
2
+
1
3
  /*
2
4
  Source text for the `$esc` / `$attr` / `$spread` / `$text` / `$snip` helpers injected
3
5
  into every SSR render body. `$esc` escapes the five HTML-significant characters. `$attr`
@@ -30,6 +32,6 @@ export const SSR_ESCAPE =
30
32
  "const $SNIP = Symbol.for('abide.snippet');\n" +
31
33
  'const $snip = (s) => ({ [$SNIP]: s });\n' +
32
34
  'const $text = (v) => (v !== null && typeof v === "object" && $SNIP in v) ' +
33
- "? ('<!--abide:snippet-->' + v[$SNIP] + '<!--/abide:snippet-->') " +
35
+ `? ('<!--${SNIPPET_OPEN}-->' + v[$SNIP] + '<!--${SNIPPET_CLOSE}-->') ` +
34
36
  ': (v !== null && typeof v === "object" && $RAW in v) ' +
35
37
  "? ('<!--abide:html-->' + v[$RAW] + '<!--/abide:html-->') : $esc(v);"
@@ -417,6 +417,11 @@ function emitNode(node: TemplateNode, builder: Builder): void {
417
417
  if (node.key !== undefined) {
418
418
  builder.raw(`void (${node.key});\n`)
419
419
  }
420
+ /* `index="i"` binds the row's position — always a number (the row ordinal,
421
+ or an async stream's arrival count). Declare it so body references check. */
422
+ if (node.index !== undefined) {
423
+ builder.raw(`const ${node.index}: number = 0;\n`)
424
+ }
420
425
  emitNodes(node.children, builder)
421
426
  builder.raw('}\n')
422
427
  return
@@ -0,0 +1,38 @@
1
+ import ts from 'typescript'
2
+
3
+ /*
4
+ The leaf binding names a destructuring pattern introduces, in source order —
5
+ `[a, b]` → `a, b`; `{ x, y: z }` → `x, z`; `[a, ...rest]` → `a, rest`; nested
6
+ patterns flatten. Used to re-bind an `await` `then` destructure as per-leaf
7
+ reactive reads of the resolved-value cell, so a re-settle updates each leaf in
8
+ place instead of rebuilding the branch.
9
+ */
10
+ export function destructureBindingNames(pattern: string): string[] {
11
+ const source = ts.createSourceFile(
12
+ 'pattern.ts',
13
+ `const ${pattern} = $;`,
14
+ ts.ScriptTarget.Latest,
15
+ true,
16
+ )
17
+ const names: string[] = []
18
+ const collect = (name: ts.BindingName): void => {
19
+ if (ts.isIdentifier(name)) {
20
+ names.push(name.text)
21
+ return
22
+ }
23
+ // Object/array pattern: each element binds; array holes are OmittedExpression, not BindingElement.
24
+ for (const element of name.elements) {
25
+ if (ts.isBindingElement(element)) {
26
+ collect(element.name)
27
+ }
28
+ }
29
+ }
30
+ for (const statement of source.statements) {
31
+ if (ts.isVariableStatement(statement)) {
32
+ for (const declaration of statement.declarationList.declarations) {
33
+ collect(declaration.name)
34
+ }
35
+ }
36
+ }
37
+ return names
38
+ }
@@ -1,9 +1,11 @@
1
1
  import { assertExhaustive } from '../../shared/assertExhaustive.ts'
2
2
  import { HOLE_ATTRIBUTE } from '../runtime/HOLE_ATTRIBUTE.ts'
3
3
  import { OUTLET_TAG } from '../runtime/OUTLET_TAG.ts'
4
+ import { ANCHOR } from '../runtime/RANGE_MARKER.ts'
4
5
  import { asOutlet } from './asOutlet.ts'
5
6
  import { bindListenEvent } from './bindListenEvent.ts'
6
7
  import { composeProps } from './composeProps.ts'
8
+ import { destructureBindingNames } from './destructureBindingNames.ts'
7
9
  import { groupBindParts } from './groupBindParts.ts'
8
10
  import { isControlFlow } from './isControlFlow.ts'
9
11
  import { isWhitespaceText } from './isWhitespaceText.ts'
@@ -17,6 +19,12 @@ import { staticTextPart } from './staticTextPart.ts'
17
19
  import type { TemplateNode } from './types/TemplateNode.ts'
18
20
  import { VOID_TAGS } from './VOID_TAGS.ts'
19
21
 
22
+ /* The skeleton positioning anchor a control-flow block / component / slot / outlet
23
+ stamps into its skeleton markup, sourced from the same `ANCHOR` wire-alphabet constant
24
+ the client's anchor scan (`skeleton`) matches — so the markup the build clones and the
25
+ markup the scan reads can never drift on a literal. */
26
+ const ANCHOR_COMMENT = `<!--${ANCHOR}-->`
27
+
20
28
  /*
21
29
  Generates the build statements for a parsed template: element creation, static
22
30
  attributes, reactive `attr`/`text` bindings, `on` listeners, keyed `each`, and
@@ -85,10 +93,50 @@ export function generateBuild(
85
93
  expression: lowerExpression,
86
94
  statement: lowerStatement,
87
95
  withNestedScripts,
96
+ withLocalDerived,
88
97
  bindRead,
89
98
  bindWrite,
90
99
  } = lowerContext(stateNames, derivedNames, computedNames)
91
100
 
101
+ /* A value binding the runtime can update in place (an `await` `then` value, a keyed `each`
102
+ item) is passed as a reactive `.value` cell; the consuming branch/row reads its NAME(s)
103
+ as a deref so it re-runs in place when a re-settle/re-key sets the cell. */
104
+ const isPlainIdentifier = (name: string | undefined): name is string =>
105
+ name !== undefined && /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(name)
106
+
107
+ /* How a reactive value param binds: `param` is the thunk's value parameter (the cell),
108
+ `prefix` declares any per-leaf readers, `localNames` enter the deref scope for the body.
109
+ A plain identifier reads the cell directly (`item` → `item.value`); a destructure
110
+ re-applies over the cell per read so each leaf stays reactive (JS handles
111
+ defaults/rest/rename/nesting). The caller lowers the body with `localNames` derived. */
112
+ function reactiveBinding(authorParam: string): {
113
+ param: string
114
+ prefix: string
115
+ localNames: string[]
116
+ } {
117
+ if (isPlainIdentifier(authorParam)) {
118
+ return { param: authorParam, prefix: '', localNames: [authorParam] }
119
+ }
120
+ const cellParam = nextVar('aw')
121
+ const deriveVar = nextVar('ad')
122
+ const leaves = destructureBindingNames(authorParam)
123
+ /* Lower the destructure declaration so a default/computed-key initializer that
124
+ references a component signal (`{ label = fallback }`, `{ [key]: v }`) is rewritten
125
+ to its `model`/cell form — the bound leaf names are name-slots and stay untouched.
126
+ A pattern with no such initializer lowers to itself, so the common case is unchanged
127
+ and SSR (which lowers the same declaration) stays congruent. */
128
+ const declaration = lowerStatement(`const ${authorParam} = ${cellParam}.value`)
129
+ const prefix =
130
+ `const ${deriveVar} = { get value() { ${declaration} return { ${leaves.join(', ')} }; } };\n` +
131
+ leaves
132
+ .map(
133
+ (leaf) =>
134
+ `const ${leaf} = { get value() { return ${deriveVar}.value.${leaf}; } };\n`,
135
+ )
136
+ .join('')
137
+ return { param: cellParam, prefix, localNames: leaves }
138
+ }
139
+
92
140
  /* Emits the wiring for one non-static attribute against an already-obtained skeleton
93
141
  element var — reactive `attr`, `on` listener, `attach`, or a two-way `bind`. */
94
142
  function dynamicAttr(
@@ -172,7 +220,7 @@ export function generateBuild(
172
220
  binds.push(
173
221
  `appendTextAt(${skVar}.an[${holeIndex(anIndex, part)}], ${namedThunk('text', `return (${lowerExpression(part.code)})`)});\n`,
174
222
  )
175
- return '<!--a-->'
223
+ return ANCHOR_COMMENT
176
224
  })
177
225
  .join('')
178
226
  }
@@ -185,7 +233,7 @@ export function generateBuild(
185
233
  element — so its root lays out as a true direct child of `anchor.parentNode`. */
186
234
  const anchorVar = anchorVarAt(node, skVar, binds)
187
235
  binds.push(generateChild(node, `${anchorVar}.parentNode`, `anchorCursor(${anchorVar})`))
188
- return '<!--a-->'
236
+ return ANCHOR_COMMENT
189
237
  }
190
238
  if (node.kind === 'script') {
191
239
  /* A nested `<script>` (scoped reactive block) emits no markup — its lowered body
@@ -210,7 +258,7 @@ export function generateBuild(
210
258
  No wrapper element — the filled child lays out as a direct child of the parent. */
211
259
  const anchorVar = anchorVarAt(node, skVar, binds)
212
260
  binds.push(`outlet(${anchorVar}.parentNode, anchorCursor(${anchorVar}));\n`)
213
- return '<!--a-->'
261
+ return ANCHOR_COMMENT
214
262
  }
215
263
  if (node.tag === 'slot') {
216
264
  /* A `<slot>` outlet at its position: an `<!--a-->` anchor, the slot's content
@@ -220,7 +268,7 @@ export function generateBuild(
220
268
  binds.push(
221
269
  `mountSlot(${anchorVar}.parentNode, (${hostVar}) => {\n${generateSlot(node, hostVar)}}, anchorCursor(${anchorVar}));\n`,
222
270
  )
223
- return '<!--a-->'
271
+ return ANCHOR_COMMENT
224
272
  }
225
273
  const hasReactiveAttr = node.attrs.some((attr) => attr.kind !== 'static')
226
274
  const reactiveTextChild = node.children.find(
@@ -498,13 +546,21 @@ export function generateBuild(
498
546
  const pending = node.blocking
499
547
  ? []
500
548
  : node.children.filter((child) => child.kind !== 'branch')
549
+ /* The resolved value is reactive: a re-settle updates it in place rather than
550
+ rebuilding the branch (see awaitBlock). The branch reads it as a `.value` cell. */
501
551
  const thenThunk = node.blocking
502
552
  ? branchThunk(
503
553
  node.children.filter((child) => child.kind !== 'branch'),
504
554
  node.as ?? '_value',
505
555
  finallyChildren,
556
+ true,
557
+ )
558
+ : branchThunk(
559
+ thenBranch?.children ?? [],
560
+ thenBranch?.as ?? '_value',
561
+ finallyChildren,
562
+ true,
506
563
  )
507
- : branchThunk(thenBranch?.children ?? [], thenBranch?.as ?? '_value', finallyChildren)
508
564
  /* Neither catch nor finally → pass `undefined` so awaitBlock re-throws the
509
565
  rejection (surfacing it) instead of rendering an empty branch. A finally-only
510
566
  block keeps a catch thunk that renders just finally. */
@@ -536,18 +592,29 @@ export function generateBuild(
536
592
  children: TemplateNode[],
537
593
  valueParam?: string,
538
594
  finallyChildren: TemplateNode[] = [],
595
+ reactiveValue = false,
539
596
  ): string {
540
597
  const parentParam = nextVar('p')
541
- const head =
542
- valueParam === undefined ? `(${parentParam})` : `(${parentParam}, ${valueParam})`
543
- const body = withNestedScripts(children, () => generateChildren(children, parentParam))
598
+ /* A reactive value (an `await` `then`) arrives as a `.value` cell the runtime can set
599
+ in place, so the branch re-runs in place on a re-settle instead of being rebuilt. */
600
+ const binding =
601
+ reactiveValue && valueParam !== undefined ? reactiveBinding(valueParam) : undefined
602
+ const param = binding?.param ?? valueParam
603
+ const prefix = binding?.prefix ?? ''
604
+ const localNames = binding?.localNames ?? []
605
+ const head = param === undefined ? `(${parentParam})` : `(${parentParam}, ${param})`
606
+ const body = withNestedScripts(children, () =>
607
+ localNames.length === 0
608
+ ? generateChildren(children, parentParam)
609
+ : withLocalDerived(localNames, () => generateChildren(children, parentParam)),
610
+ )
544
611
  const finallyBody =
545
612
  finallyChildren.length > 0
546
613
  ? withNestedScripts(finallyChildren, () =>
547
614
  generateChildren(finallyChildren, parentParam),
548
615
  )
549
616
  : ''
550
- return `${head} => {\n${body}${finallyBody}}`
617
+ return `${head} => {\n${prefix}${body}${finallyBody}}`
551
618
  }
552
619
 
553
620
  /* True when a branch has content worth a render thunk — vs an absent/empty branch
@@ -626,13 +693,30 @@ export function generateBuild(
626
693
  before: string,
627
694
  ): string {
628
695
  const rowParam = nextVar('p')
696
+ /* The item is a reactive `.value` cell so a re-key with a changed value updates the row
697
+ in place (no rebuild). `keyOf` receives the RAW item; the key expression is lowered
698
+ with the author name plain — derive it BEFORE the row body puts that name in the
699
+ deref scope. With no explicit `key`, default the key to the item's own identity: a
700
+ plain `as` returns its name; a destructuring `as` binds a fresh param and returns
701
+ THAT, not the pattern re-wrapped (`[i,crumb]` → `[i,crumb]` would allocate a fresh
702
+ array per reconcile, so keys never match and every row rebuilds). An explicit `key`
703
+ destructures the item via `node.as` to read its leaves. */
704
+ const rawItemParam = isPlainIdentifier(node.as) ? node.as : nextVar('k')
705
+ const keyParam = node.key === undefined ? rawItemParam : node.as
706
+ const keyExpression = node.key === undefined ? rawItemParam : lowerExpression(node.key)
707
+ const binding = reactiveBinding(node.as)
708
+ /* `index="i"` binds the row's position as a third reactive cell param (the runtime
709
+ always passes it). It is a plain identifier — read as `i.value` — so it enters the
710
+ body's deref scope alongside the item's leaf names; an unnamed param when absent. */
711
+ const indexParam = node.index === undefined ? '' : `, ${node.index}`
712
+ const bodyLocalNames =
713
+ node.index === undefined ? binding.localNames : [...binding.localNames, node.index]
629
714
  /* The row body builds its children (a `<script>` declares per-row local signals,
630
715
  emitted in document order) into the row parent. A `<template catch>` child is
631
716
  consumed by the async-each, not the row — `generateChildren` skips it. */
632
717
  const rowBody = withNestedScripts(node.children, () =>
633
- generateChildren(node.children, rowParam),
718
+ withLocalDerived(bodyLocalNames, () => generateChildren(node.children, rowParam)),
634
719
  )
635
- const keyExpression = node.key === undefined ? node.as : lowerExpression(node.key)
636
720
  /* `await` → the AsyncIterable runtime, drained row-by-row on the client, with an
637
721
  optional `<template catch>` branch rendered (after the streamed rows) when the
638
722
  iterator rejects. Absent → `undefined`, so the rejection surfaces instead. */
@@ -643,7 +727,7 @@ export function generateBuild(
643
727
  : ''
644
728
  return (
645
729
  `${fn}(${parentVar}, () => (${lowerExpression(node.items)}), ` +
646
- `(${node.as}) => (${keyExpression}), (${rowParam}, ${node.as}) => {\n${rowBody}}${catchArg}, ${before});\n`
730
+ `(${keyParam}) => (${keyExpression}), (${rowParam}, ${binding.param}${indexParam}) => {\n${binding.prefix}${rowBody}}${catchArg}, ${before});\n`
647
731
  )
648
732
  }
649
733