@abide/abide 0.51.0 → 0.52.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 (58) hide show
  1. package/AGENTS.md +509 -525
  2. package/CHANGELOG.md +29 -0
  3. package/README.md +128 -157
  4. package/package.json +4 -1
  5. package/src/abideResolverPlugin.ts +12 -0
  6. package/src/checkAbide.ts +60 -7
  7. package/src/lib/cli/completeCli.ts +38 -0
  8. package/src/lib/cli/printTopLevelHelp.ts +1 -0
  9. package/src/lib/cli/renderCliCompletions.ts +56 -0
  10. package/src/lib/cli/runCli.ts +27 -0
  11. package/src/lib/server/render.ts +70 -0
  12. package/src/lib/server/runtime/cacheStalenessBroadcaster.ts +33 -0
  13. package/src/lib/server/runtime/createServer.ts +43 -0
  14. package/src/lib/server/runtime/createUiPageRenderer.ts +37 -0
  15. package/src/lib/server/runtime/pageRenderSlot.ts +15 -0
  16. package/src/lib/server/runtime/runWithRequestScope.ts +1 -0
  17. package/src/lib/server/runtime/types/RequestStore.ts +9 -0
  18. package/src/lib/server/sockets/createSocketDispatcher.ts +9 -0
  19. package/src/lib/server/sockets/registerSocket.ts +12 -0
  20. package/src/lib/shared/CACHE_STALENESS_SOCKET.ts +8 -0
  21. package/src/lib/shared/RESERVED_SOCKET_PREFIX.ts +8 -0
  22. package/src/lib/shared/applyCacheStalenessLocally.ts +18 -0
  23. package/src/lib/shared/attachRpcSelectorMethods.ts +3 -1
  24. package/src/lib/shared/cache.ts +32 -4
  25. package/src/lib/shared/cacheStalenessSlot.ts +21 -0
  26. package/src/lib/shared/createRpcServerProgram.ts +187 -0
  27. package/src/lib/shared/docSnapshotsSlot.ts +13 -0
  28. package/src/lib/shared/invalidate.ts +39 -0
  29. package/src/lib/shared/matcherFromEnvelope.ts +31 -0
  30. package/src/lib/shared/prepareRpcModule.ts +22 -3
  31. package/src/lib/shared/refresh.ts +9 -2
  32. package/src/lib/shared/selectorMatcher.ts +2 -15
  33. package/src/lib/shared/serializeSelector.ts +69 -0
  34. package/src/lib/shared/setsIntersect.ts +15 -0
  35. package/src/lib/shared/types/CacheStalenessApply.ts +13 -0
  36. package/src/lib/shared/types/CacheStalenessFrame.ts +24 -0
  37. package/src/lib/shared/types/DocSnapshots.ts +12 -0
  38. package/src/lib/shared/types/RemoteFunction.ts +11 -8
  39. package/src/lib/shared/types/RpcBuildStamps.ts +9 -0
  40. package/src/lib/shared/types/SsrPayload.ts +5 -0
  41. package/src/lib/test/createTestApp.ts +15 -0
  42. package/src/lib/ui/compile/COMPOUND_ASSIGNMENT_OPERATORS.ts +19 -0
  43. package/src/lib/ui/compile/UI_RUNTIME_IMPORTS.ts +1 -0
  44. package/src/lib/ui/compile/compileShadow.ts +92 -14
  45. package/src/lib/ui/compile/lowerDocAccess.ts +2 -14
  46. package/src/lib/ui/compile/lowerScript.ts +6 -1
  47. package/src/lib/ui/compile/renameSignalRefs.ts +65 -0
  48. package/src/lib/ui/compile/wrapReactionCellSources.ts +32 -0
  49. package/src/lib/ui/createScope.ts +42 -1
  50. package/src/lib/ui/dom/assertClaimedText.ts +14 -4
  51. package/src/lib/ui/dom/attr.ts +25 -2
  52. package/src/lib/ui/dom/writeCell.ts +22 -0
  53. package/src/lib/ui/runtime/DOC_SEED.ts +11 -0
  54. package/src/lib/ui/runtime/claimExpected.ts +6 -1
  55. package/src/lib/ui/runtime/reportHydrationDivergence.ts +30 -0
  56. package/src/lib/ui/startClient.ts +22 -1
  57. package/src/lib/ui/subscribeCacheStaleness.ts +85 -0
  58. package/src/serverEntry.ts +12 -0
@@ -0,0 +1,19 @@
1
+ import ts from 'typescript'
2
+
3
+ /*
4
+ Maps a compound-assignment operator to its plain binary counterpart, for lowering
5
+ `x += y` into an unconditional read-combine-write. Logical assignments
6
+ (`||=`/`&&=`/`??=`) lower the same way — the patch/cell write always fires,
7
+ consistent with how `+=` lowers. Shared by lowerDocAccess (the `$$model` write path)
8
+ and renameSignalRefs (the `$$writeCell` linked-cell path) so the two lowerings can't
9
+ drift.
10
+ */
11
+ export const COMPOUND_ASSIGNMENT_OPERATORS = new Map<ts.SyntaxKind, ts.BinaryOperator>([
12
+ [ts.SyntaxKind.PlusEqualsToken, ts.SyntaxKind.PlusToken],
13
+ [ts.SyntaxKind.MinusEqualsToken, ts.SyntaxKind.MinusToken],
14
+ [ts.SyntaxKind.AsteriskEqualsToken, ts.SyntaxKind.AsteriskToken],
15
+ [ts.SyntaxKind.SlashEqualsToken, ts.SyntaxKind.SlashToken],
16
+ [ts.SyntaxKind.BarBarEqualsToken, ts.SyntaxKind.BarBarToken],
17
+ [ts.SyntaxKind.AmpersandAmpersandEqualsToken, ts.SyntaxKind.AmpersandAmpersandToken],
18
+ [ts.SyntaxKind.QuestionQuestionEqualsToken, ts.SyntaxKind.QuestionQuestionToken],
19
+ ])
@@ -56,6 +56,7 @@ export const UI_RUNTIME_IMPORTS: { name: string; specifier: string; alias?: stri
56
56
  { name: 'spreadAttrs', specifier: 'ui/dom/spreadAttrs', alias: '$$spreadAttrs' },
57
57
  { name: 'readCall', specifier: 'ui/dom/readCall', alias: '$$readCall' },
58
58
  { name: 'readCell', specifier: 'ui/dom/readCell', alias: '$$readCell' },
59
+ { name: 'writeCell', specifier: 'ui/dom/writeCell', alias: '$$writeCell' },
59
60
  { name: 'cellPending', specifier: 'ui/dom/cellPending', alias: '$$cellPending' },
60
61
  { name: 'withPath', specifier: 'ui/runtime/withPath', alias: '$$withPath' },
61
62
  { name: 'settleAsyncCells', specifier: 'ui/settleAsyncCells', alias: '$$settleAsyncCells' },
@@ -94,6 +94,14 @@ export function compileShadow(
94
94
  watchLocalName,
95
95
  } = analyzeScript(scriptBody, scriptStart)
96
96
  builder.raw(shadowPreamble(importedReactives))
97
+ /* Every `computed`/`linked` scope line reads through this: it invokes the seed thunk and
98
+ unwraps a promise/stream to the value a BARE cell read peeks (ADR-0019/0032) — so an async
99
+ `computed(await …)` / stream `computed(src)` type-checks as its resolved value, and a sync
100
+ seed keeps its return type (`Awaited` is identity on a non-thenable). Reserved `$$` name so
101
+ it can never collide with an author binding; `declare`-only, so it carries no runtime. */
102
+ builder.raw(
103
+ 'declare function $$cellValue<R>(seed: () => R): R extends AsyncIterable<infer F> ? F : Awaited<R>\n',
104
+ )
97
105
  /* The peek helpers the async-interpolation wrap targets (ADR-0032): `$$peek` unwraps a promise
98
106
  sub-expression to its resolved value (`undefined` while pending) and `$$peekStream` reads an
99
107
  async iterable's latest frame, so `getFoo()?.name`/`{#if getFoo()}` type-check against the
@@ -395,27 +403,38 @@ function collectReservedNameDiagnostics(
395
403
  top-level await transpiles to `await` in a non-async function and breaks the bundle; the
396
404
  shadow's render fn is async, so `tsc` alone never flags it (a check/runtime parity gap).
397
405
  Stops descending at function boundaries — their own async-ness is the author's concern —
398
- and catches both `await expr` and `for await (… of …)`, flagging the `await` keyword. */
406
+ and catches both `await expr` and `for await (… of …)`, flagging the `await` keyword. A
407
+ `computed`/`linked` SEED argument is exempt: `wrapSeed` (desugarSignals) lowers a top-level
408
+ `await` there into an async thunk (`computed(await load())` → `async () => await load()`), so
409
+ its await runs off the sync build — flagging it would be a false positive. */
399
410
  function collectTopLevelAwaitDiagnostics(
400
411
  file: ts.SourceFile,
401
412
  scriptStart: number,
402
413
  diagnostics: ShadowDiagnostic[],
414
+ bindings: ReactiveImportBindings,
403
415
  ): void {
404
416
  const message =
405
417
  'top-level `await` is not allowed in a `<script>` — the component build runs synchronously. Use `{#await expr}…{:then value}…{/await}` markup for blocking data, or `await` inside an async event handler.'
406
418
  const visit = (node: ts.Node): void => {
407
419
  /* A nested function introduces its own (possibly async) scope; its awaits are legal. */
408
- if (
409
- ts.isFunctionDeclaration(node) ||
410
- ts.isFunctionExpression(node) ||
411
- ts.isArrowFunction(node) ||
412
- ts.isMethodDeclaration(node) ||
413
- ts.isGetAccessorDeclaration(node) ||
414
- ts.isSetAccessorDeclaration(node) ||
415
- ts.isConstructorDeclaration(node)
416
- ) {
420
+ if (isFunctionScopeBoundary(node)) {
417
421
  return
418
422
  }
423
+ /* A `computed`/`linked` seed becomes an async thunk (`wrapSeed`), so an await in it is
424
+ legal — visit the callee and any trailing args, but skip the seed (`arguments[0]`). */
425
+ if (ts.isCallExpression(node) && node.arguments.length > 0) {
426
+ const callee = resolveReactiveExport(node.expression, bindings)
427
+ if (callee === 'computed' || callee === 'linked') {
428
+ visit(node.expression)
429
+ for (let index = 1; index < node.arguments.length; index++) {
430
+ const argument = node.arguments[index]
431
+ if (argument !== undefined) {
432
+ visit(argument)
433
+ }
434
+ }
435
+ return
436
+ }
437
+ }
419
438
  /* The `await` keyword token: the AwaitExpression's first child, or a `for await`'s
420
439
  await modifier. */
421
440
  const keyword = ts.isAwaitExpression(node)
@@ -449,7 +468,8 @@ function collectNestedScriptAwaitDiagnostics(
449
468
  }
450
469
  if (node.kind === 'script' && node.loc !== undefined) {
451
470
  const file = ts.createSourceFile('nested.ts', node.code, ts.ScriptTarget.Latest, true)
452
- collectTopLevelAwaitDiagnostics(file, node.loc, diagnostics)
471
+ /* A nested script inherits the surface by canonical name (no imports of its own). */
472
+ collectTopLevelAwaitDiagnostics(file, node.loc, diagnostics, NESTED_REACTIVE_BINDINGS)
453
473
  }
454
474
  if ('children' in node) {
455
475
  collectNestedScriptAwaitDiagnostics(node.children, diagnostics)
@@ -503,7 +523,7 @@ function analyzeScript(scriptBody: string, scriptStart: number): ScriptAnalysis
503
523
  collectReservedNameDiagnostics(file, scriptStart, diagnostics)
504
524
  /* The leading script runs in the synchronous `build()`, so a top-level await breaks the
505
525
  bundle — catch it here with a legible message instead of an opaque transpile error. */
506
- collectTopLevelAwaitDiagnostics(file, scriptStart, diagnostics)
526
+ collectTopLevelAwaitDiagnostics(file, scriptStart, diagnostics, bindings)
507
527
  /* A verbatim span: original text + the segment mapping it back, relative to the
508
528
  line start (the caller rebases shadowStart onto the running shadow length). */
509
529
  const span = (node: ts.Node, prefixLength: number): ScopeLine['segments'][number] => ({
@@ -683,13 +703,71 @@ function scopeLineFor(
683
703
  segments: [span(declaration.name, keywordOffset)],
684
704
  })
685
705
  }
686
- const prefix = `${keyword} ${name}${annotation} = (`
706
+ /* Mirror `wrapSeed` (desugarSignals): a bare (non-thunk) seed is normalised to `() => (seed)`,
707
+ made ASYNC when it carries a top-level `await` — so `computed(await load())` projects as a
708
+ legal async thunk instead of a raw top-level await (`(await load())()` — a build-breaker that
709
+ also mis-called the resolved value). A literal thunk passes through unchanged. `$$cellValue`
710
+ then invokes it and unwraps a promise/stream to the value a bare read peeks, so an async or
711
+ stream seed types as its RESOLVED value (`Promise<T>` → `T`) rather than the raw promise. */
712
+ const fnIsThunk = ts.isArrowFunction(fn) || ts.isFunctionExpression(fn)
713
+ const wrapPrefix = fnIsThunk ? '' : seedIsAsync(fn) ? 'async () => (' : '() => ('
714
+ const wrapSuffix = fnIsThunk ? '' : ')'
715
+ const prefix = `${keyword} ${name}${annotation} = $$cellValue(${wrapPrefix}`
687
716
  return withCalleeRef({
688
- text: `${prefix}${verbatim(fn)})();`,
717
+ text: `${prefix}${verbatim(fn)}${wrapSuffix});`,
689
718
  segments: [span(declaration.name, keywordOffset), span(fn, prefix.length)],
690
719
  })
691
720
  }
692
721
 
722
+ /* True for a `computed`/`linked` seed that `wrapSeed` (desugarSignals) turns into an ASYNC thunk —
723
+ a thunk the author wrote `async`, or a bare seed carrying a top-level `await` (`computed(await
724
+ x)`). The shadow must agree so it unwraps the promise to the resolved value AND so the top-level
725
+ await scan spares the seed. Stops at function boundaries — an inner `async` callback is its own
726
+ scope, mirroring `hasTopLevelAwait`. */
727
+ function seedIsAsync(seed: ts.Expression): boolean {
728
+ if (ts.isArrowFunction(seed) || ts.isFunctionExpression(seed)) {
729
+ return (
730
+ ts
731
+ .getModifiers(seed)
732
+ ?.some((modifier) => modifier.kind === ts.SyntaxKind.AsyncKeyword) ?? false
733
+ )
734
+ }
735
+ return expressionHasTopLevelAwait(seed)
736
+ }
737
+
738
+ /* True when `node` contains an `await` at its own top level — not nested inside a function. The
739
+ seed-classification counterpart to `collectTopLevelAwaitDiagnostics`' walk; kept local so the
740
+ shadow doesn't reach into `desugarSignals`' private `hasTopLevelAwait`. */
741
+ function expressionHasTopLevelAwait(node: ts.Node): boolean {
742
+ let found = false
743
+ const visit = (child: ts.Node): void => {
744
+ if (found || isFunctionScopeBoundary(child)) {
745
+ return
746
+ }
747
+ if (ts.isAwaitExpression(child)) {
748
+ found = true
749
+ return
750
+ }
751
+ ts.forEachChild(child, visit)
752
+ }
753
+ visit(node)
754
+ return found
755
+ }
756
+
757
+ /* A node that opens its own (possibly async) function scope — awaits inside it are the author's
758
+ concern, so both the top-level-await walk and the seed classifier stop descending here. */
759
+ function isFunctionScopeBoundary(node: ts.Node): boolean {
760
+ return (
761
+ ts.isFunctionDeclaration(node) ||
762
+ ts.isFunctionExpression(node) ||
763
+ ts.isArrowFunction(node) ||
764
+ ts.isMethodDeclaration(node) ||
765
+ ts.isGetAccessorDeclaration(node) ||
766
+ ts.isSetAccessorDeclaration(node) ||
767
+ ts.isConstructorDeclaration(node)
768
+ )
769
+ }
770
+
693
771
  /* Whether any ELEMENT in the tree carries an `attach` — gates emitting the DOM-typed
694
772
  attachment aliases. All nested content (block bodies, await/switch branches, snippet
695
773
  bodies) routes through `children`, so a recursive `children` walk is complete. */
@@ -1,5 +1,6 @@
1
1
  import ts from 'typescript'
2
2
  import { escapeKey } from '../runtime/escapeKey.ts'
3
+ import { COMPOUND_ASSIGNMENT_OPERATORS } from './COMPOUND_ASSIGNMENT_OPERATORS.ts'
3
4
 
4
5
  /*
5
6
  The linchpin compiler pass. Rewrites idiomatic data access on a reactive document
@@ -30,9 +31,6 @@ used as an index lowers too. Exposed as `docAccessTransformer` (a
30
31
  /* A path segment is either a literal key or a runtime expression (a dynamic index). */
31
32
  type Segment = { kind: 'literal'; value: string } | { kind: 'expression'; node: ts.Expression }
32
33
 
33
- /* Maps a compound-assignment operator to its plain binary counterpart. Logical
34
- assignments (`||=`/`&&=`/`??=`) lower to an unconditional replace of the
35
- combined value — consistent with how `+=` lowers (the patch always writes). */
36
34
  /* Methods that mutate a doc-rooted container in place — Array, Map and Set (the three
37
35
  mutable containers the doc codec serializes). Called on a doc value these can't lower to
38
36
  a bare `readCall` (which would mutate the live tree by reference and never emit a patch —
@@ -57,16 +55,6 @@ const MUTATING_CONTAINER_METHODS = new Set([
57
55
  'clear',
58
56
  ])
59
57
 
60
- const COMPOUND_OPERATORS = new Map<ts.SyntaxKind, ts.BinaryOperator>([
61
- [ts.SyntaxKind.PlusEqualsToken, ts.SyntaxKind.PlusToken],
62
- [ts.SyntaxKind.MinusEqualsToken, ts.SyntaxKind.MinusToken],
63
- [ts.SyntaxKind.AsteriskEqualsToken, ts.SyntaxKind.AsteriskToken],
64
- [ts.SyntaxKind.SlashEqualsToken, ts.SyntaxKind.SlashToken],
65
- [ts.SyntaxKind.BarBarEqualsToken, ts.SyntaxKind.BarBarToken],
66
- [ts.SyntaxKind.AmpersandAmpersandEqualsToken, ts.SyntaxKind.AmpersandAmpersandToken],
67
- [ts.SyntaxKind.QuestionQuestionEqualsToken, ts.SyntaxKind.QuestionQuestionToken],
68
- ])
69
-
70
58
  export function docAccessTransformer(docName: string): ts.TransformerFactory<ts.SourceFile> {
71
59
  return (context) => (root) => {
72
60
  /* Collects the path of an access chain rooted at `docName`, visiting any
@@ -114,7 +102,7 @@ export function docAccessTransformer(docName: string): ts.TransformerFactory<ts.
114
102
  ts.visitNode(node.right, visit) as ts.Expression,
115
103
  ])
116
104
  }
117
- const binary = COMPOUND_OPERATORS.get(operator)
105
+ const binary = COMPOUND_ASSIGNMENT_OPERATORS.get(operator)
118
106
  if (binary) {
119
107
  const next = ts.factory.createBinaryExpression(
120
108
  docCall(docName, 'read', [buildPath(segments)]),
@@ -117,9 +117,14 @@ export function lowerScript(
117
117
  ...computedNames,
118
118
  ...cellReadNames,
119
119
  ])
120
+ /* `wrapReactionCellSources` only folds `watch(cell, …)` calls, so it's a pure
121
+ identity walk when the script imports no `watch` (the common case) — skip the
122
+ extra full-tree pass entirely then. */
123
+ const reactionTransforms =
124
+ watchLocalNames.size > 0 ? [wrapReactionCellSources(cellNames, watchLocalNames)] : []
120
125
  const result = ts.transform(source, [
121
126
  transformer,
122
- wrapReactionCellSources(cellNames, watchLocalNames),
127
+ ...reactionTransforms,
123
128
  signalRefsTransformer(
124
129
  stateNames,
125
130
  derivedNames,
@@ -1,4 +1,5 @@
1
1
  import ts from 'typescript'
2
+ import { COMPOUND_ASSIGNMENT_OPERATORS } from './COMPOUND_ASSIGNMENT_OPERATORS.ts'
2
3
 
3
4
  /*
4
5
  Rewrites references to a component's signal bindings into the document form the
@@ -21,6 +22,21 @@ Exposed as a `ts.TransformerFactory`, so the script pipeline can chain it with
21
22
  `docAccessTransformer` over a SINGLE parsed tree (see `lowerScript`) instead of
22
23
  print-then-reparse between passes.
23
24
  */
25
+ /* `$$readCell(name)` — the unified cell read (peek async / `.value` sync). */
26
+ function cellReadCall(name: string): ts.Expression {
27
+ return ts.factory.createCallExpression(ts.factory.createIdentifier('$$readCell'), undefined, [
28
+ ts.factory.createIdentifier(name),
29
+ ])
30
+ }
31
+
32
+ /* `$$writeCell(name, value)` — the unified cell write (`.value =` sync / `.set(...)` async). */
33
+ function writeCellCall(name: string, value: ts.Expression): ts.Expression {
34
+ return ts.factory.createCallExpression(ts.factory.createIdentifier('$$writeCell'), undefined, [
35
+ ts.factory.createIdentifier(name),
36
+ value,
37
+ ])
38
+ }
39
+
24
40
  export function signalRefsTransformer(
25
41
  stateNames: ReadonlySet<string>,
26
42
  derivedNames: ReadonlySet<string>,
@@ -87,6 +103,55 @@ export function signalRefsTransformer(
87
103
  if (ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) {
88
104
  return node
89
105
  }
106
+ /* An assignment to a `linked` cell (a `cellReadNames` name) — its read form
107
+ is `$$readCell(name)`, a call, so it can't sit on an assignment's left.
108
+ Lower the write to `$$writeCell(name, value)`, which dispatches `.value =`
109
+ (sync `State`) vs `.set(...)` (async `AsyncState`). A compound/logical
110
+ assignment folds the current value in through the read form. `state` and
111
+ `derived` writes stay on their own paths (`$$model.replace` / `.value =`). */
112
+ if (
113
+ ts.isBinaryExpression(node) &&
114
+ ts.isIdentifier(node.left) &&
115
+ cellReadNames.has(node.left.text) &&
116
+ !shadowed.has(node.left.text)
117
+ ) {
118
+ const target = node.left.text
119
+ const right = ts.visitNode(node.right, visit) as ts.Expression
120
+ if (node.operatorToken.kind === ts.SyntaxKind.EqualsToken) {
121
+ return writeCellCall(target, right)
122
+ }
123
+ const binary = COMPOUND_ASSIGNMENT_OPERATORS.get(node.operatorToken.kind)
124
+ if (binary !== undefined) {
125
+ return writeCellCall(
126
+ target,
127
+ ts.factory.createBinaryExpression(cellReadCall(target), binary, right),
128
+ )
129
+ }
130
+ }
131
+ /* `draft++` / `++draft` / `draft--` on a `linked` cell → a `$$writeCell` of the
132
+ stepped value, mirroring the `+= 1` shape (the bare `++` would otherwise land
133
+ on `$$readCell(draft)`, an invalid lvalue). */
134
+ if (
135
+ (ts.isPostfixUnaryExpression(node) || ts.isPrefixUnaryExpression(node)) &&
136
+ (node.operator === ts.SyntaxKind.PlusPlusToken ||
137
+ node.operator === ts.SyntaxKind.MinusMinusToken) &&
138
+ ts.isIdentifier(node.operand) &&
139
+ cellReadNames.has(node.operand.text) &&
140
+ !shadowed.has(node.operand.text)
141
+ ) {
142
+ const step =
143
+ node.operator === ts.SyntaxKind.PlusPlusToken
144
+ ? ts.SyntaxKind.PlusToken
145
+ : ts.SyntaxKind.MinusToken
146
+ return writeCellCall(
147
+ node.operand.text,
148
+ ts.factory.createBinaryExpression(
149
+ cellReadCall(node.operand.text),
150
+ step,
151
+ ts.factory.createNumericLiteral(1),
152
+ ),
153
+ )
154
+ }
90
155
  /* Shorthand `{ count }` → `{ count: $$model.count }` / `{ total: total.value }`,
91
156
  unless a nearer scope shadows the name. */
92
157
  if (ts.isShorthandPropertyAssignment(node) && !shadowed.has(node.name.text)) {
@@ -46,6 +46,23 @@ export function wrapReactionCellSources(
46
46
  return false
47
47
  }
48
48
 
49
+ /* The inert footgun: a member access (`s.foo`, `s['foo']`) whose base is a known cell.
50
+ abide has no per-property cells — the whole object lives in the one cell — so a member
51
+ expression is read ONCE as a plain value (outside any tracking scope) and handed to the
52
+ runtime as a dead value: nothing subscribes, the reaction never fires. It can't be folded
53
+ like a bare cell (a member access isn't a whole-cell subscription), so return the base
54
+ name to warn on and leave the source for the author to rewrite. */
55
+ function cellMemberBase(source: ts.Expression): string | undefined {
56
+ if (
57
+ (ts.isPropertyAccessExpression(source) || ts.isElementAccessExpression(source)) &&
58
+ ts.isIdentifier(source.expression) &&
59
+ cellNames.has(source.expression.text)
60
+ ) {
61
+ return source.expression.text
62
+ }
63
+ return undefined
64
+ }
65
+
49
66
  return (context) => (root) => {
50
67
  function visit(node: ts.Node): ts.Node {
51
68
  const [sourceArg, handlerArg] = ts.isCallExpression(node) ? node.arguments : []
@@ -78,6 +95,21 @@ export function wrapReactionCellSources(
78
95
  )
79
96
  return ts.factory.createCallExpression(node.expression, undefined, [thunk])
80
97
  }
98
+ /* Warn on the inert member-expression form `watch(s.foo, handler)` — it looks valid but
99
+ subscribes to nothing (see `cellMemberBase`). Not foldable; the fix is the author's. */
100
+ if (
101
+ ts.isCallExpression(node) &&
102
+ ts.isIdentifier(node.expression) &&
103
+ watchLocalNames.has(node.expression.text) &&
104
+ node.arguments.length === 2 &&
105
+ sourceArg !== undefined &&
106
+ cellMemberBase(sourceArg) !== undefined
107
+ ) {
108
+ const sourceText = sourceArg.getText(root)
109
+ console.warn(
110
+ `[abide] \`watch(${sourceText}, …)\` does not track \`${sourceText}\` — a member access on a cell is read once as a plain value (abide has no per-property cells), so the reaction is inert. Wrap it in a thunk (\`watch(() => …(${sourceText}))\`) or watch the whole cell (\`watch(${cellMemberBase(sourceArg)}, v => …)\`).`,
111
+ )
112
+ }
81
113
  return ts.visitEachChild(node, visit, context)
82
114
  }
83
115
  return ts.visitNode(root, visit) as ts.SourceFile
@@ -1,8 +1,11 @@
1
+ import { decodeRefJson } from '../shared/decodeRefJson.ts'
2
+ import { docSnapshotsSlot } from '../shared/docSnapshotsSlot.ts'
1
3
  import { computed } from './computed.ts'
2
4
  import { effect } from './effect.ts'
3
5
  import { linked } from './linked.ts'
4
6
  import { CURRENT_PATH } from './runtime/CURRENT_PATH.ts'
5
7
  import { createDoc } from './runtime/createDoc.ts'
8
+ import { DOC_SEED } from './runtime/DOC_SEED.ts'
6
9
  import type { Cell } from './runtime/types/Cell.ts'
7
10
  import type { Doc } from './runtime/types/Doc.ts'
8
11
  import { state } from './state.ts'
@@ -43,6 +46,33 @@ export function createScope(
43
46
  : parent === undefined
44
47
  ? `scope-${nextId++}`
45
48
  : `${parent.id}.${nextId++}`
49
+ /* Doc-state warm-seed (client hydration only): a plain `state(initial)` re-runs its initializer
50
+ on the client, so a uuid/timestamp/random would diverge from the SSR HTML. The server snapshot
51
+ for this scope's render-path id is decoded here and consumed by `replace` on each slot's FIRST
52
+ 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
+ let pendingSeed: Map<string, unknown> | undefined
55
+ /* `DOC_SEED` is populated only on the client by `startClient` (empty on the server), so the read
56
+ needs no window guard — it mirrors the async-cell warm read. */
57
+ const encodedSeed = DOC_SEED[id]
58
+ if (encodedSeed !== undefined) {
59
+ delete DOC_SEED[id]
60
+ try {
61
+ pendingSeed = new Map(
62
+ Object.entries(decodeRefJson(encodedSeed) as Record<string, unknown>),
63
+ )
64
+ } catch {
65
+ /* A corrupt seed falls back to a cold init — the same failure mode as a warm cell. */
66
+ }
67
+ }
68
+ /* Server: register this scope's doc snapshot for the `__SSR__.docs` warm-seed, keyed by its
69
+ render-path id. Lazy — taken at render-return, after the synchronous state inits have run.
70
+ Only a rendered scope (stable render-path id) registers; a detached scope (counter id) and the
71
+ client never do. An empty/unused doc is dropped at the stamp, so a stateless scope costs only
72
+ the push. */
73
+ if (typeof window === 'undefined' && renderPath !== '') {
74
+ docSnapshotsSlot.get()?.entries.push({ id, take: () => document?.snapshot() })
75
+ }
46
76
  /* Adopted build teardowns (the reactivity stopper from the mount core). Disposed
47
77
  first and in reverse on teardown — so the one `dispose` runs the order the call sites
48
78
  hand-composed as `stop(); lexical.dispose()`. */
@@ -65,7 +95,18 @@ export function createScope(
65
95
  nextCellIndex: () => cellIndex++,
66
96
  parent,
67
97
  read: (path) => data().read(path),
68
- replace: (path, value) => data().replace(path, value),
98
+ /* Consume-once seed adoption: on the client's hydrating build, the FIRST write to each seeded
99
+ slot is the eager `state(initial)` init — swap in the server value and drop the seed, so a
100
+ `state(uuid())` keeps the SSR value while its throwaway fresh init is discarded. Every later
101
+ write (a real reassignment) and every non-seeded path passes straight through. */
102
+ replace: (path, value) => {
103
+ if (pendingSeed?.has(path)) {
104
+ const seeded = pendingSeed.get(path)
105
+ pendingSeed.delete(path)
106
+ return data().replace(path, seeded)
107
+ }
108
+ return data().replace(path, value)
109
+ },
69
110
  add: (path, value) => data().add(path, value),
70
111
  remove: (path) => data().remove(path),
71
112
  apply: (patch) => data().apply(patch),
@@ -1,3 +1,6 @@
1
+ import { CURRENT_PATH } from '../runtime/CURRENT_PATH.ts'
2
+ import { reportHydrationDivergence } from '../runtime/reportHydrationDivergence.ts'
3
+
1
4
  /*
2
5
  A text claim that MUST match the server's rendered text. On hydrate,
3
6
  appendStatic/appendText adopt a merged SSR text node and split it at the CLIENT
@@ -12,9 +15,16 @@ the following siblings' text AFTER it, so the value is exactly the leading slice
12
15
  mismatch means SSR and the client build disagree on the text here.
13
16
  */
14
17
  export function assertClaimedText(node: Text, value: string): void {
15
- if (!node.data.startsWith(value)) {
16
- throw new Error(
17
- `[abide] hydration desync: expected server text beginning with ${JSON.stringify(value)} here, but the server DOM had ${JSON.stringify(node.data)} — SSR markup and the client build disagree on the text at this position.`,
18
- )
18
+ if (node.data.startsWith(value)) {
19
+ return
20
+ }
21
+ /* Report on the `hydrate` channel with the render-path. With the channel on, keep hydrating
22
+ (re-split proceeds below on the client length) so one reload surfaces every text divergence;
23
+ off, the hard throw stays the default. A text miss is cursor-safe to continue past. */
24
+ if (!reportHydrationDivergence('text desync', { expected: value, server: node.data })) {
25
+ return
19
26
  }
27
+ throw new Error(
28
+ `[abide] hydration desync at ${CURRENT_PATH.current || '(root)'}: expected server text beginning with ${JSON.stringify(value)} here, but the server DOM had ${JSON.stringify(node.data)} — SSR markup and the client build disagree on the text at this position.`,
29
+ )
20
30
  }
@@ -1,4 +1,15 @@
1
1
  import { effect } from '../effect.ts'
2
+ import { RENDER } from '../runtime/RENDER.ts'
3
+ import { reportHydrationDivergence } from '../runtime/reportHydrationDivergence.ts'
4
+
5
+ /* The attribute string an element carries for `value`, or `null` when absent — the present/absent +
6
+ stringify semantics below, factored so the hydration compare and the write agree exactly. */
7
+ function attributeText(value: unknown): string | null {
8
+ if (value === false || value === null || value === undefined) {
9
+ return null
10
+ }
11
+ return value === true ? '' : String(value)
12
+ }
2
13
 
3
14
  /*
4
15
  Binds an element attribute to `read()`. A boolean true sets the bare attribute,
@@ -8,12 +19,24 @@ changed attribute touches the DOM.
8
19
  */
9
20
  // @documentation plumbing
10
21
  export function attr(element: Element, name: string, read: () => unknown): void {
22
+ /* Captured at bind time (inside the hydrating build) so the first effect run can compare the
23
+ server-rendered attribute before overwriting it — an attribute divergence is otherwise
24
+ invisible, since the effect always overwrites. */
25
+ let hydrating = RENDER.hydration !== undefined
11
26
  effect(() => {
12
27
  const value = read()
13
- if (value === false || value === null || value === undefined) {
28
+ const next = attributeText(value)
29
+ if (hydrating) {
30
+ hydrating = false
31
+ const server = element.getAttribute(name)
32
+ if (server !== next) {
33
+ reportHydrationDivergence(`attr "${name}" desync`, { server, client: next })
34
+ }
35
+ }
36
+ if (next === null) {
14
37
  element.removeAttribute(name)
15
38
  } else {
16
- element.setAttribute(name, value === true ? '' : String(value))
39
+ element.setAttribute(name, next)
17
40
  }
18
41
  })
19
42
  }
@@ -0,0 +1,22 @@
1
+ import { isAsyncCell } from '../../shared/isAsyncCell.ts'
2
+ import type { AsyncState } from '../../shared/types/AsyncState.ts'
3
+
4
+ /*
5
+ Unified write for a `linked` reference the compiler lowers to `$$writeCell(NAME, value)`
6
+ from an author assignment (`draft = x`, `draft += 1`, `draft++`). The read side is
7
+ `$$readCell` — a call, so not an assignable target — which is why an assignment to a
8
+ `linked` binding needs its own lowering. A `linked` cell is a writable `State` for a
9
+ sync seed (write `.value`) or a writable `AsyncState` for an async/stream seed (write
10
+ `.set(value)`, which latches until the next reseed); one write shape covers both, so
11
+ codegen never branches on the seed kind. Returns `value` so the lowered assignment still
12
+ evaluates to the written value in expression position (`a = b = draft = x`).
13
+ */
14
+ // @documentation plumbing
15
+ export function writeCell(cell: unknown, value: unknown): unknown {
16
+ if (isAsyncCell(cell)) {
17
+ ;(cell as AsyncState<unknown>).set(value)
18
+ } else {
19
+ ;(cell as { value: unknown }).value = value
20
+ }
21
+ return value
22
+ }
@@ -0,0 +1,11 @@
1
+ /* The doc-state warm-seed manifest: an SSR-captured reactive-document snapshot, keyed by its
2
+ scope's serialization-stable render-path id, as a ref-json-encoded STRING (decoded at the read
3
+ site in `createScope`). `startClient` drains `__SSR__.docs` into here before mount; a hydrating
4
+ scope reads its key to seed a plain `state(initial)` to the SERVER value instead of the fresh
5
+ init — a uuid/timestamp/random otherwise diverges from the SSR HTML. Backed by
6
+ `globalThis.__abideDocs` so an inline pre-bundle script and the framework share one store —
7
+ whoever runs first creates it, the other adopts the same reference (mirrors CELL_SEED). */
8
+ const globalScope = globalThis as { __abideDocs?: Record<string, string> }
9
+ globalScope.__abideDocs ??= {}
10
+
11
+ export const DOC_SEED: Record<string, string> = globalScope.__abideDocs
@@ -1,5 +1,7 @@
1
+ import { CURRENT_PATH } from './CURRENT_PATH.ts'
1
2
  import { claimChild } from './claimChild.ts'
2
3
  import type { RENDER } from './RENDER.ts'
4
+ import { reportHydrationDivergence } from './reportHydrationDivergence.ts'
3
5
 
4
6
  /*
5
7
  A hydration claim that MUST succeed. Like `claimChild`, but throws a structural error
@@ -18,8 +20,11 @@ export function claimExpected(
18
20
  ): Node {
19
21
  const node = claimChild(hydration, parent)
20
22
  if (node === null) {
23
+ /* Name where first on the `hydrate` channel (return ignored — a missing structural node
24
+ desyncs the claim cursor, so unlike a text miss this can't safely continue) then throw. */
25
+ reportHydrationDivergence('structure desync', { expected })
21
26
  throw new Error(
22
- `[abide] hydration desync: expected ${expected} here, but the server DOM had no matching node — SSR markup and the client build disagree on structure at this position.`,
27
+ `[abide] hydration desync at ${CURRENT_PATH.current || '(root)'}: expected ${expected} here, but the server DOM had no matching node — SSR markup and the client build disagree on structure at this position.`,
23
28
  )
24
29
  }
25
30
  return node
@@ -0,0 +1,30 @@
1
+ import { log } from '../../shared/log.ts'
2
+ import type { ChannelLog } from '../../shared/types/ChannelLog.ts'
3
+ import { CURRENT_PATH } from './CURRENT_PATH.ts'
4
+
5
+ /* The `hydrate` diagnostic channel. `createChannelLog` attaches a live `enabled()` (re-read per
6
+ call, so toggling `abide-debug` in devtools takes effect without reload) that the public
7
+ `ChannelLog` type narrows away — reach it here in the one place that needs it. The channel is
8
+ silent unless DEBUG/`abide-debug` matches `hydrate`. */
9
+ const hydrateChannel = log.channel('hydrate') as ChannelLog & { enabled(): boolean }
10
+
11
+ /*
12
+ Reports an SSR↔client hydration divergence on the DEBUG-gated `hydrate` channel, tagged with the
13
+ ambient render-path of the enclosing component/branch/row (`CURRENT_PATH` is coarse — it does not
14
+ descend to the individual node/attribute, so the path locates the component, the detail names the
15
+ value). Returns whether the caller should STILL THROW: with the channel off it returns `true`, so
16
+ the caller's hard throw stays the default and a real desync fails loudly; with the channel on it
17
+ warns and returns `false`, so the caller can keep hydrating and one reload surfaces EVERY
18
+ divergence instead of aborting at the first. Structural callers ignore the return (continuing past
19
+ a missing node desyncs the claim cursor) and throw regardless — the warn just names where first.
20
+ */
21
+ export function reportHydrationDivergence(
22
+ summary: string,
23
+ detail: Record<string, unknown>,
24
+ ): boolean {
25
+ if (!hydrateChannel.enabled()) {
26
+ return true
27
+ }
28
+ hydrateChannel.warn(`${summary} at ${CURRENT_PATH.current || '(root)'}`, detail)
29
+ return false
30
+ }