@abide/abide 0.54.0 → 0.55.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 (96) hide show
  1. package/AGENTS.md +330 -498
  2. package/CHANGELOG.md +49 -0
  3. package/README.md +166 -146
  4. package/package.json +2 -3
  5. package/src/lib/server/rpc/defineRpc.ts +2 -1
  6. package/src/lib/server/rpc/types/RpcHelper.ts +29 -22
  7. package/src/lib/server/runtime/SSR_SWAP_SCRIPT.ts +3 -2
  8. package/src/lib/server/runtime/createUiPageRenderer.ts +34 -26
  9. package/src/lib/shared/SEEDS.ts +33 -0
  10. package/src/lib/shared/SOCKET_SEED.ts +4 -5
  11. package/src/lib/shared/cache.ts +169 -75
  12. package/src/lib/shared/createRemoteFunction.ts +4 -1
  13. package/src/lib/shared/encodeSeedValue.ts +24 -0
  14. package/src/lib/shared/jsonSchemaForType.ts +3 -16
  15. package/src/lib/shared/peek.ts +3 -6
  16. package/src/lib/ui/amendReaderHook.ts +1 -1
  17. package/src/lib/ui/compile/UI_RUNTIME_IMPORTS.ts +1 -6
  18. package/src/lib/ui/compile/analyzeComponent.ts +0 -2
  19. package/src/lib/ui/compile/awaitSubjectExpr.ts +23 -0
  20. package/src/lib/ui/compile/cachedShadowProgram.ts +2 -1
  21. package/src/lib/ui/compile/compileComponent.ts +1 -10
  22. package/src/lib/ui/compile/componentOrdinals.ts +39 -0
  23. package/src/lib/ui/compile/desugarSignals.ts +8 -44
  24. package/src/lib/ui/compile/generateBuild.ts +25 -24
  25. package/src/lib/ui/compile/generateSSR.ts +37 -24
  26. package/src/lib/ui/compile/hoistableAwaits.ts +1 -11
  27. package/src/lib/ui/compile/isSpuriousAsyncReadDiagnostic.ts +46 -5
  28. package/src/lib/ui/compile/lowerContext.ts +26 -6
  29. package/src/lib/ui/compile/lowerScript.ts +1 -11
  30. package/src/lib/ui/compile/renameSignalRefs.ts +7 -21
  31. package/src/lib/ui/compile/seedTypeClassifierForRoot.ts +1 -1
  32. package/src/lib/ui/compile/shadowInterpolationClassifier.ts +1 -1
  33. package/src/lib/ui/compile/types/AnalyzedComponent.ts +3 -5
  34. package/src/lib/ui/compile/wrapReactiveSeeds.ts +44 -0
  35. package/src/lib/ui/compile/wrapSeed.ts +28 -0
  36. package/src/lib/ui/computed.ts +16 -0
  37. package/src/lib/ui/createScope.ts +4 -3
  38. package/src/lib/ui/dom/anchorCursor.ts +2 -1
  39. package/src/lib/ui/dom/anchoredBranch.ts +14 -3
  40. package/src/lib/ui/dom/appendStatic.ts +11 -20
  41. package/src/lib/ui/dom/appendText.ts +41 -43
  42. package/src/lib/ui/dom/appendTextAt.ts +10 -57
  43. package/src/lib/ui/dom/awaitBlock.ts +30 -44
  44. package/src/lib/ui/dom/awaitSubject.ts +118 -0
  45. package/src/lib/ui/dom/claimText.ts +38 -0
  46. package/src/lib/ui/dom/cloneStatic.ts +2 -8
  47. package/src/lib/ui/dom/discardAndRebuild.ts +23 -0
  48. package/src/lib/ui/dom/discardBoundary.ts +5 -4
  49. package/src/lib/ui/dom/each.ts +3 -5
  50. package/src/lib/ui/dom/fillBoundary.ts +2 -1
  51. package/src/lib/ui/dom/hydrate.ts +5 -12
  52. package/src/lib/ui/dom/mountChild.ts +73 -5
  53. package/src/lib/ui/dom/mountSwappableRange.ts +2 -1
  54. package/src/lib/ui/dom/openMarker.ts +2 -4
  55. package/src/lib/ui/dom/outlet.ts +4 -3
  56. package/src/lib/ui/dom/readCell.ts +26 -11
  57. package/src/lib/ui/dom/skeleton.ts +2 -7
  58. package/src/lib/ui/dom/switchBlock.ts +2 -1
  59. package/src/lib/ui/dom/tryBlock.ts +26 -38
  60. package/src/lib/ui/dom/when.ts +6 -2
  61. package/src/lib/ui/linked.ts +14 -0
  62. package/src/lib/ui/resumeSeedScript.ts +1 -1
  63. package/src/lib/ui/router.ts +12 -24
  64. package/src/lib/ui/runtime/AsyncCellError.ts +2 -1
  65. package/src/lib/ui/runtime/BRANCH_SEGMENT.ts +24 -0
  66. package/src/lib/ui/runtime/CELL_SEED.ts +4 -6
  67. package/src/lib/ui/runtime/DOC_SEED.ts +5 -6
  68. package/src/lib/ui/runtime/RENDER.ts +7 -5
  69. package/src/lib/ui/runtime/RESUME.ts +5 -7
  70. package/src/lib/ui/runtime/SEED_MARKS.ts +14 -0
  71. package/src/lib/ui/runtime/SuspenseSignal.ts +2 -2
  72. package/src/lib/ui/runtime/claimChild.ts +5 -7
  73. package/src/lib/ui/runtime/claimExpected.ts +2 -6
  74. package/src/lib/ui/runtime/claimMarker.ts +13 -0
  75. package/src/lib/ui/runtime/claimRun.ts +22 -0
  76. package/src/lib/ui/runtime/consumeSeed.ts +38 -0
  77. package/src/lib/ui/runtime/createAsyncCell.ts +56 -8
  78. package/src/lib/ui/runtime/createComputedNode.ts +1 -0
  79. package/src/lib/ui/runtime/createEffectNode.ts +1 -0
  80. package/src/lib/ui/runtime/createSignalNode.ts +1 -0
  81. package/src/lib/ui/runtime/parkCursor.ts +10 -0
  82. package/src/lib/ui/runtime/readNode.ts +21 -1
  83. package/src/lib/ui/runtime/renderPath.ts +2 -2
  84. package/src/lib/ui/runtime/routeBareSeed.ts +28 -0
  85. package/src/lib/ui/runtime/runHydrationPass.ts +69 -0
  86. package/src/lib/ui/runtime/runNode.ts +14 -0
  87. package/src/lib/ui/runtime/types/HydrationCursor.ts +8 -0
  88. package/src/lib/ui/runtime/types/ReactiveNode.ts +5 -0
  89. package/src/lib/ui/settleAsyncCells.ts +30 -3
  90. package/src/lib/ui/startClient.ts +0 -7
  91. package/src/lib/ui/tryEncodeResume.ts +6 -15
  92. package/src/lib/ui/watch.ts +3 -11
  93. package/src/lib/ui/dom/mountStreamedChild.ts +0 -84
  94. package/src/lib/ui/dom/readCellBlocking.ts +0 -29
  95. package/src/lib/ui/runtime/restoreWarmSeeds.ts +0 -20
  96. package/src/lib/ui/runtime/warmSeedBackup.ts +0 -10
@@ -6,6 +6,7 @@ import { signalRefsTransformer } from './renameSignalRefs.ts'
6
6
  import { TS_PRINTER } from './TS_PRINTER.ts'
7
7
  import type { TemplateNode } from './types/TemplateNode.ts'
8
8
  import { unwrapParens } from './unwrapParens.ts'
9
+ import { wrapReactiveSeedsTransformer } from './wrapReactiveSeeds.ts'
9
10
 
10
11
  /* Compiled once — strips the trailing `;` off every lowered expression. */
11
12
  const TRAILING_SEMICOLON = /;$/
@@ -25,11 +26,10 @@ export function lowerContext(
25
26
  stateNames: ReadonlySet<string>,
26
27
  derivedNames: ReadonlySet<string>,
27
28
  computedNames: ReadonlySet<string> = new Set(),
28
- /* `linked` / async `computed` names, read through `$$readCell(name)`. */
29
+ /* `linked` / async `computed` names, read through `$$readCell(name)` — one read form
30
+ whose pause-vs-peek is decided at runtime by the cell's own `blocking` bit (ADR-0042),
31
+ so both back-ends lower a cell read identically with nothing site-specific to thread. */
29
32
  cellReadNames: ReadonlySet<string> = new Set(),
30
- /* The subset that are BLOCKING `await` cells (ADR-0042), read through `$$readCellBlocking(name)`
31
- (suspend-on-pending). Passed only by the CLIENT back-end; SSR leaves it empty. */
32
- blockingCellNames: ReadonlySet<string> = new Set(),
33
33
  ) {
34
34
  /* The typed branch-local shadow stack: one auto-popping value owning both kinds.
35
35
  `derived` names deref to `.value` like a `computed` (block value params the client
@@ -44,9 +44,13 @@ export function lowerContext(
44
44
  one tree — the two string passes would each parse + reprint. `localDerived` is
45
45
  snapshotted per call (as the transformer's block-local shadow set) so a binding
46
46
  pushed mid-compile is honoured AND shadows a same-named component signal. */
47
- function lowerOnce(code: string): string {
47
+ function lowerOnce(
48
+ code: string,
49
+ leadingTransformers: ts.TransformerFactory<ts.SourceFile>[] = [],
50
+ ): string {
48
51
  const source = ts.createSourceFile('expr.ts', code, ts.ScriptTarget.Latest, true)
49
52
  const result = ts.transform(source, [
53
+ ...leadingTransformers,
50
54
  signalRefsTransformer(
51
55
  stateNames,
52
56
  derivedNames,
@@ -54,7 +58,6 @@ export function lowerContext(
54
58
  new Set(scope.names('derived')),
55
59
  new Set(scope.names('plain')),
56
60
  cellReadNames,
57
- blockingCellNames,
58
61
  ),
59
62
  docAccessTransformer('$$model'),
60
63
  ])
@@ -75,6 +78,16 @@ export function lowerContext(
75
78
  return lowerOnce(code)
76
79
  }
77
80
 
81
+ /* A nested branch `<script>` body. Same lowering as `statement`, but first normalises its
82
+ literal `state.computed`/`state.linked` seeds into thunks (`wrapReactiveSeeds`) so a bare-
83
+ value seed (`state.computed(a * 2)`) matches the top-level desugar rather than reaching the
84
+ runtime primitive un-thunked (which would call the value as the compute function and crash).
85
+ The wrap runs BEFORE the reference rename so it wraps the raw seed and the renamed refs land
86
+ inside the thunk. */
87
+ function script(code: string): string {
88
+ return lowerOnce(code, [wrapReactiveSeedsTransformer()])
89
+ }
90
+
78
91
  /* A two-way bind target is either an LVALUE (`count`, `model.lines[i]`) — reads as
79
92
  itself, writes by assignment — or an ACCESSOR object from `bind:value={{ get, set }}`
80
93
  — reads via `.get()`, writes via `.set(v)`. The accessor is the only way to bind a
@@ -136,11 +149,18 @@ export function lowerContext(
136
149
  return {
137
150
  expression,
138
151
  statement,
152
+ script,
139
153
  withNestedScripts,
140
154
  /* The raw branch-local shadow registration both back-ends drive `withBindings`
141
155
  through: a block's bindings flow to a `ShadowKind` only via that one shared loop,
142
156
  never through a per-block `scope.withShadow` call here. */
143
157
  withShadow: scope.withShadow,
158
+ /* Whether `name` is currently shadowed by a block binding (either kind) at this point
159
+ of the walk — the live query classification sites (e.g. `awaitSubjectExpr`) consult
160
+ so a binder that shadows a component cell name is lowered as the local it is, never
161
+ wrapped as the cell it hides. */
162
+ isShadowed: (name: string): boolean =>
163
+ scope.names('plain').has(name) || scope.names('derived').has(name),
144
164
  bindRead,
145
165
  bindWrite,
146
166
  }
@@ -91,19 +91,10 @@ export function lowerScript(
91
91
  derivedNames: Set<string>
92
92
  computedNames: Set<string>
93
93
  cellReadNames: Set<string>
94
- /* Template-injected + script-level `await` cells (ADR-0042), read via `$$readCellBlocking`. */
95
- blockingCellNames: Set<string>
96
94
  droppedReactiveImports: Set<string>
97
95
  } {
98
96
  const source = ts.createSourceFile('component.ts', scriptBody, ts.ScriptTarget.Latest, true)
99
- const {
100
- transformer,
101
- stateNames,
102
- derivedNames,
103
- computedNames,
104
- cellReadNames,
105
- blockingCellNames: allBlockingNames,
106
- } = desugarSignals(
97
+ const { transformer, stateNames, derivedNames, computedNames, cellReadNames } = desugarSignals(
107
98
  source,
108
99
  injectedCellNames,
109
100
  blockingCellNames,
@@ -194,7 +185,6 @@ export function lowerScript(
194
185
  derivedNames,
195
186
  computedNames,
196
187
  cellReadNames,
197
- blockingCellNames: allBlockingNames,
198
188
  droppedReactiveImports,
199
189
  }
200
190
  }
@@ -54,12 +54,10 @@ export function signalRefsTransformer(
54
54
  blockLocalPlain: ReadonlySet<string> = new Set(),
55
55
  /* Component signals read through `$$readCell(name)` — every `linked` and every async
56
56
  `computed` (see `desugarSignals`). A nearer lexical binding of the same name shadows
57
- them like any other signal. */
57
+ them like any other signal. Whether a read PAUSES (blocking `await` cell) or peeks
58
+ (streaming) is decided at runtime by the cell itself (`readCell` consults `cell.blocking`),
59
+ so there is one read form here for every cell and nothing site-specific to thread. */
58
60
  cellReadNames: ReadonlySet<string> = new Set(),
59
- /* The subset of `cellReadNames` that are BLOCKING `await` cells (ADR-0042), read through
60
- `$$readCellBlocking(name)` (suspend-on-pending) instead of `$$readCell`. Non-empty only on
61
- the CLIENT template lowering; the script pass and SSR keep it empty (`$$readCell`). */
62
- blockingCellNames: ReadonlySet<string> = new Set(),
63
61
  ): ts.TransformerFactory<ts.SourceFile> {
64
62
  /* The signal names that a nested binding can shadow — only these matter for
65
63
  scope tracking, so we ignore every other local binding. */
@@ -160,7 +158,6 @@ export function signalRefsTransformer(
160
158
  computedNames,
161
159
  blockLocal,
162
160
  cellReadNames,
163
- blockingCellNames,
164
161
  )
165
162
  if (replacement !== undefined) {
166
163
  return ts.factory.createPropertyAssignment(node.name.text, replacement)
@@ -174,7 +171,6 @@ export function signalRefsTransformer(
174
171
  computedNames,
175
172
  blockLocal,
176
173
  cellReadNames,
177
- blockingCellNames,
178
174
  )
179
175
  if (replacement !== undefined) {
180
176
  return replacement
@@ -393,7 +389,6 @@ function referenceFor(
393
389
  computedNames: ReadonlySet<string>,
394
390
  blockLocal: ReadonlySet<string> = new Set(),
395
391
  cellReadNames: ReadonlySet<string> = new Set(),
396
- blockingCellNames: ReadonlySet<string> = new Set(),
397
392
  ): ts.Expression | undefined {
398
393
  if (blockLocal.has(name)) {
399
394
  return ts.factory.createPropertyAccessExpression(ts.factory.createIdentifier(name), 'value')
@@ -403,19 +398,10 @@ function referenceFor(
403
398
  if (name === 'scope') {
404
399
  return ts.factory.createIdentifier('$$scope')
405
400
  }
406
- /* A BLOCKING `await` cell → `$$readCellBlocking(name)` (ADR-0042): the throwing peek that
407
- SUSPENDS the render region while pending, so an `await` binding reads as a resolved value
408
- and its region withholds until it settles. Client template lowering only — `blockingCellNames`
409
- is empty in the script pass and SSR, which keep the plain `$$readCell` peek below. */
410
- if (blockingCellNames.has(name)) {
411
- return ts.factory.createCallExpression(
412
- ts.factory.createIdentifier('$$readCellBlocking'),
413
- undefined,
414
- [ts.factory.createIdentifier(name)],
415
- )
416
- }
417
- /* A `linked` / async `computed` cell → `$$readCell(name)`: one read shape that peeks an
418
- async cell and reads `.value` off a sync one. */
401
+ /* A `linked` / async `computed` cell → `$$readCell(name)`: ONE read shape for every cell,
402
+ script and template. It peeks a sync cell's `.value`, and for an async cell decides at
403
+ runtime whether to pause (blocking `await`) or peek `undefined` (streaming) by the cell's
404
+ own `blocking` bit — so no blocking/streaming distinction is threaded through codegen. */
419
405
  if (cellReadNames.has(name)) {
420
406
  return ts.factory.createCallExpression(
421
407
  ts.factory.createIdentifier('$$readCell'),
@@ -41,9 +41,9 @@ export function seedTypeClassifierForRoot(
41
41
  return undefined
42
42
  }
43
43
  const checker = program.getTypeChecker()
44
+ const mappings = shadows.get(abidePath)?.mappings ?? []
44
45
  return (loc, code) => {
45
46
  try {
46
- const mappings = shadows.get(abidePath)?.mappings ?? []
47
47
  const offset = sourceToShadowOffset(mappings, loc)
48
48
  if (offset === undefined) {
49
49
  return undefined
@@ -28,9 +28,9 @@ export function shadowInterpolationClassifier(
28
28
  return undefined
29
29
  }
30
30
  const checker = program.getTypeChecker()
31
+ const mappings = shadows.get(abidePath)?.mappings ?? []
31
32
  return (loc, code) => {
32
33
  try {
33
- const mappings = shadows.get(abidePath)?.mappings ?? []
34
34
  const offset = sourceToShadowOffset(mappings, loc)
35
35
  if (offset === undefined) {
36
36
  return 'sync'
@@ -26,12 +26,10 @@ export type AnalyzedComponent = {
26
26
  computedNames: Set<string>
27
27
  /* Component signals read through `$$readCell(name)`: every `linked` and every async
28
28
  `computed`. Threaded to both back-ends so a template reference (`{draft}`) lowers to
29
- the unified cell read consistently on client and server. */
29
+ the unified cell read consistently on client and server. Whether a pending read pauses
30
+ (blocking `await`) or peeks (streaming) is decided at runtime by the cell's own
31
+ `blocking` bit (ADR-0042), so no blocking subset is threaded through codegen. */
30
32
  cellReadNames: Set<string>
31
- /* The subset of `cellReadNames` that are BLOCKING `await` cells (ADR-0042): a template-injected
32
- `{await X}` cell or a script `computed(await …)`. The CLIENT back-end reads these via
33
- `$$readCellBlocking` (suspend-on-pending) rather than `$$readCell`. */
34
- blockingCellNames: Set<string>
35
33
  nodes: TemplateNode[]
36
34
  /* One entry per non-empty `<style>` in the template (in source order): the scope
37
35
  attribute its covered elements carry (annotated onto `nodes`) and the scoped
@@ -0,0 +1,44 @@
1
+ import ts from 'typescript'
2
+ import { NESTED_REACTIVE_BINDINGS, resolveReactiveExport } from './resolveReactiveExport.ts'
3
+ import { wrapSeed } from './wrapSeed.ts'
4
+
5
+ /*
6
+ Normalises every `state.computed(seed)` / `state.linked(seed)` in a nested branch
7
+ `<script>` so a BARE-VALUE seed becomes a thunk (`state.computed(a * 2)` →
8
+ `state.computed(() => a * 2)`), exactly as the top-level desugar (`desugarSignals` via
9
+ `wrapSeed`) does. A nested script keeps its reactive calls literal — it is not desugared to
10
+ the doc — so absent this the bare value reaches the runtime primitive, which stores it as
11
+ the compute function and calls it on read (`a * 2 is not a function`, a runtime crash). A
12
+ literal `() => …` / `function` thunk passes through unchanged, so the wrap is a no-op on the
13
+ already-thunked form the author usually writes.
14
+
15
+ Recognition is name-based (`NESTED_REACTIVE_BINDINGS`) because a nested script carries no
16
+ import of its own — it inherits the surface by the canonical `state` name, the same way
17
+ `nestedBindingNames` resolves it. Only the seed argument (`arguments[0]`) is wrapped; a
18
+ `state.linked(seed, ...)`'s trailing args pass through. Exposed as a `ts.TransformerFactory`
19
+ so the nested-script lowering can chain it ahead of the reference rename over ONE parsed tree.
20
+ */
21
+ export function wrapReactiveSeedsTransformer(): ts.TransformerFactory<ts.SourceFile> {
22
+ return (context) => (root) => {
23
+ const visit = (node: ts.Node): ts.Node => {
24
+ if (ts.isCallExpression(node) && node.arguments.length > 0) {
25
+ const primitive = resolveReactiveExport(node.expression, NESTED_REACTIVE_BINDINGS)
26
+ if (primitive === 'computed' || primitive === 'linked') {
27
+ const seed = node.arguments[0] as ts.Expression
28
+ const wrapped = wrapSeed(ts.visitNode(seed, visit) as ts.Expression)
29
+ const rest = node.arguments
30
+ .slice(1)
31
+ .map((argument) => ts.visitNode(argument, visit) as ts.Expression)
32
+ return ts.factory.updateCallExpression(
33
+ node,
34
+ node.expression,
35
+ node.typeArguments,
36
+ [wrapped, ...rest],
37
+ )
38
+ }
39
+ }
40
+ return ts.visitEachChild(node, visit, context)
41
+ }
42
+ return ts.visitNode(root, visit) as ts.SourceFile
43
+ }
44
+ }
@@ -0,0 +1,28 @@
1
+ import ts from 'typescript'
2
+ import { hasTopLevelAwait } from './hasTopLevelAwait.ts'
3
+
4
+ const factory = ts.factory
5
+
6
+ /* Normalises a `computed`/`linked` argument into a seed THUNK: a literal `() => …` /
7
+ `function` argument passes through unchanged (the author already wrote the thunk), any
8
+ other expression is wrapped as `() => arg`. The wrapper arrow is made ASYNC when the
9
+ expression contains a top-level `await` (`computed(await load())` → `async () => await
10
+ load()`), which is exactly the marker the runtime primitive uses to route to an async cell.
11
+ Shared by the top-level desugar (`desugarSignals`) and the nested-`<script>` lowering
12
+ (`wrapReactiveSeeds`) so a bare-value seed is normalised identically on both surfaces. */
13
+ export function wrapSeed(argument: ts.Expression): ts.Expression {
14
+ if (ts.isArrowFunction(argument) || ts.isFunctionExpression(argument)) {
15
+ return argument
16
+ }
17
+ const modifiers = hasTopLevelAwait(argument)
18
+ ? [factory.createModifier(ts.SyntaxKind.AsyncKeyword)]
19
+ : undefined
20
+ return factory.createArrowFunction(
21
+ modifiers,
22
+ undefined,
23
+ [],
24
+ undefined,
25
+ factory.createToken(ts.SyntaxKind.EqualsGreaterThanToken),
26
+ argument,
27
+ )
28
+ }
@@ -5,6 +5,7 @@ import { createComputedNode } from './runtime/createComputedNode.ts'
5
5
  import { isAsyncFunction } from './runtime/isAsyncFunction.ts'
6
6
  import { OWNER } from './runtime/OWNER.ts'
7
7
  import { readNode } from './runtime/readNode.ts'
8
+ import { routeBareSeed } from './runtime/routeBareSeed.ts'
8
9
  import type { Computed } from './runtime/types/Computed.ts'
9
10
  import { unlinkDeps } from './runtime/unlinkDeps.ts'
10
11
 
@@ -32,6 +33,21 @@ export function computed<T>(compute: () => T): Computed<T>
32
33
  export function computed<T>(
33
34
  compute: () => T | Promise<T> | NamedAsyncIterable<T>,
34
35
  ): Computed<T> | AsyncComputed<T> {
36
+ /* A BARE seed that reached the runtime literally — a branch-nested `<script>` (which
37
+ keeps its `state.computed(...)` calls literal; see lowerScript) or a direct JS caller.
38
+ The compile-time `wrapSeed` normalization only covers the leading script, so honor the
39
+ same authored contract (ADR-0045: a bare seed behaves like its thunk form) by VALUE via
40
+ the shared `routeBareSeed`; a non-async value becomes a constant computed over it.
41
+ Without this a raw value lands as `node.compute` and the first read calls it
42
+ (`node.compute is 3` — a dead render mid-stream). */
43
+ if (typeof compute !== 'function') {
44
+ const seed: unknown = compute
45
+ const cell = routeBareSeed(seed, false)
46
+ if (cell !== undefined) {
47
+ return cell as AsyncComputed<T>
48
+ }
49
+ compute = () => seed as T
50
+ }
35
51
  /* `await` marker: an async-function thunk unwraps its promise into a tracked async cell.
36
52
  This is the only classification the read-only primitive can make without running the
37
53
  seed — a sync `computed` MUST stay lazy (it does not compute until first read). A stream
@@ -4,6 +4,7 @@ import { computed } from './computed.ts'
4
4
  import { effect } from './effect.ts'
5
5
  import { linked } from './linked.ts'
6
6
  import { CURRENT_PATH } from './runtime/CURRENT_PATH.ts'
7
+ import { consumeSeed } from './runtime/consumeSeed.ts'
7
8
  import { createDoc } from './runtime/createDoc.ts'
8
9
  import { DOC_SEED } from './runtime/DOC_SEED.ts'
9
10
  import type { Cell } from './runtime/types/Cell.ts'
@@ -50,13 +51,13 @@ export function createScope(
50
51
  on the client, so a uuid/timestamp/random would diverge from the SSR HTML. The server snapshot
51
52
  for this scope's render-path id is decoded here and consumed by `replace` on each slot's FIRST
52
53
  write (the eager init), keeping the server value while the throwaway init is discarded. One-shot
53
- — deleted on read so an SPA re-nav to the same path re-inits fresh; empty on the server. */
54
+ via `consumeSeed` (two-phase, ADR-0048) — an SPA re-nav to the same path re-inits fresh, and a
55
+ hydration-desync throw hands the seed to the cold rebuild; empty on the server. */
54
56
  let pendingSeed: Map<string, unknown> | undefined
55
57
  /* `DOC_SEED` is populated only on the client by `startClient` (empty on the server), so the read
56
58
  needs no window guard — it mirrors the async-cell warm read. */
57
- const encodedSeed = DOC_SEED[id]
59
+ const encodedSeed = consumeSeed(DOC_SEED, id)
58
60
  if (encodedSeed !== undefined) {
59
- delete DOC_SEED[id]
60
61
  try {
61
62
  pendingSeed = new Map(
62
63
  Object.entries(decodeRefJson(encodedSeed) as Record<string, unknown>),
@@ -1,3 +1,4 @@
1
+ import { parkCursor } from '../runtime/parkCursor.ts'
1
2
  import { RENDER } from '../runtime/RENDER.ts'
2
3
 
3
4
  /*
@@ -18,7 +19,7 @@ export function anchorCursor(anchor: Node): Node | null {
18
19
  const reference = anchor.nextSibling
19
20
  const hydration = RENDER.hydration
20
21
  if (hydration !== undefined) {
21
- hydration.next.set(anchor.parentNode as Node, reference)
22
+ parkCursor(hydration, anchor.parentNode as Node, reference)
22
23
  }
23
24
  return reference
24
25
  }
@@ -19,9 +19,10 @@ the accidental copy-paste, notably its two subtle guards:
19
19
  rethrowing, so the caller's cold rebuild starts clean).
20
20
 
21
21
  `anchor` is a mutable field: each block parks its own anchor text node at create /
22
- hydrate / rebuild time (placement is bespoke), and `place` reads it. The block owns its
23
- `scopeGroup`; each built branch's disposer is tracked in it so it disposes on owner
24
- teardown, not only on a swap.
22
+ hydrate / rebuild time via `parkAnchor` (WHERE it parks is bespoke — before the boundary
23
+ close, after a discarded boundary, at the create-mode `before`), and `place` reads it. The
24
+ block owns its `scopeGroup`; each built branch's disposer is tracked in it so it disposes
25
+ on owner teardown, not only on a swap.
25
26
  */
26
27
  export function anchoredBranch(
27
28
  parent: Node,
@@ -29,6 +30,7 @@ export function anchoredBranch(
29
30
  ): {
30
31
  anchor: Node | undefined
31
32
  detach: () => void
33
+ parkAnchor: (before: Node | null) => void
32
34
  place: (build: (host: Node) => void, wrapBuild?: (run: () => void) => void) => void
33
35
  adoptStrand: (
34
36
  build: (host: Node) => void,
@@ -128,6 +130,14 @@ export function anchoredBranch(
128
130
  }
129
131
  }
130
132
 
133
+ /* Park a fresh empty text node as the branch's swap anchor at `before` (null appends) —
134
+ the create/hydrate/rebuild triple every block repeated by hand. */
135
+ const parkAnchor = (before: Node | null): void => {
136
+ const node = document.createTextNode('')
137
+ anchor = node
138
+ parent.insertBefore(node, before)
139
+ }
140
+
131
141
  return {
132
142
  get anchor(): Node | undefined {
133
143
  return anchor
@@ -136,6 +146,7 @@ export function anchoredBranch(
136
146
  anchor = node
137
147
  },
138
148
  detach,
149
+ parkAnchor,
139
150
  place,
140
151
  adoptStrand,
141
152
  }
@@ -1,34 +1,25 @@
1
1
  import { claimChild } from '../runtime/claimChild.ts'
2
+ import { parkCursor } from '../runtime/parkCursor.ts'
2
3
  import { RENDER } from '../runtime/RENDER.ts'
3
- import { assertClaimedText } from './assertClaimedText.ts'
4
+ import { claimText } from './claimText.ts'
4
5
 
5
6
  /*
6
7
  A static (non-reactive) text node under `parent`: created and appended (create
7
- mode), or claimed from the server-rendered text (hydrate mode). As with reactive
8
- text, a merged SSR text node is split at this literal's length so the next claim
9
- lines up; nothing is bound since the text never changes.
8
+ mode), or claimed from the server-rendered text (hydrate mode) — the shared
9
+ `claimText` asserts, splits the merged SSR node at this literal's length so the
10
+ next claim lines up, and advances. Nothing is bound since the text never changes.
11
+ A static always renders on the server, so a non-text node at the cursor is a
12
+ tolerated desync: advance past it (assertClaimedText's domain is a text mismatch;
13
+ a structural one is claimExpected's).
10
14
  */
11
15
  // @documentation plumbing
12
16
  export function appendStatic(parent: Node, value: string, splitAlways = false): void {
13
17
  const hydration = RENDER.hydration
14
18
  if (hydration !== undefined) {
15
- const node = claimChild(hydration, parent) as unknown as Text
16
- const isText = node !== null && typeof node.splitText === 'function'
17
- /* The claimed SSR node must begin with this literal, or the split misaligns — throw
18
- legibly at the divergence (only when a text node is actually here; a structural
19
- mismatch is claimExpected's domain). */
20
- if (isText) {
21
- assertClaimedText(node, value)
19
+ if (claimText(hydration, parent, value, splitAlways) === undefined) {
20
+ const node = claimChild(hydration, parent)
21
+ parkCursor(hydration, parent, node === null ? null : node.nextSibling)
22
22
  }
23
- /* Split even on an exact-length consume when a sibling text binding follows
24
- (`splitAlways`), so it gets its own node; see appendText for the rationale. */
25
- if (
26
- isText &&
27
- (splitAlways ? value.length <= node.data.length : value.length < node.data.length)
28
- ) {
29
- node.splitText(value.length)
30
- }
31
- hydration.next.set(parent, node === null ? null : node.nextSibling)
32
23
  return
33
24
  }
34
25
  parent.appendChild(document.createTextNode(value))
@@ -2,10 +2,11 @@ import { rawHtmlString } from '../../shared/html.ts'
2
2
  import { snippetPayload } from '../../shared/snippet.ts'
3
3
  import { effect } from '../effect.ts'
4
4
  import { claimChild } from '../runtime/claimChild.ts'
5
+ import { parkCursor } from '../runtime/parkCursor.ts'
5
6
  import { RENDER } from '../runtime/RENDER.ts'
6
7
  import { SuspenseSignal } from '../runtime/SuspenseSignal.ts'
7
8
  import { appendSnippet } from './appendSnippet.ts'
8
- import { assertClaimedText } from './assertClaimedText.ts'
9
+ import { claimText } from './claimText.ts'
9
10
  import { isComment } from './isComment.ts'
10
11
  import { parseRawNodes } from './parseRawNodes.ts'
11
12
  import { readTextOrSuspend } from './readTextOrSuspend.ts'
@@ -34,7 +35,15 @@ cold client render (on hydrate the warm-seed makes the cell `refreshing()`, not
34
35
  decided from the first resolved value.
35
36
  */
36
37
  // @documentation plumbing
37
- export function appendText(parent: Node, read: () => unknown, splitAlways = false): void {
38
+ export function appendText(
39
+ parent: Node,
40
+ read: () => unknown,
41
+ splitAlways = false,
42
+ /* Create-mode insertion reference: content lands before this node instead of at the end of
43
+ `parent` (`null` appends). Lets `appendTextAt` mount at a skeleton anchor through this one
44
+ create path. Ignored on hydrate, where the cursor decides position. */
45
+ before: Node | null = null,
46
+ ): void {
38
47
  /* Probe the first value once, tolerating a suspend (a pending blocking read) — a suspended
39
48
  interpolation skips snippet/html detection and takes the text path, starting empty. */
40
49
  let probe: unknown
@@ -50,62 +59,51 @@ export function appendText(parent: Node, read: () => unknown, splitAlways = fals
50
59
  /* A snippet call (`{row(args)}`) mounts its builder; a `html\`\`` value inserts
51
60
  raw markup; everything else is escaped text — decided by the first value. */
52
61
  if (!suspended && typeof snippetPayload(probe) === 'function') {
53
- appendSnippet(parent, read)
62
+ if (before === null) {
63
+ appendSnippet(parent, read)
64
+ return
65
+ }
66
+ /* Positioned mount: buffer the snippet in a fragment, then splice it in at `before` —
67
+ `appendSnippet` itself only appends, and its markers track position from then on. */
68
+ const fragment = document.createDocumentFragment()
69
+ appendSnippet(fragment, read)
70
+ parent.insertBefore(fragment, before)
54
71
  return
55
72
  }
56
73
  if (!suspended && rawHtmlString(probe) !== undefined) {
57
- appendRawHtml(parent, read)
74
+ appendRawHtml(parent, read, before)
58
75
  return
59
76
  }
60
77
  const hydration = RENDER.hydration
61
78
  if (hydration !== undefined) {
62
- const claimed = claimChild(hydration, parent)
63
79
  /* Nullish reads render as empty text, never the literal `"undefined"` — so a
64
80
  pending async read (undefined-while-pending, ADR-0032 D3) shows nothing. A blocking
65
81
  read never suspends here (warm-seeded → `refreshing()`), so `probe` holds its value. */
66
82
  const firstValue = probe
67
83
  const value = firstValue == null ? '' : String(firstValue)
68
- /* A value that first rendered empty produced NO server text node, so the cursor
69
- points at the following node (an element/comment) or past the end (null) — not a
70
- text node to claim. Bind to a Text node either way: claim the merged SSR node when
71
- one is here, else synthesize an empty one at the cursor and leave the claimed node
72
- for the next consumer (a following element hole, a sibling binding, or nothing).
73
- Without this the bind effect below derefs a null/element `node`. A text node is
74
- detected by `splitText` (not `nodeType`), so the test mini-dom is covered too. */
75
- const isText = claimed !== null && typeof (claimed as Text).splitText === 'function'
76
- const node = (
77
- isText ? claimed : parent.insertBefore(document.createTextNode(''), claimed)
78
- ) as Text
79
- /* The claimed SSR node must begin with this binding's value, or the split below
80
- lands mid-run and orphans the tail — throw legibly at the divergence instead. A
81
- synthesized empty node is this binding's own, so there's nothing to disagree. */
82
- if (isText) {
83
- assertClaimedText(node, value)
84
- }
85
- /* Peel this binding's text off the merged SSR node. A non-final binding in a
86
- run (`splitAlways`) splits even when it consumes the whole node, leaving an
87
- empty node for the next binding — otherwise an interpolation that renders to
88
- empty string (or whose followers do) has no node and the next claim grabs the
89
- wrong sibling. The final binding keeps `<` so it doesn't leave a stray node a
90
- following element would claim. A synthesized node is already this binding's own,
91
- so it never splits. */
92
- if (
93
- isText &&
94
- (splitAlways ? value.length <= node.data.length : value.length < node.data.length)
95
- ) {
96
- node.splitText(value.length)
84
+ /* Claim this binding's portion of the merged SSR text node (assert + split +
85
+ advance — see `claimText`). A value that first rendered empty produced NO server
86
+ text node, so the cursor points at the following node (an element/comment) or past
87
+ the end — the miss arm: bind a synthesized empty Text at the cursor and leave the
88
+ unclaimed node for the next consumer (a following element hole, a sibling binding,
89
+ or nothing). Without it the bind effect below derefs a null/element node. */
90
+ let node = claimText(hydration, parent, value, splitAlways)
91
+ if (node === undefined) {
92
+ const unclaimed = claimChild(hydration, parent)
93
+ node = parent.insertBefore(document.createTextNode(''), unclaimed)
94
+ /* Pin the cursor on the still-unclaimed node — the synthesized node is this
95
+ binding's own, and an unset cursor would default back to the (now synthesized)
96
+ first child. */
97
+ parkCursor(hydration, parent, unclaimed)
97
98
  }
98
- /* Advance past the claimed text node; for a synthesized node leave the cursor on the
99
- still-unclaimed `claimed` node it was inserted before (an element/comment, or null
100
- at the end). */
101
- hydration.next.set(parent, isText ? node.nextSibling : claimed)
99
+ const bound = node
102
100
  effect(() => {
103
- node.data = readTextOrSuspend(read)
101
+ bound.data = readTextOrSuspend(read)
104
102
  })
105
103
  return
106
104
  }
107
105
  const node = document.createTextNode('')
108
- parent.appendChild(node)
106
+ parent.insertBefore(node, before)
109
107
  effect(() => {
110
108
  node.data = readTextOrSuspend(read)
111
109
  })
@@ -113,7 +111,7 @@ export function appendText(parent: Node, read: () => unknown, splitAlways = fals
113
111
 
114
112
  /* Raw-markup interpolation: parse the branded string into nodes behind an anchor,
115
113
  re-parsing on change; on hydrate adopt the server markup between its markers. */
116
- function appendRawHtml(parent: Node, read: () => unknown): void {
114
+ function appendRawHtml(parent: Node, read: () => unknown, before: Node | null): void {
117
115
  const hydration = RENDER.hydration
118
116
  const markup = (): string => rawHtmlString(read()) ?? ''
119
117
  const anchor = document.createTextNode('')
@@ -141,7 +139,7 @@ function appendRawHtml(parent: Node, read: () => unknown): void {
141
139
  nodes.push(node)
142
140
  node = node.nextSibling
143
141
  }
144
- hydration.next.set(parent, node === null ? null : node.nextSibling)
142
+ parkCursor(hydration, parent, node === null ? null : node.nextSibling)
145
143
  parent.insertBefore(anchor, node)
146
144
  let first = true
147
145
  effect(() => {
@@ -155,7 +153,7 @@ function appendRawHtml(parent: Node, read: () => unknown): void {
155
153
  return
156
154
  }
157
155
 
158
- parent.appendChild(anchor)
156
+ parent.insertBefore(anchor, before)
159
157
  effect(() => {
160
158
  set(markup())
161
159
  })