@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.
- package/AGENTS.md +486 -490
- package/CHANGELOG.md +39 -0
- package/README.md +130 -149
- package/package.json +4 -1
- package/src/abideResolverPlugin.ts +12 -0
- package/src/checkAbide.ts +60 -7
- package/src/lib/cli/completeCli.ts +38 -0
- package/src/lib/cli/printTopLevelHelp.ts +1 -0
- package/src/lib/cli/renderCliCompletions.ts +56 -0
- package/src/lib/cli/runCli.ts +27 -0
- package/src/lib/server/render.ts +70 -0
- package/src/lib/server/runtime/cacheStalenessBroadcaster.ts +33 -0
- package/src/lib/server/runtime/createServer.ts +43 -0
- package/src/lib/server/runtime/createUiPageRenderer.ts +37 -0
- package/src/lib/server/runtime/pageRenderSlot.ts +15 -0
- package/src/lib/server/runtime/runWithRequestScope.ts +1 -0
- package/src/lib/server/runtime/types/RequestStore.ts +9 -0
- package/src/lib/server/sockets/createSocketDispatcher.ts +9 -0
- package/src/lib/server/sockets/registerSocket.ts +12 -0
- package/src/lib/shared/CACHE_STALENESS_SOCKET.ts +8 -0
- package/src/lib/shared/RESERVED_SOCKET_PREFIX.ts +8 -0
- package/src/lib/shared/applyCacheStalenessLocally.ts +18 -0
- package/src/lib/shared/attachRpcSelectorMethods.ts +3 -1
- package/src/lib/shared/cache.ts +32 -4
- package/src/lib/shared/cacheStalenessSlot.ts +21 -0
- package/src/lib/shared/createRpcServerProgram.ts +187 -0
- package/src/lib/shared/docSnapshotsSlot.ts +13 -0
- package/src/lib/shared/invalidate.ts +39 -0
- package/src/lib/shared/matcherFromEnvelope.ts +31 -0
- package/src/lib/shared/prepareRpcModule.ts +22 -3
- package/src/lib/shared/refresh.ts +9 -2
- package/src/lib/shared/selectorMatcher.ts +2 -15
- package/src/lib/shared/serializeSelector.ts +69 -0
- package/src/lib/shared/setsIntersect.ts +15 -0
- package/src/lib/shared/types/CacheStalenessApply.ts +13 -0
- package/src/lib/shared/types/CacheStalenessFrame.ts +24 -0
- package/src/lib/shared/types/DocSnapshots.ts +12 -0
- package/src/lib/shared/types/RemoteFunction.ts +11 -8
- package/src/lib/shared/types/RpcBuildStamps.ts +9 -0
- package/src/lib/shared/types/SsrPayload.ts +5 -0
- package/src/lib/test/createTestApp.ts +15 -0
- package/src/lib/ui/compile/COMPOUND_ASSIGNMENT_OPERATORS.ts +19 -0
- package/src/lib/ui/compile/UI_RUNTIME_IMPORTS.ts +1 -0
- package/src/lib/ui/compile/compileShadow.ts +141 -25
- package/src/lib/ui/compile/lowerDocAccess.ts +2 -14
- package/src/lib/ui/compile/lowerScript.ts +23 -0
- package/src/lib/ui/compile/renameSignalRefs.ts +65 -0
- package/src/lib/ui/compile/wrapReactionCellSources.ts +117 -0
- package/src/lib/ui/createScope.ts +42 -1
- package/src/lib/ui/dom/assertClaimedText.ts +14 -4
- package/src/lib/ui/dom/attr.ts +25 -2
- package/src/lib/ui/dom/writeCell.ts +22 -0
- package/src/lib/ui/runtime/DOC_SEED.ts +11 -0
- package/src/lib/ui/runtime/claimExpected.ts +6 -1
- package/src/lib/ui/runtime/reportHydrationDivergence.ts +30 -0
- package/src/lib/ui/startClient.ts +22 -1
- package/src/lib/ui/subscribeCacheStaleness.ts +85 -0
- 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 {
|
|
90
|
-
|
|
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`
|
|
118
|
-
emitting
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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),
|