@abide/abide 0.55.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/CHANGELOG.md +14 -0
- package/package.json +1 -1
- package/src/lib/ui/compile/compileShadow.ts +113 -8
- package/src/lib/ui/compile/desugarSignals.ts +187 -0
- package/src/lib/ui/compile/liftScriptSeed.ts +47 -0
- package/src/lib/ui/linked.ts +18 -1
- package/src/lib/ui/runtime/createAsyncCell.ts +10 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
# abide
|
|
2
2
|
|
|
3
|
+
## 0.56.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- lift buried async sub-expressions in state/linked/computed seeds (ADR-0032, script side) ([`6b269f4`](https://github.com/briancray/abide/commit/6b269f45d8ab2e117d62684620542cfd1199631e))
|
|
8
|
+
- a linked seed that reads a pending blocking cell becomes a blocking AsyncState ([`8329365`](https://github.com/briancray/abide/commit/83293652689d6a95c8cf4ffe66722e33be1aa63b))
|
|
9
|
+
- a034b2d: `linked` seed that reads a pending blocking cell becomes a blocking `AsyncState`
|
|
10
|
+
|
|
11
|
+
A `linked()` whose seed transitively reads a blocking (`await`-marked) async cell used to throw a `SuspenseSignal` out of its eager reseed effect at construction — the effect is a leaf with no reader to swallow the suspend, so it escaped to the caller. The classification probe now routes a seed that suspends to a writable async cell, and that cell marks itself blocking when its seed synchronously suspends, so its own reads pause too. A blocking read now behaves the same inside a `linked` seed as it does in a `computed` seed or a template binding.
|
|
12
|
+
|
|
13
|
+
- a034b2d: Lift buried async sub-expressions in `state` / `state.linked` / `state.computed` seeds
|
|
14
|
+
|
|
15
|
+
A script seed whose argument buries a promise/stream call — `state.linked(getSession()?.filteredSources ?? [])` — now lifts that call to an injected streaming peek-cell, exactly as a `{#if getSession()?.x}` template position already does. It reads `undefined` while pending (composing with `?.`/`??`) instead of type-erroring on `Promise.filteredSources`, and reseeds when the source resolves. A bare whole-seed call/identifier (`state.computed(getRates())`, `chat.done()`) is unchanged — only a compound seed that buries an async call is lifted. This closes the gap where the script side was less forgiving than the template side.
|
|
16
|
+
|
|
3
17
|
## 0.55.0
|
|
4
18
|
|
|
5
19
|
### Minor Changes
|
package/package.json
CHANGED
|
@@ -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
|
|
@@ -1,8 +1,11 @@
|
|
|
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'
|
|
8
11
|
import { wrapSeed } from './wrapSeed.ts'
|
|
@@ -279,6 +282,77 @@ export function desugarSignals(
|
|
|
279
282
|
}
|
|
280
283
|
return seedKind(argument) === 'promise'
|
|
281
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
|
+
}
|
|
282
356
|
for (const statement of source.statements) {
|
|
283
357
|
if (!ts.isVariableStatement(statement)) {
|
|
284
358
|
continue
|
|
@@ -317,6 +391,16 @@ export function desugarSignals(
|
|
|
317
391
|
cellReadNames.add(declaration.name.text)
|
|
318
392
|
continue
|
|
319
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
|
+
}
|
|
320
404
|
if (isPlainStateSlot(declaration, bindings)) {
|
|
321
405
|
/* Plain `state(initial)` → a serializable `model` doc slot. */
|
|
322
406
|
stateNames.add(declaration.name.text)
|
|
@@ -394,6 +478,7 @@ export function desugarSignals(
|
|
|
394
478
|
writtenNames,
|
|
395
479
|
isEagerStreamComputed,
|
|
396
480
|
isPromiseComputed,
|
|
481
|
+
seedLiftFor,
|
|
397
482
|
),
|
|
398
483
|
)
|
|
399
484
|
}
|
|
@@ -422,9 +507,11 @@ function loweredStatement(
|
|
|
422
507
|
writtenNames: ReadonlySet<string>,
|
|
423
508
|
isEagerStreamComputed: EagerStreamPredicate,
|
|
424
509
|
isPromiseComputed: EagerStreamPredicate,
|
|
510
|
+
seedLiftFor: (declaration: ts.VariableDeclaration) => ScriptSeedLift | undefined,
|
|
425
511
|
): ts.Statement[] {
|
|
426
512
|
rejectMixedDeclaration(statement, bindings)
|
|
427
513
|
return (
|
|
514
|
+
liftedSeedStatements(statement, bindings, seedLiftFor) ??
|
|
428
515
|
injectedComputedStatements(statement, injectedCellNames, blockingCellNames) ??
|
|
429
516
|
stateAssignmentStatements(statement, bindings) ??
|
|
430
517
|
computedStatements(
|
|
@@ -439,6 +526,106 @@ function loweredStatement(
|
|
|
439
526
|
)
|
|
440
527
|
}
|
|
441
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
|
+
|
|
442
629
|
/* Lowers a synthetic `const __vN = computed(<seed>)` (an async (sub)expression peek-cell
|
|
443
630
|
`analyzeComponent` injected, ADR-0032) to a `scope().trackedComputed(...)` cell read via
|
|
444
631
|
`$$readCell`. Two shapes, by the seed:
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { liftAsyncSubExpressions } from './liftAsyncSubExpressions.ts'
|
|
2
|
+
import type { InjectedCell } from './lowerAsyncInterpolations.ts'
|
|
3
|
+
import type { InterpolationClassifier } from './types/InterpolationClassifier.ts'
|
|
4
|
+
|
|
5
|
+
/* The pieces a script-seed lift yields: the async sub-expressions hoisted out of a
|
|
6
|
+
`state`/`linked`/`computed` seed argument (each an injected peek-cell) plus the seed
|
|
7
|
+
rewritten to reference them by bare `__vsN`. `desugarSignals` turns `cells` into
|
|
8
|
+
`scope().trackedComputed(...)` consts emitted BEFORE the owning declaration and parses
|
|
9
|
+
`rewrittenSeedText` back into the lowered seed. */
|
|
10
|
+
export type ScriptSeedLift = { cells: InjectedCell[]; rewrittenSeedText: string }
|
|
11
|
+
|
|
12
|
+
/*
|
|
13
|
+
The script-side twin of the template `lowerAsyncInterpolations` walk (ADR-0032): runs
|
|
14
|
+
`liftAsyncSubExpressions` over a `state`/`state.linked`/`state.computed` seed ARGUMENT so a
|
|
15
|
+
buried promise/stream sub-expression (`getSession()?.filteredSources ?? []`) lifts to an
|
|
16
|
+
injected streaming peek-cell — reading `undefined` while pending so `?.`/`??` compose — exactly
|
|
17
|
+
as it would in a `{#if getSession()?.x}` template position. Only PROPER sub-expressions lift: a
|
|
18
|
+
seed that IS a single async expression (`computed(getSession())`, `linked(getStream())`) is left
|
|
19
|
+
whole for the existing whole-seed classification / runtime probe, so this never double-wraps.
|
|
20
|
+
|
|
21
|
+
`classify` absent (no warm program) ⇒ no lift — a buried bare-async sub-expression needs the
|
|
22
|
+
checker to know it is a promise, and a seed carrying a top-level `await` is already routed by the
|
|
23
|
+
caller's whole-seed classification, so fail-open here is a plain no-op. `seedLoc` is the seed's
|
|
24
|
+
absolute source offset, so each lifted span resolves against the shadow's source→shadow mappings.
|
|
25
|
+
*/
|
|
26
|
+
export function liftScriptSeed(
|
|
27
|
+
seedText: string,
|
|
28
|
+
seedLoc: number,
|
|
29
|
+
classify: InterpolationClassifier | undefined,
|
|
30
|
+
mint: () => string,
|
|
31
|
+
): ScriptSeedLift | undefined {
|
|
32
|
+
if (classify === undefined) {
|
|
33
|
+
return undefined
|
|
34
|
+
}
|
|
35
|
+
const result = liftAsyncSubExpressions(seedText, seedLoc, classify, mint, 'content')
|
|
36
|
+
if (result.lifts.length === 0) {
|
|
37
|
+
return undefined
|
|
38
|
+
}
|
|
39
|
+
/* A single span covering the whole seed is the whole-seed case (`computed(getSession())`) —
|
|
40
|
+
leave it to the existing classification (`isPromiseComputed`) / the runtime probe rather
|
|
41
|
+
than pointlessly wrapping the seed in its own cell. Only proper sub-expressions lift. */
|
|
42
|
+
const only = result.spans.length === 1 ? result.spans[0] : undefined
|
|
43
|
+
if (only !== undefined && seedText.slice(only.start, only.end).trim() === seedText.trim()) {
|
|
44
|
+
return undefined
|
|
45
|
+
}
|
|
46
|
+
return { cells: result.lifts, rewrittenSeedText: result.code }
|
|
47
|
+
}
|
package/src/lib/ui/linked.ts
CHANGED
|
@@ -5,6 +5,7 @@ import { createAsyncCell } from './runtime/createAsyncCell.ts'
|
|
|
5
5
|
import { createEffectNode } from './runtime/createEffectNode.ts'
|
|
6
6
|
import { isAsyncFunction } from './runtime/isAsyncFunction.ts'
|
|
7
7
|
import { routeBareSeed } from './runtime/routeBareSeed.ts'
|
|
8
|
+
import { SuspenseSignal } from './runtime/SuspenseSignal.ts'
|
|
8
9
|
import type { State } from './runtime/types/State.ts'
|
|
9
10
|
import { state } from './state.ts'
|
|
10
11
|
|
|
@@ -67,10 +68,26 @@ export function linked<T>(
|
|
|
67
68
|
non-stream value falls through to the plain synchronous cell. */
|
|
68
69
|
let probe: unknown
|
|
69
70
|
let threw = false
|
|
71
|
+
let suspended = false
|
|
70
72
|
try {
|
|
71
73
|
probe = seed()
|
|
72
|
-
} catch {
|
|
74
|
+
} catch (error) {
|
|
73
75
|
threw = true
|
|
76
|
+
suspended = error instanceof SuspenseSignal
|
|
77
|
+
}
|
|
78
|
+
/* The seed SUSPENDED — it read a pending BLOCKING cell (`await`-marked). A sync `state`
|
|
79
|
+
here would hold `undefined` while the reseed effect re-threw the suspend, letting it
|
|
80
|
+
escape at construction. Route to the eager async cell instead: `createAsyncCell` already
|
|
81
|
+
treats a synchronously-suspending seed as "pending" — it stays in flight, the throwing
|
|
82
|
+
read subscribed its effect to the blocking dependency, and it re-runs on settle — and it
|
|
83
|
+
marks the cell BLOCKING so its own reads pause too, matching a lazy `computed(() =>
|
|
84
|
+
blockingCell)`. This makes a blocking read behave the same in a `linked` seed as in a
|
|
85
|
+
`computed` seed or a template. A real (non-suspense) throw keeps the sync fall-through. */
|
|
86
|
+
if (suspended) {
|
|
87
|
+
return createAsyncCell(seed as () => unknown, {
|
|
88
|
+
writable: true,
|
|
89
|
+
transform: coerce,
|
|
90
|
+
}) as AsyncState<T>
|
|
74
91
|
}
|
|
75
92
|
if (!threw && isAsyncIterable(probe)) {
|
|
76
93
|
return createAsyncCell(seed as () => unknown, {
|
|
@@ -210,6 +210,16 @@ export function createAsyncCell(
|
|
|
210
210
|
promise is registered (there is none), so the SSR barrier's fixpoint drain
|
|
211
211
|
picks this cell up on that re-run instead. */
|
|
212
212
|
if (error instanceof SuspenseSignal) {
|
|
213
|
+
/* This cell's pending reads must PAUSE too — the seed derives from a blocking
|
|
214
|
+
source, so a reader peeking `undefined` (the streaming default) would race the
|
|
215
|
+
dependency instead of waiting for it. Mark blocking from the SUSPEND itself,
|
|
216
|
+
since a synchronously-suspending seed produces no promise for the `isThenable`
|
|
217
|
+
branch to read it from; honour a `streaming` opt-out for symmetry with that
|
|
218
|
+
branch. Once the seed re-runs and settles a value this flag is moot (a retained
|
|
219
|
+
value is returned before `blocking` is ever consulted). */
|
|
220
|
+
if (options.streaming !== true) {
|
|
221
|
+
blockingSource = true
|
|
222
|
+
}
|
|
213
223
|
if (myRun === runId) {
|
|
214
224
|
inFlight = undefined
|
|
215
225
|
writeNode(errorNode, undefined)
|