@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.
Files changed (99) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +17 -0
  3. package/dist/ast/index.d.ts +2 -0
  4. package/dist/ast/index.d.ts.map +1 -0
  5. package/dist/ast/index.js +3 -0
  6. package/dist/ast/index.js.map +1 -0
  7. package/dist/ast/nodes.d.ts +437 -0
  8. package/dist/ast/nodes.d.ts.map +1 -0
  9. package/dist/ast/nodes.js +35 -0
  10. package/dist/ast/nodes.js.map +1 -0
  11. package/dist/behaviours/index.d.ts +18 -0
  12. package/dist/behaviours/index.d.ts.map +1 -0
  13. package/dist/behaviours/index.js +36 -0
  14. package/dist/behaviours/index.js.map +1 -0
  15. package/dist/behaviours/plugin.d.ts +22 -0
  16. package/dist/behaviours/plugin.d.ts.map +1 -0
  17. package/dist/behaviours/plugin.js +70 -0
  18. package/dist/behaviours/plugin.js.map +1 -0
  19. package/dist/behaviours/replayable.d.ts +10 -0
  20. package/dist/behaviours/replayable.d.ts.map +1 -0
  21. package/dist/behaviours/replayable.js +86 -0
  22. package/dist/behaviours/replayable.js.map +1 -0
  23. package/dist/behaviours/types.d.ts +77 -0
  24. package/dist/behaviours/types.d.ts.map +1 -0
  25. package/dist/behaviours/types.js +10 -0
  26. package/dist/behaviours/types.js.map +1 -0
  27. package/dist/behaviours/undoable.d.ts +10 -0
  28. package/dist/behaviours/undoable.d.ts.map +1 -0
  29. package/dist/behaviours/undoable.js +62 -0
  30. package/dist/behaviours/undoable.js.map +1 -0
  31. package/dist/compile.d.ts +69 -0
  32. package/dist/compile.d.ts.map +1 -0
  33. package/dist/compile.js +75 -0
  34. package/dist/compile.js.map +1 -0
  35. package/dist/conventions/index.d.ts +110 -0
  36. package/dist/conventions/index.d.ts.map +1 -0
  37. package/dist/conventions/index.js +193 -0
  38. package/dist/conventions/index.js.map +1 -0
  39. package/dist/index.d.ts +48 -0
  40. package/dist/index.d.ts.map +1 -0
  41. package/dist/index.js +77 -0
  42. package/dist/index.js.map +1 -0
  43. package/dist/passes/index.d.ts +5 -0
  44. package/dist/passes/index.d.ts.map +1 -0
  45. package/dist/passes/index.js +6 -0
  46. package/dist/passes/index.js.map +1 -0
  47. package/dist/passes/pass-1-parse.d.ts +3 -0
  48. package/dist/passes/pass-1-parse.d.ts.map +1 -0
  49. package/dist/passes/pass-1-parse.js +21 -0
  50. package/dist/passes/pass-1-parse.js.map +1 -0
  51. package/dist/passes/pass-2-extract.d.ts +4 -0
  52. package/dist/passes/pass-2-extract.d.ts.map +1 -0
  53. package/dist/passes/pass-2-extract.js +762 -0
  54. package/dist/passes/pass-2-extract.js.map +1 -0
  55. package/dist/passes/pass-3-readset.d.ts +11 -0
  56. package/dist/passes/pass-3-readset.d.ts.map +1 -0
  57. package/dist/passes/pass-3-readset.js +338 -0
  58. package/dist/passes/pass-3-readset.js.map +1 -0
  59. package/dist/passes/pass-9-codegen.d.ts +27 -0
  60. package/dist/passes/pass-9-codegen.d.ts.map +1 -0
  61. package/dist/passes/pass-9-codegen.js +2755 -0
  62. package/dist/passes/pass-9-codegen.js.map +1 -0
  63. package/dist/preprocess/helpers.d.ts +71 -0
  64. package/dist/preprocess/helpers.d.ts.map +1 -0
  65. package/dist/preprocess/helpers.js +342 -0
  66. package/dist/preprocess/helpers.js.map +1 -0
  67. package/dist/preprocess/index.d.ts +6 -0
  68. package/dist/preprocess/index.d.ts.map +1 -0
  69. package/dist/preprocess/index.js +11 -0
  70. package/dist/preprocess/index.js.map +1 -0
  71. package/dist/preprocess/keywords.d.ts +28 -0
  72. package/dist/preprocess/keywords.d.ts.map +1 -0
  73. package/dist/preprocess/keywords.js +99 -0
  74. package/dist/preprocess/keywords.js.map +1 -0
  75. package/dist/preprocess/lexer.d.ts +8 -0
  76. package/dist/preprocess/lexer.d.ts.map +1 -0
  77. package/dist/preprocess/lexer.js +143 -0
  78. package/dist/preprocess/lexer.js.map +1 -0
  79. package/dist/preprocess/preprocess.d.ts +3 -0
  80. package/dist/preprocess/preprocess.d.ts.map +1 -0
  81. package/dist/preprocess/preprocess.js +568 -0
  82. package/dist/preprocess/preprocess.js.map +1 -0
  83. package/dist/preprocess/rewriters.d.ts +35 -0
  84. package/dist/preprocess/rewriters.d.ts.map +1 -0
  85. package/dist/preprocess/rewriters.js +1391 -0
  86. package/dist/preprocess/rewriters.js.map +1 -0
  87. package/dist/preprocess/source-map.d.ts +70 -0
  88. package/dist/preprocess/source-map.d.ts.map +1 -0
  89. package/dist/preprocess/source-map.js +253 -0
  90. package/dist/preprocess/source-map.js.map +1 -0
  91. package/dist/preprocess/types.d.ts +57 -0
  92. package/dist/preprocess/types.d.ts.map +1 -0
  93. package/dist/preprocess/types.js +7 -0
  94. package/dist/preprocess/types.js.map +1 -0
  95. package/dist/sidecar.d.ts +137 -0
  96. package/dist/sidecar.d.ts.map +1 -0
  97. package/dist/sidecar.js +172 -0
  98. package/dist/sidecar.js.map +1 -0
  99. 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