@abide/abide 0.52.0 → 0.53.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 +16 -7
- package/CHANGELOG.md +25 -0
- package/README.md +7 -5
- package/package.json +2 -1
- package/src/lib/ui/compile/UI_RUNTIME_IMPORTS.ts +1 -0
- package/src/lib/ui/compile/analyzeComponent.ts +2 -0
- package/src/lib/ui/compile/compileComponent.ts +3 -1
- package/src/lib/ui/compile/compileSSR.ts +12 -0
- package/src/lib/ui/compile/compileShadow.ts +37 -34
- package/src/lib/ui/compile/desugarSignals.ts +60 -32
- package/src/lib/ui/compile/generateBuild.ts +5 -1
- package/src/lib/ui/compile/hasTopLevelAwait.ts +28 -0
- package/src/lib/ui/compile/isFunctionScopeBoundary.ts +20 -0
- package/src/lib/ui/compile/liftAsyncSubExpressions.ts +2 -2
- package/src/lib/ui/compile/lowerContext.ts +4 -0
- package/src/lib/ui/compile/lowerScript.ts +11 -1
- package/src/lib/ui/compile/renameSignalRefs.ts +18 -0
- package/src/lib/ui/compile/types/AnalyzedComponent.ts +4 -0
- package/src/lib/ui/dom/appendText.ts +29 -8
- package/src/lib/ui/dom/appendTextAt.ts +21 -4
- package/src/lib/ui/dom/attr.ts +16 -1
- package/src/lib/ui/dom/awaitBlock.ts +34 -13
- package/src/lib/ui/dom/each.ts +10 -2
- package/src/lib/ui/dom/eachAsync.ts +9 -1
- package/src/lib/ui/dom/readCellBlocking.ts +29 -0
- package/src/lib/ui/dom/readTextOrSuspend.ts +21 -0
- package/src/lib/ui/dom/spreadAttrs.ts +47 -1
- package/src/lib/ui/dom/switchBlock.ts +19 -2
- package/src/lib/ui/dom/tryBlock.ts +10 -0
- package/src/lib/ui/dom/when.ts +19 -1
- package/src/lib/ui/dom/withSuspense.ts +22 -0
- package/src/lib/ui/runtime/SuspenseSignal.ts +24 -0
- package/src/lib/ui/runtime/flushEffects.ts +18 -10
- package/src/lib/ui/watch.ts +7 -2
package/AGENTS.md
CHANGED
|
@@ -176,13 +176,19 @@ Mustache `{#…}` blocks, not `<template>`. The rendered blocks are
|
|
|
176
176
|
| `{#try}` / `{:catch}` / `{:finally}` / `{/try}` | Render error boundary. |
|
|
177
177
|
| `{#snippet name(args)}…{/snippet}` | Reusable builder; called `{name(args)}`. |
|
|
178
178
|
|
|
179
|
-
Async reads have no ceremony
|
|
180
|
-
(`{getMessages({ limit })}`)
|
|
181
|
-
`undefined` while pending (
|
|
182
|
-
`?.`, `{#if}`, and
|
|
183
|
-
probes. `{await fn()}`
|
|
184
|
-
|
|
185
|
-
the
|
|
179
|
+
Async reads have no ceremony, and `await` is the marker that picks the mode
|
|
180
|
+
(ADR-0042). A **bare call in an interpolation** (`{getMessages({ limit })}`)
|
|
181
|
+
*streams*: a peek that reads `undefined` while pending (typed `T | undefined`,
|
|
182
|
+
auto-streaming on SSR), composing with `?? fallback`, `?.`, `{#if}`, and
|
|
183
|
+
attributes; pair it with `.pending(...)` / `.error(...)` probes. `{await fn()}`
|
|
184
|
+
means *resolved*: the server blocks the flush AND the client **suspends** the
|
|
185
|
+
reading region (renders nothing) until it settles, so the value is never pending
|
|
186
|
+
at the read — `{(await fn()).field}` needs no `?.` and never derefs a pending
|
|
187
|
+
`undefined`. A refetch never re-suspends (stale-while-revalidate); warm-seed keeps
|
|
188
|
+
a hydrating read `refreshing()`, so no flash. (Consequently a no-`await` `async`
|
|
189
|
+
thunk — `computed(async () => getFoo())` — is a *streaming* cell, not blocking;
|
|
190
|
+
`await` is the sole blocking marker.) `{#await}` is the opt-in for branch structure
|
|
191
|
+
and `{:then}` narrowing — not the default.
|
|
186
192
|
|
|
187
193
|
Components are capitalised tags; the content nested in a component renders where
|
|
188
194
|
it calls `{children()}` (`{#if children}{children()}{:else}…{/if}` is the
|
|
@@ -444,6 +450,9 @@ Compiler-emitted; an author never imports these directly.
|
|
|
444
450
|
read, with legible authored-scope errors.
|
|
445
451
|
- `abide/ui/dom/readCell` — unified read of a `computed`/`linked`/derive reference
|
|
446
452
|
(async → throwing peek, function → call, sync → `.value`).
|
|
453
|
+
- `abide/ui/dom/readCellBlocking` — the blocking (`await`) variant of `readCell`: on a
|
|
454
|
+
pending cell it throws a suspense signal so the reading region withholds until the
|
|
455
|
+
value resolves, rather than reading `undefined` (client template lowering; ADR-0042).
|
|
447
456
|
- `abide/ui/dom/writeCell` — unified write of a `linked` reference from an author
|
|
448
457
|
assignment (async → `.set`, sync → `.value =`).
|
|
449
458
|
- `abide/ui/dom/cellPending` — whether a control-flow subject is a still-loading async
|
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,30 @@
|
|
|
1
1
|
# abide
|
|
2
2
|
|
|
3
|
+
## 0.53.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- shadow narrows streaming cells to T|undefined + D7 barrier tripwire (ADR-0042 D5.2/D7) ([`034a384`](https://github.com/briancray/abide/commit/034a384bdfb5b482f818de757e10c2e2e88ac52c))
|
|
8
|
+
- suspend interpolations/attributes on a pending blocking read (ADR-0042) ([`88da27c`](https://github.com/briancray/abide/commit/88da27c998666b7cca9149effd5f171f3adca8d1))
|
|
9
|
+
- flip async-thunk blocking marker + emit $$readCellBlocking on the client (ADR-0042 D5/D6) ([`a3468fe`](https://github.com/briancray/abide/commit/a3468feacbb67ac9cd337ffa2e220b10af309324))
|
|
10
|
+
- dormant client-suspense foundation for await-means-resolved (ADR-0042) ([`b5f6104`](https://github.com/briancray/abide/commit/b5f6104c41ea1d57d3347ae007cdb1b316a09784))
|
|
11
|
+
- complete the client suspense withhold surface across all template effects (ADR-0042) ([`c5db314`](https://github.com/briancray/abide/commit/c5db314fa3e0ce18294f609be5fb43e5128970d0))
|
|
12
|
+
- fa7bfed: `await` now means "resolved" for a cell and its dependents, with the client suspending to mirror the server flush barrier (ADR-0042). A blocking `await` read (`{await foo()}`, `const x = state.computed(await …)`, `{#if await …}`) reads as a resolved value on both sides: the server already blocks the flush until it settles, and the client now **withholds the reading region** (interpolation, attribute, `{#if}`/`{#each}`/`{#switch}` block) until the value resolves rather than rendering against a pending `undefined`. So `{sources.length}` on an `await` binding no longer needs `?.` and no longer crashes while pending — the region simply shows nothing until it settles, then reveals. After the first resolution a refetch never re-suspends (stale-while-revalidate); on hydrate the warm-seed keeps the read `refreshing()`, never `pending()`, so there is no flash.
|
|
13
|
+
|
|
14
|
+
**Breaking:**
|
|
15
|
+
|
|
16
|
+
- **The async modifier alone is no longer a blocking marker — `await` is.** `computed(async () => getFoo())` (an `async` thunk with no top-level `await`) now creates a **streaming** cell (ships pending, resolves on the client) instead of a blocking one. Add `await` — `computed(async () => await getFoo())` — to keep the blocking/inline-SSR behavior. `computed(await …)` and `computed(async () => await …)` are unchanged (blocking).
|
|
17
|
+
- **A streaming (no-`await`) async `computed`/`linked` now types as `T | undefined`.** Its read is honestly pending-able, so guard it with `?.`/`??` (matching the runtime). A blocking `await` binding stays `T`.
|
|
18
|
+
|
|
19
|
+
No new public API — the change is in how `await` lowers and types.
|
|
20
|
+
|
|
21
|
+
### Patch Changes
|
|
22
|
+
|
|
23
|
+
- document await-means-resolved (ADR-0042) ([`16ec341`](https://github.com/briancray/abide/commit/16ec341a6ea7da509919b37a1cdcc2f237e04c1c))
|
|
24
|
+
- close await-suspense gaps and simplify the boundary (ADR-0042 review) ([`7f9fcbb`](https://github.com/briancray/abide/commit/7f9fcbbec862eba6da3103a6aca77d505ef6f58d))
|
|
25
|
+
- type {:catch} bindings as unknown, not any (ADR-0042) ([`9a91522`](https://github.com/briancray/abide/commit/9a915229caf92914b5bcd9e5625153831783049d))
|
|
26
|
+
- withhold pending blocking reads in {#await}/{#for await} subjects (ADR-0042) ([`e58b538`](https://github.com/briancray/abide/commit/e58b538f477c2bfcec654fd1a7aab65466cc956c))
|
|
27
|
+
|
|
3
28
|
## 0.52.0
|
|
4
29
|
|
|
5
30
|
### Minor Changes
|
package/README.md
CHANGED
|
@@ -124,14 +124,16 @@ async function send() {
|
|
|
124
124
|
<section class="chat" class:empty={limit === 0} style:--rows={shown} {...rootAttrs}>
|
|
125
125
|
<h1>{title}</h1>
|
|
126
126
|
|
|
127
|
-
<!--
|
|
128
|
-
|
|
127
|
+
<!-- A bare read STREAMS: it peeks `undefined` while pending, so it composes
|
|
128
|
+
with `??` and the probes. `await` is the marker for the other mode. -->
|
|
129
129
|
<p class:loading={getMessages.pending({ limit })}>
|
|
130
130
|
{getMessages({ limit })?.length ?? 0} messages
|
|
131
131
|
{#if getMessages.error({ limit })}<span>failed to load</span>{/if}
|
|
132
132
|
</p>
|
|
133
133
|
|
|
134
|
-
<!-- `{await}`
|
|
134
|
+
<!-- `{await}` means RESOLVED: the server blocks the flush and the client suspends
|
|
135
|
+
this region until it settles, so the value is never pending at the read (no `?.`)
|
|
136
|
+
— the region just shows nothing, then reveals. -->
|
|
135
137
|
<small>today: {await countToday()}</small>
|
|
136
138
|
|
|
137
139
|
<!-- `{#await}` is the explicit opt-in — a distinct pending branch and `{:then}` narrowing. -->
|
|
@@ -150,7 +152,7 @@ async function send() {
|
|
|
150
152
|
<p>raise the limit</p>
|
|
151
153
|
{/if}
|
|
152
154
|
{:catch err}
|
|
153
|
-
<p>{err.message}</p>
|
|
155
|
+
<p>{err instanceof Error ? err.message : String(err)}</p>
|
|
154
156
|
{:finally}
|
|
155
157
|
<hr />
|
|
156
158
|
{/await}
|
|
@@ -171,7 +173,7 @@ async function send() {
|
|
|
171
173
|
{#try}
|
|
172
174
|
<p>{risky()}</p>
|
|
173
175
|
{:catch e}
|
|
174
|
-
<p>widget crashed: {e.message}</p>
|
|
176
|
+
<p>widget crashed: {e instanceof Error ? e.message : String(e)}</p>
|
|
175
177
|
{:finally}
|
|
176
178
|
<span>rendered</span>
|
|
177
179
|
{/try}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@abide/abide",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.53.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"sideEffects": false,
|
|
6
6
|
"description": "Isomorphic multimodal HTTP framework built for humans and machines in a single Bun runtime",
|
|
@@ -95,6 +95,7 @@
|
|
|
95
95
|
"./ui/dom/spreadAttrs": "./src/lib/ui/dom/spreadAttrs.ts",
|
|
96
96
|
"./ui/dom/readCall": "./src/lib/ui/dom/readCall.ts",
|
|
97
97
|
"./ui/dom/readCell": "./src/lib/ui/dom/readCell.ts",
|
|
98
|
+
"./ui/dom/readCellBlocking": "./src/lib/ui/dom/readCellBlocking.ts",
|
|
98
99
|
"./ui/dom/writeCell": "./src/lib/ui/dom/writeCell.ts",
|
|
99
100
|
"./ui/dom/cellPending": "./src/lib/ui/dom/cellPending.ts",
|
|
100
101
|
"./ui/runtime/withPath": "./src/lib/ui/runtime/withPath.ts",
|
|
@@ -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: 'readCellBlocking', specifier: 'ui/dom/readCellBlocking', alias: '$$readCellBlocking' },
|
|
59
60
|
{ name: 'writeCell', specifier: 'ui/dom/writeCell', alias: '$$writeCell' },
|
|
60
61
|
{ name: 'cellPending', specifier: 'ui/dom/cellPending', alias: '$$cellPending' },
|
|
61
62
|
{ name: 'withPath', specifier: 'ui/runtime/withPath', alias: '$$withPath' },
|
|
@@ -120,6 +120,7 @@ export function analyzeComponent(
|
|
|
120
120
|
derivedNames,
|
|
121
121
|
computedNames,
|
|
122
122
|
cellReadNames,
|
|
123
|
+
blockingCellNames: allBlockingCellNames,
|
|
123
124
|
droppedReactiveImports,
|
|
124
125
|
} = lowerScript(
|
|
125
126
|
fullScriptBody,
|
|
@@ -142,6 +143,7 @@ export function analyzeComponent(
|
|
|
142
143
|
derivedNames,
|
|
143
144
|
computedNames,
|
|
144
145
|
cellReadNames,
|
|
146
|
+
blockingCellNames: allBlockingCellNames,
|
|
145
147
|
nodes,
|
|
146
148
|
styles,
|
|
147
149
|
/* Hydration adopts every block in place — including `await`, which resumes
|
|
@@ -29,7 +29,8 @@ 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 { script, stateNames, derivedNames, computedNames, cellReadNames, nodes } =
|
|
32
|
+
const { script, stateNames, derivedNames, computedNames, cellReadNames, blockingCellNames, nodes } =
|
|
33
|
+
resolved
|
|
33
34
|
const build = generateBuild(
|
|
34
35
|
nodes,
|
|
35
36
|
'host',
|
|
@@ -38,6 +39,7 @@ export function compileComponent(
|
|
|
38
39
|
computedNames,
|
|
39
40
|
isLayout,
|
|
40
41
|
cellReadNames,
|
|
42
|
+
blockingCellNames,
|
|
41
43
|
)
|
|
42
44
|
/* The scoped CSS is bundled into the entry stylesheet (see `abideUiPlugin`), not
|
|
43
45
|
injected at runtime; the build only needs the `data-a-…` scope attributes on
|
|
@@ -66,6 +66,18 @@ export function compileSSR(
|
|
|
66
66
|
flightDecls,
|
|
67
67
|
hasStagedChildren,
|
|
68
68
|
} = generateSSR(nodes, stateNames, derivedNames, computedNames, isLayout, cellReadNames)
|
|
69
|
+
/* ADR-0042 D7 (barrier completeness): every async cell — and so every BLOCKING (`await`) cell —
|
|
70
|
+
is declared in the lowered script (`lowered`), which the body below emits BEFORE the
|
|
71
|
+
`$$settleAsyncCells` barrier; the template back-end (`ssr`) constructs no cells. A blocking
|
|
72
|
+
cell built AFTER its barrier would ship an unresolved value the client then SUSPENDS against,
|
|
73
|
+
desyncing hydration. The invariant holds by construction today (cells are a script-block
|
|
74
|
+
construct); assert it as a tripwire so a future template lowering that emits a cell
|
|
75
|
+
construction fails the build LOUDLY here instead of as a runtime hydration desync. */
|
|
76
|
+
if (/\$\$scope\(\)\.(trackedComputed|computed|linked)\(/.test(ssr)) {
|
|
77
|
+
throw new Error(
|
|
78
|
+
'[abide] a reactive cell is constructed in template output, after the async-cell barrier (ADR-0042 D7) — cells must be declared in the component script so they settle before the SSR flush.',
|
|
79
|
+
)
|
|
80
|
+
}
|
|
69
81
|
/* ADR-0039: a component with hoistable children declares `$childSlots` (the body walk reserves an
|
|
70
82
|
output slot per child) and, after the walk, awaits `$$finalizeStreamedChildren` — which fills
|
|
71
83
|
each slot inline (settled child, byte-identical to the old inline await) or with a streaming
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import ts from 'typescript'
|
|
2
2
|
import { ABIDE_PACKAGE_NAME } from '../../shared/ABIDE_PACKAGE_NAME.ts'
|
|
3
3
|
import { attrLiftPosition } from './attrLiftPosition.ts'
|
|
4
|
+
import { hasTopLevelAwait } from './hasTopLevelAwait.ts'
|
|
5
|
+
import { isFunctionScopeBoundary } from './isFunctionScopeBoundary.ts'
|
|
4
6
|
import { isWhitespaceText } from './isWhitespaceText.ts'
|
|
5
7
|
import { type LiftPosition, liftAsyncSubExpressions } from './liftAsyncSubExpressions.ts'
|
|
6
8
|
import { parseTemplate } from './parseTemplate.ts'
|
|
@@ -102,6 +104,15 @@ export function compileShadow(
|
|
|
102
104
|
builder.raw(
|
|
103
105
|
'declare function $$cellValue<R>(seed: () => R): R extends AsyncIterable<infer F> ? F : Awaited<R>\n',
|
|
104
106
|
)
|
|
107
|
+
/* The STREAMING sibling (ADR-0042): a no-`await` async `computed`/`linked` cell reads
|
|
108
|
+
`undefined`-while-pending, so its shadow type is the resolved value UNIONED with `undefined` —
|
|
109
|
+
the author must guard it (`?.`/`??`), exactly the runtime. A BLOCKING (`await`) cell uses
|
|
110
|
+
`$$cellValue` above (resolved `T`, never pending — its render region suspends). Selected in
|
|
111
|
+
`scopeLineFor` by the syntactic blocking bit. Unconditional (a named cell can occur with no
|
|
112
|
+
classifier), matching `$$cellValue`. */
|
|
113
|
+
builder.raw(
|
|
114
|
+
'declare function $$cellValuePending<R>(seed: () => R): (R extends AsyncIterable<infer F> ? F : Awaited<R>) | undefined\n',
|
|
115
|
+
)
|
|
105
116
|
/* The peek helpers the async-interpolation wrap targets (ADR-0032): `$$peek` unwraps a promise
|
|
106
117
|
sub-expression to its resolved value (`undefined` while pending) and `$$peekStream` reads an
|
|
107
118
|
async iterable's latest frame, so `getFoo()?.name`/`{#if getFoo()}` type-check against the
|
|
@@ -712,7 +723,13 @@ function scopeLineFor(
|
|
|
712
723
|
const fnIsThunk = ts.isArrowFunction(fn) || ts.isFunctionExpression(fn)
|
|
713
724
|
const wrapPrefix = fnIsThunk ? '' : seedIsAsync(fn) ? 'async () => (' : '() => ('
|
|
714
725
|
const wrapSuffix = fnIsThunk ? '' : ')'
|
|
715
|
-
|
|
726
|
+
/* ADR-0042: a STREAMING promise cell (async seed, no top-level `await`) reads
|
|
727
|
+
`undefined`-while-pending → `$$cellValuePending` (resolved `T | undefined`). A BLOCKING
|
|
728
|
+
(`await`) cell suspends its region so it is always resolved, and a sync cell is never pending
|
|
729
|
+
→ `$$cellValue` (resolved `T`). (A bare stream seed keeps `$$cellValue`'s frame type — the
|
|
730
|
+
pre-existing type-directed case, unchanged here.) */
|
|
731
|
+
const helper = seedIsAsync(fn) && !seedIsBlocking(fn) ? '$$cellValuePending' : '$$cellValue'
|
|
732
|
+
const prefix = `${keyword} ${name}${annotation} = ${helper}(${wrapPrefix}`
|
|
716
733
|
return withCalleeRef({
|
|
717
734
|
text: `${prefix}${verbatim(fn)}${wrapSuffix});`,
|
|
718
735
|
segments: [span(declaration.name, keywordOffset), span(fn, prefix.length)],
|
|
@@ -732,40 +749,20 @@ function seedIsAsync(seed: ts.Expression): boolean {
|
|
|
732
749
|
?.some((modifier) => modifier.kind === ts.SyntaxKind.AsyncKeyword) ?? false
|
|
733
750
|
)
|
|
734
751
|
}
|
|
735
|
-
return
|
|
752
|
+
return hasTopLevelAwait(seed)
|
|
736
753
|
}
|
|
737
754
|
|
|
738
|
-
/* True
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
if (ts.isAwaitExpression(child)) {
|
|
748
|
-
found = true
|
|
749
|
-
return
|
|
750
|
-
}
|
|
751
|
-
ts.forEachChild(child, visit)
|
|
755
|
+
/* True for a BLOCKING async cell (ADR-0042 D6): an async thunk whose BODY has a top-level `await`
|
|
756
|
+
(`computed(async () => await X)`), or a bare seed carrying a top-level `await` (`computed(await
|
|
757
|
+
X)` — wrapSeed makes it an async thunk over that await). An async thunk with NO await
|
|
758
|
+
(`computed(async () => getFoo())`) is STREAMING, not blocking. Shares `hasTopLevelAwait` with
|
|
759
|
+
desugarSignals' `isBlockingSeed` so the shadow type (`$$cellValue` vs `$$cellValuePending`) agrees
|
|
760
|
+
with the runtime's blocking flag — the "one predicate" ADR-0042 D5 requires. */
|
|
761
|
+
function seedIsBlocking(seed: ts.Expression): boolean {
|
|
762
|
+
if (ts.isArrowFunction(seed) || ts.isFunctionExpression(seed)) {
|
|
763
|
+
return seedIsAsync(seed) && hasTopLevelAwait(seed.body)
|
|
752
764
|
}
|
|
753
|
-
|
|
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
|
-
)
|
|
765
|
+
return hasTopLevelAwait(seed)
|
|
769
766
|
}
|
|
770
767
|
|
|
771
768
|
/* Whether any ELEMENT in the tree carries an `attach` — gates emitting the DOM-typed
|
|
@@ -1047,7 +1044,11 @@ function emitNode(node: TemplateNode, builder: Builder): void {
|
|
|
1047
1044
|
} else if (branch.branch === 'catch' && branch.as !== undefined) {
|
|
1048
1045
|
builder.raw('const ')
|
|
1049
1046
|
builder.mapped(branch.as, branch.asLoc)
|
|
1050
|
-
|
|
1047
|
+
/* `unknown`, not `any` — a `catch` error is statically unknowable, and this
|
|
1048
|
+
matches what strict-mode plain TS gives a real `catch (err)`
|
|
1049
|
+
(`useUnknownInCatchVariables`), so the author must narrow before dereferencing
|
|
1050
|
+
(`err instanceof Error ? … : …`) exactly as outside a template (ADR-0042 fidelity). */
|
|
1051
|
+
builder.raw(' = undefined as unknown;\n')
|
|
1051
1052
|
}
|
|
1052
1053
|
emitNodes(branch.children, builder)
|
|
1053
1054
|
builder.raw('}\n')
|
|
@@ -1093,7 +1094,9 @@ function emitNode(node: TemplateNode, builder: Builder): void {
|
|
|
1093
1094
|
handlers consume their own branches inline and never route through this case. */
|
|
1094
1095
|
builder.raw('{\n')
|
|
1095
1096
|
if (node.as !== undefined) {
|
|
1096
|
-
|
|
1097
|
+
/* `unknown`, not `any` — see the await-`catch` binding above (matches strict TS's
|
|
1098
|
+
`catch (err)`, forcing the author to narrow). */
|
|
1099
|
+
builder.raw(`const ${node.as} = undefined as unknown;\n`)
|
|
1097
1100
|
}
|
|
1098
1101
|
emitNodes(node.children, builder)
|
|
1099
1102
|
builder.raw('}\n')
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import ts from 'typescript'
|
|
2
2
|
import { assignmentTargetNames } from './assignmentTargetNames.ts'
|
|
3
|
+
import { hasTopLevelAwait } from './hasTopLevelAwait.ts'
|
|
3
4
|
import { type ReactiveImportBindings, reactiveImportBindings } from './resolveReactiveExport.ts'
|
|
4
5
|
import { signalCallee } from './signalCallee.ts'
|
|
5
6
|
import type { InterpolationKind } from './types/InterpolationKind.ts'
|
|
@@ -13,34 +14,6 @@ type EagerStreamPredicate = (declaration: ts.VariableDeclaration) => boolean
|
|
|
13
14
|
|
|
14
15
|
const factory = ts.factory
|
|
15
16
|
|
|
16
|
-
/* True when `node` holds an `await` that runs at the seed's own top level — one NOT nested
|
|
17
|
-
inside a further function. The walk stops at every nested function boundary, so an
|
|
18
|
-
`await` in an inner callback (`items.map(async (x) => await f(x))`) does not count: only
|
|
19
|
-
a top-level `await` marks the seed itself as an async thunk the async-cell path unwraps. */
|
|
20
|
-
function hasTopLevelAwait(node: ts.Node): boolean {
|
|
21
|
-
let found = false
|
|
22
|
-
const visit = (child: ts.Node): void => {
|
|
23
|
-
if (found) {
|
|
24
|
-
return
|
|
25
|
-
}
|
|
26
|
-
/* A nested function is its own await scope — don't descend into it. */
|
|
27
|
-
if (
|
|
28
|
-
ts.isFunctionDeclaration(child) ||
|
|
29
|
-
ts.isFunctionExpression(child) ||
|
|
30
|
-
ts.isArrowFunction(child)
|
|
31
|
-
) {
|
|
32
|
-
return
|
|
33
|
-
}
|
|
34
|
-
if (ts.isAwaitExpression(child)) {
|
|
35
|
-
found = true
|
|
36
|
-
return
|
|
37
|
-
}
|
|
38
|
-
ts.forEachChild(child, visit)
|
|
39
|
-
}
|
|
40
|
-
visit(node)
|
|
41
|
-
return found
|
|
42
|
-
}
|
|
43
|
-
|
|
44
17
|
/* Normalises a `computed`/`linked` argument into a seed THUNK: a literal `() => …` /
|
|
45
18
|
`function` argument passes through unchanged (the author already wrote the thunk), any
|
|
46
19
|
other expression is wrapped as `() => arg`. The wrapper arrow is made ASYNC when the
|
|
@@ -106,6 +79,23 @@ function isBareCallComputed(declaration: ts.VariableDeclaration): boolean {
|
|
|
106
79
|
return ts.isCallExpression(argument) || ts.isIdentifier(argument)
|
|
107
80
|
}
|
|
108
81
|
|
|
82
|
+
/* True when a `computed`/`linked` seed is a BLOCKING async cell (ADR-0042 D6): its wrapped seed
|
|
83
|
+
is an async thunk whose BODY has a top-level `await` — `computed(await X)` (wrapSeed makes it
|
|
84
|
+
`async () => await X`) or `computed(async () => await X)`. An async thunk with NO await
|
|
85
|
+
(`computed(async () => getFoo())`) is STREAMING — `await` is the sole blocking marker. A
|
|
86
|
+
sync/stream seed is not blocking. The read of a blocking cell suspends its render region until
|
|
87
|
+
the value resolves; a streaming cell reads `undefined`-while-pending. Reuses `hasTopLevelAwait`
|
|
88
|
+
applied to the thunk BODY (not the arrow), so the walk descends exactly one function level. */
|
|
89
|
+
function isBlockingSeed(argument: ts.Expression): boolean {
|
|
90
|
+
const wrapped = wrapSeed(argument)
|
|
91
|
+
if (!isAsyncSeed(wrapped)) {
|
|
92
|
+
return false
|
|
93
|
+
}
|
|
94
|
+
const body =
|
|
95
|
+
ts.isArrowFunction(wrapped) || ts.isFunctionExpression(wrapped) ? wrapped.body : wrapped
|
|
96
|
+
return hasTopLevelAwait(body)
|
|
97
|
+
}
|
|
98
|
+
|
|
109
99
|
/* Emits a compile WARNING (best-effort, never fatal) when a signal read appears AFTER the
|
|
110
100
|
first top-level `await` in an async seed — a value read there is no longer tracked, so the
|
|
111
101
|
cell won't reseed when it changes. Detection only: reads before the await (or with no
|
|
@@ -223,6 +213,10 @@ export function desugarSignals(
|
|
|
223
213
|
derivedNames: Set<string>
|
|
224
214
|
computedNames: Set<string>
|
|
225
215
|
cellReadNames: Set<string>
|
|
216
|
+
/* The full BLOCKING cell set (ADR-0042): the template-injected `await` cells passed in, unioned
|
|
217
|
+
with the script-level `await` computeds/linked collected here. The client template lowering
|
|
218
|
+
reads these via `$$readCellBlocking` (suspend-on-pending). */
|
|
219
|
+
blockingCellNames: Set<string>
|
|
226
220
|
} {
|
|
227
221
|
assertNoRemovedReaders(source)
|
|
228
222
|
/* The full set of names written anywhere (this script + the template). A prop in this set
|
|
@@ -241,6 +235,10 @@ export function desugarSignals(
|
|
|
241
235
|
One read shape covers both — `$$readCell` peeks an async cell and reads `.value` off a
|
|
242
236
|
sync one — so `linked(getStream())` auto-tracks with no read-site branching. */
|
|
243
237
|
const cellReadNames = new Set<string>()
|
|
238
|
+
/* Script-level BLOCKING cell names (ADR-0042 D6): a `computed`/`linked` whose seed carries a
|
|
239
|
+
top-level `await`. Unioned with the template-injected `blockingCellNames` and returned so the
|
|
240
|
+
CLIENT template lowering reads them via `$$readCellBlocking` (suspend-on-pending). */
|
|
241
|
+
const scriptBlockingNames = new Set<string>()
|
|
244
242
|
/* A `props()` destructure must be lowered even when it declares no reactive binding
|
|
245
243
|
(a rest-only `const { ...rest } = props()`), so track its presence on its own. */
|
|
246
244
|
let hasPropsDestructure = false
|
|
@@ -325,6 +323,11 @@ export function desugarSignals(
|
|
|
325
323
|
or its seed type resolves to a stream (`isEagerStreamComputed`, ADR-0023). */
|
|
326
324
|
if (isAsyncComputed(declaration) || isEagerStreamComputed(declaration)) {
|
|
327
325
|
cellReadNames.add(declaration.name.text)
|
|
326
|
+
/* A blocking async computed (author `await`) reads suspend-on-pending. */
|
|
327
|
+
const argument = seedArgument(declaration)
|
|
328
|
+
if (argument !== undefined && isBlockingSeed(argument)) {
|
|
329
|
+
scriptBlockingNames.add(declaration.name.text)
|
|
330
|
+
}
|
|
328
331
|
} else {
|
|
329
332
|
computedNames.add(declaration.name.text)
|
|
330
333
|
}
|
|
@@ -333,6 +336,11 @@ export function desugarSignals(
|
|
|
333
336
|
is synchronous, an `AsyncState` when it tracks a promise/stream — one read
|
|
334
337
|
shape auto-tracks whichever source the runtime primitive resolved to. */
|
|
335
338
|
cellReadNames.add(declaration.name.text)
|
|
339
|
+
/* A blocking async linked (author `await`) reads suspend-on-pending, like computed. */
|
|
340
|
+
const argument = seedArgument(declaration)
|
|
341
|
+
if (argument !== undefined && isBlockingSeed(argument)) {
|
|
342
|
+
scriptBlockingNames.add(declaration.name.text)
|
|
343
|
+
}
|
|
336
344
|
} else if (callee === 'state') {
|
|
337
345
|
/* `state(initial, transform)` → a `.value` cell (its write-coercion transform
|
|
338
346
|
forces a local store); referenced as `name.value`, unchanged. */
|
|
@@ -387,7 +395,14 @@ export function desugarSignals(
|
|
|
387
395
|
return factory.updateSourceFile(root, statements)
|
|
388
396
|
}
|
|
389
397
|
|
|
390
|
-
return {
|
|
398
|
+
return {
|
|
399
|
+
transformer,
|
|
400
|
+
stateNames,
|
|
401
|
+
derivedNames,
|
|
402
|
+
computedNames,
|
|
403
|
+
cellReadNames,
|
|
404
|
+
blockingCellNames: new Set([...blockingCellNames, ...scriptBlockingNames]),
|
|
405
|
+
}
|
|
391
406
|
}
|
|
392
407
|
|
|
393
408
|
/* The lowered form of a top-level statement: state slots → `model.x = init`
|
|
@@ -627,10 +642,23 @@ function computedStatements(
|
|
|
627
642
|
)
|
|
628
643
|
: wrapSeed(argument)
|
|
629
644
|
if (isAsyncSeed(wrapped)) {
|
|
630
|
-
/* Async seed → the eager
|
|
631
|
-
|
|
645
|
+
/* Async seed → the eager async cell (`AsyncComputed`, read via `$$readCell`), the
|
|
646
|
+
runtime unwraps the promise. `await` in the thunk body → BLOCKING (joins the SSR
|
|
647
|
+
barrier, resolves inline, client suspends its render region); async modifier with NO
|
|
648
|
+
await → STREAMING (ships pending, resolves on the client) — ADR-0042 D6. Routed
|
|
649
|
+
through `trackedComputed(thunk, streaming)` (same createAsyncCell for an async thunk)
|
|
650
|
+
to carry the flag, matching the template-injected path. */
|
|
632
651
|
warnPostAwaitReads(wrapped, signalNames)
|
|
633
|
-
|
|
652
|
+
const streaming = argument === undefined || !isBlockingSeed(argument)
|
|
653
|
+
statements.push(
|
|
654
|
+
constDeclaration(
|
|
655
|
+
name,
|
|
656
|
+
scopeMethodCall('trackedComputed', [
|
|
657
|
+
wrapped,
|
|
658
|
+
streaming ? factory.createTrue() : factory.createFalse(),
|
|
659
|
+
]),
|
|
660
|
+
),
|
|
661
|
+
)
|
|
634
662
|
} else if (isEagerStreamComputed(declaration)) {
|
|
635
663
|
/* Stream seed → the eager `trackedComputed`, which probes the seed and auto-tracks a
|
|
636
664
|
stream (`AsyncComputed`) or falls back to a lazy computed; read via `$$readCell`.
|
|
@@ -77,6 +77,10 @@ export function generateBuild(
|
|
|
77
77
|
isLayout = false,
|
|
78
78
|
/* `linked` / async `computed` names, lowered to `$$readCell(name)` in template exprs. */
|
|
79
79
|
cellReadNames: ReadonlySet<string> = new Set(),
|
|
80
|
+
/* The subset that are BLOCKING `await` cells (ADR-0042), lowered to `$$readCellBlocking(name)`
|
|
81
|
+
on the CLIENT so a pending read suspends its render region. Server keeps `$$readCell` (the
|
|
82
|
+
SSR barrier resolves blocking cells before the template, so the read never suspends). */
|
|
83
|
+
blockingCellNames: ReadonlySet<string> = new Set(),
|
|
80
84
|
): string {
|
|
81
85
|
const nextVar = makeVarNamer()
|
|
82
86
|
/* A per-body source-order ordinal for each `<Child/>` mount site, so two same-type siblings
|
|
@@ -121,7 +125,7 @@ export function generateBuild(
|
|
|
121
125
|
withShadow,
|
|
122
126
|
bindRead,
|
|
123
127
|
bindWrite,
|
|
124
|
-
} = lowerContext(stateNames, derivedNames, computedNames, cellReadNames)
|
|
128
|
+
} = lowerContext(stateNames, derivedNames, computedNames, cellReadNames, blockingCellNames)
|
|
125
129
|
|
|
126
130
|
/* Maps a plan `Binding`'s classification to the client `ShadowKind`: a `reactive` value
|
|
127
131
|
derefs as a `.value` cell (`derived`), a `plain` value as the bare local (`plain`). The
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import ts from 'typescript'
|
|
2
|
+
import { isFunctionScopeBoundary } from './isFunctionScopeBoundary.ts'
|
|
3
|
+
|
|
4
|
+
/*
|
|
5
|
+
True when `node` contains an `await` at its OWN top level — not nested inside any function scope
|
|
6
|
+
(`isFunctionScopeBoundary`: function/arrow/method/accessor/constructor). The SINGLE seed classifier
|
|
7
|
+
both the build lowering (`desugarSignals`: `await` present → a BLOCKING cell that suspends its render
|
|
8
|
+
region) and the type shadow (`compileShadow`: `$$cellValue` `T` vs `$$cellValuePending` `T | undefined`)
|
|
9
|
+
share, so the runtime blocking flag and the shadow type can never disagree on whether a seed is
|
|
10
|
+
`await`-marked (ADR-0042 D5's "one predicate"). An `await` in an inner callback / method
|
|
11
|
+
(`items.map(async (x) => await f(x))`, `{ async m() { await x } }`) does NOT count — only a
|
|
12
|
+
top-level `await` marks the seed itself.
|
|
13
|
+
*/
|
|
14
|
+
export function hasTopLevelAwait(node: ts.Node): boolean {
|
|
15
|
+
let found = false
|
|
16
|
+
const visit = (child: ts.Node): void => {
|
|
17
|
+
if (found || isFunctionScopeBoundary(child)) {
|
|
18
|
+
return
|
|
19
|
+
}
|
|
20
|
+
if (ts.isAwaitExpression(child)) {
|
|
21
|
+
found = true
|
|
22
|
+
return
|
|
23
|
+
}
|
|
24
|
+
ts.forEachChild(child, visit)
|
|
25
|
+
}
|
|
26
|
+
visit(node)
|
|
27
|
+
return found
|
|
28
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import ts from 'typescript'
|
|
2
|
+
|
|
3
|
+
/*
|
|
4
|
+
A node that opens its own (possibly async) function scope — awaits inside it are that scope's
|
|
5
|
+
concern, not the enclosing seed's. Both the top-level-await walk (`hasTopLevelAwait`) and the
|
|
6
|
+
top-level-await diagnostics stop descending here, so a seed classifies the same way a value read
|
|
7
|
+
inside such a scope would behave. One predicate shared by the build lowering and the type shadow so
|
|
8
|
+
they can never disagree on a boundary (ADR-0042 D5's "one predicate").
|
|
9
|
+
*/
|
|
10
|
+
export function isFunctionScopeBoundary(node: ts.Node): boolean {
|
|
11
|
+
return (
|
|
12
|
+
ts.isFunctionDeclaration(node) ||
|
|
13
|
+
ts.isFunctionExpression(node) ||
|
|
14
|
+
ts.isArrowFunction(node) ||
|
|
15
|
+
ts.isMethodDeclaration(node) ||
|
|
16
|
+
ts.isGetAccessorDeclaration(node) ||
|
|
17
|
+
ts.isSetAccessorDeclaration(node) ||
|
|
18
|
+
ts.isConstructorDeclaration(node)
|
|
19
|
+
)
|
|
20
|
+
}
|
|
@@ -101,8 +101,8 @@ export function liftAsyncSubExpressions(
|
|
|
101
101
|
/* A nested function is its own evaluation scope — an async (sub)expression inside a callback
|
|
102
102
|
(`items.map(x => fetchName(x))`, `items.map(async x => await load(x))`) must NOT be hoisted
|
|
103
103
|
to a top-level cell: its parameters/closure vars would become free identifiers, and it
|
|
104
|
-
would run once instead of per row. Stop at the boundary, mirroring
|
|
105
|
-
`
|
|
104
|
+
would run once instead of per row. Stop at the boundary, mirroring the shared
|
|
105
|
+
`hasTopLevelAwait` walk. */
|
|
106
106
|
if (
|
|
107
107
|
ts.isArrowFunction(node) ||
|
|
108
108
|
ts.isFunctionExpression(node) ||
|
|
@@ -27,6 +27,9 @@ export function lowerContext(
|
|
|
27
27
|
computedNames: ReadonlySet<string> = new Set(),
|
|
28
28
|
/* `linked` / async `computed` names, read through `$$readCell(name)`. */
|
|
29
29
|
cellReadNames: ReadonlySet<string> = new Set(),
|
|
30
|
+
/* The subset that are BLOCKING `await` cells (ADR-0042), read through `$$readCellBlocking(name)`
|
|
31
|
+
(suspend-on-pending). Passed only by the CLIENT back-end; SSR leaves it empty. */
|
|
32
|
+
blockingCellNames: ReadonlySet<string> = new Set(),
|
|
30
33
|
) {
|
|
31
34
|
/* The typed branch-local shadow stack: one auto-popping value owning both kinds.
|
|
32
35
|
`derived` names deref to `.value` like a `computed` (block value params the client
|
|
@@ -51,6 +54,7 @@ export function lowerContext(
|
|
|
51
54
|
new Set(scope.names('derived')),
|
|
52
55
|
new Set(scope.names('plain')),
|
|
53
56
|
cellReadNames,
|
|
57
|
+
blockingCellNames,
|
|
54
58
|
),
|
|
55
59
|
docAccessTransformer('$$model'),
|
|
56
60
|
])
|
|
@@ -91,10 +91,19 @@ export function lowerScript(
|
|
|
91
91
|
derivedNames: Set<string>
|
|
92
92
|
computedNames: Set<string>
|
|
93
93
|
cellReadNames: Set<string>
|
|
94
|
+
/* Template-injected + script-level `await` cells (ADR-0042), read via `$$readCellBlocking`. */
|
|
95
|
+
blockingCellNames: Set<string>
|
|
94
96
|
droppedReactiveImports: Set<string>
|
|
95
97
|
} {
|
|
96
98
|
const source = ts.createSourceFile('component.ts', scriptBody, ts.ScriptTarget.Latest, true)
|
|
97
|
-
const {
|
|
99
|
+
const {
|
|
100
|
+
transformer,
|
|
101
|
+
stateNames,
|
|
102
|
+
derivedNames,
|
|
103
|
+
computedNames,
|
|
104
|
+
cellReadNames,
|
|
105
|
+
blockingCellNames: allBlockingNames,
|
|
106
|
+
} = desugarSignals(
|
|
98
107
|
source,
|
|
99
108
|
injectedCellNames,
|
|
100
109
|
blockingCellNames,
|
|
@@ -185,6 +194,7 @@ export function lowerScript(
|
|
|
185
194
|
derivedNames,
|
|
186
195
|
computedNames,
|
|
187
196
|
cellReadNames,
|
|
197
|
+
blockingCellNames: allBlockingNames,
|
|
188
198
|
droppedReactiveImports,
|
|
189
199
|
}
|
|
190
200
|
}
|