@abide/abide 0.39.0 → 0.40.1
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 +318 -146
- package/CHANGELOG.md +89 -0
- package/README.md +77 -79
- package/package.json +1 -1
- package/src/lib/server/rpc/parseArgs.ts +10 -1
- package/src/lib/server/rpc/runWithVerbTimeout.ts +7 -9
- package/src/lib/server/rpc/types/VerbHelper.ts +18 -21
- package/src/lib/server/runtime/SSR_SWAP_SCRIPT.ts +8 -7
- package/src/lib/server/sockets/createSocketDispatcher.ts +12 -3
- package/src/lib/server/sockets/defineSocket.ts +3 -1
- package/src/lib/shared/REF_JSON_HEADER.ts +9 -0
- package/src/lib/shared/REF_JSON_TAGS.ts +31 -0
- package/src/lib/shared/assertExhaustive.ts +14 -0
- package/src/lib/shared/buildRpcRequest.ts +8 -1
- package/src/lib/shared/decodeRefJson.ts +110 -0
- package/src/lib/shared/encodeRefJson.ts +106 -0
- package/src/lib/test/createTestSocketChannel.ts +6 -2
- package/src/lib/ui/compile/TS_PRINTER.ts +7 -0
- package/src/lib/ui/compile/analyzeComponent.ts +15 -16
- package/src/lib/ui/compile/assertRuntimeHelpersBound.ts +66 -0
- package/src/lib/ui/compile/assertTranspiles.ts +19 -0
- package/src/lib/ui/compile/compileModule.ts +56 -34
- package/src/lib/ui/compile/compileSSR.ts +1 -3
- package/src/lib/ui/compile/compileShadow.ts +14 -7
- package/src/lib/ui/compile/desugarSignals.ts +168 -107
- package/src/lib/ui/compile/generateBuild.ts +49 -59
- package/src/lib/ui/compile/generateSSR.ts +30 -21
- package/src/lib/ui/compile/hoistCells.ts +2 -2
- package/src/lib/ui/compile/isWhitespaceText.ts +11 -0
- package/src/lib/ui/compile/lowerContext.ts +23 -10
- package/src/lib/ui/compile/lowerDocAccess.ts +29 -3
- package/src/lib/ui/compile/lowerScript.ts +64 -0
- package/src/lib/ui/compile/parseTemplate.ts +74 -0
- package/src/lib/ui/compile/renameSignalRefs.ts +160 -90
- package/src/lib/ui/compile/resolveBranches.ts +21 -0
- package/src/lib/ui/compile/stripEffects.ts +22 -17
- package/src/lib/ui/compile/types/AnalyzedComponent.ts +5 -0
- package/src/lib/ui/compile/types/TemplateNode.ts +12 -2
- package/src/lib/ui/createScope.ts +14 -0
- package/src/lib/ui/dom/appendText.ts +2 -3
- package/src/lib/ui/dom/appendTextAt.ts +2 -3
- package/src/lib/ui/dom/applyResolved.ts +5 -8
- package/src/lib/ui/dom/awaitBlock.ts +22 -2
- package/src/lib/ui/dom/each.ts +4 -0
- package/src/lib/ui/dom/on.ts +7 -0
- package/src/lib/ui/dom/parseRawNodes.ts +17 -0
- package/src/lib/ui/dom/switchBlock.ts +4 -0
- package/src/lib/ui/dom/when.ts +4 -0
- package/src/lib/ui/installInspectorBridge.ts +3 -1
- package/src/lib/ui/navigate.ts +28 -3
- package/src/lib/ui/renderToStream.ts +17 -9
- package/src/lib/ui/resumeSeedScript.ts +16 -6
- package/src/lib/ui/router.ts +108 -15
- package/src/lib/ui/runtime/NODE_STATE.ts +22 -0
- package/src/lib/ui/runtime/RESUME.ts +13 -6
- package/src/lib/ui/runtime/clientPage.ts +114 -9
- package/src/lib/ui/runtime/createComputedNode.ts +5 -3
- package/src/lib/ui/runtime/createEffectNode.ts +3 -1
- package/src/lib/ui/runtime/createSignalNode.ts +4 -2
- package/src/lib/ui/runtime/flushEffects.ts +8 -5
- package/src/lib/ui/runtime/historyEntries.ts +39 -1
- package/src/lib/ui/runtime/localStoragePersistence.ts +14 -8
- package/src/lib/ui/runtime/readNode.ts +8 -7
- package/src/lib/ui/runtime/runNode.ts +18 -2
- package/src/lib/ui/runtime/scope.ts +12 -1
- package/src/lib/ui/runtime/trigger.ts +40 -24
- package/src/lib/ui/runtime/types/ReactiveNode.ts +4 -1
- package/src/lib/ui/runtime/updateIfNecessary.ts +40 -0
- package/src/lib/ui/socketChannel.ts +5 -2
- package/src/lib/ui/tryEncodeResume.ts +20 -0
- package/src/lib/ui/types/Scope.ts +9 -3
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import ts from 'typescript'
|
|
2
|
+
import { TS_PRINTER } from './TS_PRINTER.ts'
|
|
2
3
|
|
|
3
4
|
/*
|
|
4
5
|
Rewrites references to a component's signal bindings into the document form the
|
|
@@ -24,92 +25,177 @@ export function renameSignalRefs(
|
|
|
24
25
|
computedNames: ReadonlySet<string> = new Set(),
|
|
25
26
|
): string {
|
|
26
27
|
const source = ts.createSourceFile('component.ts', code, ts.ScriptTarget.Latest, true)
|
|
28
|
+
const result = ts.transform(source, [
|
|
29
|
+
signalRefsTransformer(stateNames, derivedNames, computedNames),
|
|
30
|
+
])
|
|
31
|
+
const output = TS_PRINTER.printFile(result.transformed[0] as ts.SourceFile)
|
|
32
|
+
result.dispose()
|
|
33
|
+
return output
|
|
34
|
+
}
|
|
27
35
|
|
|
36
|
+
/*
|
|
37
|
+
The signal-rewrite as a `ts.TransformerFactory`, so the script pipeline can chain it
|
|
38
|
+
with `docAccessTransformer` over a SINGLE parsed tree (see `lowerScript`) instead of
|
|
39
|
+
print-then-reparse between passes. `renameSignalRefs` is the standalone string wrapper
|
|
40
|
+
kept for per-expression callers (`lowerContext`).
|
|
41
|
+
*/
|
|
42
|
+
export function signalRefsTransformer(
|
|
43
|
+
stateNames: ReadonlySet<string>,
|
|
44
|
+
derivedNames: ReadonlySet<string>,
|
|
45
|
+
computedNames: ReadonlySet<string> = new Set(),
|
|
46
|
+
): ts.TransformerFactory<ts.SourceFile> {
|
|
28
47
|
/* The signal names that a nested binding can shadow — only these matter for
|
|
29
48
|
scope tracking, so we ignore every other local binding. */
|
|
30
49
|
const signalNames = new Set<string>([...stateNames, ...derivedNames, ...computedNames])
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
50
|
+
return (context) => (root) => {
|
|
51
|
+
/* The identifier nodes that are names, not value reads — collected by walking
|
|
52
|
+
each parent and recording its name children (`parent.name`, a label, a
|
|
53
|
+
specifier). Read structurally, NOT via `child.parent`, so it holds on
|
|
54
|
+
synthesized nodes too — `desugar`'s rewritten declarations carry no parent
|
|
55
|
+
pointers, and chaining this transformer after `desugarTransformer` over one
|
|
56
|
+
tree must still classify their refs correctly. */
|
|
57
|
+
const nameSlots = collectNameSlots(root)
|
|
58
|
+
/* Each visitor carries the set of signal names shadowed by the scopes it sits
|
|
59
|
+
inside; entering a scope that re-binds a signal name produces a fresh visitor
|
|
60
|
+
with that name added, so shadowing is per-branch (a sibling scope is unaffected). */
|
|
61
|
+
const makeVisitor = (shadowed: ReadonlySet<string>): ts.Visitor => {
|
|
62
|
+
const visit = (node: ts.Node): ts.Node => {
|
|
63
|
+
/* Type space never holds a value read. A type alias, an interface, or any
|
|
64
|
+
type annotation can name a signal — a prop-type member `option?: …`, a
|
|
65
|
+
`typeof x` — without it being a runtime reference. Leave the whole type
|
|
66
|
+
subtree untouched so it isn't rewritten into a call/access (`option()`)
|
|
67
|
+
and emitted as broken code (types erase at build anyway). */
|
|
68
|
+
if (
|
|
69
|
+
ts.isTypeAliasDeclaration(node) ||
|
|
70
|
+
ts.isInterfaceDeclaration(node) ||
|
|
71
|
+
ts.isTypeNode(node)
|
|
72
|
+
) {
|
|
73
|
+
return node
|
|
74
|
+
}
|
|
75
|
+
/* Import/export specifiers are binding/module names, never value reads.
|
|
76
|
+
An aliased import whose original name collides with a signal
|
|
77
|
+
(`import { pending as p }` next to a `pending` prop) would otherwise
|
|
78
|
+
have its `pending` specifier rewritten to the reader form, corrupting
|
|
79
|
+
the declaration. Leave the whole subtree untouched. */
|
|
80
|
+
if (ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) {
|
|
81
|
+
return node
|
|
82
|
+
}
|
|
83
|
+
/* Shorthand `{ count }` → `{ count: model.count }` / `{ total: total.value }`,
|
|
84
|
+
unless a nearer scope shadows the name. */
|
|
85
|
+
if (ts.isShorthandPropertyAssignment(node) && !shadowed.has(node.name.text)) {
|
|
86
|
+
const replacement = referenceFor(
|
|
87
|
+
node.name.text,
|
|
88
|
+
stateNames,
|
|
89
|
+
derivedNames,
|
|
90
|
+
computedNames,
|
|
91
|
+
)
|
|
92
|
+
if (replacement !== undefined) {
|
|
93
|
+
return ts.factory.createPropertyAssignment(node.name.text, replacement)
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
if (ts.isIdentifier(node) && !nameSlots.has(node) && !shadowed.has(node.text)) {
|
|
97
|
+
const replacement = referenceFor(
|
|
98
|
+
node.text,
|
|
99
|
+
stateNames,
|
|
100
|
+
derivedNames,
|
|
101
|
+
computedNames,
|
|
102
|
+
)
|
|
103
|
+
if (replacement !== undefined) {
|
|
104
|
+
return replacement
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
/* Recurse with the scope this node introduces folded in (same visitor when
|
|
108
|
+
it adds no shadowing name, so unscoped subtrees allocate nothing extra). */
|
|
109
|
+
const inner = extendShadowed(node, shadowed, signalNames)
|
|
110
|
+
return ts.visitEachChild(
|
|
111
|
+
node,
|
|
112
|
+
inner === shadowed ? visit : makeVisitor(inner),
|
|
113
|
+
context,
|
|
114
|
+
)
|
|
46
115
|
}
|
|
116
|
+
return visit
|
|
47
117
|
}
|
|
48
|
-
|
|
49
|
-
|
|
118
|
+
return ts.visitNode(root, makeVisitor(new Set())) as ts.SourceFile
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/*
|
|
123
|
+
Collects every identifier NODE that occupies a name/label/specifier slot — a position
|
|
124
|
+
that DECLARES, NAMES, or LABELS rather than READS. Walks each PARENT and records its
|
|
125
|
+
name children (`parent.name`, a label, a specifier), so it reads structure top-down
|
|
126
|
+
and never touches `child.parent` — it therefore holds on synthesized nodes (e.g.
|
|
127
|
+
`desugar`'s rewritten declarations), which carry no parent pointers. The visitor then
|
|
128
|
+
rewrites any identifier NOT in this set (rewrite is the default — value reads appear
|
|
129
|
+
under almost any expression parent and can't be allow-listed), so this set is the
|
|
130
|
+
exhaustive list of non-read slots. It is positional, so `pending` in
|
|
131
|
+
`import { pending as p }` and `pending` in `pending(query)` are told apart by where
|
|
132
|
+
they sit. A non-read slot missing here would be misread as a value read and rewritten
|
|
133
|
+
into broken syntax, so completeness is NOT self-evident — it is guarded empirically by
|
|
134
|
+
the syntax fuzz corpus (`uiCompileSyntaxFuzz.test.ts`), which transpiles each pass's
|
|
135
|
+
output and fails on any corruption.
|
|
136
|
+
*/
|
|
137
|
+
function collectNameSlots(root: ts.Node): Set<ts.Node> {
|
|
138
|
+
const slots = new Set<ts.Node>()
|
|
139
|
+
const add = (name: ts.Node | undefined): void => {
|
|
140
|
+
if (name !== undefined && ts.isIdentifier(name)) {
|
|
141
|
+
slots.add(name)
|
|
50
142
|
}
|
|
51
|
-
|
|
52
|
-
|
|
143
|
+
}
|
|
144
|
+
const visit = (node: ts.Node): void => {
|
|
145
|
+
/* `obj.NAME` — the member name; the object side (`.expression`) is the read. */
|
|
146
|
+
if (ts.isPropertyAccessExpression(node)) {
|
|
147
|
+
add(node.name)
|
|
53
148
|
}
|
|
149
|
+
/* `NAME: value`, and method/property/accessor/enum-member names in classes,
|
|
150
|
+
object literals, and type members. */
|
|
54
151
|
if (
|
|
55
|
-
|
|
56
|
-
node
|
|
152
|
+
ts.isPropertyAssignment(node) ||
|
|
153
|
+
ts.isMethodDeclaration(node) ||
|
|
154
|
+
ts.isPropertyDeclaration(node) ||
|
|
155
|
+
ts.isGetAccessorDeclaration(node) ||
|
|
156
|
+
ts.isSetAccessorDeclaration(node) ||
|
|
157
|
+
ts.isMethodSignature(node) ||
|
|
158
|
+
ts.isPropertySignature(node) ||
|
|
159
|
+
ts.isEnumMember(node)
|
|
160
|
+
) {
|
|
161
|
+
add(node.name)
|
|
162
|
+
}
|
|
163
|
+
/* Declaration names: const/let/var, parameters, named functions and classes. */
|
|
164
|
+
if (
|
|
165
|
+
ts.isVariableDeclaration(node) ||
|
|
166
|
+
ts.isParameter(node) ||
|
|
167
|
+
ts.isFunctionDeclaration(node) ||
|
|
168
|
+
ts.isFunctionExpression(node) ||
|
|
169
|
+
ts.isClassDeclaration(node) ||
|
|
170
|
+
ts.isClassExpression(node)
|
|
57
171
|
) {
|
|
58
|
-
|
|
172
|
+
add(node.name)
|
|
173
|
+
}
|
|
174
|
+
/* A destructure element binds (`{ a }` / `[a]`) or renames (`{ a: b }`): both
|
|
175
|
+
the bound name and the source key are names, not reads. */
|
|
176
|
+
if (ts.isBindingElement(node)) {
|
|
177
|
+
add(node.name)
|
|
178
|
+
add(node.propertyName)
|
|
179
|
+
}
|
|
180
|
+
/* Labels: `NAME:` and `break NAME` / `continue NAME`. */
|
|
181
|
+
if (ts.isLabeledStatement(node)) {
|
|
182
|
+
add(node.label)
|
|
183
|
+
}
|
|
184
|
+
if (ts.isBreakStatement(node) || ts.isContinueStatement(node)) {
|
|
185
|
+
add(node.label)
|
|
59
186
|
}
|
|
60
|
-
|
|
187
|
+
/* Import/export specifier and clause names. */
|
|
188
|
+
if (ts.isImportSpecifier(node) || ts.isExportSpecifier(node)) {
|
|
189
|
+
add(node.name)
|
|
190
|
+
add(node.propertyName)
|
|
191
|
+
}
|
|
192
|
+
if (ts.isImportClause(node) || ts.isNamespaceImport(node) || ts.isNamespaceExport(node)) {
|
|
193
|
+
add(node.name)
|
|
194
|
+
}
|
|
195
|
+
ts.forEachChild(node, visit)
|
|
61
196
|
}
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
const result = ts.transform(source, [
|
|
65
|
-
(context) => (root) => {
|
|
66
|
-
/* Each visitor carries the set of signal names shadowed by the scopes it sits
|
|
67
|
-
inside; entering a scope that re-binds a signal name produces a fresh visitor
|
|
68
|
-
with that name added, so shadowing is per-branch (a sibling scope is unaffected). */
|
|
69
|
-
const makeVisitor = (shadowed: ReadonlySet<string>): ts.Visitor => {
|
|
70
|
-
const visit = (node: ts.Node): ts.Node => {
|
|
71
|
-
/* Shorthand `{ count }` → `{ count: model.count }` / `{ total: total.value }`,
|
|
72
|
-
unless a nearer scope shadows the name. */
|
|
73
|
-
if (ts.isShorthandPropertyAssignment(node) && !shadowed.has(node.name.text)) {
|
|
74
|
-
const replacement = referenceFor(
|
|
75
|
-
node.name.text,
|
|
76
|
-
stateNames,
|
|
77
|
-
derivedNames,
|
|
78
|
-
computedNames,
|
|
79
|
-
)
|
|
80
|
-
if (replacement !== undefined) {
|
|
81
|
-
return ts.factory.createPropertyAssignment(node.name.text, replacement)
|
|
82
|
-
}
|
|
83
|
-
}
|
|
84
|
-
if (ts.isIdentifier(node) && !skip.has(node) && !shadowed.has(node.text)) {
|
|
85
|
-
const replacement = referenceFor(
|
|
86
|
-
node.text,
|
|
87
|
-
stateNames,
|
|
88
|
-
derivedNames,
|
|
89
|
-
computedNames,
|
|
90
|
-
)
|
|
91
|
-
if (replacement !== undefined) {
|
|
92
|
-
return replacement
|
|
93
|
-
}
|
|
94
|
-
}
|
|
95
|
-
/* Recurse with the scope this node introduces folded in (same visitor when
|
|
96
|
-
it adds no shadowing name, so unscoped subtrees allocate nothing extra). */
|
|
97
|
-
const inner = extendShadowed(node, shadowed, signalNames)
|
|
98
|
-
return ts.visitEachChild(
|
|
99
|
-
node,
|
|
100
|
-
inner === shadowed ? visit : makeVisitor(inner),
|
|
101
|
-
context,
|
|
102
|
-
)
|
|
103
|
-
}
|
|
104
|
-
return visit
|
|
105
|
-
}
|
|
106
|
-
return ts.visitNode(root, makeVisitor(new Set())) as ts.SourceFile
|
|
107
|
-
},
|
|
108
|
-
])
|
|
109
|
-
const printer = ts.createPrinter({ newLine: ts.NewLineKind.LineFeed })
|
|
110
|
-
const output = printer.printFile(result.transformed[0] as ts.SourceFile)
|
|
111
|
-
result.dispose()
|
|
112
|
-
return output
|
|
197
|
+
visit(root)
|
|
198
|
+
return slots
|
|
113
199
|
}
|
|
114
200
|
|
|
115
201
|
/* The shadowed-name set for `node`'s children: the parent set plus any signal name
|
|
@@ -187,22 +273,6 @@ function collectStatementBindings(statement: ts.Statement, into: Set<string>): v
|
|
|
187
273
|
}
|
|
188
274
|
}
|
|
189
275
|
|
|
190
|
-
/* The identifier NODES a binding name binds — a plain identifier or the leaves of a
|
|
191
|
-
destructuring pattern (object/array, including nested patterns and rest elements).
|
|
192
|
-
For `{ a: b }` only the bound name `b` is a binding; `a` is the source property.
|
|
193
|
-
Feeds the skip set so a destructured name shadowing a signal is never rewritten. */
|
|
194
|
-
function collectBindingIdentifiers(name: ts.BindingName, into: Set<ts.Node>): void {
|
|
195
|
-
if (ts.isIdentifier(name)) {
|
|
196
|
-
into.add(name)
|
|
197
|
-
return
|
|
198
|
-
}
|
|
199
|
-
for (const element of name.elements) {
|
|
200
|
-
if (ts.isBindingElement(element)) {
|
|
201
|
-
collectBindingIdentifiers(element.name, into)
|
|
202
|
-
}
|
|
203
|
-
}
|
|
204
|
-
}
|
|
205
|
-
|
|
206
276
|
/* Every identifier bound by a binding name — a plain identifier or the leaves of a
|
|
207
277
|
destructuring pattern (object/array, including rest elements). */
|
|
208
278
|
function collectBindingNames(name: ts.BindingName, into: Set<string>): void {
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { TemplateNode } from './types/TemplateNode.ts'
|
|
2
|
+
|
|
3
|
+
type Branch = Extract<TemplateNode, { kind: 'branch' }>
|
|
4
|
+
type BranchName = Branch['branch']
|
|
5
|
+
|
|
6
|
+
/* Extracts the `then`/`catch`/`finally` branch children of a control-flow block
|
|
7
|
+
(await/try), one slot per requested name in the order asked, so callers
|
|
8
|
+
destructure them directly. A requested branch that is absent yields `undefined`
|
|
9
|
+
— the caller reads `?.children`/`?.as` for its content and bound var. This is
|
|
10
|
+
the one branch-lookup site both back-ends share, replacing the per-generator
|
|
11
|
+
`findBranch`/`branchNamed`/`branchChildren`/`branchVar` copies. */
|
|
12
|
+
export function resolveBranches(
|
|
13
|
+
node: Extract<TemplateNode, { children: TemplateNode[] }>,
|
|
14
|
+
...which: BranchName[]
|
|
15
|
+
): (Branch | undefined)[] {
|
|
16
|
+
return which.map((name) =>
|
|
17
|
+
node.children.find(
|
|
18
|
+
(child): child is Branch => child.kind === 'branch' && child.branch === name,
|
|
19
|
+
),
|
|
20
|
+
)
|
|
21
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import ts from 'typescript'
|
|
2
|
+
import { TS_PRINTER } from './TS_PRINTER.ts'
|
|
2
3
|
|
|
3
4
|
/*
|
|
4
5
|
Removes `effect(...)` calls from a script for the SSR back-end. Effects are client
|
|
@@ -10,23 +11,27 @@ binding keeps a defined (unused) name. Client compilation keeps effects untouche
|
|
|
10
11
|
*/
|
|
11
12
|
export function stripEffects(code: string): string {
|
|
12
13
|
const source = ts.createSourceFile('script.ts', code, ts.ScriptTarget.Latest, true)
|
|
13
|
-
const result = ts.transform(source, [
|
|
14
|
-
|
|
15
|
-
const visit = (node: ts.Node): ts.Node => {
|
|
16
|
-
if (
|
|
17
|
-
ts.isCallExpression(node) &&
|
|
18
|
-
ts.isIdentifier(node.expression) &&
|
|
19
|
-
node.expression.text === 'effect'
|
|
20
|
-
) {
|
|
21
|
-
return ts.factory.createIdentifier('undefined')
|
|
22
|
-
}
|
|
23
|
-
return ts.visitEachChild(node, visit, context)
|
|
24
|
-
}
|
|
25
|
-
return ts.visitNode(root, visit) as ts.SourceFile
|
|
26
|
-
},
|
|
27
|
-
])
|
|
28
|
-
const printer = ts.createPrinter({ newLine: ts.NewLineKind.LineFeed })
|
|
29
|
-
const output = printer.printFile(result.transformed[0] as ts.SourceFile)
|
|
14
|
+
const result = ts.transform(source, [stripEffectsTransformer()])
|
|
15
|
+
const output = TS_PRINTER.printFile(result.transformed[0] as ts.SourceFile)
|
|
30
16
|
result.dispose()
|
|
31
17
|
return output
|
|
32
18
|
}
|
|
19
|
+
|
|
20
|
+
/* The effect-stripping as a `ts.TransformerFactory`, so the SSR script path can run it
|
|
21
|
+
over the tree `lowerScript` already built (one extra print, no reparse). `stripEffects`
|
|
22
|
+
is the standalone string wrapper kept for the per-nested-`<script>` SSR callers. */
|
|
23
|
+
export function stripEffectsTransformer(): ts.TransformerFactory<ts.SourceFile> {
|
|
24
|
+
return (context) => (root) => {
|
|
25
|
+
const visit = (node: ts.Node): ts.Node => {
|
|
26
|
+
if (
|
|
27
|
+
ts.isCallExpression(node) &&
|
|
28
|
+
ts.isIdentifier(node.expression) &&
|
|
29
|
+
node.expression.text === 'effect'
|
|
30
|
+
) {
|
|
31
|
+
return ts.factory.createIdentifier('undefined')
|
|
32
|
+
}
|
|
33
|
+
return ts.visitEachChild(node, visit, context)
|
|
34
|
+
}
|
|
35
|
+
return ts.visitNode(root, visit) as ts.SourceFile
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -7,7 +7,12 @@ The shared front-end result for a component, consumed by both the client
|
|
|
7
7
|
(so template expressions rewrite consistently), and the parsed template tree.
|
|
8
8
|
*/
|
|
9
9
|
export type AnalyzedComponent = {
|
|
10
|
+
/* The lowered client script — keeps `effect(...)` calls (client lifecycle). */
|
|
10
11
|
script: string
|
|
12
|
+
/* The same lowered script with `effect(...)` calls stripped, for the SSR render
|
|
13
|
+
(effects emit no HTML and must not run on the server). Produced from the one
|
|
14
|
+
parse `script` came from, not a downstream re-parse. */
|
|
15
|
+
ssrScript: string
|
|
11
16
|
/* Top-level import statements hoisted out of the script (e.g. child
|
|
12
17
|
components), placed at module scope by the module wrapper. */
|
|
13
18
|
imports: string
|
|
@@ -4,7 +4,7 @@ import type { TextPart } from './TextPart.ts'
|
|
|
4
4
|
/*
|
|
5
5
|
A parsed template node. `text` carries interpolation parts; `element` carries
|
|
6
6
|
attributes and children; `each` is the `<template each as key>` control flow over
|
|
7
|
-
a list
|
|
7
|
+
a list; `if`/`await`/`switch`/`try` are its control-flow siblings.
|
|
8
8
|
|
|
9
9
|
`loc` (where present) is the absolute offset of the node's primary expression in
|
|
10
10
|
the original `.abide` source — additive, set only when the parser tracks
|
|
@@ -67,7 +67,17 @@ export type TemplateNode =
|
|
|
67
67
|
children: TemplateNode[]
|
|
68
68
|
}
|
|
69
69
|
| { kind: 'switch'; subject: string; children: TemplateNode[]; loc?: number }
|
|
70
|
-
|
|
70
|
+
/* A branch of a `<template switch>` (`match` set) or `<template if>` chain. Inside an
|
|
71
|
+
`if`, `<template elseif={c}>` sets `condition` (match-less, truthy-tested), and
|
|
72
|
+
`<template else>` leaves both unset (the default). `loc` points at whichever
|
|
73
|
+
expression the node carries (`match` or `condition`). */
|
|
74
|
+
| {
|
|
75
|
+
kind: 'case'
|
|
76
|
+
match: string | undefined
|
|
77
|
+
condition?: string
|
|
78
|
+
children: TemplateNode[]
|
|
79
|
+
loc?: number
|
|
80
|
+
}
|
|
71
81
|
/* A `<template name="row" args={item}>` snippet: a named, scope-capturing
|
|
72
82
|
builder declared once and called like a function (`{row(item)}`). `params`
|
|
73
83
|
is the raw `args` source spliced into the builder's parameter list. */
|
|
@@ -37,6 +37,9 @@ export function createScope(
|
|
|
37
37
|
const data = (): Doc => (document ??= createDoc({}))
|
|
38
38
|
const id = parent === undefined ? `scope-${nextId++}` : `${parent.id}.${nextId++}`
|
|
39
39
|
const children: Scope[] = []
|
|
40
|
+
/* Context values shared down the tree, held apart from the reactive doc (which
|
|
41
|
+
a child does not inherit): keyed by name, read by the closest ancestor walk. */
|
|
42
|
+
const shared = new Map<string, unknown>()
|
|
40
43
|
let past: History | undefined
|
|
41
44
|
let persistence: PersistHandle | undefined
|
|
42
45
|
let unsync: (() => void) | undefined
|
|
@@ -64,6 +67,16 @@ export function createScope(
|
|
|
64
67
|
return created
|
|
65
68
|
},
|
|
66
69
|
root: () => (parent === undefined ? self : parent.root()),
|
|
70
|
+
/* Reference store — no tracking, so a lookup never subscribes; reactivity comes
|
|
71
|
+
from what is shared (a cell/scope), not from the share. */
|
|
72
|
+
share: (key, value) => {
|
|
73
|
+
shared.set(key, value)
|
|
74
|
+
},
|
|
75
|
+
/* Closest-ancestor resolve: own map first (self can read what it shared), then
|
|
76
|
+
defer up via each scope's own `shared`. `has` distinguishes a shared
|
|
77
|
+
`undefined` from "not provided"; undefined at the root means no provider. */
|
|
78
|
+
shared: <T>(key: string): T | undefined =>
|
|
79
|
+
shared.has(key) ? (shared.get(key) as T) : parent?.shared<T>(key),
|
|
67
80
|
record: (options) => {
|
|
68
81
|
past ??= history(data(), options)
|
|
69
82
|
},
|
|
@@ -82,6 +95,7 @@ export function createScope(
|
|
|
82
95
|
created.dispose()
|
|
83
96
|
}
|
|
84
97
|
children.length = 0
|
|
98
|
+
shared.clear()
|
|
85
99
|
past?.dispose()
|
|
86
100
|
past = undefined
|
|
87
101
|
persistence?.dispose()
|
|
@@ -4,6 +4,7 @@ import { effect } from '../effect.ts'
|
|
|
4
4
|
import { claimChild } from '../runtime/claimChild.ts'
|
|
5
5
|
import { RENDER } from '../runtime/RENDER.ts'
|
|
6
6
|
import { appendSnippet } from './appendSnippet.ts'
|
|
7
|
+
import { parseRawNodes } from './parseRawNodes.ts'
|
|
7
8
|
|
|
8
9
|
const CLOSE = '/abide:html'
|
|
9
10
|
|
|
@@ -73,9 +74,7 @@ function appendRawHtml(parent: Node, read: () => unknown): void {
|
|
|
73
74
|
for (const node of nodes) {
|
|
74
75
|
parent.removeChild(node)
|
|
75
76
|
}
|
|
76
|
-
|
|
77
|
-
holder.innerHTML = value
|
|
78
|
-
nodes = [...holder.childNodes]
|
|
77
|
+
nodes = parseRawNodes(parent, value)
|
|
79
78
|
for (const node of nodes) {
|
|
80
79
|
parent.insertBefore(node, anchor)
|
|
81
80
|
}
|
|
@@ -4,6 +4,7 @@ import { effect } from '../effect.ts'
|
|
|
4
4
|
import { RENDER } from '../runtime/RENDER.ts'
|
|
5
5
|
import { appendSnippet } from './appendSnippet.ts'
|
|
6
6
|
import { appendText } from './appendText.ts'
|
|
7
|
+
import { parseRawNodes } from './parseRawNodes.ts'
|
|
7
8
|
|
|
8
9
|
/*
|
|
9
10
|
A reactive `{expr}` interpolation mounted at a skeleton anchor comment (`<!--a-->`), used
|
|
@@ -51,9 +52,7 @@ export function appendTextAt(anchor: Node, read: () => unknown): void {
|
|
|
51
52
|
for (const node of nodes) {
|
|
52
53
|
parent.removeChild(node)
|
|
53
54
|
}
|
|
54
|
-
|
|
55
|
-
holder.innerHTML = rawHtmlString(read()) ?? ''
|
|
56
|
-
nodes = [...holder.childNodes]
|
|
55
|
+
nodes = parseRawNodes(parent, rawHtmlString(read()) ?? '')
|
|
57
56
|
/* Insert the fresh markup just after the anchor (its live re-insertion point). */
|
|
58
57
|
const after = anchor.nextSibling
|
|
59
58
|
for (const node of nodes) {
|
|
@@ -41,16 +41,13 @@ export function applyResolved(root: Element, frame: string): void {
|
|
|
41
41
|
if (id === null) {
|
|
42
42
|
return
|
|
43
43
|
}
|
|
44
|
-
/* The resolved value rides in a leading <script type=application/json
|
|
45
|
-
|
|
46
|
-
|
|
44
|
+
/* The resolved value rides in a leading <script type=application/json> as ref-json
|
|
45
|
+
text; store it raw and remove the node so only the resolved markup moves into the
|
|
46
|
+
boundary. Decoding is deferred to the read in `awaitBlock` (which has the codec);
|
|
47
|
+
recording it lets a later hydrate adopt this branch (no re-fetch). */
|
|
47
48
|
const payload = resolved.firstChild as Element | null
|
|
48
49
|
if (payload !== null && payload.nodeName === 'SCRIPT') {
|
|
49
|
-
|
|
50
|
-
RESUME[Number(id)] = JSON.parse(payload.textContent ?? 'null')
|
|
51
|
-
} catch {
|
|
52
|
-
/* malformed payload — leave unregistered, hydration re-runs the promise */
|
|
53
|
-
}
|
|
50
|
+
RESUME[Number(id)] = payload.textContent ?? ''
|
|
54
51
|
payload.remove()
|
|
55
52
|
}
|
|
56
53
|
const open = `abide:await:${id}`
|
|
@@ -1,6 +1,8 @@
|
|
|
1
|
+
import { decodeRefJson } from '../../shared/decodeRefJson.ts'
|
|
1
2
|
import { effect } from '../effect.ts'
|
|
2
3
|
import { claimChild } from '../runtime/claimChild.ts'
|
|
3
4
|
import { RENDER } from '../runtime/RENDER.ts'
|
|
5
|
+
import type { ResumeEntry } from '../runtime/RESUME.ts'
|
|
4
6
|
import { RESUME } from '../runtime/RESUME.ts'
|
|
5
7
|
import { scope } from '../runtime/scope.ts'
|
|
6
8
|
import { scopeGroup } from '../runtime/scopeGroup.ts'
|
|
@@ -161,7 +163,18 @@ export function awaitBlock(
|
|
|
161
163
|
const firstHydrate = (result: unknown): void => {
|
|
162
164
|
const cursor = hydration as NonNullable<typeof hydration>
|
|
163
165
|
const open = claimChild(cursor, parent)
|
|
164
|
-
|
|
166
|
+
/* RESUME holds the ref-json-encoded entry STRING; decode here, where the codec
|
|
167
|
+
lives. A decode failure (malformed/absent payload) reads as "no resume" — fall
|
|
168
|
+
through to the live promise rather than crash hydration. */
|
|
169
|
+
const raw = RESUME[id]
|
|
170
|
+
let entry: ResumeEntry | undefined
|
|
171
|
+
if (raw !== undefined) {
|
|
172
|
+
try {
|
|
173
|
+
entry = decodeRefJson(raw) as ResumeEntry
|
|
174
|
+
} catch {
|
|
175
|
+
entry = undefined
|
|
176
|
+
}
|
|
177
|
+
}
|
|
165
178
|
if (entry !== undefined) {
|
|
166
179
|
let build: (host: Node) => void
|
|
167
180
|
if (entry.ok) {
|
|
@@ -210,7 +223,14 @@ export function awaitBlock(
|
|
|
210
223
|
subscribes to its reactive source (a cache key). A cache-remote read is warm
|
|
211
224
|
on resume — it serves the snapshot without a network round-trip, so adoption
|
|
212
225
|
stays no-flash AND a later cache.invalidate re-runs the block. Without this
|
|
213
|
-
read a resume-adopted block has no deps and invalidate is a no-op.
|
|
226
|
+
read a resume-adopted block has no deps and invalidate is a no-op.
|
|
227
|
+
|
|
228
|
+
ONLY the promise read is tracked. The warm-sync resolve, the hydration adopt,
|
|
229
|
+
and the pending render all BUILD the branch through `scope`, which builds
|
|
230
|
+
untracked — so the branch's own reactive reads don't subscribe THIS effect
|
|
231
|
+
(otherwise the whole block re-runs and re-suspends on any branch-state change,
|
|
232
|
+
e.g. a sibling route param updating in place). The branch's own child effects
|
|
233
|
+
still track normally; the block re-runs only when the promise source does. */
|
|
214
234
|
const result = promiseThunk()
|
|
215
235
|
if (first) {
|
|
216
236
|
first = false
|
package/src/lib/ui/dom/each.ts
CHANGED
|
@@ -45,6 +45,10 @@ export function each<T>(
|
|
|
45
45
|
claim): claim the start marker, build content in place, claim the end marker.
|
|
46
46
|
Create mode: markers + content in a fragment, held in `pending` until placement
|
|
47
47
|
inserts it. */
|
|
48
|
+
/* Reconcile runs inside the effect below; the row build doesn't subscribe it
|
|
49
|
+
(`scope` builds untracked), so a raw reactive read in the row content — e.g. a
|
|
50
|
+
nested `<script>` body — can't re-reconcile the whole list. Only `items()` drives
|
|
51
|
+
the each; each row's own interpolations track through their own effects. */
|
|
48
52
|
const buildRow = (item: T): EachRow => {
|
|
49
53
|
const hydration = RENDER.hydration
|
|
50
54
|
if (hydration !== undefined) {
|
package/src/lib/ui/dom/on.ts
CHANGED
|
@@ -12,6 +12,13 @@ resolves the component, not whatever is current when the event fires.
|
|
|
12
12
|
*/
|
|
13
13
|
// @documentation plumbing
|
|
14
14
|
export function on(element: Element, type: string, handler: EventListener): void {
|
|
15
|
+
/* A caller that omits an optional `on*` prop forwards it as undefined, yet the
|
|
16
|
+
compiler still emits this on() call — so skip attaching a non-function rather
|
|
17
|
+
than invoke undefined when the event fires (e.g. input/keydown on a search box,
|
|
18
|
+
which would throw "handler is not a function" on every keystroke). */
|
|
19
|
+
if (typeof handler !== 'function') {
|
|
20
|
+
return
|
|
21
|
+
}
|
|
15
22
|
const captured = CURRENT_SCOPE.current
|
|
16
23
|
const wrapped: EventListener = (event) => inScope(captured, () => handler(event))
|
|
17
24
|
element.addEventListener(type, wrapped)
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { foreignWrapperTag } from './foreignWrapperTag.ts'
|
|
2
|
+
|
|
3
|
+
/*
|
|
4
|
+
Parses a trusted raw-markup string into detached nodes in the SAME foreign namespace
|
|
5
|
+
as `parent`. The HTML fragment parser namespaces by its context element, so markup
|
|
6
|
+
bound inside an <svg>/<math> must parse inside a matching wrapper — otherwise a bare
|
|
7
|
+
`<path>` lands in the HTML namespace and never renders. Mirrors `cloneStatic`'s
|
|
8
|
+
foreign handling, but parses per-call into a throwaway `<template>` rather than the
|
|
9
|
+
skeleton cache: raw values are dynamic, so caching every distinct string would leak.
|
|
10
|
+
*/
|
|
11
|
+
export function parseRawNodes(parent: Node, markup: string): Node[] {
|
|
12
|
+
const wrapper = foreignWrapperTag(parent)
|
|
13
|
+
const template = document.createElement('template')
|
|
14
|
+
template.innerHTML = wrapper === undefined ? markup : `<${wrapper}>${markup}</${wrapper}>`
|
|
15
|
+
const source = wrapper === undefined ? template.content : (template.content.firstChild as Node)
|
|
16
|
+
return [...source.childNodes]
|
|
17
|
+
}
|
|
@@ -42,6 +42,10 @@ export function switchBlock(
|
|
|
42
42
|
}
|
|
43
43
|
const caseAt = (index: number): SwitchCase | undefined =>
|
|
44
44
|
index === -1 ? undefined : cases[index]
|
|
45
|
+
/* The chosen case builds through `scope` (directly on hydrate, via `fillBefore` on a
|
|
46
|
+
swap), which builds untracked — so a raw reactive read in the case content doesn't
|
|
47
|
+
subscribe the swap effect below; only `subject()` (and each case's `match()`) drives
|
|
48
|
+
the swap. The case's own interpolations still track, each through its own effect. */
|
|
45
49
|
|
|
46
50
|
/* `before` places the range among static siblings on create (block before a suffix);
|
|
47
51
|
hydrate ignores it and uses the parked claim cursor. */
|
package/src/lib/ui/dom/when.ts
CHANGED
|
@@ -28,6 +28,10 @@ export function when(
|
|
|
28
28
|
before: Node | null = null,
|
|
29
29
|
): void {
|
|
30
30
|
const hydration = RENDER.hydration
|
|
31
|
+
/* The chosen branch builds through `scope` (directly on hydrate, via `fillBefore`
|
|
32
|
+
on a swap), which builds untracked — so a raw reactive read in the branch content
|
|
33
|
+
doesn't subscribe the swap effect below; only `condition()` drives the toggle. The
|
|
34
|
+
branch's own interpolations still track, each through its own effect. */
|
|
31
35
|
const chosenFor = (branch: 'then' | 'else') => (branch === 'then' ? render : renderElse)
|
|
32
36
|
/* The live branch's scope, registered with the owner so it disposes on owner
|
|
33
37
|
teardown — not only on a branch flip via clearBetween. */
|
|
@@ -49,7 +49,9 @@ function routerState() {
|
|
|
49
49
|
return {
|
|
50
50
|
path: runtimePath.value,
|
|
51
51
|
route: page.route,
|
|
52
|
-
|
|
52
|
+
/* `page.params` is a reactive Proxy — structuredClone (postMessage) can't carry
|
|
53
|
+
a Proxy and throws, dropping every router frame. Spread to a plain object. */
|
|
54
|
+
params: { ...page.params },
|
|
53
55
|
navigating: page.navigating,
|
|
54
56
|
url: page.url.href,
|
|
55
57
|
entry: historyEntries.current,
|