@abide/abide 0.44.0 → 0.45.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 +8 -6
- package/CHANGELOG.md +64 -0
- package/README.md +171 -181
- package/package.json +2 -1
- package/src/abideResolverPlugin.ts +6 -1
- package/src/devEntry.ts +43 -9
- package/src/lib/cli/parseArgvForRpc.ts +21 -24
- package/src/lib/cli/runCli.ts +9 -24
- package/src/lib/cli/tokenizeArgvFlags.ts +84 -0
- package/src/lib/server/error.ts +48 -53
- package/src/lib/server/json.ts +4 -3
- package/src/lib/server/jsonl.ts +1 -1
- package/src/lib/server/rpc/defineRpc.ts +11 -9
- package/src/lib/server/rpc/types/RemoteHandler.ts +5 -7
- package/src/lib/server/rpc/types/RpcHelper.ts +125 -35
- package/src/lib/server/rpc/types/TypedError.ts +18 -0
- package/src/lib/server/rpc/validationError.ts +3 -3
- package/src/lib/server/runtime/STATUS_TEXT.ts +22 -0
- package/src/lib/server/runtime/createServer.ts +36 -12
- package/src/lib/server/runtime/devHotModuleResponse.ts +1 -1
- package/src/lib/server/runtime/gzipResponse.ts +14 -16
- package/src/lib/server/runtime/installAmbientScopeStore.ts +33 -0
- package/src/lib/server/runtime/runWithRequestScope.ts +4 -1
- package/src/lib/server/runtime/snapshotEntryFromCache.ts +2 -0
- package/src/lib/server/runtime/typedErrorResponse.ts +26 -0
- package/src/lib/server/runtime/types/RequestStore.ts +25 -0
- package/src/lib/server/runtime/withResponseDefaults.ts +8 -3
- package/src/lib/server/sockets/defineSocket.ts +5 -1
- package/src/lib/server/sse.ts +1 -1
- package/src/lib/shared/HttpError.ts +1 -1
- package/src/lib/shared/RPC_ARGS_TYPE.ts +7 -0
- package/src/lib/shared/activeCacheStore.ts +5 -14
- package/src/lib/shared/activePage.ts +5 -19
- package/src/lib/shared/augmentModule.ts +18 -0
- package/src/lib/shared/basePath.ts +6 -5
- package/src/lib/shared/baseResolver.ts +10 -0
- package/src/lib/shared/baseSlot.ts +6 -12
- package/src/lib/shared/cache.ts +46 -4
- package/src/lib/shared/cacheEntryFromSnapshot.ts +21 -1
- package/src/lib/shared/cacheKeyOf.ts +7 -0
- package/src/lib/shared/cacheKeyStore.ts +8 -0
- package/src/lib/shared/cacheStoreResolver.ts +12 -0
- package/src/lib/shared/cacheStoreSlot.ts +5 -13
- package/src/lib/shared/changeAffectsClient.ts +35 -0
- package/src/lib/shared/createResolverSlot.ts +37 -0
- package/src/lib/shared/debugGate.ts +90 -0
- package/src/lib/shared/emitLogRecord.ts +18 -4
- package/src/lib/shared/globalCacheStoreResolver.ts +12 -0
- package/src/lib/shared/globalCacheStoreSlot.ts +6 -11
- package/src/lib/shared/httpErrorFor.ts +1 -1
- package/src/lib/shared/isDebugEnabled.ts +3 -10
- package/src/lib/shared/isDebugNegated.ts +4 -6
- package/src/lib/shared/pageResolver.ts +16 -0
- package/src/lib/shared/pageSlot.ts +5 -14
- package/src/lib/shared/recordCacheKey.ts +6 -0
- package/src/lib/shared/requestScopeResolver.ts +12 -0
- package/src/lib/shared/requestScopeSlot.ts +7 -12
- package/src/lib/shared/responseBodyKind.ts +35 -0
- package/src/lib/shared/setBaseResolver.ts +2 -4
- package/src/lib/shared/setCacheStoreResolver.ts +3 -5
- package/src/lib/shared/setGlobalCacheStoreResolver.ts +3 -5
- package/src/lib/shared/setPageResolver.ts +2 -5
- package/src/lib/shared/setRequestScopeResolver.ts +3 -5
- package/src/lib/shared/toTagSet.ts +3 -3
- package/src/lib/shared/types/CacheEntry.ts +15 -0
- package/src/lib/shared/types/CacheOptions.ts +5 -5
- package/src/lib/shared/types/CacheSnapshotEntry.ts +5 -0
- package/src/lib/shared/types/DebugGate.ts +10 -0
- package/src/lib/shared/types/ErrorSpec.ts +6 -6
- package/src/lib/shared/types/RemoteFunction.ts +8 -4
- package/src/lib/shared/types/ResolverSlot.ts +10 -0
- package/src/lib/shared/writeDts.ts +8 -1
- package/src/lib/shared/writePublicAssetsDts.ts +5 -9
- package/src/lib/shared/writeRoutesDts.ts +12 -16
- package/src/lib/shared/writeRpcDts.ts +13 -12
- package/src/lib/shared/writeTestRpcDts.ts +11 -11
- package/src/lib/shared/writeTestSocketsDts.ts +9 -9
- package/src/lib/ui/compile/UI_RUNTIME_IMPORTS.ts +1 -0
- package/src/lib/ui/compile/abideUiPlugin.ts +9 -7
- package/src/lib/ui/compile/assertRuntimeHelpersBound.ts +24 -12
- package/src/lib/ui/compile/compileModule.ts +37 -16
- package/src/lib/ui/compile/compileShadow.ts +75 -1
- package/src/lib/ui/compile/createShadowLanguageService.ts +29 -3
- package/src/lib/ui/compile/generateBuild.ts +7 -1
- package/src/lib/ui/compile/generateSSR.ts +10 -3
- package/src/lib/ui/compile/parseTemplate.ts +4 -1
- package/src/lib/ui/compile/types/TemplateNode.ts +1 -1
- package/src/lib/ui/createScope.ts +11 -4
- package/src/lib/ui/deferResume.ts +29 -0
- package/src/lib/ui/dom/appendText.ts +19 -4
- package/src/lib/ui/dom/awaitBlock.ts +74 -26
- package/src/lib/ui/dom/eachAsync.ts +11 -18
- package/src/lib/ui/dom/mountSwappableRange.ts +79 -0
- package/src/lib/ui/dom/skeleton.ts +10 -1
- package/src/lib/ui/dom/switchBlock.ts +15 -63
- package/src/lib/ui/dom/when.ts +11 -64
- package/src/lib/ui/installHotBridge.ts +2 -0
- package/src/lib/ui/matchRoute.ts +18 -1
- package/src/lib/ui/remoteProxy.ts +11 -1
- package/src/lib/ui/renderToStream.ts +13 -8
- package/src/lib/ui/resumeSeedScript.ts +2 -2
- package/src/lib/ui/runtime/CURRENT_SCOPE.ts +14 -1
- package/src/lib/ui/runtime/RESUME.ts +6 -0
- package/src/lib/ui/runtime/ambientScopeBacking.ts +26 -0
- package/src/lib/ui/runtime/createDoc.ts +102 -12
- package/src/lib/ui/runtime/createEffectNode.ts +7 -1
- package/src/lib/ui/runtime/flushEffects.ts +18 -0
- package/src/lib/ui/runtime/generationGuard.ts +40 -0
- package/src/lib/ui/runtime/runNode.ts +9 -1
- package/src/lib/ui/runtime/types/SsrRender.ts +4 -3
- package/src/lib/ui/scope.ts +6 -8
- package/src/lib/ui/tryEncodeResume.ts +5 -2
- package/src/lib/ui/types/Scope.ts +3 -4
- package/src/zodCjsPlugin.ts +16 -1
- package/src/lib/server/rpc/buildErrorConstructors.ts +0 -19
- package/src/lib/shared/matchesDebugPattern.ts +0 -16
- package/src/lib/shared/parseDebugPatterns.ts +0 -21
- package/src/lib/shared/types/ErrorConstructors.ts +0 -17
- package/src/lib/shared/types/ErrorDescriptor.ts +0 -10
|
@@ -5,6 +5,7 @@ import { assertRuntimeHelpersBound } from './assertRuntimeHelpersBound.ts'
|
|
|
5
5
|
import { assertTranspiles } from './assertTranspiles.ts'
|
|
6
6
|
import { compileComponent } from './compileComponent.ts'
|
|
7
7
|
import { compileSSR } from './compileSSR.ts'
|
|
8
|
+
import type { AnalyzedComponent } from './types/AnalyzedComponent.ts'
|
|
8
9
|
import { UI_RUNTIME_IMPORTS } from './UI_RUNTIME_IMPORTS.ts'
|
|
9
10
|
|
|
10
11
|
/*
|
|
@@ -26,7 +27,7 @@ what the `.abide` bundler loader emits.
|
|
|
26
27
|
export function compileModule(
|
|
27
28
|
source: string,
|
|
28
29
|
options: { isLayout?: boolean; moduleId?: string; hot?: boolean } = {},
|
|
29
|
-
): string {
|
|
30
|
+
): { code: string; styles: AnalyzedComponent['styles'] } {
|
|
30
31
|
const isLayout = options.isLayout ?? false
|
|
31
32
|
/* Run the shared front-end once and feed it to both back-ends — the analysis is
|
|
32
33
|
pure over (source, moduleId), so the client and SSR builds reuse one parse
|
|
@@ -52,7 +53,10 @@ export function compileModule(
|
|
|
52
53
|
: `${entry.name}: ${entry.alias}`,
|
|
53
54
|
).join(', ')
|
|
54
55
|
const id = JSON.stringify(options.moduleId)
|
|
55
|
-
|
|
56
|
+
/* Hot mode never imports the scoped CSS (it reuses the live bundle's sheet), so
|
|
57
|
+
styles are empty here regardless of the analyzed blocks. */
|
|
58
|
+
return {
|
|
59
|
+
code: `const { ${names}, hotReplace } = window.__abide
|
|
56
60
|
${userImports}
|
|
57
61
|
function build(host, $props) {
|
|
58
62
|
${body}
|
|
@@ -63,7 +67,9 @@ function component(host, $props) {
|
|
|
63
67
|
component.build = build
|
|
64
68
|
component.__abideId = ${id}
|
|
65
69
|
if (!hotReplace(${id}, component)) location.reload()
|
|
66
|
-
|
|
70
|
+
`,
|
|
71
|
+
styles: [],
|
|
72
|
+
}
|
|
67
73
|
}
|
|
68
74
|
|
|
69
75
|
const ssrBody = indent(compileSSR(source, isLayout, options.moduleId, analyzed))
|
|
@@ -101,10 +107,24 @@ ${options.moduleId === undefined ? '' : `component.__abideId = ${JSON.stringify(
|
|
|
101
107
|
string/comment/HTML literal (e.g. an `on`-attribute in static markup) no longer
|
|
102
108
|
forces a spurious import, so no per-surface scoping is needed: a client-only helper
|
|
103
109
|
simply doesn't appear as an identifier in the SSR body. */
|
|
104
|
-
|
|
105
|
-
|
|
110
|
+
/* Parse the generated bodies ONCE and feed the single tree to both AST passes: the
|
|
111
|
+
dead-import filter (`collectIdentifiers`) and the binding backstop
|
|
112
|
+
(`assertRuntimeHelpersBound`). The import block isn't in this tree yet — it is derived
|
|
113
|
+
from the filter's result — so the backstop is told the names that block will bind
|
|
114
|
+
(`importedHelpers`), which is exactly what it would have read from the prepended imports
|
|
115
|
+
had it re-parsed the whole module. `setParentNodes` is required by the backstop's
|
|
116
|
+
`getStart`/line lookups. */
|
|
117
|
+
const bodySource = ts.createSourceFile(
|
|
118
|
+
'module.ts',
|
|
119
|
+
`${userImports}\n${moduleBody}`,
|
|
120
|
+
ts.ScriptTarget.Latest,
|
|
121
|
+
/* setParentNodes */ true,
|
|
122
|
+
)
|
|
123
|
+
const referenced = collectIdentifiers(bodySource)
|
|
124
|
+
const keptImports = UI_RUNTIME_IMPORTS.filter((entry) =>
|
|
106
125
|
referenced.has(entry.alias ?? entry.name),
|
|
107
126
|
)
|
|
127
|
+
const importBlock = keptImports
|
|
108
128
|
.map((entry) => {
|
|
109
129
|
const local =
|
|
110
130
|
entry.alias === undefined || entry.alias === entry.name
|
|
@@ -126,9 +146,15 @@ ${moduleBody}`
|
|
|
126
146
|
/* `assertTranspiles` only proves the output PARSES — a call to an un-imported helper is
|
|
127
147
|
valid syntax, so it slips through. This second guard proves the output is BOUND: every
|
|
128
148
|
runtime helper it calls is actually imported (an independent check of the dead-import
|
|
129
|
-
filter above), turning a runtime `ReferenceError` into a located compile error.
|
|
130
|
-
|
|
131
|
-
|
|
149
|
+
filter above), turning a runtime `ReferenceError` into a located compile error. It walks
|
|
150
|
+
the SAME tree the filter just parsed, with the kept helper imports supplied as the bound
|
|
151
|
+
set the prepended import block provides. */
|
|
152
|
+
assertRuntimeHelpersBound(
|
|
153
|
+
bodySource,
|
|
154
|
+
new Set(keptImports.map((entry) => entry.alias ?? entry.name)),
|
|
155
|
+
'component module generation',
|
|
156
|
+
)
|
|
157
|
+
return { code: module, styles: analyzed.styles }
|
|
132
158
|
}
|
|
133
159
|
|
|
134
160
|
/* Indents a body block for embedding inside a wrapper function. Lines whose start
|
|
@@ -170,14 +196,9 @@ function unescapedBacktickCount(line: string): number {
|
|
|
170
196
|
in a handler — leaves the scanner unable to find the substitution's closing `}` without
|
|
171
197
|
the parser's re-scan, so a token-only pass mis-reads the rest of the module as template
|
|
172
198
|
text and drops every helper referenced after it (an `import`-less `effect`/`mountChild`
|
|
173
|
-
→ a `ReferenceError` at mount → the router's reload fallback → a refresh loop).
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
'module.ts',
|
|
177
|
-
code,
|
|
178
|
-
ts.ScriptTarget.Latest,
|
|
179
|
-
/* setParentNodes */ false,
|
|
180
|
-
)
|
|
199
|
+
→ a `ReferenceError` at mount → the router's reload fallback → a refresh loop). Takes the
|
|
200
|
+
already-parsed source so the one tree feeds this pass and the binding backstop both. */
|
|
201
|
+
function collectIdentifiers(source: ts.SourceFile): Set<string> {
|
|
181
202
|
const names = new Set<string>()
|
|
182
203
|
const visit = (node: ts.Node): void => {
|
|
183
204
|
if (ts.isIdentifier(node)) {
|
|
@@ -97,7 +97,11 @@ export function compileShadow(source: string, propsType = 'Record<string, any>')
|
|
|
97
97
|
for (const line of scope) {
|
|
98
98
|
builder.flush(line)
|
|
99
99
|
}
|
|
100
|
-
|
|
100
|
+
const templateNodes = parseTemplate(source.slice(templateStart), templateStart).nodes
|
|
101
|
+
/* Nested `<script>` blocks inline into the synchronous `build()` too, so a top-level
|
|
102
|
+
await in one is the same build-breaker — flag it, mapped via the node's body offset. */
|
|
103
|
+
collectNestedScriptAwaitDiagnostics(templateNodes, diagnostics)
|
|
104
|
+
emitNodes(templateNodes, builder)
|
|
101
105
|
builder.raw('}\n')
|
|
102
106
|
return { ...builder.result(), diagnostics }
|
|
103
107
|
}
|
|
@@ -214,6 +218,73 @@ function collectReservedNameDiagnostics(
|
|
|
214
218
|
visit(file)
|
|
215
219
|
}
|
|
216
220
|
|
|
221
|
+
/* Pushes a diagnostic for every `await` sitting at the script's top level — outside any
|
|
222
|
+
nested function. The generated `build()` runs the leading script synchronously, so a
|
|
223
|
+
top-level await transpiles to `await` in a non-async function and breaks the bundle; the
|
|
224
|
+
shadow's render fn is async, so `tsc` alone never flags it (a check/runtime parity gap).
|
|
225
|
+
Stops descending at function boundaries — their own async-ness is the author's concern —
|
|
226
|
+
and catches both `await expr` and `for await (… of …)`, flagging the `await` keyword. */
|
|
227
|
+
function collectTopLevelAwaitDiagnostics(
|
|
228
|
+
file: ts.SourceFile,
|
|
229
|
+
scriptStart: number,
|
|
230
|
+
diagnostics: ShadowDiagnostic[],
|
|
231
|
+
): void {
|
|
232
|
+
const message =
|
|
233
|
+
'top-level `await` is not allowed in a `<script>` — the component build runs synchronously. Use `{#await expr}…{:then value}…{/await}` markup for blocking data, or `await` inside an async event handler.'
|
|
234
|
+
const visit = (node: ts.Node): void => {
|
|
235
|
+
/* A nested function introduces its own (possibly async) scope; its awaits are legal. */
|
|
236
|
+
if (
|
|
237
|
+
ts.isFunctionDeclaration(node) ||
|
|
238
|
+
ts.isFunctionExpression(node) ||
|
|
239
|
+
ts.isArrowFunction(node) ||
|
|
240
|
+
ts.isMethodDeclaration(node) ||
|
|
241
|
+
ts.isGetAccessorDeclaration(node) ||
|
|
242
|
+
ts.isSetAccessorDeclaration(node) ||
|
|
243
|
+
ts.isConstructorDeclaration(node)
|
|
244
|
+
) {
|
|
245
|
+
return
|
|
246
|
+
}
|
|
247
|
+
/* The `await` keyword token: the AwaitExpression's first child, or a `for await`'s
|
|
248
|
+
await modifier. */
|
|
249
|
+
const keyword = ts.isAwaitExpression(node)
|
|
250
|
+
? node.getChildAt(0, file)
|
|
251
|
+
: ts.isForOfStatement(node)
|
|
252
|
+
? node.awaitModifier
|
|
253
|
+
: undefined
|
|
254
|
+
if (keyword !== undefined) {
|
|
255
|
+
diagnostics.push({
|
|
256
|
+
start: scriptStart + keyword.getStart(file),
|
|
257
|
+
length: keyword.getEnd() - keyword.getStart(file),
|
|
258
|
+
message,
|
|
259
|
+
})
|
|
260
|
+
}
|
|
261
|
+
ts.forEachChild(node, visit)
|
|
262
|
+
}
|
|
263
|
+
visit(file)
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/* Recursively visits every nested `<script>` in the template and flags a top-level await in
|
|
267
|
+
its body — the same trap as the leading script (it inlines into the sync `build()`), mapped
|
|
268
|
+
through the node's body offset (`loc`). Walks `children` on every node kind that carries
|
|
269
|
+
them, so a script buried in an `{#if}`/`{#for}`/`{#await}` branch is still reached. */
|
|
270
|
+
function collectNestedScriptAwaitDiagnostics(
|
|
271
|
+
nodes: TemplateNode[],
|
|
272
|
+
diagnostics: ShadowDiagnostic[],
|
|
273
|
+
): void {
|
|
274
|
+
for (const node of nodes) {
|
|
275
|
+
if (node === undefined) {
|
|
276
|
+
continue
|
|
277
|
+
}
|
|
278
|
+
if (node.kind === 'script' && node.loc !== undefined) {
|
|
279
|
+
const file = ts.createSourceFile('nested.ts', node.code, ts.ScriptTarget.Latest, true)
|
|
280
|
+
collectTopLevelAwaitDiagnostics(file, node.loc, diagnostics)
|
|
281
|
+
}
|
|
282
|
+
if ('children' in node) {
|
|
283
|
+
collectNestedScriptAwaitDiagnostics(node.children, diagnostics)
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
217
288
|
/* Walks the leading `<script>` and produces the shadow's module imports, the
|
|
218
289
|
module-scope type declarations, the value-typed scope lines, and the `props<Shape>()`
|
|
219
290
|
prop shapes. `scriptStart` is the body's absolute offset in the source, so verbatim
|
|
@@ -232,6 +303,9 @@ function analyzeScript(scriptBody: string, scriptStart: number): ScriptAnalysis
|
|
|
232
303
|
`$$scope`, …), so an author binding may not start with it — that's the contract that
|
|
233
304
|
lets a user freely name a variable after any helper. Flag every such declaration. */
|
|
234
305
|
collectReservedNameDiagnostics(file, scriptStart, diagnostics)
|
|
306
|
+
/* The leading script runs in the synchronous `build()`, so a top-level await breaks the
|
|
307
|
+
bundle — catch it here with a legible message instead of an opaque transpile error. */
|
|
308
|
+
collectTopLevelAwaitDiagnostics(file, scriptStart, diagnostics)
|
|
235
309
|
/* A verbatim span: original text + the segment mapping it back, relative to the
|
|
236
310
|
line start (the caller rebases shadowStart onto the running shadow length). */
|
|
237
311
|
const span = (node: ts.Node, prefixLength: number): ScopeLine['segments'][number] => ({
|
|
@@ -47,6 +47,21 @@ export function createShadowLanguageService(cwd: string): ShadowLanguageService
|
|
|
47
47
|
const versions = new Map<string, number>()
|
|
48
48
|
const shadows = new Map<string, CompiledShadow>()
|
|
49
49
|
const parseErrors = new Map<string, string>()
|
|
50
|
+
/* Memo of `shadowText` output keyed by source path, tagged with the shadow
|
|
51
|
+
version it was compiled at; a stale tag forces recompilation. `update`/`close`
|
|
52
|
+
bump that version, so the next read recompiles. */
|
|
53
|
+
const compiledAt = new Map<string, { version: number; code: string }>()
|
|
54
|
+
/* Memo of `pagePropsType` (route re-parse) per source path; the path → props
|
|
55
|
+
type mapping is immutable, so no version tag is needed. */
|
|
56
|
+
const propsTypes = new Map<string, string | undefined>()
|
|
57
|
+
|
|
58
|
+
/* The route props type for a component, parsed once and reused. */
|
|
59
|
+
const propsTypeOf = (abidePath: string): string | undefined => {
|
|
60
|
+
if (!propsTypes.has(abidePath)) {
|
|
61
|
+
propsTypes.set(abidePath, pagePropsType(abidePath))
|
|
62
|
+
}
|
|
63
|
+
return propsTypes.get(abidePath)
|
|
64
|
+
}
|
|
50
65
|
|
|
51
66
|
/* Ambient declarations for bundler-handled asset imports (`*.css`, …). */
|
|
52
67
|
const assets = assetModulesFile(cwd)
|
|
@@ -55,18 +70,29 @@ export function createShadowLanguageService(cwd: string): ShadowLanguageService
|
|
|
55
70
|
overlays.has(abidePath) || ts.sys.fileExists(abidePath)
|
|
56
71
|
|
|
57
72
|
/* Compiles (and caches) a component's shadow from its overlay or disk text; a
|
|
58
|
-
template parse error yields a minimal valid module + a recorded message.
|
|
73
|
+
template parse error yields a minimal valid module + a recorded message.
|
|
74
|
+
Memoised against the shadow's version (bumped by `update`/`close`), so
|
|
75
|
+
repeated reads at the same version skip the ~700-line compile and the
|
|
76
|
+
`shadows`/`parseErrors` caches stay current. */
|
|
59
77
|
const shadowText = (abidePath: string): string => {
|
|
78
|
+
const version = versions.get(suffixed(abidePath)) ?? 0
|
|
79
|
+
const memo = compiledAt.get(abidePath)
|
|
80
|
+
if (memo !== undefined && memo.version === version) {
|
|
81
|
+
return memo.code
|
|
82
|
+
}
|
|
60
83
|
const source = overlays.get(abidePath) ?? ts.sys.readFile(abidePath) ?? ''
|
|
61
84
|
try {
|
|
62
|
-
const compiled = compileShadow(source,
|
|
85
|
+
const compiled = compileShadow(source, propsTypeOf(abidePath))
|
|
63
86
|
shadows.set(abidePath, compiled)
|
|
64
87
|
parseErrors.delete(abidePath)
|
|
88
|
+
compiledAt.set(abidePath, { version, code: compiled.code })
|
|
65
89
|
return compiled.code
|
|
66
90
|
} catch (error) {
|
|
67
91
|
shadows.set(abidePath, { code: '', mappings: [] })
|
|
68
92
|
parseErrors.set(abidePath, messageFromError(error))
|
|
69
|
-
|
|
93
|
+
const code = 'export default function (): void {}\n'
|
|
94
|
+
compiledAt.set(abidePath, { version, code })
|
|
95
|
+
return code
|
|
70
96
|
}
|
|
71
97
|
}
|
|
72
98
|
|
|
@@ -125,7 +125,13 @@ export function generateBuild(
|
|
|
125
125
|
(typeof node.attrs)[number],
|
|
126
126
|
{
|
|
127
127
|
kind:
|
|
128
|
-
|
|
128
|
+
| 'expression'
|
|
129
|
+
| 'interpolated'
|
|
130
|
+
| 'event'
|
|
131
|
+
| 'attach'
|
|
132
|
+
| 'bind'
|
|
133
|
+
| 'class'
|
|
134
|
+
| 'style'
|
|
129
135
|
}
|
|
130
136
|
>,
|
|
131
137
|
varName: string,
|
|
@@ -527,10 +527,15 @@ export function generateSSR(
|
|
|
527
527
|
`withBindings` shadow models, so a reference to the binding reads the plain
|
|
528
528
|
local rather than the (unresolved) component signal it shadows. */
|
|
529
529
|
const resolved = nextVar('$av')
|
|
530
|
+
/* Keep the promise object: a cache() read tagged it with its store key, which
|
|
531
|
+
`$$deferResume` reads to defer a cache-backed value (ship a `{defer,key}` marker +
|
|
532
|
+
lazy seed) instead of inlining it — the blocking form's contract. */
|
|
533
|
+
const promiseVar = nextVar('$ap')
|
|
530
534
|
let code = `const ${id} = $ctx.next++;\n`
|
|
531
535
|
code += `${target}.push("<!--abide:await:" + ${id} + "-->");\n`
|
|
532
536
|
code += `try {\n`
|
|
533
|
-
code += `const ${
|
|
537
|
+
code += `const ${promiseVar} = (${lowerExpression(node.promise)});\n`
|
|
538
|
+
code += `const ${resolved} = await ${promiseVar};\n`
|
|
534
539
|
code += `{\n`
|
|
535
540
|
code += `const ${plan.resolvedAs} = ${resolved};\n`
|
|
536
541
|
code += withBindings(withShadow, plan.resolvedBindings, ssrBindingKind, () =>
|
|
@@ -540,7 +545,7 @@ export function generateSSR(
|
|
|
540
545
|
shadow — matching the catch branch below, so a finally expression naming the
|
|
541
546
|
same identifier as the `then` binding reads the component signal, not the local. */
|
|
542
547
|
code += branchContent(plan.finallyChildren, target)
|
|
543
|
-
code += `$resume[${id}] = {
|
|
548
|
+
code += `$resume[${id}] = $$deferResume(${promiseVar}, ${plan.resolvedAs});\n`
|
|
544
549
|
code += `}\n`
|
|
545
550
|
if (plan.surfaceRejection) {
|
|
546
551
|
/* No catch/finally → let the rejection surface instead of an empty branch. */
|
|
@@ -564,7 +569,9 @@ export function generateSSR(
|
|
|
564
569
|
`await` block inside the branch composes. `finally` appends after the outcome,
|
|
565
570
|
matching the client's concatenated node range. Neither catch nor finally → omit
|
|
566
571
|
`catch` so a rejection surfaces (renderToStream re-throws); a finally-only block
|
|
567
|
-
keeps a catch renderer that renders just finally.
|
|
572
|
+
keeps a catch renderer that renders just finally. A cache-backed streaming value is
|
|
573
|
+
DEFERRED at settle time (renderToStream ships a `{defer,key}` marker + lazy body, the
|
|
574
|
+
client adopts the streamed branch inert) — the machinery here is unchanged. */
|
|
568
575
|
function generateStreamingAwait(
|
|
569
576
|
node: Extract<TemplateNode, { kind: 'await' }>,
|
|
570
577
|
target: string,
|
|
@@ -615,6 +615,9 @@ export function parseTemplate(source: string, baseOffset = 0): { nodes: Template
|
|
|
615
615
|
verbatim to its `</script>` (not parsed as markup), scoped by the
|
|
616
616
|
containing branch when compiled. */
|
|
617
617
|
if (tag === 'script' && !selfClosing) {
|
|
618
|
+
/* Body starts at the current cursor; its absolute source offset lets the shadow
|
|
619
|
+
map a diagnostic raised inside it (e.g. a top-level await) back to source. */
|
|
620
|
+
const bodyLoc = baseOffset + cursor
|
|
618
621
|
const close = source.indexOf('</script>', cursor)
|
|
619
622
|
const end = close === -1 ? source.length : close
|
|
620
623
|
const code = source.slice(cursor, end)
|
|
@@ -631,7 +634,7 @@ export function parseTemplate(source: string, baseOffset = 0): { nodes: Template
|
|
|
631
634
|
"import statements must live in the component's leading <script>, not a nested <template> script — they hoist to module scope for the whole template. For lazy loading, use a dynamic import() inside an effect.",
|
|
632
635
|
)
|
|
633
636
|
}
|
|
634
|
-
return { kind: 'script', code }
|
|
637
|
+
return { kind: 'script', code, loc: bodyLoc }
|
|
635
638
|
}
|
|
636
639
|
/* A capitalised tag is a child component; its attributes become props and
|
|
637
640
|
its children become slot content (rendered where the child puts <slot>). */
|
|
@@ -12,7 +12,7 @@ positions for the type-checking shadow, ignored by the runtime back-ends.
|
|
|
12
12
|
*/
|
|
13
13
|
export type TemplateNode =
|
|
14
14
|
| { kind: 'text'; parts: TextPart[] }
|
|
15
|
-
| { kind: 'script'; code: string }
|
|
15
|
+
| { kind: 'script'; code: string; loc?: number }
|
|
16
16
|
/* A `<style>` declared in the template. `css` is its raw body, read structurally
|
|
17
17
|
so a `<style>` inside an expression isn't mistaken for one. The node stays in
|
|
18
18
|
place: it scopes its sibling subtree, so the front-end derives each scope
|
|
@@ -5,6 +5,7 @@ import { linked } from './linked.ts'
|
|
|
5
5
|
import { persist as persistDoc } from './persist.ts'
|
|
6
6
|
import { createDoc } from './runtime/createDoc.ts'
|
|
7
7
|
import { liveScopes } from './runtime/liveScopes.ts'
|
|
8
|
+
import type { Cell } from './runtime/types/Cell.ts'
|
|
8
9
|
import type { Doc } from './runtime/types/Doc.ts'
|
|
9
10
|
import { state } from './state.ts'
|
|
10
11
|
import { sync } from './sync.ts'
|
|
@@ -49,7 +50,11 @@ export function createScope(
|
|
|
49
50
|
let persistence: PersistHandle | undefined
|
|
50
51
|
let unsync: (() => void) | undefined
|
|
51
52
|
|
|
52
|
-
|
|
53
|
+
/* `cell` is not on the public `Scope` type — it is the compiler-only leaf the
|
|
54
|
+
cell-hoisting lowering targets (`const _cell0 = $$scope().cell("path")`, see
|
|
55
|
+
`hoistCells`). It stays on the runtime object but off the documented surface,
|
|
56
|
+
so authors reach data through `read`/`replace`/`derive`, not a raw cell handle. */
|
|
57
|
+
const self: Scope & { cell: <T>(path: string) => Cell<T> } = {
|
|
53
58
|
id,
|
|
54
59
|
label,
|
|
55
60
|
parent,
|
|
@@ -79,7 +84,7 @@ export function createScope(
|
|
|
79
84
|
},
|
|
80
85
|
root: () => (parent === undefined ? self : parent.root()),
|
|
81
86
|
/* Reference store — no tracking, so a lookup never subscribes; reactivity comes
|
|
82
|
-
from what is shared (a
|
|
87
|
+
from what is shared (a scope, whose doc is reactive), not from the share. */
|
|
83
88
|
share: (key, value) => {
|
|
84
89
|
shared.set(key, value)
|
|
85
90
|
},
|
|
@@ -109,8 +114,10 @@ export function createScope(
|
|
|
109
114
|
owned[index]?.()
|
|
110
115
|
}
|
|
111
116
|
owned.length = 0
|
|
112
|
-
|
|
113
|
-
|
|
117
|
+
/* Children reverse too (last created first), so a later child that captured an
|
|
118
|
+
earlier sibling tears down before the sibling it depends on — LIFO like `owned`. */
|
|
119
|
+
for (let index = children.length - 1; index >= 0; index -= 1) {
|
|
120
|
+
children[index]?.dispose()
|
|
114
121
|
}
|
|
115
122
|
children.length = 0
|
|
116
123
|
shared.clear()
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { activeCacheStore } from '../shared/activeCacheStore.ts'
|
|
2
|
+
import { cacheKeyOf } from '../shared/cacheKeyOf.ts'
|
|
3
|
+
import { snapshotShippable } from '../shared/snapshotShippable.ts'
|
|
4
|
+
import type { DeferMarker, ResumeEntry } from './runtime/RESUME.ts'
|
|
5
|
+
|
|
6
|
+
/*
|
|
7
|
+
The resume-manifest entry for a blocking `{#await expr then value}`. When the awaited value
|
|
8
|
+
is a shippable cache-backed read, DEFER it: flag the store entry so its SSR snapshot seeds
|
|
9
|
+
lazily (the body is decoded on first read, not at hydration) and return a `{ defer, key }`
|
|
10
|
+
marker in place of the value. Hydration then adopts the server branch inert and decodes
|
|
11
|
+
neither copy — the blocking form's contract: "render it on the server, keep the page, refetch
|
|
12
|
+
only when I read or invalidate it." A non-cache value (a plain promise, a computation) or a
|
|
13
|
+
cache read whose entry can't ship inlines its value as before. Server-only in practice — the
|
|
14
|
+
client codegen never calls it. */
|
|
15
|
+
// @documentation plumbing
|
|
16
|
+
export function deferResume(promise: unknown, value: unknown): ResumeEntry | DeferMarker {
|
|
17
|
+
const isPromise =
|
|
18
|
+
promise !== null && typeof (promise as { then?: unknown })?.then === 'function'
|
|
19
|
+
const key = isPromise ? cacheKeyOf(promise as Promise<unknown>) : undefined
|
|
20
|
+
if (key === undefined) {
|
|
21
|
+
return { ok: true, value }
|
|
22
|
+
}
|
|
23
|
+
const entry = activeCacheStore().entries.get(key)
|
|
24
|
+
if (entry === undefined || !snapshotShippable(entry)) {
|
|
25
|
+
return { ok: true, value }
|
|
26
|
+
}
|
|
27
|
+
entry.deferred = true
|
|
28
|
+
return { defer: true, key }
|
|
29
|
+
}
|
|
@@ -36,21 +36,36 @@ export function appendText(parent: Node, read: () => unknown, splitAlways = fals
|
|
|
36
36
|
}
|
|
37
37
|
const hydration = RENDER.hydration
|
|
38
38
|
if (hydration !== undefined) {
|
|
39
|
-
const
|
|
39
|
+
const claimed = claimChild(hydration, parent)
|
|
40
40
|
const value = String(read())
|
|
41
|
+
/* A value that first rendered empty produced NO server text node, so the cursor
|
|
42
|
+
points at the following node (an element/comment) or past the end (null) — not a
|
|
43
|
+
text node to claim. Bind to a Text node either way: claim the merged SSR node when
|
|
44
|
+
one is here, else synthesize an empty one at the cursor and leave the claimed node
|
|
45
|
+
for the next consumer (a following element hole, a sibling binding, or nothing).
|
|
46
|
+
Without this the bind effect below derefs a null/element `node`. A text node is
|
|
47
|
+
detected by `splitText` (not `nodeType`), so the test mini-dom is covered too. */
|
|
48
|
+
const isText = claimed !== null && typeof (claimed as Text).splitText === 'function'
|
|
49
|
+
const node = (
|
|
50
|
+
isText ? claimed : parent.insertBefore(document.createTextNode(''), claimed)
|
|
51
|
+
) as Text
|
|
41
52
|
/* Peel this binding's text off the merged SSR node. A non-final binding in a
|
|
42
53
|
run (`splitAlways`) splits even when it consumes the whole node, leaving an
|
|
43
54
|
empty node for the next binding — otherwise an interpolation that renders to
|
|
44
55
|
empty string (or whose followers do) has no node and the next claim grabs the
|
|
45
56
|
wrong sibling. The final binding keeps `<` so it doesn't leave a stray node a
|
|
46
|
-
following element would claim.
|
|
57
|
+
following element would claim. A synthesized node is already this binding's own,
|
|
58
|
+
so it never splits. */
|
|
47
59
|
if (
|
|
48
|
-
|
|
60
|
+
isText &&
|
|
49
61
|
(splitAlways ? value.length <= node.data.length : value.length < node.data.length)
|
|
50
62
|
) {
|
|
51
63
|
node.splitText(value.length)
|
|
52
64
|
}
|
|
53
|
-
|
|
65
|
+
/* Advance past the claimed text node; for a synthesized node leave the cursor on the
|
|
66
|
+
still-unclaimed `claimed` node it was inserted before (an element/comment, or null
|
|
67
|
+
at the end). */
|
|
68
|
+
hydration.next.set(parent, isText ? node.nextSibling : claimed)
|
|
54
69
|
effect(() => {
|
|
55
70
|
node.data = String(read())
|
|
56
71
|
})
|
|
@@ -1,9 +1,11 @@
|
|
|
1
|
+
import { activeCacheStore } from '../../shared/activeCacheStore.ts'
|
|
1
2
|
import { decodeRefJson } from '../../shared/decodeRefJson.ts'
|
|
2
3
|
import { effect } from '../effect.ts'
|
|
3
4
|
import { claimChild } from '../runtime/claimChild.ts'
|
|
5
|
+
import { generationGuard } from '../runtime/generationGuard.ts'
|
|
4
6
|
import { RANGE_CLOSE, RANGE_OPEN } from '../runtime/RANGE_MARKER.ts'
|
|
5
7
|
import { RENDER } from '../runtime/RENDER.ts'
|
|
6
|
-
import type { ResumeEntry } from '../runtime/RESUME.ts'
|
|
8
|
+
import type { DeferMarker, ResumeEntry } from '../runtime/RESUME.ts'
|
|
7
9
|
import { RESUME } from '../runtime/RESUME.ts'
|
|
8
10
|
import { scope } from '../runtime/scope.ts'
|
|
9
11
|
import { scopeGroup } from '../runtime/scopeGroup.ts'
|
|
@@ -56,8 +58,11 @@ export function awaitBlock(
|
|
|
56
58
|
let active: { start: Comment; end: Comment; dispose: () => void } | undefined
|
|
57
59
|
let anchor: Node | undefined
|
|
58
60
|
let first = true
|
|
59
|
-
/* Bumped each run so a prior run's in-flight promise can't clobber a newer one
|
|
60
|
-
|
|
61
|
+
/* Bumped each run so a prior run's in-flight promise can't clobber a newer one, AND on
|
|
62
|
+
owner teardown so an in-flight promise that settles AFTER the enclosing `{#if}`/
|
|
63
|
+
`{#for}`/component tears this block out is abandoned — otherwise its settle runs
|
|
64
|
+
`place` on the block's now-detached anchor and `insertBefore` throws NotFoundError. */
|
|
65
|
+
const guard = generationGuard()
|
|
61
66
|
/* The resolved value, held as a reactive cell so the then-branch reads it through its
|
|
62
67
|
own effects. A re-run that resolves to a NEW value SETS this cell instead of rebuilding
|
|
63
68
|
the branch — the branch (and any keyed `each` inside it) survives and updates in place,
|
|
@@ -120,7 +125,7 @@ export function awaitBlock(
|
|
|
120
125
|
|
|
121
126
|
/* Render a settled-or-pending result into the current generation. */
|
|
122
127
|
const render = (result: unknown): void => {
|
|
123
|
-
const gen =
|
|
128
|
+
const gen = guard.token()
|
|
124
129
|
if (!isThenable(result)) {
|
|
125
130
|
settleThen(result) // warm-sync → resolved now, no flash
|
|
126
131
|
return
|
|
@@ -140,12 +145,12 @@ export function awaitBlock(
|
|
|
140
145
|
}
|
|
141
146
|
result.then(
|
|
142
147
|
(value) => {
|
|
143
|
-
if (gen
|
|
148
|
+
if (guard.live(gen)) {
|
|
144
149
|
settleThen(value)
|
|
145
150
|
}
|
|
146
151
|
},
|
|
147
152
|
(error) => {
|
|
148
|
-
if (gen
|
|
153
|
+
if (guard.live(gen)) {
|
|
149
154
|
settleError(error)
|
|
150
155
|
}
|
|
151
156
|
},
|
|
@@ -176,6 +181,42 @@ export function awaitBlock(
|
|
|
176
181
|
active = { start, end, dispose }
|
|
177
182
|
}
|
|
178
183
|
|
|
184
|
+
/* Inert adoption — a deferred `{#await cache()}` (large payload, shipped as a `{defer,key}`
|
|
185
|
+
resume marker). Adopt the SSR then-branch WITHOUT building its bindings or materializing
|
|
186
|
+
the awaited value: the server markup is already correct, so keep it verbatim and skip the
|
|
187
|
+
payload decode entirely (the boot-path cost deferral targets). Only subscribe THIS block's
|
|
188
|
+
effect to the cache key — reads, decodes and fetches all wait. A later cache.invalidate
|
|
189
|
+
re-runs the effect, which then reads the value for real and builds a fresh branch. Sound
|
|
190
|
+
because a display-first read replaces the whole branch on re-read anyway, so there is no
|
|
191
|
+
extra flash beyond the swap the re-read already performs. */
|
|
192
|
+
const adoptInert = (open: Node | null, key: string): void => {
|
|
193
|
+
const cursor = hydration as NonNullable<typeof hydration>
|
|
194
|
+
const firstKept = open?.nextSibling ?? null
|
|
195
|
+
/* Scan to THIS block's close marker, leaving every server node in place. Nested
|
|
196
|
+
blocks carry different ids, so the first data match is our own close. */
|
|
197
|
+
let node: Node | null = firstKept
|
|
198
|
+
while (node !== null && (node as { data?: string }).data !== `/abide:await:${id}`) {
|
|
199
|
+
node = node.nextSibling
|
|
200
|
+
}
|
|
201
|
+
const close = node
|
|
202
|
+
cursor.next.set(parent, close?.nextSibling ?? null)
|
|
203
|
+
/* Bracket the kept nodes as a `[`…`]` range + park an anchor, identical to `adopt`,
|
|
204
|
+
so the first re-run's `place`/`detach` evicts them like any other branch. */
|
|
205
|
+
const start = document.createComment(RANGE_OPEN)
|
|
206
|
+
parent.insertBefore(start, firstKept ?? close)
|
|
207
|
+
const end = document.createComment(RANGE_CLOSE)
|
|
208
|
+
parent.insertBefore(end, close)
|
|
209
|
+
anchor = document.createTextNode('')
|
|
210
|
+
parent.insertBefore(anchor, close)
|
|
211
|
+
/* No scope/effects were built for the kept nodes, so disposal only evicts the range. */
|
|
212
|
+
active = { start, end, dispose: () => undefined }
|
|
213
|
+
/* Not 'then' — the first re-run rebuilds the branch fresh via `place`. */
|
|
214
|
+
activeKind = 'pending'
|
|
215
|
+
/* Subscribe without a value read: no clone, no decode, no fetch — just the key, so
|
|
216
|
+
cache.invalidate re-runs this effect (createSubscriber ties it to the running scope). */
|
|
217
|
+
activeCacheStore().subscribe(key)
|
|
218
|
+
}
|
|
219
|
+
|
|
179
220
|
/* Discard the SSR boundary and (re)build the block from the live promise, fresh
|
|
180
221
|
(hydration off) — the recovery path when adoption can't use the server markup. */
|
|
181
222
|
const rebuildCold = (open: Node | null): void => {
|
|
@@ -205,21 +246,34 @@ export function awaitBlock(
|
|
|
205
246
|
didn't round-trip (e.g. a non-serializable Response) throws while building the
|
|
206
247
|
branch — fall back to the live promise, which reads the properly-reconstructed
|
|
207
248
|
warm cache (or re-fetches) instead of crashing hydration. */
|
|
208
|
-
const firstHydrate = (
|
|
249
|
+
const firstHydrate = (): void => {
|
|
209
250
|
const cursor = hydration as NonNullable<typeof hydration>
|
|
210
251
|
const open = claimChild(cursor, parent)
|
|
211
252
|
/* RESUME holds the ref-json-encoded entry STRING; decode here, where the codec
|
|
212
253
|
lives. A decode failure (malformed/absent payload) reads as "no resume" — fall
|
|
213
254
|
through to the live promise rather than crash hydration. */
|
|
214
255
|
const raw = RESUME[id]
|
|
215
|
-
let
|
|
256
|
+
let decoded: ResumeEntry | DeferMarker | undefined
|
|
216
257
|
if (raw !== undefined) {
|
|
217
258
|
try {
|
|
218
|
-
|
|
259
|
+
decoded = decodeRefJson(raw) as ResumeEntry | DeferMarker
|
|
219
260
|
} catch {
|
|
220
|
-
|
|
261
|
+
decoded = undefined
|
|
221
262
|
}
|
|
222
263
|
}
|
|
264
|
+
/* A deferred marker ships the cache key, not the value — adopt the server branch inert
|
|
265
|
+
and skip the decode. Intercept before the ok/catch logic. Object-guard the `in`: a
|
|
266
|
+
corrupt manifest decoding to a primitive/null must read as "no resume" (fall through),
|
|
267
|
+
not throw past the decode try/catch and crash hydration. */
|
|
268
|
+
if (typeof decoded === 'object' && decoded !== null && 'defer' in decoded) {
|
|
269
|
+
adoptInert(open, decoded.key)
|
|
270
|
+
return
|
|
271
|
+
}
|
|
272
|
+
/* Non-deferred: read the promise now so the block subscribes to its reactive source
|
|
273
|
+
(a cache key) — warm on resume, so no round-trip — then adopt the resume value /
|
|
274
|
+
warm-sync result below, or discard and build the pending branch fresh. */
|
|
275
|
+
const result = promiseThunk()
|
|
276
|
+
const entry = decoded as ResumeEntry | undefined
|
|
223
277
|
if (entry !== undefined) {
|
|
224
278
|
/* Build the adopted branch around a value CELL (then) so a later re-run updates
|
|
225
279
|
it in place, exactly like a fresh mount. The `throw` for a catch-less rejection
|
|
@@ -278,30 +332,24 @@ export function awaitBlock(
|
|
|
278
332
|
}
|
|
279
333
|
|
|
280
334
|
effect(() => {
|
|
281
|
-
|
|
282
|
-
/* Read the promise EVERY run, including the first hydrate run, so the block
|
|
283
|
-
subscribes to its reactive source (a cache key). A cache-remote read is warm
|
|
284
|
-
on resume — it serves the snapshot without a network round-trip, so adoption
|
|
285
|
-
stays no-flash AND a later cache.invalidate re-runs the block. Without this
|
|
286
|
-
read a resume-adopted block has no deps and invalidate is a no-op.
|
|
287
|
-
|
|
288
|
-
ONLY the promise read is tracked. The warm-sync resolve, the hydration adopt,
|
|
289
|
-
and the pending render all BUILD the branch through `scope`, which builds
|
|
290
|
-
untracked — so the branch's own reactive reads don't subscribe THIS effect
|
|
291
|
-
(otherwise the whole block re-runs and re-suspends on any branch-state change,
|
|
292
|
-
e.g. a sibling route param updating in place). The branch's own child effects
|
|
293
|
-
still track normally; the block re-runs only when the promise source does. */
|
|
294
|
-
const result = promiseThunk()
|
|
335
|
+
guard.renew()
|
|
295
336
|
if (first) {
|
|
296
337
|
first = false
|
|
297
338
|
if (hydration !== undefined) {
|
|
298
|
-
firstHydrate
|
|
339
|
+
/* firstHydrate reads the promise ITSELF, after checking the resume marker: a
|
|
340
|
+
deferred block must not invoke promiseThunk (it would materialize/fetch the
|
|
341
|
+
value we're deferring) and subscribes by key instead; every other path reads
|
|
342
|
+
it so the block subscribes to its reactive source (a cache key). */
|
|
343
|
+
firstHydrate()
|
|
299
344
|
return
|
|
300
345
|
}
|
|
301
346
|
anchor = document.createTextNode('')
|
|
302
347
|
parent.insertBefore(anchor, before)
|
|
303
348
|
}
|
|
304
|
-
|
|
349
|
+
/* Read the promise every subsequent run so an invalidate re-runs the block. ONLY this
|
|
350
|
+
read is tracked (the branch builds untracked via `scope`), so the block re-runs only
|
|
351
|
+
when its promise source does, not on any branch-state change. */
|
|
352
|
+
render(promiseThunk())
|
|
305
353
|
})
|
|
306
354
|
}
|
|
307
355
|
|