@abide/abide 0.48.0 → 0.49.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +15 -7
- package/CHANGELOG.md +39 -0
- package/README.md +9 -3
- package/package.json +3 -1
- package/src/buildCli.ts +3 -5
- package/src/buildDisconnected.ts +2 -3
- package/src/bundleApp.ts +2 -3
- package/src/compile.ts +2 -4
- package/src/lib/bundle/installDownloads.ts +13 -3
- package/src/lib/bundle/installMacMenu.ts +13 -3
- package/src/lib/cli/parseArgvForRpc.ts +36 -9
- package/src/lib/cli/tokenizeArgvFlags.ts +4 -2
- package/src/lib/mcp/createMcpResourceServer.ts +13 -2
- package/src/lib/mcp/toolResultFromResponse.ts +40 -12
- package/src/lib/server/rpc/parseArgs.ts +15 -1
- package/src/lib/server/rpc/runWithRpcTimeout.ts +12 -1
- package/src/lib/server/runtime/finalizeResponse.ts +11 -1
- package/src/lib/server/runtime/snapshotEntryFromCache.ts +10 -2
- package/src/lib/shared/buildArtifact.ts +17 -0
- package/src/lib/shared/buildRpcRequest.ts +6 -2
- package/src/lib/shared/canonicalJson.ts +24 -1
- package/src/lib/shared/createChannelLog.ts +24 -0
- package/src/lib/shared/exitOnBuildFailure.ts +4 -3
- package/src/lib/shared/parseEnv.ts +17 -6
- package/src/lib/shared/serializeEnv.ts +18 -6
- package/src/lib/shared/snippet.ts +11 -6
- package/src/lib/shared/url.ts +5 -0
- package/src/lib/ui/compile/UI_RUNTIME_IMPORTS.ts +1 -0
- package/src/lib/ui/compile/bindListenEvent.ts +5 -6
- package/src/lib/ui/compile/compileSSR.ts +1 -1
- package/src/lib/ui/compile/compileShadow.ts +121 -25
- package/src/lib/ui/compile/composeProps.ts +3 -2
- package/src/lib/ui/compile/desugarSignals.ts +1 -1
- package/src/lib/ui/compile/generateBuild.ts +23 -9
- package/src/lib/ui/compile/generateSSR.ts +49 -37
- package/src/lib/ui/compile/isWhitespaceText.ts +10 -2
- package/src/lib/ui/compile/lowerDocAccess.ts +39 -1
- package/src/lib/ui/compile/parseTemplate.ts +3 -1
- package/src/lib/ui/compile/renameSignalRefs.ts +5 -18
- package/src/lib/ui/compile/resolveReactiveExport.ts +4 -7
- package/src/lib/ui/compile/scopeCss.ts +27 -4
- package/src/lib/ui/dom/awaitBlock.ts +67 -29
- package/src/lib/ui/dom/discardBoundary.ts +24 -6
- package/src/lib/ui/dom/each.ts +15 -8
- package/src/lib/ui/dom/fillBefore.ts +6 -7
- package/src/lib/ui/dom/mergeProps.ts +1 -1
- package/src/lib/ui/dom/mountSlot.ts +1 -1
- package/src/lib/ui/dom/mutateDocArray.ts +38 -0
- package/src/lib/ui/dom/restProps.ts +2 -2
- package/src/lib/ui/dom/spreadProps.ts +3 -4
- package/src/lib/ui/dom/tryBlock.ts +10 -11
- package/src/lib/ui/dom/withScope.ts +7 -0
- package/src/lib/ui/history.ts +14 -7
- package/src/lib/ui/installHotBridge.ts +2 -0
- package/src/lib/ui/props.ts +17 -0
- package/src/lib/ui/renderChain.ts +7 -3
- package/src/lib/ui/router.ts +23 -17
- package/src/lib/ui/runtime/CHILD_PRESENT.ts +2 -2
- package/src/lib/ui/runtime/applyPatchToTree.ts +16 -3
- package/src/lib/ui/runtime/captureModelDoc.ts +22 -7
- package/src/lib/ui/runtime/createDoc.ts +28 -12
- package/src/lib/ui/runtime/flushEffects.ts +23 -1
- package/src/lib/ui/runtime/scope.ts +11 -0
- package/src/lib/ui/runtime/toTeardown.ts +10 -3
- package/src/lib/ui/runtime/types/UiProps.ts +6 -5
- package/src/lib/ui/runtime/withoutHydration.ts +22 -0
- package/src/lib/ui/state.ts +9 -3
|
@@ -184,15 +184,17 @@ export function generateSSR(
|
|
|
184
184
|
})
|
|
185
185
|
|
|
186
186
|
/* Snippet names whose body produces an `await`, so the snippet must be an `async function`
|
|
187
|
-
and its `{name(...)}` call sites awaited: it inlines a child component
|
|
188
|
-
block
|
|
189
|
-
|
|
190
|
-
|
|
187
|
+
and its `{name(...)}` call sites awaited: it inlines a child component, holds an await
|
|
188
|
+
block, or emits a `{children()}` slot fill (all `await $props.$children()` in SSR) — a
|
|
189
|
+
structural scan — OR it text-calls another async snippet. The latter is a dependency
|
|
190
|
+
between snippets, so resolve it to a fixpoint — seed with the structural set, then keep
|
|
191
|
+
adding any snippet that calls an already-async one until nothing changes. */
|
|
191
192
|
const subtreeAwaits = (children: TemplateNode[]): boolean =>
|
|
192
193
|
children.some(
|
|
193
194
|
(child) =>
|
|
194
195
|
child.kind === 'component' ||
|
|
195
196
|
child.kind === 'await' ||
|
|
197
|
+
(child.kind === 'element' && child.tag === 'slot') ||
|
|
196
198
|
('children' in child && subtreeAwaits(child.children)),
|
|
197
199
|
)
|
|
198
200
|
const snippetDefs = new Map<string, TemplateNode[]>()
|
|
@@ -223,14 +225,20 @@ export function generateSSR(
|
|
|
223
225
|
}
|
|
224
226
|
}
|
|
225
227
|
}
|
|
226
|
-
/* A text-part expression
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
228
|
+
/* A text-part expression whose value may be a Promise, so `$text` must `await` it:
|
|
229
|
+
either it calls an async snippet declared HERE, or it CALLS a computed-backed
|
|
230
|
+
binding. The latter covers a snippet handed down as a prop and called by its prop
|
|
231
|
+
name (`{item(label)}`) — that prop lowers to a computed, so it never appears in this
|
|
232
|
+
component's own `asyncSnippets`, yet the parent's snippet body may be async and the
|
|
233
|
+
call returns a Promise (the `[object Promise]` bug). A computed READ (`{full}`) is not
|
|
234
|
+
a call, so plain interpolation stays sync; only `name(...)` on a computed is awaited. */
|
|
235
|
+
const awaitableCallNames = new Set<string>([...asyncSnippets, ...computedNames])
|
|
236
|
+
const callsAwaitable = (code: string): boolean => {
|
|
237
|
+
// The common component with no async snippet and no computed pays zero regex work.
|
|
238
|
+
if (awaitableCallNames.size === 0) {
|
|
231
239
|
return false
|
|
232
240
|
}
|
|
233
|
-
for (const name of
|
|
241
|
+
for (const name of awaitableCallNames) {
|
|
234
242
|
if (callPattern(name).test(code)) {
|
|
235
243
|
return true
|
|
236
244
|
}
|
|
@@ -285,7 +293,7 @@ export function generateSSR(
|
|
|
285
293
|
Plain expressions stay sync, so a component with only interpolation keeps a
|
|
286
294
|
sync render. */
|
|
287
295
|
const lowered = lowerExpression(part.code)
|
|
288
|
-
const value =
|
|
296
|
+
const value = callsAwaitable(part.code)
|
|
289
297
|
? `$text(await (${lowered}))`
|
|
290
298
|
: `$text(${lowered})`
|
|
291
299
|
return markText.get(node)
|
|
@@ -396,7 +404,7 @@ export function generateSSR(
|
|
|
396
404
|
/* Server-render the child via its `render` and inline the HTML inside the same
|
|
397
405
|
`[ … ]` marker range the client mounts into (`mountRange`) — no wrapper element,
|
|
398
406
|
so SSR and client agree and the child's root lays out as a direct child. Props
|
|
399
|
-
pass as thunks; slot content passes as a string-returning
|
|
407
|
+
pass as thunks; slot content passes as a string-returning `children` the child
|
|
400
408
|
invokes from its <slot>. */
|
|
401
409
|
/* Slot content is a fresh build context — the child's `<slot>` mounts it via
|
|
402
410
|
`mountSlot`, not the parent skeleton clone, and the client builds it through
|
|
@@ -404,19 +412,22 @@ export function generateSSR(
|
|
|
404
412
|
records it reset, so its children emit no enclosing-skeleton anchors the client
|
|
405
413
|
slot builder would lack. */
|
|
406
414
|
const slotCode = generateInto(node.children, '$slot')
|
|
407
|
-
/*
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
415
|
+
/* Slot content rides the `children` prop key as a `Snippet`: a zero-arg callable
|
|
416
|
+
returning an ASYNC builder the child `await`s at its `{children()}` position
|
|
417
|
+
(`generateSlot`), whose resolved value is a `$snip`-branded string — so it renders
|
|
418
|
+
through the same `$text` snippet-marker path as any `{snippet(args)}` and unifies
|
|
419
|
+
with a passed `children={snippet}`. It is NOT pre-resolved: pre-resolving here would
|
|
420
|
+
run the slot's `$ctx.next++` block ids BEFORE the child render's own, but the client
|
|
421
|
+
builds slot content lazily at the `{children()}` site — so a child with an await/try
|
|
422
|
+
before its slot would allocate ids in the opposite order and desync hydration.
|
|
423
|
+
Keeping the slot lazy draws its ids at the `{children()}` site on both sides. The
|
|
424
|
+
builder shares the enclosing render's `$ctx`/`$awaits`/`$resume` (a closure), so
|
|
425
|
+
nested awaits register and number correctly during the child render. A child with a
|
|
426
|
+
slot fill is therefore always an async render. */
|
|
416
427
|
const slotPart =
|
|
417
428
|
slotCode.trim() === ''
|
|
418
429
|
? undefined
|
|
419
|
-
: `"
|
|
430
|
+
: `"children": () => (async () => { const $slot = []; ${slotCode}return $snip($slot.join('')); })`
|
|
420
431
|
/* The same last-wins layering the client build emits (`composeProps`), so SSR
|
|
421
432
|
and hydration read the same prop bag. */
|
|
422
433
|
const propsExpr = composeProps(node.props, lowerExpression, slotPart)
|
|
@@ -430,7 +441,11 @@ export function generateSSR(
|
|
|
430
441
|
return (
|
|
431
442
|
anchor +
|
|
432
443
|
push(target, RANGE_OPEN) +
|
|
433
|
-
|
|
444
|
+
/* The tag lowers like any reference (see generateBuild): a static import
|
|
445
|
+
is left bare, a reactive/loop/await binding derefs — SSR registers such
|
|
446
|
+
a binding as `plain`, so it reads the bare local holding the resolved
|
|
447
|
+
component, keeping SSR and client congruent. */
|
|
448
|
+
`const ${result} = await ${lowerExpression(node.name)}.render(${propsExpr}, $ctx);\n` +
|
|
434
449
|
`${target}.push(${result}.html);\n` +
|
|
435
450
|
`for (const $a of ${result}.awaits) { $awaits.push($a); }\n` +
|
|
436
451
|
`Object.assign($resume, ${result}.resume);\n` +
|
|
@@ -572,26 +587,23 @@ export function generateSSR(
|
|
|
572
587
|
return code
|
|
573
588
|
}
|
|
574
589
|
|
|
575
|
-
/* A
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
590
|
+
/* A component `{children()}` fill point: render the `children` prop (a `Snippet`) as a
|
|
591
|
+
snippet text part. `children()()` reads the destructured `children` computed and calls
|
|
592
|
+
it → a Promise of a `$snip`-branded string; `await` it and push through `$text`, which
|
|
593
|
+
wraps the branded string in `<!--abide:snippet-->` markers — the same range the client's
|
|
594
|
+
appendText→appendSnippet emits and claims, so hydration stays congruent. Inside a skeleton
|
|
595
|
+
the slot is positioned by an `<!--a-->` anchor and bounded by a `[ … ]` range (matching the
|
|
596
|
+
client's `mountSlot`); outside one it emits just the snippet markers (matching the client's
|
|
597
|
+
direct `appendText`). The `await` makes a component with a slot an async render (its caller
|
|
598
|
+
already `await`s `render()`). A fallback is now an authored `{#if children}…{:else}…{/if}`,
|
|
599
|
+
so the slot node carries no children. */
|
|
582
600
|
function generateSlot(
|
|
583
601
|
node: Extract<TemplateNode, { kind: 'element' }>,
|
|
584
602
|
target: string,
|
|
585
603
|
anchor: string,
|
|
586
604
|
): string {
|
|
587
605
|
const wrap = inSkeleton.get(node)
|
|
588
|
-
|
|
589
|
-
slot content's block ids allocate AT the slot position — the same order the
|
|
590
|
-
client builds slot content — keeping hydration congruent. The `await` makes a
|
|
591
|
-
component with a slot an async render (its caller already `await`s `render()`).
|
|
592
|
-
A fallback is now an authored `{#if children}…{:else}…{/if}`, so the slot node
|
|
593
|
-
carries no children. */
|
|
594
|
-
const body = `if ($props && $props.$children) { ${target}.push(await $props.$children()); }\n`
|
|
606
|
+
const body = `${target}.push($text(await (${lowerExpression('children()')})));\n`
|
|
595
607
|
if (!wrap) {
|
|
596
608
|
return body
|
|
597
609
|
}
|
|
@@ -1,11 +1,19 @@
|
|
|
1
1
|
import type { TemplateNode } from './types/TemplateNode.ts'
|
|
2
2
|
|
|
3
|
+
/* Only HTML-collapsible whitespace — space, tab, LF, CR, FF. Deliberately EXCLUDES
|
|
4
|
+
U+00A0 (` `), which browsers neither collapse nor trim; `.trim()`/`\s` would
|
|
5
|
+
wrongly count it, so a `<span> </span>` spacer would be dropped as blank. */
|
|
6
|
+
const COLLAPSIBLE_WHITESPACE = /[^ \t\n\r\f]/
|
|
7
|
+
|
|
3
8
|
/* A text node that is purely whitespace (no interpolation, only blank static
|
|
4
9
|
parts). Both back-ends drop it, so it neither contributes markup nor breaks a
|
|
5
|
-
static clone run — it stays transparent so `<a/>\n<b/>` still coalesces.
|
|
10
|
+
static clone run — it stays transparent so `<a/>\n<b/>` still coalesces. A part
|
|
11
|
+
holding a non-breaking space is renderable content, not blank. */
|
|
6
12
|
export function isWhitespaceText(node: TemplateNode): boolean {
|
|
7
13
|
return (
|
|
8
14
|
node.kind === 'text' &&
|
|
9
|
-
node.parts.every(
|
|
15
|
+
node.parts.every(
|
|
16
|
+
(part) => part.kind === 'static' && !COLLAPSIBLE_WHITESPACE.test(part.value),
|
|
17
|
+
)
|
|
10
18
|
)
|
|
11
19
|
}
|
|
@@ -39,6 +39,22 @@ type Segment = { kind: 'literal'; value: string } | { kind: 'expression'; node:
|
|
|
39
39
|
/* Maps a compound-assignment operator to its plain binary counterpart. Logical
|
|
40
40
|
assignments (`||=`/`&&=`/`??=`) lower to an unconditional replace of the
|
|
41
41
|
combined value — consistent with how `+=` lowers (the patch always writes). */
|
|
42
|
+
/* Array methods that mutate the receiver in place. Called on a doc-rooted array
|
|
43
|
+
these can't lower to a bare `readCall` (which would mutate the live tree by
|
|
44
|
+
reference and never emit a patch — no re-render, no undo/persistence/sync);
|
|
45
|
+
they route through `$$mutateDocArray`, which clones-applies-replaces so a real
|
|
46
|
+
patch fires. `push` is handled separately above (fine-grained `add` patches). */
|
|
47
|
+
const MUTATING_ARRAY_METHODS = new Set([
|
|
48
|
+
'pop',
|
|
49
|
+
'shift',
|
|
50
|
+
'unshift',
|
|
51
|
+
'splice',
|
|
52
|
+
'sort',
|
|
53
|
+
'reverse',
|
|
54
|
+
'fill',
|
|
55
|
+
'copyWithin',
|
|
56
|
+
])
|
|
57
|
+
|
|
42
58
|
const COMPOUND_OPERATORS = new Map<ts.SyntaxKind, ts.BinaryOperator>([
|
|
43
59
|
[ts.SyntaxKind.PlusEqualsToken, ts.SyntaxKind.PlusToken],
|
|
44
60
|
[ts.SyntaxKind.MinusEqualsToken, ts.SyntaxKind.MinusToken],
|
|
@@ -162,10 +178,32 @@ export function docAccessTransformer(docName: string): ts.TransformerFactory<ts.
|
|
|
162
178
|
const access = node.expression
|
|
163
179
|
const segments = pathSegments(access.expression)
|
|
164
180
|
if (segments) {
|
|
165
|
-
const read = docCall(docName, 'read', [buildPath(segments)])
|
|
166
181
|
const args = node.arguments.map(
|
|
167
182
|
(arg) => ts.visitNode(arg, visit) as ts.Expression,
|
|
168
183
|
)
|
|
184
|
+
/* An in-place-mutating array method on a doc path → route through
|
|
185
|
+
`$$mutateDocArray(doc, path, member, [args])` so the mutation lands as a
|
|
186
|
+
patch instead of silently mutating the live tree by reference. Optional
|
|
187
|
+
chaining (`model.items?.splice(…)`) keeps the bare-call semantics below —
|
|
188
|
+
skip-if-absent is the author's explicit choice, and a nullish array has
|
|
189
|
+
nothing to mutate. */
|
|
190
|
+
if (
|
|
191
|
+
MUTATING_ARRAY_METHODS.has(access.name.text) &&
|
|
192
|
+
!access.questionDotToken &&
|
|
193
|
+
!node.questionDotToken
|
|
194
|
+
) {
|
|
195
|
+
return ts.factory.createCallExpression(
|
|
196
|
+
ts.factory.createIdentifier('$$mutateDocArray'),
|
|
197
|
+
undefined,
|
|
198
|
+
[
|
|
199
|
+
ts.factory.createIdentifier(docName),
|
|
200
|
+
buildPath(segments),
|
|
201
|
+
ts.factory.createStringLiteral(access.name.text),
|
|
202
|
+
ts.factory.createArrayLiteralExpression(args),
|
|
203
|
+
],
|
|
204
|
+
)
|
|
205
|
+
}
|
|
206
|
+
const read = docCall(docName, 'read', [buildPath(segments)])
|
|
169
207
|
/* Optional chaining is the author's explicit skip-if-absent: a nullish
|
|
170
208
|
read short-circuits the whole call to `undefined`. Keep it bare —
|
|
171
209
|
routing it through the throwing guard would invert that semantics.
|
|
@@ -590,7 +590,9 @@ export function parseTemplate(source: string, baseOffset = 0): { nodes: Template
|
|
|
590
590
|
value += source.charAt(cursor)
|
|
591
591
|
cursor += 1
|
|
592
592
|
}
|
|
593
|
-
|
|
593
|
+
/* Decode entities like the quoted path does — a real HTML parser decodes
|
|
594
|
+
unquoted attribute values too, so `value=a&b` is the value `a&b`. */
|
|
595
|
+
attrs.push({ kind: 'static', name, value: decodeHtmlEntities(value) })
|
|
594
596
|
}
|
|
595
597
|
}
|
|
596
598
|
return attrs
|
|
@@ -61,11 +61,6 @@ export function signalRefsTransformer(
|
|
|
61
61
|
...derivedNames,
|
|
62
62
|
...computedNames,
|
|
63
63
|
...blockLocal,
|
|
64
|
-
/* The reserved slot reader (`{#if children}` → `$props?.$children`) is rewritten
|
|
65
|
-
through `referenceFor` like a signal, so it inherits the same lexical-scope
|
|
66
|
-
tracking: a `{#snippet row(children)}` arg, a `{#for children of …}` item, a
|
|
67
|
-
script param named `children` re-binds it for that subtree and is left untouched. */
|
|
68
|
-
'children',
|
|
69
64
|
/* `scope` is the author-facing reactive entry (`scope().state(...)`), lowered to the
|
|
70
65
|
reserved `$$scope` import so a user variable named `scope` can never collide — but
|
|
71
66
|
shadowably: a `const scope`/param `scope` re-binds it for that subtree and is left
|
|
@@ -332,10 +327,11 @@ function functionParameters(node: ts.Node): ts.NodeArray<ts.ParameterDeclaration
|
|
|
332
327
|
|
|
333
328
|
/* `$$model.<name>` for a state binding, `<name>()` for a computed doc-slot (the
|
|
334
329
|
string-free reader `scope().derive` returns), `<name>.value` for a runtime cell
|
|
335
|
-
(linked / lens / transform-state),
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
330
|
+
(linked / lens / transform-state), else undefined. A `blockLocal` binding shadows
|
|
331
|
+
any same-named component signal — it is a nearer lexical scope — so it derefs as a
|
|
332
|
+
cell (`<name>.value`) regardless of a colliding `state`/`computed`/`derived`.
|
|
333
|
+
`children` is an ordinary destructured prop now — it rewrites via the `derivedNames`
|
|
334
|
+
branch below like any other, with no special case. */
|
|
339
335
|
function referenceFor(
|
|
340
336
|
name: string,
|
|
341
337
|
stateNames: ReadonlySet<string>,
|
|
@@ -346,15 +342,6 @@ function referenceFor(
|
|
|
346
342
|
if (blockLocal.has(name)) {
|
|
347
343
|
return ts.factory.createPropertyAccessExpression(ts.factory.createIdentifier(name), 'value')
|
|
348
344
|
}
|
|
349
|
-
/* `{children()}` is a slot NODE the parser handles; any other bare `children` read
|
|
350
|
-
(notably `{#if children}`) is the reserved reader. `?.` guards a propless mount. */
|
|
351
|
-
if (name === 'children') {
|
|
352
|
-
return ts.factory.createPropertyAccessChain(
|
|
353
|
-
ts.factory.createIdentifier('$props'),
|
|
354
|
-
ts.factory.createToken(ts.SyntaxKind.QuestionDotToken),
|
|
355
|
-
ts.factory.createIdentifier('$children'),
|
|
356
|
-
)
|
|
357
|
-
}
|
|
358
345
|
/* The author-facing reactive entry `scope` lowers to its reserved `$$scope` import (the
|
|
359
346
|
value read only — a `.scope` member or a shadowing local is never reached here). */
|
|
360
347
|
if (name === 'scope') {
|
|
@@ -9,9 +9,7 @@ export type ReactivePrimitive = 'state' | 'linked' | 'computed' | 'effect' | 'wa
|
|
|
9
9
|
|
|
10
10
|
/* The `abide/ui/*` specifier each importable reactive primitive is published at,
|
|
11
11
|
mapped to its canonical name. Built from the package name so a rename is one edit.
|
|
12
|
-
`linked`/`computed` are members of `state`, not standalone imports
|
|
13
|
-
ambient sugar today (no module) — kept here so a future `abide/ui/props` resolves
|
|
14
|
-
with no code change. */
|
|
12
|
+
`linked`/`computed` are members of `state`, not standalone imports. */
|
|
15
13
|
const REACTIVE_SPECIFIERS: Record<string, ReactivePrimitive> = {
|
|
16
14
|
[`${ABIDE_PACKAGE_NAME}/ui/state`]: 'state',
|
|
17
15
|
[`${ABIDE_PACKAGE_NAME}/ui/effect`]: 'effect',
|
|
@@ -39,6 +37,7 @@ export const NESTED_REACTIVE_BINDINGS: ReactiveImportBindings = {
|
|
|
39
37
|
['state', 'state'],
|
|
40
38
|
['effect', 'effect'],
|
|
41
39
|
['watch', 'watch'],
|
|
40
|
+
['props', 'props'],
|
|
42
41
|
]),
|
|
43
42
|
stateRoots: new Set(['state']),
|
|
44
43
|
}
|
|
@@ -70,9 +69,7 @@ export function reactiveImportBindings(source: ts.SourceFile): ReactiveImportBin
|
|
|
70
69
|
return { direct, stateRoots }
|
|
71
70
|
}
|
|
72
71
|
|
|
73
|
-
/* The reactive primitive a call's callee resolves to, or undefined.
|
|
74
|
-
identifier is always the ambient prop reader — the one primitive with no runtime module
|
|
75
|
-
(pure compiler sugar), so it resolves with no import. Every other bare identifier
|
|
72
|
+
/* The reactive primitive a call's callee resolves to, or undefined. Every bare identifier
|
|
76
73
|
resolves through the direct import bindings (alias-safe); a `stateRoot.linked` /
|
|
77
74
|
`.computed` member call resolves off a local bound to `state`. Every other callee is
|
|
78
75
|
undefined (a user's own function, an unrelated member access). */
|
|
@@ -81,7 +78,7 @@ export function resolveReactiveExport(
|
|
|
81
78
|
bindings: ReactiveImportBindings,
|
|
82
79
|
): ReactivePrimitive | undefined {
|
|
83
80
|
if (ts.isIdentifier(callee)) {
|
|
84
|
-
return
|
|
81
|
+
return bindings.direct.get(callee.text)
|
|
85
82
|
}
|
|
86
83
|
if (
|
|
87
84
|
ts.isPropertyAccessExpression(callee) &&
|
|
@@ -79,12 +79,35 @@ function scopeSelector(selector: string, attribute: string): string {
|
|
|
79
79
|
if (selector === '') {
|
|
80
80
|
return selector
|
|
81
81
|
}
|
|
82
|
-
|
|
83
|
-
|
|
82
|
+
/* The last compound begins just after the final combinator (whitespace / `>` / `+` / `~`)
|
|
83
|
+
at bracket depth zero. Bracket-aware so a space inside a quoted attribute value
|
|
84
|
+
(`[data-label="Read more"]`) is NOT mistaken for a descendant combinator — a plain
|
|
85
|
+
`/\s/` split there desyncs pseudoIndex's bracket count and drops the whole rule. */
|
|
86
|
+
let depth = 0
|
|
87
|
+
let lastCompoundStart = 0
|
|
88
|
+
for (let index = 0; index < selector.length; index += 1) {
|
|
89
|
+
const char = selector[index] as string
|
|
90
|
+
if (char === '[') {
|
|
91
|
+
depth += 1
|
|
92
|
+
} else if (char === ']') {
|
|
93
|
+
depth -= 1
|
|
94
|
+
} else if (
|
|
95
|
+
depth === 0 &&
|
|
96
|
+
(char === ' ' ||
|
|
97
|
+
char === '\t' ||
|
|
98
|
+
char === '\n' ||
|
|
99
|
+
char === '>' ||
|
|
100
|
+
char === '+' ||
|
|
101
|
+
char === '~')
|
|
102
|
+
) {
|
|
103
|
+
lastCompoundStart = index + 1
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
const prefix = selector.slice(0, lastCompoundStart)
|
|
107
|
+
const last = selector.slice(lastCompoundStart)
|
|
108
|
+
if (last === '') {
|
|
84
109
|
return `${selector}[${attribute}]`
|
|
85
110
|
}
|
|
86
|
-
const prefix = match[1] ?? ''
|
|
87
|
-
const last = match[2] ?? ''
|
|
88
111
|
const pseudo = pseudoIndex(last)
|
|
89
112
|
const scopedLast =
|
|
90
113
|
pseudo === -1
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { decodeRefJson } from '../../shared/decodeRefJson.ts'
|
|
2
2
|
import { effect } from '../effect.ts'
|
|
3
|
-
import {
|
|
3
|
+
import { claimExpected } from '../runtime/claimExpected.ts'
|
|
4
4
|
import { generationGuard } from '../runtime/generationGuard.ts'
|
|
5
5
|
import { RANGE_CLOSE, RANGE_OPEN } from '../runtime/RANGE_MARKER.ts'
|
|
6
6
|
import { RENDER } from '../runtime/RENDER.ts'
|
|
@@ -9,6 +9,7 @@ import { RESUME } from '../runtime/RESUME.ts'
|
|
|
9
9
|
import { scope } from '../runtime/scope.ts'
|
|
10
10
|
import { scopeGroup } from '../runtime/scopeGroup.ts'
|
|
11
11
|
import type { State } from '../runtime/types/State.ts'
|
|
12
|
+
import { withoutHydration } from '../runtime/withoutHydration.ts'
|
|
12
13
|
import { state } from '../state.ts'
|
|
13
14
|
import { buildDetachedRange } from './buildDetachedRange.ts'
|
|
14
15
|
import { discardBoundary } from './discardBoundary.ts'
|
|
@@ -89,6 +90,18 @@ export function awaitBlock(
|
|
|
89
90
|
nested blocks — appends freely), the same create primitive the keyed-list runtimes use,
|
|
90
91
|
which lands as a marker-bounded range the next swap detaches with `removeRange`. */
|
|
91
92
|
const place = (build: (parent: Node) => void): void => {
|
|
93
|
+
/* Backstop for a settle whose anchor has been detached from the tree. The
|
|
94
|
+
generationGuard is the PRIMARY defense — it drops a late settle after the owner
|
|
95
|
+
tears down — but it only covers teardowns that dispose THIS block's scope. A
|
|
96
|
+
gap (e.g. a nested hydration `adopt` that aborts to `rebuildCold`, leaving the
|
|
97
|
+
inner block's range removed while its guard stays live) can still route a late
|
|
98
|
+
settle here with `anchor` already pulled out of the DOM. Inserting before a
|
|
99
|
+
node that is no longer a child of any parent throws a `NotFoundError` from
|
|
100
|
+
`insertBefore` — surfacing as a process-fatal unhandled rejection under Bun. A
|
|
101
|
+
detached anchor unambiguously means the block is gone, so drop the settle. */
|
|
102
|
+
if (anchor !== undefined && anchor.parentNode === null) {
|
|
103
|
+
return
|
|
104
|
+
}
|
|
92
105
|
detach()
|
|
93
106
|
const namespaceParent = anchor?.parentNode ?? parent
|
|
94
107
|
const { start, end, fragment, dispose } = buildDetachedRange(namespaceParent, build)
|
|
@@ -166,18 +179,53 @@ export function awaitBlock(
|
|
|
166
179
|
const cursor = hydration as NonNullable<typeof hydration>
|
|
167
180
|
const firstAdopted = open?.nextSibling ?? null
|
|
168
181
|
cursor.next.set(parent, firstAdopted)
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
182
|
+
/* Adoption is guarded (see firstHydrate): a build that can't claim the server
|
|
183
|
+
markup — a resume value that didn't round-trip, a nested-adopt claim desync —
|
|
184
|
+
throws, and the caller recovers via `rebuildCold`. But the partial build may have
|
|
185
|
+
already created a live sub-scope (an inner `await`'s effect/guard, a subscription)
|
|
186
|
+
before it threw; letting the throw escape `scope()` would strand that scope's
|
|
187
|
+
disposer (unreachable → never disposed), leaking the effect AND leaving its guard
|
|
188
|
+
un-bumped so a late settle stays "live". So capture the build's error, ALWAYS take
|
|
189
|
+
the returned disposer, and dispose it on ANY failure before rethrowing — `rebuildCold`
|
|
190
|
+
then starts from a clean slate. */
|
|
191
|
+
let dispose: (() => void) | undefined
|
|
192
|
+
try {
|
|
193
|
+
let buildFailed = false
|
|
194
|
+
let buildError: unknown
|
|
195
|
+
dispose = group.track(
|
|
196
|
+
scope(() => {
|
|
197
|
+
try {
|
|
198
|
+
build(parent)
|
|
199
|
+
} catch (error) {
|
|
200
|
+
buildFailed = true
|
|
201
|
+
buildError = error
|
|
202
|
+
}
|
|
203
|
+
}),
|
|
204
|
+
)
|
|
205
|
+
if (buildFailed) {
|
|
206
|
+
throw buildError
|
|
207
|
+
}
|
|
208
|
+
/* A guaranteed control-flow marker — claimExpected throws on a desync (caught by
|
|
209
|
+
firstHydrate's adopt try/catch → rebuildCold) instead of silently claiming null
|
|
210
|
+
and over-clearing the parent. */
|
|
211
|
+
const close = claimExpected(cursor, parent, `/abide:await:${id} close marker`)
|
|
212
|
+
cursor.next.set(parent, close.nextSibling ?? null)
|
|
213
|
+
/* Bracket the adopted nodes: `[` before the first claimed node (or before `close`
|
|
214
|
+
for an empty branch), `]` then the anchor just before `close`. */
|
|
215
|
+
const start = document.createComment(RANGE_OPEN)
|
|
216
|
+
parent.insertBefore(start, firstAdopted ?? close)
|
|
217
|
+
const end = document.createComment(RANGE_CLOSE)
|
|
218
|
+
parent.insertBefore(end, close)
|
|
219
|
+
anchor = document.createTextNode('')
|
|
220
|
+
parent.insertBefore(anchor, close)
|
|
221
|
+
active = { start, end, dispose }
|
|
222
|
+
} catch (error) {
|
|
223
|
+
/* `dispose` (the group-tracked wrapper) is idempotent; running it here tears the
|
|
224
|
+
partial branch scope down and drops it from the group so `rebuildCold` doesn't
|
|
225
|
+
inherit a stranded scope, then the caller's `catch` falls back to a cold build. */
|
|
226
|
+
dispose?.()
|
|
227
|
+
throw error
|
|
228
|
+
}
|
|
181
229
|
}
|
|
182
230
|
|
|
183
231
|
/* Discard the SSR boundary and (re)build the block from the live promise, fresh
|
|
@@ -195,13 +243,7 @@ export function awaitBlock(
|
|
|
195
243
|
)
|
|
196
244
|
anchor = document.createTextNode('')
|
|
197
245
|
parent.insertBefore(anchor, after)
|
|
198
|
-
|
|
199
|
-
RENDER.hydration = undefined
|
|
200
|
-
try {
|
|
201
|
-
render(promiseThunk())
|
|
202
|
-
} finally {
|
|
203
|
-
RENDER.hydration = previous
|
|
204
|
-
}
|
|
246
|
+
withoutHydration(() => render(promiseThunk()))
|
|
205
247
|
}
|
|
206
248
|
|
|
207
249
|
/* The first run when hydrating: adopt by precedence (resume / warm-sync), else
|
|
@@ -211,7 +253,9 @@ export function awaitBlock(
|
|
|
211
253
|
warm cache (or re-fetches) instead of crashing hydration. */
|
|
212
254
|
const firstHydrate = (): void => {
|
|
213
255
|
const cursor = hydration as NonNullable<typeof hydration>
|
|
214
|
-
|
|
256
|
+
/* The await block's open marker is compiler-guaranteed — claimExpected throws a
|
|
257
|
+
legible desync here rather than propagating a null that over-clears the parent. */
|
|
258
|
+
const open = claimExpected(cursor, parent, `abide:await:${id} open marker`)
|
|
215
259
|
/* RESUME holds the ref-json-encoded entry STRING; decode here, where the codec
|
|
216
260
|
lives. A decode failure (malformed/absent payload) reads as "no resume" — fall
|
|
217
261
|
through to the live promise rather than crash hydration. */
|
|
@@ -283,15 +327,9 @@ export function awaitBlock(
|
|
|
283
327
|
anchor = document.createTextNode('')
|
|
284
328
|
parent.insertBefore(anchor, after)
|
|
285
329
|
/* The boundary's server nodes are gone, so the pending branch builds FRESH — clear
|
|
286
|
-
the claim cursor (
|
|
330
|
+
the claim cursor (see withoutHydration) so its `cloneStatic`/text don't try to
|
|
287
331
|
claim discarded nodes and silently render nothing. */
|
|
288
|
-
|
|
289
|
-
RENDER.hydration = undefined
|
|
290
|
-
try {
|
|
291
|
-
render(result)
|
|
292
|
-
} finally {
|
|
293
|
-
RENDER.hydration = previous
|
|
294
|
-
}
|
|
332
|
+
withoutHydration(() => render(result))
|
|
295
333
|
}
|
|
296
334
|
|
|
297
335
|
effect(() => {
|
|
@@ -10,14 +10,32 @@ export function discardBoundary(
|
|
|
10
10
|
closeData: string,
|
|
11
11
|
hydration: NonNullable<(typeof RENDER)['hydration']>,
|
|
12
12
|
): Node | null {
|
|
13
|
-
|
|
14
|
-
|
|
13
|
+
/* Nothing to discard (no open marker) — park the cursor and return, as before. */
|
|
14
|
+
if (open === null) {
|
|
15
|
+
hydration.next.set(parent, null)
|
|
16
|
+
return null
|
|
17
|
+
}
|
|
18
|
+
/* Locate the close marker WITHOUT mutating first. Removing as we walk would, on a
|
|
19
|
+
marker/id desync (the await/try block-id counter drifting between server and
|
|
20
|
+
client), delete every remaining sibling before discovering there is no match —
|
|
21
|
+
silently wiping unrelated later content. Throw AT the divergence like `outlet`
|
|
22
|
+
instead, rather than over-clear to end-of-parent. */
|
|
23
|
+
let close: Node | null = open
|
|
24
|
+
while (close !== null && (close as { data?: string }).data !== closeData) {
|
|
25
|
+
close = close.nextSibling
|
|
26
|
+
}
|
|
27
|
+
if (close === null) {
|
|
28
|
+
throw new Error(
|
|
29
|
+
`[abide] hydration desync: boundary open marker has no matching close "${closeData}" — the server DOM is truncated or the block id drifted.`,
|
|
30
|
+
)
|
|
31
|
+
}
|
|
32
|
+
const after = close.nextSibling
|
|
33
|
+
/* Remove open..close inclusive now that the range is known-bounded. */
|
|
34
|
+
let node: Node | null = open
|
|
15
35
|
while (node !== null) {
|
|
16
|
-
const next = node.nextSibling
|
|
17
|
-
const isClose = (node as { data?: string }).data === closeData
|
|
36
|
+
const next: Node | null = node.nextSibling
|
|
18
37
|
parent.removeChild(node)
|
|
19
|
-
if (
|
|
20
|
-
after = next
|
|
38
|
+
if (node === close) {
|
|
21
39
|
break
|
|
22
40
|
}
|
|
23
41
|
node = next
|
package/src/lib/ui/dom/each.ts
CHANGED
|
@@ -5,6 +5,7 @@ import { RENDER } from '../runtime/RENDER.ts'
|
|
|
5
5
|
import { scope } from '../runtime/scope.ts'
|
|
6
6
|
import { scopeGroup } from '../runtime/scopeGroup.ts'
|
|
7
7
|
import type { State } from '../runtime/types/State.ts'
|
|
8
|
+
import { withoutHydration } from '../runtime/withoutHydration.ts'
|
|
8
9
|
import { state } from '../state.ts'
|
|
9
10
|
import { buildDetachedRange } from './buildDetachedRange.ts'
|
|
10
11
|
import { moveRange } from './moveRange.ts'
|
|
@@ -144,11 +145,9 @@ export function each<T>(
|
|
|
144
145
|
write that reconciles *mid-hydrate* (RENDER.hydration still active — e.g. a
|
|
145
146
|
page setting shared state during the hydrate pass) would otherwise make
|
|
146
147
|
buildRow and its inner row render claim SSR nodes that don't exist for a
|
|
147
|
-
freshly keyed row.
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
RENDER.hydration = undefined
|
|
151
|
-
try {
|
|
148
|
+
freshly keyed row. withoutHydration restores the outer cursor after, so the
|
|
149
|
+
enclosing hydrate pass is untouched (mirrors awaitBlock/tryBlock). */
|
|
150
|
+
withoutHydration(() => {
|
|
152
151
|
const list = Array.isArray(source) ? source : [...source]
|
|
153
152
|
const keys = list.map(keyOf)
|
|
154
153
|
generation += 1
|
|
@@ -183,6 +182,16 @@ export function each<T>(
|
|
|
183
182
|
for (let index = list.length - 1; index >= 0; index -= 1) {
|
|
184
183
|
let row = resolved[index]
|
|
185
184
|
if (row === undefined) {
|
|
185
|
+
/* Duplicate key within this list: a row for this key was already built
|
|
186
|
+
earlier in this pass. Building another and rows.set-ing it would
|
|
187
|
+
overwrite the map entry for the first, orphaning it — the prune loop
|
|
188
|
+
only reaches rows still in the map, so it would leak on screen forever.
|
|
189
|
+
Collapse to one row (like the hydration path above), skipping this
|
|
190
|
+
index rather than inserting an unreachable duplicate. */
|
|
191
|
+
const built = rows.get(keys[index] as string)
|
|
192
|
+
if (built !== undefined && built.gen === pass) {
|
|
193
|
+
continue
|
|
194
|
+
}
|
|
186
195
|
row = buildRow(list[index] as T, index)
|
|
187
196
|
row.gen = pass
|
|
188
197
|
rows.set(keys[index] as string, row)
|
|
@@ -197,8 +206,6 @@ export function each<T>(
|
|
|
197
206
|
placeBefore(row, cursor)
|
|
198
207
|
cursor = row.start
|
|
199
208
|
}
|
|
200
|
-
}
|
|
201
|
-
RENDER.hydration = previousHydration
|
|
202
|
-
}
|
|
209
|
+
})
|
|
203
210
|
})
|
|
204
211
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { RENDER } from '../runtime/RENDER.ts'
|
|
2
1
|
import { scope } from '../runtime/scope.ts'
|
|
2
|
+
import { withoutHydration } from '../runtime/withoutHydration.ts'
|
|
3
3
|
import { enterNamespace } from './enterNamespace.ts'
|
|
4
4
|
|
|
5
5
|
/*
|
|
@@ -29,15 +29,14 @@ export function fillBefore(end: Node, content: (into: Node) => void): () => void
|
|
|
29
29
|
return () => {}
|
|
30
30
|
}
|
|
31
31
|
const fragment = document.createDocumentFragment()
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
32
|
+
/* `fillBefore` is exclusively the CREATE path, so build with the claim cursor
|
|
33
|
+
cleared (see withoutHydration): a rebuild that runs mid-hydrate must not claim
|
|
34
|
+
SSR nodes that don't exist for this fresh content. */
|
|
35
|
+
return withoutHydration(() => {
|
|
35
36
|
/* Build under the insertion parent's foreign namespace (if any), so foreign
|
|
36
37
|
elements built into the fragment are namespaced off `end`'s live parent. */
|
|
37
38
|
const dispose = enterNamespace(end.parentNode ?? end, () => scope(() => content(fragment)))
|
|
38
39
|
;(end.parentNode ?? end).insertBefore(fragment, end)
|
|
39
40
|
return dispose
|
|
40
|
-
}
|
|
41
|
-
RENDER.hydration = previousHydration
|
|
42
|
-
}
|
|
41
|
+
})
|
|
43
42
|
}
|