@abide/abide 0.50.1 → 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 +486 -490
  2. package/CHANGELOG.md +39 -0
  3. package/README.md +130 -149
  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 +141 -25
  45. package/src/lib/ui/compile/lowerDocAccess.ts +2 -14
  46. package/src/lib/ui/compile/lowerScript.ts +23 -0
  47. package/src/lib/ui/compile/renameSignalRefs.ts +65 -0
  48. package/src/lib/ui/compile/wrapReactionCellSources.ts +117 -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' },
@@ -39,9 +39,6 @@ function shadowPreamble(importedReactives: ReadonlySet<string>): string {
39
39
  importedReactives.has('effect')
40
40
  ? undefined
41
41
  : `import { effect } from '${ABIDE_PACKAGE_NAME}/ui/effect'`,
42
- importedReactives.has('watch')
43
- ? undefined
44
- : `import { watch } from '${ABIDE_PACKAGE_NAME}/ui/watch'`,
45
42
  `import { snippet } from '${ABIDE_PACKAGE_NAME}/shared/snippet'`,
46
43
  `import { scope } from '${ABIDE_PACKAGE_NAME}/ui/currentScope'`,
47
44
  importedReactives.has('state')
@@ -86,9 +83,25 @@ export function compileShadow(
86
83
  const scriptStart = leadingScript ? source.indexOf('>', leadingScript.index) + 1 : 0
87
84
  const templateStart = leadingScript ? (leadingScript.index ?? 0) + leadingScript[0].length : 0
88
85
 
89
- const { imports, types, scope, propsShapes, diagnostics, importedReactives, propsLocalName } =
90
- analyzeScript(scriptBody, scriptStart)
86
+ const {
87
+ imports,
88
+ types,
89
+ scope,
90
+ propsShapes,
91
+ diagnostics,
92
+ importedReactives,
93
+ propsLocalName,
94
+ watchLocalName,
95
+ } = analyzeScript(scriptBody, scriptStart)
91
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
+ )
92
105
  /* The peek helpers the async-interpolation wrap targets (ADR-0032): `$$peek` unwraps a promise
93
106
  sub-expression to its resolved value (`undefined` while pending) and `$$peekStream` reads an
94
107
  async iterable's latest frame, so `getFoo()?.name`/`{#if getFoo()}` type-check against the
@@ -112,15 +125,30 @@ export function compileShadow(
112
125
  if (propsLocalName !== undefined) {
113
126
  builder.raw(`declare function ${propsLocalName}<T = {}>(): (${propsType}) & T\n`)
114
127
  }
128
+ /* `watch` is re-typed with a trailing value-source overload: the real overloads
129
+ (`State`/socket/rpc/thunk) intersected with `<T>(source: T, handler: (value: T) => void)`.
130
+ A `watch(cell, handler)` source is projected to the cell's VALUE type (like every read),
131
+ so it never matches the real `State<T>` overload — the value-source form catches it and
132
+ types `handler`'s parameter as that value. Ordered after the real overloads (intersection
133
+ left-to-right), so a socket/rpc source still resolves to its specific frame/return type.
134
+ The real type is referenced through `typeof import(...)` (no value import to keep live), and
135
+ the binding uses the author's local alias — or the `watch` fallback for a bare/nested use
136
+ that isn't author-imported. */
137
+ const watchSpecifier = `${ABIDE_PACKAGE_NAME}/ui/watch`
138
+ builder.raw(
139
+ `declare const ${watchLocalName ?? 'watch'}: typeof import('${watchSpecifier}').watch & (<__WatchT>(source: __WatchT, handler: (value: __WatchT) => void) => () => void)\n`,
140
+ )
115
141
  const propsSpecifier = `${ABIDE_PACKAGE_NAME}/ui/props`
116
142
  for (const line of imports) {
117
- /* The `props` import is replaced by the contextual `declare function` above;
118
- emitting it too would be a duplicate-identifier error. Matched by EXACT specifier
143
+ /* The `props`/`watch` imports are replaced by the contextual declarations above;
144
+ emitting one too would be a duplicate-identifier error. Matched by EXACT specifier
119
145
  (either quote style, not a loose suffix match) so an unrelated user module merely
120
- named `.../ui/props` survives verbatim. */
146
+ named `.../ui/props` (or `.../ui/watch`) survives verbatim. */
121
147
  if (
122
148
  line.text.includes(`from '${propsSpecifier}'`) ||
123
- line.text.includes(`from "${propsSpecifier}"`)
149
+ line.text.includes(`from "${propsSpecifier}"`) ||
150
+ line.text.includes(`from '${watchSpecifier}'`) ||
151
+ line.text.includes(`from "${watchSpecifier}"`)
124
152
  ) {
125
153
  continue
126
154
  }
@@ -331,6 +359,11 @@ type ScriptAnalysis = {
331
359
  for the canonical import, `p` for `props as p`), or undefined when not imported. The
332
360
  `declare function` for `props` must target this name, not the canonical `'props'`. */
333
361
  propsLocalName: string | undefined
362
+ /* The LOCAL binding name the author's `watch` import is bound to (alias-safe), or undefined
363
+ when not imported. The shadow re-types `watch` under this name with a value-source
364
+ overload so the `watch(cell, handler)` form type-checks (the cell is projected to its
365
+ value, so the real State/socket/rpc overloads never match it). */
366
+ watchLocalName: string | undefined
334
367
  }
335
368
 
336
369
  /* Pushes a diagnostic for every author binding whose name starts with the reserved `$$`
@@ -370,27 +403,38 @@ function collectReservedNameDiagnostics(
370
403
  top-level await transpiles to `await` in a non-async function and breaks the bundle; the
371
404
  shadow's render fn is async, so `tsc` alone never flags it (a check/runtime parity gap).
372
405
  Stops descending at function boundaries — their own async-ness is the author's concern —
373
- 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. */
374
410
  function collectTopLevelAwaitDiagnostics(
375
411
  file: ts.SourceFile,
376
412
  scriptStart: number,
377
413
  diagnostics: ShadowDiagnostic[],
414
+ bindings: ReactiveImportBindings,
378
415
  ): void {
379
416
  const message =
380
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.'
381
418
  const visit = (node: ts.Node): void => {
382
419
  /* A nested function introduces its own (possibly async) scope; its awaits are legal. */
383
- if (
384
- ts.isFunctionDeclaration(node) ||
385
- ts.isFunctionExpression(node) ||
386
- ts.isArrowFunction(node) ||
387
- ts.isMethodDeclaration(node) ||
388
- ts.isGetAccessorDeclaration(node) ||
389
- ts.isSetAccessorDeclaration(node) ||
390
- ts.isConstructorDeclaration(node)
391
- ) {
420
+ if (isFunctionScopeBoundary(node)) {
392
421
  return
393
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
+ }
394
438
  /* The `await` keyword token: the AwaitExpression's first child, or a `for await`'s
395
439
  await modifier. */
396
440
  const keyword = ts.isAwaitExpression(node)
@@ -424,7 +468,8 @@ function collectNestedScriptAwaitDiagnostics(
424
468
  }
425
469
  if (node.kind === 'script' && node.loc !== undefined) {
426
470
  const file = ts.createSourceFile('nested.ts', node.code, ts.ScriptTarget.Latest, true)
427
- 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)
428
473
  }
429
474
  if ('children' in node) {
430
475
  collectNestedScriptAwaitDiagnostics(node.children, diagnostics)
@@ -451,6 +496,7 @@ function analyzeScript(scriptBody: string, scriptStart: number): ScriptAnalysis
451
496
  diagnostics,
452
497
  importedReactives: new Set(),
453
498
  propsLocalName: undefined,
499
+ watchLocalName: undefined,
454
500
  }
455
501
  }
456
502
  const file = ts.createSourceFile('script.ts', scriptBody, ts.ScriptTarget.Latest, true)
@@ -462,10 +508,13 @@ function analyzeScript(scriptBody: string, scriptStart: number): ScriptAnalysis
462
508
  /* The local name bound to `props` (alias-safe): the key whose value is the canonical
463
509
  `'props'`. At most one — a file imports `props` from one specifier. */
464
510
  let propsLocalName: string | undefined
511
+ let watchLocalName: string | undefined
465
512
  for (const [local, canonical] of bindings.direct) {
466
513
  if (canonical === 'props') {
467
514
  propsLocalName = local
468
- break
515
+ }
516
+ if (canonical === 'watch') {
517
+ watchLocalName = local
469
518
  }
470
519
  }
471
520
  /* The `$$` prefix is reserved for the compiler's injected runtime (`$$each`, `$$model`,
@@ -474,7 +523,7 @@ function analyzeScript(scriptBody: string, scriptStart: number): ScriptAnalysis
474
523
  collectReservedNameDiagnostics(file, scriptStart, diagnostics)
475
524
  /* The leading script runs in the synchronous `build()`, so a top-level await breaks the
476
525
  bundle — catch it here with a legible message instead of an opaque transpile error. */
477
- collectTopLevelAwaitDiagnostics(file, scriptStart, diagnostics)
526
+ collectTopLevelAwaitDiagnostics(file, scriptStart, diagnostics, bindings)
478
527
  /* A verbatim span: original text + the segment mapping it back, relative to the
479
528
  line start (the caller rebases shadowStart onto the running shadow length). */
480
529
  const span = (node: ts.Node, prefixLength: number): ScopeLine['segments'][number] => ({
@@ -507,7 +556,16 @@ function analyzeScript(scriptBody: string, scriptStart: number): ScriptAnalysis
507
556
  scope.push(scopeLineFor(declaration, propsShapes, verbatim, span, bindings))
508
557
  }
509
558
  }
510
- return { imports, types, scope, propsShapes, diagnostics, importedReactives, propsLocalName }
559
+ return {
560
+ imports,
561
+ types,
562
+ scope,
563
+ propsShapes,
564
+ diagnostics,
565
+ importedReactives,
566
+ propsLocalName,
567
+ watchLocalName,
568
+ }
511
569
  }
512
570
 
513
571
  /* Value-projects a nested control-flow `<script>` body the way `analyzeScript`
@@ -645,13 +703,71 @@ function scopeLineFor(
645
703
  segments: [span(declaration.name, keywordOffset)],
646
704
  })
647
705
  }
648
- 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}`
649
716
  return withCalleeRef({
650
- text: `${prefix}${verbatim(fn)})();`,
717
+ text: `${prefix}${verbatim(fn)}${wrapSuffix});`,
651
718
  segments: [span(declaration.name, keywordOffset), span(fn, prefix.length)],
652
719
  })
653
720
  }
654
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
+
655
771
  /* Whether any ELEMENT in the tree carries an `attach` — gates emitting the DOM-typed
656
772
  attachment aliases. All nested content (block bodies, await/switch branches, snippet
657
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)]),
@@ -5,9 +5,11 @@ import { desugarSignals } from './desugarSignals.ts'
5
5
  import { identifierReferencePattern } from './identifierReferencePattern.ts'
6
6
  import { docAccessTransformer } from './lowerDocAccess.ts'
7
7
  import { signalRefsTransformer } from './renameSignalRefs.ts'
8
+ import { reactiveImportBindings } from './resolveReactiveExport.ts'
8
9
  import { stripEffectsTransformer } from './stripEffects.ts'
9
10
  import { TS_PRINTER } from './TS_PRINTER.ts'
10
11
  import type { SeedTypeClassifier } from './types/SeedTypeClassifier.ts'
12
+ import { wrapReactionCellSources } from './wrapReactionCellSources.ts'
11
13
 
12
14
  /* The `abide/ui/*` modules the reactive surface is imported from. An author's import of
13
15
  one is compiler-recognised and lowered, so its binding is often fully consumed — a plain
@@ -100,8 +102,29 @@ export function lowerScript(
100
102
  seedClassify,
101
103
  scriptBase,
102
104
  )
105
+ /* The local names bound to `watch` (alias-safe), and the full set of read-rewritten cell
106
+ names — together they let `wrapReactionCellSources` fold a `watch(cell, handler)` into
107
+ the thunk form BEFORE the read-lowering turns the cell reference into a value read. */
108
+ const watchLocalNames = new Set<string>()
109
+ for (const [local, canonical] of reactiveImportBindings(source).direct) {
110
+ if (canonical === 'watch') {
111
+ watchLocalNames.add(local)
112
+ }
113
+ }
114
+ const cellNames = new Set<string>([
115
+ ...stateNames,
116
+ ...derivedNames,
117
+ ...computedNames,
118
+ ...cellReadNames,
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)] : []
103
125
  const result = ts.transform(source, [
104
126
  transformer,
127
+ ...reactionTransforms,
105
128
  signalRefsTransformer(
106
129
  stateNames,
107
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)) {
@@ -0,0 +1,117 @@
1
+ import ts from 'typescript'
2
+
3
+ /*
4
+ Folds a cell-source `watch(source, handler)` into the auto-tracked thunk form
5
+ `watch(() => (handler)(source))`, so the reaction reads the cell reactively
6
+ instead of receiving a one-time value.
7
+
8
+ The plain-variable ergonomic makes a bare `count` a VALUE read everywhere the
9
+ read-lowering runs (`renameSignalRefs` → `docAccessTransformer`). That is right
10
+ for `{count}` / `count + 1`, but it also lowered `watch(count, handler)`'s source
11
+ to `$$model.read("count")` — a number — so the runtime `watch` (which expects a
12
+ `State` and reads `.value` inside an effect) never subscribed: the reaction was
13
+ inert. The `watch(count, n => …)` form was documented but silently dead.
14
+
15
+ This runs BEFORE the read-lowering, so the `source` moved inside the new thunk is
16
+ lowered to its normal reactive read there — one rewrite covers every cell kind
17
+ (doc-slot `state`, `.value` cell, `computed`, `linked`), because it reuses the
18
+ read the rest of the pipeline already emits. The runtime `watch(thunk)` is the
19
+ same auto-tracked effect the compiler's own bindings use, so semantics match the
20
+ old `effect(() => handler(cell.value))` cell branch exactly.
21
+
22
+ Only a bare cell reference (or an array literal of them — the `watch([a, b], …)`
23
+ form) is folded. A socket / rpc / arbitrary object source is left untouched so the
24
+ runtime's own source dispatch (`cache.on` / `reactToRpc`) still handles it; the
25
+ thunk (`watch(() => …)`) and rpc-with-args (`watch(fn, args, handler)`) forms are
26
+ already correct and are matched out by arity.
27
+ */
28
+ export function wrapReactionCellSources(
29
+ cellNames: ReadonlySet<string>,
30
+ watchLocalNames: ReadonlySet<string>,
31
+ ): ts.TransformerFactory<ts.SourceFile> {
32
+ /* A source that names a reactive cell: a bare cell identifier, or a non-empty array
33
+ literal whose every element is one (the multi-cell `watch([a, b], …)` form). */
34
+ function isCellSource(source: ts.Expression): boolean {
35
+ if (ts.isIdentifier(source)) {
36
+ return cellNames.has(source.text)
37
+ }
38
+ if (ts.isArrayLiteralExpression(source)) {
39
+ return (
40
+ source.elements.length > 0 &&
41
+ source.elements.every(
42
+ (element) => ts.isIdentifier(element) && cellNames.has(element.text),
43
+ )
44
+ )
45
+ }
46
+ return false
47
+ }
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
+
66
+ return (context) => (root) => {
67
+ function visit(node: ts.Node): ts.Node {
68
+ const [sourceArg, handlerArg] = ts.isCallExpression(node) ? node.arguments : []
69
+ if (
70
+ ts.isCallExpression(node) &&
71
+ ts.isIdentifier(node.expression) &&
72
+ watchLocalNames.has(node.expression.text) &&
73
+ node.arguments.length === 2 &&
74
+ sourceArg !== undefined &&
75
+ handlerArg !== undefined &&
76
+ isCellSource(sourceArg)
77
+ ) {
78
+ /* Visit the operands first — a handler body may itself contain a nested cell-source
79
+ watch, and the source stays a plain identifier the read-lowering rewrites next. */
80
+ const source = ts.visitNode(sourceArg, visit) as ts.Expression
81
+ const handler = ts.visitNode(handlerArg, visit) as ts.Expression
82
+ /* `(handler)(source)` — parenthesise the handler so an arrow callee prints callable. */
83
+ const call = ts.factory.createCallExpression(
84
+ ts.factory.createParenthesizedExpression(handler),
85
+ undefined,
86
+ [source],
87
+ )
88
+ const thunk = ts.factory.createArrowFunction(
89
+ undefined,
90
+ undefined,
91
+ [],
92
+ undefined,
93
+ ts.factory.createToken(ts.SyntaxKind.EqualsGreaterThanToken),
94
+ call,
95
+ )
96
+ return ts.factory.createCallExpression(node.expression, undefined, [thunk])
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
+ }
113
+ return ts.visitEachChild(node, visit, context)
114
+ }
115
+ return ts.visitNode(root, visit) as ts.SourceFile
116
+ }
117
+ }
@@ -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),