@rrjs/babel-plugin 0.1.1 → 0.2.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.
@@ -0,0 +1,2004 @@
1
+ import * as t from '@babel/types';
2
+ // This pass accepts a deliberately checked subset. It never introduces a
3
+ // component render loop, React fallback, or old/new tree comparison.
4
+ export function compileRunOnce(program, derive, choose, rendererHelper, moduleMetadata) {
5
+ const reactive = new Map();
6
+ const generated = new Set();
7
+ const deferredRemovals = [];
8
+ const components = new Set();
9
+ const stateSetters = new Map();
10
+ const operationStates = new Set();
11
+ const mapUpdaters = new Map();
12
+ const importedOperationEdges = [];
13
+ const recordMapUpdater = (state, mapper) => {
14
+ const updaters = mapUpdaters.get(state) ?? [];
15
+ updaters.push(t.cloneNode(mapper, true));
16
+ mapUpdaters.set(state, updaters);
17
+ };
18
+ const mapperPreservesProperty = (mapper, property) => {
19
+ if (!t.isArrowFunctionExpression(mapper) && !t.isFunctionExpression(mapper))
20
+ return false;
21
+ const item = mapper.params[0];
22
+ if (!t.isIdentifier(item))
23
+ return false;
24
+ const preserves = (value) => {
25
+ if (t.isIdentifier(value, { name: item.name }))
26
+ return true;
27
+ if (t.isConditionalExpression(value))
28
+ return preserves(value.consequent) && preserves(value.alternate);
29
+ if (!t.isObjectExpression(value))
30
+ return false;
31
+ let lastIdentityWrite = -1;
32
+ let lastPropertyWrite = -1;
33
+ let unknownSpreadAfterIdentity = false;
34
+ value.properties.forEach((entry, index) => {
35
+ if (t.isSpreadElement(entry)) {
36
+ if (t.isIdentifier(entry.argument, { name: item.name }))
37
+ lastIdentityWrite = index;
38
+ else if (lastIdentityWrite >= 0)
39
+ unknownSpreadAfterIdentity = true;
40
+ return;
41
+ }
42
+ if (!t.isObjectProperty(entry) || entry.computed)
43
+ return;
44
+ const name = t.isIdentifier(entry.key) ? entry.key.name
45
+ : t.isStringLiteral(entry.key) ? entry.key.value : undefined;
46
+ if (name === property)
47
+ lastPropertyWrite = index;
48
+ });
49
+ return lastIdentityWrite >= 0 && lastPropertyWrite < lastIdentityWrite && !unknownSpreadAfterIdentity;
50
+ };
51
+ if (!t.isBlockStatement(mapper.body))
52
+ return preserves(mapper.body);
53
+ const returns = [];
54
+ t.traverseFast(mapper.body, node => { if (t.isReturnStatement(node))
55
+ returns.push(node); });
56
+ return returns.length > 0 && returns.every(statement => preserves(statement.argument));
57
+ };
58
+ // Ordinary React source often writes `function C(props)` and reads
59
+ // `props.title` rather than destructuring in the parameter list. Both forms
60
+ // carry the same information, so normalise the identifier form into the
61
+ // destructured one before any analysis runs. Everything downstream then sees
62
+ // the single shape it already supports, with no new reactive machinery.
63
+ //
64
+ // Only provably simple uses are rewritten. If `props` escapes -- spread,
65
+ // passed as a value, computed access, or written to -- the parameter is left
66
+ // alone so the existing diagnostics still reject it rather than this pass
67
+ // silently guessing.
68
+ program.traverse({
69
+ Function(path) {
70
+ const params = path.get('params');
71
+ const first = params[0];
72
+ if (!first?.isIdentifier())
73
+ return;
74
+ if (params.length > 2)
75
+ return;
76
+ // Only component functions. A capitalised binding name is the boundary
77
+ // React itself uses, and it keeps list mappers such as
78
+ // `item => <li>{item.text}</li>` out of this rewrite -- their parameter
79
+ // is data, not a props object.
80
+ const declared = path.isFunctionDeclaration() ? path.node.id?.name
81
+ : path.parentPath?.isVariableDeclarator() && t.isIdentifier(path.parentPath.node.id)
82
+ ? path.parentPath.node.id.name : undefined;
83
+ if (!declared || !/^[A-Z]/.test(declared))
84
+ return;
85
+ let hasJsx = false;
86
+ path.traverse({ JSXElement() { hasJsx = true; }, JSXFragment() { hasJsx = true; } });
87
+ if (!hasJsx)
88
+ return;
89
+ const binding = path.scope.getBinding(first.node.name);
90
+ if (!binding || !binding.constant)
91
+ return;
92
+ const reads = [];
93
+ const keys = new Set();
94
+ for (const reference of binding.referencePaths) {
95
+ const parent = reference.parentPath;
96
+ if (!parent?.isMemberExpression() || parent.node.object !== reference.node
97
+ || parent.node.computed || !t.isIdentifier(parent.node.property))
98
+ return;
99
+ const grand = parent.parentPath;
100
+ if (grand?.isAssignmentExpression() && grand.node.left === parent.node)
101
+ return;
102
+ if (grand?.isUpdateExpression() || grand?.isUnaryExpression({ operator: 'delete' }))
103
+ return;
104
+ reads.push(parent);
105
+ keys.add(parent.node.property.name);
106
+ }
107
+ if (binding.constantViolations.length || keys.size === 0)
108
+ return;
109
+ const locals = new Map();
110
+ for (const key of keys) {
111
+ const free = !path.scope.hasBinding(key) && !path.scope.parent?.hasBinding(key);
112
+ locals.set(key, t.identifier(free ? key : path.scope.generateUid(key)));
113
+ }
114
+ first.replaceWith(t.objectPattern([...locals].map(([key, local]) => t.objectProperty(t.identifier(key), t.cloneNode(local, true), false, key === local.name))));
115
+ for (const read of reads) {
116
+ read.replaceWith(t.cloneNode(locals.get(read.node.property.name), true));
117
+ }
118
+ path.scope.crawl();
119
+ },
120
+ });
121
+ // `const rows = items.map(render); return <ul>{rows}</ul>` is the same list as
122
+ // writing the map inline, just named. Naming it must not cost the direct list
123
+ // operations, so inline the single JSX use and let the list machinery see the
124
+ // shape it already compiles. The JSX ban on derived bindings stays: a mapper
125
+ // inside a derive would rebuild every row when a dependency changed, which is
126
+ // the reconciliation this compiler exists to avoid.
127
+ //
128
+ // Only the unambiguous case is inlined -- one reference, used as a JSX child,
129
+ // in the return statement that directly follows the declaration -- so the map
130
+ // still runs exactly where it used to.
131
+ program.traverse({
132
+ VariableDeclarator(path) {
133
+ if (!t.isIdentifier(path.node.id))
134
+ return;
135
+ const init = path.get('init');
136
+ if (!init.isCallExpression())
137
+ return;
138
+ const callee = init.get('callee');
139
+ if (!callee.isMemberExpression() || callee.node.computed
140
+ || !callee.get('property').isIdentifier({ name: 'map' }))
141
+ return;
142
+ const declaration = path.parentPath;
143
+ if (!declaration.isVariableDeclaration({ kind: 'const' })
144
+ || declaration.node.declarations.length !== 1)
145
+ return;
146
+ const binding = path.scope.getBinding(path.node.id.name);
147
+ if (!binding || !binding.constant || binding.referencePaths.length !== 1)
148
+ return;
149
+ const reference = binding.referencePaths[0];
150
+ const container = reference.parentPath;
151
+ if (!container?.isJSXExpressionContainer()
152
+ || container.node.expression !== reference.node
153
+ || !container.parentPath?.isJSXElement())
154
+ return;
155
+ // The map must still evaluate where it was written.
156
+ const statement = reference.getStatementParent();
157
+ if (!statement?.isReturnStatement())
158
+ return;
159
+ const siblings = declaration.getAllNextSiblings();
160
+ if (siblings.length !== 1 || siblings[0].node !== statement.node)
161
+ return;
162
+ reference.replaceWith(t.cloneNode(init.node, true));
163
+ declaration.remove();
164
+ },
165
+ });
166
+ // `useReducer` is `useState` with the update spelled as a reducer. React
167
+ // queues dispatches and applies them in order -- two `d(1)` calls in one
168
+ // handler move the count by two, not one -- so the rewrite uses the
169
+ // functional updater form, which has exactly that behaviour here.
170
+ //
171
+ // const [s, d] = useReducer(reducer, init)
172
+ // becomes
173
+ // const [s, _set] = useState(init)
174
+ // const d = action => _set(prev => reducer(prev, action))
175
+ //
176
+ // React's third argument is a lazy initialiser: the initial state is
177
+ // `init(arg)`. Everything downstream then sees ordinary `useState`.
178
+ program.traverse({
179
+ VariableDeclarator(path) {
180
+ const init = path.get('init');
181
+ if (!init.isCallExpression() || hookName(init) !== 'useReducer')
182
+ return;
183
+ const args = init.get('arguments');
184
+ if (args.length < 2 || args.length > 3) {
185
+ throw init.buildCodeFrameError('runOnce: useReducer takes a reducer, an initial value and an optional initialiser');
186
+ }
187
+ const reducer = args[0];
188
+ if (!reducer.isIdentifier() && !reducer.isArrowFunctionExpression() && !reducer.isFunctionExpression()) {
189
+ throw reducer.buildCodeFrameError('runOnce: useReducer requires a named or inline reducer');
190
+ }
191
+ if (!t.isArrayPattern(path.node.id) || !t.isIdentifier(path.node.id.elements[0])
192
+ || !t.isIdentifier(path.node.id.elements[1])) {
193
+ throw path.buildCodeFrameError('runOnce: useReducer requires a named state and dispatch binding');
194
+ }
195
+ const state = path.node.id.elements[0];
196
+ const dispatch = path.node.id.elements[1];
197
+ const setter = path.scope.generateUidIdentifier('set' + state.name);
198
+ const previous = path.scope.generateUidIdentifier('previous');
199
+ const action = path.scope.generateUidIdentifier('action');
200
+ const initial = args.length === 3
201
+ ? t.callExpression(t.cloneNode(args[2].node, true), [t.cloneNode(args[1].node, true)])
202
+ : t.cloneNode(args[1].node, true);
203
+ const updater = t.arrowFunctionExpression([t.cloneNode(previous, true)], t.callExpression(t.cloneNode(reducer.node, true), [t.cloneNode(previous, true), t.cloneNode(action, true)]));
204
+ const dispatchFn = t.arrowFunctionExpression([t.cloneNode(action, true)], t.callExpression(t.cloneNode(setter, true), [updater]));
205
+ const declaration = path.parentPath;
206
+ if (!declaration.isVariableDeclaration()) {
207
+ throw path.buildCodeFrameError('runOnce: useReducer must be a direct const declaration');
208
+ }
209
+ declaration.insertAfter(t.variableDeclaration('const', [
210
+ t.variableDeclarator(t.cloneNode(dispatch, true), dispatchFn),
211
+ ]));
212
+ path.node.id = t.arrayPattern([t.cloneNode(state, true), t.cloneNode(setter, true)]);
213
+ init.node.callee = t.identifier('useState');
214
+ init.node.arguments = [initial];
215
+ path.scope.crawl();
216
+ },
217
+ });
218
+ // `useMemo` and `useCallback` exist in React because the component body runs
219
+ // again on every render: one caches a result across those runs, the other
220
+ // keeps a function's identity stable across them. Neither happens here. The
221
+ // body runs once, so a function written in it is already created once, and a
222
+ // derived expression is already a computation that recomputes only when
223
+ // something it reads changes.
224
+ //
225
+ // So both unwrap to the value they were wrapping, and the rest of the compiler
226
+ // sees ordinary source. The dependency array is dropped: dependencies are
227
+ // tracked from the reads themselves. That differs from React for a list that
228
+ // deliberately understates the reads -- `useMemo(() => x, [])` freezes in
229
+ // React and stays live here -- and that difference is asserted in
230
+ // apps/compat-audit/tests/react-pattern-corpus.test.ts rather than left
231
+ // unstated.
232
+ program.traverse({
233
+ CallExpression(path) {
234
+ const hook = hookName(path);
235
+ if (hook !== 'useMemo' && hook !== 'useCallback')
236
+ return;
237
+ const args = path.get('arguments');
238
+ if (args.length > 2) {
239
+ throw path.buildCodeFrameError(`runOnce: ${hook} takes a function and an optional dependency array`);
240
+ }
241
+ const fn = args[0];
242
+ if (!fn || !(fn.isArrowFunctionExpression() || fn.isFunctionExpression())) {
243
+ throw path.buildCodeFrameError(`runOnce: ${hook} requires an inline function`);
244
+ }
245
+ if (fn.node.async || fn.node.generator || fn.node.params.length > 0) {
246
+ throw path.buildCodeFrameError(`runOnce: ${hook} requires a plain zero-argument function`);
247
+ }
248
+ if (hook === 'useCallback') {
249
+ // The identity is already stable, so the wrapper is the only thing to remove.
250
+ path.replaceWith(fn.node);
251
+ return;
252
+ }
253
+ if (t.isBlockStatement(fn.node.body)) {
254
+ throw path.buildCodeFrameError('runOnce: useMemo requires an expression body in the current scope');
255
+ }
256
+ path.replaceWith(fn.node.body);
257
+ },
258
+ });
259
+ // Import spelling can hide a hook from call-site name checks. Only direct
260
+ // named supported hook calls have established state-binding semantics.
261
+ program.traverse({
262
+ ImportSpecifier(path) {
263
+ const imported = path.node.imported;
264
+ const name = t.isIdentifier(imported) ? imported.name : imported.value;
265
+ if (!/^use[A-Z]/.test(name))
266
+ return;
267
+ const declaration = path.parentPath;
268
+ const supported = declaration.isImportDeclaration()
269
+ && ['react', '@rrjs/react-compat'].includes(declaration.node.source.value)
270
+ && ['useState', 'useRef', 'useEffect', 'useContext'].includes(name);
271
+ const binding = path.scope.getBinding(path.node.local.name);
272
+ const analyzed = isImportedReactiveHook(binding);
273
+ for (const reference of binding?.referencePaths ?? []) {
274
+ if (!supported && !analyzed)
275
+ throw reference.buildCodeFrameError('runOnce: imported custom or unsupported hooks require module analysis');
276
+ if (!reference.parentPath?.isCallExpression() || reference.key !== 'callee') {
277
+ throw reference.buildCodeFrameError('runOnce: hook indirection requires binding analysis; call the named import directly');
278
+ }
279
+ }
280
+ },
281
+ });
282
+ // Fold only a tail if-return followed by a return. No component statements
283
+ // are moved into an update callback.
284
+ program.traverse({
285
+ Function(path) {
286
+ if (!t.isBlockStatement(path.node.body))
287
+ return;
288
+ const statements = path.node.body.body;
289
+ while (statements.length >= 2) {
290
+ const last = statements[statements.length - 1];
291
+ const guard = statements[statements.length - 2];
292
+ if (!t.isReturnStatement(last) || !t.isIfStatement(guard) || guard.alternate)
293
+ break;
294
+ const branch = t.isBlockStatement(guard.consequent) && guard.consequent.body.length === 1
295
+ ? guard.consequent.body[0] : guard.consequent;
296
+ if (!t.isReturnStatement(branch))
297
+ break;
298
+ statements.splice(-2, 2, t.returnStatement(t.conditionalExpression(guard.test, branch.argument ?? t.nullLiteral(), last.argument ?? t.nullLiteral())));
299
+ }
300
+ },
301
+ });
302
+ function hookName(call) {
303
+ const callee = call.get('callee');
304
+ if (!callee.isIdentifier())
305
+ return undefined;
306
+ const binding = callee.scope.getBinding(callee.node.name);
307
+ if (!binding)
308
+ return callee.node.name; // classic runtime supplied by the caller
309
+ if (!binding.path.isImportSpecifier())
310
+ return undefined;
311
+ const declaration = binding.path.parentPath;
312
+ if (!declaration.isImportDeclaration() || !['react', '@rrjs/react-compat'].includes(declaration.node.source.value))
313
+ return undefined;
314
+ const imported = binding.path.node.imported;
315
+ return t.isIdentifier(imported) ? imported.name : imported.value;
316
+ }
317
+ function importedIdentity(binding) {
318
+ if (!binding?.path.isImportSpecifier())
319
+ return undefined;
320
+ const declaration = binding.path.parentPath;
321
+ if (!declaration.isImportDeclaration())
322
+ return undefined;
323
+ const imported = binding.path.node.imported;
324
+ return {
325
+ source: declaration.node.source.value,
326
+ exported: t.isIdentifier(imported) ? imported.name : imported.value,
327
+ };
328
+ }
329
+ function importedComponentContract(binding) {
330
+ const identity = importedIdentity(binding);
331
+ const exact = identity && moduleMetadata?.imports?.[identity.source]?.components?.[identity.exported];
332
+ if (exact)
333
+ return exact;
334
+ return binding ? moduleMetadata?.importedComponents?.[binding.identifier.name] : undefined;
335
+ }
336
+ function isImportedReactiveHook(binding) {
337
+ const identity = importedIdentity(binding);
338
+ if (identity && moduleMetadata?.imports?.[identity.source]?.hooks?.includes(identity.exported))
339
+ return true;
340
+ return Boolean(binding && moduleMetadata?.importedHooks?.includes(binding.identifier.name));
341
+ }
342
+ const componentUses = [];
343
+ const providerUses = [];
344
+ const componentProps = new Map();
345
+ const componentDefaultProps = new Map();
346
+ const propSources = [];
347
+ program.traverse({
348
+ JSXOpeningElement(path) {
349
+ const name = path.node.name;
350
+ if (t.isJSXIdentifier(name) && /^[a-z]/.test(name.name))
351
+ return;
352
+ if (t.isJSXMemberExpression(name) && t.isJSXIdentifier(name.object)
353
+ && t.isJSXIdentifier(name.property, { name: 'Provider' })) {
354
+ const contextBinding = path.scope.getBinding(name.object.name);
355
+ const init = contextBinding?.path.isVariableDeclarator() ? contextBinding.path.get('init') : undefined;
356
+ if (!init?.isCallExpression() || !t.isIdentifier(init.node.callee, { name: 'createContext' })) {
357
+ throw path.buildCodeFrameError('runOnce: Provider must come from a direct local createContext call');
358
+ }
359
+ providerUses.push(path);
360
+ return;
361
+ }
362
+ if (!t.isJSXIdentifier(name))
363
+ throw path.buildCodeFrameError('runOnce: member components require wrapper compilation');
364
+ const binding = path.scope.getBinding(name.name);
365
+ let forwardRef = false;
366
+ let component = binding?.path.isFunctionDeclaration() ? binding.path
367
+ : binding?.path.isVariableDeclarator()
368
+ && (binding.path.get('init').isFunctionExpression() || binding.path.get('init').isArrowFunctionExpression())
369
+ ? binding.path.get('init') : undefined;
370
+ if (!component && binding?.path.isVariableDeclarator()) {
371
+ const init = binding.path.get('init');
372
+ if (init.isCallExpression() && t.isIdentifier(init.node.callee, { name: 'forwardRef' })) {
373
+ const render = init.get('arguments.0');
374
+ if (render?.isFunctionExpression() || render?.isArrowFunctionExpression()) {
375
+ component = render;
376
+ forwardRef = true;
377
+ }
378
+ }
379
+ }
380
+ if (!component) {
381
+ const contract = importedComponentContract(binding);
382
+ const declared = Array.isArray(contract) ? contract : contract?.props;
383
+ if (!binding?.path.isImportSpecifier() || !declared) {
384
+ throw path.buildCodeFrameError('runOnce: only analyzed imported or direct local function components are supported in the current composition scope');
385
+ }
386
+ componentUses.push({ opening: path, props: new Map(), declaredProps: new Set(declared), forwardRef: false });
387
+ return;
388
+ }
389
+ const element = path.parentPath;
390
+ const params = component.get('params');
391
+ if ((forwardRef ? params.length !== 2 : params.length !== 1) || !params[0].isObjectPattern()) {
392
+ if (path.node.attributes.length)
393
+ throw path.buildCodeFrameError('runOnce: component props currently require one destructured object parameter');
394
+ componentUses.push({ opening: path, component, props: new Map(), forwardRef });
395
+ components.add(component);
396
+ return;
397
+ }
398
+ let props = componentProps.get(component);
399
+ if (!props) {
400
+ props = new Map();
401
+ const defaults = new Set();
402
+ for (const property of params[0].get('properties')) {
403
+ if (!property.isObjectProperty() || property.node.computed
404
+ || !(t.isIdentifier(property.node.key) || t.isStringLiteral(property.node.key))
405
+ || !(t.isIdentifier(property.node.value) || (t.isAssignmentPattern(property.node.value)
406
+ && t.isIdentifier(property.node.value.left)))) {
407
+ throw params[0].buildCodeFrameError('runOnce: component props currently require direct destructured bindings');
408
+ }
409
+ const external = t.isIdentifier(property.node.key) ? property.node.key.name : property.node.key.value;
410
+ const local = (t.isIdentifier(property.node.value) ? property.node.value : property.node.value.left);
411
+ if (t.isAssignmentPattern(property.node.value)) {
412
+ const fallback = property.node.value.right;
413
+ if (!(t.isStringLiteral(fallback) || t.isNumericLiteral(fallback)
414
+ || t.isBooleanLiteral(fallback) || t.isNullLiteral(fallback))) {
415
+ throw property.buildCodeFrameError('runOnce: default component props currently require a static scalar');
416
+ }
417
+ property.node.value.right = t.arrowFunctionExpression([], fallback);
418
+ defaults.add(external);
419
+ }
420
+ const propBinding = component.scope.getBinding(local.name);
421
+ if (!propBinding)
422
+ throw property.buildCodeFrameError('runOnce: unable to resolve component prop binding');
423
+ props.set(external, propBinding);
424
+ }
425
+ componentProps.set(component, props);
426
+ componentDefaultProps.set(component, defaults);
427
+ }
428
+ componentUses.push({ opening: path, component, props, forwardRef });
429
+ components.add(component);
430
+ },
431
+ });
432
+ program.traverse({
433
+ VariableDeclarator(path) {
434
+ const init = path.get('init');
435
+ if (!init.isCallExpression() || hookName(init) !== 'useState')
436
+ return;
437
+ const fn = path.getFunctionParent();
438
+ if (!fn)
439
+ throw path.buildCodeFrameError('runOnce: useState must be inside a component');
440
+ if (!t.isArrayPattern(path.node.id) || !t.isIdentifier(path.node.id.elements[0]))
441
+ throw path.buildCodeFrameError('runOnce: useState requires a named state binding');
442
+ const binding = path.scope.getBinding(path.node.id.elements[0].name);
443
+ if (!binding.constant)
444
+ throw path.buildCodeFrameError('runOnce: state bindings cannot be reassigned');
445
+ reactive.set(binding, fn);
446
+ // `count()` is the older spelling, where state was a getter. Under these
447
+ // semantics `count` is the value, so calling it is a call on whatever the
448
+ // state holds -- for `useState(0)` that is a TypeError at the first click
449
+ // rather than at build time. When the initial value is plainly not
450
+ // callable, say so now instead of shipping that crash.
451
+ const initial = init.node.arguments[0];
452
+ const plainlyNotCallable = t.isNumericLiteral(initial) || t.isStringLiteral(initial)
453
+ || t.isBooleanLiteral(initial) || t.isNullLiteral(initial)
454
+ || t.isArrayExpression(initial) || t.isObjectExpression(initial)
455
+ || t.isTemplateLiteral(initial);
456
+ if (plainlyNotCallable) {
457
+ for (const reference of binding.referencePaths) {
458
+ const parent = reference.parentPath;
459
+ if (parent?.isCallExpression() && parent.node.callee === reference.node) {
460
+ throw parent.buildCodeFrameError(`runOnce: state is a value here, so \`${binding.identifier.name}()\` calls it; read \`${binding.identifier.name}\` instead`);
461
+ }
462
+ }
463
+ }
464
+ const setter = path.node.id.elements[1];
465
+ if (t.isIdentifier(setter)) {
466
+ const setterBinding = path.scope.getBinding(setter.name);
467
+ if (setterBinding)
468
+ stateSetters.set(setterBinding, binding);
469
+ }
470
+ components.add(fn);
471
+ },
472
+ });
473
+ program.traverse({
474
+ VariableDeclarator(path) {
475
+ const init = path.get('init');
476
+ if (!t.isIdentifier(path.node.id) || !init.isCallExpression() || hookName(init) !== 'useContext')
477
+ return;
478
+ const fn = path.getFunctionParent();
479
+ if (!fn)
480
+ throw path.buildCodeFrameError('runOnce: useContext must be inside a component');
481
+ const binding = path.scope.getBinding(path.node.id.name);
482
+ if (!binding.constant)
483
+ throw path.buildCodeFrameError('runOnce: context bindings cannot be reassigned');
484
+ reactive.set(binding, fn);
485
+ components.add(fn);
486
+ },
487
+ });
488
+ program.traverse({
489
+ VariableDeclarator(path) {
490
+ if (!t.isIdentifier(path.node.id))
491
+ return;
492
+ const init = path.get('init');
493
+ if (!init.isCallExpression() || !t.isIdentifier(init.node.callee))
494
+ return;
495
+ const imported = init.scope.getBinding(init.node.callee.name);
496
+ if (!isImportedReactiveHook(imported))
497
+ return;
498
+ const fn = path.getFunctionParent();
499
+ if (!fn)
500
+ throw path.buildCodeFrameError('runOnce: analyzed imported hooks must be called inside a component');
501
+ reactive.set(path.scope.getBinding(path.node.id.name), fn);
502
+ components.add(fn);
503
+ },
504
+ FunctionDeclaration(path) {
505
+ if (!path.node.id || !/^[A-Z]/.test(path.node.id.name)
506
+ || !path.parentPath.isExportNamedDeclaration() || componentProps.has(path))
507
+ return;
508
+ const param = path.get('params.0');
509
+ if (!param?.isObjectPattern())
510
+ return;
511
+ const props = new Map();
512
+ for (const property of param.get('properties')) {
513
+ if (!property.isObjectProperty() || property.node.computed
514
+ || !(t.isIdentifier(property.node.key) || t.isStringLiteral(property.node.key))
515
+ || !t.isIdentifier(property.node.value)) {
516
+ throw property.buildCodeFrameError('runOnce: analyzed exported component props require direct destructured bindings');
517
+ }
518
+ const external = t.isIdentifier(property.node.key) ? property.node.key.name : property.node.key.value;
519
+ const binding = path.scope.getBinding(property.node.value.name);
520
+ if (!binding)
521
+ throw property.buildCodeFrameError('runOnce: unable to resolve exported component prop binding');
522
+ props.set(external, binding);
523
+ }
524
+ componentProps.set(path, props);
525
+ for (const [name, binding] of props) {
526
+ reactive.set(binding, path);
527
+ if (moduleMetadata?.operationProps?.includes(name))
528
+ operationStates.add(binding);
529
+ }
530
+ components.add(path);
531
+ },
532
+ });
533
+ for (const [component, props] of componentProps) {
534
+ for (const binding of props.values())
535
+ reactive.set(binding, component);
536
+ }
537
+ const customHooks = new Map();
538
+ program.traverse({
539
+ Function(path) {
540
+ const name = path.isFunctionDeclaration() ? path.node.id?.name
541
+ : path.parentPath.isVariableDeclarator() && t.isIdentifier(path.parentPath.node.id)
542
+ ? path.parentPath.node.id.name : undefined;
543
+ if (!name || !/^use[A-Z]/.test(name) || !t.isBlockStatement(path.node.body))
544
+ return;
545
+ const returned = path.node.body.body.find(statement => t.isReturnStatement(statement));
546
+ if (!returned || !t.isReturnStatement(returned) || !t.isArrayExpression(returned.argument))
547
+ return;
548
+ const values = new Map();
549
+ returned.argument.elements.forEach((element, index) => {
550
+ if (!t.isIdentifier(element))
551
+ return;
552
+ const binding = path.scope.getBinding(element.name);
553
+ if (binding && reactive.get(binding) === path)
554
+ values.set(index, binding);
555
+ });
556
+ const ownBinding = path.isFunctionDeclaration() && path.node.id
557
+ ? path.parentPath.scope.getBinding(path.node.id.name)
558
+ : path.parentPath.isVariableDeclarator() && t.isIdentifier(path.parentPath.node.id)
559
+ ? path.parentPath.scope.getBinding(path.parentPath.node.id.name) : undefined;
560
+ if (ownBinding && values.size)
561
+ customHooks.set(ownBinding, values);
562
+ },
563
+ });
564
+ program.traverse({
565
+ VariableDeclarator(path) {
566
+ const init = path.get('init');
567
+ if (!init.isCallExpression() || !t.isIdentifier(init.node.callee))
568
+ return;
569
+ const hook = customHooks.get(init.scope.getBinding(init.node.callee.name));
570
+ if (!hook)
571
+ return;
572
+ const component = path.getFunctionParent();
573
+ if (!component || !t.isArrayPattern(path.node.id))
574
+ throw path.buildCodeFrameError('runOnce: analyzed custom hooks require array destructuring inside a component');
575
+ for (const index of hook.keys()) {
576
+ const local = path.node.id.elements[index];
577
+ if (!t.isIdentifier(local))
578
+ throw path.buildCodeFrameError('runOnce: custom-hook reactive returns require named array bindings');
579
+ const binding = path.scope.getBinding(local.name);
580
+ if (!binding)
581
+ throw path.buildCodeFrameError('runOnce: unable to resolve custom-hook return binding');
582
+ reactive.set(binding, component);
583
+ }
584
+ components.add(component);
585
+ },
586
+ });
587
+ // Every accepted component prop is a getter in the target. This gives the
588
+ // child a stable prop binding that can subscribe directly to the parent's
589
+ // signal while both component bodies remain single-execution.
590
+ for (const { opening, component, props, declaredProps, forwardRef } of componentUses) {
591
+ const attributes = new Map();
592
+ for (const attribute of opening.get('attributes')) {
593
+ if (attribute.isJSXSpreadAttribute())
594
+ throw opening.buildCodeFrameError('runOnce: component spread props require reactive prop compilation');
595
+ if (!attribute.isJSXAttribute() || !t.isJSXIdentifier(attribute.node.name))
596
+ throw opening.buildCodeFrameError('runOnce: namespaced component props are unsupported');
597
+ const name = attribute.node.name.name;
598
+ if (name === 'ref') {
599
+ if (!forwardRef)
600
+ throw opening.buildCodeFrameError('runOnce: component refs require a direct local forwardRef wrapper');
601
+ continue;
602
+ }
603
+ if (name === 'key')
604
+ throw opening.buildCodeFrameError('runOnce: component keys require lifetime compilation');
605
+ if (!props.has(name) && !declaredProps?.has(name))
606
+ throw attribute.buildCodeFrameError(`runOnce: component prop ${name} is not declared by the analyzed component`);
607
+ attributes.set(name, attribute);
608
+ }
609
+ for (const name of props.keys()) {
610
+ if (name === 'children')
611
+ continue;
612
+ if (component && componentDefaultProps.get(component)?.has(name))
613
+ continue;
614
+ if (!attributes.has(name))
615
+ throw opening.buildCodeFrameError(`runOnce: component prop ${name} must be supplied until default/optional prop compilation is implemented`);
616
+ }
617
+ for (const [name, attribute] of attributes) {
618
+ const value = attribute.node.value;
619
+ const expression = value == null ? t.booleanLiteral(true)
620
+ : t.isStringLiteral(value) ? value
621
+ : t.isJSXExpressionContainer(value) && !t.isJSXEmptyExpression(value.expression)
622
+ ? value.expression : undefined;
623
+ if (!expression)
624
+ throw attribute.buildCodeFrameError(`runOnce: unsupported value for component prop ${name}`);
625
+ const staticValue = t.isStringLiteral(expression) || t.isNumericLiteral(expression)
626
+ || t.isBooleanLiteral(expression) || t.isNullLiteral(expression);
627
+ const parent = opening.getFunctionParent();
628
+ const directBinding = t.isIdentifier(expression) ? opening.scope.getBinding(expression.name) : undefined;
629
+ const inlineFunction = t.isFunctionExpression(expression) || t.isArrowFunctionExpression(expression);
630
+ const directFunction = Boolean(directBinding?.constant
631
+ && (directBinding.path.isFunctionDeclaration()
632
+ || (directBinding.path.isVariableDeclarator()
633
+ && (directBinding.path.get('init').isFunctionExpression() || directBinding.path.get('init').isArrowFunctionExpression())))
634
+ && directBinding.path.getFunctionParent() === parent);
635
+ if (!staticValue && !inlineFunction && !directFunction
636
+ && (!parent || !directBinding || reactive.get(directBinding) !== parent)) {
637
+ throw attribute.buildCodeFrameError('runOnce: component props currently accept static scalars or direct reactive bindings');
638
+ }
639
+ const childBinding = props.get(name);
640
+ if (directBinding && childBinding) {
641
+ propSources.push({
642
+ childScope: childBinding.scope,
643
+ childName: childBinding.identifier.name,
644
+ parentScope: directBinding.scope,
645
+ parentName: directBinding.identifier.name,
646
+ });
647
+ }
648
+ if (directBinding && t.isJSXIdentifier(opening.node.name)) {
649
+ const contract = importedComponentContract(opening.scope.getBinding(opening.node.name.name));
650
+ const operationKey = !Array.isArray(contract) ? contract?.operationKeys?.[name] : undefined;
651
+ if (operationKey) {
652
+ operationStates.add(directBinding);
653
+ importedOperationEdges.push({
654
+ scope: directBinding.scope,
655
+ state: directBinding.identifier.name,
656
+ prop: name,
657
+ key: operationKey,
658
+ origin: attribute,
659
+ });
660
+ }
661
+ }
662
+ const getter = t.arrowFunctionExpression([], expression);
663
+ generated.add(getter);
664
+ attribute.node.value = t.jsxExpressionContainer(getter);
665
+ }
666
+ const element = opening.parentPath;
667
+ if (element.isJSXElement()) {
668
+ const actualChildren = element.node.children.filter(child => !t.isJSXText(child) || child.value.trim().length > 0);
669
+ if (actualChildren.length) {
670
+ if (!props.has('children') && !declaredProps?.has('children'))
671
+ throw opening.buildCodeFrameError('runOnce: component children require a declared children prop');
672
+ if (actualChildren.length !== 1 || t.isJSXSpreadChild(actualChildren[0])) {
673
+ throw opening.buildCodeFrameError('runOnce: multiple and spread component children require child collection compilation');
674
+ }
675
+ const child = actualChildren[0];
676
+ const expression = t.isJSXText(child) ? t.stringLiteral(child.value)
677
+ : t.isJSXExpressionContainer(child) && !t.isJSXEmptyExpression(child.expression)
678
+ ? child.expression
679
+ : t.isJSXElement(child) || t.isJSXFragment(child) ? child : undefined;
680
+ if (!expression)
681
+ throw opening.buildCodeFrameError('runOnce: unsupported component child');
682
+ const factory = t.arrowFunctionExpression([], expression);
683
+ factory.extra = { ...(factory.extra ?? {}), rrjsRegion: true };
684
+ generated.add(factory);
685
+ element.node.children = [t.jsxExpressionContainer(factory)];
686
+ }
687
+ else if (props.has('children') || declaredProps?.has('children')) {
688
+ throw opening.buildCodeFrameError('runOnce: a destructured children prop must receive one child in the current scope');
689
+ }
690
+ }
691
+ }
692
+ for (const opening of providerUses) {
693
+ const attributes = opening.get('attributes');
694
+ if (attributes.length !== 1 || !attributes[0].isJSXAttribute()
695
+ || !t.isJSXIdentifier(attributes[0].node.name, { name: 'value' })) {
696
+ throw opening.buildCodeFrameError('runOnce: Context.Provider currently requires exactly one value prop');
697
+ }
698
+ const value = attributes[0].node.value;
699
+ const expression = t.isStringLiteral(value) ? value
700
+ : t.isJSXExpressionContainer(value) && !t.isJSXEmptyExpression(value.expression)
701
+ ? value.expression : undefined;
702
+ if (!expression)
703
+ throw attributes[0].buildCodeFrameError('runOnce: unsupported Context.Provider value');
704
+ const parent = opening.getFunctionParent();
705
+ // The value is compiled into a getter, so it is re-read whenever anything
706
+ // it depends on changes. That is sound for a scalar, for a binding this
707
+ // component owns, and -- by the same argument applied to each entry -- for
708
+ // an object or array literal built only out of those. `value={{ user }}`
709
+ // is the ordinary React spelling, so accept it rather than forcing callers
710
+ // to hoist a binding they never needed.
711
+ const accepts = (node) => {
712
+ if (t.isStringLiteral(node) || t.isNumericLiteral(node)
713
+ || t.isBooleanLiteral(node) || t.isNullLiteral(node))
714
+ return true;
715
+ if (t.isIdentifier(node)) {
716
+ const binding = opening.scope.getBinding(node.name);
717
+ if (!binding)
718
+ return false;
719
+ const local = Boolean(parent && binding.constant
720
+ && binding.path.isVariableDeclarator()
721
+ && binding.path.getFunctionParent() === parent);
722
+ return local || Boolean(parent && reactive.get(binding) === parent);
723
+ }
724
+ if (t.isObjectExpression(node)) {
725
+ return node.properties.every(property => t.isObjectProperty(property)
726
+ && !property.computed
727
+ && (t.isIdentifier(property.key) || t.isStringLiteral(property.key))
728
+ && t.isExpression(property.value) && accepts(property.value));
729
+ }
730
+ if (t.isArrayExpression(node)) {
731
+ return node.elements.every(element => element !== null && t.isExpression(element) && accepts(element));
732
+ }
733
+ return false;
734
+ };
735
+ if (!accepts(expression)) {
736
+ throw attributes[0].buildCodeFrameError('runOnce: provider values currently accept static scalars, bindings this component owns, and object or array literals of those');
737
+ }
738
+ const valueGetter = t.arrowFunctionExpression([], expression);
739
+ generated.add(valueGetter);
740
+ attributes[0].node.value = t.jsxExpressionContainer(valueGetter);
741
+ const element = opening.parentPath;
742
+ if (!element.isJSXElement())
743
+ throw opening.buildCodeFrameError('runOnce: provider must be a JSX element');
744
+ const actualChildren = element.node.children.filter(child => !t.isJSXText(child) || child.value.trim().length > 0);
745
+ if (actualChildren.length !== 1 || t.isJSXSpreadChild(actualChildren[0])) {
746
+ throw opening.buildCodeFrameError('runOnce: provider currently requires exactly one owned child');
747
+ }
748
+ const child = actualChildren[0];
749
+ const childExpression = t.isJSXText(child) ? t.stringLiteral(child.value)
750
+ : t.isJSXExpressionContainer(child) && !t.isJSXEmptyExpression(child.expression)
751
+ ? child.expression
752
+ : t.isJSXElement(child) || t.isJSXFragment(child) ? child : undefined;
753
+ if (!childExpression)
754
+ throw opening.buildCodeFrameError('runOnce: unsupported provider child');
755
+ const childFactory = t.arrowFunctionExpression([], childExpression);
756
+ childFactory.extra = { ...(childFactory.extra ?? {}), rrjsRegion: true };
757
+ generated.add(childFactory);
758
+ element.node.children = [t.jsxExpressionContainer(childFactory)];
759
+ }
760
+ // Phase 3 starts with direct inline callbacks and literal empty dependency
761
+ // lists. Reactive captures are snapshotted below at component execution, so
762
+ // both effect setup and its cleanup observe the initial render values.
763
+ const changingEffects = new Set();
764
+ const inferredEffects = new Set();
765
+ program.traverse({
766
+ CallExpression(path) {
767
+ if (hookName(path) !== 'useEffect')
768
+ return;
769
+ const [callback, deps] = path.get('arguments');
770
+ if (path.node.arguments.length > 2) {
771
+ throw path.buildCodeFrameError('runOnce: useEffect takes a callback and an optional dependency array');
772
+ }
773
+ if (!callback || !(callback.isFunctionExpression() || callback.isArrowFunctionExpression())) {
774
+ throw path.buildCodeFrameError('runOnce: useEffect requires an inline callback in the supported empty-dependency scope');
775
+ }
776
+ // No dependency argument is React's "after every render". There is no
777
+ // second render here, so the closest honest reading is to re-run when
778
+ // something the callback actually reads changes. The dependency list for
779
+ // that is synthesised below, once every reactive binding is known.
780
+ if (!deps) {
781
+ inferredEffects.add(path.node);
782
+ return;
783
+ }
784
+ if (!deps.isArrayExpression()) {
785
+ throw path.buildCodeFrameError('runOnce: useEffect requires a literal dependency array');
786
+ }
787
+ if (deps.node.elements.length > 0)
788
+ changingEffects.add(path.node);
789
+ },
790
+ });
791
+ // Find derived bindings transitively before replacing references. Binding
792
+ // identity, rather than identifier spelling, preserves lexical shadowing.
793
+ function helperKind(path) {
794
+ if (!t.isIdentifier(path.node.callee))
795
+ return 'unsupported';
796
+ const binding = path.scope.getBinding(path.node.callee.name);
797
+ if (!binding?.constant)
798
+ return 'unsupported';
799
+ const node = binding.path.isVariableDeclarator() ? binding.path.node.init : binding.path.node;
800
+ if (!node || (!t.isFunctionDeclaration(node) && !t.isArrowFunctionExpression(node) && !t.isFunctionExpression(node)))
801
+ return 'unsupported';
802
+ if (node.async || node.generator || !node.params.every(parameter => t.isIdentifier(parameter)))
803
+ return 'unsupported';
804
+ const parameters = new Set(node.params.map(parameter => parameter.name));
805
+ const pure = (value) => {
806
+ if (!value)
807
+ return false;
808
+ if (t.isNumericLiteral(value) || t.isStringLiteral(value) || t.isBooleanLiteral(value) || t.isNullLiteral(value))
809
+ return true;
810
+ if (t.isIdentifier(value))
811
+ return parameters.has(value.name);
812
+ if (t.isBinaryExpression(value) || t.isLogicalExpression(value))
813
+ return pure(value.left) && pure(value.right);
814
+ if (t.isUnaryExpression(value))
815
+ return value.operator !== 'delete' && pure(value.argument);
816
+ if (t.isConditionalExpression(value))
817
+ return pure(value.test) && pure(value.consequent) && pure(value.alternate);
818
+ return false;
819
+ };
820
+ const expression = t.isBlockStatement(node.body)
821
+ ? node.body.body.length === 1 && t.isReturnStatement(node.body.body[0]) ? node.body.body[0].argument : null
822
+ : node.body;
823
+ if (pure(expression))
824
+ return 'pure';
825
+ return 'unsupported';
826
+ }
827
+ function supportedCall(path, _component) {
828
+ const callee = path.get('callee');
829
+ if (callee.isMemberExpression() && !callee.node.computed
830
+ && callee.get('property').isIdentifier({ name: 'trim' })
831
+ && path.node.arguments.length === 0)
832
+ return true;
833
+ if (callee.isMemberExpression() && !callee.node.computed
834
+ && callee.get('property').isIdentifier({ name: 'filter' })
835
+ && path.node.arguments.length === 1) {
836
+ const predicate = path.get('arguments.0');
837
+ if (!predicate.isArrowFunctionExpression() && !predicate.isFunctionExpression())
838
+ return false;
839
+ let impure = predicate.node.async || predicate.node.generator;
840
+ predicate.traverse({
841
+ Function(nested) { if (nested.node !== predicate.node)
842
+ nested.skip(); },
843
+ CallExpression() { impure = true; },
844
+ NewExpression() { impure = true; },
845
+ AssignmentExpression() { impure = true; },
846
+ UpdateExpression() { impure = true; },
847
+ AwaitExpression() { impure = true; },
848
+ });
849
+ return !impure;
850
+ }
851
+ return helperKind(path) === 'pure';
852
+ }
853
+ // A map over a source-declared array has fixed membership. Preserve those
854
+ // rows and expose only reactive object fields as lazy children; the renderer
855
+ // already tracks function children without rebuilding their enclosing nodes.
856
+ program.traverse({
857
+ CallExpression(path) {
858
+ const fn = path.getFunctionParent();
859
+ const callee = path.get('callee');
860
+ if (!fn || !components.has(fn) || !callee.isMemberExpression() || callee.node.computed
861
+ || !callee.get('property').isIdentifier({ name: 'map' }))
862
+ return;
863
+ const source = callee.get('object');
864
+ if (!source.isArrayExpression())
865
+ return;
866
+ for (const element of source.get('elements')) {
867
+ if (!element?.isObjectExpression())
868
+ continue;
869
+ for (const property of element.get('properties')) {
870
+ if (!property.isObjectProperty() || property.node.computed)
871
+ continue;
872
+ const value = property.get('value');
873
+ let depends = value.isReferencedIdentifier()
874
+ && reactive.has(value.scope.getBinding(value.node.name));
875
+ value.traverse({
876
+ ReferencedIdentifier(ref) {
877
+ if (reactive.has(ref.scope.getBinding(ref.node.name)))
878
+ depends = true;
879
+ },
880
+ });
881
+ if (!depends || value.isFunctionExpression() || value.isArrowFunctionExpression())
882
+ continue;
883
+ const getter = t.arrowFunctionExpression([], value.node);
884
+ getter.extra = { rrjsReactiveGetter: true };
885
+ generated.add(getter);
886
+ value.replaceWith(getter);
887
+ }
888
+ }
889
+ },
890
+ });
891
+ let added = true;
892
+ while (added) {
893
+ added = false;
894
+ program.traverse({
895
+ VariableDeclarator(path) {
896
+ if (!t.isIdentifier(path.node.id) || !path.node.init)
897
+ return;
898
+ const fn = path.getFunctionParent();
899
+ if (!fn || !components.has(fn))
900
+ return;
901
+ const binding = path.scope.getBinding(path.node.id.name);
902
+ if (reactive.has(binding))
903
+ return;
904
+ const init = path.get('init');
905
+ const initCallee = init.isCallExpression() ? init.get('callee') : undefined;
906
+ if (initCallee?.isMemberExpression() && !initCallee.node.computed
907
+ && initCallee.get('property').isIdentifier({ name: 'map' })
908
+ && initCallee.get('object').isArrayExpression())
909
+ return;
910
+ let depends = init.isReferencedIdentifier() && reactive.has(init.scope.getBinding(init.node.name));
911
+ init.traverse({
912
+ Function(nested) { if (generated.has(nested.node))
913
+ nested.skip(); },
914
+ ReferencedIdentifier(ref) { if (reactive.has(ref.scope.getBinding(ref.node.name)))
915
+ depends = true; },
916
+ });
917
+ if (!depends)
918
+ return;
919
+ if (init.isFunctionExpression() || init.isArrowFunctionExpression())
920
+ return;
921
+ if (!binding.constant || !path.parentPath.isVariableDeclaration({ kind: 'const' }))
922
+ throw path.buildCodeFrameError('runOnce: derived bindings must be const');
923
+ let unsupported = false;
924
+ init.traverse({
925
+ Function(nested) { nested.skip(); },
926
+ CallExpression(call) { if (!supportedCall(call, fn))
927
+ unsupported = true; },
928
+ NewExpression() { unsupported = true; },
929
+ AssignmentExpression() { unsupported = true; },
930
+ UpdateExpression() { unsupported = true; },
931
+ JSXElement() { unsupported = true; },
932
+ AwaitExpression() { unsupported = true; },
933
+ });
934
+ if ((init.isCallExpression() && !supportedCall(init, fn)) || init.isNewExpression() || init.isJSXElement() || unsupported)
935
+ throw init.buildCodeFrameError('runOnce: derived calls, mutations, and JSX need further compiler analysis');
936
+ reactive.set(binding, fn);
937
+ added = true;
938
+ const factory = t.arrowFunctionExpression([], init.node);
939
+ generated.add(factory);
940
+ init.replaceWith(t.callExpression(derive(), [factory]));
941
+ },
942
+ });
943
+ }
944
+ // Derived bindings (for example `done`) become known during the fixed-point
945
+ // pass above, so complete the same fixed-row lowering for those fields now.
946
+ program.traverse({
947
+ CallExpression(path) {
948
+ const fn = path.getFunctionParent();
949
+ const callee = path.get('callee');
950
+ if (!fn || !components.has(fn) || !callee.isMemberExpression() || callee.node.computed
951
+ || !callee.get('property').isIdentifier({ name: 'map' }))
952
+ return;
953
+ const source = callee.get('object');
954
+ if (!source.isArrayExpression())
955
+ return;
956
+ for (const element of source.get('elements')) {
957
+ if (!element?.isObjectExpression())
958
+ continue;
959
+ for (const property of element.get('properties')) {
960
+ if (!property.isObjectProperty() || property.node.computed)
961
+ continue;
962
+ const value = property.get('value');
963
+ if (value.isFunctionExpression() || value.isArrowFunctionExpression())
964
+ continue;
965
+ let depends = value.isReferencedIdentifier()
966
+ && reactive.has(value.scope.getBinding(value.node.name));
967
+ value.traverse({ ReferencedIdentifier(ref) {
968
+ if (reactive.has(ref.scope.getBinding(ref.node.name)))
969
+ depends = true;
970
+ } });
971
+ if (!depends)
972
+ continue;
973
+ const getter = t.arrowFunctionExpression([], value.node);
974
+ getter.extra = { rrjsReactiveGetter: true };
975
+ generated.add(getter);
976
+ value.replaceWith(getter);
977
+ }
978
+ }
979
+ },
980
+ });
981
+ // Fixed source rows may carry reactive fields as generated getters. Restore
982
+ // ordinary JavaScript value semantics inside the mapper by invoking exactly
983
+ // those fields. Normalize every row to the same getter shape first so a
984
+ // mapper cannot sometimes receive a value and sometimes a function.
985
+ program.traverse({
986
+ CallExpression(path) {
987
+ const callee = path.get('callee');
988
+ if (!callee.isMemberExpression() || callee.node.computed
989
+ || !callee.get('property').isIdentifier({ name: 'map' }))
990
+ return;
991
+ const source = callee.get('object');
992
+ const callback = path.get('arguments.0');
993
+ if (!source.isArrayExpression() || !callback?.isArrowFunctionExpression())
994
+ return;
995
+ const objects = source.get('elements').filter((element) => Boolean(element?.isObjectExpression()));
996
+ if (objects.length !== source.node.elements.length) {
997
+ let reactiveElement = false;
998
+ source.traverse({ ReferencedIdentifier(ref) {
999
+ if (reactive.has(ref.scope.getBinding(ref.node.name)))
1000
+ reactiveElement = true;
1001
+ } });
1002
+ if (reactiveElement)
1003
+ throw source.buildCodeFrameError('runOnce: reactive primitive fixed-map elements require explicit value compilation');
1004
+ return;
1005
+ }
1006
+ const keyOf = (property) => {
1007
+ if (!property.isObjectProperty() || property.node.computed)
1008
+ return undefined;
1009
+ return t.isIdentifier(property.node.key) ? property.node.key.name
1010
+ : t.isStringLiteral(property.node.key) ? property.node.key.value : undefined;
1011
+ };
1012
+ const dynamic = new Set();
1013
+ for (const object of objects)
1014
+ for (const property of object.get('properties')) {
1015
+ const key = keyOf(property);
1016
+ const value = property.isObjectProperty() ? property.get('value') : undefined;
1017
+ if (key && value?.isArrowFunctionExpression() && value.node.extra?.rrjsReactiveGetter)
1018
+ dynamic.add(key);
1019
+ }
1020
+ if (!dynamic.size)
1021
+ return;
1022
+ const parameter = callback.get('params.0');
1023
+ if (!parameter?.isIdentifier())
1024
+ throw callback.buildCodeFrameError('runOnce: reactive fixed-map rows require an identifier parameter');
1025
+ for (const object of objects) {
1026
+ const fields = new Map(object.get('properties').map(property => [keyOf(property), property]));
1027
+ for (const key of dynamic) {
1028
+ const property = fields.get(key);
1029
+ if (!property?.isObjectProperty())
1030
+ throw object.buildCodeFrameError('runOnce: reactive fixed-map rows require a consistent direct-property shape');
1031
+ const value = property.get('value');
1032
+ if (value.isArrowFunctionExpression() && value.node.extra?.rrjsReactiveGetter)
1033
+ continue;
1034
+ const getter = t.arrowFunctionExpression([], value.node);
1035
+ getter.extra = { rrjsReactiveGetter: true };
1036
+ generated.add(getter);
1037
+ value.replaceWith(getter);
1038
+ }
1039
+ }
1040
+ const parameterBinding = callback.scope.getBinding(parameter.node.name);
1041
+ callback.traverse({
1042
+ MemberExpression(member) {
1043
+ const object = member.get('object');
1044
+ const property = member.get('property');
1045
+ if (!object.isIdentifier() || object.scope.getBinding(object.node.name) !== parameterBinding)
1046
+ return;
1047
+ const key = !member.node.computed && property.isIdentifier() ? property.node.name
1048
+ : member.node.computed && property.isStringLiteral() ? property.node.value : undefined;
1049
+ if (!key || !dynamic.has(key))
1050
+ return;
1051
+ const keyAttribute = member.findParent(parent => parent.isJSXAttribute()
1052
+ && t.isJSXIdentifier(parent.node.name, { name: 'key' }));
1053
+ if (keyAttribute)
1054
+ throw member.buildCodeFrameError('runOnce: reactive fixed-map keys are unsupported without reconciliation');
1055
+ if (member.parentPath.isCallExpression() && member.key === 'callee')
1056
+ return;
1057
+ member.replaceWith(t.callExpression(member.node, []));
1058
+ member.skip();
1059
+ },
1060
+ });
1061
+ },
1062
+ });
1063
+ // Rebuild references after moving initializer nodes beneath generated
1064
+ // factories, including the direct-alias case `const doubled = count`.
1065
+ const identities = [...reactive].map(([binding, component]) => ({ scope: binding.scope, name: binding.identifier.name, component }));
1066
+ const operationIdentities = [...operationStates].map(binding => ({ scope: binding.scope, name: binding.identifier.name }));
1067
+ const setterIdentities = [...stateSetters].map(([setter, state]) => ({
1068
+ scope: setter.scope,
1069
+ setter: setter.identifier.name,
1070
+ stateScope: state.scope,
1071
+ state: state.identifier.name,
1072
+ }));
1073
+ program.scope.crawl();
1074
+ reactive.clear();
1075
+ for (const { scope, name, component } of identities)
1076
+ reactive.set(scope.getBinding(name), component);
1077
+ operationStates.clear();
1078
+ for (const { scope, name } of operationIdentities) {
1079
+ const binding = scope.getBinding(name);
1080
+ if (binding)
1081
+ operationStates.add(binding);
1082
+ }
1083
+ stateSetters.clear();
1084
+ for (const identity of setterIdentities) {
1085
+ const setter = identity.scope.getBinding(identity.setter);
1086
+ const state = identity.stateScope.getBinding(identity.state);
1087
+ if (setter && state)
1088
+ stateSetters.set(setter, state);
1089
+ }
1090
+ // Give every `useEffect(fn)` the dependency list its callback implies: each
1091
+ // reactive binding the callback reads, in first-read order. The existing
1092
+ // changing-dependency machinery then does the rest -- the list is read live,
1093
+ // and the callback sees a fresh snapshot per run.
1094
+ //
1095
+ // This is deliberately narrower than React, which re-runs such an effect on
1096
+ // every render including ones it has no stake in. Reading nothing reactive
1097
+ // therefore yields `[]` and the effect runs once, which is the only thing
1098
+ // that can be true when the body never runs a second time.
1099
+ program.traverse({
1100
+ CallExpression(path) {
1101
+ if (!inferredEffects.has(path.node))
1102
+ return;
1103
+ const effectCall = path.node;
1104
+ const callback = path.get('arguments')[0];
1105
+ const component = path.getFunctionParent();
1106
+ const dependencies = [];
1107
+ const seen = new Set();
1108
+ callback.traverse({
1109
+ ReferencedIdentifier(reference) {
1110
+ if (generated.has(reference.node))
1111
+ return;
1112
+ const binding = reference.scope.getBinding(reference.node.name);
1113
+ if (!binding || seen.has(binding))
1114
+ return;
1115
+ if (!component || reactive.get(binding) !== component)
1116
+ return;
1117
+ seen.add(binding);
1118
+ // Emit the live read directly. The reference-rewriting pass has
1119
+ // already collected its work from an earlier scope crawl, so a bare
1120
+ // identifier added here would stay the getter itself -- an identity
1121
+ // that never changes, and an effect that never re-runs.
1122
+ dependencies.push(t.callExpression(t.identifier(binding.identifier.name), []));
1123
+ },
1124
+ });
1125
+ path.node.arguments.push(t.arrayExpression(dependencies));
1126
+ if (dependencies.length > 0)
1127
+ changingEffects.add(effectCall);
1128
+ },
1129
+ });
1130
+ const snapshots = new Map();
1131
+ const snapshotFor = (origin, binding) => {
1132
+ const component = reactive.get(binding);
1133
+ let callback;
1134
+ let ancestor = origin.parentPath;
1135
+ while (ancestor && ancestor !== component) {
1136
+ if (ancestor.isFunction() && !generated.has(ancestor.node))
1137
+ callback = ancestor;
1138
+ ancestor = ancestor.parentPath;
1139
+ }
1140
+ if (!callback)
1141
+ throw origin.buildCodeFrameError('runOnce: direct state snapshots require an event callback');
1142
+ const bindings = snapshots.get(callback) ?? new Map();
1143
+ let snapshot = bindings.get(binding);
1144
+ if (!snapshot) {
1145
+ snapshot = callback.scope.generateUidIdentifier(binding.identifier.name);
1146
+ bindings.set(binding, snapshot);
1147
+ snapshots.set(callback, bindings);
1148
+ }
1149
+ return t.cloneNode(snapshot);
1150
+ };
1151
+ // Preserve direct state-value list operations before event capture rewrites
1152
+ // their reads into snapshot locals.
1153
+ program.traverse({
1154
+ CallExpression(path) {
1155
+ const callee = path.get('callee');
1156
+ if (!callee.isIdentifier() || path.node.arguments.length !== 1)
1157
+ return;
1158
+ const setter = callee.scope.getBinding(callee.node.name);
1159
+ const state = setter ? stateSetters.get(setter) : undefined;
1160
+ if (!state)
1161
+ return;
1162
+ const argument = path.get('arguments.0');
1163
+ const isStateReference = (node) => t.isIdentifier(node) && path.scope.getBinding(node.name) === state;
1164
+ if (argument?.isIdentifier()) {
1165
+ const copied = argument.scope.getBinding(argument.node.name);
1166
+ const declaration = copied?.path.isVariableDeclarator() ? copied.path : undefined;
1167
+ const declarationStatement = declaration?.parentPath;
1168
+ const setterStatement = path.parentPath;
1169
+ const block = setterStatement?.parentPath;
1170
+ if (declaration && declarationStatement?.isVariableDeclaration()
1171
+ && setterStatement?.isExpressionStatement() && block?.isBlockStatement()) {
1172
+ const init = declaration.get('init');
1173
+ const statements = block.get('body');
1174
+ const declarationIndex = statements.findIndex(statement => statement.node === declarationStatement.node);
1175
+ const setterIndex = statements.findIndex(statement => statement.node === setterStatement.node);
1176
+ const removeStatement = statements[declarationIndex + 1];
1177
+ const insertStatement = statements[declarationIndex + 2];
1178
+ const removeDeclaration = removeStatement?.isVariableDeclaration()
1179
+ && removeStatement.node.declarations.length === 1 ? removeStatement.get('declarations.0') : undefined;
1180
+ const removeInit = removeDeclaration?.isVariableDeclarator() ? removeDeclaration.get('init') : undefined;
1181
+ const removedId = removeDeclaration?.isVariableDeclarator() && t.isArrayPattern(removeDeclaration.node.id)
1182
+ ? removeDeclaration.node.id.elements[0] : undefined;
1183
+ const removeCall = removeInit?.isCallExpression() ? removeInit : undefined;
1184
+ const insertCall = insertStatement?.isExpressionStatement() && insertStatement.get('expression').isCallExpression()
1185
+ ? insertStatement.get('expression') : undefined;
1186
+ const matchesSplice = (call) => {
1187
+ const callee = call?.get('callee');
1188
+ return Boolean(callee?.isMemberExpression() && !callee.node.computed
1189
+ && callee.get('property').isIdentifier({ name: 'splice' })
1190
+ && callee.get('object').isIdentifier({ name: argument.node.name })
1191
+ && callee.get('object').scope.getBinding(argument.node.name) === copied);
1192
+ };
1193
+ const sliceCallee = init.isCallExpression() ? init.get('callee') : undefined;
1194
+ const copiedState = (init.isArrayExpression() && init.node.elements.length === 1
1195
+ && t.isSpreadElement(init.node.elements[0]) && isStateReference(init.node.elements[0].argument))
1196
+ || (init.isCallExpression() && init.node.arguments.length === 0
1197
+ && sliceCallee?.isMemberExpression() && !sliceCallee.node.computed
1198
+ && sliceCallee.get('property').isIdentifier({ name: 'slice' })
1199
+ && sliceCallee.get('object').isIdentifier()
1200
+ && isStateReference(sliceCallee.get('object').node));
1201
+ const from = removeCall?.get('arguments.0');
1202
+ const to = insertCall?.get('arguments.0');
1203
+ const guarded = from?.isIdentifier() && to?.isIdentifier() && statements.slice(0, declarationIndex).some(statement => {
1204
+ if (!statement.isIfStatement())
1205
+ return false;
1206
+ const consequent = statement.get('consequent');
1207
+ const exits = consequent.isReturnStatement() || (consequent.isBlockStatement()
1208
+ && consequent.get('body').some(entry => entry.isReturnStatement()));
1209
+ if (!exits)
1210
+ return false;
1211
+ let fromLower = false, toLower = false, toUpper = false;
1212
+ statement.get('test').traverse({
1213
+ BinaryExpression(binary) {
1214
+ const left = binary.get('left'), right = binary.get('right');
1215
+ if (binary.node.operator === '<' && left.isIdentifier({ name: from.node.name })
1216
+ && left.scope.getBinding(left.node.name) === from.scope.getBinding(from.node.name)
1217
+ && right.isNumericLiteral({ value: 0 }))
1218
+ fromLower = true;
1219
+ if (binary.node.operator === '<' && left.isIdentifier({ name: to.node.name })
1220
+ && left.scope.getBinding(left.node.name) === to.scope.getBinding(to.node.name)
1221
+ && right.isNumericLiteral({ value: 0 }))
1222
+ toLower = true;
1223
+ if (binary.node.operator === '>=' && left.isIdentifier({ name: to.node.name })
1224
+ && left.scope.getBinding(left.node.name) === to.scope.getBinding(to.node.name)
1225
+ && right.isMemberExpression() && !right.node.computed
1226
+ && right.get('object').isIdentifier()
1227
+ && isStateReference(right.get('object').node)
1228
+ && right.get('property').isIdentifier({ name: 'length' }))
1229
+ toUpper = true;
1230
+ },
1231
+ });
1232
+ return fromLower && toLower && toUpper;
1233
+ });
1234
+ const movedBinding = t.isIdentifier(removedId) ? removeDeclaration?.scope.getBinding(removedId.name) : undefined;
1235
+ if (copiedState
1236
+ && declarationStatement.node.declarations.length === 1
1237
+ && declarationIndex >= 0 && setterIndex === declarationIndex + 3
1238
+ && copied?.referencePaths.length === 3 && movedBinding?.referencePaths.length === 1
1239
+ && guarded
1240
+ && matchesSplice(removeCall) && removeCall.node.arguments.length === 2
1241
+ && t.isNumericLiteral(removeCall.node.arguments[1], { value: 1 })
1242
+ && t.isIdentifier(removedId)
1243
+ && matchesSplice(insertCall) && insertCall.node.arguments.length === 3
1244
+ && t.isNumericLiteral(insertCall.node.arguments[1], { value: 0 })
1245
+ && t.isIdentifier(insertCall.node.arguments[2], { name: removedId.name })
1246
+ && t.isExpression(removeCall.node.arguments[0]) && t.isExpression(insertCall.node.arguments[0])) {
1247
+ operationStates.add(state);
1248
+ const move = t.callExpression(rendererHelper('listMove'), [
1249
+ snapshotFor(path, state),
1250
+ t.cloneNode(removeCall.node.arguments[0]),
1251
+ t.cloneNode(insertCall.node.arguments[0]),
1252
+ ]);
1253
+ move.extra = { rrjsListOperation: true };
1254
+ path.node.arguments[0] = move;
1255
+ deferredRemovals.push(declarationStatement, removeStatement, insertStatement);
1256
+ return;
1257
+ }
1258
+ }
1259
+ }
1260
+ if (argument?.isArrayExpression()) {
1261
+ const [head, ...tail] = argument.node.elements;
1262
+ if (t.isSpreadElement(head) && isStateReference(head.argument)
1263
+ && tail.length > 0 && tail.every((element) => Boolean(element) && t.isExpression(element))) {
1264
+ operationStates.add(state);
1265
+ const operation = t.callExpression(rendererHelper('listAppend'), [
1266
+ snapshotFor(path, state),
1267
+ ...tail.map(element => t.cloneNode(element)),
1268
+ ]);
1269
+ operation.extra = { rrjsListOperation: true };
1270
+ path.node.arguments[0] = operation;
1271
+ }
1272
+ else if (t.isSpreadElement(head)) {
1273
+ throw argument.buildCodeFrameError('runOnce: array replacement from a non-state snapshot is unsupported without identity matching');
1274
+ }
1275
+ }
1276
+ else if (argument?.isCallExpression() && t.isMemberExpression(argument.node.callee)
1277
+ && !argument.node.callee.computed && isStateReference(argument.node.callee.object)
1278
+ && t.isIdentifier(argument.node.callee.property, { name: 'filter' })
1279
+ && argument.node.arguments.length === 1 && t.isExpression(argument.node.arguments[0])) {
1280
+ operationStates.add(state);
1281
+ const operation = t.callExpression(rendererHelper('listFilter'), [
1282
+ snapshotFor(path, state),
1283
+ t.cloneNode(argument.node.arguments[0]),
1284
+ ]);
1285
+ operation.extra = { rrjsListOperation: true };
1286
+ path.node.arguments[0] = operation;
1287
+ }
1288
+ else if (argument?.isCallExpression() && t.isMemberExpression(argument.node.callee)
1289
+ && !argument.node.callee.computed && isStateReference(argument.node.callee.object)
1290
+ && t.isIdentifier(argument.node.callee.property, { name: 'map' })
1291
+ && argument.node.arguments.length === 1 && t.isExpression(argument.node.arguments[0])) {
1292
+ operationStates.add(state);
1293
+ recordMapUpdater(state, argument.node.arguments[0]);
1294
+ const operation = t.callExpression(rendererHelper('listMap'), [
1295
+ snapshotFor(path, state),
1296
+ t.cloneNode(argument.node.arguments[0]),
1297
+ ]);
1298
+ operation.extra = { rrjsListOperation: true };
1299
+ path.node.arguments[0] = operation;
1300
+ }
1301
+ },
1302
+ });
1303
+ const effectSnapshots = new Map();
1304
+ const changingEffectSnapshots = new Map();
1305
+ for (const [binding, component] of reactive) {
1306
+ for (const reference of [...binding.referencePaths]) {
1307
+ const componentName = component.isFunctionDeclaration() ? component.node.id?.name
1308
+ : component.parentPath.isVariableDeclarator() && t.isIdentifier(component.parentPath.node.id)
1309
+ ? component.parentPath.node.id.name : undefined;
1310
+ const customReturn = /^use[A-Z]/.test(componentName ?? '')
1311
+ && reference.parentPath?.isArrayExpression()
1312
+ && reference.findParent(parent => parent.isReturnStatement()
1313
+ && parent.getFunctionParent() === component);
1314
+ if (customReturn)
1315
+ continue;
1316
+ const changingDeps = reference.findParent(parent => parent.isArrayExpression()
1317
+ && parent.parentPath.isCallExpression() && changingEffects.has(parent.parentPath.node));
1318
+ if (changingDeps) {
1319
+ reference.replaceWith(t.callExpression(t.identifier(binding.identifier.name), []));
1320
+ continue;
1321
+ }
1322
+ if (reference.parentPath?.isCallExpression() && reference.key === 'callee'
1323
+ && [...(componentProps.get(component)?.values() ?? [])].includes(binding)) {
1324
+ reference.replaceWith(t.callExpression(t.identifier(binding.identifier.name), []));
1325
+ continue;
1326
+ }
1327
+ // Find the outermost callback below this component. Nested asynchronous
1328
+ // callbacks then close over its snapshot rather than reading future state.
1329
+ let callback;
1330
+ let ancestor = reference.parentPath;
1331
+ let inGenerated = false;
1332
+ while (ancestor && ancestor !== component) {
1333
+ if (generated.has(ancestor.node) || ancestor.node.extra?.rrjsReactiveGetter)
1334
+ inGenerated = true;
1335
+ else if (ancestor.isFunction())
1336
+ callback = ancestor;
1337
+ ancestor = ancestor.parentPath;
1338
+ }
1339
+ if (callback && !inGenerated) {
1340
+ const parent = callback.parentPath;
1341
+ const args = parent.isCallExpression() ? parent.get('arguments') : [];
1342
+ const effectCallback = parent.isCallExpression() && hookName(parent)
1343
+ === 'useEffect' && args[0] === callback && args[1]?.isArrayExpression()
1344
+ && args[1].node.elements.length === 0;
1345
+ const changingEffectCallback = parent.isCallExpression()
1346
+ && changingEffects.has(parent.node) && args[0] === callback;
1347
+ if (changingEffectCallback) {
1348
+ const bindings = changingEffectSnapshots.get(callback) ?? new Map();
1349
+ let snapshot = bindings.get(binding);
1350
+ if (!snapshot) {
1351
+ snapshot = callback.scope.generateUidIdentifier(binding.identifier.name);
1352
+ bindings.set(binding, snapshot);
1353
+ changingEffectSnapshots.set(callback, bindings);
1354
+ }
1355
+ reference.replaceWith(t.cloneNode(snapshot));
1356
+ continue;
1357
+ }
1358
+ const listCallback = parent.isCallExpression() && (() => {
1359
+ const callee = parent.get('callee');
1360
+ if (!callee.isMemberExpression() || callee.node.computed
1361
+ || !callee.get('property').isIdentifier({ name: 'map' }))
1362
+ return false;
1363
+ return (() => {
1364
+ const source = parent.get('callee.object');
1365
+ const identifier = source.isIdentifier() ? source
1366
+ : source.isCallExpression() && source.get('callee').isIdentifier()
1367
+ ? source.get('callee') : undefined;
1368
+ return Boolean(identifier && operationStates.has(identifier.scope.getBinding(identifier.node.name)));
1369
+ })();
1370
+ })();
1371
+ if (listCallback) {
1372
+ reference.replaceWith(t.callExpression(t.identifier(binding.identifier.name), []));
1373
+ continue;
1374
+ }
1375
+ if (effectCallback) {
1376
+ const bindings = effectSnapshots.get(callback) ?? new Map();
1377
+ let snapshot = bindings.get(binding);
1378
+ if (!snapshot) {
1379
+ snapshot = callback.scope.generateUidIdentifier(binding.identifier.name);
1380
+ bindings.set(binding, snapshot);
1381
+ }
1382
+ effectSnapshots.set(callback, bindings);
1383
+ reference.replaceWith(t.cloneNode(snapshot));
1384
+ continue;
1385
+ }
1386
+ if (parent.isCallExpression())
1387
+ throw callback.buildCodeFrameError('runOnce: reactive callback arguments require explicit effect/callback compilation');
1388
+ const bindings = snapshots.get(callback) ?? new Map();
1389
+ let snapshot = bindings.get(binding);
1390
+ if (!snapshot) {
1391
+ snapshot = callback.scope.generateUidIdentifier(binding.identifier.name);
1392
+ bindings.set(binding, snapshot);
1393
+ }
1394
+ snapshots.set(callback, bindings);
1395
+ reference.replaceWith(t.cloneNode(snapshot));
1396
+ }
1397
+ else {
1398
+ const jsx = reference.findParent(parent => parent.isJSXExpressionContainer());
1399
+ const rootReturn = reference.findParent(parent => parent.isReturnStatement() && parent.getFunctionParent() === component);
1400
+ const conditionalRoot = rootReturn?.isReturnStatement() && t.isConditionalExpression(rootReturn.node.argument);
1401
+ if (!inGenerated && !jsx && !conditionalRoot)
1402
+ throw reference.buildCodeFrameError('runOnce: state-dependent component control flow is not yet supported; use a JSX expression or const derivation');
1403
+ reference.replaceWith(t.callExpression(t.identifier(binding.identifier.name), []));
1404
+ }
1405
+ }
1406
+ }
1407
+ for (const [callback, bindings] of effectSnapshots) {
1408
+ const call = callback.parentPath;
1409
+ const statement = call.findParent(parent => parent.isStatement());
1410
+ if (!statement)
1411
+ throw callback.buildCodeFrameError('runOnce: useEffect must be a component statement');
1412
+ // An effect callback may close over a state binding declared later in the
1413
+ // component. Register the callback in its original hook position, but fill
1414
+ // its snapshot after each captured binding has been initialized. Passive
1415
+ // work cannot run until after component construction and commit.
1416
+ const component = reactive.get(bindings.keys().next().value);
1417
+ const body = component.get('body');
1418
+ if (!body.isBlockStatement())
1419
+ throw callback.buildCodeFrameError('runOnce: effect snapshots require a block component body');
1420
+ body.unshiftContainer('body', t.variableDeclaration('let', [...bindings].map(([, snapshot]) => t.variableDeclarator(snapshot))));
1421
+ for (const [binding, snapshot] of bindings) {
1422
+ const declaration = binding.path.findParent(parent => parent.isStatement() && parent.getFunctionParent() === component);
1423
+ if (!declaration)
1424
+ throw callback.buildCodeFrameError('runOnce: effect snapshot binding must be initialized by a component statement');
1425
+ declaration.insertAfter(t.expressionStatement(t.assignmentExpression('=', t.cloneNode(snapshot), t.callExpression(t.identifier(binding.identifier.name), []))));
1426
+ }
1427
+ }
1428
+ for (const [callback, bindings] of changingEffectSnapshots) {
1429
+ if (!t.isBlockStatement(callback.node.body)) {
1430
+ callback.node.body = t.blockStatement([t.returnStatement(callback.node.body)]);
1431
+ }
1432
+ callback.node.body.body.unshift(t.variableDeclaration('const', [...bindings].map(([binding, snapshot]) => t.variableDeclarator(snapshot, t.callExpression(t.identifier(binding.identifier.name), [])))));
1433
+ }
1434
+ for (const effectCall of changingEffects) {
1435
+ const deps = effectCall.arguments[1];
1436
+ if (t.isArrayExpression(deps))
1437
+ effectCall.arguments[1] = t.arrowFunctionExpression([], deps);
1438
+ }
1439
+ for (const [callback, bindings] of snapshots) {
1440
+ const eventReference = (path) => {
1441
+ const container = path.parentPath;
1442
+ const attribute = container?.parentPath;
1443
+ return Boolean(container?.isJSXExpressionContainer() && attribute?.isJSXAttribute() && t.isJSXIdentifier(attribute.node.name) && /^on[A-Z]/.test(attribute.node.name.name));
1444
+ };
1445
+ const eventUse = (path) => eventReference(path)
1446
+ || Boolean(path.findParent(parent => parent.isFunction() && eventReference(parent)));
1447
+ let references = [];
1448
+ if (callback.isFunctionDeclaration() && callback.node.id)
1449
+ references = callback.parentPath.scope.getBinding(callback.node.id.name)?.referencePaths ?? [];
1450
+ else if (callback.parentPath.isVariableDeclarator() && t.isIdentifier(callback.parentPath.node.id))
1451
+ references = callback.parentPath.scope.getBinding(callback.parentPath.node.id.name)?.referencePaths ?? [];
1452
+ if (!eventReference(callback) && (!references.length || references.some(reference => !eventUse(reference))))
1453
+ throw callback.buildCodeFrameError('runOnce: state-capturing functions must be DOM event handlers; escaping callbacks need snapshot compilation');
1454
+ if (!t.isBlockStatement(callback.node.body))
1455
+ callback.node.body = t.blockStatement([t.returnStatement(callback.node.body)]);
1456
+ callback.node.body.body.unshift(t.variableDeclaration('const', [...bindings].map(([binding, snapshot]) => t.variableDeclarator(snapshot, t.callExpression(t.identifier(binding.identifier.name), [])))));
1457
+ }
1458
+ // Build a lazy selector per region. Arm factories execute only on a real
1459
+ // selection change; nested bindings own their own subscriptions.
1460
+ // A portal call is also structural: selecting it constructs an externally
1461
+ // placed subtree whose lifetime is represented by its source-tree anchor.
1462
+ // Mark only an unbound classic-harness helper or an import whose binding is
1463
+ // the named renderer/react-dom export; a same-spelled local is ordinary code.
1464
+ program.traverse({
1465
+ CallExpression(path) {
1466
+ const callee = path.get('callee');
1467
+ if (!callee.isIdentifier({ name: 'createPortal' }))
1468
+ return;
1469
+ const binding = callee.scope.getBinding(callee.node.name);
1470
+ if (binding) {
1471
+ if (!binding.path.isImportSpecifier())
1472
+ return;
1473
+ const imported = binding.path.node.imported;
1474
+ const importedName = t.isIdentifier(imported) ? imported.name : imported.value;
1475
+ const declaration = binding.path.parentPath;
1476
+ if (importedName !== 'createPortal' || !declaration.isImportDeclaration()
1477
+ || !['react-dom', '@rrjs/renderer'].includes(declaration.node.source.value))
1478
+ return;
1479
+ }
1480
+ if (path.node.arguments.length < 2 || !t.isExpression(path.node.arguments[1])) {
1481
+ throw path.buildCodeFrameError('runOnce: createPortal requires a child and a stable target identifier');
1482
+ }
1483
+ const target = path.get('arguments.1');
1484
+ if (!target.isIdentifier()) {
1485
+ throw target.buildCodeFrameError('runOnce: createPortal target must be a stable identifier');
1486
+ }
1487
+ const targetBinding = target.scope.getBinding(target.node.name);
1488
+ if (targetBinding && (reactive.has(targetBinding) || !targetBinding.constant)) {
1489
+ throw target.buildCodeFrameError('runOnce: changing createPortal targets are unsupported');
1490
+ }
1491
+ if (path.node.arguments.length > 2 && !t.isNullLiteral(path.node.arguments[2])) {
1492
+ throw path.get('arguments.2').buildCodeFrameError('runOnce: createPortal keys are unsupported');
1493
+ }
1494
+ path.node.extra = { ...(path.node.extra ?? {}), rrjsRegion: true };
1495
+ },
1496
+ });
1497
+ const structural = (node) => {
1498
+ if (t.isJSXElement(node) || t.isJSXFragment(node) || node.extra?.rrjsRegion)
1499
+ return true;
1500
+ if (t.isConditionalExpression(node))
1501
+ return structural(node.consequent) || structural(node.alternate);
1502
+ if (t.isLogicalExpression(node))
1503
+ return structural(node.left) || structural(node.right);
1504
+ return false;
1505
+ };
1506
+ const children = (node) => node.children.filter(child => !t.isJSXText(child) || child.value.trim().length > 0 || !/[\r\n]/.test(child.value));
1507
+ const childExpr = (child) => !child ? t.nullLiteral()
1508
+ : t.isJSXText(child) ? t.stringLiteral(child.value)
1509
+ : t.isJSXExpressionContainer(child) ? t.isJSXEmptyExpression(child.expression) ? t.nullLiteral() : child.expression
1510
+ : t.isJSXSpreadChild(child) ? child.expression : child;
1511
+ function gateByLazyAncestors(origin, test) {
1512
+ const component = origin.getFunctionParent();
1513
+ let gated = test;
1514
+ let cursor = origin;
1515
+ while (cursor?.parentPath && cursor.parentPath !== component) {
1516
+ const parent = cursor.parentPath;
1517
+ if (parent.isConditionalExpression()) {
1518
+ if (cursor.key === 'consequent') {
1519
+ gated = t.conditionalExpression(t.cloneNode(parent.node.test), gated, t.booleanLiteral(false));
1520
+ }
1521
+ else if (cursor.key === 'alternate') {
1522
+ gated = t.conditionalExpression(t.cloneNode(parent.node.test), t.booleanLiteral(false), gated);
1523
+ }
1524
+ }
1525
+ else if (parent.isLogicalExpression() && cursor.key === 'right') {
1526
+ if (parent.node.operator === '&&') {
1527
+ gated = t.conditionalExpression(t.cloneNode(parent.node.left), gated, t.booleanLiteral(false));
1528
+ }
1529
+ else if (parent.node.operator === '||') {
1530
+ gated = t.conditionalExpression(t.cloneNode(parent.node.left), t.booleanLiteral(false), gated);
1531
+ }
1532
+ }
1533
+ cursor = parent;
1534
+ }
1535
+ return gated;
1536
+ }
1537
+ function cacheCondition(origin, test) {
1538
+ const component = origin.getFunctionParent();
1539
+ const statement = origin.findParent(parent => parent.isStatement() && parent.getFunctionParent() === component);
1540
+ if (!component || !components.has(component) || !statement) {
1541
+ throw origin.buildCodeFrameError('runOnce: common branch conditions require a component statement');
1542
+ }
1543
+ const binding = component.scope.generateUidIdentifier('condition');
1544
+ const factory = t.arrowFunctionExpression([], gateByLazyAncestors(origin, test));
1545
+ statement.insertBefore(t.variableDeclaration('const', [
1546
+ t.variableDeclarator(t.cloneNode(binding), t.callExpression(derive(), [factory])),
1547
+ ]));
1548
+ return t.callExpression(t.cloneNode(binding), []);
1549
+ }
1550
+ function select(test, yes, no, origin, passthrough, cached = false) {
1551
+ // Unkeyed fragment syntax has the same positional identity semantics as its
1552
+ // single child. Peel that transparent layer before merging common elements.
1553
+ if (t.isJSXFragment(yes) && t.isJSXFragment(no)) {
1554
+ const yesChildren = children(yes), noChildren = children(no);
1555
+ if (yesChildren.length === 1 && noChildren.length === 1) {
1556
+ return select(test, childExpr(yesChildren[0]), childExpr(noChildren[0]), origin, passthrough, cached);
1557
+ }
1558
+ }
1559
+ // Compile common native structure once. This comparison is at compile time;
1560
+ // the generated runtime has no old/new element-tree matching operation.
1561
+ if (t.isJSXElement(yes) && t.isJSXElement(no)
1562
+ && t.isJSXIdentifier(yes.openingElement.name) && t.isJSXIdentifier(no.openingElement.name)
1563
+ && yes.openingElement.name.name === no.openingElement.name.name) {
1564
+ const attrs = (node) => new Map(node.openingElement.attributes
1565
+ .filter((attr) => t.isJSXAttribute(attr) && t.isJSXIdentifier(attr.name))
1566
+ .map(attr => [attr.name.name, attr]));
1567
+ const a = attrs(yes), b = attrs(no);
1568
+ const same = (left, right) => !left || !right ? left === right : t.isNodesEquivalent(left, right);
1569
+ if (same(a.get('key'), b.get('key'))) {
1570
+ if (yes.openingElement.attributes.some(attr => t.isJSXSpreadAttribute(attr)) || no.openingElement.attributes.some(attr => t.isJSXSpreadAttribute(attr))) {
1571
+ throw program.buildCodeFrameError('runOnce: common branch elements with spread props require further analysis');
1572
+ }
1573
+ const condition = cached ? test : cacheCondition(origin, test);
1574
+ const expression = (attr) => !attr ? t.unaryExpression('void', t.numericLiteral(0))
1575
+ : !attr.value ? t.booleanLiteral(true)
1576
+ : t.isJSXExpressionContainer(attr.value) ? attr.value.expression : attr.value;
1577
+ const merged = t.cloneNode(yes);
1578
+ merged.openingElement.attributes = [...new Set([...a.keys(), ...b.keys()])].map(name => {
1579
+ if (same(a.get(name), b.get(name)))
1580
+ return t.cloneNode(a.get(name));
1581
+ if (/^on[A-Z]/.test(name) || name === 'ref')
1582
+ throw program.buildCodeFrameError('runOnce: changing event/ref props across common branch elements require commit compilation');
1583
+ return t.jsxAttribute(t.jsxIdentifier(name), t.jsxExpressionContainer(t.conditionalExpression(t.cloneNode(condition), expression(a.get(name)), expression(b.get(name)))));
1584
+ });
1585
+ const ac = children(yes), bc = children(no);
1586
+ merged.children = Array.from({ length: Math.max(ac.length, bc.length) }, (_, index) => {
1587
+ const left = childExpr(ac[index]), right = childExpr(bc[index]);
1588
+ return t.jsxExpressionContainer(structural(left) || structural(right)
1589
+ ? select(t.cloneNode(condition), left, right, origin, undefined, true) : t.conditionalExpression(t.cloneNode(condition), left, right));
1590
+ });
1591
+ return merged;
1592
+ }
1593
+ }
1594
+ const arm = (node, side) => passthrough === side
1595
+ ? (() => {
1596
+ const condition = origin.scope.generateUidIdentifier('condition');
1597
+ return t.arrowFunctionExpression([condition], t.arrowFunctionExpression([], t.callExpression(t.cloneNode(condition), [])));
1598
+ })()
1599
+ : t.arrowFunctionExpression([], t.isJSXElement(node) || t.isJSXFragment(node) || node.extra?.rrjsRegion
1600
+ ? node : t.arrowFunctionExpression([], node));
1601
+ const call = t.callExpression(choose(), [t.arrowFunctionExpression([], test), arm(yes, 'yes'), arm(no, 'no')]);
1602
+ call.extra = { rrjsRegion: true };
1603
+ return call;
1604
+ }
1605
+ program.traverse({
1606
+ ConditionalExpression: { exit(path) {
1607
+ if (!structural(path.node))
1608
+ return;
1609
+ path.replaceWith(select(path.node.test, path.node.consequent, path.node.alternate, path));
1610
+ path.skip();
1611
+ } },
1612
+ LogicalExpression: { exit(path) {
1613
+ if (!structural(path.node) || path.node.operator === '??')
1614
+ return;
1615
+ const { left, right, operator } = path.node;
1616
+ path.replaceWith(operator === '&&'
1617
+ ? select(left, right, t.cloneNode(left), path, 'no')
1618
+ : select(left, t.cloneNode(left), right, path, 'yes'));
1619
+ path.skip();
1620
+ } },
1621
+ });
1622
+ // A direct local prop getter retains the parent's operation-bearing array
1623
+ // unchanged. Resolve bindings after scope rebuilding and propagate only that
1624
+ // exact identity edge; aliases, spreads and computed wrappers remain rejected.
1625
+ let propagated = true;
1626
+ while (propagated) {
1627
+ propagated = false;
1628
+ for (const edge of propSources) {
1629
+ const parent = edge.parentScope.getBinding(edge.parentName);
1630
+ const child = edge.childScope.getBinding(edge.childName);
1631
+ if (!parent || !child || operationStates.has(child))
1632
+ continue;
1633
+ operationStates.add(parent);
1634
+ operationStates.add(child);
1635
+ const parentMappers = mapUpdaters.get(parent);
1636
+ if (parentMappers)
1637
+ mapUpdaters.set(child, parentMappers);
1638
+ propagated = true;
1639
+ }
1640
+ }
1641
+ // Phase 5 first slice: retain source-visible append provenance. The state
1642
+ // value remains an ordinary array; non-enumerable metadata carries the exact
1643
+ // operation to the owned list region.
1644
+ program.traverse({
1645
+ CallExpression: { exit(path) {
1646
+ const callee = path.get('callee');
1647
+ if (callee.isIdentifier()) {
1648
+ const setterBinding = callee.scope.getBinding(callee.node.name);
1649
+ if (setterBinding && stateSetters.has(setterBinding) && path.node.arguments.length === 1) {
1650
+ const stateBinding = stateSetters.get(setterBinding);
1651
+ const updater = path.get('arguments.0');
1652
+ const operationHelpers = new Set(['listAppend', 'listPrepend', 'listClear', 'listTruncate', 'listSplice', 'listReverse', 'listFilter', 'listSort', 'listMap', 'listMove']);
1653
+ const generatedOperation = updater?.isCallExpression() && updater.get('callee').isIdentifier() && (() => {
1654
+ const helper = updater.get('callee');
1655
+ if (operationHelpers.has(helper.node.name))
1656
+ return true;
1657
+ const helperBinding = helper.scope.getBinding(helper.node.name);
1658
+ const imported = helperBinding?.path.isImportSpecifier() ? helperBinding.path.node.imported : undefined;
1659
+ return t.isIdentifier(imported) && operationHelpers.has(imported.name);
1660
+ })();
1661
+ let recognized = Boolean(updater?.node.extra?.rrjsListOperation || generatedOperation);
1662
+ const isSnapshot = (node) => {
1663
+ if (!t.isIdentifier(node))
1664
+ return false;
1665
+ const binding = path.scope.getBinding(node.name);
1666
+ if (binding === stateBinding)
1667
+ return true;
1668
+ const init = binding?.path.isVariableDeclarator() ? binding.path.node.init : undefined;
1669
+ return Boolean(binding && t.isCallExpression(init) && init.arguments.length === 0
1670
+ && t.isIdentifier(init.callee)
1671
+ && binding.path.scope.getBinding(init.callee.name) === stateBinding);
1672
+ };
1673
+ if (updater?.isArrayExpression() && updater.node.elements.length === 0) {
1674
+ recognized = true;
1675
+ operationStates.add(stateBinding);
1676
+ path.node.arguments[0] = t.callExpression(rendererHelper('listClear'), [
1677
+ t.callExpression(t.identifier(stateBinding.identifier.name), []),
1678
+ ]);
1679
+ }
1680
+ if (updater?.isArrayExpression()) {
1681
+ const [head, ...tail] = updater.node.elements;
1682
+ if (t.isSpreadElement(head) && isSnapshot(head.argument)
1683
+ && tail.length > 0 && tail.every((element) => Boolean(element) && t.isExpression(element))) {
1684
+ recognized = true;
1685
+ operationStates.add(stateBinding);
1686
+ path.node.arguments[0] = t.callExpression(rendererHelper('listAppend'), [
1687
+ t.cloneNode(head.argument),
1688
+ ...tail.map(element => t.cloneNode(element)),
1689
+ ]);
1690
+ }
1691
+ }
1692
+ if (updater?.isCallExpression() && t.isMemberExpression(updater.node.callee)
1693
+ && !updater.node.callee.computed && isSnapshot(updater.node.callee.object)
1694
+ && t.isIdentifier(updater.node.callee.property, { name: 'filter' })
1695
+ && updater.node.arguments.length === 1 && t.isExpression(updater.node.arguments[0])) {
1696
+ recognized = true;
1697
+ operationStates.add(stateBinding);
1698
+ path.node.arguments[0] = t.callExpression(rendererHelper('listFilter'), [
1699
+ t.cloneNode(updater.node.callee.object),
1700
+ t.cloneNode(updater.node.arguments[0]),
1701
+ ]);
1702
+ }
1703
+ if (updater?.isCallExpression() && t.isMemberExpression(updater.node.callee)
1704
+ && !updater.node.callee.computed && isSnapshot(updater.node.callee.object)
1705
+ && t.isIdentifier(updater.node.callee.property, { name: 'map' })
1706
+ && updater.node.arguments.length === 1 && t.isExpression(updater.node.arguments[0])) {
1707
+ recognized = true;
1708
+ operationStates.add(stateBinding);
1709
+ recordMapUpdater(stateBinding, updater.node.arguments[0]);
1710
+ path.node.arguments[0] = t.callExpression(rendererHelper('listMap'), [
1711
+ t.cloneNode(updater.node.callee.object),
1712
+ t.cloneNode(updater.node.arguments[0]),
1713
+ ]);
1714
+ }
1715
+ if (updater?.isArrowFunctionExpression() && updater.node.params.length === 1
1716
+ && t.isIdentifier(updater.node.params[0]) && t.isArrayExpression(updater.node.body)) {
1717
+ const elements = updater.node.body.elements;
1718
+ const [head, ...tail] = elements;
1719
+ const last = elements.at(-1);
1720
+ const prefix = elements.slice(0, -1);
1721
+ if (t.isSpreadElement(head) && t.isIdentifier(head.argument, { name: updater.node.params[0].name })
1722
+ && tail.length > 0 && tail.every((element) => Boolean(element) && t.isExpression(element))) {
1723
+ recognized = true;
1724
+ operationStates.add(stateBinding);
1725
+ updater.node.body = t.callExpression(rendererHelper('listAppend'), [
1726
+ t.cloneNode(updater.node.params[0]),
1727
+ ...tail.map(element => t.cloneNode(element)),
1728
+ ]);
1729
+ }
1730
+ else if (t.isSpreadElement(last) && t.isIdentifier(last.argument, { name: updater.node.params[0].name })
1731
+ && prefix.length > 0 && prefix.every((element) => Boolean(element) && t.isExpression(element))) {
1732
+ recognized = true;
1733
+ operationStates.add(stateBinding);
1734
+ updater.node.body = t.callExpression(rendererHelper('listPrepend'), [
1735
+ t.cloneNode(updater.node.params[0]),
1736
+ ...prefix.map(element => t.cloneNode(element)),
1737
+ ]);
1738
+ }
1739
+ }
1740
+ if (updater?.isArrowFunctionExpression() && updater.node.params.length === 1
1741
+ && t.isIdentifier(updater.node.params[0]) && t.isCallExpression(updater.node.body)
1742
+ && t.isMemberExpression(updater.node.body.callee) && !updater.node.body.callee.computed
1743
+ && t.isIdentifier(updater.node.body.callee.object, { name: updater.node.params[0].name })
1744
+ && t.isIdentifier(updater.node.body.callee.property, { name: 'slice' })
1745
+ && updater.node.body.arguments.length === 2
1746
+ && t.isNumericLiteral(updater.node.body.arguments[0], { value: 0 })
1747
+ && t.isUnaryExpression(updater.node.body.arguments[1], { operator: '-' })
1748
+ && t.isNumericLiteral(updater.node.body.arguments[1].argument, { value: 1 })) {
1749
+ recognized = true;
1750
+ operationStates.add(stateBinding);
1751
+ updater.node.body = t.callExpression(rendererHelper('listTruncate'), [
1752
+ t.cloneNode(updater.node.params[0]),
1753
+ t.binaryExpression('-', t.memberExpression(t.cloneNode(updater.node.params[0]), t.identifier('length')), t.numericLiteral(1)),
1754
+ ]);
1755
+ }
1756
+ if (updater?.isArrowFunctionExpression() && updater.node.params.length === 1
1757
+ && t.isIdentifier(updater.node.params[0]) && t.isCallExpression(updater.node.body)
1758
+ && t.isMemberExpression(updater.node.body.callee) && !updater.node.body.callee.computed
1759
+ && t.isIdentifier(updater.node.body.callee.object, { name: updater.node.params[0].name })
1760
+ && t.isIdentifier(updater.node.body.callee.property, { name: 'toSpliced' })
1761
+ && updater.node.body.arguments.length >= 2
1762
+ && updater.node.body.arguments.every(argument => t.isExpression(argument))) {
1763
+ recognized = true;
1764
+ operationStates.add(stateBinding);
1765
+ updater.node.body = t.callExpression(rendererHelper('listSplice'), [
1766
+ t.cloneNode(updater.node.params[0]),
1767
+ ...updater.node.body.arguments.map(argument => t.cloneNode(argument)),
1768
+ ]);
1769
+ }
1770
+ if (updater?.isArrowFunctionExpression() && updater.node.params.length === 1
1771
+ && t.isIdentifier(updater.node.params[0]) && t.isCallExpression(updater.node.body)
1772
+ && t.isMemberExpression(updater.node.body.callee) && !updater.node.body.callee.computed
1773
+ && t.isIdentifier(updater.node.body.callee.object, { name: updater.node.params[0].name })
1774
+ && t.isIdentifier(updater.node.body.callee.property, { name: 'toReversed' })
1775
+ && updater.node.body.arguments.length === 0) {
1776
+ recognized = true;
1777
+ operationStates.add(stateBinding);
1778
+ updater.node.body = t.callExpression(rendererHelper('listReverse'), [t.cloneNode(updater.node.params[0])]);
1779
+ }
1780
+ if (updater?.isArrowFunctionExpression() && updater.node.params.length === 1
1781
+ && t.isIdentifier(updater.node.params[0]) && t.isCallExpression(updater.node.body)
1782
+ && t.isMemberExpression(updater.node.body.callee) && !updater.node.body.callee.computed
1783
+ && t.isIdentifier(updater.node.body.callee.object, { name: updater.node.params[0].name })
1784
+ && t.isIdentifier(updater.node.body.callee.property, { name: 'filter' })
1785
+ && updater.node.body.arguments.length === 1 && t.isExpression(updater.node.body.arguments[0])) {
1786
+ recognized = true;
1787
+ operationStates.add(stateBinding);
1788
+ updater.node.body = t.callExpression(rendererHelper('listFilter'), [
1789
+ t.cloneNode(updater.node.params[0]),
1790
+ t.cloneNode(updater.node.body.arguments[0]),
1791
+ ]);
1792
+ }
1793
+ if (updater?.isArrowFunctionExpression() && updater.node.params.length === 1
1794
+ && t.isIdentifier(updater.node.params[0]) && t.isCallExpression(updater.node.body)
1795
+ && t.isMemberExpression(updater.node.body.callee) && !updater.node.body.callee.computed
1796
+ && t.isIdentifier(updater.node.body.callee.object, { name: updater.node.params[0].name })
1797
+ && t.isIdentifier(updater.node.body.callee.property, { name: 'toSorted' })
1798
+ && updater.node.body.arguments.length <= 1
1799
+ && updater.node.body.arguments.every(argument => t.isExpression(argument))) {
1800
+ recognized = true;
1801
+ operationStates.add(stateBinding);
1802
+ updater.node.body = t.callExpression(rendererHelper('listSort'), [
1803
+ t.cloneNode(updater.node.params[0]),
1804
+ ...updater.node.body.arguments.map(argument => t.cloneNode(argument)),
1805
+ ]);
1806
+ }
1807
+ if (!recognized && updater?.isArrayExpression()
1808
+ && updater.node.elements.some(element => t.isSpreadElement(element))) {
1809
+ throw updater.buildCodeFrameError('runOnce: array replacement from a non-state snapshot is unsupported without identity matching');
1810
+ }
1811
+ if (!recognized && operationStates.has(stateBinding)) {
1812
+ throw updater.buildCodeFrameError('runOnce: opaque list replacement is unsupported without direct operation provenance');
1813
+ }
1814
+ }
1815
+ }
1816
+ if (!callee.isMemberExpression() || callee.node.computed
1817
+ || !callee.get('property').isIdentifier({ name: 'map' }) || path.node.arguments.length !== 1)
1818
+ return;
1819
+ const source = callee.get('object');
1820
+ const sourceIdentifier = source.isIdentifier() ? source
1821
+ : source.isCallExpression() && source.node.arguments.length === 0 && source.get('callee').isIdentifier()
1822
+ ? source.get('callee') : undefined;
1823
+ if (!sourceIdentifier)
1824
+ return;
1825
+ const sourceBinding = sourceIdentifier.scope.getBinding(sourceIdentifier.node.name);
1826
+ if (!sourceBinding || !reactive.has(sourceBinding) || !operationStates.has(sourceBinding))
1827
+ return;
1828
+ const callback = path.get('arguments.0');
1829
+ if (!callback?.isArrowFunctionExpression() || callback.node.async
1830
+ || callback.node.params.length < 1 || callback.node.params.length > 2
1831
+ || !callback.node.params.every(parameter => t.isIdentifier(parameter)))
1832
+ return;
1833
+ const returned = t.isJSXElement(callback.node.body) ? callback.node.body
1834
+ : t.isBlockStatement(callback.node.body)
1835
+ ? [...callback.node.body.body].reverse().find(statement => t.isReturnStatement(statement))?.argument
1836
+ : undefined;
1837
+ if (!returned || !t.isJSXElement(returned))
1838
+ return;
1839
+ const key = returned.openingElement.attributes.find(attribute => t.isJSXAttribute(attribute)
1840
+ && t.isJSXIdentifier(attribute.name, { name: 'key' }));
1841
+ if (!key)
1842
+ return;
1843
+ const mappedByState = mapUpdaters.get(sourceBinding) ?? (() => {
1844
+ for (const edge of propSources) {
1845
+ const child = edge.childScope.getBinding(edge.childName);
1846
+ const parent = edge.parentScope.getBinding(edge.parentName);
1847
+ if (child === sourceBinding && parent && mapUpdaters.has(parent))
1848
+ return mapUpdaters.get(parent);
1849
+ }
1850
+ return undefined;
1851
+ })();
1852
+ const item = callback.node.params[0];
1853
+ const keyValue = t.isJSXAttribute(key) && t.isJSXExpressionContainer(key.value)
1854
+ ? key.value.expression : undefined;
1855
+ const property = t.isIdentifier(item) && t.isMemberExpression(keyValue)
1856
+ && !keyValue.computed && t.isIdentifier(keyValue.object, { name: item.name })
1857
+ && t.isIdentifier(keyValue.property) ? keyValue.property.name : undefined;
1858
+ const owner = path.getFunctionParent();
1859
+ const propName = owner ? [...(componentProps.get(owner)?.entries() ?? [])]
1860
+ .find(([, binding]) => binding.identifier.name === sourceBinding.identifier.name
1861
+ && binding.scope === sourceBinding.scope)?.[0] : undefined;
1862
+ const contractedKey = propName ? moduleMetadata?.operationKeyProps?.[propName] : undefined;
1863
+ if (contractedKey && property !== contractedKey) {
1864
+ throw path.buildCodeFrameError(`runOnce: operation prop ${propName} must use direct JSX key ${contractedKey}`);
1865
+ }
1866
+ let stableKeyProperty = contractedKey;
1867
+ if (mappedByState?.length) {
1868
+ if (!property || mappedByState.some(mapper => !mapperPreservesProperty(mapper, property))) {
1869
+ throw path.buildCodeFrameError('runOnce: listMap must statically preserve the direct-property JSX key');
1870
+ }
1871
+ stableKeyProperty = property;
1872
+ }
1873
+ if (t.isIdentifier(callback.node.params[0]) && t.isBlockStatement(callback.node.body)) {
1874
+ const itemBinding = callback.scope.getBinding(callback.node.params[0].name);
1875
+ const callbackBody = callback.get('body');
1876
+ if (!callbackBody.isBlockStatement())
1877
+ throw callback.buildCodeFrameError('runOnce: expected a list callback block');
1878
+ for (const statement of callbackBody.get('body')) {
1879
+ if (!statement.isVariableDeclaration())
1880
+ continue;
1881
+ for (const declaration of statement.get('declarations')) {
1882
+ const init = declaration.get('init');
1883
+ if (!init.isExpression())
1884
+ continue;
1885
+ const properties = new Set();
1886
+ init.traverse({
1887
+ Identifier(reference) {
1888
+ if (!itemBinding || reference.scope.getBinding(reference.node.name) !== itemBinding)
1889
+ return;
1890
+ const member = reference.parentPath;
1891
+ if (member.isMemberExpression() && reference.key === 'object'
1892
+ && !member.node.computed && t.isIdentifier(member.node.property)) {
1893
+ properties.add(member.node.property.name);
1894
+ }
1895
+ else {
1896
+ properties.add('*');
1897
+ }
1898
+ },
1899
+ });
1900
+ if (properties.size && (!stableKeyProperty
1901
+ || [...properties].some(property => property !== stableKeyProperty))) {
1902
+ throw declaration.buildCodeFrameError('runOnce: listMap callback locals derived from mutable item fields require row-owned reactive compilation');
1903
+ }
1904
+ }
1905
+ }
1906
+ }
1907
+ if (callback.node.params.length === 2 && t.isIdentifier(callback.node.params[1])) {
1908
+ const indexBinding = callback.scope.getBinding(callback.node.params[1].name);
1909
+ for (const reference of indexBinding?.referencePaths ?? []) {
1910
+ reference.replaceWith(t.callExpression(t.cloneNode(callback.node.params[1]), []));
1911
+ }
1912
+ }
1913
+ path.replaceWith(t.callExpression(rendererHelper('operationList'), [
1914
+ t.arrowFunctionExpression([], source.isCallExpression()
1915
+ ? t.cloneNode(source.node) : t.callExpression(t.cloneNode(sourceIdentifier.node), [])),
1916
+ t.cloneNode(callback.node),
1917
+ ]));
1918
+ path.skip();
1919
+ } },
1920
+ });
1921
+ for (const edge of importedOperationEdges) {
1922
+ const state = edge.scope.getBinding(edge.state);
1923
+ const mappers = state ? mapUpdaters.get(state) : undefined;
1924
+ if (mappers?.some(mapper => !mapperPreservesProperty(mapper, edge.key))) {
1925
+ throw edge.origin.buildCodeFrameError(`runOnce: imported operation prop ${edge.prop} must preserve JSX key ${edge.key}`);
1926
+ }
1927
+ }
1928
+ // A rejected construct must not quietly route through the keyed reconciler.
1929
+ program.traverse({
1930
+ JSXOpeningElement(path) {
1931
+ if (!t.isJSXIdentifier(path.node.name, { name: 'input' }))
1932
+ return;
1933
+ const attributes = path.get('attributes');
1934
+ const hasChange = attributes.some(attribute => attribute.isJSXAttribute()
1935
+ && t.isJSXIdentifier(attribute.node.name, { name: 'onChange' }));
1936
+ if (!hasChange)
1937
+ return;
1938
+ const type = attributes.find(attribute => attribute.isJSXAttribute()
1939
+ && t.isJSXIdentifier(attribute.node.name, { name: 'type' }));
1940
+ if (!type?.isJSXAttribute() || type.node.value == null || t.isStringLiteral(type.node.value))
1941
+ return;
1942
+ if (t.isJSXExpressionContainer(type.node.value)
1943
+ && t.isStringLiteral(type.node.value.expression))
1944
+ return;
1945
+ throw type.buildCodeFrameError('runOnce: dynamic input type with onChange is unsupported because the native event mapping can change');
1946
+ },
1947
+ CallExpression(path) {
1948
+ const name = hookName(path);
1949
+ const callee = path.node.callee;
1950
+ const hookSyntax = t.isIdentifier(callee) ? callee.name : t.isMemberExpression(callee) && t.isIdentifier(callee.property) ? callee.property.name : '';
1951
+ const localHookBinding = t.isIdentifier(callee) ? path.scope.getBinding(callee.name) : undefined;
1952
+ const localHook = Boolean(localHookBinding
1953
+ && [...customHooks.keys()].some(binding => binding.path.node === localHookBinding.path.node));
1954
+ const analyzedHook = Boolean(t.isIdentifier(callee) && isImportedReactiveHook(localHookBinding));
1955
+ if (/^use[A-Z]/.test(hookSyntax) && !name && !localHook && !analyzedHook)
1956
+ throw path.buildCodeFrameError('runOnce: custom and namespace hook calls require additional compiler analysis; use named supported hooks');
1957
+ const supportedHooks = new Set(['useState', 'useRef', 'useEffect', 'useContext', 'useCallback', 'useMemo', 'useReducer']);
1958
+ if (name && /^use[A-Z]/.test(name) && !supportedHooks.has(name))
1959
+ throw path.buildCodeFrameError(`runOnce: ${name} is not supported by this compiler pass yet`);
1960
+ if (t.isMemberExpression(path.node.callee) && t.isIdentifier(path.node.callee.property, { name: 'map' })) {
1961
+ const object = path.get('callee.object');
1962
+ const binding = object.isIdentifier() ? object.scope.getBinding(object.node.name) : undefined;
1963
+ // A const array literal that nothing mutates is fixed for as long as the
1964
+ // component exists, so its rows can be emitted once. Module scope shows
1965
+ // that trivially; a declaration inside the component qualifies too,
1966
+ // because the body runs once.
1967
+ //
1968
+ // An array that reads state, a derived value, a prop or context is a
1969
+ // reactive binding by the time this runs, and reactive bindings are read
1970
+ // live -- the call site is `xs().map(...)`. Its callee object is a call
1971
+ // rather than an identifier, so no binding is resolved here and the
1972
+ // array is refused instead of being emitted once and left stale. The
1973
+ // `refuses a fixed array whose contents read ...` cases in
1974
+ // apps/compat-audit/tests/runonce-constructs.test.ts pin that.
1975
+ const onlyMapped = (candidate) => candidate.referencePaths.every(reference => {
1976
+ const member = reference.parentPath;
1977
+ const call = member?.parentPath;
1978
+ return Boolean(member?.isMemberExpression() && reference.key === 'object' && !member.node.computed
1979
+ && member.get('property').isIdentifier({ name: 'map' })
1980
+ && call?.isCallExpression() && member.key === 'callee');
1981
+ });
1982
+ const literal = binding?.constant && binding.path.isVariableDeclarator()
1983
+ && t.isArrayExpression(binding.path.node.init) ? binding.path.get('init') : undefined;
1984
+ const ownedByThisComponent = Boolean(binding
1985
+ && binding.scope.getFunctionParent() === path.scope.getFunctionParent());
1986
+ const fixedArray = Boolean(literal && onlyMapped(binding)
1987
+ && (binding.scope.path.isProgram() || ownedByThisComponent));
1988
+ const staticArray = object.isArrayExpression() || fixedArray;
1989
+ if (!staticArray)
1990
+ throw path.buildCodeFrameError('runOnce: lists require direct operation compilation; keyed reconciliation is disabled');
1991
+ path.node.extra = { ...(path.node.extra ?? {}), rrjsStaticMap: true };
1992
+ }
1993
+ if (t.isIdentifier(path.node.callee)) {
1994
+ const binding = path.scope.getBinding(path.node.callee.name);
1995
+ const imported = binding?.path.isImportSpecifier() ? binding.path.node.imported : undefined;
1996
+ if ((!binding && path.node.callee.name === 'list') || (imported && t.isIdentifier(imported, { name: 'list' })))
1997
+ throw path.buildCodeFrameError('runOnce: explicit keyed reconciler calls are disabled');
1998
+ }
1999
+ },
2000
+ });
2001
+ for (const path of deferredRemovals)
2002
+ if (!path.removed)
2003
+ path.remove();
2004
+ }