@abide/abide 0.32.0 → 0.32.2

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 CHANGED
@@ -181,6 +181,7 @@ Same selector grammar as `cache.invalidate`; also accept a `Subscribable`. See C
181
181
  Valid HTML with `<script>` + native `<template>` control flow + scoped `<style>`.
182
182
  - **Bindings:** `{expr}` text, `name={expr}` attr, `onclick={fn}`, `bind:value={…}` / `bind:checked` / `bind:group`, `attach={fn}` (node-lifetime attachment — the dual of `on`; the `use:`-action / `{@attach}` equivalent, lowered to `ui/dom/attach`).
183
183
  - **Control flow (native `<template>`):** `if`/`else`, `each={list} as="x" key="x.id"`, `await={p}`/`then`/`catch`, `switch`/`case`/`default`.
184
+ - **A branch is a lexical scope:** any control-flow branch may host its own nested `<script>` and `<style>`. The nested `<script>` declares branch-local **plain** signals (`state`/`derived`/`linked`/`prop`) — owned by the branch's render scope, re-seeded each mount (not the serializable top-level `doc`) — with the branch's binding in scope (`then`/`catch`'s `as` value, the `each` `as`/`key` row), so it can derive from the awaited/iterated value; its bindings cover the branch subtree and later siblings auto-deref them. The nested `<style>` is scoped to that branch alone (its own `data-a-<hash>`), not the whole component.
184
185
  - **Components:** capitalised tags (`<Layout title=…>`); children fill `<slot/>`; props are reactive (passed as thunks). A component has no directives — every attribute is a prop under its written name (so `onclick=`/`bind:open=`/`attach=` pass through as props, e.g. callbacks, not the DOM-element directives those are on a lowercase tag) and is type-checked against the child's declared props. `prop('name')` reads a typed component prop (the parent-supplied thunk, reactive + read-only); route params come from the `page` proxy (`page.params.name`), not `prop()`.
185
186
  - **Snippets / named slots:** `<template name="x" args={…}>` declares a reusable named builder (the `snippet()` form), rendered like a function — covers named slots / `{@render}`.
186
187
  - **Reactivity:** write plain assignment (`count += 1`, `items.push(x)`); the compiler lowers it to patches. Deep-field edits wake only that field.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,35 @@
1
1
  # abide
2
2
 
3
+ ## 0.32.2
4
+
5
+ ### Patch Changes
6
+
7
+ - [`786f07a`](https://github.com/briancray/abide/commit/786f07a3c23986e988b2f0c0b3326e050129c959) - sync examples and ui README to current API ([`923bb86`](https://github.com/briancray/abide/commit/923bb8674814e8efcacc954beac0f1c7982b0e2c))
8
+
9
+ - [`786f07a`](https://github.com/briancray/abide/commit/786f07a3c23986e988b2f0c0b3326e050129c959) - reject a static import in a nested <template> script ([`b9e7c76`](https://github.com/briancray/abide/commit/b9e7c76509d2401484d3f997b0cb904af4b5080c))
10
+
11
+ - [`786f07a`](https://github.com/briancray/abide/commit/786f07a3c23986e988b2f0c0b3326e050129c959) - merge valueAtPath + pathExists into a single-pass walkPath ([`bad087b`](https://github.com/briancray/abide/commit/bad087b39c4b5427bf44fec6c3f2eb1fdbccf9c2))
12
+
13
+ - [`786f07a`](https://github.com/briancray/abide/commit/786f07a3c23986e988b2f0c0b3326e050129c959) - remap any HTML-element-named component wrapper, not just void ([`bbbbe35`](https://github.com/briancray/abide/commit/bbbbe353e22b5d016cd47e81eb8bc9a786fbe336))
14
+
15
+ - [`786f07a`](https://github.com/briancray/abide/commit/786f07a3c23986e988b2f0c0b3326e050129c959) - extract the static-clone template cache, keyed per document ([`d35beaa`](https://github.com/briancray/abide/commit/d35beaa956a449b79a5be63034473262226ad44d))
16
+
17
+ ## 0.32.1
18
+
19
+ ### Patch Changes
20
+
21
+ - [`660ce08`](https://github.com/briancray/abide/commit/660ce08109160156dc9697fcb223fbb335229243) - fold asset-serve request logging into a timedServe helper ([`17aa14a`](https://github.com/briancray/abide/commit/17aa14aa3f04abe76a2e06b0c2fb0a8d4e725603))
22
+
23
+ - [`660ce08`](https://github.com/briancray/abide/commit/660ce08109160156dc9697fcb223fbb335229243) - swallow the superseded each-async iterator's return rejection ([`5e2898f`](https://github.com/briancray/abide/commit/5e2898ff8a785508aa8fde70f3fc351dcdc8e014))
24
+
25
+ - [`9643066`](https://github.com/briancray/abide/commit/9643066cf19bb944a55509333fd1421a3a8cce7a) - fix(check): narrow `<template else>` against the condition's negation. The shadow type-checker emitted a nested `else` child inside the `if` block, so the else body inherited the if's positive narrowing — a literal-union compare read as "no overlap" and a `typeof`-narrowed branch saw the wrong member. It now pairs the `else` child as a real `if (…) {…} else {…}`, matching the runtime's pairing.
26
+
27
+ - [`9643066`](https://github.com/briancray/abide/commit/9643066cf19bb944a55509333fd1421a3a8cce7a) - fix(check): value-project a nested control-flow `<script>` like the leading one. The shadow type-checker emitted a branch-scoped `<script>` body raw, so its `state`/`derived` declarations kept their `State<T>`/`Derived<T>` types — every read of a nested signal in the branch's markup false-positived (`'Derived<string>' and 'string' have no overlap`, `Property 'length' does not exist on type 'Derived<…>'`). It now rewrites a nested script's reactive declarations to their value types, matching the runtime, which derefs nested-script signals through the rest of the branch.
28
+
29
+ - [`0c54e5a`](https://github.com/briancray/abide/commit/0c54e5a025eef96ebc7eece3f51b8a167241434d) - fix(ui): don't emit a void element as a component's mount wrapper. A component instance mounts into a wrapper tag derived from its name (`<Search>`→`<search>`); when the name lowercases to a void element (`<Input>`→`<input>`, `<Img>`→`<img>`) the wrapper self-closes and the HTML parser reparents the component's own markup as the wrapper's siblings, so on hydration `openChild` finds the wrapper empty, claims `null`, and `attr` throws `null is not an object (setAttribute)` — aborting hydration. Such names now map to a hyphenated custom-element tag (`abide-input`, never void) made layout-transparent with `display:contents`, so the child's real root still lays out as a direct child of the parent. Both the SSR string and the client build go through the shared `componentWrapperTag`, keeping them in agreement.
30
+
31
+ - [`9695bea`](https://github.com/briancray/abide/commit/9695bea26309b693d6b61a4dec1641559421c714) - perf(ui): carry the streamed await-block resume value in a `<script type="application/json">` child instead of a `data-resume` attribute. The attribute form HTML-escaped the JSON (`"` → `&quot;`, plus `&`/`<` passes), inflating the raw payload ~38% and costing two full-string regex passes per render (~2.6 ms/MB; ~10 ms for a 3.7 MB payload). Script content is raw text, so only `<` is neutralized as the JSON escape — ~130–150× cheaper to encode and no quote inflation. `applyResolved` and the inline `SSR_SWAP_SCRIPT` now read the value via `.textContent` and drop the script before swapping the resolved markup into its boundary.
32
+
3
33
  ## 0.32.0
4
34
 
5
35
  ### Minor Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@abide/abide",
3
- "version": "0.32.0",
3
+ "version": "0.32.2",
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,6 +1,7 @@
1
1
  /*
2
2
  The tiny inline script the abide-ui SSR stream ships in <head>. For each streamed
3
- `<abide-resolve data-id data-resume>` frame it registers the resolved value into
3
+ `<abide-resolve data-id>` frame it reads the leading `<script type=application/json>`,
4
+ registers the resolved value into
4
5
  `window.__abideResume` (the resume manifest hydration reads) and swaps the resolved
5
6
  markup into the matching `<!--abide:await:ID-->…<!--/abide:await:ID-->` boundary —
6
7
  so the pending shell paints instantly and each value lands as it arrives, before
@@ -9,8 +10,8 @@ minified to one line so it inlines cheaply ahead of the document body.
9
10
  */
10
11
  export const SSR_SWAP_SCRIPT =
11
12
  "function __abideSwap(){var f=document.querySelector('abide-resolve');while(f){" +
12
- "var id=f.getAttribute('data-id'),w=document.createTreeWalker(document.body,NodeFilter.SHOW_COMMENT),o=null,c;" +
13
- "try{(window.__abideResume=window.__abideResume||{})[id]=JSON.parse(f.getAttribute('data-resume')||'null');}catch(e){}" +
13
+ "var id=f.getAttribute('data-id'),p=f.firstChild,w=document.createTreeWalker(document.body,NodeFilter.SHOW_COMMENT),o=null,c;" +
14
+ "if(p&&p.nodeName==='SCRIPT'){try{(window.__abideResume=window.__abideResume||{})[id]=JSON.parse(p.textContent||'null');}catch(e){}p.remove();}" +
14
15
  "while((c=w.nextNode())){if(c.data==='abide:await:'+id){o=c;break;}}" +
15
16
  "if(o){var n=o.nextSibling;while(n&&!(n.nodeType===8&&n.data==='/abide:await:'+id)){var x=n.nextSibling;n.remove();n=x;}" +
16
17
  "while(f.firstChild){o.parentNode.insertBefore(f.firstChild,n);}}f.remove();f=document.querySelector('abide-resolve');}}"
@@ -238,6 +238,32 @@ export async function createServer({
238
238
  /* Request closing records are on by default — DEBUG=-abide is the off switch (negation, like the abide channel itself). */
239
239
  const logRequests = !isDebugNegated('abide')
240
240
 
241
+ /*
242
+ Time an asset serve and emit its closing record when logging is on. A miss
243
+ (undefined, from the public server) passes through unlogged so the request
244
+ can fall through to the 404 path.
245
+ */
246
+ const timedServe = async <T extends Response | undefined>(
247
+ serve: () => Promise<T>,
248
+ req: Request,
249
+ url: URL,
250
+ ): Promise<T> => {
251
+ if (!logRequests) {
252
+ return serve()
253
+ }
254
+ const start = Bun.nanoseconds()
255
+ const response = await serve()
256
+ if (response) {
257
+ logClosingRecord(
258
+ req.method,
259
+ `${url.pathname}${url.search}`,
260
+ response.status,
261
+ (Bun.nanoseconds() - start) / 1e6,
262
+ )
263
+ }
264
+ return response
265
+ }
266
+
241
267
  // App-configured headers extend the in-process forward allowlist for the process lifetime.
242
268
  extraForwardHeaders.set(app?.forwardHeaders ?? [])
243
269
 
@@ -546,19 +572,7 @@ export async function createServer({
546
572
  catches anything that goes wrong inside serveAppAsset.
547
573
  */
548
574
  if (url.pathname.startsWith('/_app/')) {
549
- if (!logRequests) {
550
- return serveAppAsset(req, url)
551
- }
552
- const start = Bun.nanoseconds()
553
- const response = await serveAppAsset(req, url)
554
- const ms = (Bun.nanoseconds() - start) / 1e6
555
- logClosingRecord(
556
- req.method,
557
- `${url.pathname}${url.search}`,
558
- response.status,
559
- ms,
560
- )
561
- return response
575
+ return timedServe(() => serveAppAsset(req, url), req, url)
562
576
  }
563
577
  /*
564
578
  Files under public/ are served at the site root, sidestepping
@@ -566,17 +580,8 @@ export async function createServer({
566
580
  undefined so the request falls through to the 404 / middleware
567
581
  path below.
568
582
  */
569
- const publicStart = Bun.nanoseconds()
570
- const publicResponse = await servePublicAsset(req, url)
583
+ const publicResponse = await timedServe(() => servePublicAsset(req, url), req, url)
571
584
  if (publicResponse) {
572
- if (logRequests) {
573
- logClosingRecord(
574
- req.method,
575
- `${url.pathname}${url.search}`,
576
- publicResponse.status,
577
- (Bun.nanoseconds() - publicStart) / 1e6,
578
- )
579
- }
580
585
  return publicResponse
581
586
  }
582
587
  /*
@@ -27,7 +27,7 @@ Response out.
27
27
 
28
28
  A page with no `await` block renders synchronously and ships buffered. A page with
29
29
  await blocks STREAMS: the pending shell flushes first, then each resolved fragment
30
- (`<abide-resolve data-resume>`) as its promise settles, swapped into its boundary
30
+ (`<abide-resolve>` carrying a JSON `<script>`) as its promise settles, swapped into its boundary
31
31
  by the inline SSR_SWAP_SCRIPT — which also registers the value into the resume
32
32
  manifest so client hydration adopts it without re-fetching (see abide/ui/awaitBlock).
33
33
 
@@ -46,8 +46,8 @@ every page, and `.abide` files are the only component format.
46
46
  `<template await>`/`then`/`catch`, `<template switch>`/`case`/`default`.
47
47
  - **Components** are capitalised tags (`<Layout title="…">`); children fill the
48
48
  child's `<slot>`. Props are reactive (passed as thunks).
49
- - **Scoped styles**: a `<style>` block is scoped per component via a
50
- `[data-b-<hash>]` attribute.
49
+ - **Scoped styles**: a `<style>` block is scoped via a `[data-a-<hash>]`
50
+ attribute — per component, or per control-flow branch when nested in one.
51
51
 
52
52
  ## Substrate (why it's fast)
53
53
 
@@ -0,0 +1,132 @@
1
+ /*
2
+ Every standard HTML element name (lowercase), plus the two foreign-content roots
3
+ `svg`/`math`. A component wrapper tag that collides with one of these is a real
4
+ element with a content model and parser quirks — `<button>`/`<a>` reject interactive
5
+ descendants, table/list/select families foster or auto-close non-conforming children,
6
+ void elements self-close, and `<svg>`/`<math>` switch the parser into SVG/MathML
7
+ namespace so the wrapper's HTML children break out (foster-parent) of it entirely —
8
+ so the parser reparents the component's own markup out of the wrapper and hydration
9
+ claims `null`. `componentWrapperTag` remaps any such name to a transparent custom
10
+ element instead. Pure SVG/MathML descendant names (`circle`, `path`, `mrow`, …) are
11
+ NOT here: in HTML context they are inert unknown elements that hold children fine, so
12
+ a `<Circle>` component stays as-is like any non-element name. Superset of VOID_TAGS
13
+ (which the parser/SSR still use for self-closing); this set is only the
14
+ wrapper-safety check.
15
+ */
16
+ export const HTML_TAGS: ReadonlySet<string> = new Set([
17
+ 'a',
18
+ 'abbr',
19
+ 'address',
20
+ 'area',
21
+ 'article',
22
+ 'aside',
23
+ 'audio',
24
+ 'b',
25
+ 'base',
26
+ 'bdi',
27
+ 'bdo',
28
+ 'blockquote',
29
+ 'body',
30
+ 'br',
31
+ 'button',
32
+ 'canvas',
33
+ 'caption',
34
+ 'cite',
35
+ 'code',
36
+ 'col',
37
+ 'colgroup',
38
+ 'data',
39
+ 'datalist',
40
+ 'dd',
41
+ 'del',
42
+ 'details',
43
+ 'dfn',
44
+ 'dialog',
45
+ 'div',
46
+ 'dl',
47
+ 'dt',
48
+ 'em',
49
+ 'embed',
50
+ 'fieldset',
51
+ 'figcaption',
52
+ 'figure',
53
+ 'footer',
54
+ 'form',
55
+ 'h1',
56
+ 'h2',
57
+ 'h3',
58
+ 'h4',
59
+ 'h5',
60
+ 'h6',
61
+ 'head',
62
+ 'header',
63
+ 'hgroup',
64
+ 'hr',
65
+ 'html',
66
+ 'i',
67
+ 'iframe',
68
+ 'img',
69
+ 'input',
70
+ 'ins',
71
+ 'kbd',
72
+ 'label',
73
+ 'legend',
74
+ 'li',
75
+ 'link',
76
+ 'main',
77
+ 'map',
78
+ 'mark',
79
+ 'math',
80
+ 'menu',
81
+ 'meta',
82
+ 'meter',
83
+ 'nav',
84
+ 'noscript',
85
+ 'object',
86
+ 'ol',
87
+ 'optgroup',
88
+ 'option',
89
+ 'output',
90
+ 'p',
91
+ 'param',
92
+ 'picture',
93
+ 'pre',
94
+ 'progress',
95
+ 'q',
96
+ 'rp',
97
+ 'rt',
98
+ 'ruby',
99
+ 's',
100
+ 'samp',
101
+ 'script',
102
+ 'search',
103
+ 'section',
104
+ 'select',
105
+ 'slot',
106
+ 'small',
107
+ 'source',
108
+ 'span',
109
+ 'strong',
110
+ 'style',
111
+ 'sub',
112
+ 'summary',
113
+ 'sup',
114
+ 'svg',
115
+ 'table',
116
+ 'tbody',
117
+ 'td',
118
+ 'template',
119
+ 'textarea',
120
+ 'tfoot',
121
+ 'th',
122
+ 'thead',
123
+ 'time',
124
+ 'title',
125
+ 'tr',
126
+ 'track',
127
+ 'u',
128
+ 'ul',
129
+ 'var',
130
+ 'video',
131
+ 'wbr',
132
+ ])
@@ -184,6 +184,27 @@ function analyzeScript(scriptBody: string, scriptStart: number): ScriptAnalysis
184
184
  return { imports, types, scope, props }
185
185
  }
186
186
 
187
+ /* Value-projects a nested control-flow `<script>` body the way `analyzeScript`
188
+ projects the leading script's scope: reactive declarations become their value
189
+ type, every other statement stays verbatim. Returns the projected source text
190
+ (unmapped — nested scripts carry no source offset yet), so a branch's markup
191
+ reads a nested signal as its value type instead of the raw `State`/`Derived`. */
192
+ function projectNestedScript(code: string): string {
193
+ const file = ts.createSourceFile('nested.ts', code, ts.ScriptTarget.Latest, true)
194
+ const verbatim = (node: ts.Node): string => code.slice(node.getStart(file), node.getEnd())
195
+ /* No mapping: a zero-length segment the caller drops. */
196
+ const span = (): ShadowMapping => ({ shadowStart: 0, sourceStart: 0, length: 0 })
197
+ return file.statements
198
+ .flatMap((statement) => {
199
+ const reactive = reactiveDeclarations(statement)
200
+ if (reactive === undefined) {
201
+ return [verbatim(statement)]
202
+ }
203
+ return reactive.map((declaration) => scopeLineFor(declaration, [], verbatim, span).text)
204
+ })
205
+ .join('\n')
206
+ }
207
+
187
208
  /* The `state`/`derived`/`prop` declarations in a variable statement, or undefined
188
209
  if it isn't one declaring them (so the caller emits it verbatim). A statement
189
210
  mixing reactive and plain declarations is rare; treated as all-verbatim. */
@@ -264,51 +285,13 @@ function scopeLineFor(
264
285
  return { text: `let ${name} = props[${JSON.stringify(keyText)}];`, segments: [] }
265
286
  }
266
287
 
267
- /* Emits a sibling list. Walks with lookahead so an `if` and its trailing `else`
268
- (the next meaningful sibling — a `case` with no match) fuse into one
269
- `if (…) {…} else {…}`, giving the else branch the condition's negative narrowing
270
- instead of being checked bare against the un-narrowed type. Every other node is
271
- emitted standalone via `emitNode`. */
288
+ /* Emits a sibling list each node standalone via `emitNode`. */
272
289
  function emitNodes(nodes: TemplateNode[], builder: Builder): void {
273
- for (let index = 0; index < nodes.length; index += 1) {
274
- const node = nodes[index]
275
- if (node === undefined) {
276
- continue
277
- }
278
- if (node.kind !== 'if') {
290
+ for (const node of nodes) {
291
+ if (node !== undefined) {
279
292
  emitNode(node, builder)
280
- continue
281
- }
282
- builder.raw('if ')
283
- builder.expr(node.condition, node.loc)
284
- builder.raw(' {\n')
285
- emitNodes(node.children, builder)
286
- builder.raw('}')
287
- const elseIndex = nextMeaningful(nodes, index + 1)
288
- const elseNode = elseIndex === -1 ? undefined : nodes[elseIndex]
289
- if (elseNode?.kind === 'case' && elseNode.match === undefined) {
290
- builder.raw(' else {\n')
291
- emitNodes(elseNode.children, builder)
292
- builder.raw('}')
293
- index = elseIndex
294
- }
295
- builder.raw('\n')
296
- }
297
- }
298
-
299
- /* Index of the next node that isn't whitespace-only text (which separates the `if`
300
- and `else` tags in source but carries no checkable content); -1 if none remain. */
301
- function nextMeaningful(nodes: TemplateNode[], from: number): number {
302
- for (let index = from; index < nodes.length; index += 1) {
303
- const node = nodes[index]
304
- const blank =
305
- node?.kind === 'text' &&
306
- node.parts.every((part) => part.kind === 'static' && part.value.trim() === '')
307
- if (!blank) {
308
- return index
309
293
  }
310
294
  }
311
- return -1
312
295
  }
313
296
 
314
297
  /* Emits a template node's expressions into the shadow's render body. Control flow
@@ -353,15 +336,31 @@ function emitNode(node: TemplateNode, builder: Builder): void {
353
336
  emitNodes(node.children, builder)
354
337
  return
355
338
  }
356
- case 'if':
357
- /* Reached only for an `if` emitted outside a sibling list (none today);
358
- `emitNodes` owns the `if`/`else` fusion. Emit without an else. */
339
+ case 'if': {
340
+ /* The optional `<template else>` is a match-less `case` CHILD (the runtime
341
+ pairs it the same way — see `generateIf`); the rest are the then-content.
342
+ Emitting it as a real `else` gives its body the condition's NEGATIVE
343
+ narrowing — emitting it inside the `if` block (as a plain child) instead
344
+ gave it the positive narrowing, so a literal-union compare read as a
345
+ "no overlap" and a typeof-narrowed branch saw the wrong member. */
346
+ const elseChild = node.children.find(
347
+ (child): child is Extract<TemplateNode, { kind: 'case' }> =>
348
+ child.kind === 'case' && child.match === undefined,
349
+ )
350
+ const thenChildren = node.children.filter((child) => child !== elseChild)
359
351
  builder.raw('if ')
360
352
  builder.expr(node.condition, node.loc)
361
353
  builder.raw(' {\n')
362
- emitNodes(node.children, builder)
363
- builder.raw('}\n')
354
+ emitNodes(thenChildren, builder)
355
+ builder.raw('}')
356
+ if (elseChild !== undefined) {
357
+ builder.raw(' else {\n')
358
+ emitNodes(elseChild.children, builder)
359
+ builder.raw('}')
360
+ }
361
+ builder.raw('\n')
364
362
  return
363
+ }
365
364
  case 'each':
366
365
  /* `for await` over an async each's AsyncIterable, plain `for…of` otherwise —
367
366
  so the item binds to the element type under either iteration protocol. */
@@ -436,8 +435,8 @@ function emitNode(node: TemplateNode, builder: Builder): void {
436
435
  builder.raw('}\n')
437
436
  return
438
437
  case 'case':
439
- /* Reached only for a stray case outside a switch/if-else (none today); a
440
- `switch` emits its own cases and `emitNodes` consumes an `else`. */
438
+ /* Reached only for a stray case outside a switch/if (none today); a `switch`
439
+ emits its own cases and the `if` handler consumes its `else` child. */
441
440
  if (node.match !== undefined) {
442
441
  builder.stmt(node.match, node.loc)
443
442
  }
@@ -464,14 +463,16 @@ function emitNode(node: TemplateNode, builder: Builder): void {
464
463
  builder.raw('};\n')
465
464
  return
466
465
  case 'script':
467
- /* A scoped reactive `<script>`: emit its body INLINE in the current block,
468
- not a nested `{}` so its bindings are visible to the branch's later
469
- siblings (a nested if/each within the same branch), matching runtime
470
- scope where a nested script's declarations deref through the rest of the
471
- branch. A wrapping block trapped them, surfacing "Cannot find name" on a
472
- sibling. Leading `;` guards a preceding semicolon-less call from merging
473
- in (see the component case). Not yet position-mapped (rare). */
474
- builder.raw(`;\n${node.code}\n`)
466
+ /* A scoped reactive `<script>`: value-project its reactive declarations to
467
+ their value types (`derived()` the computed value, `state(…)` the
468
+ initial) exactly as the leading script's scope lines are, so the branch's
469
+ markup type-checks a nested signal as its value matching the runtime,
470
+ which derefs nested-script signals through the rest of the branch. Emitted
471
+ INLINE in the current block, not a nested `{…}`, so its bindings reach the
472
+ branch's later siblings (a nested if/each); a wrapping block trapped them,
473
+ surfacing "Cannot find name". Leading `;` guards a preceding semicolon-less
474
+ call from merging in (see the component case). Not yet position-mapped. */
475
+ builder.raw(`;\n${projectNestedScript(node.code)}\n`)
475
476
  return
476
477
  case 'style':
477
478
  /* CSS, not TypeScript — nothing for the shadow to type-check. */
@@ -0,0 +1,23 @@
1
+ import { HTML_TAGS } from './HTML_TAGS.ts'
2
+
3
+ /*
4
+ The element tag a component instance mounts into. Normally the component name
5
+ lowercased — readable in devtools, a real box like any abide wrapper. But a name
6
+ that lowercases to a real HTML element (`Button`→`button`, `Input`→`input`) yields a
7
+ wrapper with a content model the parser enforces: void elements self-close, and
8
+ `<button>`/`<a>`/table/list/select families reject or foster the component's own
9
+ markup as the wrapper's siblings — so on hydration `openChild` finds the wrapper
10
+ empty, claims `null`, and `attr` throws on it. Those names map to a hyphenated
11
+ custom-element tag (a custom element is never void and has no content model) made
12
+ layout-transparent with `display:contents`, so the component's real root still lays
13
+ out as a direct child of the parent the way the (parse-broken) wrapper would have.
14
+ A name that is NOT a known HTML element (the common case — `Card`, `Dropdown`) is an
15
+ inert unknown tag that holds any content untouched, so it stays as-is. Both back-ends
16
+ call this so the SSR string and the client build agree on the wrapper.
17
+ */
18
+ export function componentWrapperTag(name: string): { tag: string; transparent: boolean } {
19
+ const lower = name.toLowerCase()
20
+ return HTML_TAGS.has(lower)
21
+ ? { tag: `abide-${lower}`, transparent: true }
22
+ : { tag: lower, transparent: false }
23
+ }
@@ -1,5 +1,6 @@
1
1
  import { OUTLET_TAG } from '../runtime/OUTLET_TAG.ts'
2
2
  import { bindListenEvent } from './bindListenEvent.ts'
3
+ import { componentWrapperTag } from './componentWrapperTag.ts'
3
4
  import { groupBindParts } from './groupBindParts.ts'
4
5
  import { lowerContext } from './lowerContext.ts'
5
6
  import { partitionSlots } from './partitionSlots.ts'
@@ -283,10 +284,14 @@ export function generateBuild(
283
284
  node: Extract<TemplateNode, { kind: 'component' }>,
284
285
  parentVar: string,
285
286
  ): string {
286
- return mountComponent(
287
+ const { tag, transparent } = componentWrapperTag(node.name)
288
+ const { code, varName } = mountComponent(
287
289
  node,
288
- `openChild(${parentVar}, ${JSON.stringify(node.name.toLowerCase())})`,
289
- ).code
290
+ `openChild(${parentVar}, ${JSON.stringify(tag)})`,
291
+ )
292
+ /* A void-name remap is layout-transparent so the child's root stays a direct
293
+ child of the parent (idempotent on a claimed SSR node that already has it). */
294
+ return transparent ? `${code}${varName}.setAttribute("style", "display:contents");\n` : code
290
295
  }
291
296
 
292
297
  /* An await block: pending → resolved(value) / error branches. Each branch is a
@@ -1,4 +1,5 @@
1
1
  import { OUTLET_TAG } from '../runtime/OUTLET_TAG.ts'
2
+ import { componentWrapperTag } from './componentWrapperTag.ts'
2
3
  import { groupBindParts } from './groupBindParts.ts'
3
4
  import { lowerContext } from './lowerContext.ts'
4
5
  import { partitionSlots } from './partitionSlots.ts'
@@ -154,7 +155,7 @@ export function generateSSR(
154
155
  the same wrapper the client mounts into, so SSR and client agree.
155
156
  Props pass as thunks; slot content passes as a string-returning
156
157
  `$children` the child invokes from its <slot>. */
157
- const tag = node.name.toLowerCase()
158
+ const { tag, transparent } = componentWrapperTag(node.name)
158
159
  const parts = node.props.map(
159
160
  (prop) => `${JSON.stringify(prop.name)}: () => (${lowerExpression(prop.code)})`,
160
161
  )
@@ -181,7 +182,7 @@ export function generateSSR(
181
182
  the enclosing render body, including from branch closures.) */
182
183
  const result = nextVar('$child')
183
184
  return (
184
- push(target, `<${tag}>`) +
185
+ push(target, `<${tag}${transparent ? ' style="display:contents"' : ''}>`) +
185
186
  `const ${result} = ${node.name}.render({ ${parts.join(', ')} });\n` +
186
187
  `${target}.push(${result}.html);\n` +
187
188
  `for (const $a of ${result}.awaits) { $awaits.push($a); }\n` +
@@ -20,6 +20,12 @@ text, never mistaken for a real style. Keeping it in the tree lets the front-end
20
20
  scope it to its sibling subtree (`analyzeComponent`); the node emits no DOM/markup.
21
21
  */
22
22
 
23
+ /* A line-leading static `import` in a nested script body. The `(?=\s)` requires
24
+ whitespace after the keyword (sparing `import.meta` and no-space `import(...)`),
25
+ and `(?!\s*\()` spares a dynamic `import (...)` written with whitespace before the
26
+ paren — both legitimate lazy paths — so only a true static import statement matches. */
27
+ const NESTED_STATIC_IMPORT = /^[ \t]*import(?=\s)(?!\s*\()/m
28
+
23
29
  /* A braced template expression with the absolute source offset of its first
24
30
  (post-trim) character, so the type-checking shadow can map a diagnostic back. */
25
31
  type Braced = { code: string; loc: number }
@@ -179,6 +185,18 @@ export function parseTemplate(source: string, baseOffset = 0): { nodes: Template
179
185
  const end = close === -1 ? source.length : close
180
186
  const code = source.slice(cursor, end)
181
187
  cursor = close === -1 ? source.length : end + '</script>'.length
188
+ /* A static `import` can't live here: a nested script compiles INTO the
189
+ branch's render-function body, where an import is illegal — and an
190
+ import nested in a branch falsely implies conditional/lazy loading ES
191
+ imports can't do (they hoist module-wide and load unconditionally). The
192
+ leading `<script>` hoists imports to module scope for the whole template,
193
+ so they belong there. The pattern spares dynamic `import(...)` (with or
194
+ without whitespace) and `import.meta` — the real lazy paths. */
195
+ if (NESTED_STATIC_IMPORT.test(code)) {
196
+ throw new Error(
197
+ "import statements must live in the component's leading <script>, not a nested <template> script — they hoist to module scope for the whole template. For lazy loading, use a dynamic import() inside an effect.",
198
+ )
199
+ }
182
200
  return { kind: 'script', code }
183
201
  }
184
202
  /* A capitalised tag is a child component; its attributes become props and
@@ -2,8 +2,8 @@ import { RESUME } from '../runtime/RESUME.ts'
2
2
 
3
3
  /*
4
4
  Client consumer of an SSR stream fragment. Parses a streamed
5
- `<abide-resolve data-id="ID" data-resume="">…</abide-resolve>` frame, registers
6
- its serialized value in the resume manifest (for later hydration), finds the
5
+ `<abide-resolve data-id="ID"><script type="application/json">…</script>…</abide-resolve>`
6
+ frame, registers its serialized value in the resume manifest (for later hydration), finds the
7
7
  matching `<!--abide:await:ID-->…<!--/abide:await:ID-->` boundary in `root`, removes
8
8
  the pending nodes between the markers, and inserts the resolved content in their
9
9
  place. The pending shell painted instantly; this swaps in each value as it
@@ -21,14 +21,17 @@ export function applyResolved(root: Element, frame: string): void {
21
21
  if (id === null) {
22
22
  return
23
23
  }
24
- /* Record the resolved value so a later hydrate adopts this branch (no re-fetch). */
25
- const resume = resolved.getAttribute('data-resume')
26
- if (resume !== null) {
24
+ /* The resolved value rides in a leading <script type=application/json>; parse and
25
+ remove it so only the resolved markup moves into the boundary. Recording it lets a
26
+ later hydrate adopt this branch (no re-fetch). */
27
+ const payload = resolved.firstChild as Element | null
28
+ if (payload !== null && payload.nodeName === 'SCRIPT') {
27
29
  try {
28
- RESUME[Number(id)] = JSON.parse(resume)
30
+ RESUME[Number(id)] = JSON.parse(payload.textContent ?? 'null')
29
31
  } catch {
30
32
  /* malformed payload — leave unregistered, hydration re-runs the promise */
31
33
  }
34
+ payload.remove()
32
35
  }
33
36
  const open = `abide:await:${id}`
34
37
  const close = `/abide:await:${id}`
@@ -1,22 +1,6 @@
1
1
  import { claimChild } from '../runtime/claimChild.ts'
2
2
  import { RENDER } from '../runtime/RENDER.ts'
3
-
4
- /*
5
- Parsed-once `<template>` per unique static-skeleton string, reused across every
6
- mount. A `<template>` (not a detached `<div>`) so table/select content parses by
7
- the real content model, exactly as the browser parsed the server markup.
8
- */
9
- const TEMPLATES = new Map<string, HTMLTemplateElement>()
10
-
11
- function templateFor(html: string): HTMLTemplateElement {
12
- let template = TEMPLATES.get(html)
13
- if (template === undefined) {
14
- template = document.createElement('template')
15
- template.innerHTML = html
16
- TEMPLATES.set(html, template)
17
- }
18
- return template
19
- }
3
+ import { templateFor } from './templateFor.ts'
20
4
 
21
5
  /*
22
6
  Appends a fully-static subtree (one or more top-level nodes) under `parent` — what
@@ -69,7 +69,7 @@ export function eachAsync<T>(
69
69
  effect(() => {
70
70
  generation += 1
71
71
  const generationAtStart = generation
72
- iterator?.return?.() // close the superseded run's iterator before re-streaming
72
+ iterator?.return?.(undefined)?.catch(() => undefined) // close the superseded run's iterator before re-streaming
73
73
  iterator = undefined
74
74
  clearError() // a fresh run drops a prior error branch
75
75
  const iterable = items() // read (subscribe) synchronously
@@ -128,7 +128,7 @@ export function eachAsync<T>(
128
128
  if (OWNER.current !== undefined) {
129
129
  OWNER.current.push(() => {
130
130
  generation += 1
131
- iterator?.return?.()
131
+ iterator?.return?.(undefined)?.catch(() => undefined)
132
132
  iterator = undefined
133
133
  clearError()
134
134
  for (const row of rows.values()) {
@@ -0,0 +1,28 @@
1
+ /*
2
+ Parsed-once `<template>` per unique static-skeleton string, reused across every
3
+ mount. A `<template>` (not a detached `<div>`) so table/select content parses by
4
+ the real content model, exactly as the browser parsed the server markup. Backs
5
+ `cloneStatic`'s skeleton-string→template cache.
6
+
7
+ The cache is keyed by the owning `document`: a template belongs to the document
8
+ that created it, and its clones must land in that same document. In production there
9
+ is one document, so this is one inner map; under the test harness, which installs a
10
+ fresh `document` per file, it keeps each file's templates (and their node class)
11
+ from leaking into the next.
12
+ */
13
+ const CACHES = new WeakMap<object, Map<string, HTMLTemplateElement>>()
14
+
15
+ export function templateFor(html: string): HTMLTemplateElement {
16
+ let cache = CACHES.get(document)
17
+ if (cache === undefined) {
18
+ cache = new Map()
19
+ CACHES.set(document, cache)
20
+ }
21
+ let template = cache.get(html)
22
+ if (template === undefined) {
23
+ template = document.createElement('template')
24
+ template.innerHTML = html
25
+ cache.set(html, template)
26
+ }
27
+ return template
28
+ }
@@ -5,10 +5,11 @@ import type { SsrAwait, SsrRender } from './runtime/types/SsrRender.ts'
5
5
  Out-of-order SSR streaming. Yields the pending shell first (so the browser paints
6
6
  immediately), then one resolved fragment per await block as its promise settles —
7
7
  in completion order, not source order, so a slow read never blocks a fast one.
8
- Each resolved fragment is a `<abide-resolve data-id="ID" data-resume="">…</abide-resolve>`
9
- that `applyResolved` swaps into the matching `<!--abide:await:ID-->` boundary; the
10
- `data-resume` payload is the JSON-serialized value, registered for hydration so an
11
- `await` block adopts the resolved branch on resume instead of re-running.
8
+ Each resolved fragment is a `<abide-resolve data-id="ID"><script type="application/json">
9
+ …</script>…</abide-resolve>` that `applyResolved` swaps into the matching
10
+ `<!--abide:await:ID-->` boundary; the leading script holds the JSON-serialized value,
11
+ registered for hydration so an `await` block adopts the resolved branch on resume
12
+ instead of re-running.
12
13
 
13
14
  This is the await-block-streams half of the cache rule: a top-level `await` in the
14
15
  script would have blocked the shell (inlined), but an await *block* flushes its
@@ -46,7 +47,9 @@ export async function* renderToStream(render: () => SsrRender): AsyncGenerator<s
46
47
  const resolved = await Promise.race(inflight.values())
47
48
  inflight.delete(resolved.id)
48
49
  const resume = encodeResume(resolved.resume)
49
- yield `<abide-resolve data-id="${resolved.id}" data-resume="${resume}">${resolved.html}</abide-resolve>`
50
+ yield `<abide-resolve data-id="${resolved.id}">` +
51
+ `<script type="application/json">${resume}</script>` +
52
+ `${resolved.html}</abide-resolve>`
50
53
  }
51
54
  }
52
55
 
@@ -94,11 +97,11 @@ function settle(block: SsrAwait): Promise<Settled> {
94
97
  )
95
98
  }
96
99
 
97
- /* JSON for an HTML double-quoted attribute: escape `"` and `&` (and `<` for safety
98
- inside markup). `applyResolved`/the inline swap script decode it via the DOM. */
100
+ /* JSON for a `<script type="application/json">` data block: script content is raw
101
+ text, so only `<` needs neutralizing (emitted as a unicode escape) to keep a
102
+ literal `</script>` from closing the block early — quotes stay raw. Far cheaper
103
+ than attribute escaping (no full-string `"`/`&` passes) and JSON.parse decodes it
104
+ back. `applyResolved`/the inline swap script read it via `.textContent`. */
99
105
  function encodeResume(resume: ResumeEntry): string {
100
- return JSON.stringify(resume)
101
- .replace(/&/g, '&amp;')
102
- .replace(/"/g, '&quot;')
103
- .replace(/</g, '&lt;')
106
+ return JSON.stringify(resume).replace(/</g, '\\u003c')
104
107
  }
@@ -1,7 +1,6 @@
1
1
  import { applyPatchToTree } from './applyPatchToTree.ts'
2
2
  import { createSignalNode } from './createSignalNode.ts'
3
3
  import { flushEffects } from './flushEffects.ts'
4
- import { pathExists } from './pathExists.ts'
5
4
  import { REACTIVE_CONTEXT } from './REACTIVE_CONTEXT.ts'
6
5
  import { readNode } from './readNode.ts'
7
6
  import { trigger } from './trigger.ts'
@@ -9,7 +8,7 @@ import type { Cell } from './types/Cell.ts'
9
8
  import type { Doc } from './types/Doc.ts'
10
9
  import type { Patch } from './types/Patch.ts'
11
10
  import type { ReactiveNode } from './types/ReactiveNode.ts'
12
- import { valueAtPath } from './valueAtPath.ts'
11
+ import { walkPath } from './walkPath.ts'
13
12
  import { writeNode } from './writeNode.ts'
14
13
 
15
14
  /*
@@ -39,7 +38,7 @@ export function createDoc(initial: unknown): Doc {
39
38
  function nodeFor(path: string): ReactiveNode {
40
39
  let node = nodes.get(path)
41
40
  if (node === undefined) {
42
- node = createSignalNode(valueAtPath(tree, path))
41
+ node = createSignalNode(walkPath(tree, path).value)
43
42
  nodes.set(path, node)
44
43
  }
45
44
  return node
@@ -60,7 +59,7 @@ export function createDoc(initial: unknown): Doc {
60
59
  paying a scan over every minted node.
61
60
  */
62
61
  function wakeSubtree(rootPath: string, force: boolean, descend: boolean): void {
63
- const rootValue = valueAtPath(tree, rootPath)
62
+ const rootValue = walkPath(tree, rootPath).value
64
63
  const rootNode = nodes.get(rootPath)
65
64
  if (rootNode !== undefined) {
66
65
  if (force) {
@@ -82,8 +81,9 @@ export function createDoc(initial: unknown): Doc {
82
81
  and this very descend scan degrades linearly with it. The woken
83
82
  reader re-mints a fresh node on its flush if the path ever returns.
84
83
  Deleting the current entry mid-iteration is safe on a Map. */
85
- if (pathExists(tree, candidate)) {
86
- writeNode(node, valueAtPath(tree, candidate))
84
+ const walk = walkPath(tree, candidate)
85
+ if (walk.exists) {
86
+ writeNode(node, walk.value)
87
87
  } else {
88
88
  writeNode(node, undefined)
89
89
  nodes.delete(candidate)
@@ -95,8 +95,11 @@ export function createDoc(initial: unknown): Doc {
95
95
  function apply(patch: Patch): void {
96
96
  const segments = patch.path === '' ? [] : patch.path.split('/')
97
97
  tree = applyPatchToTree(tree, patch, segments)
98
- const parentPath = segments.slice(0, -1).join('/')
99
- const parentValue = valueAtPath(tree, parentPath)
98
+ /* parentPath is patch.path minus its last segment — the same string
99
+ `segments.slice(0, -1).join('/')` rebuilds, taken by one slice instead. */
100
+ const lastSlash = patch.path.lastIndexOf('/')
101
+ const parentPath = lastSlash === -1 ? '' : patch.path.slice(0, lastSlash)
102
+ const parentValue = walkPath(tree, parentPath).value
100
103
  const leafKey = segments[segments.length - 1] as string | undefined
101
104
  /* A structural change (add/remove, or an array element replaced by index)
102
105
  reshapes the parent; a plain value replace reshapes only its own path. */
@@ -0,0 +1,10 @@
1
+ /*
2
+ The result of walking a `/`-joined path through a tree: whether the path still
3
+ resolves to an own slot at every segment, and the value it holds. `exists` is
4
+ false when a segment is missing (a deleted key, an out-of-range index); `value`
5
+ is then `undefined`. Separates a missing path from one holding a real `undefined`.
6
+ */
7
+ export type PathWalk = {
8
+ exists: boolean
9
+ value: unknown
10
+ }
@@ -0,0 +1,27 @@
1
+ import type { PathWalk } from './types/PathWalk.ts'
2
+
3
+ /*
4
+ Walks a `/`-joined path through a plain tree in one pass, returning both whether
5
+ the path still resolves (`exists`) and the value it holds (`value`). `''` is the
6
+ root. Arrays index by their numeric segment as a string — JS array access coerces
7
+ the key, and `in` covers an own index in range or `length`.
8
+
9
+ The two answers are inseparable on the eviction path (`createDoc`'s descend),
10
+ which must distinguish a path the tree no longer has (a deleted key, an
11
+ out-of-range index after a shrink) from one holding a genuine `undefined` — a
12
+ distinction the value alone can't make. Returning both walks the path once where
13
+ a separate value-read + existence-check would walk it twice.
14
+ */
15
+ export function walkPath(tree: unknown, path: string): PathWalk {
16
+ if (path === '') {
17
+ return { exists: tree !== undefined, value: tree }
18
+ }
19
+ let current: unknown = tree
20
+ for (const segment of path.split('/')) {
21
+ if (current === null || typeof current !== 'object' || !(segment in current)) {
22
+ return { exists: false, value: undefined }
23
+ }
24
+ current = (current as Record<string, unknown>)[segment]
25
+ }
26
+ return { exists: true, value: current }
27
+ }
@@ -1,23 +0,0 @@
1
- /*
2
- Whether a `/`-joined path still resolves through `tree` — every segment present
3
- in its container. The `in` operator covers both shapes: an object key, an array's
4
- own index (in range) or its `length`. Distinguishes a path the tree no longer has
5
- (a deleted key, an out-of-range index after a shrink) from one holding a genuine
6
- `undefined`, which `valueAtPath` alone can't — used to evict dead reactive nodes.
7
- */
8
- export function pathExists(tree: unknown, path: string): boolean {
9
- if (path === '') {
10
- return tree !== undefined
11
- }
12
- let current: unknown = tree
13
- for (const segment of path.split('/')) {
14
- if (current === null || typeof current !== 'object') {
15
- return false
16
- }
17
- if (!(segment in current)) {
18
- return false
19
- }
20
- current = (current as Record<string, unknown>)[segment]
21
- }
22
- return true
23
- }
@@ -1,18 +0,0 @@
1
- /*
2
- Reads the value at a `/`-joined path in a plain tree. `''` is the root. Returns
3
- undefined if any segment is missing — arrays index by their numeric segment as a
4
- string, which works because JS array access coerces the key.
5
- */
6
- export function valueAtPath(tree: unknown, path: string): unknown {
7
- if (path === '') {
8
- return tree
9
- }
10
- let current: unknown = tree
11
- for (const segment of path.split('/')) {
12
- if (current === null || typeof current !== 'object') {
13
- return undefined
14
- }
15
- current = (current as Record<string, unknown>)[segment]
16
- }
17
- return current
18
- }