@abide/abide 0.54.0 → 0.56.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 +330 -498
- package/CHANGELOG.md +63 -0
- package/README.md +166 -146
- package/package.json +2 -3
- package/src/lib/server/rpc/defineRpc.ts +2 -1
- package/src/lib/server/rpc/types/RpcHelper.ts +29 -22
- package/src/lib/server/runtime/SSR_SWAP_SCRIPT.ts +3 -2
- package/src/lib/server/runtime/createUiPageRenderer.ts +34 -26
- package/src/lib/shared/SEEDS.ts +33 -0
- package/src/lib/shared/SOCKET_SEED.ts +4 -5
- package/src/lib/shared/cache.ts +169 -75
- package/src/lib/shared/createRemoteFunction.ts +4 -1
- package/src/lib/shared/encodeSeedValue.ts +24 -0
- package/src/lib/shared/jsonSchemaForType.ts +3 -16
- package/src/lib/shared/peek.ts +3 -6
- package/src/lib/ui/amendReaderHook.ts +1 -1
- package/src/lib/ui/compile/UI_RUNTIME_IMPORTS.ts +1 -6
- package/src/lib/ui/compile/analyzeComponent.ts +0 -2
- package/src/lib/ui/compile/awaitSubjectExpr.ts +23 -0
- package/src/lib/ui/compile/cachedShadowProgram.ts +2 -1
- package/src/lib/ui/compile/compileComponent.ts +1 -10
- package/src/lib/ui/compile/compileShadow.ts +113 -8
- package/src/lib/ui/compile/componentOrdinals.ts +39 -0
- package/src/lib/ui/compile/desugarSignals.ts +195 -44
- package/src/lib/ui/compile/generateBuild.ts +25 -24
- package/src/lib/ui/compile/generateSSR.ts +37 -24
- package/src/lib/ui/compile/hoistableAwaits.ts +1 -11
- package/src/lib/ui/compile/isSpuriousAsyncReadDiagnostic.ts +46 -5
- package/src/lib/ui/compile/liftScriptSeed.ts +47 -0
- package/src/lib/ui/compile/lowerContext.ts +26 -6
- package/src/lib/ui/compile/lowerScript.ts +1 -11
- package/src/lib/ui/compile/renameSignalRefs.ts +7 -21
- package/src/lib/ui/compile/seedTypeClassifierForRoot.ts +1 -1
- package/src/lib/ui/compile/shadowInterpolationClassifier.ts +1 -1
- package/src/lib/ui/compile/types/AnalyzedComponent.ts +3 -5
- package/src/lib/ui/compile/wrapReactiveSeeds.ts +44 -0
- package/src/lib/ui/compile/wrapSeed.ts +28 -0
- package/src/lib/ui/computed.ts +16 -0
- package/src/lib/ui/createScope.ts +4 -3
- package/src/lib/ui/dom/anchorCursor.ts +2 -1
- package/src/lib/ui/dom/anchoredBranch.ts +14 -3
- package/src/lib/ui/dom/appendStatic.ts +11 -20
- package/src/lib/ui/dom/appendText.ts +41 -43
- package/src/lib/ui/dom/appendTextAt.ts +10 -57
- package/src/lib/ui/dom/awaitBlock.ts +30 -44
- package/src/lib/ui/dom/awaitSubject.ts +118 -0
- package/src/lib/ui/dom/claimText.ts +38 -0
- package/src/lib/ui/dom/cloneStatic.ts +2 -8
- package/src/lib/ui/dom/discardAndRebuild.ts +23 -0
- package/src/lib/ui/dom/discardBoundary.ts +5 -4
- package/src/lib/ui/dom/each.ts +3 -5
- package/src/lib/ui/dom/fillBoundary.ts +2 -1
- package/src/lib/ui/dom/hydrate.ts +5 -12
- package/src/lib/ui/dom/mountChild.ts +73 -5
- package/src/lib/ui/dom/mountSwappableRange.ts +2 -1
- package/src/lib/ui/dom/openMarker.ts +2 -4
- package/src/lib/ui/dom/outlet.ts +4 -3
- package/src/lib/ui/dom/readCell.ts +26 -11
- package/src/lib/ui/dom/skeleton.ts +2 -7
- package/src/lib/ui/dom/switchBlock.ts +2 -1
- package/src/lib/ui/dom/tryBlock.ts +26 -38
- package/src/lib/ui/dom/when.ts +6 -2
- package/src/lib/ui/linked.ts +32 -1
- package/src/lib/ui/resumeSeedScript.ts +1 -1
- package/src/lib/ui/router.ts +12 -24
- package/src/lib/ui/runtime/AsyncCellError.ts +2 -1
- package/src/lib/ui/runtime/BRANCH_SEGMENT.ts +24 -0
- package/src/lib/ui/runtime/CELL_SEED.ts +4 -6
- package/src/lib/ui/runtime/DOC_SEED.ts +5 -6
- package/src/lib/ui/runtime/RENDER.ts +7 -5
- package/src/lib/ui/runtime/RESUME.ts +5 -7
- package/src/lib/ui/runtime/SEED_MARKS.ts +14 -0
- package/src/lib/ui/runtime/SuspenseSignal.ts +2 -2
- package/src/lib/ui/runtime/claimChild.ts +5 -7
- package/src/lib/ui/runtime/claimExpected.ts +2 -6
- package/src/lib/ui/runtime/claimMarker.ts +13 -0
- package/src/lib/ui/runtime/claimRun.ts +22 -0
- package/src/lib/ui/runtime/consumeSeed.ts +38 -0
- package/src/lib/ui/runtime/createAsyncCell.ts +66 -8
- package/src/lib/ui/runtime/createComputedNode.ts +1 -0
- package/src/lib/ui/runtime/createEffectNode.ts +1 -0
- package/src/lib/ui/runtime/createSignalNode.ts +1 -0
- package/src/lib/ui/runtime/parkCursor.ts +10 -0
- package/src/lib/ui/runtime/readNode.ts +21 -1
- package/src/lib/ui/runtime/renderPath.ts +2 -2
- package/src/lib/ui/runtime/routeBareSeed.ts +28 -0
- package/src/lib/ui/runtime/runHydrationPass.ts +69 -0
- package/src/lib/ui/runtime/runNode.ts +14 -0
- package/src/lib/ui/runtime/types/HydrationCursor.ts +8 -0
- package/src/lib/ui/runtime/types/ReactiveNode.ts +5 -0
- package/src/lib/ui/settleAsyncCells.ts +30 -3
- package/src/lib/ui/startClient.ts +0 -7
- package/src/lib/ui/tryEncodeResume.ts +6 -15
- package/src/lib/ui/watch.ts +3 -11
- package/src/lib/ui/dom/mountStreamedChild.ts +0 -84
- package/src/lib/ui/dom/readCellBlocking.ts +0 -29
- package/src/lib/ui/runtime/restoreWarmSeeds.ts +0 -20
- package/src/lib/ui/runtime/warmSeedBackup.ts +0 -10
|
@@ -120,7 +120,6 @@ export function analyzeComponent(
|
|
|
120
120
|
derivedNames,
|
|
121
121
|
computedNames,
|
|
122
122
|
cellReadNames,
|
|
123
|
-
blockingCellNames: allBlockingCellNames,
|
|
124
123
|
droppedReactiveImports,
|
|
125
124
|
} = lowerScript(
|
|
126
125
|
fullScriptBody,
|
|
@@ -143,7 +142,6 @@ export function analyzeComponent(
|
|
|
143
142
|
derivedNames,
|
|
144
143
|
computedNames,
|
|
145
144
|
cellReadNames,
|
|
146
|
-
blockingCellNames: allBlockingCellNames,
|
|
147
145
|
nodes,
|
|
148
146
|
styles,
|
|
149
147
|
/* Hydration adopts every block in place — including `await`, which resumes
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/*
|
|
2
|
+
The lowered `{#await X}` subject, normalised so a cell subject AWAITS its resolution
|
|
3
|
+
(ADR-0047): a BARE async-cell reference is passed RAW (not peeked to `$$readCell(cell)`)
|
|
4
|
+
and wrapped in `$$awaitSubject(...)`, which resolves a cell to a promise-of-its-value —
|
|
5
|
+
so `{#await rates}` (a cell) shows pending then the value; any other subject lowers
|
|
6
|
+
unchanged, so `{#await getFoo()}` (a plain promise) is byte-identical. A name currently
|
|
7
|
+
SHADOWED by a block binding (`{#for rates of …}` / `{:then rates}` over a component cell
|
|
8
|
+
`rates`) is the binder's local, not the cell — it lowers through `lowerExpression` like any
|
|
9
|
+
other subject, matching how every other reference site honours the shadow stack. ONE
|
|
10
|
+
decision site shared by both back-ends (the client `awaitBlock` emit and SSR's blocking /
|
|
11
|
+
streaming / flight-hoist emits), so the bare-cell classification can't drift between them.
|
|
12
|
+
*/
|
|
13
|
+
export function awaitSubjectExpr(
|
|
14
|
+
promiseCode: string,
|
|
15
|
+
cellReadNames: ReadonlySet<string>,
|
|
16
|
+
isShadowed: (name: string) => boolean,
|
|
17
|
+
lowerExpression: (code: string) => string,
|
|
18
|
+
): string {
|
|
19
|
+
const bare = promiseCode.trim()
|
|
20
|
+
return cellReadNames.has(bare) && !isShadowed(bare)
|
|
21
|
+
? `$$awaitSubject(${bare})`
|
|
22
|
+
: lowerExpression(promiseCode)
|
|
23
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { messageFromError } from '../../shared/messageFromError.ts'
|
|
1
2
|
import { createShadowProgram, type ShadowProgram } from './createShadowProgram.ts'
|
|
2
3
|
|
|
3
4
|
/*
|
|
@@ -26,7 +27,7 @@ export function cachedShadowProgram(
|
|
|
26
27
|
cache.set(root, build(root))
|
|
27
28
|
} catch (error) {
|
|
28
29
|
cache.set(root, undefined)
|
|
29
|
-
const detail =
|
|
30
|
+
const detail = messageFromError(error)
|
|
30
31
|
console.warn(
|
|
31
32
|
`[abide] couldn't build the type program for ${root}: ${detail}\n` +
|
|
32
33
|
' type-directed async detection is disabled for this project — a bare promise/stream\n' +
|
|
@@ -29,15 +29,7 @@ export function compileComponent(
|
|
|
29
29
|
a direct caller (tests) omits it and the front-end runs here — threading `classify` and
|
|
30
30
|
`seedClassify` so type-directed interpolation + cell lowering happen on this path too. */
|
|
31
31
|
const resolved = analyzed ?? analyzeComponent(source, scopeSeed, classify, seedClassify)
|
|
32
|
-
const {
|
|
33
|
-
script,
|
|
34
|
-
stateNames,
|
|
35
|
-
derivedNames,
|
|
36
|
-
computedNames,
|
|
37
|
-
cellReadNames,
|
|
38
|
-
blockingCellNames,
|
|
39
|
-
nodes,
|
|
40
|
-
} = resolved
|
|
32
|
+
const { script, stateNames, derivedNames, computedNames, cellReadNames, nodes } = resolved
|
|
41
33
|
const build = generateBuild(
|
|
42
34
|
nodes,
|
|
43
35
|
'host',
|
|
@@ -46,7 +38,6 @@ export function compileComponent(
|
|
|
46
38
|
computedNames,
|
|
47
39
|
isLayout,
|
|
48
40
|
cellReadNames,
|
|
49
|
-
blockingCellNames,
|
|
50
41
|
)
|
|
51
42
|
/* The scoped CSS is bundled into the entry stylesheet (see `abideUiPlugin`), not
|
|
52
43
|
injected at runtime; the build only needs the `data-a-…` scope attributes on
|
|
@@ -4,7 +4,11 @@ import { attrLiftPosition } from './attrLiftPosition.ts'
|
|
|
4
4
|
import { hasTopLevelAwait } from './hasTopLevelAwait.ts'
|
|
5
5
|
import { isFunctionScopeBoundary } from './isFunctionScopeBoundary.ts'
|
|
6
6
|
import { isWhitespaceText } from './isWhitespaceText.ts'
|
|
7
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
type LiftPosition,
|
|
9
|
+
type LiftSpan,
|
|
10
|
+
liftAsyncSubExpressions,
|
|
11
|
+
} from './liftAsyncSubExpressions.ts'
|
|
8
12
|
import { parseTemplate } from './parseTemplate.ts'
|
|
9
13
|
import {
|
|
10
14
|
NESTED_REACTIVE_BINDINGS,
|
|
@@ -94,7 +98,7 @@ export function compileShadow(
|
|
|
94
98
|
importedReactives,
|
|
95
99
|
propsLocalName,
|
|
96
100
|
watchLocalName,
|
|
97
|
-
} = analyzeScript(scriptBody, scriptStart)
|
|
101
|
+
} = analyzeScript(scriptBody, scriptStart, classify)
|
|
98
102
|
builder.raw(shadowPreamble(importedReactives))
|
|
99
103
|
/* Every `computed`/`linked` scope line reads through this: it invokes the seed thunk and
|
|
100
104
|
unwraps a promise/stream to the value a BARE cell read peeks (ADR-0019/0032) — so an async
|
|
@@ -492,7 +496,11 @@ function collectNestedScriptAwaitDiagnostics(
|
|
|
492
496
|
module-scope type declarations, the value-typed scope lines, and the `props<Shape>()`
|
|
493
497
|
prop shapes. `scriptStart` is the body's absolute offset in the source, so verbatim
|
|
494
498
|
spans map back exactly. */
|
|
495
|
-
function analyzeScript(
|
|
499
|
+
function analyzeScript(
|
|
500
|
+
scriptBody: string,
|
|
501
|
+
scriptStart: number,
|
|
502
|
+
classify?: InterpolationClassifier,
|
|
503
|
+
): ScriptAnalysis {
|
|
496
504
|
const imports: ScopeLine[] = []
|
|
497
505
|
const types: ScopeLine[] = []
|
|
498
506
|
const scope: ScopeLine[] = []
|
|
@@ -564,7 +572,7 @@ function analyzeScript(scriptBody: string, scriptStart: number): ScriptAnalysis
|
|
|
564
572
|
continue
|
|
565
573
|
}
|
|
566
574
|
for (const declaration of reactive) {
|
|
567
|
-
scope.push(scopeLineFor(declaration, propsShapes, verbatim, span, bindings))
|
|
575
|
+
scope.push(scopeLineFor(declaration, propsShapes, verbatim, span, bindings, classify))
|
|
568
576
|
}
|
|
569
577
|
}
|
|
570
578
|
return {
|
|
@@ -623,12 +631,85 @@ function reactiveDeclarations(
|
|
|
623
631
|
|
|
624
632
|
/* Builds one scope line for a reactive declaration, projecting it to its value
|
|
625
633
|
type. A `props()` destructure contributes its whole shape (pushed into `propsShapes`). */
|
|
634
|
+
/* Splits a SEED's source text so each buried async sub-expression is wrapped in its shadow peek
|
|
635
|
+
helper (`$$peek`/`$$peekStream`) — the script-seed twin of the builder's `emitPeeked` (ADR-0032),
|
|
636
|
+
but returning a mapped `{ text, segments }` for a scope line rather than driving the builder. So a
|
|
637
|
+
bare `state.linked(getSession()?.x ?? [])` shadow-types via `$$peek(getSession())?.x ?? []` (the
|
|
638
|
+
RESOLVED value, `?.`/`??` compose) instead of erroring on `Promise.x` — mirroring exactly what the
|
|
639
|
+
`desugarSignals` lift builds. Fail-open: no classifier, a thunk seed (the walk stops at function
|
|
640
|
+
boundaries — matching the build, which also leaves a `() => …` seed unlifted), or an unparseable
|
|
641
|
+
span → the seed emits verbatim as one span. Every original char keeps its true source offset; the
|
|
642
|
+
inserted wrapper chars are unmapped, so diagnostics land precisely. */
|
|
643
|
+
function peekWrappedSeed(
|
|
644
|
+
seedText: string,
|
|
645
|
+
sourceStart: number,
|
|
646
|
+
prefixLength: number,
|
|
647
|
+
classify: InterpolationClassifier | undefined,
|
|
648
|
+
): { text: string; segments: ShadowMapping[] } {
|
|
649
|
+
const verbatimResult = {
|
|
650
|
+
text: seedText,
|
|
651
|
+
segments: [{ shadowStart: prefixLength, sourceStart, length: seedText.length }],
|
|
652
|
+
}
|
|
653
|
+
if (classify === undefined) {
|
|
654
|
+
return verbatimResult
|
|
655
|
+
}
|
|
656
|
+
let spans: LiftSpan[]
|
|
657
|
+
try {
|
|
658
|
+
let counter = 0
|
|
659
|
+
spans = liftAsyncSubExpressions(
|
|
660
|
+
seedText,
|
|
661
|
+
sourceStart,
|
|
662
|
+
classify,
|
|
663
|
+
() => `__v${counter++}`,
|
|
664
|
+
'content',
|
|
665
|
+
).spans.filter((span) => !span.blocking)
|
|
666
|
+
} catch {
|
|
667
|
+
return verbatimResult
|
|
668
|
+
}
|
|
669
|
+
if (spans.length === 0) {
|
|
670
|
+
return verbatimResult
|
|
671
|
+
}
|
|
672
|
+
/* A single span covering the WHOLE seed is the whole-seed case (`computed(getRates())`): the
|
|
673
|
+
`$$cellValue`/`$$cellValuePending` wrapper the caller already applies unwraps it, so peek-
|
|
674
|
+
wrapping it here would double-unwrap (`T` → `T | undefined`). Skip it — only PROPER sub-
|
|
675
|
+
expressions wrap, matching `liftScriptSeed`'s identical whole-seed skip on the build side. */
|
|
676
|
+
const only = spans.length === 1 ? spans[0] : undefined
|
|
677
|
+
if (only !== undefined && seedText.slice(only.start, only.end).trim() === seedText.trim()) {
|
|
678
|
+
return verbatimResult
|
|
679
|
+
}
|
|
680
|
+
let text = ''
|
|
681
|
+
const segments: ShadowMapping[] = []
|
|
682
|
+
const pushMapped = (chunk: string, chunkSource: number): void => {
|
|
683
|
+
segments.push({
|
|
684
|
+
shadowStart: prefixLength + text.length,
|
|
685
|
+
sourceStart: chunkSource,
|
|
686
|
+
length: chunk.length,
|
|
687
|
+
})
|
|
688
|
+
text += chunk
|
|
689
|
+
}
|
|
690
|
+
let cursor = 0
|
|
691
|
+
for (const span of spans) {
|
|
692
|
+
if (span.start > cursor) {
|
|
693
|
+
pushMapped(seedText.slice(cursor, span.start), sourceStart + cursor)
|
|
694
|
+
}
|
|
695
|
+
text += span.kind === 'asyncIterable' ? '$$peekStream(' : '$$peek('
|
|
696
|
+
pushMapped(seedText.slice(span.start, span.end), sourceStart + span.start)
|
|
697
|
+
text += ')'
|
|
698
|
+
cursor = span.end
|
|
699
|
+
}
|
|
700
|
+
if (cursor < seedText.length) {
|
|
701
|
+
pushMapped(seedText.slice(cursor), sourceStart + cursor)
|
|
702
|
+
}
|
|
703
|
+
return { text, segments }
|
|
704
|
+
}
|
|
705
|
+
|
|
626
706
|
function scopeLineFor(
|
|
627
707
|
declaration: ts.VariableDeclaration,
|
|
628
708
|
propsShapes: string[],
|
|
629
709
|
verbatim: (node: ts.Node) => string,
|
|
630
710
|
span: (node: ts.Node, prefixLength: number) => ShadowMapping,
|
|
631
711
|
bindings: ReactiveImportBindings,
|
|
712
|
+
classify?: InterpolationClassifier,
|
|
632
713
|
): ScopeLine {
|
|
633
714
|
const name = ts.isIdentifier(declaration.name) ? declaration.name.text : '_'
|
|
634
715
|
const call = declaration.initializer as ts.CallExpression
|
|
@@ -689,9 +770,12 @@ function scopeLineFor(
|
|
|
689
770
|
})
|
|
690
771
|
}
|
|
691
772
|
const prefix = `let ${name}${annotation} = (`
|
|
773
|
+
/* Peek-wrap a buried async sub-expression in the initial too (the `desugarSignals` lift
|
|
774
|
+
runs on `state` seeds), so `state(getSession()?.x ?? [])` type-checks. */
|
|
775
|
+
const seed = seedPeekWrap(init, verbatim, span, prefix.length, classify)
|
|
692
776
|
return withCalleeRef({
|
|
693
|
-
text: `${prefix}${
|
|
694
|
-
segments: [span(declaration.name, 4),
|
|
777
|
+
text: `${prefix}${seed.text});`,
|
|
778
|
+
segments: [span(declaration.name, 4), ...seed.segments],
|
|
695
779
|
})
|
|
696
780
|
}
|
|
697
781
|
/* computed<T>(compute) / linked<T>(seed) — the only callees left: T is the value
|
|
@@ -730,12 +814,33 @@ function scopeLineFor(
|
|
|
730
814
|
pre-existing type-directed case, unchanged here.) */
|
|
731
815
|
const helper = seedIsAsync(fn) && !seedIsBlocking(fn) ? '$$cellValuePending' : '$$cellValue'
|
|
732
816
|
const prefix = `${keyword} ${name}${annotation} = ${helper}(${wrapPrefix}`
|
|
817
|
+
/* Peek-wrap any buried async sub-expression in the seed (ADR-0032, script side) so a bare
|
|
818
|
+
`getSession()?.x ?? []` types as its resolved value — matching the `desugarSignals` lift.
|
|
819
|
+
A whole-seed async call / a thunk seed yields no wrap (verbatim), exactly as the build. */
|
|
820
|
+
const seed = seedPeekWrap(fn, verbatim, span, prefix.length, classify)
|
|
733
821
|
return withCalleeRef({
|
|
734
|
-
text: `${prefix}${
|
|
735
|
-
segments: [span(declaration.name, keywordOffset),
|
|
822
|
+
text: `${prefix}${seed.text}${wrapSuffix});`,
|
|
823
|
+
segments: [span(declaration.name, keywordOffset), ...seed.segments],
|
|
736
824
|
})
|
|
737
825
|
}
|
|
738
826
|
|
|
827
|
+
/* The seed peek-wrap as a scope line contributes it: a bare CALL or IDENTIFIER seed (the whole-seed
|
|
828
|
+
/ probe case — `getRates()`, `chat.done()`) stays verbatim so the `$$cellValue` wrapper unwraps it
|
|
829
|
+
and no probe receiver is lifted, mirroring `desugarSignals`'s bare-seed skip; every other seed
|
|
830
|
+
descends into `peekWrappedSeed`. */
|
|
831
|
+
function seedPeekWrap(
|
|
832
|
+
seed: ts.Expression,
|
|
833
|
+
verbatim: (node: ts.Node) => string,
|
|
834
|
+
span: (node: ts.Node, prefixLength: number) => ShadowMapping,
|
|
835
|
+
prefixLength: number,
|
|
836
|
+
classify: InterpolationClassifier | undefined,
|
|
837
|
+
): { text: string; segments: ShadowMapping[] } {
|
|
838
|
+
if (ts.isCallExpression(seed) || ts.isIdentifier(seed)) {
|
|
839
|
+
return { text: verbatim(seed), segments: [span(seed, prefixLength)] }
|
|
840
|
+
}
|
|
841
|
+
return peekWrappedSeed(verbatim(seed), span(seed, 0).sourceStart, prefixLength, classify)
|
|
842
|
+
}
|
|
843
|
+
|
|
739
844
|
/* True for a `computed`/`linked` seed that `wrapSeed` (desugarSignals) turns into an ASYNC thunk —
|
|
740
845
|
a thunk the author wrote `async`, or a bare seed carrying a top-level `await` (`computed(await
|
|
741
846
|
x)`). The shadow must agree so it unwraps the promise to the resolved value AND so the top-level
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { TemplateNode } from './types/TemplateNode.ts'
|
|
2
|
+
|
|
3
|
+
/*
|
|
4
|
+
Document-order (pre-order) numbering of every `<Child/>` mount site in a template — the
|
|
5
|
+
ordinal a child's render-path roots under (`mountChild` on the client, `$$withPath(ordinal)`
|
|
6
|
+
/ `$$renderPath(ordinal)` on the server). ONE walk both back-ends read (the `skeletonContext`
|
|
7
|
+
pattern), replacing the per-back-end `childOrdinal++` counters that numbered components in
|
|
8
|
+
each generator's own EMISSION order — an order that could silently diverge (build emits a
|
|
9
|
+
component's slot content before taking its own ordinal; SSR emits a switch's default case
|
|
10
|
+
after later cases). Numbering from the tree, not the emission, makes SSR↔client ordinal
|
|
11
|
+
congruence hold by construction.
|
|
12
|
+
|
|
13
|
+
Returns the lookup, not the map, so the missing-entry policy lives here too: a miss means a
|
|
14
|
+
back-end reached a mount site the shared walk didn't number — a structural divergence,
|
|
15
|
+
surfaced loudly at compile time rather than as a runtime warm-seed/boundary desync.
|
|
16
|
+
*/
|
|
17
|
+
export function componentOrdinals(nodes: TemplateNode[]): (node: TemplateNode) => number {
|
|
18
|
+
const ordinals = new WeakMap<TemplateNode, number>()
|
|
19
|
+
let next = 0
|
|
20
|
+
const walk = (children: TemplateNode[]): void => {
|
|
21
|
+
for (const child of children) {
|
|
22
|
+
if (child.kind === 'component') {
|
|
23
|
+
ordinals.set(child, next)
|
|
24
|
+
next += 1
|
|
25
|
+
}
|
|
26
|
+
if ('children' in child) {
|
|
27
|
+
walk(child.children)
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
walk(nodes)
|
|
32
|
+
return (node: TemplateNode): number => {
|
|
33
|
+
const ordinal = ordinals.get(node)
|
|
34
|
+
if (ordinal === undefined) {
|
|
35
|
+
throw new Error('[abide] component mount site not numbered by the shared ordinal walk')
|
|
36
|
+
}
|
|
37
|
+
return ordinal
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -1,10 +1,14 @@
|
|
|
1
1
|
import ts from 'typescript'
|
|
2
2
|
import { assignmentTargetNames } from './assignmentTargetNames.ts'
|
|
3
3
|
import { hasTopLevelAwait } from './hasTopLevelAwait.ts'
|
|
4
|
+
import { liftScriptSeed, type ScriptSeedLift } from './liftScriptSeed.ts'
|
|
5
|
+
import type { InjectedCell } from './lowerAsyncInterpolations.ts'
|
|
4
6
|
import { type ReactiveImportBindings, reactiveImportBindings } from './resolveReactiveExport.ts'
|
|
5
7
|
import { signalCallee } from './signalCallee.ts'
|
|
8
|
+
import type { InterpolationClassifier } from './types/InterpolationClassifier.ts'
|
|
6
9
|
import type { InterpolationKind } from './types/InterpolationKind.ts'
|
|
7
10
|
import type { SeedTypeClassifier } from './types/SeedTypeClassifier.ts'
|
|
11
|
+
import { wrapSeed } from './wrapSeed.ts'
|
|
8
12
|
|
|
9
13
|
/* The routing decision for a no-marker `computed(seed)`: `true` routes to the eager
|
|
10
14
|
`trackedComputed` stream cell (read via `$$readCell`, the `cellReadNames` bucket), `false`
|
|
@@ -14,28 +18,6 @@ type EagerStreamPredicate = (declaration: ts.VariableDeclaration) => boolean
|
|
|
14
18
|
|
|
15
19
|
const factory = ts.factory
|
|
16
20
|
|
|
17
|
-
/* Normalises a `computed`/`linked` argument into a seed THUNK: a literal `() => …` /
|
|
18
|
-
`function` argument passes through unchanged (the author already wrote the thunk), any
|
|
19
|
-
other expression is wrapped as `() => arg`. The wrapper arrow is made ASYNC when the
|
|
20
|
-
expression contains a top-level `await` (`computed(await load())` → `async () => await
|
|
21
|
-
load()`), which is exactly the marker the runtime primitive uses to route to an async cell. */
|
|
22
|
-
function wrapSeed(argument: ts.Expression): ts.Expression {
|
|
23
|
-
if (ts.isArrowFunction(argument) || ts.isFunctionExpression(argument)) {
|
|
24
|
-
return argument
|
|
25
|
-
}
|
|
26
|
-
const modifiers = hasTopLevelAwait(argument)
|
|
27
|
-
? [factory.createModifier(ts.SyntaxKind.AsyncKeyword)]
|
|
28
|
-
: undefined
|
|
29
|
-
return factory.createArrowFunction(
|
|
30
|
-
modifiers,
|
|
31
|
-
undefined,
|
|
32
|
-
[],
|
|
33
|
-
undefined,
|
|
34
|
-
factory.createToken(ts.SyntaxKind.EqualsGreaterThanToken),
|
|
35
|
-
argument,
|
|
36
|
-
)
|
|
37
|
-
}
|
|
38
|
-
|
|
39
21
|
/* Wraps a seed expression as an async unwrapping thunk `async () => await (arg)` — for a
|
|
40
22
|
type-directed PROMISE seed (ADR-0023/0043) the author wrote WITHOUT `await`
|
|
41
23
|
(`state.computed(getFoo())`). The `async`+`await` makes createAsyncCell unwrap the resolved
|
|
@@ -231,10 +213,6 @@ export function desugarSignals(
|
|
|
231
213
|
derivedNames: Set<string>
|
|
232
214
|
computedNames: Set<string>
|
|
233
215
|
cellReadNames: Set<string>
|
|
234
|
-
/* The full BLOCKING cell set (ADR-0042): the template-injected `await` cells passed in, unioned
|
|
235
|
-
with the script-level `await` computeds/linked collected here. The client template lowering
|
|
236
|
-
reads these via `$$readCellBlocking` (suspend-on-pending). */
|
|
237
|
-
blockingCellNames: Set<string>
|
|
238
216
|
} {
|
|
239
217
|
assertNoRemovedReaders(source)
|
|
240
218
|
/* The full set of names written anywhere (this script + the template). A prop in this set
|
|
@@ -253,10 +231,6 @@ export function desugarSignals(
|
|
|
253
231
|
One read shape covers both — `$$readCell` peeks an async cell and reads `.value` off a
|
|
254
232
|
sync one — so `linked(getStream())` auto-tracks with no read-site branching. */
|
|
255
233
|
const cellReadNames = new Set<string>()
|
|
256
|
-
/* Script-level BLOCKING cell names (ADR-0042 D6): a `computed`/`linked` whose seed carries a
|
|
257
|
-
top-level `await`. Unioned with the template-injected `blockingCellNames` and returned so the
|
|
258
|
-
CLIENT template lowering reads them via `$$readCellBlocking` (suspend-on-pending). */
|
|
259
|
-
const scriptBlockingNames = new Set<string>()
|
|
260
234
|
/* A `props()` destructure must be lowered even when it declares no reactive binding
|
|
261
235
|
(a rest-only `const { ...rest } = props()`), so track its presence on its own. */
|
|
262
236
|
let hasPropsDestructure = false
|
|
@@ -308,6 +282,77 @@ export function desugarSignals(
|
|
|
308
282
|
}
|
|
309
283
|
return seedKind(argument) === 'promise'
|
|
310
284
|
}
|
|
285
|
+
/* ADR-0032 (script side): a `state`/`linked`/`computed` seed whose ARGUMENT buries a
|
|
286
|
+
promise/stream sub-expression (`getSession()?.filteredSources ?? []`) — not the whole seed
|
|
287
|
+
— gets that sub-expression lifted to an injected streaming peek-cell, exactly as a template
|
|
288
|
+
interpolation does, so it reads `undefined` while pending and composes with `?.`/`??`. The
|
|
289
|
+
lift runs on the seed's checker type via the SAME classifier the whole-seed routing uses,
|
|
290
|
+
bridged to the interpolation shape (its `undefined` failure sentinel folds to `sync`, safe
|
|
291
|
+
because the walk only branches on `promise`/`asyncIterable`). Cached per declaration so the
|
|
292
|
+
shared `__vs` mint stays consistent between the name-collection pass and the transform. */
|
|
293
|
+
const liftClassify: InterpolationClassifier | undefined =
|
|
294
|
+
seedClassify === undefined ? undefined : (loc, code) => seedClassify(loc, code) ?? 'sync'
|
|
295
|
+
let scriptSeedCounter = 0
|
|
296
|
+
const mintScriptSeed = (): string => `__vs${scriptSeedCounter++}`
|
|
297
|
+
const seedLifts = new Map<ts.VariableDeclaration, ScriptSeedLift | undefined>()
|
|
298
|
+
const seedLiftFor = (declaration: ts.VariableDeclaration): ScriptSeedLift | undefined => {
|
|
299
|
+
if (seedLifts.has(declaration)) {
|
|
300
|
+
return seedLifts.get(declaration)
|
|
301
|
+
}
|
|
302
|
+
const lift = computeSeedLift(declaration)
|
|
303
|
+
seedLifts.set(declaration, lift)
|
|
304
|
+
return lift
|
|
305
|
+
}
|
|
306
|
+
/* Applies the per-callee gate then runs the lift. A seed the WHOLE-seed classification already
|
|
307
|
+
claims — an author `await` (blocking), a bare promise/stream call — is left untouched so the
|
|
308
|
+
existing routing (or the runtime probe) owns it and nothing double-wraps; only a buried
|
|
309
|
+
sub-expression in an otherwise-sync seed lifts. */
|
|
310
|
+
function computeSeedLift(declaration: ts.VariableDeclaration): ScriptSeedLift | undefined {
|
|
311
|
+
if (liftClassify === undefined) {
|
|
312
|
+
return undefined
|
|
313
|
+
}
|
|
314
|
+
/* Only a single-declaration statement lifts: the injected peek-cells are emitted before the
|
|
315
|
+
owning statement, which can't split a `let a = …, b = …` list cleanly — and a
|
|
316
|
+
`rejectMixedDeclaration`-legal same-kind multi-declaration seed-lift is vanishingly rare.
|
|
317
|
+
Leaving it unlifted degrades to the pre-lift behavior (no injected orphan cell). */
|
|
318
|
+
const list = declaration.parent
|
|
319
|
+
if (!ts.isVariableDeclarationList(list) || list.declarations.length !== 1) {
|
|
320
|
+
return undefined
|
|
321
|
+
}
|
|
322
|
+
const callee = signalCallee(declaration, bindings)
|
|
323
|
+
if (callee === 'computed') {
|
|
324
|
+
if (
|
|
325
|
+
!isComputedSlot(declaration, bindings) ||
|
|
326
|
+
isAsyncComputed(declaration) ||
|
|
327
|
+
isEagerStreamComputed(declaration) ||
|
|
328
|
+
isPromiseComputed(declaration)
|
|
329
|
+
) {
|
|
330
|
+
return undefined
|
|
331
|
+
}
|
|
332
|
+
} else if (callee === 'linked' || callee === 'state') {
|
|
333
|
+
const argument = seedArgument(declaration)
|
|
334
|
+
if (argument === undefined || isAsyncSeed(wrapSeed(argument))) {
|
|
335
|
+
return undefined
|
|
336
|
+
}
|
|
337
|
+
} else {
|
|
338
|
+
return undefined
|
|
339
|
+
}
|
|
340
|
+
const argument = seedArgument(declaration)
|
|
341
|
+
if (argument === undefined) {
|
|
342
|
+
return undefined
|
|
343
|
+
}
|
|
344
|
+
/* A bare CALL or IDENTIFIER seed is the whole-seed case (`computed(getRates())`,
|
|
345
|
+
`linked(chat.done())`, `state.computed(getRates.refreshing({...}))`) — the existing
|
|
346
|
+
whole-seed routing / runtime probe owns it, and descending into it would lift a probe
|
|
347
|
+
RECEIVER (`chat` out of `chat.done()`), breaking the call. Only a COMPOUND seed — a
|
|
348
|
+
member/binary/etc. that BURIES an async call — lifts. Mirrors `isBareCallComputed`. */
|
|
349
|
+
if (ts.isCallExpression(argument) || ts.isIdentifier(argument)) {
|
|
350
|
+
return undefined
|
|
351
|
+
}
|
|
352
|
+
const start = argument.getStart(source)
|
|
353
|
+
const seedText = source.text.slice(start, argument.getEnd())
|
|
354
|
+
return liftScriptSeed(seedText, scriptBase + start, liftClassify, mintScriptSeed)
|
|
355
|
+
}
|
|
311
356
|
for (const statement of source.statements) {
|
|
312
357
|
if (!ts.isVariableStatement(statement)) {
|
|
313
358
|
continue
|
|
@@ -346,6 +391,16 @@ export function desugarSignals(
|
|
|
346
391
|
cellReadNames.add(declaration.name.text)
|
|
347
392
|
continue
|
|
348
393
|
}
|
|
394
|
+
/* Register any injected `__vsN` peek-cell a buried async seed sub-expression lifts, so
|
|
395
|
+
the reference-lowering emits `$$readCell(__vsN)` for its reads. The owning
|
|
396
|
+
declaration keeps its own bucket (a lifted computed's seed is now SYNC — a lazy
|
|
397
|
+
`derive` — while a lifted `linked`/`state` stays its usual cell). */
|
|
398
|
+
const seedLift = seedLiftFor(declaration)
|
|
399
|
+
if (seedLift !== undefined) {
|
|
400
|
+
for (const cell of seedLift.cells) {
|
|
401
|
+
cellReadNames.add(cell.name)
|
|
402
|
+
}
|
|
403
|
+
}
|
|
349
404
|
if (isPlainStateSlot(declaration, bindings)) {
|
|
350
405
|
/* Plain `state(initial)` → a serializable `model` doc slot. */
|
|
351
406
|
stateNames.add(declaration.name.text)
|
|
@@ -359,27 +414,21 @@ export function desugarSignals(
|
|
|
359
414
|
isEagerStreamComputed(declaration) ||
|
|
360
415
|
isPromiseComputed(declaration)
|
|
361
416
|
) {
|
|
417
|
+
/* Async `computed` (author `await`), an eager stream, or a promise seed — all
|
|
418
|
+
read via `$$readCell(name)`. Whether a pending read PAUSES (author `await`,
|
|
419
|
+
a blocking cell) or peeks `undefined` (streaming) is carried by the cell
|
|
420
|
+
itself at construction (the `trackedComputed(thunk, streaming)` flag), so the
|
|
421
|
+
read form is uniform and nothing blocking-specific is collected here. */
|
|
362
422
|
cellReadNames.add(declaration.name.text)
|
|
363
|
-
/* A blocking async computed (author `await`) reads suspend-on-pending; a bare
|
|
364
|
-
promise (isPromiseComputed) carries no `await` → streaming, so isBlockingSeed
|
|
365
|
-
is false and it never joins this set. */
|
|
366
|
-
const argument = seedArgument(declaration)
|
|
367
|
-
if (argument !== undefined && isBlockingSeed(argument)) {
|
|
368
|
-
scriptBlockingNames.add(declaration.name.text)
|
|
369
|
-
}
|
|
370
423
|
} else {
|
|
371
424
|
computedNames.add(declaration.name.text)
|
|
372
425
|
}
|
|
373
426
|
} else if (callee === 'linked') {
|
|
374
427
|
/* `linked` → a cell read via `$$readCell(name)`: a plain `State` when the seed
|
|
375
428
|
is synchronous, an `AsyncState` when it tracks a promise/stream — one read
|
|
376
|
-
shape auto-tracks whichever source the runtime primitive resolved to.
|
|
429
|
+
shape auto-tracks whichever source the runtime primitive resolved to. Its
|
|
430
|
+
blocking-ness (author `await`) likewise rides on the constructed cell. */
|
|
377
431
|
cellReadNames.add(declaration.name.text)
|
|
378
|
-
/* A blocking async linked (author `await`) reads suspend-on-pending, like computed. */
|
|
379
|
-
const argument = seedArgument(declaration)
|
|
380
|
-
if (argument !== undefined && isBlockingSeed(argument)) {
|
|
381
|
-
scriptBlockingNames.add(declaration.name.text)
|
|
382
|
-
}
|
|
383
432
|
} else if (callee === 'state') {
|
|
384
433
|
/* `state(initial, transform)` → a `.value` cell (its write-coercion transform
|
|
385
434
|
forces a local store); referenced as `name.value`, unchanged. */
|
|
@@ -429,6 +478,7 @@ export function desugarSignals(
|
|
|
429
478
|
writtenNames,
|
|
430
479
|
isEagerStreamComputed,
|
|
431
480
|
isPromiseComputed,
|
|
481
|
+
seedLiftFor,
|
|
432
482
|
),
|
|
433
483
|
)
|
|
434
484
|
}
|
|
@@ -441,7 +491,6 @@ export function desugarSignals(
|
|
|
441
491
|
derivedNames,
|
|
442
492
|
computedNames,
|
|
443
493
|
cellReadNames,
|
|
444
|
-
blockingCellNames: new Set([...blockingCellNames, ...scriptBlockingNames]),
|
|
445
494
|
}
|
|
446
495
|
}
|
|
447
496
|
|
|
@@ -458,9 +507,11 @@ function loweredStatement(
|
|
|
458
507
|
writtenNames: ReadonlySet<string>,
|
|
459
508
|
isEagerStreamComputed: EagerStreamPredicate,
|
|
460
509
|
isPromiseComputed: EagerStreamPredicate,
|
|
510
|
+
seedLiftFor: (declaration: ts.VariableDeclaration) => ScriptSeedLift | undefined,
|
|
461
511
|
): ts.Statement[] {
|
|
462
512
|
rejectMixedDeclaration(statement, bindings)
|
|
463
513
|
return (
|
|
514
|
+
liftedSeedStatements(statement, bindings, seedLiftFor) ??
|
|
464
515
|
injectedComputedStatements(statement, injectedCellNames, blockingCellNames) ??
|
|
465
516
|
stateAssignmentStatements(statement, bindings) ??
|
|
466
517
|
computedStatements(
|
|
@@ -475,6 +526,106 @@ function loweredStatement(
|
|
|
475
526
|
)
|
|
476
527
|
}
|
|
477
528
|
|
|
529
|
+
/* Parses a lifted seed fragment (`getSession()` / `__vs0?.filteredSources ?? []`) back into an
|
|
530
|
+
expression node. Wrapped in parens so an object-literal or comma seed parses as an expression;
|
|
531
|
+
the node is synthetic (mini-parse positions), safe because every downstream pass — reference
|
|
532
|
+
renaming, doc-access lowering — is position-independent. */
|
|
533
|
+
function parseSeedExpression(code: string): ts.Expression {
|
|
534
|
+
const file = ts.createSourceFile('__seed.ts', `(${code})`, ts.ScriptTarget.Latest, true)
|
|
535
|
+
const statement = file.statements[0]
|
|
536
|
+
if (statement !== undefined && ts.isExpressionStatement(statement)) {
|
|
537
|
+
const expression = statement.expression
|
|
538
|
+
return ts.isParenthesizedExpression(expression) ? expression.expression : expression
|
|
539
|
+
}
|
|
540
|
+
throw new Error(`abide: could not parse lifted seed expression: ${code}`)
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
/* `() => (<expr>)` — the bare thunk an injected STREAM peek-cell wraps its source in (byte-mirrors
|
|
544
|
+
an explicit `state.computed(getStream())`). */
|
|
545
|
+
function bareThunk(expression: ts.Expression): ts.Expression {
|
|
546
|
+
return factory.createArrowFunction(
|
|
547
|
+
undefined,
|
|
548
|
+
undefined,
|
|
549
|
+
[],
|
|
550
|
+
undefined,
|
|
551
|
+
factory.createToken(ts.SyntaxKind.EqualsGreaterThanToken),
|
|
552
|
+
factory.createParenthesizedExpression(expression),
|
|
553
|
+
)
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
/* The injected `const __vsN = scope().trackedComputed(...)` cell for one lifted sub-expression —
|
|
557
|
+
the script-side twin of `injectedComputedStatements`: a PROMISE sub-expression becomes an async
|
|
558
|
+
unwrapping thunk carrying the streaming flag (`!blocking`), a STREAM stays a bare probe thunk. */
|
|
559
|
+
function injectedSeedCell(cell: InjectedCell): ts.Statement {
|
|
560
|
+
const source = parseSeedExpression(cell.code)
|
|
561
|
+
const args =
|
|
562
|
+
cell.kind === 'promise'
|
|
563
|
+
? [wrapAwaitSeed(source), cell.blocking ? factory.createFalse() : factory.createTrue()]
|
|
564
|
+
: [bareThunk(source)]
|
|
565
|
+
return constDeclaration(cell.name, scopeMethodCall('trackedComputed', args))
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
/* If any declaration in `statement` carries a script-seed lift (ADR-0032), lowers the whole
|
|
569
|
+
statement HERE: each lifted declaration emits its injected `__vsN` peek-cells FIRST (declared
|
|
570
|
+
before the owning statement — the seed runs eagerly at construction, so an appended cell would
|
|
571
|
+
be a TDZ reference), then the owning declaration lowered over its now-SYNC rewritten seed (a
|
|
572
|
+
lifted `computed` is a lazy `derive`; a `linked`/`state` keeps its cell). A non-lifted sibling
|
|
573
|
+
in the same (same-kind) statement re-enters the normal lowering as its own single declaration.
|
|
574
|
+
Returns undefined when no declaration lifts, so the standard dispatch runs. */
|
|
575
|
+
function liftedSeedStatements(
|
|
576
|
+
statement: ts.Statement,
|
|
577
|
+
bindings: ReactiveImportBindings,
|
|
578
|
+
seedLiftFor: (declaration: ts.VariableDeclaration) => ScriptSeedLift | undefined,
|
|
579
|
+
): ts.Statement[] | undefined {
|
|
580
|
+
if (!ts.isVariableStatement(statement) || statement.declarationList.declarations.length !== 1) {
|
|
581
|
+
return undefined
|
|
582
|
+
}
|
|
583
|
+
const declaration = statement.declarationList.declarations[0]!
|
|
584
|
+
const lift = seedLiftFor(declaration)
|
|
585
|
+
if (lift === undefined || !ts.isIdentifier(declaration.name)) {
|
|
586
|
+
return undefined
|
|
587
|
+
}
|
|
588
|
+
const output: ts.Statement[] = []
|
|
589
|
+
{
|
|
590
|
+
for (const cell of lift.cells) {
|
|
591
|
+
output.push(injectedSeedCell(cell))
|
|
592
|
+
}
|
|
593
|
+
const name = declaration.name.text
|
|
594
|
+
const seed = parseSeedExpression(lift.rewrittenSeedText)
|
|
595
|
+
const callee = signalCallee(declaration, bindings)
|
|
596
|
+
const rest = (declaration.initializer as ts.CallExpression).arguments.slice(1)
|
|
597
|
+
if (callee === 'computed') {
|
|
598
|
+
/* The rewritten seed is SYNC (it reads the peek-cells), so the computed is a lazy
|
|
599
|
+
`derive` doc slot read as `name()` — recomputes when a peek-cell reseeds. */
|
|
600
|
+
output.push(
|
|
601
|
+
constDeclaration(
|
|
602
|
+
name,
|
|
603
|
+
scopeMethodCall('derive', [factory.createStringLiteral(name), wrapSeed(seed)]),
|
|
604
|
+
),
|
|
605
|
+
)
|
|
606
|
+
} else if (callee === 'linked') {
|
|
607
|
+
output.push(
|
|
608
|
+
constDeclaration(name, scopeMethodCall('linked', [wrapSeed(seed), ...rest])),
|
|
609
|
+
)
|
|
610
|
+
} else if (isPlainStateSlot(declaration, bindings)) {
|
|
611
|
+
output.push(
|
|
612
|
+
factory.createExpressionStatement(
|
|
613
|
+
factory.createAssignment(
|
|
614
|
+
factory.createPropertyAccessExpression(
|
|
615
|
+
factory.createIdentifier('$$model'),
|
|
616
|
+
name,
|
|
617
|
+
),
|
|
618
|
+
seed,
|
|
619
|
+
),
|
|
620
|
+
),
|
|
621
|
+
)
|
|
622
|
+
} else {
|
|
623
|
+
output.push(constDeclaration(name, scopeMethodCall('state', [seed, ...rest])))
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
return output
|
|
627
|
+
}
|
|
628
|
+
|
|
478
629
|
/* Lowers a synthetic `const __vN = computed(<seed>)` (an async (sub)expression peek-cell
|
|
479
630
|
`analyzeComponent` injected, ADR-0032) to a `scope().trackedComputed(...)` cell read via
|
|
480
631
|
`$$readCell`. Two shapes, by the seed:
|