@abide/abide 0.32.0 → 0.32.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.
- package/AGENTS.md +1 -0
- package/CHANGELOG.md +16 -0
- package/package.json +1 -1
- package/src/lib/server/runtime/SSR_SWAP_SCRIPT.ts +4 -3
- package/src/lib/server/runtime/createServer.ts +28 -23
- package/src/lib/server/runtime/createUiPageRenderer.ts +1 -1
- package/src/lib/ui/compile/compileShadow.ts +57 -56
- package/src/lib/ui/compile/componentWrapperTag.ts +20 -0
- package/src/lib/ui/compile/generateBuild.ts +8 -3
- package/src/lib/ui/compile/generateSSR.ts +3 -2
- package/src/lib/ui/dom/applyResolved.ts +9 -6
- package/src/lib/ui/dom/eachAsync.ts +2 -2
- package/src/lib/ui/renderToStream.ts +14 -11
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,21 @@
|
|
|
1
1
|
# abide
|
|
2
2
|
|
|
3
|
+
## 0.32.1
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- [`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))
|
|
8
|
+
|
|
9
|
+
- [`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))
|
|
10
|
+
|
|
11
|
+
- [`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.
|
|
12
|
+
|
|
13
|
+
- [`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.
|
|
14
|
+
|
|
15
|
+
- [`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.
|
|
16
|
+
|
|
17
|
+
- [`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 (`"` → `"`, 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.
|
|
18
|
+
|
|
3
19
|
## 0.32.0
|
|
4
20
|
|
|
5
21
|
### Minor Changes
|
package/package.json
CHANGED
|
@@ -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
|
|
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(
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
|
@@ -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
|
|
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 (
|
|
274
|
-
|
|
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
|
-
/*
|
|
358
|
-
|
|
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(
|
|
363
|
-
builder.raw('}
|
|
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
|
|
440
|
-
|
|
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>`:
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
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,20 @@
|
|
|
1
|
+
import { VOID_TAGS } from './VOID_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 VOID element (`Input`→`input`, `Img`→`img`) would yield a
|
|
7
|
+
wrapper the HTML parser self-closes, reparenting the component's own markup as the
|
|
8
|
+
wrapper's siblings — so on hydration `openChild` finds the wrapper empty, claims
|
|
9
|
+
`null`, and `attr` throws on it. Those names map to a hyphenated custom-element tag
|
|
10
|
+
(a custom element is never void) made layout-transparent with `display:contents`,
|
|
11
|
+
so the component's real root still lays out as a direct child of the parent the way
|
|
12
|
+
the (parse-broken) void wrapper effectively did. Both back-ends call this so the SSR
|
|
13
|
+
string and the client build agree on the wrapper.
|
|
14
|
+
*/
|
|
15
|
+
export function componentWrapperTag(name: string): { tag: string; transparent: boolean } {
|
|
16
|
+
const lower = name.toLowerCase()
|
|
17
|
+
return VOID_TAGS.has(lower)
|
|
18
|
+
? { tag: `abide-${lower}`, transparent: true }
|
|
19
|
+
: { tag: lower, transparent: false }
|
|
20
|
+
}
|
|
@@ -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
|
-
|
|
287
|
+
const { tag, transparent } = componentWrapperTag(node.name)
|
|
288
|
+
const { code, varName } = mountComponent(
|
|
287
289
|
node,
|
|
288
|
-
`openChild(${parentVar}, ${JSON.stringify(
|
|
289
|
-
)
|
|
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
|
|
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` +
|
|
@@ -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"
|
|
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
|
-
/*
|
|
25
|
-
|
|
26
|
-
|
|
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(
|
|
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}`
|
|
@@ -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()) {
|
|
@@ -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"
|
|
9
|
-
that `applyResolved` swaps into the matching
|
|
10
|
-
|
|
11
|
-
`await` block adopts the resolved branch on resume
|
|
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}"
|
|
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
|
|
98
|
-
|
|
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, '&')
|
|
102
|
-
.replace(/"/g, '"')
|
|
103
|
-
.replace(/</g, '<')
|
|
106
|
+
return JSON.stringify(resume).replace(/</g, '\\u003c')
|
|
104
107
|
}
|