@reactra/babel-plugin 0.1.0-alpha.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/LICENSE +21 -0
- package/README.md +17 -0
- package/dist/ast/index.d.ts +2 -0
- package/dist/ast/index.d.ts.map +1 -0
- package/dist/ast/index.js +3 -0
- package/dist/ast/index.js.map +1 -0
- package/dist/ast/nodes.d.ts +437 -0
- package/dist/ast/nodes.d.ts.map +1 -0
- package/dist/ast/nodes.js +35 -0
- package/dist/ast/nodes.js.map +1 -0
- package/dist/behaviours/index.d.ts +18 -0
- package/dist/behaviours/index.d.ts.map +1 -0
- package/dist/behaviours/index.js +36 -0
- package/dist/behaviours/index.js.map +1 -0
- package/dist/behaviours/plugin.d.ts +22 -0
- package/dist/behaviours/plugin.d.ts.map +1 -0
- package/dist/behaviours/plugin.js +70 -0
- package/dist/behaviours/plugin.js.map +1 -0
- package/dist/behaviours/replayable.d.ts +10 -0
- package/dist/behaviours/replayable.d.ts.map +1 -0
- package/dist/behaviours/replayable.js +86 -0
- package/dist/behaviours/replayable.js.map +1 -0
- package/dist/behaviours/types.d.ts +77 -0
- package/dist/behaviours/types.d.ts.map +1 -0
- package/dist/behaviours/types.js +10 -0
- package/dist/behaviours/types.js.map +1 -0
- package/dist/behaviours/undoable.d.ts +10 -0
- package/dist/behaviours/undoable.d.ts.map +1 -0
- package/dist/behaviours/undoable.js +62 -0
- package/dist/behaviours/undoable.js.map +1 -0
- package/dist/compile.d.ts +69 -0
- package/dist/compile.d.ts.map +1 -0
- package/dist/compile.js +75 -0
- package/dist/compile.js.map +1 -0
- package/dist/conventions/index.d.ts +110 -0
- package/dist/conventions/index.d.ts.map +1 -0
- package/dist/conventions/index.js +193 -0
- package/dist/conventions/index.js.map +1 -0
- package/dist/index.d.ts +48 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +77 -0
- package/dist/index.js.map +1 -0
- package/dist/passes/index.d.ts +5 -0
- package/dist/passes/index.d.ts.map +1 -0
- package/dist/passes/index.js +6 -0
- package/dist/passes/index.js.map +1 -0
- package/dist/passes/pass-1-parse.d.ts +3 -0
- package/dist/passes/pass-1-parse.d.ts.map +1 -0
- package/dist/passes/pass-1-parse.js +21 -0
- package/dist/passes/pass-1-parse.js.map +1 -0
- package/dist/passes/pass-2-extract.d.ts +4 -0
- package/dist/passes/pass-2-extract.d.ts.map +1 -0
- package/dist/passes/pass-2-extract.js +762 -0
- package/dist/passes/pass-2-extract.js.map +1 -0
- package/dist/passes/pass-3-readset.d.ts +11 -0
- package/dist/passes/pass-3-readset.d.ts.map +1 -0
- package/dist/passes/pass-3-readset.js +338 -0
- package/dist/passes/pass-3-readset.js.map +1 -0
- package/dist/passes/pass-9-codegen.d.ts +27 -0
- package/dist/passes/pass-9-codegen.d.ts.map +1 -0
- package/dist/passes/pass-9-codegen.js +2755 -0
- package/dist/passes/pass-9-codegen.js.map +1 -0
- package/dist/preprocess/helpers.d.ts +71 -0
- package/dist/preprocess/helpers.d.ts.map +1 -0
- package/dist/preprocess/helpers.js +342 -0
- package/dist/preprocess/helpers.js.map +1 -0
- package/dist/preprocess/index.d.ts +6 -0
- package/dist/preprocess/index.d.ts.map +1 -0
- package/dist/preprocess/index.js +11 -0
- package/dist/preprocess/index.js.map +1 -0
- package/dist/preprocess/keywords.d.ts +28 -0
- package/dist/preprocess/keywords.d.ts.map +1 -0
- package/dist/preprocess/keywords.js +99 -0
- package/dist/preprocess/keywords.js.map +1 -0
- package/dist/preprocess/lexer.d.ts +8 -0
- package/dist/preprocess/lexer.d.ts.map +1 -0
- package/dist/preprocess/lexer.js +143 -0
- package/dist/preprocess/lexer.js.map +1 -0
- package/dist/preprocess/preprocess.d.ts +3 -0
- package/dist/preprocess/preprocess.d.ts.map +1 -0
- package/dist/preprocess/preprocess.js +568 -0
- package/dist/preprocess/preprocess.js.map +1 -0
- package/dist/preprocess/rewriters.d.ts +35 -0
- package/dist/preprocess/rewriters.d.ts.map +1 -0
- package/dist/preprocess/rewriters.js +1391 -0
- package/dist/preprocess/rewriters.js.map +1 -0
- package/dist/preprocess/source-map.d.ts +70 -0
- package/dist/preprocess/source-map.d.ts.map +1 -0
- package/dist/preprocess/source-map.js +253 -0
- package/dist/preprocess/source-map.js.map +1 -0
- package/dist/preprocess/types.d.ts +57 -0
- package/dist/preprocess/types.d.ts.map +1 -0
- package/dist/preprocess/types.js +7 -0
- package/dist/preprocess/types.js.map +1 -0
- package/dist/sidecar.d.ts +137 -0
- package/dist/sidecar.d.ts.map +1 -0
- package/dist/sidecar.js +172 -0
- package/dist/sidecar.js.map +1 -0
- package/package.json +42 -0
|
@@ -0,0 +1,2755 @@
|
|
|
1
|
+
// Pass 9 — code-gen.
|
|
2
|
+
//
|
|
3
|
+
// Walks a FileGraph (output of Pass 2) and emits valid React 19 TSX as a
|
|
4
|
+
// string. Implementation strategy: rather than depend on @babel/generator
|
|
5
|
+
// (which has transitive deps that aren't always resolvable in this
|
|
6
|
+
// sandbox), we slice expression text from the preprocessed source using
|
|
7
|
+
// Babel AST positions, then assemble the output with string templates.
|
|
8
|
+
//
|
|
9
|
+
// Owner spec: reactra-compiler-spec.md §4 Pass 9.
|
|
10
|
+
//
|
|
11
|
+
// Phase-1 scope as of this commit:
|
|
12
|
+
// - components (incl. resources + await/pending/error) — Day 9a
|
|
13
|
+
// - export/route/session stores → closure factories + StoreRegistry
|
|
14
|
+
// bindings; bare `use storeX` → useReactraStore destructure (Day 9b).
|
|
15
|
+
// - services → Strategy A singleton factories + ServiceRegistry.get
|
|
16
|
+
// lookups at every `inject X` site (Day 9c).
|
|
17
|
+
// - argumented `use storeX(...)` (router lifecycle) → next session.
|
|
18
|
+
import _traverse from "@babel/traverse";
|
|
19
|
+
import * as t from "@babel/types";
|
|
20
|
+
import { fieldLocal } from "../ast/nodes.js";
|
|
21
|
+
// framework-review §B1: setter naming + await arg order + `cap` are the shared
|
|
22
|
+
// emission conventions — ONE home in `conventions/`, used by Pass 9 AND the
|
|
23
|
+
// language-tools shadow emitter (which previously re-derived a divergent copy).
|
|
24
|
+
import { AWAIT_ARG_ORDER, cap, classifyStateSetters, setterActionTarget, setterNameFor, } from "../conventions/index.js";
|
|
25
|
+
import { matchedNativeBehaviours, NATIVE_BEHAVIOURS } from "../behaviours/index.js";
|
|
26
|
+
import { resolvePluginBehaviours } from "../behaviours/plugin.js";
|
|
27
|
+
const traverse = (_traverse.default ?? _traverse);
|
|
28
|
+
/** Slice a substring out of the preprocessed source by Babel AST positions. */
|
|
29
|
+
const sliceNode = (preprocessed, node) => {
|
|
30
|
+
if (node.start == null || node.end == null)
|
|
31
|
+
return "";
|
|
32
|
+
return preprocessed.slice(node.start, node.end);
|
|
33
|
+
};
|
|
34
|
+
/** Boilerplate / compiler-injected text with no DSL origin. */
|
|
35
|
+
const plain = (text) => ({ text, marks: [] });
|
|
36
|
+
/** Concatenate emitted parts, shifting each part's marks by the running length. */
|
|
37
|
+
const concatE = (parts) => {
|
|
38
|
+
let text = "";
|
|
39
|
+
const marks = [];
|
|
40
|
+
for (const p of parts) {
|
|
41
|
+
const base = text.length;
|
|
42
|
+
for (const m of p.marks)
|
|
43
|
+
marks.push({ at: base + m.at, ppOffset: m.ppOffset });
|
|
44
|
+
text += p.text;
|
|
45
|
+
}
|
|
46
|
+
return { text, marks };
|
|
47
|
+
};
|
|
48
|
+
/** Join emitted parts with a (plain) separator — mirrors `array.join(sep)`. */
|
|
49
|
+
const joinE = (parts, sep) => {
|
|
50
|
+
const woven = [];
|
|
51
|
+
parts.forEach((p, i) => {
|
|
52
|
+
if (i > 0)
|
|
53
|
+
woven.push(plain(sep));
|
|
54
|
+
woven.push(p);
|
|
55
|
+
});
|
|
56
|
+
return concatE(woven);
|
|
57
|
+
};
|
|
58
|
+
/**
|
|
59
|
+
* One source mark per line of a piece list: at the body start and at the first
|
|
60
|
+
* char of every subsequent line. Within a `verbatim` piece the origin is exact
|
|
61
|
+
* (`srcStart + offset`); within a `gen` piece it anchors at the edit site. Line
|
|
62
|
+
* granularity is what breakpoint binding needs — a breakpoint on any DSL line of a
|
|
63
|
+
* multi-line body now lands on the matching compiled line, not the body's first.
|
|
64
|
+
*/
|
|
65
|
+
const pieceLineMarks = (pieces) => {
|
|
66
|
+
const out = [];
|
|
67
|
+
let at = 0;
|
|
68
|
+
let atLineStart = true;
|
|
69
|
+
for (const p of pieces) {
|
|
70
|
+
for (let i = 0; i < p.text.length; i++) {
|
|
71
|
+
if (atLineStart) {
|
|
72
|
+
out.push({ at, ppOffset: p.kind === "verbatim" ? p.srcStart + i : p.anchorSrc });
|
|
73
|
+
atLineStart = false;
|
|
74
|
+
}
|
|
75
|
+
if (p.text.charCodeAt(i) === 10 /* \n */)
|
|
76
|
+
atLineStart = true;
|
|
77
|
+
at++;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return out;
|
|
81
|
+
};
|
|
82
|
+
/**
|
|
83
|
+
* Assemble `prefix` + the pieces + `suffix` into an `Emitted`, attaching the
|
|
84
|
+
* per-line marks (shifted past the prefix). `.text` is byte-identical to a plain
|
|
85
|
+
* `prefix + pieces.join("") + suffix` — only the map rides alongside.
|
|
86
|
+
*/
|
|
87
|
+
const emitMappedPieces = (prefix, pieces, suffix) => {
|
|
88
|
+
const body = pieces.map((p) => p.text).join("");
|
|
89
|
+
const text = `${prefix}${body}${suffix}`;
|
|
90
|
+
if (body.length === 0)
|
|
91
|
+
return plain(text);
|
|
92
|
+
const marks = pieceLineMarks(pieces).map((m) => ({
|
|
93
|
+
at: prefix.length + m.at,
|
|
94
|
+
ppOffset: m.ppOffset,
|
|
95
|
+
}));
|
|
96
|
+
return { text, marks };
|
|
97
|
+
};
|
|
98
|
+
/**
|
|
99
|
+
* A single emitted fragment mapping one verbatim user-code slice: `prefix` + the
|
|
100
|
+
* slice text + `suffix`. The slice is copied char-for-char, so it maps per line
|
|
101
|
+
* back to its DSL origin (#10-followup §8). `node` supplies both the text
|
|
102
|
+
* (`sliceNode`) and the origin (`node.start`).
|
|
103
|
+
*/
|
|
104
|
+
const mappedSlice = (preprocessed, prefix, node, suffix) => {
|
|
105
|
+
const text = node ? sliceNode(preprocessed, node) : "";
|
|
106
|
+
if (node && node.start != null && text.length > 0) {
|
|
107
|
+
return emitMappedPieces(prefix, [{ kind: "verbatim", srcStart: node.start, text }], suffix);
|
|
108
|
+
}
|
|
109
|
+
return plain(`${prefix}${text}${suffix}`);
|
|
110
|
+
};
|
|
111
|
+
/**
|
|
112
|
+
* Maps a compound assignment operator to the arithmetic/logical operator to use
|
|
113
|
+
* in `setX(prev => prev OP (rhs))` lowering (F2 / Component DSL §2.3).
|
|
114
|
+
*
|
|
115
|
+
* Returns the binary op string, e.g. `"+="` → `"+"`. Returns `null` for operators
|
|
116
|
+
* that are NOT compound writes on a state name (plain `"="` handled separately).
|
|
117
|
+
*/
|
|
118
|
+
const COMPOUND_OP_MAP = {
|
|
119
|
+
"+=": "+",
|
|
120
|
+
"-=": "-",
|
|
121
|
+
"*=": "*",
|
|
122
|
+
"/=": "/",
|
|
123
|
+
"%=": "%",
|
|
124
|
+
"**=": "**",
|
|
125
|
+
"&=": "&",
|
|
126
|
+
"|=": "|",
|
|
127
|
+
"^=": "^",
|
|
128
|
+
"<<=": "<<",
|
|
129
|
+
">>=": ">>",
|
|
130
|
+
">>>=": ">>>",
|
|
131
|
+
"||=": "||",
|
|
132
|
+
"&&=": "&&",
|
|
133
|
+
"??=": "??",
|
|
134
|
+
};
|
|
135
|
+
/**
|
|
136
|
+
* Decompose a component `action` body into source-mapped pieces. Every
|
|
137
|
+
* `stateName = expr` lowers to a `set<Cap(X)>(expr)` call; compound writes
|
|
138
|
+
* (`x += e`, `x++`, etc.) lower to `setX(prev => prev OP (e))`.
|
|
139
|
+
*
|
|
140
|
+
* `set<X>(` and `)` are `gen` pieces anchored at the assignment, while the
|
|
141
|
+
* right-hand expression stays `verbatim` so it keeps a char-accurate origin
|
|
142
|
+
* (the setter is `set<Cap(X)>`, or the internal `__set<Cap(X)>` when a
|
|
143
|
+
* non-trivial `action set<Cap(X)>` owns the public name — `renamedStates`).
|
|
144
|
+
* Untouched spans between assignments are `verbatim` too. Joining the pieces'
|
|
145
|
+
* text reproduces the rewritten body exactly (#10-followup §8, Session 3).
|
|
146
|
+
*
|
|
147
|
+
* F2 (compound/update writes): compound AssignmentExpression (`+=`, `-=`, etc.)
|
|
148
|
+
* and UpdateExpression (`x++`, `++x`, `x--`, `--x`) on state names are lowered
|
|
149
|
+
* to `setX(prev => prev OP (rhs))`. Non-state targets pass through verbatim.
|
|
150
|
+
*/
|
|
151
|
+
const actionBodyPieces = (preprocessed, action, stateNames, renamedStates,
|
|
152
|
+
// A compiler-native behaviour (Pass 9.1) injects this statement right after the
|
|
153
|
+
// body's opening `{` — e.g. undoable's `__undo.push(__snapshot());` or replay's
|
|
154
|
+
// `const __t0 = …; __replay.action(…)`.
|
|
155
|
+
bodyPrologue,
|
|
156
|
+
// …and this one just before the body's closing `}` — e.g. replay's
|
|
157
|
+
// `__replay.snapshot({…}, __t0)` (Replay §4.1). `undoable` has no epilogue.
|
|
158
|
+
bodyEpilogue) => {
|
|
159
|
+
const arrow = action.arrow;
|
|
160
|
+
if (arrow.start == null || arrow.end == null)
|
|
161
|
+
return [];
|
|
162
|
+
const substs = [];
|
|
163
|
+
// Build a path container so we can use traverse on a sub-tree.
|
|
164
|
+
traverse(t.file(t.program([t.expressionStatement(arrow)])), {
|
|
165
|
+
AssignmentExpression(path) {
|
|
166
|
+
const left = path.node.left;
|
|
167
|
+
if (!t.isIdentifier(left))
|
|
168
|
+
return;
|
|
169
|
+
if (!stateNames.has(left.name))
|
|
170
|
+
return;
|
|
171
|
+
if (path.node.start == null ||
|
|
172
|
+
path.node.end == null)
|
|
173
|
+
return;
|
|
174
|
+
const setter = setterNameFor(left.name, renamedStates);
|
|
175
|
+
if (path.node.operator === "=") {
|
|
176
|
+
// Plain assignment — verbatim RHS slice (existing behaviour).
|
|
177
|
+
if (path.node.right.start == null || path.node.right.end == null)
|
|
178
|
+
return;
|
|
179
|
+
substs.push({
|
|
180
|
+
start: path.node.start,
|
|
181
|
+
end: path.node.end,
|
|
182
|
+
setter,
|
|
183
|
+
rhsStart: path.node.right.start,
|
|
184
|
+
rhsEnd: path.node.right.end,
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
else {
|
|
188
|
+
// Compound assignment (+=, -=, etc.) — lower to functional setter.
|
|
189
|
+
const binOp = COMPOUND_OP_MAP[path.node.operator];
|
|
190
|
+
if (!binOp)
|
|
191
|
+
return; // unknown operator — pass through verbatim
|
|
192
|
+
if (path.node.right.start == null || path.node.right.end == null)
|
|
193
|
+
return;
|
|
194
|
+
const rhsText = preprocessed.slice(path.node.right.start, path.node.right.end);
|
|
195
|
+
substs.push({
|
|
196
|
+
start: path.node.start,
|
|
197
|
+
end: path.node.end,
|
|
198
|
+
setter,
|
|
199
|
+
genRhs: `prev => prev ${binOp} (${rhsText})`,
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
},
|
|
203
|
+
UpdateExpression(path) {
|
|
204
|
+
// x++ / ++x / x-- / --x on a state name → setX(prev => prev +/- 1).
|
|
205
|
+
const arg = path.node.argument;
|
|
206
|
+
if (!t.isIdentifier(arg) || !stateNames.has(arg.name))
|
|
207
|
+
return;
|
|
208
|
+
if (path.node.start == null || path.node.end == null)
|
|
209
|
+
return;
|
|
210
|
+
const setter = setterNameFor(arg.name, renamedStates);
|
|
211
|
+
const op = path.node.operator === "++" ? "+" : "-";
|
|
212
|
+
substs.push({
|
|
213
|
+
start: path.node.start,
|
|
214
|
+
end: path.node.end,
|
|
215
|
+
setter,
|
|
216
|
+
genRhs: `prev => prev ${op} 1`,
|
|
217
|
+
});
|
|
218
|
+
},
|
|
219
|
+
});
|
|
220
|
+
// Walk left-to-right, alternating verbatim source spans with the lowered
|
|
221
|
+
// setter pieces. The `s.start < cursor` guard skips a nested state assignment
|
|
222
|
+
// (e.g. `a = (b = 1)`): the outer's RHS already carries it verbatim, so we
|
|
223
|
+
// don't double-emit — robust where the prior reverse-replace would corrupt.
|
|
224
|
+
substs.sort((a, b) => a.start - b.start);
|
|
225
|
+
const pieces = [];
|
|
226
|
+
let cursor = arrow.start;
|
|
227
|
+
// Inject the prologue immediately after the body's opening `{` (the `{` sits
|
|
228
|
+
// before any state-assignment subst, so this stays ahead of the loop below).
|
|
229
|
+
if (bodyPrologue && t.isBlockStatement(arrow.body) && arrow.body.start != null) {
|
|
230
|
+
const afterBrace = arrow.body.start + 1;
|
|
231
|
+
pieces.push({ kind: "verbatim", srcStart: cursor, text: preprocessed.slice(cursor, afterBrace) });
|
|
232
|
+
pieces.push({ kind: "gen", text: ` ${bodyPrologue}`, anchorSrc: arrow.body.start });
|
|
233
|
+
cursor = afterBrace;
|
|
234
|
+
}
|
|
235
|
+
for (const s of substs) {
|
|
236
|
+
if (s.start < cursor)
|
|
237
|
+
continue;
|
|
238
|
+
if (s.start > cursor) {
|
|
239
|
+
pieces.push({ kind: "verbatim", srcStart: cursor, text: preprocessed.slice(cursor, s.start) });
|
|
240
|
+
}
|
|
241
|
+
if (s.genRhs) {
|
|
242
|
+
// Compound/update write — fully generated setter call.
|
|
243
|
+
pieces.push({ kind: "gen", text: `${s.setter}(${s.genRhs})`, anchorSrc: s.start });
|
|
244
|
+
}
|
|
245
|
+
else {
|
|
246
|
+
// Plain write — verbatim RHS.
|
|
247
|
+
pieces.push({ kind: "gen", text: `${s.setter}(`, anchorSrc: s.start });
|
|
248
|
+
pieces.push({
|
|
249
|
+
kind: "verbatim",
|
|
250
|
+
srcStart: s.rhsStart,
|
|
251
|
+
text: preprocessed.slice(s.rhsStart, s.rhsEnd),
|
|
252
|
+
});
|
|
253
|
+
pieces.push({ kind: "gen", text: `)`, anchorSrc: s.rhsEnd });
|
|
254
|
+
}
|
|
255
|
+
cursor = s.end;
|
|
256
|
+
}
|
|
257
|
+
// Final remainder. With an epilogue, split the tail so the injected statement
|
|
258
|
+
// lands just before the body's closing `}` (Replay §4.1); otherwise copy the
|
|
259
|
+
// rest verbatim (the prior behaviour — byte-identical when no epilogue).
|
|
260
|
+
if (bodyEpilogue && t.isBlockStatement(arrow.body) && arrow.body.end != null) {
|
|
261
|
+
const beforeBrace = arrow.body.end - 1; // index of the closing `}`
|
|
262
|
+
if (beforeBrace > cursor) {
|
|
263
|
+
pieces.push({ kind: "verbatim", srcStart: cursor, text: preprocessed.slice(cursor, beforeBrace) });
|
|
264
|
+
}
|
|
265
|
+
// Leading `;` separates the epilogue from the user's last (possibly
|
|
266
|
+
// unterminated, single-line) statement; safe before a `}`/`;`/newline too.
|
|
267
|
+
pieces.push({ kind: "gen", text: `; ${bodyEpilogue} `, anchorSrc: beforeBrace });
|
|
268
|
+
pieces.push({ kind: "verbatim", srcStart: beforeBrace, text: preprocessed.slice(beforeBrace, arrow.end) });
|
|
269
|
+
}
|
|
270
|
+
else if (cursor < arrow.end) {
|
|
271
|
+
pieces.push({ kind: "verbatim", srcStart: cursor, text: preprocessed.slice(cursor, arrow.end) });
|
|
272
|
+
}
|
|
273
|
+
return pieces;
|
|
274
|
+
};
|
|
275
|
+
/**
|
|
276
|
+
* Top-level `state = rhs` writes in an action body → `Map<stateName, rhsText>`
|
|
277
|
+
* (Replay §4.3 rule 3 — the snapshot object source). Straight-line only: writes
|
|
278
|
+
* nested in a branch/loop/callback are not collected (RLIM-08). Last write wins.
|
|
279
|
+
*/
|
|
280
|
+
const straightLineStateWrites = (preprocessed, a, stateNames) => {
|
|
281
|
+
const out = new Map();
|
|
282
|
+
const body = a.arrow.body;
|
|
283
|
+
if (!t.isBlockStatement(body))
|
|
284
|
+
return out;
|
|
285
|
+
for (const stmt of body.body) {
|
|
286
|
+
if (!t.isExpressionStatement(stmt))
|
|
287
|
+
continue;
|
|
288
|
+
const e = stmt.expression;
|
|
289
|
+
if (!t.isAssignmentExpression(e) || e.operator !== "=")
|
|
290
|
+
continue;
|
|
291
|
+
if (!t.isIdentifier(e.left) || !stateNames.has(e.left.name))
|
|
292
|
+
continue;
|
|
293
|
+
if (e.right.start == null || e.right.end == null)
|
|
294
|
+
continue;
|
|
295
|
+
out.set(e.left.name, preprocessed.slice(e.right.start, e.right.end));
|
|
296
|
+
}
|
|
297
|
+
return out;
|
|
298
|
+
};
|
|
299
|
+
/** The optimistic write-set of a command's `optimistic {}` arrow — `Map<state, rhs>` (Slice 3b). */
|
|
300
|
+
const optimisticWritesOf = (preprocessed, cmd, stateNames) => cmd.optimistic
|
|
301
|
+
? straightLineStateWrites(preprocessed, { arrow: cmd.optimistic }, stateNames)
|
|
302
|
+
: new Map();
|
|
303
|
+
/** Union of every command's optimistic-written state names — these states emit a useOptimistic shadow. */
|
|
304
|
+
const optimisticStatesOf = (preprocessed, c, stateNames) => {
|
|
305
|
+
const out = new Set();
|
|
306
|
+
for (const cmd of c.commands)
|
|
307
|
+
for (const k of optimisticWritesOf(preprocessed, cmd, stateNames).keys())
|
|
308
|
+
out.add(k);
|
|
309
|
+
return out;
|
|
310
|
+
};
|
|
311
|
+
/** Copy `from..to` of a piece, keeping a verbatim origin char-accurate. */
|
|
312
|
+
const slicePiece = (p, from, to) => p.kind === "verbatim"
|
|
313
|
+
? { kind: "verbatim", srcStart: p.srcStart + from, text: p.text.slice(from, to) }
|
|
314
|
+
: { kind: "gen", anchorSrc: p.anchorSrc, text: p.text.slice(from, to) };
|
|
315
|
+
/** Drop the first `n` chars across a piece list (advances verbatim origins). */
|
|
316
|
+
const dropFront = (pieces, n) => {
|
|
317
|
+
const out = [];
|
|
318
|
+
let rem = n;
|
|
319
|
+
for (const p of pieces) {
|
|
320
|
+
if (rem <= 0) {
|
|
321
|
+
out.push(p);
|
|
322
|
+
continue;
|
|
323
|
+
}
|
|
324
|
+
if (p.text.length <= rem) {
|
|
325
|
+
rem -= p.text.length;
|
|
326
|
+
continue;
|
|
327
|
+
}
|
|
328
|
+
out.push(slicePiece(p, rem, p.text.length));
|
|
329
|
+
rem = 0;
|
|
330
|
+
}
|
|
331
|
+
return out;
|
|
332
|
+
};
|
|
333
|
+
/** Keep only the first `n` chars across a piece list (the mirror of `dropFront`). */
|
|
334
|
+
const takeFront = (pieces, n) => {
|
|
335
|
+
const out = [];
|
|
336
|
+
let rem = n;
|
|
337
|
+
for (const p of pieces) {
|
|
338
|
+
if (rem <= 0)
|
|
339
|
+
break;
|
|
340
|
+
if (p.text.length <= rem) {
|
|
341
|
+
out.push(p);
|
|
342
|
+
rem -= p.text.length;
|
|
343
|
+
continue;
|
|
344
|
+
}
|
|
345
|
+
out.push(slicePiece(p, 0, rem));
|
|
346
|
+
rem = 0;
|
|
347
|
+
}
|
|
348
|
+
return out;
|
|
349
|
+
};
|
|
350
|
+
/** Drop the last `n` chars across a piece list (truncates from the tail). */
|
|
351
|
+
const dropBack = (pieces, n) => {
|
|
352
|
+
const out = pieces.slice();
|
|
353
|
+
let rem = n;
|
|
354
|
+
while (rem > 0 && out.length > 0) {
|
|
355
|
+
const p = out[out.length - 1]; // out.length > 0 guarded by the loop condition
|
|
356
|
+
if (p.text.length <= rem) {
|
|
357
|
+
rem -= p.text.length;
|
|
358
|
+
out.pop();
|
|
359
|
+
continue;
|
|
360
|
+
}
|
|
361
|
+
out[out.length - 1] = slicePiece(p, 0, p.text.length - rem);
|
|
362
|
+
rem = 0;
|
|
363
|
+
}
|
|
364
|
+
return out;
|
|
365
|
+
};
|
|
366
|
+
/**
|
|
367
|
+
* Decompose the view body into source-mapped `Piece`s: verbatim JSX spans
|
|
368
|
+
* (char-accurate origin, so each line maps back to its own DSL line) interleaved
|
|
369
|
+
* with each `{__reactra_await__(...)}` container rewritten to the Resource v0 §4
|
|
370
|
+
* Suspense + ErrorBoundary + `use(r.promise)` pattern. The await wrapper's
|
|
371
|
+
* boilerplate is `gen` (anchored at the await block), while the user's resource /
|
|
372
|
+
* success / pending / error bodies stay `verbatim` so they keep their own per-line
|
|
373
|
+
* origins. Joining the pieces' text reproduces the prior whole-body string rewrite
|
|
374
|
+
* exactly (#10-followup §8, Session 4 — replaces the single whole-view segment with
|
|
375
|
+
* per-line marks; the view body is passthrough in the preprocess hop, so per-line
|
|
376
|
+
* here composes into accurate source→compiled view mapping).
|
|
377
|
+
*/
|
|
378
|
+
const viewBodyPieces = (preprocessed, viewBody,
|
|
379
|
+
/** Additional substitutions to apply (e.g. inline-handler auto-wrap G6). */
|
|
380
|
+
extraSubsts) => {
|
|
381
|
+
if (viewBody.start == null || viewBody.end == null)
|
|
382
|
+
return [];
|
|
383
|
+
const substs = [];
|
|
384
|
+
// Seed with extra substitutions (converted to the internal Subst format).
|
|
385
|
+
if (extraSubsts) {
|
|
386
|
+
for (const s of extraSubsts) {
|
|
387
|
+
substs.push({ start: s.start, end: s.end, pieces: [{ kind: "gen", text: s.replacement, anchorSrc: s.start }] });
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
// The synthetic wrap keeps node references stable, so `path.parent` still
|
|
391
|
+
// points at the original JSXExpressionContainer surrounding the call.
|
|
392
|
+
traverse(t.file(t.program([t.expressionStatement(viewBody)])), {
|
|
393
|
+
CallExpression(path) {
|
|
394
|
+
const callee = path.node.callee;
|
|
395
|
+
if (!t.isIdentifier(callee) || callee.name !== "__reactra_await__")
|
|
396
|
+
return;
|
|
397
|
+
if (path.node.start == null || path.node.end == null)
|
|
398
|
+
return;
|
|
399
|
+
// §B1: index via the shared AWAIT_ARG_ORDER so the resource/success/
|
|
400
|
+
// pending/error wiring can't drift between Pass 9 and the shadow.
|
|
401
|
+
const args = path.node.arguments;
|
|
402
|
+
const resourceArrow = args[AWAIT_ARG_ORDER.resource];
|
|
403
|
+
const successArrow = args[AWAIT_ARG_ORDER.success];
|
|
404
|
+
const pendingArrow = args[AWAIT_ARG_ORDER.pending];
|
|
405
|
+
const errorArrow = args[AWAIT_ARG_ORDER.error];
|
|
406
|
+
if (!t.isArrowFunctionExpression(resourceArrow) ||
|
|
407
|
+
!t.isArrowFunctionExpression(successArrow)) {
|
|
408
|
+
return;
|
|
409
|
+
}
|
|
410
|
+
const resourceText = sliceNode(preprocessed, resourceArrow.body);
|
|
411
|
+
const hasPending = t.isArrowFunctionExpression(pendingArrow);
|
|
412
|
+
const hasError = t.isArrowFunctionExpression(errorArrow);
|
|
413
|
+
let errorParam = "e";
|
|
414
|
+
if (hasError) {
|
|
415
|
+
const p = errorArrow.params[0];
|
|
416
|
+
if (p && t.isIdentifier(p))
|
|
417
|
+
errorParam = p.name;
|
|
418
|
+
}
|
|
419
|
+
// Replace the whole `{__reactra_await__(...)}` container so the
|
|
420
|
+
// surrounding JSX text remains well-formed.
|
|
421
|
+
const parent = path.parent;
|
|
422
|
+
const start = t.isJSXExpressionContainer(parent) && parent.start != null
|
|
423
|
+
? parent.start
|
|
424
|
+
: path.node.start;
|
|
425
|
+
const end = t.isJSXExpressionContainer(parent) && parent.end != null ? parent.end : path.node.end;
|
|
426
|
+
// Resource v0 §4 emission contract. The success body must run
|
|
427
|
+
// INSIDE a child component of <Suspense>, not as an IIFE — when
|
|
428
|
+
// `use(r.promise)` throws (the pending sentinel), React only
|
|
429
|
+
// catches it if the throw happens inside a render call that React
|
|
430
|
+
// is making. An IIFE evaluated during JSX construction throws
|
|
431
|
+
// BEFORE React sees the Suspense element, so the throw escapes
|
|
432
|
+
// the boundary and the parent component fails to mount.
|
|
433
|
+
// Wrapping in `<__AwaitInner />` defers `use()` to React's
|
|
434
|
+
// render-of-Inner, where the throw is correctly caught at the
|
|
435
|
+
// surrounding Suspense.
|
|
436
|
+
//
|
|
437
|
+
// displayName (devtools-debugging backlog issue C): the inner wrapper
|
|
438
|
+
// would otherwise show as "__AwaitInner" in React DevTools (repeated
|
|
439
|
+
// per await block). Label it after the awaited resource when that's a
|
|
440
|
+
// simple identifier (`Await(customer)`), else a plain `Await`. The
|
|
441
|
+
// label is a build-time string literal — zero runtime cost. The const
|
|
442
|
+
// keeps the `__` prefix: it's compiler-emitted + IIFE-local (R017).
|
|
443
|
+
//
|
|
444
|
+
// The wrapper text is `gen` (anchored at the await block `start`), so a
|
|
445
|
+
// breakpoint in it binds to the `await(...)` line; the user's resource /
|
|
446
|
+
// success / pending / error bodies are `verbatim`, so a breakpoint in
|
|
447
|
+
// any of them binds to its own DSL line (#10-followup §8, Session 4).
|
|
448
|
+
const awaitLabel = /^[A-Za-z_$][\w$]*$/.test(resourceText.trim())
|
|
449
|
+
? `Await(${resourceText.trim()})`
|
|
450
|
+
: "Await";
|
|
451
|
+
const gen = (text) => ({ kind: "gen", text, anchorSrc: start });
|
|
452
|
+
const verbatim = (node) => ({
|
|
453
|
+
kind: "verbatim",
|
|
454
|
+
srcStart: node.start,
|
|
455
|
+
text: sliceNode(preprocessed, node),
|
|
456
|
+
});
|
|
457
|
+
const pieces = [];
|
|
458
|
+
if (hasError) {
|
|
459
|
+
pieces.push(gen(`<ErrorBoundary fallback={(${errorParam}) => `));
|
|
460
|
+
pieces.push(verbatim(errorArrow.body));
|
|
461
|
+
pieces.push(gen(`}>`));
|
|
462
|
+
}
|
|
463
|
+
pieces.push(gen(`<Suspense fallback={`));
|
|
464
|
+
if (hasPending)
|
|
465
|
+
pieces.push(verbatim(pendingArrow.body));
|
|
466
|
+
else
|
|
467
|
+
pieces.push(gen(`null`));
|
|
468
|
+
pieces.push(gen(`}>{(() => { const __AwaitInner = () => { use(`));
|
|
469
|
+
pieces.push(verbatim(resourceArrow.body));
|
|
470
|
+
pieces.push(gen(`.promise); return `));
|
|
471
|
+
pieces.push(verbatim(successArrow.body));
|
|
472
|
+
pieces.push(gen(` }; __AwaitInner.displayName = ${JSON.stringify(awaitLabel)}; return <__AwaitInner /> })()}</Suspense>`));
|
|
473
|
+
if (hasError)
|
|
474
|
+
pieces.push(gen(`</ErrorBoundary>`));
|
|
475
|
+
substs.push({ start, end, pieces });
|
|
476
|
+
},
|
|
477
|
+
});
|
|
478
|
+
// Walk left-to-right: verbatim gaps between await containers, the await pieces
|
|
479
|
+
// inline. The `s.start < cursor` guard drops an await nested in a prior await's
|
|
480
|
+
// body (already carried verbatim in that body's success slice) — robust where
|
|
481
|
+
// the prior reverse-replace would corrupt offsets.
|
|
482
|
+
substs.sort((a, b) => a.start - b.start);
|
|
483
|
+
const out = [];
|
|
484
|
+
let cursor = viewBody.start;
|
|
485
|
+
for (const s of substs) {
|
|
486
|
+
if (s.start < cursor)
|
|
487
|
+
continue;
|
|
488
|
+
if (s.start > cursor) {
|
|
489
|
+
out.push({ kind: "verbatim", srcStart: cursor, text: preprocessed.slice(cursor, s.start) });
|
|
490
|
+
}
|
|
491
|
+
out.push(...s.pieces);
|
|
492
|
+
cursor = s.end;
|
|
493
|
+
}
|
|
494
|
+
if (cursor < viewBody.end) {
|
|
495
|
+
out.push({ kind: "verbatim", srcStart: cursor, text: preprocessed.slice(cursor, viewBody.end) });
|
|
496
|
+
}
|
|
497
|
+
return out;
|
|
498
|
+
};
|
|
499
|
+
/**
|
|
500
|
+
* Replicate the legacy `view.replace(/^\(|\)$/g, "").trim()` cleanup at the piece
|
|
501
|
+
* level: strip a single leading `(` and trailing `)`, then surrounding whitespace,
|
|
502
|
+
* adjusting verbatim origins so they stay char-accurate. The joined text of the
|
|
503
|
+
* returned pieces equals the old trimmed string exactly (byte-identical output).
|
|
504
|
+
*/
|
|
505
|
+
const trimViewPieces = (pieces) => {
|
|
506
|
+
let s = pieces.map((p) => p.text).join("");
|
|
507
|
+
let front = 0;
|
|
508
|
+
let back = 0;
|
|
509
|
+
if (s.startsWith("(")) {
|
|
510
|
+
front += 1;
|
|
511
|
+
s = s.slice(1);
|
|
512
|
+
}
|
|
513
|
+
if (s.endsWith(")")) {
|
|
514
|
+
back += 1;
|
|
515
|
+
s = s.slice(0, -1);
|
|
516
|
+
}
|
|
517
|
+
const afterLead = s.trimStart();
|
|
518
|
+
front += s.length - afterLead.length;
|
|
519
|
+
back += afterLead.length - afterLead.trimEnd().length;
|
|
520
|
+
return dropBack(dropFront(pieces, front), back);
|
|
521
|
+
};
|
|
522
|
+
const extractInlineHandlers = (preprocessed, viewBody, stateNames, renamedStates) => {
|
|
523
|
+
const handlers = [];
|
|
524
|
+
const substs = [];
|
|
525
|
+
if (viewBody.start == null || viewBody.end == null)
|
|
526
|
+
return { handlers, substs };
|
|
527
|
+
// Count per event-prop name to suffix duplicates.
|
|
528
|
+
const propCount = new Map();
|
|
529
|
+
traverse(t.file(t.program([t.expressionStatement(viewBody)])), {
|
|
530
|
+
JSXAttribute(path) {
|
|
531
|
+
const attrName = path.node.name;
|
|
532
|
+
// Only `on*` event props.
|
|
533
|
+
if (!t.isJSXIdentifier(attrName) || !attrName.name.startsWith("on"))
|
|
534
|
+
return;
|
|
535
|
+
const container = path.node.value;
|
|
536
|
+
if (!t.isJSXExpressionContainer(container))
|
|
537
|
+
return;
|
|
538
|
+
const arrow = container.expression;
|
|
539
|
+
if (!t.isArrowFunctionExpression(arrow))
|
|
540
|
+
return;
|
|
541
|
+
if (arrow.start == null || arrow.end == null)
|
|
542
|
+
return;
|
|
543
|
+
// Does the body contain at least one state write (plain `=`, compound `+=`, update `++`)?
|
|
544
|
+
let hasStateWrite = false;
|
|
545
|
+
traverse(t.file(t.program([t.expressionStatement(arrow)])), {
|
|
546
|
+
AssignmentExpression(inner) {
|
|
547
|
+
const left = inner.node.left;
|
|
548
|
+
if (t.isIdentifier(left) && stateNames.has(left.name))
|
|
549
|
+
hasStateWrite = true;
|
|
550
|
+
},
|
|
551
|
+
UpdateExpression(inner) {
|
|
552
|
+
if (t.isIdentifier(inner.node.argument) && stateNames.has(inner.node.argument.name))
|
|
553
|
+
hasStateWrite = true;
|
|
554
|
+
},
|
|
555
|
+
});
|
|
556
|
+
if (!hasStateWrite)
|
|
557
|
+
return;
|
|
558
|
+
// Synthesize a unique hoisted name.
|
|
559
|
+
const baseName = attrName.name; // e.g. "onChange"
|
|
560
|
+
const count = (propCount.get(baseName) ?? 0) + 1;
|
|
561
|
+
propCount.set(baseName, count);
|
|
562
|
+
const synName = count === 1 ? `__inline_${baseName}` : `__inline_${baseName}_${count}`;
|
|
563
|
+
const writeSubsts = [];
|
|
564
|
+
traverse(t.file(t.program([t.expressionStatement(arrow)])), {
|
|
565
|
+
AssignmentExpression(inner) {
|
|
566
|
+
const left = inner.node.left;
|
|
567
|
+
if (!t.isIdentifier(left) || !stateNames.has(left.name))
|
|
568
|
+
return;
|
|
569
|
+
if (inner.node.start == null || inner.node.end == null)
|
|
570
|
+
return;
|
|
571
|
+
const setter = setterNameFor(left.name, renamedStates);
|
|
572
|
+
if (inner.node.operator === "=") {
|
|
573
|
+
if (inner.node.right.start == null || inner.node.right.end == null)
|
|
574
|
+
return;
|
|
575
|
+
const rhsText = preprocessed.slice(inner.node.right.start, inner.node.right.end);
|
|
576
|
+
writeSubsts.push({ start: inner.node.start, end: inner.node.end, replacement: `${setter}(${rhsText})` });
|
|
577
|
+
}
|
|
578
|
+
else {
|
|
579
|
+
// Compound assignment — F2
|
|
580
|
+
const binOp = COMPOUND_OP_MAP[inner.node.operator];
|
|
581
|
+
if (!binOp)
|
|
582
|
+
return;
|
|
583
|
+
if (inner.node.right.start == null || inner.node.right.end == null)
|
|
584
|
+
return;
|
|
585
|
+
const rhsText = preprocessed.slice(inner.node.right.start, inner.node.right.end);
|
|
586
|
+
writeSubsts.push({ start: inner.node.start, end: inner.node.end, replacement: `${setter}(prev => prev ${binOp} (${rhsText}))` });
|
|
587
|
+
}
|
|
588
|
+
},
|
|
589
|
+
UpdateExpression(inner) {
|
|
590
|
+
// x++ / ++x / x-- / --x — F2
|
|
591
|
+
const arg = inner.node.argument;
|
|
592
|
+
if (!t.isIdentifier(arg) || !stateNames.has(arg.name))
|
|
593
|
+
return;
|
|
594
|
+
if (inner.node.start == null || inner.node.end == null)
|
|
595
|
+
return;
|
|
596
|
+
const setter = setterNameFor(arg.name, renamedStates);
|
|
597
|
+
const op = inner.node.operator === "++" ? "+" : "-";
|
|
598
|
+
writeSubsts.push({ start: inner.node.start, end: inner.node.end, replacement: `${setter}(prev => prev ${op} 1)` });
|
|
599
|
+
},
|
|
600
|
+
});
|
|
601
|
+
writeSubsts.sort((a, b) => a.start - b.start);
|
|
602
|
+
// Rewrite the arrow source text: replace writes with setter calls.
|
|
603
|
+
let body = preprocessed.slice(arrow.start, arrow.end);
|
|
604
|
+
const base = arrow.start;
|
|
605
|
+
// Walk right-to-left so offset replacements don't shift pending positions.
|
|
606
|
+
for (let i = writeSubsts.length - 1; i >= 0; i--) {
|
|
607
|
+
const s = writeSubsts[i];
|
|
608
|
+
body = body.slice(0, s.start - base) + s.replacement + body.slice(s.end - base);
|
|
609
|
+
}
|
|
610
|
+
// Collect read-deps: align with Pass-3 collectDeps exclusions (F10):
|
|
611
|
+
// - skip plain `=` LHS (compound LHS is a read)
|
|
612
|
+
// - skip member property `obj.PROP` (not computed)
|
|
613
|
+
// - skip object key `{ KEY: val }` (not shorthand, not computed)
|
|
614
|
+
const deps = [];
|
|
615
|
+
const seenDeps = new Set();
|
|
616
|
+
traverse(t.file(t.program([t.expressionStatement(arrow)])), {
|
|
617
|
+
Identifier(inner) {
|
|
618
|
+
const name = inner.node.name;
|
|
619
|
+
if (!stateNames.has(name) || seenDeps.has(name))
|
|
620
|
+
return;
|
|
621
|
+
const parent = inner.parent;
|
|
622
|
+
// Skip plain assignment LHS only (compound reads the value).
|
|
623
|
+
if (t.isAssignmentExpression(parent) && parent.left === inner.node && parent.operator === "=")
|
|
624
|
+
return;
|
|
625
|
+
// Skip member property `obj.NAME` (not computed).
|
|
626
|
+
if (t.isMemberExpression(parent) && parent.property === inner.node && !parent.computed)
|
|
627
|
+
return;
|
|
628
|
+
// Skip object key `{ NAME: val }` (not shorthand, not computed).
|
|
629
|
+
if (t.isObjectProperty(parent) && parent.key === inner.node && !parent.computed && !parent.shorthand)
|
|
630
|
+
return;
|
|
631
|
+
deps.push(name);
|
|
632
|
+
seenDeps.add(name);
|
|
633
|
+
},
|
|
634
|
+
});
|
|
635
|
+
const decl = ` const ${synName} = useCallback(${body}, [${deps.join(", ")}])`;
|
|
636
|
+
handlers.push({ name: synName, decl });
|
|
637
|
+
// Substitution: replace the whole JSXExpressionContainer span.
|
|
638
|
+
const containerStart = container.start;
|
|
639
|
+
const containerEnd = container.end;
|
|
640
|
+
if (containerStart != null && containerEnd != null) {
|
|
641
|
+
substs.push({ start: containerStart, end: containerEnd, replacement: `{${synName}}` });
|
|
642
|
+
}
|
|
643
|
+
// Stop traversing into this attribute's children.
|
|
644
|
+
path.skip();
|
|
645
|
+
},
|
|
646
|
+
});
|
|
647
|
+
return { handlers, substs };
|
|
648
|
+
};
|
|
649
|
+
const scanAwaits = (c) => {
|
|
650
|
+
const usage = { hasAwait: false, hasErrorBranch: false };
|
|
651
|
+
if (!c.view)
|
|
652
|
+
return usage;
|
|
653
|
+
const viewBody = c.view.body.body;
|
|
654
|
+
traverse(t.file(t.program([t.expressionStatement(viewBody)])), {
|
|
655
|
+
CallExpression(path) {
|
|
656
|
+
const callee = path.node.callee;
|
|
657
|
+
if (!t.isIdentifier(callee) || callee.name !== "__reactra_await__")
|
|
658
|
+
return;
|
|
659
|
+
usage.hasAwait = true;
|
|
660
|
+
const errorArrow = path.node.arguments[AWAIT_ARG_ORDER.error];
|
|
661
|
+
if (t.isArrowFunctionExpression(errorArrow))
|
|
662
|
+
usage.hasErrorBranch = true;
|
|
663
|
+
},
|
|
664
|
+
});
|
|
665
|
+
return usage;
|
|
666
|
+
};
|
|
667
|
+
/** Determine which React hooks need to be imported based on what the file uses. */
|
|
668
|
+
const collectReactImports = (graph) => {
|
|
669
|
+
const needed = new Set();
|
|
670
|
+
for (const c of graph.containers) {
|
|
671
|
+
if (c.kind !== "component")
|
|
672
|
+
continue;
|
|
673
|
+
// The universal test-hook line (Testing §2 / Pass 9 v2.18) is a useEffect —
|
|
674
|
+
// every component needs the import, through this same collector path.
|
|
675
|
+
needed.add("useEffect");
|
|
676
|
+
if (c.states.length > 0)
|
|
677
|
+
needed.add("useState");
|
|
678
|
+
if (c.deriveds.length > 0)
|
|
679
|
+
needed.add("useMemo");
|
|
680
|
+
// Suppressed identity-setter actions (R016) aren't emitted, so they don't
|
|
681
|
+
// need useCallback. Async actions are never suppressed (always non-trivial).
|
|
682
|
+
const { suppressedActions } = classifyStateSetters(c);
|
|
683
|
+
if (c.actions.some((a) => !suppressedActions.has(a.name))) {
|
|
684
|
+
needed.add("useCallback");
|
|
685
|
+
if (c.actions.some((a) => a.isAsync))
|
|
686
|
+
needed.add("useTransition");
|
|
687
|
+
}
|
|
688
|
+
if (c.commands.some((cmd) => cmd.form === "block"))
|
|
689
|
+
needed.add("useActionState");
|
|
690
|
+
if (c.commands.some((cmd) => cmd.form === "arrow"))
|
|
691
|
+
needed.add("useTransition");
|
|
692
|
+
if (c.commands.some((cmd) => cmd.optimistic != null))
|
|
693
|
+
needed.add("useOptimistic");
|
|
694
|
+
// G6 — inline-handler auto-wrap emits `useCallback` declarations; check if
|
|
695
|
+
// any exist in the view body (quick scan — same state set, no full rewrite).
|
|
696
|
+
if (c.view != null && c.states.length > 0) {
|
|
697
|
+
const pp = graph.preprocessed;
|
|
698
|
+
const stateSet = new Set(c.states.map((s) => s.name));
|
|
699
|
+
const inlineResult = extractInlineHandlers(pp, c.view.body.body, stateSet, new Set());
|
|
700
|
+
if (inlineResult.handlers.length > 0)
|
|
701
|
+
needed.add("useCallback");
|
|
702
|
+
}
|
|
703
|
+
if (c.refs.length > 0)
|
|
704
|
+
needed.add("useRef");
|
|
705
|
+
if (c.effects.length > 0 ||
|
|
706
|
+
c.mounts.length > 0 ||
|
|
707
|
+
c.cleanups.length > 0 ||
|
|
708
|
+
// `meta { title }` applies the title in a useEffect (document.title).
|
|
709
|
+
c.meta !== undefined) {
|
|
710
|
+
needed.add("useEffect");
|
|
711
|
+
}
|
|
712
|
+
const usage = scanAwaits(c);
|
|
713
|
+
if (usage.hasAwait) {
|
|
714
|
+
needed.add("Suspense");
|
|
715
|
+
needed.add("use");
|
|
716
|
+
}
|
|
717
|
+
// Component-level `suspense { … }` wraps the view in <Suspense> (§7/§11).
|
|
718
|
+
if (c.suspense !== undefined)
|
|
719
|
+
needed.add("Suspense");
|
|
720
|
+
// Compiler-native behaviours (`uses undoable`/`replayable`) pull their own
|
|
721
|
+
// React hooks (e.g. undoable's preamble needs `useCallback` for undo/redo even
|
|
722
|
+
// with no actions — Undo §4.1; replayable needs `useEffect`). Pass 9.1 registry.
|
|
723
|
+
for (const b of matchedNativeBehaviours(c))
|
|
724
|
+
for (const hook of b.reactImports)
|
|
725
|
+
needed.add(hook);
|
|
726
|
+
// Wave 3 §2b — `provide X with Y` codegen needs `use(ServiceContext)`
|
|
727
|
+
// to read the parent overrides, then `useMemo` to keep the composed
|
|
728
|
+
// value stable across re-renders.
|
|
729
|
+
if (c.provides.length > 0) {
|
|
730
|
+
needed.add("use");
|
|
731
|
+
needed.add("useMemo");
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
return needed;
|
|
735
|
+
};
|
|
736
|
+
/** Which symbols, if any, must be imported from @reactra/resource. */
|
|
737
|
+
const collectResourceImports = (graph) => {
|
|
738
|
+
const needed = new Set();
|
|
739
|
+
for (const c of graph.containers) {
|
|
740
|
+
if (c.kind === "component") {
|
|
741
|
+
if (c.resources.length > 0)
|
|
742
|
+
needed.add("useResource");
|
|
743
|
+
// A command's `invalidate [a, b]` clause refetches resources via the cache.
|
|
744
|
+
if (c.commands.some((cmd) => cmd.invalidate && cmd.invalidate.length > 0))
|
|
745
|
+
needed.add("__getResourceCache");
|
|
746
|
+
// <ErrorBoundary> is needed by an `await … error(e)` branch OR a
|
|
747
|
+
// component-level `errorBoundary(e) { … }` wrap (§7/§11).
|
|
748
|
+
if (scanAwaits(c).hasErrorBranch || c.errorBoundary !== undefined)
|
|
749
|
+
needed.add("ErrorBoundary");
|
|
750
|
+
}
|
|
751
|
+
else if (c.kind === "route-store" && c.resources.length > 0) {
|
|
752
|
+
// Stage 2 compiler — `warm(inputs, signal)` companion fans out to
|
|
753
|
+
// warmResource per declared resource (Resource v1 §8.1).
|
|
754
|
+
needed.add("warmResource");
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
return needed;
|
|
758
|
+
};
|
|
759
|
+
/** Which symbols, if any, must be imported from @reactra/store. */
|
|
760
|
+
const collectStoreImports = (graph) => {
|
|
761
|
+
const needed = new Set();
|
|
762
|
+
for (const c of graph.containers) {
|
|
763
|
+
if (isStoreContainer(c)) {
|
|
764
|
+
needed.add("createStoreInstance");
|
|
765
|
+
// The HMR replace block at file tail calls `StoreRegistry.replace`
|
|
766
|
+
// for every store the file declares — pull the registry in.
|
|
767
|
+
needed.add("StoreRegistry");
|
|
768
|
+
// A store with `state` fields emits a `__registerStoreRestore(...)` call
|
|
769
|
+
// so devtools time-travel can re-drive it (plan store-time-travel §3.1).
|
|
770
|
+
// Only pulled in when some store in the file actually has state — a
|
|
771
|
+
// stateless store emits no call, so an unconditional import would be an
|
|
772
|
+
// unused-import diagnostic under noUnusedLocals.
|
|
773
|
+
if (c.states.length > 0)
|
|
774
|
+
needed.add("__registerStoreRestore");
|
|
775
|
+
// Each store emits `export type <name> = StoreSurface<typeof <name>>`
|
|
776
|
+
// (Store §2.5) so cross-file consumers bind via `import type`. The
|
|
777
|
+
// `type` modifier keeps it erasable under Node type-stripping + Vite.
|
|
778
|
+
needed.add("type StoreSurface");
|
|
779
|
+
}
|
|
780
|
+
if (c.kind === "component") {
|
|
781
|
+
// Both bare and argumented `use` subscribe to a live instance — they
|
|
782
|
+
// need the hook. Only argumented `use` ALSO emits routeBindings that
|
|
783
|
+
// call into StoreRegistry directly, which adds the registry import.
|
|
784
|
+
// A2: field-selected uses (`inject store X { a, b }`) emit
|
|
785
|
+
// `useReactraStoreFields` instead of the whole-store `useReactraStore`;
|
|
786
|
+
// import each only when that form is actually present (a file can have
|
|
787
|
+
// both forms in different components).
|
|
788
|
+
const isFieldSelected = (u) => !!u.fields && u.fields.length > 0;
|
|
789
|
+
if (c.storeUses.some((u) => !isFieldSelected(u)))
|
|
790
|
+
needed.add("useReactraStore");
|
|
791
|
+
if (c.storeUses.some(isFieldSelected))
|
|
792
|
+
needed.add("useReactraStoreFields");
|
|
793
|
+
if (c.storeUses.some((u) => u.classification === "argumented")) {
|
|
794
|
+
needed.add("StoreRegistry");
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
return needed;
|
|
799
|
+
};
|
|
800
|
+
/**
|
|
801
|
+
* Which symbols, if any, must be imported from @reactra/router. Day 10a:
|
|
802
|
+
* `useRoute` whenever a component declares any `param X` or `query X`.
|
|
803
|
+
* Path/query coercion, RouteLink, useQueryUpdater are Day 10b.
|
|
804
|
+
*/
|
|
805
|
+
const collectRouterImports = (graph) => {
|
|
806
|
+
const needed = new Set();
|
|
807
|
+
for (const c of graph.containers) {
|
|
808
|
+
if (c.kind !== "component")
|
|
809
|
+
continue;
|
|
810
|
+
if (c.params.length > 0 || c.queries.length > 0)
|
|
811
|
+
needed.add("useRoute");
|
|
812
|
+
// #5c-typed runtime query coercion: any query that isn't a bare
|
|
813
|
+
// string (or that carries a default) is read through `coerceQuery`.
|
|
814
|
+
if (c.queries.some(queryNeedsCoercion))
|
|
815
|
+
needed.add("coerceQuery");
|
|
816
|
+
// Day 16 / `#18b`: page components with argumented `use` emit a
|
|
817
|
+
// module-level `RouterRegistry.registerRouteBindings(…)` side effect.
|
|
818
|
+
if (c.storeUses.some((u) => u.classification === "argumented")) {
|
|
819
|
+
needed.add("RouterRegistry");
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
return needed;
|
|
823
|
+
};
|
|
824
|
+
/** A string-literal-union type, e.g. `"a" | "b"` or `'a' | 'b'`. */
|
|
825
|
+
const ENUM_TYPE_RE = /^(?:"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*')(?:\s*\|\s*(?:"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'))*$/;
|
|
826
|
+
/** Strip a single pair of matching surrounding quotes from a literal member. */
|
|
827
|
+
const stripQuotes = (s) => {
|
|
828
|
+
const t = s.trim();
|
|
829
|
+
if (t.length >= 2 && (t[0] === '"' || t[0] === "'") && t[t.length - 1] === t[0]) {
|
|
830
|
+
return t.slice(1, -1);
|
|
831
|
+
}
|
|
832
|
+
return t;
|
|
833
|
+
};
|
|
834
|
+
/**
|
|
835
|
+
* The `kind` argument to pass to the runtime `coerceQuery` for a declared
|
|
836
|
+
* query type: `"number"` / `"boolean"` / a JS array literal of allowed raw
|
|
837
|
+
* values for an enum / `"string"` for anything else (the passthrough case).
|
|
838
|
+
*/
|
|
839
|
+
const classifyQueryCoercionArg = (tsType) => {
|
|
840
|
+
const t = tsType.trim();
|
|
841
|
+
if (t === "number")
|
|
842
|
+
return `"number"`;
|
|
843
|
+
if (t === "boolean")
|
|
844
|
+
return `"boolean"`;
|
|
845
|
+
// `string[]` (repeated-key array) — distinct from an enum (which is a union of
|
|
846
|
+
// string literals); coerceQuery normalizes single→one-element-array on this kind.
|
|
847
|
+
if (t === "string[]")
|
|
848
|
+
return `"string[]"`;
|
|
849
|
+
if (ENUM_TYPE_RE.test(t)) {
|
|
850
|
+
const members = t.split("|").map((m) => JSON.stringify(stripQuotes(m)));
|
|
851
|
+
return `[${members.join(", ")}]`;
|
|
852
|
+
}
|
|
853
|
+
return `"string"`;
|
|
854
|
+
};
|
|
855
|
+
/**
|
|
856
|
+
* A query read needs `coerceQuery` iff its declared type is not a bare `string`,
|
|
857
|
+
* or it carries a `= default` (the default must be applied even for a string
|
|
858
|
+
* field). A plain `string` with no default is read directly. A custom parser
|
|
859
|
+
* (`from parseFoo`) replaces `coerceQuery` entirely with a `parseFoo(raw)` call.
|
|
860
|
+
*/
|
|
861
|
+
const queryNeedsCoercion = (q) => q.fromParser === undefined && (q.tsType.trim() !== "string" || q.defaultText !== undefined);
|
|
862
|
+
/** Emit the per-query read line: a custom parser call, a `coerceQuery(...)` call, or a plain read. */
|
|
863
|
+
const emitQueryRead = (q) => {
|
|
864
|
+
// Custom `from parseFoo`: the user's parser owns coercion. If a `= default`
|
|
865
|
+
// was also declared, `?? default` covers a null/undefined return.
|
|
866
|
+
if (q.fromParser !== undefined) {
|
|
867
|
+
const call = `${q.fromParser}(__route.query.${q.name})`;
|
|
868
|
+
const expr = q.defaultText !== undefined ? `(${call}) ?? (${q.defaultText})` : call;
|
|
869
|
+
return ` const ${q.name} = ${expr}`;
|
|
870
|
+
}
|
|
871
|
+
if (!queryNeedsCoercion(q))
|
|
872
|
+
return ` const ${q.name} = __route.query.${q.name}`;
|
|
873
|
+
const kindArg = classifyQueryCoercionArg(q.tsType);
|
|
874
|
+
const args = q.defaultText !== undefined
|
|
875
|
+
? `__route.query.${q.name}, ${kindArg}, ${q.defaultText}`
|
|
876
|
+
: `__route.query.${q.name}, ${kindArg}`;
|
|
877
|
+
return ` const ${q.name} = coerceQuery(${args})`;
|
|
878
|
+
};
|
|
879
|
+
/**
|
|
880
|
+
* Which symbols, if any, must be imported from @reactra/service.
|
|
881
|
+
*
|
|
882
|
+
* `createServiceInstance` is needed whenever this file declares a service.
|
|
883
|
+
* `ServiceRegistry` is needed whenever any container — service, store, or
|
|
884
|
+
* component — has at least one `inject` declaration that needs runtime lookup
|
|
885
|
+
* (Strategy A: every inject compiles to `ServiceRegistry.get("X")`).
|
|
886
|
+
*/
|
|
887
|
+
const collectServiceImports = (graph) => {
|
|
888
|
+
const needed = new Set();
|
|
889
|
+
for (const c of graph.containers) {
|
|
890
|
+
if (c.kind === "service")
|
|
891
|
+
needed.add("createServiceInstance");
|
|
892
|
+
if (c.injects.length > 0)
|
|
893
|
+
needed.add("ServiceRegistry");
|
|
894
|
+
// The HMR replace block at file tail calls `ServiceRegistry.replace`
|
|
895
|
+
// — pull the registry in for any service-bearing file even when no
|
|
896
|
+
// component injects.
|
|
897
|
+
if (c.kind === "service")
|
|
898
|
+
needed.add("ServiceRegistry");
|
|
899
|
+
// Wave 3 §2b — Strategy B. A component-side `inject service X` flips
|
|
900
|
+
// to `useService("X")`; a `provide X with Y` block needs
|
|
901
|
+
// `ServiceContext` + `composeOverrides` (the value passed to the
|
|
902
|
+
// emitted <Provider>). `ServiceRegistry` is also pulled in for the
|
|
903
|
+
// `provide` codegen — composeOverrides accepts `{ X: ServiceRegistry
|
|
904
|
+
// .get("Y") }` for each provide pair.
|
|
905
|
+
if (c.kind === "component") {
|
|
906
|
+
const hasServiceInject = c.injects.some((i) => i.serviceKind === "service");
|
|
907
|
+
if (hasServiceInject)
|
|
908
|
+
needed.add("useService");
|
|
909
|
+
if (c.provides.length > 0) {
|
|
910
|
+
needed.add("ServiceContext");
|
|
911
|
+
needed.add("composeOverrides");
|
|
912
|
+
needed.add("ServiceRegistry");
|
|
913
|
+
}
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
return needed;
|
|
917
|
+
};
|
|
918
|
+
/** True for any of the three store container kinds. */
|
|
919
|
+
const isStoreContainer = (c) => c.kind === "export-store" || c.kind === "route-store" || c.kind === "session-store";
|
|
920
|
+
/**
|
|
921
|
+
* True when the file owns at least one Reactra store or service container.
|
|
922
|
+
* Pure-component files emit no HMR block — Vite's `@vitejs/plugin-react`
|
|
923
|
+
* already handles them via Fast Refresh (verified empirically Day 16).
|
|
924
|
+
*/
|
|
925
|
+
const hasHmrTargets = (graph) => graph.containers.some((c) => isStoreContainer(c) || c.kind === "service");
|
|
926
|
+
/**
|
|
927
|
+
* Emit the `import.meta.hot.accept((newMod) => { … })` block per Compiler
|
|
928
|
+
* §5.3. For every store binding the file exports, call
|
|
929
|
+
* `StoreRegistry.replace(newMod.<name>)` so the registry picks up the new
|
|
930
|
+
* factory. Same shape for services via `ServiceRegistry.replace`.
|
|
931
|
+
*
|
|
932
|
+
* The `if (!newMod) return` guard handles Vite's hot-dispose path — when
|
|
933
|
+
* the module is removed from the dep graph, newMod is undefined and we
|
|
934
|
+
* have nothing to swap.
|
|
935
|
+
*
|
|
936
|
+
* Vite's HMR API allows multiple `accept` calls per module; React's plugin
|
|
937
|
+
* registers its own handler for Fast Refresh of any component exports in
|
|
938
|
+
* the same file. The mixed page+store case still falls back to full reload
|
|
939
|
+
* because Fast Refresh bails on `Object.assign`-wrapped page components —
|
|
940
|
+
* tracked as a known Phase-1 limitation.
|
|
941
|
+
*/
|
|
942
|
+
const emitHmrBlock = (graph) => {
|
|
943
|
+
const stores = graph.containers.filter(isStoreContainer);
|
|
944
|
+
const services = graph.containers.filter((c) => c.kind === "service");
|
|
945
|
+
const replaceLines = [
|
|
946
|
+
...stores.map((c) => ` StoreRegistry.replace(newMod.${c.name})`),
|
|
947
|
+
...services.map((c) => ` ServiceRegistry.replace(newMod.${c.name})`),
|
|
948
|
+
];
|
|
949
|
+
return [
|
|
950
|
+
"if (import.meta.hot) {",
|
|
951
|
+
" import.meta.hot.accept((newMod) => {",
|
|
952
|
+
" if (!newMod) return",
|
|
953
|
+
...replaceLines,
|
|
954
|
+
" })",
|
|
955
|
+
"}",
|
|
956
|
+
].join("\n");
|
|
957
|
+
};
|
|
958
|
+
/**
|
|
959
|
+
* Emit `const X = <lookup>` for an `inject X` declaration.
|
|
960
|
+
*
|
|
961
|
+
* - In a **component** body (Wave 3 §2b — Strategy B): emit
|
|
962
|
+
* `const X = useService("X")` so any ancestor `provide X with Y` is
|
|
963
|
+
* honoured. The hook reads `ServiceContext` and falls back to the
|
|
964
|
+
* module-level singleton when no override is active.
|
|
965
|
+
* - In a **service** or **store** body: emit
|
|
966
|
+
* `const X = ServiceRegistry.get("X")` directly. Service-to-service and
|
|
967
|
+
* store-to-service injection happens outside any React render context,
|
|
968
|
+
* so `useService` is unavailable AND unnecessary — store/service code
|
|
969
|
+
* doesn't sit inside a `<provide>` subtree.
|
|
970
|
+
*/
|
|
971
|
+
const emitInjectLookup = (inj, containerKind) => {
|
|
972
|
+
// SVC015 (Requirements v14): an opaque bare `inject X` — no `store`/`service`
|
|
973
|
+
// kind qualifier and no `from` source clause — is a build error. The
|
|
974
|
+
// `from config(...)` / `from inject` forms carry a `source` and stay valid.
|
|
975
|
+
if (inj.serviceKind === undefined && inj.source === undefined) {
|
|
976
|
+
throw new Error(`[reactra:compile] SVC015: \`inject ${inj.name}\` is missing a kind qualifier. ` +
|
|
977
|
+
`Write \`inject service ${inj.name}\` (or \`inject store ${inj.name}\` for a store), ` +
|
|
978
|
+
`or add a \`from config(...)\` / \`from inject\` source clause. (Requirements v14 / Service v3 §12.)`);
|
|
979
|
+
}
|
|
980
|
+
// Strategy B applies to component-side `inject service X` only — `from`-
|
|
981
|
+
// sourced injects (config / inject-pass-through) keep their existing
|
|
982
|
+
// singleton path (the runtime equivalent of useService isn't needed).
|
|
983
|
+
// `inject service X as Y` (Service v2.3 §4.2) renames the binding; the service
|
|
984
|
+
// identity ("X") is unchanged.
|
|
985
|
+
const binding = inj.alias ?? inj.name;
|
|
986
|
+
if (containerKind === "component" && inj.serviceKind === "service") {
|
|
987
|
+
return `const ${binding} = useService("${inj.name}")`;
|
|
988
|
+
}
|
|
989
|
+
return `const ${binding} = ServiceRegistry.get("${inj.name}")`;
|
|
990
|
+
};
|
|
991
|
+
/**
|
|
992
|
+
* Emit `const [name, setName] = useState(init)` for a StateNode. The setter is
|
|
993
|
+
* the public `set<Cap(name)>`, except when a non-trivial `action set<Cap(name)>`
|
|
994
|
+
* owns that name — then it's the internal `__set<Cap(name)>` (improvements #4).
|
|
995
|
+
*/
|
|
996
|
+
const emitState = (preprocessed, s, renamedStates, optimisticStates) => {
|
|
997
|
+
const setter = setterNameFor(s.name, renamedStates);
|
|
998
|
+
// Optimistic-tracked state (a `command`'s `optimistic {}` writes it) gets a
|
|
999
|
+
// shadow pair: `__base_X` is the real useState the commit settles into, and the
|
|
1000
|
+
// public `X` the view reads is the useOptimistic mirror that paints instantly
|
|
1001
|
+
// and auto-reverts if the await throws (Slice 3b). The view needs no rewrite —
|
|
1002
|
+
// `X` already resolves to the mirror.
|
|
1003
|
+
if (optimisticStates.has(s.name)) {
|
|
1004
|
+
const prefix = `const [__base_${s.name}, ${setter}] = useState(`;
|
|
1005
|
+
const suffix = `)\n const [${s.name}, __setOpt_${s.name}] = useOptimistic(__base_${s.name})`;
|
|
1006
|
+
return mappedSlice(preprocessed, prefix, s.initializer, suffix);
|
|
1007
|
+
}
|
|
1008
|
+
const prefix = `const [${s.name}, ${setter}] = useState(`;
|
|
1009
|
+
return mappedSlice(preprocessed, prefix, s.initializer, `)`);
|
|
1010
|
+
};
|
|
1011
|
+
/**
|
|
1012
|
+
* Emit `const d = useMemo(() => expr, [<reactive reads>])` for a DerivedNode.
|
|
1013
|
+
*
|
|
1014
|
+
* F1 fix: when the thunk body is an ObjectExpression (e.g. `derived r = { a: 1 }`,
|
|
1015
|
+
* which the block-derived G4 rewriter preserves as an arrow returning an object
|
|
1016
|
+
* literal), the bare slice `{ a: 1 }` in arrow position is a block statement that
|
|
1017
|
+
* returns `undefined`. Wrap it in parens → `() => ({ a: 1 })`.
|
|
1018
|
+
* The IIFE block form (`derived r = { const t = x; return t }`) has the thunk body
|
|
1019
|
+
* as a CallExpression (the IIFE), not an ObjectExpression — it is unaffected.
|
|
1020
|
+
*/
|
|
1021
|
+
const emitDerived = (preprocessed, d) => {
|
|
1022
|
+
const isObjLiteral = t.isObjectExpression(d.thunk.body);
|
|
1023
|
+
const prefix = `const ${d.name} = useMemo(() => ${isObjLiteral ? "(" : ""}`;
|
|
1024
|
+
const suffix = `${isObjLiteral ? ")" : ""}, [${d.deps.join(", ")}])`;
|
|
1025
|
+
return mappedSlice(preprocessed, prefix, d.thunk.body, suffix);
|
|
1026
|
+
};
|
|
1027
|
+
/**
|
|
1028
|
+
* Emit `const r = useResource(deps, fetcher, { resourceName: "r" })` for a
|
|
1029
|
+
* ResourceNode.
|
|
1030
|
+
*
|
|
1031
|
+
* The preprocessor emits `__reactra_resource__("r", () => (deps), (deps, ctx) => (fn(deps)))`;
|
|
1032
|
+
* Pass 2 captures both arrows. Here we unwrap the deps thunk to a bare deps
|
|
1033
|
+
* expression (Resource v0 §2 — `deps` is the value itself, not a thunk) and
|
|
1034
|
+
* pass the fetcher arrow through verbatim. The fetcher's second parameter is
|
|
1035
|
+
* named `ctx` by the preprocessor; the runtime supplies `{ signal }` — the
|
|
1036
|
+
* user code in the fetcher body is whatever the DSL author wrote and may
|
|
1037
|
+
* choose to ignore ctx entirely.
|
|
1038
|
+
*
|
|
1039
|
+
* Resource v1 (architect Q2 Path B): the binding identifier is also injected
|
|
1040
|
+
* as `opts.resourceName`. This is what lets `useMutation.invalidate(["r"])`
|
|
1041
|
+
* report RES014 on typos and gives the runtime a stable name for cache
|
|
1042
|
+
* + warm participation. DSL-compiled callers stay cache-OFF by default —
|
|
1043
|
+
* we deliberately do NOT inject `cache: true`. Per-resource cache opt-in
|
|
1044
|
+
* is a separate DSL grammar extension (post v1.1); hand-written callers
|
|
1045
|
+
* can still pass `cache: true` directly to `useResource` today.
|
|
1046
|
+
*/
|
|
1047
|
+
/**
|
|
1048
|
+
* Wave 3 §2b Stage 5 — AbortSignal auto-injection (Service spec §8.4).
|
|
1049
|
+
*
|
|
1050
|
+
* Detects the canonical pattern `resource X(deps) => svc.method(args)`
|
|
1051
|
+
* where:
|
|
1052
|
+
* - the fetcher's body is a single call expression on a member
|
|
1053
|
+
* (`svc.method(...)`), AND
|
|
1054
|
+
* - the receiver (`svc`) is a component-side `inject service svc`, AND
|
|
1055
|
+
* - the user did NOT explicitly use the `signal` modifier
|
|
1056
|
+
* (which would already destructure `{ signal }` in the params).
|
|
1057
|
+
*
|
|
1058
|
+
* When matched, returns rewritten fetcher text that adds `{ signal }` to
|
|
1059
|
+
* the params and appends `signal` to the method call's arguments. Falls
|
|
1060
|
+
* back to `null` (verbatim emit) otherwise — the user's complex fetcher
|
|
1061
|
+
* gets emitted as-is, no surprises.
|
|
1062
|
+
*
|
|
1063
|
+
* The text construction slices the preprocessed source for each param
|
|
1064
|
+
* and the body's call expression, then inserts `, signal` before the
|
|
1065
|
+
* call's closing `)` (handling the empty-args case where no leading
|
|
1066
|
+
* comma is needed).
|
|
1067
|
+
*/
|
|
1068
|
+
const autoInjectAbortSignal = (preprocessed, r, c) => {
|
|
1069
|
+
const fetcher = r.fetcher;
|
|
1070
|
+
// Skip when the user's `signal` modifier already destructured signal —
|
|
1071
|
+
// that path is the user's explicit signal-aware fetcher.
|
|
1072
|
+
for (const p of fetcher.params) {
|
|
1073
|
+
if (t.isObjectPattern(p)) {
|
|
1074
|
+
for (const prop of p.properties) {
|
|
1075
|
+
if (t.isObjectProperty(prop) &&
|
|
1076
|
+
t.isIdentifier(prop.key) &&
|
|
1077
|
+
prop.key.name === "signal") {
|
|
1078
|
+
return null;
|
|
1079
|
+
}
|
|
1080
|
+
}
|
|
1081
|
+
}
|
|
1082
|
+
}
|
|
1083
|
+
if (!t.isCallExpression(fetcher.body))
|
|
1084
|
+
return null;
|
|
1085
|
+
const callee = fetcher.body.callee;
|
|
1086
|
+
if (!t.isMemberExpression(callee) || callee.computed)
|
|
1087
|
+
return null;
|
|
1088
|
+
if (!t.isIdentifier(callee.object))
|
|
1089
|
+
return null;
|
|
1090
|
+
const receiverName = callee.object.name;
|
|
1091
|
+
const matched = c.injects.find((i) => i.name === receiverName && i.serviceKind === "service");
|
|
1092
|
+
if (!matched)
|
|
1093
|
+
return null;
|
|
1094
|
+
// Build the new params + body text. Each param is sliced verbatim from
|
|
1095
|
+
// the preprocessed source so a destructured `({ id, page })` shape is
|
|
1096
|
+
// preserved exactly.
|
|
1097
|
+
const paramsText = fetcher.params.length === 0
|
|
1098
|
+
? "{ signal }"
|
|
1099
|
+
: fetcher.params
|
|
1100
|
+
.map((p) => preprocessed.slice(p.start, p.end))
|
|
1101
|
+
.join(", ") + ", { signal }";
|
|
1102
|
+
const bodyText = preprocessed.slice(fetcher.body.start, fetcher.body.end);
|
|
1103
|
+
// Find the `(` after the callee — that's the args-list opener. Insert
|
|
1104
|
+
// `, signal` before the matching `)`. The bodyText is the FULL call
|
|
1105
|
+
// expression so the last char is the close paren.
|
|
1106
|
+
const openParen = bodyText.lastIndexOf("(");
|
|
1107
|
+
if (openParen < 0)
|
|
1108
|
+
return null;
|
|
1109
|
+
const argsInside = bodyText.slice(openParen + 1, -1).trim();
|
|
1110
|
+
const newArgs = argsInside === "" ? "signal" : `${argsInside}, signal`;
|
|
1111
|
+
const newBody = `${bodyText.slice(0, openParen + 1)}${newArgs})`;
|
|
1112
|
+
return `(${paramsText}) => ${newBody}`;
|
|
1113
|
+
};
|
|
1114
|
+
const emitResource = (preprocessed, r, c) => {
|
|
1115
|
+
const injected = autoInjectAbortSignal(preprocessed, r, c);
|
|
1116
|
+
if (injected !== null) {
|
|
1117
|
+
// Auto-injected fetcher emitted as constructed text — source-map
|
|
1118
|
+
// fidelity for the fetcher body is lost on this line in exchange
|
|
1119
|
+
// for the spec §8.4 abort-safe wiring.
|
|
1120
|
+
return concatE([
|
|
1121
|
+
mappedSlice(preprocessed, `const ${r.name} = useResource(`, r.depsThunk.body, ``),
|
|
1122
|
+
plain(`, ${injected}`),
|
|
1123
|
+
plain(`, { resourceName: ${JSON.stringify(r.name)}${emitResourceOpts(r)} })`),
|
|
1124
|
+
]);
|
|
1125
|
+
}
|
|
1126
|
+
return concatE([
|
|
1127
|
+
mappedSlice(preprocessed, `const ${r.name} = useResource(`, r.depsThunk.body, ``),
|
|
1128
|
+
plain(`, `),
|
|
1129
|
+
mappedSlice(preprocessed, ``, r.fetcher, ``),
|
|
1130
|
+
plain(`, { resourceName: ${JSON.stringify(r.name)}${emitResourceOpts(r)} })`),
|
|
1131
|
+
]);
|
|
1132
|
+
};
|
|
1133
|
+
/**
|
|
1134
|
+
* Stage 3 — emit the comma-separated tail of opt-in ResourceOptions fields
|
|
1135
|
+
* (compiler-grammar-driven). Returns "" when no modifiers were declared,
|
|
1136
|
+
* preserving the Stage 1 / Path B emission shape exactly. `swr` supersedes
|
|
1137
|
+
* a bare `cache` (swr implies caching with `staleWhileRevalidate: true`).
|
|
1138
|
+
* Parameterized forms supply their own value: `swr(D)` / `cache(ttl: D)` →
|
|
1139
|
+
* `cacheTtl` (ms, validated at Pass 1.1); `cache(ttl: D)` without swr is
|
|
1140
|
+
* hard-expiry; `retry(N)` → `retryCount`. Bare modifiers fall back to the
|
|
1141
|
+
* defaults (`ttlMs: 60_000`, `retry: 3`, `cache: true`) — byte-identical to
|
|
1142
|
+
* pre-parameterization. Custom retry backoff (`baseMs`/`jitter`) is not yet
|
|
1143
|
+
* DSL-expressible (raw `useResource` callers can still pass it).
|
|
1144
|
+
*/
|
|
1145
|
+
const emitResourceOpts = (r) => {
|
|
1146
|
+
const parts = [];
|
|
1147
|
+
if (r.optSwr)
|
|
1148
|
+
parts.push(`cache: { staleWhileRevalidate: true, ttlMs: ${r.cacheTtl ?? "60_000"} }`);
|
|
1149
|
+
else if (r.optCache)
|
|
1150
|
+
parts.push(r.cacheTtl !== undefined ? `cache: { ttlMs: ${r.cacheTtl} }` : `cache: true`);
|
|
1151
|
+
if (r.optRetry)
|
|
1152
|
+
parts.push(`retry: ${r.retryCount ?? "3"}`);
|
|
1153
|
+
if (r.select !== undefined)
|
|
1154
|
+
parts.push(`select: ${r.select}`);
|
|
1155
|
+
return parts.length > 0 ? `, ${parts.join(", ")}` : ``;
|
|
1156
|
+
};
|
|
1157
|
+
/**
|
|
1158
|
+
* Stage B — functional-setter lowering for the self-reference case. When a sync
|
|
1159
|
+
* action's whole body is a single `X = expr` whose only reactive read is the
|
|
1160
|
+
* assigned state `X` itself (`Pass 3` deps === `[X]`), lower it to
|
|
1161
|
+
* `setX(prev => expr[X→prev])` and emit `[]` deps. This keeps a stable callback
|
|
1162
|
+
* identity (the callback never closes over `X`), which matters when the action
|
|
1163
|
+
* is passed to a `React.memo` child. Returns null when not eligible — async
|
|
1164
|
+
* actions, compound operators, multi-statement bodies, or any other reactive
|
|
1165
|
+
* read all fall back to Stage A (`actionBodyPieces` + inferred deps).
|
|
1166
|
+
*
|
|
1167
|
+
* Source-map fidelity for the rewritten body line is traded for the stable
|
|
1168
|
+
* identity (the verbatim Stage-A path keeps its mapping); same trade as the
|
|
1169
|
+
* AbortSignal auto-injection.
|
|
1170
|
+
*/
|
|
1171
|
+
const selfSetterRewrite = (preprocessed, a, stateNames, renamedStates) => {
|
|
1172
|
+
if (a.isAsync)
|
|
1173
|
+
return null;
|
|
1174
|
+
if (a.deps.length !== 1)
|
|
1175
|
+
return null; // sole reactive read must be the assigned state
|
|
1176
|
+
const arrow = a.arrow;
|
|
1177
|
+
const body = arrow.body;
|
|
1178
|
+
if (!t.isBlockStatement(body) || body.body.length !== 1)
|
|
1179
|
+
return null;
|
|
1180
|
+
const stmt = body.body[0];
|
|
1181
|
+
if (!t.isExpressionStatement(stmt) || !t.isAssignmentExpression(stmt.expression))
|
|
1182
|
+
return null;
|
|
1183
|
+
const assign = stmt.expression;
|
|
1184
|
+
if (assign.operator !== "=")
|
|
1185
|
+
return null; // compound `x += …` reads + writes — Stage A
|
|
1186
|
+
const left = assign.left;
|
|
1187
|
+
if (!t.isIdentifier(left) || !stateNames.has(left.name) || left.name !== a.deps[0])
|
|
1188
|
+
return null;
|
|
1189
|
+
const rhs = assign.right;
|
|
1190
|
+
if (arrow.start == null ||
|
|
1191
|
+
body.start == null ||
|
|
1192
|
+
rhs.start == null ||
|
|
1193
|
+
rhs.end == null) {
|
|
1194
|
+
return null;
|
|
1195
|
+
}
|
|
1196
|
+
// Replace the free reads of `left.name` in the RHS with `prev` (right-to-left
|
|
1197
|
+
// to preserve offsets); inner-shadowed and member-property occurrences are
|
|
1198
|
+
// left alone.
|
|
1199
|
+
// Capture into a local — the guard above narrowed `rhs.start` to non-null, but
|
|
1200
|
+
// that narrowing is lost inside the `.map` closure (a mutable object property).
|
|
1201
|
+
const rhsStart = rhs.start;
|
|
1202
|
+
let rhsText = preprocessed.slice(rhsStart, rhs.end);
|
|
1203
|
+
const ranges = freeStateReadRanges(rhs, left.name)
|
|
1204
|
+
.map((r) => ({ start: r.start - rhsStart, end: r.end - rhsStart }))
|
|
1205
|
+
.sort((x, y) => y.start - x.start);
|
|
1206
|
+
for (const r of ranges)
|
|
1207
|
+
rhsText = rhsText.slice(0, r.start) + "prev" + rhsText.slice(r.end);
|
|
1208
|
+
const setter = setterNameFor(left.name, renamedStates);
|
|
1209
|
+
const arrowHead = preprocessed.slice(arrow.start, body.start); // `(params) => `
|
|
1210
|
+
return `${arrowHead}{ ${setter}(prev => ${rhsText}) }`;
|
|
1211
|
+
};
|
|
1212
|
+
/** Absolute `{start,end}` ranges of free (non-shadowed, non-property) reads of `name` in `expr`. */
|
|
1213
|
+
const freeStateReadRanges = (expr, name) => {
|
|
1214
|
+
const out = [];
|
|
1215
|
+
traverse(t.file(t.program([t.expressionStatement(expr)])), {
|
|
1216
|
+
Identifier(path) {
|
|
1217
|
+
const node = path.node;
|
|
1218
|
+
if (node.name !== name || node.start == null || node.end == null)
|
|
1219
|
+
return;
|
|
1220
|
+
const parent = path.parent;
|
|
1221
|
+
if (t.isMemberExpression(parent) && parent.property === node && !parent.computed)
|
|
1222
|
+
return;
|
|
1223
|
+
if (t.isObjectProperty(parent) &&
|
|
1224
|
+
parent.key === node &&
|
|
1225
|
+
!parent.computed &&
|
|
1226
|
+
!parent.shorthand) {
|
|
1227
|
+
return;
|
|
1228
|
+
}
|
|
1229
|
+
if (path.scope.hasBinding(name))
|
|
1230
|
+
return; // shadowed by an inner arrow/local
|
|
1231
|
+
out.push({ start: node.start, end: node.end });
|
|
1232
|
+
},
|
|
1233
|
+
});
|
|
1234
|
+
return out;
|
|
1235
|
+
};
|
|
1236
|
+
/** True when an action body assigns to a `state` field (so `undoable` snapshots it). */
|
|
1237
|
+
const actionWritesState = (arrow, stateNames) => {
|
|
1238
|
+
let writes = false;
|
|
1239
|
+
traverse(t.file(t.program([t.expressionStatement(arrow)])), {
|
|
1240
|
+
AssignmentExpression(path) {
|
|
1241
|
+
const left = path.node.left;
|
|
1242
|
+
if (t.isIdentifier(left) && stateNames.has(left.name))
|
|
1243
|
+
writes = true;
|
|
1244
|
+
},
|
|
1245
|
+
});
|
|
1246
|
+
return writes;
|
|
1247
|
+
};
|
|
1248
|
+
/**
|
|
1249
|
+
* Wrap an async action's body in `startTransition_f(async () => …)` and append
|
|
1250
|
+
* `f.isPending = isPending_f`, per Component DSL §11 (the `action async` row). The
|
|
1251
|
+
* outer arrow keeps the user's params (`async (id) => …`); the inner `async () =>`
|
|
1252
|
+
* runs the body INSIDE the transition so React 19 drives the pending flag, and the
|
|
1253
|
+
* exposed `f.isPending` lets the view read it (`disabled={save.isPending}`). The
|
|
1254
|
+
* `head` already declared `const [isPending_f, startTransition_f] = useTransition()`.
|
|
1255
|
+
*
|
|
1256
|
+
* Without this, the declared transition locals were unused and `f.isPending` was
|
|
1257
|
+
* `undefined` — the whole pending-state feature was inert (the original BUG-1).
|
|
1258
|
+
*/
|
|
1259
|
+
/**
|
|
1260
|
+
* Emit the BLOCK `command` form → React 19 `useActionState` (P3 Slice 1).
|
|
1261
|
+
*
|
|
1262
|
+
* command f(args) { …return X… }
|
|
1263
|
+
*
|
|
1264
|
+
* lowers to a reducer that runs the user's (implicitly-async) body and folds its
|
|
1265
|
+
* outcome into a `{ ok, value | error }` result, plus the `useActionState` wiring
|
|
1266
|
+
* and the handle surface `f.pending` / `f.result` / `f.error`. The user's arrow is
|
|
1267
|
+
* emitted verbatim (source-mapped) and IIFE-called with the dispatch payload, so
|
|
1268
|
+
* no body rewrite is needed. No deps array — `useActionState` re-reads the action
|
|
1269
|
+
* each render, so the reducer closes over fresh state. (Arrow + `optimistic {}` →
|
|
1270
|
+
* `useOptimistic`, and the `uses replayable`/`undoable` begin/commit/rollback
|
|
1271
|
+
* recording layer, are later slices.)
|
|
1272
|
+
*/
|
|
1273
|
+
const emitCommand = (preprocessed, cmd, c, stateNames, renamedStates) => {
|
|
1274
|
+
const n = cmd.name;
|
|
1275
|
+
// Recording layer (Slice 2): when the component `uses replayable`, the command
|
|
1276
|
+
// records as ONE transaction unit via the P2 channel — begin → commit (success)
|
|
1277
|
+
// | rollback (failure). The settle snapshot lists the current state (the
|
|
1278
|
+
// channel's delta machinery drops an empty diff). No observability opt-in →
|
|
1279
|
+
// the bare React-19 lowering.
|
|
1280
|
+
const recording = c.uses?.names.includes("replayable") ?? false;
|
|
1281
|
+
const stateObj = c.states.map((s) => s.name).join(", ");
|
|
1282
|
+
// Arrow form (Slice 3a/3b): `command f() => fetcher` → useTransition. With an
|
|
1283
|
+
// `optimistic {}` clause (Slice 3b) the tracked states are useOptimistic mirrors:
|
|
1284
|
+
// apply the optimistic write before the await (`__setOpt_X` paints instantly,
|
|
1285
|
+
// auto-reverts on throw), settle the real write (`setX`) only on success.
|
|
1286
|
+
if (cmd.form === "arrow") {
|
|
1287
|
+
const optWrites = optimisticWritesOf(preprocessed, cmd, stateNames);
|
|
1288
|
+
const hasOpt = optWrites.size > 0;
|
|
1289
|
+
const apply = [...optWrites].map(([nm, rhs]) => `__setOpt_${nm}(${rhs});`).join(" ");
|
|
1290
|
+
const commitW = [...optWrites].map(([nm, rhs]) => `${setterNameFor(nm, renamedStates)}(${rhs});`).join(" ");
|
|
1291
|
+
// The recorded delta = the optimistic write-set (so the timeline shows the
|
|
1292
|
+
// intended next state); with no optimistic clause it's the whole state object.
|
|
1293
|
+
const deltaObj = hasOpt ? `{ ${[...optWrites].map(([nm, rhs]) => `${nm}: ${rhs}`).join(", ")} }` : `{ ${stateObj} }`;
|
|
1294
|
+
const baseObj = `{ ${stateObj} }`;
|
|
1295
|
+
// `invalidate [a, b]` — refetch the listed resources after the command settles.
|
|
1296
|
+
const hasInv = (cmd.invalidate?.length ?? 0) > 0;
|
|
1297
|
+
const invT = hasInv ? `__getResourceCache().invalidate([${cmd.invalidate.map((nm) => JSON.stringify(nm)).join(", ")}]); ` : "";
|
|
1298
|
+
const hasRb = cmd.rollback != null;
|
|
1299
|
+
const decl = `const [__pending_${n}, __start_${n}] = useTransition();\n` +
|
|
1300
|
+
` const ${n} = (__payload?: unknown) => __start_${n}(async () => { `;
|
|
1301
|
+
const expose = `\n ${n}.pending = __pending_${n}`;
|
|
1302
|
+
const needTry = recording || hasRb;
|
|
1303
|
+
// Preamble before the await: recording begin (+ optimistic mark), then the
|
|
1304
|
+
// optimistic apply (paints the mirror).
|
|
1305
|
+
let pre = "";
|
|
1306
|
+
if (recording) {
|
|
1307
|
+
pre += `const __tx_${n} = __replay.begin("${n}", [__payload], ${baseObj}); `;
|
|
1308
|
+
if (hasOpt)
|
|
1309
|
+
pre += `__tx_${n}.mark(${deltaObj}); `;
|
|
1310
|
+
}
|
|
1311
|
+
if (hasOpt)
|
|
1312
|
+
pre += `${apply} `;
|
|
1313
|
+
// Success tail (after the await): commit the real setters, invalidate, channel commit.
|
|
1314
|
+
let okTail = "";
|
|
1315
|
+
if (hasOpt)
|
|
1316
|
+
okTail += `${commitW} `;
|
|
1317
|
+
if (hasInv)
|
|
1318
|
+
okTail += invT;
|
|
1319
|
+
if (recording)
|
|
1320
|
+
okTail += hasOpt ? `__tx_${n}.commit(${deltaObj}); ` : `__tx_${n}.commit(${baseObj}); `;
|
|
1321
|
+
const parts = [plain(`${decl}${pre}${needTry ? "try { " : ""}await (`)];
|
|
1322
|
+
parts.push(mappedSlice(preprocessed, "", cmd.arrow, "")); // the fetcher, source-mapped
|
|
1323
|
+
parts.push(plain(`)(__payload); ${okTail}`));
|
|
1324
|
+
if (needTry) {
|
|
1325
|
+
let catchHead = `} catch (__e) { `;
|
|
1326
|
+
if (recording)
|
|
1327
|
+
catchHead += `__tx_${n}.rollback(${baseObj}); `;
|
|
1328
|
+
parts.push(plain(catchHead));
|
|
1329
|
+
if (hasRb) {
|
|
1330
|
+
// The user's rollback handler — lowered like an action body (state writes →
|
|
1331
|
+
// setters), IIFE-called with the error. The failure is treated as handled.
|
|
1332
|
+
const rbPieces = actionBodyPieces(preprocessed, { arrow: cmd.rollback, name: n }, stateNames, renamedStates);
|
|
1333
|
+
parts.push(plain("("));
|
|
1334
|
+
parts.push(emitMappedPieces("", rbPieces, ""));
|
|
1335
|
+
parts.push(plain(")(__e); "));
|
|
1336
|
+
}
|
|
1337
|
+
else {
|
|
1338
|
+
// No user handler: re-throw so the error reaches the error boundary (the
|
|
1339
|
+
// transition reverts the optimistic mirror either way).
|
|
1340
|
+
parts.push(plain(`throw __e; `));
|
|
1341
|
+
}
|
|
1342
|
+
parts.push(plain(`} });${expose}`));
|
|
1343
|
+
}
|
|
1344
|
+
else {
|
|
1345
|
+
parts.push(plain(`});${expose}`));
|
|
1346
|
+
}
|
|
1347
|
+
return concatE(parts);
|
|
1348
|
+
}
|
|
1349
|
+
// Block form (Slice 1/2): `command f() { …return… }` → useActionState, exposing
|
|
1350
|
+
// f.pending / f.result / f.error. The user's arrow is IIFE-called with the payload.
|
|
1351
|
+
const begin = recording ? `const __tx_${n} = __replay.begin("${n}", [__payload], { ${stateObj} }); ` : "";
|
|
1352
|
+
const commit = recording ? `__tx_${n}.commit({ ${stateObj} }); ` : "";
|
|
1353
|
+
const rollback = recording ? `__tx_${n}.rollback({ ${stateObj} }); ` : "";
|
|
1354
|
+
const prefix = `const __cmd_${n} = async (__prev: unknown, __payload: unknown) => { ${begin}` +
|
|
1355
|
+
`try { const __v = await (`;
|
|
1356
|
+
const suffix = `)(__payload); ${commit}return { ok: true as const, value: __v }; } ` +
|
|
1357
|
+
`catch (__e) { ${rollback}return { ok: false as const, error: __e instanceof Error ? __e.message : String(__e) }; } };\n` +
|
|
1358
|
+
` const [__state_${n}, ${n}, __pending_${n}] = useActionState(__cmd_${n}, { ok: null as boolean | null });\n` +
|
|
1359
|
+
` ${n}.pending = __pending_${n};\n` +
|
|
1360
|
+
` ${n}.result = __state_${n}.ok === true ? __state_${n}.value : undefined;\n` +
|
|
1361
|
+
` ${n}.error = __state_${n}.ok === false ? __state_${n}.error : undefined`;
|
|
1362
|
+
return mappedSlice(preprocessed, prefix, cmd.arrow, suffix);
|
|
1363
|
+
};
|
|
1364
|
+
const emitAsyncAction = (preprocessed, a, head, pieces, deps) => {
|
|
1365
|
+
const arrow = a.arrow;
|
|
1366
|
+
// `f.isPending = isPending_f` is re-asserted each render onto the (possibly
|
|
1367
|
+
// memoised) callback, so a `pending` flip — which re-renders — refreshes it.
|
|
1368
|
+
const exposeSuffix = `, [${deps}])\n ${a.name}.isPending = isPending_${a.name}`;
|
|
1369
|
+
// Split the body pieces at the block's opening `{` so the user's arrow head
|
|
1370
|
+
// (`async (params) => `) stays OUTSIDE the inner transition arrow.
|
|
1371
|
+
if (arrow.start == null || !t.isBlockStatement(arrow.body) || arrow.body.start == null) {
|
|
1372
|
+
// Defensive — a parsed `action async f() {…}` always has a block body.
|
|
1373
|
+
return emitMappedPieces(head, pieces, exposeSuffix);
|
|
1374
|
+
}
|
|
1375
|
+
const headLen = arrow.body.start - arrow.start; // length of `async (params) => `
|
|
1376
|
+
const wrapped = [
|
|
1377
|
+
...takeFront(pieces, headLen),
|
|
1378
|
+
{ kind: "gen", text: `startTransition_${a.name}(async () => `, anchorSrc: arrow.body.start },
|
|
1379
|
+
...dropFront(pieces, headLen),
|
|
1380
|
+
{ kind: "gen", text: `)`, anchorSrc: arrow.end ?? arrow.body.start },
|
|
1381
|
+
];
|
|
1382
|
+
return emitMappedPieces(head, wrapped, exposeSuffix);
|
|
1383
|
+
};
|
|
1384
|
+
const NO_AUGMENTATION = {
|
|
1385
|
+
augmented: false,
|
|
1386
|
+
prologue: null,
|
|
1387
|
+
epilogue: null,
|
|
1388
|
+
widensDeps: false,
|
|
1389
|
+
};
|
|
1390
|
+
/**
|
|
1391
|
+
* Fold the matched native behaviours (Pass 9.1) into a single per-action
|
|
1392
|
+
* augmentation, in `uses` source order. For `uses undoable` this yields the
|
|
1393
|
+
* former `isUndoableWrite` path verbatim (prologue `__undo.push(__snapshot())` +
|
|
1394
|
+
* all-state dep widening on state-writers). Multi-behaviour prologue/epilogue
|
|
1395
|
+
* composition is exercised once `replayable` lands (Piece A step 2).
|
|
1396
|
+
*/
|
|
1397
|
+
const augmentationFor = (a, c, behaviours, ctx) => {
|
|
1398
|
+
const applies = behaviours.filter((b) => b.augmentsAction(a, c, ctx));
|
|
1399
|
+
if (applies.length === 0)
|
|
1400
|
+
return NO_AUGMENTATION;
|
|
1401
|
+
const prologues = applies.map((b) => b.actionPrologue(a, c, ctx)).filter((p) => p != null);
|
|
1402
|
+
// Epilogues compose in reverse `uses` order so the outermost behaviour's
|
|
1403
|
+
// teardown runs last (mirror of the preamble order), matching HOF nesting.
|
|
1404
|
+
const epilogues = [...applies]
|
|
1405
|
+
.reverse()
|
|
1406
|
+
.map((b) => b.actionEpilogue(a, c, ctx))
|
|
1407
|
+
.filter((p) => p != null);
|
|
1408
|
+
return {
|
|
1409
|
+
augmented: true,
|
|
1410
|
+
// Each prologue is its own `;`-terminated statement(s) (undoable's
|
|
1411
|
+
// `__undo.push(__snapshot());`, replay's `…action(…);`), so a space join keeps
|
|
1412
|
+
// them as separate statements and separates the last one from the body.
|
|
1413
|
+
prologue: prologues.length > 0 ? prologues.join(" ") : null,
|
|
1414
|
+
epilogue: epilogues.length > 0 ? epilogues.join("; ") : null,
|
|
1415
|
+
widensDeps: applies.some((b) => b.widensAugmentedActionDeps),
|
|
1416
|
+
};
|
|
1417
|
+
};
|
|
1418
|
+
/** Emit `const f = useCallback(rewrittenArrow, [<reactive reads>])` for an ActionNode. */
|
|
1419
|
+
const emitAction = (preprocessed, a, stateNames, renamedStates,
|
|
1420
|
+
// Native-behaviour augmentation for this action (Pass 9.1) — a state-writing
|
|
1421
|
+
// `uses undoable` action carries `__undo.push(__snapshot())` + all-state deps.
|
|
1422
|
+
aug = NO_AUGMENTATION) => {
|
|
1423
|
+
const callbackPrefix = `const ${a.name} = useCallback(`;
|
|
1424
|
+
const head = a.isAsync
|
|
1425
|
+
? `const [isPending_${a.name}, startTransition_${a.name}] = useTransition()\n ${callbackPrefix}`
|
|
1426
|
+
: callbackPrefix;
|
|
1427
|
+
// Behaviour augmentation takes precedence over Stage B: snapshot-before-body,
|
|
1428
|
+
// deps = all state names (Stage B's stable-identity `[]` is moot here since the
|
|
1429
|
+
// snapshot already closes over every state field).
|
|
1430
|
+
// Stage B — stable-identity functional-setter form (sync self-reference only;
|
|
1431
|
+
// `selfSetterRewrite` bails on async, so it never collides with the transition
|
|
1432
|
+
// wrap below). Skipped when a behaviour owns the body.
|
|
1433
|
+
if (!aug.augmented) {
|
|
1434
|
+
const rewritten = selfSetterRewrite(preprocessed, a, stateNames, renamedStates);
|
|
1435
|
+
if (rewritten !== null) {
|
|
1436
|
+
return emitMappedPieces(head, [{ kind: "gen", text: rewritten, anchorSrc: a.arrow.start }], `, [])`);
|
|
1437
|
+
}
|
|
1438
|
+
}
|
|
1439
|
+
// Stage A — verbatim body with setter lowering + inferred deps (or, for an
|
|
1440
|
+
// augmented action, the behaviour prologue + all-state deps). The body is
|
|
1441
|
+
// decomposed into pieces: verbatim source spans keep char-accurate origins,
|
|
1442
|
+
// the lowered `set<X>(`/`)` are generated. Per-line marks bind breakpoints
|
|
1443
|
+
// across a multi-line body and at each setter-rewrite site (#10-followup §8).
|
|
1444
|
+
const pieces = aug.augmented
|
|
1445
|
+
? actionBodyPieces(preprocessed, a, stateNames, renamedStates, aug.prologue ?? undefined, aug.epilogue ?? undefined)
|
|
1446
|
+
: actionBodyPieces(preprocessed, a, stateNames, renamedStates);
|
|
1447
|
+
// Widened: ALL states (the snapshot closes over them) UNION the body's
|
|
1448
|
+
// non-state reads — referenced actions (BUG-2), `param`/`query`/store reads —
|
|
1449
|
+
// so the callback still tracks them. Otherwise: the inferred read-set as-is
|
|
1450
|
+
// (already includes referenced actions after Pass 3's callback-set widening).
|
|
1451
|
+
const deps = aug.widensDeps
|
|
1452
|
+
? [...stateNames, ...a.deps.filter((d) => !stateNames.has(d))].join(", ")
|
|
1453
|
+
: a.deps.join(", ");
|
|
1454
|
+
// Async (Component DSL §11) — wrap the body in a transition + expose isPending.
|
|
1455
|
+
if (a.isAsync)
|
|
1456
|
+
return emitAsyncAction(preprocessed, a, head, pieces, deps);
|
|
1457
|
+
return emitMappedPieces(head, pieces, `, [${deps}])`);
|
|
1458
|
+
};
|
|
1459
|
+
/**
|
|
1460
|
+
* Order a component's emitted actions so a callee is declared before any action
|
|
1461
|
+
* that lists it as a dependency (BUG-2 Direction A) — otherwise the `[callee]`
|
|
1462
|
+
* dep array would reference an uninitialised `const` (TDZ). Post-order DFS over
|
|
1463
|
+
* the action→action edges (`a.deps` ∩ emitted-action-names, minus self); a back
|
|
1464
|
+
* edge is a reference cycle, which has no valid order → **R021**. Suppressed
|
|
1465
|
+
* identity setters (R016) are excluded — they emit as the stable `useState`
|
|
1466
|
+
* setter, already in scope. Independent actions keep declaration order.
|
|
1467
|
+
*/
|
|
1468
|
+
const orderActionsForEmit = (c, suppressedActions) => {
|
|
1469
|
+
const emitted = c.actions.filter((a) => !suppressedActions.has(a.name));
|
|
1470
|
+
const names = new Set(emitted.map((a) => a.name));
|
|
1471
|
+
const byName = new Map(emitted.map((a) => [a.name, a]));
|
|
1472
|
+
const out = [];
|
|
1473
|
+
const visitState = new Map();
|
|
1474
|
+
const visit = (a, stack) => {
|
|
1475
|
+
const st = visitState.get(a.name);
|
|
1476
|
+
if (st === "done")
|
|
1477
|
+
return;
|
|
1478
|
+
if (st === "visiting") {
|
|
1479
|
+
const cycle = [...stack.slice(stack.indexOf(a.name)), a.name].join(" → ");
|
|
1480
|
+
throw new Error(`[reactra:compile] R021: reference cycle among actions in component "${c.name}": ${cycle}. ` +
|
|
1481
|
+
`Mutually-recursive actions cannot be ordered so each lists the other as a dependency — ` +
|
|
1482
|
+
`break the cycle by extracting the shared logic into a store action or a plain helper. ` +
|
|
1483
|
+
`(Component DSL §14 R021.)`);
|
|
1484
|
+
}
|
|
1485
|
+
visitState.set(a.name, "visiting");
|
|
1486
|
+
for (const dep of a.deps) {
|
|
1487
|
+
if (dep !== a.name && names.has(dep))
|
|
1488
|
+
visit(byName.get(dep), [...stack, a.name]);
|
|
1489
|
+
}
|
|
1490
|
+
visitState.set(a.name, "done");
|
|
1491
|
+
out.push(a); // post-order — pushed after its callees, so callees emit first
|
|
1492
|
+
};
|
|
1493
|
+
for (const a of emitted)
|
|
1494
|
+
visit(a, []);
|
|
1495
|
+
return out;
|
|
1496
|
+
};
|
|
1497
|
+
/**
|
|
1498
|
+
* True when an `inject store X` name is bound — either a same-file store
|
|
1499
|
+
* declaration or a type-only import `import type { X }` / `import { type X }`
|
|
1500
|
+
* (Store §2.5). The type-only import is what makes the name traceable and
|
|
1501
|
+
* survives package boundaries; the sidecar is no longer consulted (backlog 1a,
|
|
1502
|
+
* Compiler §6). An unbound cross-file reference is S014.
|
|
1503
|
+
*/
|
|
1504
|
+
const isStoreNameBound = (graph, name) => {
|
|
1505
|
+
if (graph.containers.some((c) => isStoreContainer(c) && c.name === name))
|
|
1506
|
+
return true;
|
|
1507
|
+
return graph.userImports.some((imp) => importsTypeName(imp, name));
|
|
1508
|
+
};
|
|
1509
|
+
/**
|
|
1510
|
+
* Does a user import statement bind `name` as a store/service reference?
|
|
1511
|
+
* Accepts `import type { name }`, `import { type name }`, and `import { name }`
|
|
1512
|
+
* (DSL v2 honest value imports — Phase 5 migration converts `import type` to
|
|
1513
|
+
* `import { }`, so both forms must be accepted during the v1→v2 transition).
|
|
1514
|
+
*/
|
|
1515
|
+
const importsTypeName = (imp, name) => {
|
|
1516
|
+
const typeOnlyImport = new RegExp(`\\bimport\\s+type\\b[^;]*\\{[^}]*\\b${name}\\b[^}]*\\}`);
|
|
1517
|
+
const inlineTypeSpecifier = new RegExp(`\\bimport\\b[^;]*\\{[^}]*\\btype\\s+${name}\\b[^}]*\\}`);
|
|
1518
|
+
// DSL v2: plain value import `import { storeX }` from "...".
|
|
1519
|
+
// This is the honest-import form (reactra-dsl-v2-surface.md §FB-6.4).
|
|
1520
|
+
const valueImport = new RegExp(`\\bimport\\b(?!\\s+type\\b)[^;]*\\{[^}]*\\b${name}\\b[^}]*\\}`);
|
|
1521
|
+
return typeOnlyImport.test(imp) || inlineTypeSpecifier.test(imp) || valueImport.test(imp);
|
|
1522
|
+
};
|
|
1523
|
+
/**
|
|
1524
|
+
* Emit the React-side subscription for an `inject store X` binding (bare or
|
|
1525
|
+
* argumented — both subscribe to a live instance). Backlog 1a access model
|
|
1526
|
+
* (Store §2.5):
|
|
1527
|
+
* - field selection `inject store X { a, b }` → per-field subscription:
|
|
1528
|
+
* `const { a, b } = useReactraStoreFields<X>("X", ["a", "b"])` (A2 / Store
|
|
1529
|
+
* §7.3 — re-renders only when a selected source field changes)
|
|
1530
|
+
* - no braces → namespace binding `const X = useReactraStore<X>("X")` (member access `X.a`)
|
|
1531
|
+
*
|
|
1532
|
+
* The `<X>` type param resolves to the store's emitted public-surface type
|
|
1533
|
+
* (`export type X = StoreSurface<typeof X>`) — same-file (hoisted) or via the
|
|
1534
|
+
* consumer's `import type { X }`. No sidecar lookup. A cross-file reference
|
|
1535
|
+
* with neither a same-file decl nor an `import type` is **S014**.
|
|
1536
|
+
*
|
|
1537
|
+
* Argumented `inject store` ALSO emits `routeBindings` on the page component
|
|
1538
|
+
* (see `emitRegisterRouteBindings`); the lifecycle hooks live there. Keyed
|
|
1539
|
+
* `inject store X(key)({...})` subscribes by name like the bare/argumented forms.
|
|
1540
|
+
*/
|
|
1541
|
+
const emitStoreUse = (graph, u) => {
|
|
1542
|
+
if (!isStoreNameBound(graph, u.storeName)) {
|
|
1543
|
+
throw new Error(`[reactra:compile] S014: \`inject store ${u.storeName}\` — name not bound. ` +
|
|
1544
|
+
`A cross-file consumer must \`import type { ${u.storeName} } from "..."\`; ` +
|
|
1545
|
+
`no same-file store declaration was found either. (Store v4.2 §2.5 / §13.)`);
|
|
1546
|
+
}
|
|
1547
|
+
// The generic type param: the `:Type` override when present (Store §2.5 /
|
|
1548
|
+
// Compiler v14 — TypeScript then surfaces an incompatible structural subset,
|
|
1549
|
+
// standing in for the LSP-deferred S015), else the store's emitted surface type.
|
|
1550
|
+
// The override types the value; the store *name* is still resolved/bound above.
|
|
1551
|
+
const surface = u.typeOverride ?? u.storeName;
|
|
1552
|
+
if (u.fields && u.fields.length > 0) {
|
|
1553
|
+
// A2 (Store §7.3) — field-selected: per-field subscription via
|
|
1554
|
+
// `useReactraStoreFields`, so the component re-renders ONLY when one of the
|
|
1555
|
+
// selected SOURCE fields changes (not on every store write). The destructure
|
|
1556
|
+
// is UNCHANGED from the whole-store form (C0-1): a renamed field emits the JS
|
|
1557
|
+
// object-destructure rename `source: local` (Store v4.7 §2.5); a bare field
|
|
1558
|
+
// stays as-is. C3 (CRITICAL): the field-list argument is the store's SOURCE
|
|
1559
|
+
// keys — `f` for a bare field, `f.source` for a rename — NOT the renamed
|
|
1560
|
+
// locals; subscribing to a local name would target a non-existent field.
|
|
1561
|
+
const destructure = u.fields
|
|
1562
|
+
.map((f) => (typeof f === "string" ? f : `${f.source}: ${f.local}`))
|
|
1563
|
+
.join(", ");
|
|
1564
|
+
const sourceKeys = u.fields
|
|
1565
|
+
.map((f) => `"${typeof f === "string" ? f : f.source}"`)
|
|
1566
|
+
.join(", ");
|
|
1567
|
+
return `const { ${destructure} } = useReactraStoreFields<${surface}>("${u.storeName}", [${sourceKeys}])`;
|
|
1568
|
+
}
|
|
1569
|
+
// Namespace form — bind under the alias when `inject store X as Y` (Store v4.9 §2.5).
|
|
1570
|
+
// C5: no fields present ⇒ whole-store `useReactraStore` (unchanged).
|
|
1571
|
+
return `const ${u.alias ?? u.storeName} = useReactraStore<${surface}>("${u.storeName}")`;
|
|
1572
|
+
};
|
|
1573
|
+
const extractArgumentedInputs = (u) => {
|
|
1574
|
+
const out = [];
|
|
1575
|
+
if (!u.args || !t.isObjectExpression(u.args))
|
|
1576
|
+
return out;
|
|
1577
|
+
for (const prop of u.args.properties) {
|
|
1578
|
+
if (!t.isObjectProperty(prop) || !t.isIdentifier(prop.key))
|
|
1579
|
+
continue;
|
|
1580
|
+
const alias = prop.key.name;
|
|
1581
|
+
// Shorthand `{ returnTo }` and `{ alias: id }` both give an Identifier value.
|
|
1582
|
+
const valueName = t.isIdentifier(prop.value) ? prop.value.name : "";
|
|
1583
|
+
out.push({ alias, valueName });
|
|
1584
|
+
}
|
|
1585
|
+
return out;
|
|
1586
|
+
};
|
|
1587
|
+
/**
|
|
1588
|
+
* Build the `inputs` object literal an argumented `inject store` lowers to
|
|
1589
|
+
* inside `StoreRegistry.instantiate("storeX", { inputs: ... })`. Each value
|
|
1590
|
+
* identifier is resolved against the page's `param`/`query` declarations →
|
|
1591
|
+
* `params.X` / `query.X`. A value that is neither (a `state`/`derived` source,
|
|
1592
|
+
* or unknown) is **RO018** — route stores re-instantiate on navigation, not on
|
|
1593
|
+
* arbitrary reactive change (Router v4.10 §4).
|
|
1594
|
+
*/
|
|
1595
|
+
const emitInputsObj = (inputs, c, storeName) => {
|
|
1596
|
+
const paramNames = new Set(c.params.map((p) => p.name));
|
|
1597
|
+
const queryNames = new Set(c.queries.map((q) => q.name));
|
|
1598
|
+
const props = inputs.map((i) => {
|
|
1599
|
+
if (paramNames.has(i.valueName))
|
|
1600
|
+
return `${i.alias}: params.${i.valueName}`;
|
|
1601
|
+
if (queryNames.has(i.valueName))
|
|
1602
|
+
return `${i.alias}: query.${i.valueName}`;
|
|
1603
|
+
throw new Error(`[reactra:compile] RO018: \`inject store ${storeName}({ ${i.alias}: ${i.valueName || "<expr>"} })\` ` +
|
|
1604
|
+
`— the input value must be a route \`param\`/\`query\` in scope; ` +
|
|
1605
|
+
`"${i.valueName || "<expr>"}" is neither (state/derived inputs are disallowed — ` +
|
|
1606
|
+
`route stores re-instantiate on navigation). (Router v4.10 §4 / RO018.)`);
|
|
1607
|
+
});
|
|
1608
|
+
return `{ ${props.join(", ")} }`;
|
|
1609
|
+
};
|
|
1610
|
+
/**
|
|
1611
|
+
* Emit the module-level `RouterRegistry.registerRouteBindings("Foo", { … })`
|
|
1612
|
+
* side-effect call that pairs with a page component holding ≥1 argumented
|
|
1613
|
+
* `use storeX(...)`. The router calls `onEnter` on route enter (with the
|
|
1614
|
+
* matched URL params and query) and `onExit` on leave. Each argumented
|
|
1615
|
+
* use becomes one `StoreRegistry.instantiate` call (with mapped inputs)
|
|
1616
|
+
* and one `StoreRegistry.dispose`. Multiple argumented uses on the same
|
|
1617
|
+
* page are independent — Router §4.4.
|
|
1618
|
+
*
|
|
1619
|
+
* Day 16 / `#18b`: replaced the prior `Object.assign(component, { routeBindings })`
|
|
1620
|
+
* wrap with this name-keyed side effect. The component now exports as a
|
|
1621
|
+
* plain arrow function, which React Fast Refresh accepts; the name-keyed
|
|
1622
|
+
* binding survives HMR because the new module's re-execution overwrites
|
|
1623
|
+
* the entry under the same name.
|
|
1624
|
+
*/
|
|
1625
|
+
const emitRegisterRouteBindings = (c, argumentedUses) => {
|
|
1626
|
+
// `preservedKey` (the route-entry coordinates) is passed to every instantiate
|
|
1627
|
+
// and the matching savePreservedState; the registry no-ops it for stores with
|
|
1628
|
+
// no `preserved state` (Store §6 / Router §8.4). The page can't know a
|
|
1629
|
+
// cross-file store's preserved fields, so it always threads the key.
|
|
1630
|
+
const enters = argumentedUses.map((u) => {
|
|
1631
|
+
const inputs = extractArgumentedInputs(u);
|
|
1632
|
+
return ` StoreRegistry.instantiate("${u.storeName}", { inputs: ${emitInputsObj(inputs, c, u.storeName)}, preservedKey })`;
|
|
1633
|
+
});
|
|
1634
|
+
// Save before dispose on exit (Store §6.4 — values captured for the route
|
|
1635
|
+
// we're leaving). Wave 3 §2b — thread the destination route's pathname so
|
|
1636
|
+
// subtree-aware stores can keep their instance alive across pages inside
|
|
1637
|
+
// the same subtree. `toCtx` is the second positional arg the router passes
|
|
1638
|
+
// to onExit (Router v4.19); non-subtree stores ignore the nextPathname opt.
|
|
1639
|
+
const exits = argumentedUses.flatMap((u) => [
|
|
1640
|
+
` StoreRegistry.savePreservedState("${u.storeName}", preservedKey)`,
|
|
1641
|
+
` StoreRegistry.dispose("${u.storeName}", { nextPathname: toCtx?.pathname })`,
|
|
1642
|
+
]);
|
|
1643
|
+
// Stage 2 compiler — emit a `warm({ params, query }, signal)` companion
|
|
1644
|
+
// alongside onEnter/onExit. `PrefetchRuntime.warm(to, params, query)`
|
|
1645
|
+
// (Router §8.5) calls this on hover/visible/mount; we fan out to
|
|
1646
|
+
// `StoreRegistry.warm(name, inputs, signal)` for every argumented store
|
|
1647
|
+
// use, which in turn invokes the route store's `warm(inputs, signal)`
|
|
1648
|
+
// companion emitted by `emitStoreWarm` (above). Stores without resources
|
|
1649
|
+
// omit `warm` on their binding; `StoreRegistry.warm` treats absence as
|
|
1650
|
+
// a silent no-op so the fan-out stays correct even when only some of
|
|
1651
|
+
// the argumented uses have warm-able resources.
|
|
1652
|
+
const warmCalls = argumentedUses.map((u) => {
|
|
1653
|
+
const inputs = extractArgumentedInputs(u);
|
|
1654
|
+
return ` StoreRegistry.warm("${u.storeName}", ${emitInputsObj(inputs, c, u.storeName)}, signal),`;
|
|
1655
|
+
});
|
|
1656
|
+
return [
|
|
1657
|
+
`RouterRegistry.registerRouteBindings("${c.name}", {`,
|
|
1658
|
+
` onEnter: ({ pathname, params, query }) => {`,
|
|
1659
|
+
` const preservedKey = { pathname, params, query }`,
|
|
1660
|
+
...enters,
|
|
1661
|
+
` },`,
|
|
1662
|
+
` onExit: ({ pathname, params, query }, toCtx) => {`,
|
|
1663
|
+
` const preservedKey = { pathname, params, query }`,
|
|
1664
|
+
...exits,
|
|
1665
|
+
` },`,
|
|
1666
|
+
` warm: async ({ params, query }, signal) => {`,
|
|
1667
|
+
` await Promise.all([`,
|
|
1668
|
+
...warmCalls,
|
|
1669
|
+
` ])`,
|
|
1670
|
+
` },`,
|
|
1671
|
+
`})`,
|
|
1672
|
+
].join("\n");
|
|
1673
|
+
};
|
|
1674
|
+
// `setterActionTarget`, `isTrivialIdentitySetter`, `classifyStateSetters`, and
|
|
1675
|
+
// the `setterNameFor`/`cap` helpers moved to `conventions/index.ts` (framework
|
|
1676
|
+
// review §B1) so the shadow emitter shares the EXACT same gated derivation.
|
|
1677
|
+
// Imported at the top of this file.
|
|
1678
|
+
/** The prefix Pass 9 reserves for compiler-emitted identifiers (`__route`, `__meta`, `__set<X>`, …). */
|
|
1679
|
+
const RESERVED_EMISSION_PREFIX = "__";
|
|
1680
|
+
/**
|
|
1681
|
+
* R017: a Reactra-declared component identifier — `state`/`derived`/`action`/
|
|
1682
|
+
* `resource`/`ref` — must not begin with `__`. That prefix is reserved for the
|
|
1683
|
+
* compiler's own emitted identifiers (Compiler §4 Pass 9: `__route`, `__meta`,
|
|
1684
|
+
* `__AwaitInner`, the renamed setter `__set<Cap(X)>`), so a user identifier
|
|
1685
|
+
* sharing it can collide with generated code. Hard error at compile time, since
|
|
1686
|
+
* the alternative is a silent name clash (Component DSL §2.1 / §14 R017).
|
|
1687
|
+
*
|
|
1688
|
+
* Scope: component primitives (the R-prefix owns these). `param`/`query`
|
|
1689
|
+
* (Router/RO) and store/service names (Store/Service) are a noted follow-up.
|
|
1690
|
+
*/
|
|
1691
|
+
const validateReservedPrefix = (c) => {
|
|
1692
|
+
if (c.kind !== "component")
|
|
1693
|
+
return;
|
|
1694
|
+
const declared = [
|
|
1695
|
+
...c.states.map((s) => ({ kind: "state", name: s.name })),
|
|
1696
|
+
...c.deriveds.map((d) => ({ kind: "derived", name: d.name })),
|
|
1697
|
+
...c.actions.map((a) => ({ kind: "action", name: a.name })),
|
|
1698
|
+
...c.resources.map((r) => ({ kind: "resource", name: r.name })),
|
|
1699
|
+
...c.refs.map((r) => ({ kind: "ref", name: r.name })),
|
|
1700
|
+
];
|
|
1701
|
+
for (const { kind, name } of declared) {
|
|
1702
|
+
if (!name.startsWith(RESERVED_EMISSION_PREFIX))
|
|
1703
|
+
continue;
|
|
1704
|
+
throw new Error(`[reactra:compile] R017: ${kind} "${name}" in component "${c.name}" uses the reserved ` +
|
|
1705
|
+
`"__" prefix. Double-underscore identifiers are reserved for compiler-emitted code ` +
|
|
1706
|
+
`(\`__route\`, \`__meta\`, \`__set<X>\`, …) and may collide. Rename without the leading "__". ` +
|
|
1707
|
+
`(Component DSL §2.1 / §14 R017.)`);
|
|
1708
|
+
}
|
|
1709
|
+
};
|
|
1710
|
+
/**
|
|
1711
|
+
* Props validations (Component DSL §1.1 / §14):
|
|
1712
|
+
* - R020: a component takes a single props object — more than one parameter is
|
|
1713
|
+
* an error (the `forwardRef` ref-parameter is deferred).
|
|
1714
|
+
* - R018: a prop name may not collide with another component binding.
|
|
1715
|
+
* - R019: a prop is a read-only input — assigning to it (`label = …`) is an
|
|
1716
|
+
* error; seed a `state` for a local mutable value. Detected by a scoped walk
|
|
1717
|
+
* so a local/action-param that *shadows* a prop name is not flagged.
|
|
1718
|
+
*/
|
|
1719
|
+
const validateProps = (c) => {
|
|
1720
|
+
if (c.kind !== "component")
|
|
1721
|
+
return;
|
|
1722
|
+
// R020 — more than one parameter (the preprocessor emits the header param(s)
|
|
1723
|
+
// verbatim, so `(a, b)` parses as two arrow params).
|
|
1724
|
+
if (c.factory.params.length > 1) {
|
|
1725
|
+
throw new Error(`[reactra:compile] R020: component "${c.name}" declares ${c.factory.params.length} parameters — ` +
|
|
1726
|
+
`a component takes a single props object. The forwardRef ref-parameter is deferred. ` +
|
|
1727
|
+
`(Component DSL §1.1 / §14 R020.)`);
|
|
1728
|
+
}
|
|
1729
|
+
const props = c.propNames ?? [];
|
|
1730
|
+
if (props.length === 0)
|
|
1731
|
+
return;
|
|
1732
|
+
const propSet = new Set(props);
|
|
1733
|
+
// R018 — collision with another component binding.
|
|
1734
|
+
const others = [
|
|
1735
|
+
...c.states.map((s) => ["state", s.name]),
|
|
1736
|
+
...c.deriveds.map((d) => ["derived", d.name]),
|
|
1737
|
+
...c.actions.map((a) => ["action", a.name]),
|
|
1738
|
+
...c.resources.map((r) => ["resource", r.name]),
|
|
1739
|
+
...c.refs.map((r) => ["ref", r.name]),
|
|
1740
|
+
...c.params.map((p) => ["param", p.name]),
|
|
1741
|
+
...c.queries.map((q) => ["query", q.name]),
|
|
1742
|
+
...c.injects.map((inj) => ["inject", inj.alias ?? inj.name]),
|
|
1743
|
+
...c.storeUses.flatMap((su) => su.fields && su.fields.length > 0
|
|
1744
|
+
? su.fields.map((f) => ["inject store field", fieldLocal(f)])
|
|
1745
|
+
: [["inject store", su.storeName]]),
|
|
1746
|
+
];
|
|
1747
|
+
for (const [kind, name] of others) {
|
|
1748
|
+
if (propSet.has(name)) {
|
|
1749
|
+
throw new Error(`[reactra:compile] R018: prop "${name}" in component "${c.name}" collides with a ${kind} ` +
|
|
1750
|
+
`of the same name. Rename the prop or the ${kind}. (Component DSL §1.1 / §14 R018.)`);
|
|
1751
|
+
}
|
|
1752
|
+
}
|
|
1753
|
+
// R019 — assignment to a read-only prop. Precise: we flag only when the
|
|
1754
|
+
// assigned binding resolves to the component's *own* prop-parameter identifier
|
|
1755
|
+
// node (not a local or nested-arrow param that merely shadows the name).
|
|
1756
|
+
const propIdents = new Set();
|
|
1757
|
+
if (c.factory.params.length > 0)
|
|
1758
|
+
collectPatternIdentifiers(c.factory.params[0], propIdents);
|
|
1759
|
+
traverse(t.file(t.program([t.expressionStatement(c.factory)])), {
|
|
1760
|
+
AssignmentExpression(path) {
|
|
1761
|
+
const left = path.node.left;
|
|
1762
|
+
if (!t.isIdentifier(left) || !propSet.has(left.name))
|
|
1763
|
+
return;
|
|
1764
|
+
const binding = path.scope.getBinding(left.name);
|
|
1765
|
+
if (!binding || !propIdents.has(binding.identifier))
|
|
1766
|
+
return; // shadowing local — fine
|
|
1767
|
+
throw new Error(`[reactra:compile] R019: assignment to prop "${left.name}" in component "${c.name}" — ` +
|
|
1768
|
+
`props are read-only inputs from the parent. Seed a \`state\` for a local mutable value. ` +
|
|
1769
|
+
`(Component DSL §1.1 / §14 R019.)`);
|
|
1770
|
+
},
|
|
1771
|
+
});
|
|
1772
|
+
};
|
|
1773
|
+
/**
|
|
1774
|
+
* `inject store` field-selection validations (Store v4.7 §2.5 / §13):
|
|
1775
|
+
* - S017: a field rename used the import-style `as` (`{ b as localB }`) — use
|
|
1776
|
+
* the React-style `{ b: localB }`.
|
|
1777
|
+
* - S016: a field-selection **local** name collides — duplicated across the
|
|
1778
|
+
* expanded local-name list (across injects, within one brace `{ a: b, b }`),
|
|
1779
|
+
* or clashing with a `state`/`derived`/`action`/`resource`/`ref`/`param`/
|
|
1780
|
+
* `query`/prop in the same component.
|
|
1781
|
+
*/
|
|
1782
|
+
const validateStoreFieldSelection = (c) => {
|
|
1783
|
+
if (c.kind !== "component")
|
|
1784
|
+
return;
|
|
1785
|
+
// S017 — `as` rename is rejected.
|
|
1786
|
+
for (const su of c.storeUses) {
|
|
1787
|
+
for (const f of su.fields ?? []) {
|
|
1788
|
+
if (typeof f !== "string" && f.as) {
|
|
1789
|
+
throw new Error(`[reactra:compile] S017: \`inject store ${su.storeName} { ${f.source} as ${f.local} }\` ` +
|
|
1790
|
+
`uses \`as\` — use the React-style rename \`{ ${f.source}: ${f.local} }\`. ` +
|
|
1791
|
+
`(Store §2.5 / §13 S017.)`);
|
|
1792
|
+
}
|
|
1793
|
+
}
|
|
1794
|
+
}
|
|
1795
|
+
// S016 — local-name collision. Non-store bindings populate the origin map
|
|
1796
|
+
// first (a duplicate among THEM is a different error, not S016), then each
|
|
1797
|
+
// field-selection local must be unclaimed.
|
|
1798
|
+
const origin = new Map();
|
|
1799
|
+
const addBinding = (name, where) => {
|
|
1800
|
+
if (!origin.has(name))
|
|
1801
|
+
origin.set(name, where);
|
|
1802
|
+
};
|
|
1803
|
+
for (const s of c.states)
|
|
1804
|
+
addBinding(s.name, `state ${s.name}`);
|
|
1805
|
+
for (const d of c.deriveds)
|
|
1806
|
+
addBinding(d.name, `derived ${d.name}`);
|
|
1807
|
+
for (const a of c.actions)
|
|
1808
|
+
addBinding(a.name, `action ${a.name}`);
|
|
1809
|
+
for (const r of c.resources)
|
|
1810
|
+
addBinding(r.name, `resource ${r.name}`);
|
|
1811
|
+
for (const r of c.refs)
|
|
1812
|
+
addBinding(r.name, `ref ${r.name}`);
|
|
1813
|
+
for (const p of c.params)
|
|
1814
|
+
addBinding(p.name, `param ${p.name}`);
|
|
1815
|
+
for (const q of c.queries)
|
|
1816
|
+
addBinding(q.name, `query ${q.name}`);
|
|
1817
|
+
for (const n of c.propNames ?? [])
|
|
1818
|
+
addBinding(n, `prop ${n}`);
|
|
1819
|
+
const claim = (name, where, hint) => {
|
|
1820
|
+
const prev = origin.get(name);
|
|
1821
|
+
if (prev) {
|
|
1822
|
+
throw new Error(`[reactra:compile] S016: \`${name}\` in component "${c.name}" is bound by both ${prev} ` +
|
|
1823
|
+
`and ${where}. ${hint} (Store §2.5 / §13 S016.)`);
|
|
1824
|
+
}
|
|
1825
|
+
origin.set(name, where);
|
|
1826
|
+
};
|
|
1827
|
+
for (const su of c.storeUses) {
|
|
1828
|
+
// S018 — a namespace alias cannot combine with field-selection braces.
|
|
1829
|
+
if (su.alias && su.fields && su.fields.length > 0) {
|
|
1830
|
+
throw new Error(`[reactra:compile] S018: \`inject store ${su.storeName} as ${su.alias} { … }\` combines a ` +
|
|
1831
|
+
`namespace alias with field selection. The alias names the whole store; the braces ` +
|
|
1832
|
+
`destructure — use one. (Store §2.5 / §13 S018.)`);
|
|
1833
|
+
}
|
|
1834
|
+
if (su.fields && su.fields.length > 0) {
|
|
1835
|
+
for (const f of su.fields) {
|
|
1836
|
+
const local = fieldLocal(f);
|
|
1837
|
+
claim(local, `inject store ${su.storeName} { ${local} }`, `Rename one with \`{ field: newLocal }\` or use the namespace form ` +
|
|
1838
|
+
`(\`inject store ${su.storeName}\` → \`${su.storeName}.${typeof f === "string" ? f : f.source}\`).`);
|
|
1839
|
+
}
|
|
1840
|
+
}
|
|
1841
|
+
else if (su.classification === "bare") {
|
|
1842
|
+
// namespace form — the in-scope binding is the alias, else the store name.
|
|
1843
|
+
const name = su.alias ?? su.storeName;
|
|
1844
|
+
claim(name, su.alias ? `inject store ${su.storeName} as ${su.alias}` : `inject store ${su.storeName}`, `Alias the namespace with \`as ${name}2\` (or rename the other binding).`);
|
|
1845
|
+
}
|
|
1846
|
+
}
|
|
1847
|
+
// `inject service X [as Y]` (Service v2.3 §4.2) — the binding (alias, else the
|
|
1848
|
+
// service name) is subject to the same collision check → SVC016.
|
|
1849
|
+
for (const inj of c.injects) {
|
|
1850
|
+
if (inj.serviceKind !== "service")
|
|
1851
|
+
continue;
|
|
1852
|
+
const name = inj.alias ?? inj.name;
|
|
1853
|
+
const where = inj.alias ? `inject service ${inj.name} as ${inj.alias}` : `inject service ${inj.name}`;
|
|
1854
|
+
const prev = origin.get(name);
|
|
1855
|
+
if (prev) {
|
|
1856
|
+
throw new Error(`[reactra:compile] SVC016: \`${name}\` in component "${c.name}" is bound by both ${prev} ` +
|
|
1857
|
+
`and ${where}. Alias the service (\`as ${name}2\`) or rename the other binding. ` +
|
|
1858
|
+
`(Service §4.2 / §12 SVC016.)`);
|
|
1859
|
+
}
|
|
1860
|
+
origin.set(name, where);
|
|
1861
|
+
}
|
|
1862
|
+
};
|
|
1863
|
+
/**
|
|
1864
|
+
* G7 — Reactive-state mutation inside `mount`/`effect` body → R023.
|
|
1865
|
+
*
|
|
1866
|
+
* DSL v2 uniform rule (§8.3 locked / Component DSL §2.1/§3 / §14 R023): reactive
|
|
1867
|
+
* state mutation is legal only at a *tracked boundary* — an `action` (declared)
|
|
1868
|
+
* or a JSX event handler (auto-wrapped by G6). `mount` and `effect` blocks
|
|
1869
|
+
* observe state; they must call an action to mutate it.
|
|
1870
|
+
*
|
|
1871
|
+
* The check is **lexical and recurses into every nested closure** within the
|
|
1872
|
+
* body — a write inside a `.then`/timer/listener callback (`mount { p.then(d =>
|
|
1873
|
+
* x = d) }`) is R023, because a deferred write that escapes the instrumented
|
|
1874
|
+
* boundary breaks passive replay/undo determinism (this is the determinism hole
|
|
1875
|
+
* the rule closes). A write is reactive iff its target identifier is a declared
|
|
1876
|
+
* `state` name that does NOT resolve to a binding local to the body (an
|
|
1877
|
+
* effect-local `let`, a nested-callback param — `scope.getBinding`). DOM writes
|
|
1878
|
+
* (`document.title = …`) and `ref.current = …` carry a MemberExpression LHS and
|
|
1879
|
+
* are excluded by the `isIdentifier` guard; a compound `count += 1` / `count++`
|
|
1880
|
+
* on a `state` field IS R023.
|
|
1881
|
+
*/
|
|
1882
|
+
const validateEffectMutations = (c) => {
|
|
1883
|
+
if (c.kind !== "component")
|
|
1884
|
+
return;
|
|
1885
|
+
const stateNames = new Set(c.states.map((s) => s.name));
|
|
1886
|
+
if (stateNames.size === 0)
|
|
1887
|
+
return;
|
|
1888
|
+
const reportAt = (name, location) => {
|
|
1889
|
+
throw new Error(`[reactra:compile] R023: reactive state "${name}" is mutated inside a ${location} ` +
|
|
1890
|
+
`of component "${c.name}". DSL v2 uniform rule: only an \`action\` (declared) or a ` +
|
|
1891
|
+
`JSX event handler (auto-wrapped) may mutate reactive state — \`effect\`/\`mount\` ` +
|
|
1892
|
+
`observe and route mutation through an action. ` +
|
|
1893
|
+
`Move "${name} = …" into a named \`action\` and call it from the ${location}. ` +
|
|
1894
|
+
`(Component DSL §3 / §14 R023.)`);
|
|
1895
|
+
};
|
|
1896
|
+
// R023 is **lexical and recurses into nested closures** (Component DSL §2.1/§14):
|
|
1897
|
+
// a reactive `state` write at ANY function-nesting depth inside a `mount`/`effect`
|
|
1898
|
+
// body is the error — `mount { p.then(d => x = d) }`, a `setInterval`/listener
|
|
1899
|
+
// callback, a helper declared in the body. This closes the determinism hole: a
|
|
1900
|
+
// deferred write would otherwise mutate state outside any instrumented boundary,
|
|
1901
|
+
// breaking passive replay/undo. A write is reactive iff the target identifier is a
|
|
1902
|
+
// declared `state` name AND does NOT resolve to a binding local to the body
|
|
1903
|
+
// (effect-local `let`, a nested-callback param). DOM writes (`document.title = …`)
|
|
1904
|
+
// and `ref.current = …` have a MemberExpression LHS → excluded by the `isIdentifier`
|
|
1905
|
+
// guard; a compound `count += 1` / `count++` on a `state` field IS R023.
|
|
1906
|
+
const check = (bodyArrow, location) => {
|
|
1907
|
+
traverse(t.file(t.program([t.expressionStatement(bodyArrow)])), {
|
|
1908
|
+
AssignmentExpression(inner) {
|
|
1909
|
+
const left = inner.node.left;
|
|
1910
|
+
if (t.isIdentifier(left)) {
|
|
1911
|
+
// Simple assignment LHS: `x = expr`.
|
|
1912
|
+
if (!stateNames.has(left.name))
|
|
1913
|
+
return;
|
|
1914
|
+
if (inner.scope.getBinding(left.name))
|
|
1915
|
+
return; // local var/param — not the reactive state
|
|
1916
|
+
reportAt(left.name, location);
|
|
1917
|
+
}
|
|
1918
|
+
else if (t.isArrayPattern(left) || t.isObjectPattern(left)) {
|
|
1919
|
+
// F3: destructuring assignment `[x] = arr` or `({ x } = obj)`.
|
|
1920
|
+
// Collect all bound identifiers from the pattern (reuse existing helper).
|
|
1921
|
+
const idents = new Set();
|
|
1922
|
+
collectPatternIdentifiers(left, idents);
|
|
1923
|
+
for (const id of idents) {
|
|
1924
|
+
if (!stateNames.has(id.name))
|
|
1925
|
+
continue;
|
|
1926
|
+
if (inner.scope.getBinding(id.name))
|
|
1927
|
+
continue;
|
|
1928
|
+
reportAt(id.name, location);
|
|
1929
|
+
}
|
|
1930
|
+
}
|
|
1931
|
+
},
|
|
1932
|
+
UpdateExpression(inner) {
|
|
1933
|
+
const arg = inner.node.argument;
|
|
1934
|
+
if (!t.isIdentifier(arg) || !stateNames.has(arg.name))
|
|
1935
|
+
return;
|
|
1936
|
+
if (inner.scope.getBinding(arg.name))
|
|
1937
|
+
return;
|
|
1938
|
+
reportAt(arg.name, location);
|
|
1939
|
+
},
|
|
1940
|
+
});
|
|
1941
|
+
};
|
|
1942
|
+
for (const m of c.mounts)
|
|
1943
|
+
check(m.body, "mount");
|
|
1944
|
+
for (const e of c.effects)
|
|
1945
|
+
check(e.body, "effect");
|
|
1946
|
+
};
|
|
1947
|
+
/** Collect the binding Identifier NODES introduced by a props parameter pattern. */
|
|
1948
|
+
const collectPatternIdentifiers = (node, out) => {
|
|
1949
|
+
if (t.isIdentifier(node))
|
|
1950
|
+
out.add(node);
|
|
1951
|
+
else if (t.isAssignmentPattern(node))
|
|
1952
|
+
collectPatternIdentifiers(node.left, out);
|
|
1953
|
+
else if (t.isRestElement(node))
|
|
1954
|
+
collectPatternIdentifiers(node.argument, out);
|
|
1955
|
+
else if (t.isObjectPattern(node)) {
|
|
1956
|
+
for (const p of node.properties) {
|
|
1957
|
+
if (t.isObjectProperty(p))
|
|
1958
|
+
collectPatternIdentifiers(p.value, out);
|
|
1959
|
+
else
|
|
1960
|
+
collectPatternIdentifiers(p, out);
|
|
1961
|
+
}
|
|
1962
|
+
}
|
|
1963
|
+
else if (t.isArrayPattern(node)) {
|
|
1964
|
+
for (const el of node.elements)
|
|
1965
|
+
if (el)
|
|
1966
|
+
collectPatternIdentifiers(el, out);
|
|
1967
|
+
}
|
|
1968
|
+
};
|
|
1969
|
+
/**
|
|
1970
|
+
* Emit a paired `mount` + `cleanup` as one `useEffect` whose callback runs the
|
|
1971
|
+
* mount body and **returns** the cleanup as its teardown (Component DSL §3.2):
|
|
1972
|
+
*
|
|
1973
|
+
* useEffect(() => { <mount body>; return () => { <cleanup body> } }, [])
|
|
1974
|
+
*
|
|
1975
|
+
* The mount block's inner statements are spliced verbatim, then a generated
|
|
1976
|
+
* `return () => <cleanup block>` is appended on its own line (so ASI separates
|
|
1977
|
+
* it from the last mount statement). Without this the cleanup was extracted but
|
|
1978
|
+
* never emitted — teardown silently never ran.
|
|
1979
|
+
*/
|
|
1980
|
+
const emitMountWithCleanup = (preprocessed, mount, cleanup) => {
|
|
1981
|
+
const mb = mount.body.body; // mount BlockStatement
|
|
1982
|
+
const cb = cleanup.body.body; // cleanup BlockStatement
|
|
1983
|
+
if (mb.start == null || mb.end == null || cb.start == null || cb.end == null) {
|
|
1984
|
+
// Defensive: positions missing → fall back to a solo mount (cleanup dropped,
|
|
1985
|
+
// but this should never happen for parsed blocks).
|
|
1986
|
+
return mappedSlice(preprocessed, ` useEffect(() => `, mb, `, [])`);
|
|
1987
|
+
}
|
|
1988
|
+
const pieces = [
|
|
1989
|
+
{ kind: "verbatim", srcStart: mb.start + 1, text: preprocessed.slice(mb.start + 1, mb.end - 1) },
|
|
1990
|
+
{ kind: "gen", text: `\n return () => `, anchorSrc: cb.start },
|
|
1991
|
+
{ kind: "verbatim", srcStart: cb.start, text: preprocessed.slice(cb.start, cb.end) },
|
|
1992
|
+
];
|
|
1993
|
+
return emitMappedPieces(` useEffect(() => {`, pieces, ` }, [])`);
|
|
1994
|
+
};
|
|
1995
|
+
/** Emit a single component as a React 19 function component. */
|
|
1996
|
+
const emitComponent = (preprocessed, graph, c) => {
|
|
1997
|
+
validateReservedPrefix(c);
|
|
1998
|
+
validateProps(c);
|
|
1999
|
+
validateStoreFieldSelection(c);
|
|
2000
|
+
validateEffectMutations(c);
|
|
2001
|
+
const { renamedStates, suppressedActions } = classifyStateSetters(c);
|
|
2002
|
+
// R016 (warning, not a throw): the emitted code stays correct — the implicit
|
|
2003
|
+
// setter stands in for the suppressed action. Advisory style matches #5.
|
|
2004
|
+
for (const name of suppressedActions) {
|
|
2005
|
+
const stateName = setterActionTarget(name);
|
|
2006
|
+
console.warn(`[reactra:compile] R016: redundant identity setter action "${name}" in ` +
|
|
2007
|
+
`component "${c.name}" duplicates the auto-emitted setter for state "${stateName}" ` +
|
|
2008
|
+
`(\`const [${stateName}, ${name}] = useState(...)\`). The action is suppressed — ` +
|
|
2009
|
+
`call the implicit ${name}(...) directly, or give it a non-trivial body to keep it. ` +
|
|
2010
|
+
`(Component DSL §2.3 / §14 R016.)`);
|
|
2011
|
+
}
|
|
2012
|
+
// R028 (warning, not a throw): a resource dep that mints a fresh object/array
|
|
2013
|
+
// each render never key-equals, so the resource refetches every render and the
|
|
2014
|
+
// dep-id cache grows unbounded. Detected in Pass 3 (`unstableDeps`). The
|
|
2015
|
+
// emitted code is unchanged — this is purely advisory. (Component DSL §2.4.)
|
|
2016
|
+
for (const r of c.resources) {
|
|
2017
|
+
for (const dep of r.unstableDeps ?? []) {
|
|
2018
|
+
console.warn(`[reactra:compile] R028: resource "${r.name}" dep "${dep}" is a fresh object/array ` +
|
|
2019
|
+
`each render (a derived returning an object literal, or an inline literal) — it never ` +
|
|
2020
|
+
`key-equals, so the resource refetches every render and the dep-id cache grows ` +
|
|
2021
|
+
`unbounded. Use a stable scalar/identifier dep, or derive the primitive fields you ` +
|
|
2022
|
+
`actually key on. (Component DSL §2.4)`);
|
|
2023
|
+
}
|
|
2024
|
+
}
|
|
2025
|
+
// R029 (warning, not a throw): a `.map()` in the view returning keyless JSX is
|
|
2026
|
+
// the classic React reconciliation footgun. Detected in Pass 3
|
|
2027
|
+
// (`view.keylessMaps` = offending count). Emission is unchanged. (Component DSL §7.)
|
|
2028
|
+
for (let i = 0; i < (c.view?.keylessMaps ?? 0); i++) {
|
|
2029
|
+
console.warn(`[reactra:compile] R029: a \`.map()\` in component "${c.name}"'s view returns JSX ` +
|
|
2030
|
+
`with no \`key\` — React needs a stable \`key\` to reconcile list items correctly ` +
|
|
2031
|
+
`(missing keys cause lost state, wrong-row updates, and remount churn). Add ` +
|
|
2032
|
+
`\`key={…}\` to the element the callback returns. (Component DSL §7)`);
|
|
2033
|
+
}
|
|
2034
|
+
const stateNames = new Set(c.states.map((s) => s.name));
|
|
2035
|
+
// States any `command`'s `optimistic {}` writes — these emit a useOptimistic shadow.
|
|
2036
|
+
const optimisticStates = optimisticStatesOf(preprocessed, c, stateNames);
|
|
2037
|
+
// Compiler-native behaviours this component `uses`, in source order (Pass 9.1).
|
|
2038
|
+
// The ctx hands the seam methods the Pass-9 helpers they need (Compiler §4).
|
|
2039
|
+
const behaviours = matchedNativeBehaviours(c);
|
|
2040
|
+
const behaviourCtx = {
|
|
2041
|
+
stateNames,
|
|
2042
|
+
renamedStates,
|
|
2043
|
+
setterNameFor: (s) => setterNameFor(s, renamedStates),
|
|
2044
|
+
actionWritesState: (a) => actionWritesState(a.arrow, stateNames),
|
|
2045
|
+
straightLineStateWrites: (a) => straightLineStateWrites(preprocessed, a, stateNames),
|
|
2046
|
+
line: plain,
|
|
2047
|
+
};
|
|
2048
|
+
const argumentedUses = c.storeUses.filter((u) => u.classification === "argumented");
|
|
2049
|
+
const hasRouteState = c.params.length > 0 || c.queries.length > 0;
|
|
2050
|
+
// Plugin-class behaviours (Behaviour Plugin spec §3/§4): resolved up front so
|
|
2051
|
+
// an unresolvable name throws R045 before any emission. Empty for the common
|
|
2052
|
+
// no-plugin component — emission stays byte-identical then (§4 rule 5).
|
|
2053
|
+
const plugins = resolvePluginBehaviours(c, graph);
|
|
2054
|
+
// Each `entries` element is one emitted line (joined by "\n" at the end —
|
|
2055
|
+
// byte-identical to the old `lines.join`). Slice-bearing lines carry their
|
|
2056
|
+
// source marks; boilerplate lines are `plain(...)` (Compiler §8).
|
|
2057
|
+
const entries = [];
|
|
2058
|
+
const decl = c.exported ? "export const" : "const";
|
|
2059
|
+
// Day 16 / `#18b`: page component exports as a plain arrow regardless
|
|
2060
|
+
// of routeBindings — Fast Refresh accepts this shape. The bindings, if
|
|
2061
|
+
// any, land in a sibling `RouterRegistry.registerRouteBindings(…)`
|
|
2062
|
+
// side-effect call after the component definition (see emit below).
|
|
2063
|
+
// §1.1 — re-emit the React-style props parameter verbatim (or `()` if none).
|
|
2064
|
+
// With plugin behaviours the body lands on a private `X__impl` and the public
|
|
2065
|
+
// name is the wrap — once, re-exported through both export forms (§4 rule 2);
|
|
2066
|
+
// BLIM-01: this shape degrades Fast Refresh to a module reload.
|
|
2067
|
+
const implName = plugins.length > 0 ? `${c.name}__impl` : c.name;
|
|
2068
|
+
entries.push(plain(plugins.length > 0
|
|
2069
|
+
? `const ${implName} = (${c.propsParam ?? ""}) => {`
|
|
2070
|
+
: `${decl} ${c.name} = (${c.propsParam ?? ""}) => {`));
|
|
2071
|
+
// Param / query lookups go first — they introduce identifiers that store
|
|
2072
|
+
// hooks, state initialisers, injects, and the view body may reference.
|
|
2073
|
+
// One useRoute() call per render; both params and query come from the
|
|
2074
|
+
// same snapshot so they're consistent within a single transition.
|
|
2075
|
+
if (hasRouteState) {
|
|
2076
|
+
entries.push(plain(` const __route = useRoute()`));
|
|
2077
|
+
for (const p of c.params)
|
|
2078
|
+
entries.push(plain(` const ${p.name} = __route.params.${p.name}`));
|
|
2079
|
+
for (const q of c.queries)
|
|
2080
|
+
entries.push(plain(emitQueryRead(q)));
|
|
2081
|
+
}
|
|
2082
|
+
// Inject lookups next — same precedence as Day 9c. Strategy A keeps
|
|
2083
|
+
// the `ServiceRegistry.get` path for store/service bodies; Strategy B
|
|
2084
|
+
// (Wave 3 §2b) flips component-side `inject service X` to
|
|
2085
|
+
// `useService("X")` so ancestor `provide X with Y` is honoured.
|
|
2086
|
+
for (const inj of c.injects)
|
|
2087
|
+
entries.push(plain(` ${emitInjectLookup(inj, c.kind)}`));
|
|
2088
|
+
for (const u of c.storeUses)
|
|
2089
|
+
entries.push(plain(` ${emitStoreUse(graph, u)}`));
|
|
2090
|
+
// Wave 3 §2b — `provide X with Y` setup. Read the parent overrides,
|
|
2091
|
+
// compose the child additions on top, then wrap the view return in
|
|
2092
|
+
// <ServiceContext.Provider>. The useMemo dep list includes the parent
|
|
2093
|
+
// overrides identity so a re-render with the same parent + same
|
|
2094
|
+
// declared replacements doesn't allocate a new map; descendants don't
|
|
2095
|
+
// re-render solely because of the Provider value churn.
|
|
2096
|
+
if (c.kind === "component" && c.provides.length > 0) {
|
|
2097
|
+
entries.push(plain(` const __reactraParentOverrides = use(ServiceContext)`));
|
|
2098
|
+
const additions = c.provides
|
|
2099
|
+
.map((p) => `${JSON.stringify(p.name)}: ServiceRegistry.get(${JSON.stringify(p.valueText)})`)
|
|
2100
|
+
.join(", ");
|
|
2101
|
+
entries.push(plain(` const __reactraOverrides = useMemo(() => composeOverrides(__reactraParentOverrides, { ${additions} }), [__reactraParentOverrides])`));
|
|
2102
|
+
}
|
|
2103
|
+
for (const s of c.states)
|
|
2104
|
+
entries.push(concatE([plain(" "), emitState(preprocessed, s, renamedStates, optimisticStates)]));
|
|
2105
|
+
for (const d of c.deriveds)
|
|
2106
|
+
entries.push(concatE([plain(" "), emitDerived(preprocessed, d)]));
|
|
2107
|
+
for (const r of c.refs) {
|
|
2108
|
+
entries.push(mappedSlice(preprocessed, ` const ${r.name} = useRef(`, r.initializer, `)`));
|
|
2109
|
+
}
|
|
2110
|
+
for (const r of c.resources)
|
|
2111
|
+
entries.push(concatE([plain(" "), emitResource(preprocessed, r, c)]));
|
|
2112
|
+
// BUG-3 is closed: plugin-class `uses` names are resolved (R045 if not) and
|
|
2113
|
+
// wrapped (Behaviour Plugin spec §3/§4) — no inert drop remains. The one
|
|
2114
|
+
// still-pending directive is BUG-4 (Router §5.6): `transition <name>` should
|
|
2115
|
+
// compile to a `transition: { name, duration }` manifest field the runtime
|
|
2116
|
+
// applies via `document.startViewTransition()`; the field + View Transitions
|
|
2117
|
+
// runtime are deferred to Wave 3 (the flushSync+Suspense interaction). Until
|
|
2118
|
+
// then, warn (non-fatal) instead of dropping the directive on the floor.
|
|
2119
|
+
if (c.transition) {
|
|
2120
|
+
console.warn(`[reactra:compile] RO: component "${c.name}" declares \`transition ${c.transition.name}\` but ` +
|
|
2121
|
+
`route view-transitions are not yet emitted — the directive is currently inert (deferred to ` +
|
|
2122
|
+
`Wave 3). (Router §5.6.)`);
|
|
2123
|
+
}
|
|
2124
|
+
// Compiler-native behaviour preambles (Pass 9.1): land after resources and
|
|
2125
|
+
// before actions, so `__snapshot`/`__replay` close over every state and the
|
|
2126
|
+
// augmented actions can reference the behaviour's bindings. Emitted in `uses`
|
|
2127
|
+
// source order. State-writing actions are then augmented per behaviour below.
|
|
2128
|
+
if (behaviours.some((b) => b.name === "undoable") && c.states.length === 0) {
|
|
2129
|
+
// Harmless (the buffer is always empty) but almost certainly a mistake.
|
|
2130
|
+
// Behaviour-specific use-site diagnostic, owned by Undo §10 (not a seam).
|
|
2131
|
+
console.warn(`[reactra:compile] BHV004: component "${c.name}" declares \`uses undoable\` but has no ` +
|
|
2132
|
+
`\`state\` — the undo history will always be empty. (Undo spec §10.)`);
|
|
2133
|
+
}
|
|
2134
|
+
for (const b of behaviours) {
|
|
2135
|
+
for (const line of b.emitPreamble(c, behaviourCtx))
|
|
2136
|
+
entries.push(line);
|
|
2137
|
+
}
|
|
2138
|
+
// Commands emit AFTER the behaviour preamble (like actions) so a recording
|
|
2139
|
+
// command's reducer closes over an already-declared `__replay` (no TDZ-adjacent
|
|
2140
|
+
// ordering surprise) and over every state.
|
|
2141
|
+
for (const cmd of c.commands)
|
|
2142
|
+
entries.push(concatE([plain(" "), emitCommand(preprocessed, cmd, c, stateNames, renamedStates)]));
|
|
2143
|
+
// Emit actions in dependency order so a callee declared after its caller still
|
|
2144
|
+
// precedes it (BUG-2 Direction A) — a reference cycle throws R021. Suppressed
|
|
2145
|
+
// identity setters are already excluded by `orderActionsForEmit`.
|
|
2146
|
+
for (const a of orderActionsForEmit(c, suppressedActions)) {
|
|
2147
|
+
const aug = augmentationFor(a, c, behaviours, behaviourCtx);
|
|
2148
|
+
entries.push(concatE([plain(" "), emitAction(preprocessed, a, stateNames, renamedStates, aug)]));
|
|
2149
|
+
}
|
|
2150
|
+
// Behaviour resource hooks (Pass 9.1): emitted after the actions and after the
|
|
2151
|
+
// resource declarations they reference (e.g. replay's per-resource status
|
|
2152
|
+
// useEffect — Replay §4.1). `undoable` contributes none.
|
|
2153
|
+
for (const b of behaviours) {
|
|
2154
|
+
for (const line of b.emitResourceHooks(c, behaviourCtx))
|
|
2155
|
+
entries.push(line);
|
|
2156
|
+
}
|
|
2157
|
+
// Route metadata (Router §3.7). Phase-1 scope: the `meta {}` object is
|
|
2158
|
+
// evaluated in component scope (so a `title` may reference param / query /
|
|
2159
|
+
// state / derived / resource) and its `title` field is applied to
|
|
2160
|
+
// `document.title` via an effect — the canonical CSR use. Static-field
|
|
2161
|
+
// hoisting to the manifest (§3.7.1 resolveStatic), the static/dynamic
|
|
2162
|
+
// classifier, and `useRoute().meta` cross-component reads are deferred to
|
|
2163
|
+
// Wave 3 (SSR / route guards / layouts — no Phase-1 consumer).
|
|
2164
|
+
if (c.meta) {
|
|
2165
|
+
entries.push(mappedSlice(preprocessed, ` const __meta = `, c.meta.object, ``));
|
|
2166
|
+
entries.push(plain(` useEffect(() => { if (typeof document !== "undefined" && __meta.title != null) document.title = String(__meta.title) }, [__meta.title])`));
|
|
2167
|
+
}
|
|
2168
|
+
const lifecycle = [
|
|
2169
|
+
...c.mounts.map((n) => ({ kind: "mount", node: n })),
|
|
2170
|
+
...c.cleanups.map((n) => ({ kind: "cleanup", node: n })),
|
|
2171
|
+
].sort((a, b) => (a.node.call.start ?? 0) - (b.node.call.start ?? 0));
|
|
2172
|
+
const emitSoloMount = (m) => entries.push(mappedSlice(preprocessed, ` useEffect(() => `, m.body.body, `, [])`));
|
|
2173
|
+
let openMount = null;
|
|
2174
|
+
for (const item of lifecycle) {
|
|
2175
|
+
if (item.kind === "mount") {
|
|
2176
|
+
if (openMount)
|
|
2177
|
+
emitSoloMount(openMount); // a previous mount with no cleanup
|
|
2178
|
+
openMount = item.node;
|
|
2179
|
+
}
|
|
2180
|
+
else if (openMount === null) {
|
|
2181
|
+
throw new Error(`[reactra:compile] R012: orphan cleanup in component "${c.name}" — a \`cleanup\` block has ` +
|
|
2182
|
+
`no preceding \`mount\` to pair with. A \`cleanup\` becomes the \`useEffect\` teardown return ` +
|
|
2183
|
+
`of the closest preceding \`mount\`; add a \`mount\`, or move the teardown into one. ` +
|
|
2184
|
+
`(Component DSL §3.2 / §14 R012.)`);
|
|
2185
|
+
}
|
|
2186
|
+
else {
|
|
2187
|
+
entries.push(emitMountWithCleanup(preprocessed, openMount, item.node));
|
|
2188
|
+
openMount = null;
|
|
2189
|
+
}
|
|
2190
|
+
}
|
|
2191
|
+
if (openMount)
|
|
2192
|
+
emitSoloMount(openMount); // trailing mount with no cleanup
|
|
2193
|
+
for (const e of c.effects) {
|
|
2194
|
+
if (e.watch) {
|
|
2195
|
+
entries.push(concatE([
|
|
2196
|
+
mappedSlice(preprocessed, ` useEffect(() => `, e.body.body, `, [`),
|
|
2197
|
+
mappedSlice(preprocessed, ``, e.watch.body, `])`),
|
|
2198
|
+
]));
|
|
2199
|
+
}
|
|
2200
|
+
else {
|
|
2201
|
+
entries.push(mappedSlice(preprocessed, ` useEffect(() => `, e.body.body, `, [])`));
|
|
2202
|
+
}
|
|
2203
|
+
}
|
|
2204
|
+
// G6 — Inline-handler auto-wrap (DSL v2 / Compiler §4 Pass 9).
|
|
2205
|
+
// Extract JSX event handlers containing reactive-state assignments from the
|
|
2206
|
+
// view body. The hoisted `useCallback` declarations are emitted here (after
|
|
2207
|
+
// all declared actions, before the test hook). The view substitutions are
|
|
2208
|
+
// forwarded to `viewBodyPieces` so the JSX references the hoisted name.
|
|
2209
|
+
// Invariant (S1 gate): synthesized names carry the `__` prefix and are NEVER
|
|
2210
|
+
// placed in `hookBindings`.
|
|
2211
|
+
const inlineHandlers = c.kind === "component" && c.view != null
|
|
2212
|
+
? extractInlineHandlers(preprocessed, c.view.body.body, stateNames, renamedStates)
|
|
2213
|
+
: { handlers: [], substs: [] };
|
|
2214
|
+
for (const h of inlineHandlers.handlers) {
|
|
2215
|
+
entries.push(plain(h.decl));
|
|
2216
|
+
}
|
|
2217
|
+
// The test-hook line (Testing spec §2 / Compiler §4 Pass 9 v2.18): the LAST
|
|
2218
|
+
// statement before the view return, on EVERY component, in every environment.
|
|
2219
|
+
// useEffect form is mandatory (commit-accurate, StrictMode-clean); the
|
|
2220
|
+
// bindings object follows the normative ordered assembly rule — props →
|
|
2221
|
+
// state → derived → behaviour exposedBindings (uses-source order) → actions
|
|
2222
|
+
// (emit order, suppressed setters excluded) → resources.
|
|
2223
|
+
//
|
|
2224
|
+
// A1 (allocation guard): the hook is read once into `h`, and the bindings
|
|
2225
|
+
// object is built ONLY when a hook is present (`if (h) h.update(...)`). With
|
|
2226
|
+
// `?.update`, the object-literal argument is evaluated BEFORE the optional
|
|
2227
|
+
// call short-circuits, so production (no hook installed) paid a per-render
|
|
2228
|
+
// allocation on the most-instantiated unit in the framework. The guard is a
|
|
2229
|
+
// pure runtime presence check (no build-time constant) — presence IS the
|
|
2230
|
+
// opt-in, so devtools/testing/replay still fire under bun/test the instant a
|
|
2231
|
+
// hook is installed. NO dep array: every-commit firing is load-bearing (the
|
|
2232
|
+
// devtools passive ring + testing `renderCount` depend on it).
|
|
2233
|
+
const hookBindings = [
|
|
2234
|
+
// propNames already carries the destructured names OR the bag identifier.
|
|
2235
|
+
...(c.propNames ?? []),
|
|
2236
|
+
...c.states.map((s) => s.name),
|
|
2237
|
+
...c.deriveds.map((d) => d.name),
|
|
2238
|
+
...behaviours.flatMap((b) => b.exposedBindings),
|
|
2239
|
+
...orderActionsForEmit(c, suppressedActions).map((a) => a.name),
|
|
2240
|
+
...c.resources.map((r) => r.name),
|
|
2241
|
+
];
|
|
2242
|
+
const hookObj = hookBindings.length > 0 ? `{ ${hookBindings.join(", ")} }` : `{}`;
|
|
2243
|
+
entries.push(plain(` useEffect(() => { const h = globalThis.__REACTRA_TEST__; if (h) h.update("${c.name}", ${hookObj}) })`));
|
|
2244
|
+
// View — return the JSX expression from inside the view's arrow body, with any
|
|
2245
|
+
// __reactra_await__ calls rewritten to Suspense+ErrorBoundary. The body is a
|
|
2246
|
+
// per-line `Piece` list, so each view line maps back to its own DSL line and
|
|
2247
|
+
// each await wrapper to its `await(...)` line (#10-followup §8, Session 4).
|
|
2248
|
+
//
|
|
2249
|
+
// Wave 3 §2b — when the component has `provide X with Y` declarations,
|
|
2250
|
+
// wrap the JSX return in <ServiceContext.Provider value={__reactraOverrides}>
|
|
2251
|
+
// so descendants' useService("X") calls see the override.
|
|
2252
|
+
const hasProvides = c.kind === "component" && c.provides.length > 0;
|
|
2253
|
+
if (c.view) {
|
|
2254
|
+
const viewPieces = trimViewPieces(viewBodyPieces(preprocessed, c.view.body.body, inlineHandlers.substs));
|
|
2255
|
+
// Wrap the view (outer → inner): `errorBoundary` → `suspense` → `provide`
|
|
2256
|
+
// Provider → view (Component DSL §7/§11). ErrorBoundary is outermost so it
|
|
2257
|
+
// also catches errors surfaced by a suspended child; the Provider is
|
|
2258
|
+
// innermost so the view + descendants see the service overrides. Without
|
|
2259
|
+
// this wrap a component-level `errorBoundary`/`suspense` was extracted but
|
|
2260
|
+
// never emitted (the boundary silently did nothing).
|
|
2261
|
+
const open = [];
|
|
2262
|
+
const close = [];
|
|
2263
|
+
const eb = c.errorBoundary;
|
|
2264
|
+
if (eb && eb.body.start != null) {
|
|
2265
|
+
// `errorBoundary(e) { J }` → `(e) => (<>J</>)`; that arrow IS the fallback
|
|
2266
|
+
// render-fn — ErrorBoundary renders `fallback(error)` (@reactra/resource).
|
|
2267
|
+
open.push({ kind: "gen", text: `<ErrorBoundary fallback={`, anchorSrc: eb.body.start });
|
|
2268
|
+
open.push({ kind: "verbatim", srcStart: eb.body.start, text: sliceNode(preprocessed, eb.body) });
|
|
2269
|
+
open.push({ kind: "gen", text: `}>`, anchorSrc: eb.body.end ?? eb.body.start });
|
|
2270
|
+
close.unshift({ kind: "gen", text: `</ErrorBoundary>`, anchorSrc: eb.call.start ?? eb.body.start });
|
|
2271
|
+
}
|
|
2272
|
+
const sus = c.suspense;
|
|
2273
|
+
if (sus && sus.body.body.start != null) {
|
|
2274
|
+
// `suspense { J }` → `() => (<>J</>)`; the Suspense fallback is the JSX
|
|
2275
|
+
// element inside the arrow, not the arrow itself.
|
|
2276
|
+
const sj = sus.body.body;
|
|
2277
|
+
open.push({ kind: "gen", text: `<Suspense fallback={`, anchorSrc: sj.start });
|
|
2278
|
+
open.push({ kind: "verbatim", srcStart: sj.start, text: sliceNode(preprocessed, sj) });
|
|
2279
|
+
open.push({ kind: "gen", text: `}>`, anchorSrc: sj.end ?? sj.start });
|
|
2280
|
+
close.unshift({ kind: "gen", text: `</Suspense>`, anchorSrc: sus.call.start ?? sj.start });
|
|
2281
|
+
}
|
|
2282
|
+
if (hasProvides) {
|
|
2283
|
+
const a = c.view.body.start ?? 0;
|
|
2284
|
+
open.push({ kind: "gen", text: `<ServiceContext.Provider value={__reactraOverrides}>`, anchorSrc: a });
|
|
2285
|
+
close.unshift({ kind: "gen", text: `</ServiceContext.Provider>`, anchorSrc: a });
|
|
2286
|
+
}
|
|
2287
|
+
entries.push(emitMappedPieces(` return (`, [...open, ...viewPieces, ...close], `)`));
|
|
2288
|
+
}
|
|
2289
|
+
else if (hasProvides) {
|
|
2290
|
+
// No view but provides exist — still emit the Provider so a
|
|
2291
|
+
// descendant rendered via children would see overrides. Phase 1
|
|
2292
|
+
// doesn't model `children` for `export component` but the shape is
|
|
2293
|
+
// future-proof.
|
|
2294
|
+
entries.push(plain(` return (<ServiceContext.Provider value={__reactraOverrides}>{null}</ServiceContext.Provider>)`));
|
|
2295
|
+
}
|
|
2296
|
+
else {
|
|
2297
|
+
entries.push(plain(` return null`));
|
|
2298
|
+
}
|
|
2299
|
+
entries.push(plain(`}`));
|
|
2300
|
+
if (plugins.length > 0) {
|
|
2301
|
+
// Wrap once, export twice (Behaviour Plugin §4): source order, outside-in —
|
|
2302
|
+
// `uses A, B` → A(B(X__impl)). The default-export tail and the
|
|
2303
|
+
// registerRouteBindings sibling both reference `X`, so every consumer gets
|
|
2304
|
+
// the wrapped component.
|
|
2305
|
+
const wrapped = plugins
|
|
2306
|
+
.slice()
|
|
2307
|
+
.reverse()
|
|
2308
|
+
.reduce((acc, p) => `${p.name}(${acc})`, implName);
|
|
2309
|
+
entries.push(plain(`${decl} ${c.name} = ${wrapped}`));
|
|
2310
|
+
}
|
|
2311
|
+
if (argumentedUses.length > 0) {
|
|
2312
|
+
// Sibling module-level side effect that registers this page's
|
|
2313
|
+
// onEnter/onExit under the component name. The router looks them up
|
|
2314
|
+
// via `route.bindingsName` which the walker fills in from the same
|
|
2315
|
+
// name (Day 16 / `#18b`).
|
|
2316
|
+
entries.push(plain(""));
|
|
2317
|
+
entries.push(plain(emitRegisterRouteBindings(c, argumentedUses)));
|
|
2318
|
+
}
|
|
2319
|
+
return joinE(entries, "\n");
|
|
2320
|
+
};
|
|
2321
|
+
/**
|
|
2322
|
+
* Inside a store action, the user writes `count = count + 1` directly.
|
|
2323
|
+
* The closure model preserves the bare assignment (closure vars are
|
|
2324
|
+
* mutable), so we just embed the user's arrow text unchanged and append a
|
|
2325
|
+
* `notify()` call before the closing brace.
|
|
2326
|
+
*
|
|
2327
|
+
* MVP limitation: a single `notify()` fires at the END of the body. Actions
|
|
2328
|
+
* with early returns won't notify on intermediate mutations. Multi-mutation
|
|
2329
|
+
* action bodies notify once total — which avoids over-rendering and matches
|
|
2330
|
+
* the "single dispatch per action" intent of the spec's middleware pipeline
|
|
2331
|
+
* (Store §8.1).
|
|
2332
|
+
*/
|
|
2333
|
+
const emitStoreAction = (preprocessed, a) => {
|
|
2334
|
+
const arrowText = sliceNode(preprocessed, a.arrow);
|
|
2335
|
+
const prefix = `const ${a.name} = `;
|
|
2336
|
+
if (a.arrow.start == null || arrowText.length === 0)
|
|
2337
|
+
return plain(`${prefix}${arrowText}`);
|
|
2338
|
+
const start = a.arrow.start;
|
|
2339
|
+
const lastBrace = arrowText.lastIndexOf("}");
|
|
2340
|
+
// The body is verbatim (store closures keep bare assignments); the single
|
|
2341
|
+
// `notify()` injected before the closing brace is a `gen` piece, so each source
|
|
2342
|
+
// line of the body still maps to its DSL line (#10-followup §8, Session 3).
|
|
2343
|
+
const pieces = lastBrace < 0
|
|
2344
|
+
? [{ kind: "verbatim", srcStart: start, text: arrowText }]
|
|
2345
|
+
: [
|
|
2346
|
+
{ kind: "verbatim", srcStart: start, text: arrowText.slice(0, lastBrace) },
|
|
2347
|
+
{ kind: "gen", text: `; notify(); `, anchorSrc: start + lastBrace },
|
|
2348
|
+
{ kind: "verbatim", srcStart: start + lastBrace, text: arrowText.slice(lastBrace) },
|
|
2349
|
+
];
|
|
2350
|
+
return emitMappedPieces(prefix, pieces, ``);
|
|
2351
|
+
};
|
|
2352
|
+
/**
|
|
2353
|
+
* Emit the snapshot composer's body — produces the object that consumers
|
|
2354
|
+
* destructure. Recomputes derived values on every call so they always
|
|
2355
|
+
* reflect the latest state. Statement order matches DSL declaration order
|
|
2356
|
+
* so later deriveds can read earlier ones.
|
|
2357
|
+
*/
|
|
2358
|
+
const emitStoreSnapshot = (preprocessed, c) => {
|
|
2359
|
+
const stateNames = c.states.map((s) => s.name);
|
|
2360
|
+
const actionNames = c.actions.map((a) => a.name);
|
|
2361
|
+
const fieldOrder = [
|
|
2362
|
+
...stateNames,
|
|
2363
|
+
...c.deriveds.map((d) => d.name),
|
|
2364
|
+
...actionNames,
|
|
2365
|
+
];
|
|
2366
|
+
const returnObj = `{ ${fieldOrder.join(", ")} }`;
|
|
2367
|
+
if (c.deriveds.length === 0) {
|
|
2368
|
+
return plain(` return () => (${returnObj})`);
|
|
2369
|
+
}
|
|
2370
|
+
// Each derived expression is a user-code slice mapped back to its DSL `derived`
|
|
2371
|
+
// line (#10-followup §8); the surrounding `return () => {…}` is boilerplate.
|
|
2372
|
+
const derivedLines = c.deriveds.map((d) => mappedSlice(preprocessed, ` const ${d.name} = `, d.thunk.body, ``));
|
|
2373
|
+
return joinE([
|
|
2374
|
+
plain(` return () => {`),
|
|
2375
|
+
...derivedLines,
|
|
2376
|
+
plain(` return ${returnObj}`),
|
|
2377
|
+
plain(` }`),
|
|
2378
|
+
], "\n");
|
|
2379
|
+
};
|
|
2380
|
+
/** Map the container kind to the StoreBinding.kind the runtime expects. */
|
|
2381
|
+
const storeBindingKind = (c) => {
|
|
2382
|
+
if (c.kind === "export-store")
|
|
2383
|
+
return "export";
|
|
2384
|
+
if (c.kind === "session-store")
|
|
2385
|
+
return "session";
|
|
2386
|
+
return "route";
|
|
2387
|
+
};
|
|
2388
|
+
/**
|
|
2389
|
+
* Emit a store container as a closure-based factory the user passes to
|
|
2390
|
+
* `configureStores`. Force-exported regardless of `c.exported`: a Phase-1
|
|
2391
|
+
* MVP simplification so single-file demos with `route store X` / `session
|
|
2392
|
+
* store X` can still be registered manually.
|
|
2393
|
+
*
|
|
2394
|
+
* Route-store shape (Day 10a): the factory accepts an `inputs` parameter
|
|
2395
|
+
* which the router's `StoreRegistry.instantiate` call supplies. The store's
|
|
2396
|
+
* `input X` declarations destructure off it. Export and session stores
|
|
2397
|
+
* ignore the parameter — their factories run eagerly at registration.
|
|
2398
|
+
*/
|
|
2399
|
+
/**
|
|
2400
|
+
* Resource v1 §8.1 (Compiler stage 2) — emit the `warm: async (inputs, signal)`
|
|
2401
|
+
* companion for a route store with one or more `resource` declarations. The
|
|
2402
|
+
* companion destructures the same `inputs` the factory does, then
|
|
2403
|
+
* `Promise.all`s a `warmResource(name, deps, fetcher, signal)` call per
|
|
2404
|
+
* declared resource — invoking each fetcher DIRECTLY (no React tree —
|
|
2405
|
+
* architect Q8), writing the result into `ResourceCache` under
|
|
2406
|
+
* `(resourceName, depsKey)` so a consumer's
|
|
2407
|
+
* `useResource(deps, fn, { resourceName: "X" })` (compiler stage 1) hits a
|
|
2408
|
+
* fresh cache entry on mount.
|
|
2409
|
+
*
|
|
2410
|
+
* **Scope rule (intentional MVP):** the fetcher closure has only the
|
|
2411
|
+
* destructured inputs in scope — not store state / derived / inject-acquired
|
|
2412
|
+
* services (those live inside `factory`'s `createStoreInstance(...)` closure,
|
|
2413
|
+
* which is a sibling field on the binding, not a parent of `warm`). A
|
|
2414
|
+
* fetcher that references an out-of-scope identifier produces a TypeScript
|
|
2415
|
+
* "Cannot find name" diagnostic at the user's call site, which is the right
|
|
2416
|
+
* signal — store-resources should depend only on inputs (the navigation-
|
|
2417
|
+
* derived data that uniquely keys the cache entry).
|
|
2418
|
+
*
|
|
2419
|
+
* Returns `[]` for stores with no resources OR for non-route stores
|
|
2420
|
+
* (session/export stores don't have route-scoped prefetch lifecycles).
|
|
2421
|
+
*/
|
|
2422
|
+
const emitStoreWarm = (preprocessed, c) => {
|
|
2423
|
+
if (c.resources.length === 0)
|
|
2424
|
+
return [];
|
|
2425
|
+
if (storeBindingKind(c) !== "route")
|
|
2426
|
+
return [];
|
|
2427
|
+
const destructureLine = c.inputs.length > 0
|
|
2428
|
+
? plain(` const { ${c.inputs.map((i) => i.name).join(", ")} } = inputs ?? {}`)
|
|
2429
|
+
: plain(` // no inputs to destructure`);
|
|
2430
|
+
// One call per resource — `warmResource(name, deps, fetcher, signal)`.
|
|
2431
|
+
// Deps is the existing depsThunk body (compiler stage 1 already maps it
|
|
2432
|
+
// for the consumer's useResource). Fetcher is emitted verbatim — its
|
|
2433
|
+
// closure references only the destructured `inputs` (see scope rule).
|
|
2434
|
+
const callLines = c.resources.map((r) => concatE([
|
|
2435
|
+
plain(` warmResource(${JSON.stringify(r.name)}, `),
|
|
2436
|
+
mappedSlice(preprocessed, ``, r.depsThunk.body, ``),
|
|
2437
|
+
plain(`, `),
|
|
2438
|
+
mappedSlice(preprocessed, ``, r.fetcher, ``),
|
|
2439
|
+
plain(`, signal),`),
|
|
2440
|
+
]));
|
|
2441
|
+
return [
|
|
2442
|
+
plain(` warm: async (inputs, signal) => {`),
|
|
2443
|
+
destructureLine,
|
|
2444
|
+
plain(` await Promise.all([`),
|
|
2445
|
+
...callLines,
|
|
2446
|
+
plain(` ])`),
|
|
2447
|
+
plain(` },`),
|
|
2448
|
+
];
|
|
2449
|
+
};
|
|
2450
|
+
const emitStore = (preprocessed, c) => {
|
|
2451
|
+
const kind = storeBindingKind(c);
|
|
2452
|
+
const isRoute = kind === "route";
|
|
2453
|
+
// S019 (warning — Store v4.0 §2.5 / §13): the store name must end in `Store`
|
|
2454
|
+
// so that `inject store fooStore` is self-describing in raw source (the suffix
|
|
2455
|
+
// is the signal that identifies a name as a store binding vs. a plain value).
|
|
2456
|
+
if (!c.name.endsWith("Store")) {
|
|
2457
|
+
console.warn(`[reactra:compile] S019: store "${c.name}" should end in the \`Store\` suffix ` +
|
|
2458
|
+
`(e.g. "${c.name}Store"). Because the binding is a plain value import, the suffix ` +
|
|
2459
|
+
`keeps \`inject store ${c.name}Store\` self-describing in raw source. ` +
|
|
2460
|
+
`Fixable by rename. (Store v4.0 §2.5 / §14 S019.)`);
|
|
2461
|
+
}
|
|
2462
|
+
// Behaviours are components-only in Stage 1. A compiler-native name on a
|
|
2463
|
+
// store is BHV001 (error — Undo §10 ULIM-01 / Replay §11 RLIM-06); a plugin
|
|
2464
|
+
// name is BHV003 (advisory — parsed, not wrapped; Behaviour Plugin §8/BLIM-03).
|
|
2465
|
+
// Numbers assigned by Behaviour Plugin spec §8; semantics live with the owners.
|
|
2466
|
+
for (const name of c.uses?.names ?? []) {
|
|
2467
|
+
if (NATIVE_BEHAVIOURS.has(name)) {
|
|
2468
|
+
const specRef = name === "undoable" ? "Undo spec §10 / ULIM-01" : "Replay spec §11 / RLIM-06";
|
|
2469
|
+
throw new Error(`[reactra:compile] BHV001: store "${c.name}" declares \`uses ${name}\`, but \`${name}\` is ` +
|
|
2470
|
+
`components-only in Stage 1 — store-level support is deferred (${specRef}). ` +
|
|
2471
|
+
`Move \`uses ${name}\` onto the component(s) consuming this store.`);
|
|
2472
|
+
}
|
|
2473
|
+
console.warn(`[reactra:compile] BHV003: store "${c.name}" declares \`uses ${name}\` — store-level ` +
|
|
2474
|
+
`plugin behaviours are not wrapped in Stage 1; the directive is currently inert. ` +
|
|
2475
|
+
`(Behaviour Plugin spec §8 / BLIM-03.)`);
|
|
2476
|
+
}
|
|
2477
|
+
// `preserved state` validations (Store §6 / §13). S010 (non-serializable
|
|
2478
|
+
// declared type) is a type-level/LSP concern — the Babel pass can't walk types
|
|
2479
|
+
// (same boundary as S015); these are the AST-checkable ones.
|
|
2480
|
+
if (c.preserved && c.preserved.fields.length > 0) {
|
|
2481
|
+
if (!isRoute) {
|
|
2482
|
+
throw new Error(`[reactra:compile] S011: \`preserved state\` is only valid in a \`route store\` ` +
|
|
2483
|
+
`(store "${c.name}" is ${kind}). Back-button persistence is route-scoped (Store §6.2).`);
|
|
2484
|
+
}
|
|
2485
|
+
const stateNames = new Set(c.states.map((s) => s.name));
|
|
2486
|
+
const derivedNames = new Set(c.deriveds.map((d) => d.name));
|
|
2487
|
+
for (const f of c.preserved.fields) {
|
|
2488
|
+
if (derivedNames.has(f)) {
|
|
2489
|
+
throw new Error(`[reactra:compile] S013: \`preserved state ${f}\` names a \`derived\` in store "${c.name}". ` +
|
|
2490
|
+
`A derived is recomputed from state — preserve the underlying \`state\` instead (Store §6.2).`);
|
|
2491
|
+
}
|
|
2492
|
+
if (!stateNames.has(f)) {
|
|
2493
|
+
throw new Error(`[reactra:compile] S012: \`preserved state ${f}\` names an unknown field in store "${c.name}". ` +
|
|
2494
|
+
`Each name must be a declared \`state\` field (Store §6.2).`);
|
|
2495
|
+
}
|
|
2496
|
+
}
|
|
2497
|
+
}
|
|
2498
|
+
// Route stores destructure inputs at the top of the factory closure so
|
|
2499
|
+
// state initialisers, derived expressions, and actions all see the
|
|
2500
|
+
// input identifiers by bare name (Router §4.2).
|
|
2501
|
+
const inputDestructure = isRoute && c.inputs.length > 0
|
|
2502
|
+
? [plain(` const { ${c.inputs.map((i) => i.name).join(", ")} } = inputs ?? {}`)]
|
|
2503
|
+
: [];
|
|
2504
|
+
// Inject lookups next so state initialisers / actions can reference them.
|
|
2505
|
+
const injectLines = c.injects.map((inj) => plain(` ${emitInjectLookup(inj, c.kind)}`));
|
|
2506
|
+
// State initialiser + action body are user-code slices, mapped back to their
|
|
2507
|
+
// DSL lines (#10-followup §8); the factory scaffolding around them is plain.
|
|
2508
|
+
const initLines = c.states.map((s) => mappedSlice(preprocessed, ` let ${s.name} = `, s.initializer, ``));
|
|
2509
|
+
// Preserved-state restore (Store §6): a route store's `preserved state X, Y`
|
|
2510
|
+
// overwrites those `state` lets from the `restored` factory arg (read back from
|
|
2511
|
+
// sessionStorage by StoreRegistry.instantiate). `as typeof X` keeps each write
|
|
2512
|
+
// typed to the field — the only legal direct-state write outside an action.
|
|
2513
|
+
const preservedFields = c.preserved?.fields ?? [];
|
|
2514
|
+
const restoreLines = preservedFields.length > 0
|
|
2515
|
+
? [
|
|
2516
|
+
plain(` if (restored) {`),
|
|
2517
|
+
...preservedFields.map((f) => plain(` if (${JSON.stringify(f)} in restored) ${f} = restored[${JSON.stringify(f)}] as typeof ${f}`)),
|
|
2518
|
+
plain(` }`),
|
|
2519
|
+
]
|
|
2520
|
+
: [];
|
|
2521
|
+
const actionLines = c.actions.map((a) => concatE([plain(` `), emitStoreAction(preprocessed, a)]));
|
|
2522
|
+
// Devtools time-travel restore closure (plan store-time-travel §3.1): register
|
|
2523
|
+
// a name-keyed writer that drives this store's `state` lets back to a past
|
|
2524
|
+
// snapshot. ONLY `state` fields are assignable (derived recompute on notify;
|
|
2525
|
+
// actions/inputs aren't state). Mirrors the `preserved state` restore's
|
|
2526
|
+
// `as typeof <field>` write — the same blessed outside-an-action direct write
|
|
2527
|
+
// (architect C1). Always-emit (matches the unconditional __REACTRA_TEST__
|
|
2528
|
+
// hook); a store with zero state fields emits no call at all. `notify` is the
|
|
2529
|
+
// factory's existing wake.
|
|
2530
|
+
const restoreRegistration = c.states.length > 0
|
|
2531
|
+
? [
|
|
2532
|
+
plain(` __registerStoreRestore(${JSON.stringify(c.name)}, (next) => {`),
|
|
2533
|
+
plain(` let __written = 0`),
|
|
2534
|
+
...c.states.map((s) => plain(` if (${JSON.stringify(s.name)} in next) { ${s.name} = next[${JSON.stringify(s.name)}] as typeof ${s.name}; __written++ }`)),
|
|
2535
|
+
plain(` notify()`),
|
|
2536
|
+
plain(` return __written`),
|
|
2537
|
+
plain(` })`),
|
|
2538
|
+
]
|
|
2539
|
+
: [];
|
|
2540
|
+
const snapshot = emitStoreSnapshot(preprocessed, c);
|
|
2541
|
+
const factorySig = isRoute ? (preservedFields.length > 0 ? "(inputs, restored)" : "(inputs)") : "()";
|
|
2542
|
+
const preservedFieldsLine = preservedFields.length > 0
|
|
2543
|
+
? [plain(` preservedFields: [${preservedFields.map((f) => JSON.stringify(f)).join(", ")}],`)]
|
|
2544
|
+
: [];
|
|
2545
|
+
// Wave 3 §2b — `route store ... for subtree "/path"`. When present on the
|
|
2546
|
+
// ContainerNode (populated by Pass 2 from `__reactra_route_store_subtree__`),
|
|
2547
|
+
// emit `subtreePath: "/path",` on the binding so the runtime can skip-
|
|
2548
|
+
// dispose + skip-rebuild across pages inside the subtree.
|
|
2549
|
+
const subtreePathLine = c.subtreePath !== undefined
|
|
2550
|
+
? [plain(` subtreePath: ${JSON.stringify(c.subtreePath)},`)]
|
|
2551
|
+
: [];
|
|
2552
|
+
// Stage 2 compiler — emit the `warm(inputs, signal)` companion AFTER the
|
|
2553
|
+
// factory's closing `}),` so the binding object literal stays valid even
|
|
2554
|
+
// when the factory or warm has multi-line bodies. Empty array for non-
|
|
2555
|
+
// route stores or stores with no resources (silent no-op at runtime —
|
|
2556
|
+
// `StoreRegistry.warm` treats absence as a no-op).
|
|
2557
|
+
const warmField = emitStoreWarm(preprocessed, c);
|
|
2558
|
+
return joinE([
|
|
2559
|
+
plain(`export const ${c.name} = {`),
|
|
2560
|
+
plain(` name: "${c.name}",`),
|
|
2561
|
+
plain(` kind: "${kind}",`),
|
|
2562
|
+
...preservedFieldsLine,
|
|
2563
|
+
...subtreePathLine,
|
|
2564
|
+
plain(` factory: ${factorySig} => createStoreInstance((notify) => {`),
|
|
2565
|
+
...inputDestructure,
|
|
2566
|
+
...injectLines,
|
|
2567
|
+
...initLines,
|
|
2568
|
+
...restoreLines,
|
|
2569
|
+
...actionLines,
|
|
2570
|
+
...restoreRegistration,
|
|
2571
|
+
snapshot,
|
|
2572
|
+
plain(` }),`),
|
|
2573
|
+
...warmField,
|
|
2574
|
+
plain(`}`),
|
|
2575
|
+
// Store §2.5 — emit the public-surface type alias next to the binding so a
|
|
2576
|
+
// cross-file consumer's `import type { <name> }` resolves the live surface
|
|
2577
|
+
// (state + derived + actions; inputs are excluded — they're factory params,
|
|
2578
|
+
// not in the snapshot). `StoreSurface` infers it from the factory above, so
|
|
2579
|
+
// it stays in lockstep by construction. A type alias and a const may share
|
|
2580
|
+
// a name in TypeScript (separate namespaces), so this does not clash with
|
|
2581
|
+
// `export const ${c.name}`.
|
|
2582
|
+
plain(`export type ${c.name} = StoreSurface<typeof ${c.name}>`),
|
|
2583
|
+
], "\n");
|
|
2584
|
+
};
|
|
2585
|
+
/**
|
|
2586
|
+
* Inside a service action the user writes plain TypeScript — no reactive
|
|
2587
|
+
* subscription, no notify call. The factory closure owns any state vars as
|
|
2588
|
+
* mutable `let` bindings, so bare assignments are valid as-written. The
|
|
2589
|
+
* user's arrow text is sliced unchanged.
|
|
2590
|
+
*
|
|
2591
|
+
* MVP simplification matching the Service spec's "stateless callable units"
|
|
2592
|
+
* framing (§1): no per-action middleware, no transition wrapping (that lives
|
|
2593
|
+
* in components — Service spec §9.2), no AbortSignal injection yet.
|
|
2594
|
+
*/
|
|
2595
|
+
const emitServiceAction = (preprocessed, a) => mappedSlice(preprocessed, `const ${a.name} = `, a.arrow, ``);
|
|
2596
|
+
/**
|
|
2597
|
+
* Emit a service container as a Strategy A `{ name, factory }` binding the
|
|
2598
|
+
* user passes to `configureServices`. The factory runs once at registration
|
|
2599
|
+
* (eagerly, so service-to-service `inject` lookups resolve in declaration
|
|
2600
|
+
* order); the returned surface exposes the action names — the service's
|
|
2601
|
+
* public methods.
|
|
2602
|
+
*
|
|
2603
|
+
* Force-exported regardless of `c.exported`: a Phase-1 MVP simplification so
|
|
2604
|
+
* single-file demos can register the binding manually. Same convention as
|
|
2605
|
+
* Day 9b stores.
|
|
2606
|
+
*
|
|
2607
|
+
* MVP scope:
|
|
2608
|
+
* - `inject X` → `const X = ServiceRegistry.get("X")` at top
|
|
2609
|
+
* - `state X = init` → private closure `let X = init` (not exposed)
|
|
2610
|
+
* - `derived X = expr` → private closure `const X = expr` evaluated once
|
|
2611
|
+
* at factory time; not reactive (use a store if
|
|
2612
|
+
* reactivity is needed)
|
|
2613
|
+
* - `action X(...) {}` → `const X = (...) => {}` exposed in the surface
|
|
2614
|
+
* - `provide`, `scoped`, `server` modifiers → ignored, Wave 3
|
|
2615
|
+
*/
|
|
2616
|
+
const emitService = (preprocessed, c) => {
|
|
2617
|
+
const injectLines = c.injects.map((inj) => plain(` ${emitInjectLookup(inj, c.kind)}`));
|
|
2618
|
+
// State init, derived expression, and action body are user-code slices mapped
|
|
2619
|
+
// back to their DSL lines (#10-followup §8); the factory shell is plain.
|
|
2620
|
+
const stateLines = c.states.map((s) => mappedSlice(preprocessed, ` let ${s.name} = `, s.initializer, ``));
|
|
2621
|
+
const derivedLines = c.deriveds.map((d) => mappedSlice(preprocessed, ` const ${d.name} = `, d.thunk.body, ``));
|
|
2622
|
+
const actionLines = c.actions.map((a) => concatE([plain(` `), emitServiceAction(preprocessed, a)]));
|
|
2623
|
+
const surface = c.actions.map((a) => a.name);
|
|
2624
|
+
const returnObj = `{ ${surface.join(", ")} }`;
|
|
2625
|
+
// Wave 3 §2b — scoped services emit `scoped: true` on the binding so
|
|
2626
|
+
// the runtime knows NOT to instantiate at `configureServices` time
|
|
2627
|
+
// (instantiate happens via `ServiceRegistry.activateScopedServices`
|
|
2628
|
+
// wired into the router lifecycle).
|
|
2629
|
+
const scopedField = c.serviceModifier === "scoped" ? [plain(` scoped: true,`)] : [];
|
|
2630
|
+
return joinE([
|
|
2631
|
+
plain(`export const ${c.name} = {`),
|
|
2632
|
+
plain(` name: "${c.name}",`),
|
|
2633
|
+
...scopedField,
|
|
2634
|
+
plain(` factory: () => createServiceInstance(() => {`),
|
|
2635
|
+
...injectLines,
|
|
2636
|
+
...stateLines,
|
|
2637
|
+
...derivedLines,
|
|
2638
|
+
...actionLines,
|
|
2639
|
+
plain(` return ${returnObj}`),
|
|
2640
|
+
plain(` }),`),
|
|
2641
|
+
plain(`}`),
|
|
2642
|
+
], "\n");
|
|
2643
|
+
};
|
|
2644
|
+
export const codegen = (graph) => {
|
|
2645
|
+
const reactImports = collectReactImports(graph);
|
|
2646
|
+
const resourceImports = collectResourceImports(graph);
|
|
2647
|
+
const storeImports = collectStoreImports(graph);
|
|
2648
|
+
const serviceImports = collectServiceImports(graph);
|
|
2649
|
+
const routerImports = collectRouterImports(graph);
|
|
2650
|
+
const importLines = [];
|
|
2651
|
+
// User-written imports come first so their order matches the source.
|
|
2652
|
+
// The compiler-injected imports below can never collide with these —
|
|
2653
|
+
// they're scoped to react / @reactra/* package specifiers, none of
|
|
2654
|
+
// which a user would re-import as a named import for their own use.
|
|
2655
|
+
for (const imp of graph.userImports) {
|
|
2656
|
+
importLines.push(imp);
|
|
2657
|
+
}
|
|
2658
|
+
if (reactImports.size > 0) {
|
|
2659
|
+
importLines.push(`import { ${[...reactImports].sort().join(", ")} } from "react"`);
|
|
2660
|
+
}
|
|
2661
|
+
if (resourceImports.size > 0) {
|
|
2662
|
+
importLines.push(`import { ${[...resourceImports].sort().join(", ")} } from "@reactra/resource"`);
|
|
2663
|
+
}
|
|
2664
|
+
if (storeImports.size > 0) {
|
|
2665
|
+
importLines.push(`import { ${[...storeImports].sort().join(", ")} } from "@reactra/store"`);
|
|
2666
|
+
}
|
|
2667
|
+
if (serviceImports.size > 0) {
|
|
2668
|
+
importLines.push(`import { ${[...serviceImports].sort().join(", ")} } from "@reactra/service"`);
|
|
2669
|
+
}
|
|
2670
|
+
if (routerImports.size > 0) {
|
|
2671
|
+
importLines.push(`import { ${[...routerImports].sort().join(", ")} } from "@reactra/router"`);
|
|
2672
|
+
}
|
|
2673
|
+
// Compiler-native behaviour runtimes — one subpath import per behaviour used
|
|
2674
|
+
// anywhere in the file (Pass 9.1 registry; Undo §4/§5, Replay §4/§5). Keyed by
|
|
2675
|
+
// name so a behaviour used by several components imports once; first-occurrence
|
|
2676
|
+
// order across containers is deterministic.
|
|
2677
|
+
const usedBehaviours = new Map();
|
|
2678
|
+
for (const c of graph.containers) {
|
|
2679
|
+
for (const b of matchedNativeBehaviours(c))
|
|
2680
|
+
usedBehaviours.set(b.name, b);
|
|
2681
|
+
}
|
|
2682
|
+
for (const b of usedBehaviours.values()) {
|
|
2683
|
+
importLines.push(`import { ${b.runtime.hook} } from "${b.runtime.module}"`);
|
|
2684
|
+
}
|
|
2685
|
+
// First-party plugin behaviours auto-import from their compiler-constant path
|
|
2686
|
+
// (Behaviour Plugin §3 row 2); third-party names ride the user's own import.
|
|
2687
|
+
// Components only — a store's plugin name is the BHV003 advisory, never a wrap.
|
|
2688
|
+
const usedFirstPartyPlugins = new Set();
|
|
2689
|
+
for (const c of graph.containers) {
|
|
2690
|
+
if (c.kind !== "component")
|
|
2691
|
+
continue;
|
|
2692
|
+
for (const p of resolvePluginBehaviours(c, graph)) {
|
|
2693
|
+
if (p.firstParty)
|
|
2694
|
+
usedFirstPartyPlugins.add(p.name);
|
|
2695
|
+
}
|
|
2696
|
+
}
|
|
2697
|
+
for (const name of usedFirstPartyPlugins) {
|
|
2698
|
+
importLines.push(`import { ${name} } from "@reactra/behaviours/${name}"`);
|
|
2699
|
+
}
|
|
2700
|
+
const importBlock = importLines.length > 0 ? `${importLines.join("\n")}\n\n` : "";
|
|
2701
|
+
// §1.1 — preserve the file's own top-level `interface`/`type` declarations
|
|
2702
|
+
// (e.g. a component's props type), emitted after the imports and before the
|
|
2703
|
+
// container code. Type-level only — no runtime, order-independent.
|
|
2704
|
+
const typeBlock = graph.userTypes.length > 0 ? `${graph.userTypes.join("\n\n")}\n\n` : "";
|
|
2705
|
+
// Wave 3 §2b Stage 4 — `"use server"` directive prepended at the top of
|
|
2706
|
+
// the emitted module when any service in this file carries the `server`
|
|
2707
|
+
// modifier (`export service server X { ... }`). Per the spec / React 19,
|
|
2708
|
+
// a file-level `"use server"` directive marks every exported function as
|
|
2709
|
+
// a server action, so this file is intended to contain ONLY server
|
|
2710
|
+
// services. Mixing a server service with a client component in one file
|
|
2711
|
+
// is the user's call — the directive applies file-wide and would mark
|
|
2712
|
+
// the component's exported function as a server action too, which is
|
|
2713
|
+
// almost never what they want. Documented as a user-side constraint
|
|
2714
|
+
// rather than a compiler error so the surface stays small for Phase 1.
|
|
2715
|
+
const hasServerService = graph.containers.some((c) => c.kind === "service" && c.serviceModifier === "server");
|
|
2716
|
+
const useServerDirective = hasServerService ? `"use server"\n\n` : "";
|
|
2717
|
+
// Components, stores, and services all carry source marks for their user-code
|
|
2718
|
+
// slices (#10-followup §8 — components S1, stores/services S2). The remaining
|
|
2719
|
+
// unmapped slices are per-character setter-rewrite sub-spans (S3).
|
|
2720
|
+
const blocks = [];
|
|
2721
|
+
for (const c of graph.containers) {
|
|
2722
|
+
if (c.kind === "component") {
|
|
2723
|
+
blocks.push(emitComponent(graph.preprocessed, graph, c));
|
|
2724
|
+
}
|
|
2725
|
+
else if (isStoreContainer(c)) {
|
|
2726
|
+
blocks.push(emitStore(graph.preprocessed, c));
|
|
2727
|
+
}
|
|
2728
|
+
else if (c.kind === "service") {
|
|
2729
|
+
blocks.push(emitService(graph.preprocessed, c));
|
|
2730
|
+
}
|
|
2731
|
+
}
|
|
2732
|
+
// Day 21 / `#18`: auto-default-export. When a file has exactly one
|
|
2733
|
+
// `export component Foo`, emit `export default Foo` after the named
|
|
2734
|
+
// export. Mirrors Router §3.1 (pages are default-exported) and lets
|
|
2735
|
+
// the walker drop `pickPageComponent`'s function-finding heuristic
|
|
2736
|
+
// in favour of clean `import PageX from "..."` lines.
|
|
2737
|
+
// Files with zero or multiple exported components get no default —
|
|
2738
|
+
// multiple defaults would be a parse error, and Phase 1's Compiler §1
|
|
2739
|
+
// says at most one `export component` per file anyway.
|
|
2740
|
+
const exportedComponents = graph.containers.filter((c) => c.kind === "component" && c.exported);
|
|
2741
|
+
const defaultExport = exportedComponents.length === 1 ? `\nexport default ${exportedComponents[0].name}\n` : "";
|
|
2742
|
+
const tail = hasHmrTargets(graph) ? "\n\n" + emitHmrBlock(graph) + "\n" : "\n";
|
|
2743
|
+
// Assemble via concatE so the marks' offsets track into the final string;
|
|
2744
|
+
// `.text` is byte-identical to the old `importBlock + blocks.join("\n\n") + …`.
|
|
2745
|
+
const out = concatE([
|
|
2746
|
+
plain(useServerDirective),
|
|
2747
|
+
plain(importBlock),
|
|
2748
|
+
plain(typeBlock),
|
|
2749
|
+
joinE(blocks, "\n\n"),
|
|
2750
|
+
plain(defaultExport),
|
|
2751
|
+
plain(tail),
|
|
2752
|
+
]);
|
|
2753
|
+
return { code: out.text, marks: out.marks };
|
|
2754
|
+
};
|
|
2755
|
+
//# sourceMappingURL=pass-9-codegen.js.map
|