@abide/abide 0.47.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.
Files changed (84) hide show
  1. package/AGENTS.md +26 -13
  2. package/CHANGELOG.md +53 -0
  3. package/README.md +9 -3
  4. package/package.json +3 -1
  5. package/src/buildCli.ts +3 -5
  6. package/src/buildDisconnected.ts +2 -3
  7. package/src/bundleApp.ts +2 -3
  8. package/src/compile.ts +2 -4
  9. package/src/lib/bundle/installDownloads.ts +13 -3
  10. package/src/lib/bundle/installMacMenu.ts +13 -3
  11. package/src/lib/cli/parseArgvForRpc.ts +36 -9
  12. package/src/lib/cli/tokenizeArgvFlags.ts +4 -2
  13. package/src/lib/mcp/createMcpResourceServer.ts +13 -2
  14. package/src/lib/mcp/toolResultFromResponse.ts +40 -12
  15. package/src/lib/server/rpc/parseArgs.ts +15 -1
  16. package/src/lib/server/rpc/runWithRpcTimeout.ts +12 -1
  17. package/src/lib/server/runtime/buildCacheSnapshot.ts +6 -6
  18. package/src/lib/server/runtime/finalizeResponse.ts +11 -1
  19. package/src/lib/server/runtime/snapshotEntryFromCache.ts +10 -2
  20. package/src/lib/server/runtime/types/InspectorCacheEntry.ts +1 -1
  21. package/src/lib/server/runtime/types/InspectorCacheSnapshot.ts +4 -4
  22. package/src/lib/server/runtime/types/InspectorContext.ts +1 -1
  23. package/src/lib/shared/buildArtifact.ts +17 -0
  24. package/src/lib/shared/buildRpcRequest.ts +6 -2
  25. package/src/lib/shared/cache.ts +66 -28
  26. package/src/lib/shared/cacheStores.ts +3 -3
  27. package/src/lib/shared/canonicalJson.ts +24 -1
  28. package/src/lib/shared/createChannelLog.ts +24 -0
  29. package/src/lib/shared/exitOnBuildFailure.ts +4 -3
  30. package/src/lib/shared/parseEnv.ts +17 -6
  31. package/src/lib/shared/refresh.ts +3 -3
  32. package/src/lib/shared/serializeEnv.ts +18 -6
  33. package/src/lib/shared/sharedCacheStore.ts +14 -0
  34. package/src/lib/shared/sharedCacheStoreSlot.ts +10 -0
  35. package/src/lib/shared/snippet.ts +11 -6
  36. package/src/lib/shared/types/CacheOptions.ts +15 -11
  37. package/src/lib/shared/types/CacheStats.ts +1 -1
  38. package/src/lib/shared/types/SmartReadOptions.ts +15 -11
  39. package/src/lib/shared/url.ts +5 -0
  40. package/src/lib/test/createTestApp.ts +6 -6
  41. package/src/lib/ui/compile/UI_RUNTIME_IMPORTS.ts +1 -0
  42. package/src/lib/ui/compile/bindListenEvent.ts +5 -6
  43. package/src/lib/ui/compile/compileSSR.ts +1 -1
  44. package/src/lib/ui/compile/compileShadow.ts +121 -25
  45. package/src/lib/ui/compile/composeProps.ts +3 -2
  46. package/src/lib/ui/compile/desugarSignals.ts +1 -1
  47. package/src/lib/ui/compile/generateBuild.ts +23 -9
  48. package/src/lib/ui/compile/generateSSR.ts +49 -37
  49. package/src/lib/ui/compile/isWhitespaceText.ts +10 -2
  50. package/src/lib/ui/compile/lowerDocAccess.ts +39 -1
  51. package/src/lib/ui/compile/parseTemplate.ts +3 -1
  52. package/src/lib/ui/compile/renameSignalRefs.ts +5 -18
  53. package/src/lib/ui/compile/resolveReactiveExport.ts +4 -7
  54. package/src/lib/ui/compile/scopeCss.ts +27 -4
  55. package/src/lib/ui/dom/awaitBlock.ts +67 -29
  56. package/src/lib/ui/dom/discardBoundary.ts +24 -6
  57. package/src/lib/ui/dom/each.ts +15 -8
  58. package/src/lib/ui/dom/fillBefore.ts +6 -7
  59. package/src/lib/ui/dom/mergeProps.ts +1 -1
  60. package/src/lib/ui/dom/mountSlot.ts +1 -1
  61. package/src/lib/ui/dom/mutateDocArray.ts +38 -0
  62. package/src/lib/ui/dom/restProps.ts +2 -2
  63. package/src/lib/ui/dom/spreadProps.ts +3 -4
  64. package/src/lib/ui/dom/tryBlock.ts +10 -11
  65. package/src/lib/ui/dom/withScope.ts +7 -0
  66. package/src/lib/ui/history.ts +14 -7
  67. package/src/lib/ui/installHotBridge.ts +2 -0
  68. package/src/lib/ui/props.ts +17 -0
  69. package/src/lib/ui/renderChain.ts +7 -3
  70. package/src/lib/ui/router.ts +23 -17
  71. package/src/lib/ui/runtime/CHILD_PRESENT.ts +2 -2
  72. package/src/lib/ui/runtime/applyPatchToTree.ts +16 -3
  73. package/src/lib/ui/runtime/captureModelDoc.ts +22 -7
  74. package/src/lib/ui/runtime/createDoc.ts +28 -12
  75. package/src/lib/ui/runtime/flushEffects.ts +23 -1
  76. package/src/lib/ui/runtime/scope.ts +11 -0
  77. package/src/lib/ui/runtime/toTeardown.ts +10 -3
  78. package/src/lib/ui/runtime/types/UiProps.ts +6 -5
  79. package/src/lib/ui/runtime/withoutHydration.ts +22 -0
  80. package/src/lib/ui/startClient.ts +3 -3
  81. package/src/lib/ui/state.ts +9 -3
  82. package/src/serverEntry.ts +11 -9
  83. package/src/lib/shared/globalCacheStore.ts +0 -15
  84. package/src/lib/shared/globalCacheStoreSlot.ts +0 -12
@@ -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 / holds an await
188
- block (a structural scan), OR it text-calls another async snippet. The latter is a
189
- dependency between snippets, so resolve it to a fixpoint seed with the structural set,
190
- then keep adding any snippet that calls an already-async one until nothing changes. */
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 that calls an async snippet, so its value is `await`ed before
227
- `$text`. */
228
- const callsAsyncSnippet = (code: string): boolean => {
229
- // The common no-async-snippet component pays zero regex work per text part.
230
- if (asyncSnippets.size === 0) {
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 asyncSnippets) {
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 = callsAsyncSnippet(part.code)
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 `$children` the child
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
- /* `$children` is an ASYNC builder the child `await`s at its `<slot>` position
408
- (`generateSlot`), NOT a pre-resolved string. Pre-resolving here would run the
409
- slot's `$ctx.next++` block ids BEFORE the child render's own, but the client
410
- builds slot content lazily at the `<slot>` site so a child with an await/try
411
- before its `<slot>` would allocate ids in the opposite order and desync hydration.
412
- Keeping the slot lazy makes the slot's ids draw at the `<slot>` site on both
413
- sides. The builder shares the enclosing render's `$ctx`/`$awaits`/`$resume` (a
414
- closure), so nested awaits register and number correctly during the child render.
415
- A child with a `<slot>` is therefore always an async render. */
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
- : `"$children": async () => { const $slot = []; ${slotCode}return $slot.join(''); }`
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
- `const ${result} = await ${node.name}.render(${propsExpr}, $ctx);\n` +
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 `<slot>` outlet: emit the parent-provided content (`$children`), falling back to the
576
- slot's own children when none was supplied. Inside a skeleton the slot is positioned
577
- by an `<!--a-->` anchor and its content bounded by a `[ ]` range (matching the
578
- client's `mountSlot`), so it can sit among static siblings. The fallback is a fresh,
579
- non-skeleton build context the client builds it via `mountSlot`/`fillBefore`, not the
580
- skeleton clone so its reactive text takes no anchor (`skeletonContext` records the
581
- fallback children reset). */
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
- /* `$children` is an async builder the parent passes lazily; `await` it here so the
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 (`&nbsp;`), which browsers neither collapse nor trim; `.trim()`/`\s` would
5
+ wrongly count it, so a `<span>&nbsp;</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((part) => part.kind === 'static' && part.value.trim() === '')
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
- attrs.push({ kind: 'static', name, value })
593
+ /* Decode entities like the quoted path does — a real HTML parser decodes
594
+ unquoted attribute values too, so `value=a&amp;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), `$props?.$children` for the reserved slot reader,
336
- else undefined. A `blockLocal` binding shadows any same-named component signal — it is
337
- a nearer lexical scope — so it derefs as a cell (`<name>.value`) regardless of a
338
- colliding `state`/`computed`/`derived`, and ahead of the `children` slot reader. */
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; `props` is
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. A bare `props`
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 callee.text === 'props' ? 'props' : bindings.direct.get(callee.text)
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
- const match = selector.match(/^(.*?)([^\s>+~]+)$/)
83
- if (match === null) {
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 { claimChild } from '../runtime/claimChild.ts'
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
- const dispose = group.track(scope(() => build(parent)))
170
- const close = claimChild(cursor, parent)
171
- cursor.next.set(parent, close?.nextSibling ?? null)
172
- /* Bracket the adopted nodes: `[` before the first claimed node (or before `close`
173
- for an empty branch), `]` then the anchor just before `close`. */
174
- const start = document.createComment(RANGE_OPEN)
175
- parent.insertBefore(start, firstAdopted ?? close)
176
- const end = document.createComment(RANGE_CLOSE)
177
- parent.insertBefore(end, close)
178
- anchor = document.createTextNode('')
179
- parent.insertBefore(anchor, close)
180
- active = { start, end, dispose }
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
- const previous = RENDER.hydration
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
- const open = claimChild(cursor, parent)
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 (mirrors `rebuildCold`) so its `cloneStatic`/text don't try to
330
+ the claim cursor (see withoutHydration) so its `cloneStatic`/text don't try to
287
331
  claim discarded nodes and silently render nothing. */
288
- const previous = RENDER.hydration
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
- let node = open
14
- let after: Node | null = null
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 (isClose) {
20
- after = next
38
+ if (node === close) {
21
39
  break
22
40
  }
23
41
  node = next
@@ -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. The same `next` Map is restored, so the outer hydration
148
- cursor is untouched (mirrors awaitBlock/tryBlock). */
149
- const previousHydration = RENDER.hydration
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
- } finally {
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
- const previousHydration = RENDER.hydration
33
- RENDER.hydration = undefined
34
- try {
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
- } finally {
41
- RENDER.hydration = previousHydration
42
- }
41
+ })
43
42
  }