octane 0.1.3 → 0.1.4

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.
@@ -37,22 +37,20 @@ import {
37
37
  import { print as esrapPrint } from 'esrap';
38
38
  import esrapTsx from 'esrap/languages/tsx';
39
39
 
40
- const VOID_ELEMENTS = new Set([
41
- 'area',
42
- 'base',
43
- 'br',
44
- 'col',
45
- 'embed',
46
- 'hr',
47
- 'img',
48
- 'input',
49
- 'link',
50
- 'meta',
51
- 'param',
52
- 'source',
53
- 'track',
54
- 'wbr',
55
- ]);
40
+ // DOM truth tables shared with the client/server runtimes (via constants.ts) —
41
+ // static bakes and dynamic writes MUST agree on which attributes render, under
42
+ // what name, and in what form, or client/SSR/hydration drift apart. See the
43
+ // dom-tables.js header for per-table semantics.
44
+ import {
45
+ VOID_ELEMENTS,
46
+ BOOLEAN_ATTR_PROPS,
47
+ MUST_USE_PROPERTY_PROPS,
48
+ SVG_ONLY_TAGS,
49
+ ATTRIBUTE_ALIASES,
50
+ isEnumeratedBooleanAttr,
51
+ cssStyleValue,
52
+ hyphenateStyleName,
53
+ } from '../dom-tables.js';
56
54
 
57
55
  // React parity: a void element must neither have children nor use
58
56
  // `dangerouslySetInnerHTML` — React throws (ReactDOMComponent-test.js:1794/:1807).
@@ -100,6 +98,129 @@ function rejectVoidElementContent(tag, node, ctx) {
100
98
  );
101
99
  }
102
100
 
101
+ // Controlled-form binding kinds: `value`/`checked`/`defaultValue`/
102
+ // `defaultChecked` on <input>/<textarea>/<select> route to the runtime
103
+ // property helpers (setValue & co — React-parity controlled semantics on
104
+ // native events) instead of setAttribute. STATIC literals included: baking
105
+ // `value="a"` into the template would freeze the attribute instead of driving
106
+ // the property. Everything else — <option> (its `value` attribute is what the
107
+ // select projection reads), custom elements, non-form tags — keeps plain
108
+ // attribute semantics. Mirrors the runtime's setAttribute routing branch.
109
+ function controlledKindFor(tag, attrName) {
110
+ if (tag === 'input') {
111
+ if (attrName === 'value') return 'value';
112
+ if (attrName === 'checked') return 'checked';
113
+ if (attrName === 'defaultValue') return 'defaultValue';
114
+ if (attrName === 'defaultChecked') return 'defaultChecked';
115
+ return null;
116
+ }
117
+ if (tag === 'textarea') {
118
+ if (attrName === 'value') return 'value';
119
+ if (attrName === 'defaultValue') return 'defaultValue';
120
+ return null;
121
+ }
122
+ if (tag === 'select') {
123
+ if (attrName === 'value') return 'selectValue';
124
+ if (attrName === 'defaultValue') return 'defaultValue';
125
+ return null;
126
+ }
127
+ return null;
128
+ }
129
+
130
+ // The `_$`-aliased runtime helper for each controlled binding kind
131
+ // (+ autoFocus, which shares the routing but is mount-only).
132
+ const CONTROLLED_KIND_HELPERS = {
133
+ value: 'setValue',
134
+ checked: 'setChecked',
135
+ selectValue: 'setSelectValue',
136
+ defaultValue: 'setDefaultValue',
137
+ defaultChecked: 'setDefaultChecked',
138
+ autoFocus: 'setAutoFocus',
139
+ };
140
+
141
+ // Serialize one STATIC literal attribute value into template/SSR HTML —
142
+ // shared by emitElementHtml and ssrEmitElement so both bakes stay identical,
143
+ // mirroring the runtimes' dynamic coercion: aria-*/enumerated/data-* booleans
144
+ // stringify, boolean-attr props render the canonical `attr=""` presence form
145
+ // (falsy drops; overloaded download/capture keep string payloads), booleans
146
+ // on non-boolean attrs DROP (React: `title={true}` never renders), everything
147
+ // else escapes as before. Custom elements keep raw semantics.
148
+ function bakeStaticAttr(attrName, lv, tag) {
149
+ if (lv == null) return '';
150
+ const isCustom = tag !== undefined && tag.includes('-');
151
+ const lower = attrName.toLowerCase();
152
+ if (typeof lv === 'boolean') {
153
+ if (attrName.startsWith('aria-') || attrName.startsWith('data-')) {
154
+ return ` ${attrName}="${lv}"`;
155
+ }
156
+ // Enumerated booleans stringify — "false" is a real state, absent means
157
+ // inherit (the same gate the runtimes apply to dynamic values).
158
+ if (isEnumeratedBooleanAttr(lower)) {
159
+ return ` ${attrName}="${lv}"`;
160
+ }
161
+ if (isCustom) return lv ? ` ${attrName}` : '';
162
+ // Boolean attrs + the overloaded booleans (download/capture) + the
163
+ // mustUseProperty statics all take presence semantics for BOOLEAN
164
+ // literals; non-boolean overloaded values pass through verbatim below.
165
+ if (
166
+ BOOLEAN_ATTR_PROPS.has(lower) ||
167
+ MUST_USE_PROPERTY_PROPS.has(lower) ||
168
+ lower === 'download' ||
169
+ lower === 'capture'
170
+ ) {
171
+ return lv ? ` ${lower}=""` : '';
172
+ }
173
+ return ''; // boolean on a non-boolean attribute never renders (React)
174
+ }
175
+ if (!isCustom && (BOOLEAN_ATTR_PROPS.has(lower) || MUST_USE_PROPERTY_PROPS.has(lower))) {
176
+ return lv ? ` ${lower}=""` : '';
177
+ }
178
+ if (typeof lv === 'string') return ` ${attrName}="${escapeAttr(lv)}"`;
179
+ if (typeof lv === 'number') return ` ${attrName}="${lv}"`;
180
+ return '';
181
+ }
182
+
183
+ // React contract: a `<textarea>` with a `value`/`defaultValue` prop OWNS its
184
+ // content — children would fight the prop (React throws for defaultValue +
185
+ // children and warns for value + children). Compile-time error on both emit
186
+ // paths, like rejectVoidElementContent; a plain `<textarea>text</textarea>`
187
+ // keeps its native content (that IS defaultValue semantics).
188
+ function rejectTextareaValueChildren(tag, node, ctx) {
189
+ if (tag !== 'textarea') return;
190
+ const attrs = node.attributes || node.openingElement?.attributes || [];
191
+ let hasValueProp = false;
192
+ for (const a of attrs) {
193
+ if (a.type !== 'Attribute' && a.type !== 'JSXAttribute') continue;
194
+ const n = jsxAttrRawName(a);
195
+ if (n === 'value' || n === 'defaultValue') {
196
+ hasValueProp = true;
197
+ break;
198
+ }
199
+ }
200
+ if (!hasValueProp) return;
201
+ // Renderable-children check — same lightweight scan as rejectVoidElementContent.
202
+ let offending = false;
203
+ for (const c of node.children || []) {
204
+ if (!c) continue;
205
+ if (c.type === 'JSXText') {
206
+ if (/^\s*$/.test(c.value)) continue;
207
+ } else if (c.type === 'JSXExpressionContainer') {
208
+ if (!c.expression || c.expression.type === 'JSXEmptyExpression') continue;
209
+ } else if (c.type === 'JSXStyleElement') {
210
+ continue;
211
+ }
212
+ offending = true;
213
+ break;
214
+ }
215
+ if (!offending) return;
216
+ const l = node.loc && node.loc.start;
217
+ const at = l ? ` (${ctx.mapSourceName ? ctx.mapSourceName + ':' : ''}${l.line}:${l.column})` : '';
218
+ throw new Error(
219
+ '`<textarea>` must not have children when it uses `value` or `defaultValue` — ' +
220
+ `the prop owns the content. Move the text into the prop.${at}`,
221
+ );
222
+ }
223
+
103
224
  // Compiler-generated code references runtime helpers under a collision-proof
104
225
  // `_$` alias — `import { setText as _$setText } from 'octane'` + `_$setText(…)`
105
226
  // — because generated statements are interleaved with USER statements inside
@@ -142,6 +263,87 @@ function addUserImportSpecifiers(ctx, node) {
142
263
  }
143
264
  }
144
265
 
266
+ // M3 marker elision (docs/comment-marker-elision-plan.md): a component body
267
+ // whose ENTIRE output is one component call spans its own block's range by
268
+ // construction, so the call site can INHERIT the parent block's markers on the
269
+ // client and the server can skip the child's frame pair — collapsing sole-child
270
+ // wrapper chains (incl. `<ctx.Provider>` router/binding stacks) to the
271
+ // outermost pair. The predicate must be computed from the same AST by BOTH
272
+ // compile modes (client stamp ↔ server pair-skip ↔ hydration adopt-nothing
273
+ // agree by construction). Exclusions:
274
+ // - `key=` (key-driven identity forces the slot to own its range),
275
+ // - the octane boundary builtins (Suspense / ErrorBoundary / Activity —
276
+ // their pairs are load-bearing for streaming). Direct imported names are
277
+ // excluded here (collectOctaneBoundaryNames); member/dynamic/aliased tags
278
+ // that RESOLVE to a boundary builtin are declined at RUNTIME by identity —
279
+ // componentSlot and ssrComponent both check the resolved comp against the
280
+ // builtins, so the two sides always agree.
281
+ // `bodyNodes` is the normalized, HeadHoist-filtered root list of a `@{ … }`
282
+ // (JSXCodeBlock) body — callers gate on the body form.
283
+ function inheritSoleCompRoot(bodyNodes, ctx) {
284
+ if (bodyNodes.length !== 1) return false;
285
+ const n = bodyNodes[0];
286
+ if (n.type !== 'Element' || !isComponentTag(n)) return false;
287
+ const id = n.openingElement?.name || n.id;
288
+ if (!id) return false;
289
+ if (
290
+ (id.type === 'Identifier' || id.type === 'JSXIdentifier') &&
291
+ typeof id.name === 'string' &&
292
+ ctx._octaneBoundaryNames &&
293
+ ctx._octaneBoundaryNames.has(id.name)
294
+ ) {
295
+ return false;
296
+ }
297
+ const attrs = n.attributes || n.openingElement?.attributes || [];
298
+ for (const a of attrs) {
299
+ if (a.type !== 'Attribute' && a.type !== 'JSXAttribute') continue;
300
+ const name = a.name && (a.name.name || a.name);
301
+ if (name === 'key') return false;
302
+ }
303
+ return true;
304
+ }
305
+
306
+ // Collect the LOCAL names bound to the octane boundary builtins (see
307
+ // inheritSoleCompRoot) from a module's import declarations. Aliased imports
308
+ // (`import { Suspense as S }`) are matched by their local binding.
309
+ function collectOctaneBoundaryNames(astBody) {
310
+ const names = new Set();
311
+ for (const node of astBody) {
312
+ if (node.type !== 'ImportDeclaration' || node.source.value !== 'octane') continue;
313
+ for (const sp of node.specifiers || []) {
314
+ const imported = sp.imported?.name;
315
+ if (
316
+ (imported === 'Suspense' ||
317
+ imported === 'ErrorBoundary' ||
318
+ imported === 'Activity' ||
319
+ imported === 'ViewTransition' ||
320
+ imported === 'unstable_ViewTransition') &&
321
+ sp.local?.name
322
+ ) {
323
+ names.add(sp.local.name);
324
+ }
325
+ }
326
+ }
327
+ return names;
328
+ }
329
+
330
+ // Does this module import ViewTransition from 'octane' (any local alias)?
331
+ // Drives the client prelude's `_$vtSeen()` module-load hint: the runtime's
332
+ // view-transition machinery is gated on a sticky VT_SEEN flag, and without
333
+ // the hint the very FIRST transition flush that mounts a boundary would only
334
+ // learn "this app uses ViewTransition" mid-drain — after the chance to
335
+ // snapshot the old state has passed (docs/view-transitions-plan.md).
336
+ function moduleImportsViewTransition(astBody) {
337
+ for (const node of astBody) {
338
+ if (node.type !== 'ImportDeclaration' || node.source.value !== 'octane') continue;
339
+ for (const sp of node.specifiers || []) {
340
+ const imported = sp.imported?.name;
341
+ if (imported === 'ViewTransition' || imported === 'unstable_ViewTransition') return true;
342
+ }
343
+ }
344
+ return false;
345
+ }
346
+
145
347
  export const HOOK_NAMES = new Set([
146
348
  'useState',
147
349
  'useReducer',
@@ -166,16 +368,26 @@ export const HOOK_NAMES = new Set([
166
368
  // Namespace inheritance — mirrors HTML5 foreign-content rules. The element
167
369
  // itself and its children may have *different* namespaces: <foreignObject>
168
370
  // inside SVG is still an SVG element, but its children switch back to HTML.
371
+ // SVG_ONLY_TAGS (imported from ../dom-tables.js — the runtime's de-opt
372
+ // reconciler uses the same table) drives the inference: a tag that exists ONLY
373
+ // in the SVG namespace implies SVG in a namespace-ambiguous position — a
374
+ // component's ROOT template, a fragment root — without a lexical `<svg>`
375
+ // ancestor. A component whose root is `<g>`/`<path>` must not compile to an
376
+ // HTML template: the HTMLUnknownElement it would produce inside an `<svg>`
377
+ // paints nothing.
378
+
169
379
  function nsForSelf(tag, parentNs) {
170
380
  if (tag === 'svg') return 'svg';
171
381
  if (tag === 'math') return 'mathml';
172
- return parentNs; // includes <foreignObject> itself is SVG-ns
382
+ if (parentNs === 'html' && SVG_ONLY_TAGS.has(tag)) return 'svg';
383
+ return parentNs; // includes <foreignObject> under an svg parent — itself SVG-ns
173
384
  }
174
385
 
175
386
  function nsForChildren(tag, parentNs) {
176
387
  if (tag === 'foreignObject') return 'html';
177
388
  if (tag === 'svg') return 'svg';
178
389
  if (tag === 'math') return 'mathml';
390
+ if (parentNs === 'html' && SVG_ONLY_TAGS.has(tag)) return 'svg';
179
391
  return parentNs;
180
392
  }
181
393
 
@@ -190,13 +402,14 @@ function elementTagName(node) {
190
402
 
191
403
  function isNonHtmlRootTag(node) {
192
404
  const t = elementTagName(node);
193
- return t === 'svg' || t === 'math';
405
+ return t === 'svg' || t === 'math' || (t !== null && SVG_ONLY_TAGS.has(t));
194
406
  }
195
407
 
196
408
  function nsForRootTag(node, parentNs) {
197
409
  const t = elementTagName(node);
198
410
  if (t === 'svg') return 'svg';
199
411
  if (t === 'math') return 'mathml';
412
+ if (t !== null && SVG_ONLY_TAGS.has(t)) return 'svg';
200
413
  return parentNs;
201
414
  }
202
415
 
@@ -218,11 +431,23 @@ function jsxAttrRawName(attr) {
218
431
  return n.name || n;
219
432
  }
220
433
 
221
- // `className`/`htmlFor` are React-shape JSX aliases; emit the native
222
- // `class`/`for` names so the browser actually applies them (and dynamic
223
- // bindings know which setter to pick).
224
- function normalizeJsxAttrName(raw) {
225
- return raw === 'className' ? 'class' : raw === 'htmlFor' ? 'for' : raw;
434
+ // ATTRIBUTE_ALIASES (imported from ../dom-tables.js): React 19's alias table,
435
+ // camelCase JSX prop the attribute the browser actually parses
436
+ // (`strokeWidth` `stroke-width`, `htmlFor` `for`). The compiler bakes
437
+ // these into static template/SSR markup; the client/server runtimes apply the
438
+ // same table to dynamic bindings, spreads, and de-opt props.
439
+
440
+ // `className` plus the ATTRIBUTE_ALIASES table above: emit the native
441
+ // attribute names so the browser actually applies them (and dynamic bindings
442
+ // know which setter to pick). Custom elements keep every name VERBATIM (raw
443
+ // props, no alias tables) — the same gate the runtimes' setAttribute/ssrAttr
444
+ // apply to dynamic values.
445
+ function normalizeJsxAttrName(raw, tag) {
446
+ // `className` → `class` applies EVERYWHERE, custom elements included (React
447
+ // special-cases it in setPropOnCustomElement); only the alias table is raw.
448
+ if (raw === 'className') return 'class';
449
+ if (tag !== undefined && tag.includes('-')) return raw;
450
+ return ATTRIBUTE_ALIASES.get(raw) || raw;
226
451
  }
227
452
 
228
453
  // React-shape `onXxx` event-handler attribute: `on` + an uppercase letter, so
@@ -257,105 +482,25 @@ function objectExprIsStaticLiteral(obj) {
257
482
  return true;
258
483
  }
259
484
 
260
- // camelCase / vendor-prefixed style key kebab-case. Mirrors the runtime
261
- // `styleName` (runtime.ts) / `hyphenate` (runtime.server.ts) so a STATIC baked
262
- // object style produces the same CSS a dynamic one would.
263
- function hyphenateStyleName(name) {
264
- if (name.charCodeAt(0) === 45 /* - */) return name; // --custom / -webkit-…
265
- let out = '';
266
- let changed = false;
267
- for (let i = 0; i < name.length; i++) {
268
- const c = name.charCodeAt(i);
269
- if (c >= 65 && c <= 90) {
270
- out += '-' + String.fromCharCode(c + 32);
271
- changed = true;
272
- } else out += name[i];
273
- }
274
- if (!changed) return name;
275
- if (out.charCodeAt(0) === 109 && out.charCodeAt(1) === 115 && out.charCodeAt(2) === 45)
276
- out = '-' + out;
277
- return out;
278
- }
279
-
280
- // React's unitless-property set — keep in sync with `constants.ts`
281
- // (UNITLESS_STYLE_PROPS). Canonical form: lowercased, dashes stripped.
282
- const UNITLESS_STYLE_PROPS = new Set();
283
- for (const base of [
284
- 'animationIterationCount',
285
- 'aspectRatio',
286
- 'borderImageOutset',
287
- 'borderImageSlice',
288
- 'borderImageWidth',
289
- 'boxFlex',
290
- 'boxFlexGroup',
291
- 'boxOrdinalGroup',
292
- 'columnCount',
293
- 'columns',
294
- 'flex',
295
- 'flexGrow',
296
- 'flexPositive',
297
- 'flexShrink',
298
- 'flexNegative',
299
- 'flexOrder',
300
- 'gridArea',
301
- 'gridRow',
302
- 'gridRowEnd',
303
- 'gridRowSpan',
304
- 'gridRowStart',
305
- 'gridColumn',
306
- 'gridColumnEnd',
307
- 'gridColumnSpan',
308
- 'gridColumnStart',
309
- 'fontWeight',
310
- 'lineClamp',
311
- 'lineHeight',
312
- 'opacity',
313
- 'order',
314
- 'orphans',
315
- 'tabSize',
316
- 'widows',
317
- 'zIndex',
318
- 'zoom',
319
- 'fillOpacity',
320
- 'floodOpacity',
321
- 'stopOpacity',
322
- 'strokeDasharray',
323
- 'strokeDashoffset',
324
- 'strokeMiterlimit',
325
- 'strokeOpacity',
326
- 'strokeWidth',
327
- ]) {
328
- const c = base.toLowerCase();
329
- UNITLESS_STYLE_PROPS.add(c);
330
- UNITLESS_STYLE_PROPS.add('webkit' + c);
331
- UNITLESS_STYLE_PROPS.add('ms' + c);
332
- UNITLESS_STYLE_PROPS.add('moz' + c);
333
- UNITLESS_STYLE_PROPS.add('o' + c);
334
- }
335
-
336
- // React parity: a bare number gets `px` unless it's 0, a custom prop, or unitless.
337
- function cssStyleValueStatic(name, value) {
338
- if (
339
- typeof value === 'number' &&
340
- value !== 0 &&
341
- name.charCodeAt(0) !== 45 &&
342
- !UNITLESS_STYLE_PROPS.has(name.replaceAll('-', '').toLowerCase())
343
- ) {
344
- return value + 'px';
345
- }
346
- return '' + value;
347
- }
348
-
485
+ // Serialize a fully-literal style object into a `style="…"` string at compile
486
+ // time. `hyphenateStyleName` + `cssStyleValue` come from ../dom-tables.js
487
+ // the SAME key normalization and px/unitless/trim coercion the runtimes apply
488
+ // to dynamic style objects — so a STATIC baked object style produces the same
489
+ // CSS a dynamic one would. The string is CSSOM-serialization-shaped
490
+ // (`prop: value; prop2: value2;` — declarations TERMINATED, not separated):
491
+ // that's what the same element's style attribute reads back as once anything
492
+ // touches el.style, and what React's CSSOM writes serialize to — so baked and
493
+ // dynamic styles are byte-identical in innerHTML.
349
494
  function staticObjectToCssString(obj) {
350
495
  const parts = [];
351
496
  for (const p of obj.properties || []) {
352
497
  const name = p.key.type === 'Identifier' ? p.key.name : p.key.value;
353
498
  const value = p.value.value;
354
499
  if (value == null || value === false || value === '') continue;
355
- const cssValue = value === true ? '' : cssStyleValueStatic(name, value);
356
- parts.push(`${hyphenateStyleName(name)}: ${cssValue}`);
500
+ const cssValue = value === true ? '' : cssStyleValue(name, value);
501
+ parts.push(`${hyphenateStyleName(name)}: ${cssValue};`);
357
502
  }
358
- return parts.join('; ');
503
+ return parts.join(' ');
359
504
  }
360
505
 
361
506
  // ===========================================================================
@@ -594,6 +739,48 @@ function collectFreeIdentifiers(root, initiallyBound) {
594
739
  return;
595
740
  }
596
741
 
742
+ // JSX tag position: a COMPONENT tag is a real identifier reference — a
743
+ // per-body local (`const C = props.comp`) used as `<C/>` must show up as
744
+ // free, or a hoisted helper (Phase 2) would silently drop it from its
745
+ // env tuple. Host tag names ('div') and attribute NAMES are static —
746
+ // only component-shaped identifier tags (isCompatTag rule), member-tag
747
+ // ROOT objects, and dynamic `<{expr}/>` expressions count.
748
+ if (t === 'Element' || t === 'JSXElement') {
749
+ const tag = n.openingElement?.name || n.id;
750
+ if (tag) {
751
+ if (
752
+ (tag.type === 'Identifier' || tag.type === 'JSXIdentifier') &&
753
+ typeof tag.name === 'string'
754
+ ) {
755
+ if (!/^[a-z]/.test(tag.name) && !tag.name.includes('-') && !scope.has(tag.name)) {
756
+ free.add(tag.name);
757
+ }
758
+ } else if (tag.type === 'MemberExpression' || tag.type === 'JSXMemberExpression') {
759
+ let o = tag;
760
+ while (o && (o.type === 'MemberExpression' || o.type === 'JSXMemberExpression')) {
761
+ o = o.object;
762
+ }
763
+ if (
764
+ o &&
765
+ (o.type === 'Identifier' || o.type === 'JSXIdentifier') &&
766
+ typeof o.name === 'string' &&
767
+ !scope.has(o.name)
768
+ ) {
769
+ free.add(o.name);
770
+ }
771
+ } else if (tag.type === 'JSXExpressionContainer') {
772
+ walk(tag.expression, scope);
773
+ }
774
+ }
775
+ const attrs = n.attributes || n.openingElement?.attributes || [];
776
+ for (const a of attrs) {
777
+ if (a.type === 'Attribute' || a.type === 'JSXAttribute') walk(a.value, scope);
778
+ else walk(a.argument ?? a, scope); // spread attribute
779
+ }
780
+ walk(n.children, scope);
781
+ return;
782
+ }
783
+
597
784
  // Member access — `obj.prop`: prop is a static name, not a binding ref.
598
785
  if (t === 'MemberExpression' && !n.computed) {
599
786
  walk(n.object, scope);
@@ -737,6 +924,54 @@ function containsComponentCallOrControlFlow(stmts) {
737
924
  return found;
738
925
  }
739
926
 
927
+ /**
928
+ * True when the keyed-item body executes any call DURING render:
929
+ * CallExpression / NewExpression / TaggedTemplateExpression in render-value
930
+ * position (holes, attribute values, locals). Such a call can read mutable
931
+ * state that neither the item reference nor the deps tuple changes with —
932
+ * e.g. `header.column.getIsSorted()` on a memoized table-core header — so the
933
+ * PURE/DEP-PURE survivor short-circuit (see the body analysis in makeForCall)
934
+ * would freeze its output where React re-runs the body unconditionally.
935
+ *
936
+ * Calls nested inside FUNCTION VALUES (event-handler arrows, function
937
+ * expressions) are deferred to invoke time and close over the same ref-stable
938
+ * item/deps a skipped survivor would have, so they can't go stale at render
939
+ * time — the walk does not descend into function bodies or parameters.
940
+ */
941
+ function containsRenderCall(stmts) {
942
+ let found = false;
943
+ const seen = new WeakSet();
944
+ function walk(n) {
945
+ if (found || !n) return;
946
+ if (Array.isArray(n)) {
947
+ for (const x of n) walk(x);
948
+ return;
949
+ }
950
+ if (typeof n !== 'object') return;
951
+ const t = n.type;
952
+ if (!t) return;
953
+ if (seen.has(n)) return;
954
+ seen.add(n);
955
+ if (
956
+ t === 'ArrowFunctionExpression' ||
957
+ t === 'FunctionExpression' ||
958
+ t === 'FunctionDeclaration'
959
+ ) {
960
+ return; // deferred — runs at event/invoke time, not during render
961
+ }
962
+ if (t === 'CallExpression' || t === 'NewExpression' || t === 'TaggedTemplateExpression') {
963
+ found = true;
964
+ return;
965
+ }
966
+ for (const key in n) {
967
+ if (AST_WALK_SKIP_KEYS.has(key)) continue;
968
+ walk(n[key]);
969
+ }
970
+ }
971
+ for (const s of stmts) walk(s);
972
+ return found;
973
+ }
974
+
740
975
  /**
741
976
  * `() => fn(a, b, …)` — a zero-param arrow whose body is a single
742
977
  * function call. Returns `{ callee, args }` if so, else null. Used to compile
@@ -1201,11 +1436,20 @@ export function compile(source, filename, options) {
1201
1436
  // name), used by hydration-mismatch warnings and reusable by a future Chrome-DevTools
1202
1437
  // element→source layer. Strictly dev-gated so PROD output is byte-identical (zero cost).
1203
1438
  const devEnabled = !!(options && options.dev);
1439
+ // parallelUse: the parallel-`use()` transform pipeline (auto-memoized
1440
+ // creations → hoisted parallel starts → batched unwrap → __warm fetch
1441
+ // plans; docs/suspense-parallel-use-plan.md). ON by default — pass
1442
+ // `parallelUse: false` to opt out (React-timing waterfall semantics).
1443
+ // Output is byte-identical with the flag off, and the pipeline is a no-op
1444
+ // for use()-free modules either way.
1445
+ const parallelUseEnabled = !(options && options.parallelUse === false);
1204
1446
 
1205
1447
  const ctx = {
1206
1448
  filename,
1207
1449
  mode,
1208
1450
  dev: devEnabled,
1451
+ parallelUse: parallelUseEnabled,
1452
+ hmr: hmrEnabled, // gates Symbol.for vs Symbol() hook slots (allocHookSymbol)
1209
1453
  runtimeNeeded: new Set(), // helpers referenced by GENERATED code — imported as `name as _$name`
1210
1454
  userRuntimeNames: new Set(), // specifiers USER code references — imported verbatim
1211
1455
  hoistedTemplates: [], // { name, html }
@@ -1216,6 +1460,8 @@ export function compile(source, filename, options) {
1216
1460
  currentComponentLocals: null, // Set<string> while compiling a component body; null otherwise
1217
1461
  knownStringLocals: null, // Set<string> of provably-string locals (text-hole inference)
1218
1462
  nextHookSymId: 0,
1463
+ nextPuId: 0, // parallel-use `__pu$N` hoisted-creation temps
1464
+ _pendingWarm: null, // `X.__warm = …` source, set by compileFunctionBody, drained by compileComponent
1219
1465
  nextFragId: 0,
1220
1466
  nextTemplateId: 0,
1221
1467
  nextHelperId: 0,
@@ -1231,6 +1477,23 @@ export function compile(source, filename, options) {
1231
1477
  // the top-level (autoCallback) pass and drained per component below.
1232
1478
  _setupMaps: null,
1233
1479
  };
1480
+ // Imported local bindings (any source). Used by the M1 cross-module
1481
+ // singleRoot sentinel: only an IMPORTED identifier is a stable component
1482
+ // identity for the lifetime of a slot — a local `const Comp = cond ? A : B`
1483
+ // re-resolves per render, and a markerless slot regime must never be chosen
1484
+ // off an identity that can change (see makeCompCall).
1485
+ ctx.importedNames = new Set();
1486
+ for (const node of ast.body) {
1487
+ if (node.type !== 'ImportDeclaration') continue;
1488
+ for (const sp of node.specifiers || []) {
1489
+ if (sp.local && sp.local.name) ctx.importedNames.add(sp.local.name);
1490
+ }
1491
+ }
1492
+ // M3 inherit-range exclusion set (see inheritSoleCompRoot).
1493
+ ctx._octaneBoundaryNames = collectOctaneBoundaryNames(ast.body);
1494
+ // Client prelude `_$vtSeen()` module-load hint (view-transitions plan).
1495
+ ctx._usesViewTransition = moduleImportsViewTransition(ast.body);
1496
+
1234
1497
  // List of exported components needing HMR wrapping. Each entry: { name,
1235
1498
  // exportKind: 'default' | 'named' }. We emit the `Comp = hmr(Comp)` lines
1236
1499
  // and the `import.meta.hot.accept` block after walking the body, so the
@@ -1333,6 +1596,19 @@ export function compile(source, filename, options) {
1333
1596
  })(root);
1334
1597
  if (reject) eligible = false;
1335
1598
  }
1599
+ // M3 inherit-range: a body whose sole output is one component call BORROWS
1600
+ // its own block's marker range at that site (see inheritSoleCompRoot), so
1601
+ // callers must give it a real Block with a coherent range — a
1602
+ // componentSlotLite invocation (LiteBlockImpl: no startMarker, endMarker =
1603
+ // the call-site anchor) has nothing to borrow, and under HYDRATION the
1604
+ // declined borrow would probe for a child pair the server never emitted.
1605
+ // Reject lite for such callees.
1606
+ if (eligible && compNode.body && compNode.body.type === 'JSXCodeBlock') {
1607
+ const bodyNodes = normalizeChildren(
1608
+ compNode.body.render ? [compNode.body.render] : [],
1609
+ ).filter((n) => n.type !== 'HeadHoist');
1610
+ if (inheritSoleCompRoot(bodyNodes, ctx)) eligible = false;
1611
+ }
1336
1612
  info.eligible = eligible;
1337
1613
  // Single-ELEMENT-root output: the component's body renders exactly one plain
1338
1614
  // DOM element (not a component tag, fragment, or control-flow). Such a
@@ -1423,18 +1699,27 @@ export function compile(source, filename, options) {
1423
1699
  if (hmrEnabled) hmrComponents.push({ name: c.id.name, exportKind: 'named' });
1424
1700
  } else if (isReturnJsxFunction(node)) {
1425
1701
  // `function Foo() { …hooks…; return <jsx>; }` — a plain return-JSX function.
1702
+ const base = bodyLine;
1426
1703
  ctx._setupMaps = null;
1427
1704
  const chunk = compileReturnJsxFunction(node, ctx, {}) + '\n\n';
1705
+ pushDeclAnchor(node, base);
1706
+ drainSetupMaps(base);
1428
1707
  body += chunk;
1429
1708
  bodyLine += countNewlines(chunk);
1430
1709
  } else if (node.type === 'ExportNamedDeclaration' && isReturnJsxFunction(node.declaration)) {
1710
+ const base = bodyLine;
1431
1711
  ctx._setupMaps = null;
1432
1712
  const chunk = compileReturnJsxFunction(node.declaration, ctx, { export: true }) + '\n\n';
1713
+ pushDeclAnchor(node, base);
1714
+ drainSetupMaps(base);
1433
1715
  body += chunk;
1434
1716
  bodyLine += countNewlines(chunk);
1435
1717
  } else if (node.type === 'ExportDefaultDeclaration' && isReturnJsxFunction(node.declaration)) {
1718
+ const base = bodyLine;
1436
1719
  ctx._setupMaps = null;
1437
1720
  const chunk = compileReturnJsxFunction(node.declaration, ctx, { default: true }) + '\n\n';
1721
+ pushDeclAnchor(node, base);
1722
+ drainSetupMaps(base);
1438
1723
  body += chunk;
1439
1724
  bodyLine += countNewlines(chunk);
1440
1725
  } else if (node.type === 'ImportDeclaration' && node.source.value === 'octane') {
@@ -1536,12 +1821,37 @@ export function compile(source, filename, options) {
1536
1821
  '}\n';
1537
1822
  }
1538
1823
 
1824
+ // Cross-module singleRoot stamps (docs/comment-marker-elision-plan.md M1):
1825
+ // a component whose body provably renders ONE plain element carries the
1826
+ // marker on its BINDING, so a `componentSlot(…, 2)` call site in ANOTHER
1827
+ // module can take the markerless singleRoot path at mount. Emitted at the
1828
+ // module tail so it lands on the final binding (incl. the hmr() wrapper —
1829
+ // the stamp goes on what importers see). Also feeds the runtime's existing
1830
+ // value-position `$$singleRoot` descriptor check.
1831
+ let stampBlock = '';
1832
+ if (ctx.componentInfo) {
1833
+ const stamps = [];
1834
+ for (const [name, info] of ctx.componentInfo) {
1835
+ if (info.singleRoot === true) stamps.push(`${name}.$$singleRoot = true;`);
1836
+ }
1837
+ if (stamps.length > 0) stampBlock = stamps.join('\n') + '\n';
1838
+ }
1839
+
1840
+ // Module-load ViewTransition hint (see moduleImportsViewTransition) —
1841
+ // registered before the import list is built so `__vtSeen` gets aliased in.
1842
+ let vtHintBlock = '';
1843
+ if (ctx._usesViewTransition) {
1844
+ ctx.runtimeNeeded.add('__vtSeen');
1845
+ vtHintBlock = rtAlias('__vtSeen') + '();\n';
1846
+ }
1847
+
1539
1848
  // Built after HMR wiring so the import list includes `hmr`/`HMR` when needed.
1540
1849
  const finalRuntimeImport = buildRuntimeImport(ctx, 'octane');
1541
1850
 
1542
1851
  // Everything before `body` in the output — shifts every body segment's
1543
1852
  // generated line down by the prelude's line count.
1544
- const prelude = finalRuntimeImport + delegateCall + styleBlock + templatesBlock + helpersBlock;
1853
+ const prelude =
1854
+ finalRuntimeImport + vtHintBlock + delegateCall + styleBlock + templatesBlock + helpersBlock;
1545
1855
  const preludeLines = countNewlines(prelude);
1546
1856
  const segments = bodySegments.map((s) => ({
1547
1857
  genLine: s.genLine + preludeLines,
@@ -1551,7 +1861,7 @@ export function compile(source, filename, options) {
1551
1861
  }));
1552
1862
 
1553
1863
  return {
1554
- code: prelude + body + hmrBlock,
1864
+ code: prelude + body + stampBlock + hmrBlock,
1555
1865
  map: buildSourceMap(source, ctx.mapSourceName, segments),
1556
1866
  };
1557
1867
  }
@@ -1590,6 +1900,16 @@ function compileServer(source, filename, options) {
1590
1900
  const ctx = {
1591
1901
  filename,
1592
1902
  mode: 'server',
1903
+ hmr: false, // SSR never hot-swaps in place — hook slots are plain Symbol()s
1904
+ // SSR MIRROR of the parallel-`use()` pipeline (docs/suspense-parallel-use-
1905
+ // plan.md Phase 5): the same memoize (Pass A) + hoist/batch (Pass B)
1906
+ // transforms run on server bodies, emitting `_$puMemo`/`_$puBatch` — the
1907
+ // server-runtime twins with cross-pass creation identity — so independent
1908
+ // fetches REGISTER before the first suspend and a body stratum costs ONE
1909
+ // network round instead of one per use(). Same opt-out flag as the client.
1910
+ parallelUse: !(options && options.parallelUse === false),
1911
+ nextPuId: 0, // parallel-use `__pu$N` hoisted-creation temps
1912
+ _pendingWarm: null, // `X.__warm = …` source, set by ssrCompileBody, drained by compileServerComponent
1593
1913
  runtimeNeeded: new Set(), // helpers referenced by GENERATED code — imported as `name as _$name`
1594
1914
  userRuntimeNames: new Set(), // specifiers USER code references — imported verbatim
1595
1915
  hoistedHelpers: [],
@@ -1604,6 +1924,9 @@ function compileServer(source, filename, options) {
1604
1924
  mapSourceName: (filename || 'module.tsrx').split(/[\\/]/).pop(),
1605
1925
  _setupMaps: null,
1606
1926
  };
1927
+ // M3 inherit-range exclusion set — must match the client compile's
1928
+ // (see inheritSoleCompRoot; both modes read the same import declarations).
1929
+ ctx._octaneBoundaryNames = collectOctaneBoundaryNames(ast.body);
1607
1930
 
1608
1931
  let body = '';
1609
1932
  for (const node of ast.body) {
@@ -1689,9 +2012,16 @@ function compileServerComponent(node, ctx) {
1689
2012
  ctx.knownStringLocals = prevKnownStr;
1690
2013
  }
1691
2014
 
1692
- if (isDefault) return `const ${name} = ${fn};\nexport default ${name};`;
1693
- if (isExported) return `export const ${name} = ${fn};`;
1694
- return `const ${name} = ${fn};`;
2015
+ // SSR parallel-use mirror: attach the compiled fetch plan so a PARENT's warm
2016
+ // walk (`_$warmChild(Comp, props)` from its first suspending batch) can start
2017
+ // this component's independent creations before its body ever runs.
2018
+ const warmSrc = ctx._pendingWarm;
2019
+ ctx._pendingWarm = null;
2020
+ const warmTail = warmSrc ? `\n${name}.__warm = ${warmSrc};` : '';
2021
+
2022
+ if (isDefault) return `const ${name} = ${fn};${warmTail}\nexport default ${name};`;
2023
+ if (isExported) return `export const ${name} = ${fn};${warmTail}`;
2024
+ return `const ${name} = ${fn};${warmTail}`;
1695
2025
  }
1696
2026
 
1697
2027
  function ssrCompileBody(node, ctx, name, cssHash, cssEntries, parentNs = 'html') {
@@ -1716,7 +2046,24 @@ function ssrCompileBody(node, ctx, name, cssHash, cssEntries, parentNs = 'html')
1716
2046
  // return-JSX form: the returned host element (+ its directive children) is
1717
2047
  // the render output — route it to jsxNodes so it flows through ssrEmitNode
1718
2048
  // (byte-identical SSR to the `@{}` form), not printed as `return <jsx>`.
1719
- jsxNodes.push(child.argument);
2049
+ // EXCEPT a returned FRAGMENT: the client VALUE-lowers `return <>…</>` to an
2050
+ // array of createElement descriptors (rewriteJsxValues), mounted by the
2051
+ // return-slot childSlot — whose hydration adopts ONE `<!--[-->…<!--]-->`
2052
+ // range for the slot plus one range PER ITEM (including text items). The
2053
+ // template walk would instead concatenate children with markerless text
2054
+ // separators and no slot range, desyncing the hydration cursor. Route the
2055
+ // whole fragment through the same value hole (`ssrChild(loweredArray)`) so
2056
+ // the runtime's ssrChild array branch emits the exact per-item shape the
2057
+ // client adopts.
2058
+ if (child.argument.type === 'JSXFragment' || child.argument.type === 'Fragment') {
2059
+ jsxNodes.push({
2060
+ type: 'TSRXExpression',
2061
+ expression: child.argument,
2062
+ loc: child.argument.loc,
2063
+ });
2064
+ } else {
2065
+ jsxNodes.push(child.argument);
2066
+ }
1720
2067
  } else if (isJsxNode(child)) {
1721
2068
  if (child.type === 'Element' && elementTagName(child) === 'style') continue;
1722
2069
  jsxNodes.push(child);
@@ -1725,7 +2072,34 @@ function ssrCompileBody(node, ctx, name, cssHash, cssEntries, parentNs = 'html')
1725
2072
  }
1726
2073
 
1727
2074
  const inlinedSubs = [];
1728
- const rewritten = statements
2075
+ // SSR parallel-use mirror: memoize + hoist/batch BEFORE rewriteHookCalls —
2076
+ // the rewritten `use(__pu$N)` unwraps then get their server site keys from
2077
+ // rewriteHookCalls exactly like hand-written use() calls, and the
2078
+ // `_$puMemo`/`_$puBatch` helper calls it emits are compiler-aliased names it
2079
+ // leaves alone. Mirrors the client pipeline shape exactly: TOP bodies run
2080
+ // Pass A on setup statements + the WalkJsx transform over the render tree
2081
+ // (directive-arm statements memoize HERE; the transformed nodes flow into
2082
+ // ssrEmitNodes below, so ssrCompileSub receives arms PRE-memoized and runs
2083
+ // Pass B only) + the warm artifacts (`Comp.__warm` fetch plan + the first
2084
+ // batch's warm thunk — see runtime.server.ts warmMemo/warmChild); synthetic
2085
+ // subs (statement arrays) run Pass B only. Loops/functions are excluded by
2086
+ // the passes themselves (same rules as the client).
2087
+ let workingStatements = statements;
2088
+ if (ctx.parallelUse) {
2089
+ let warmThunk = null;
2090
+ const isTopBody = !Array.isArray(node.body);
2091
+ if (isTopBody) {
2092
+ const creations = [];
2093
+ const warmChildren = [];
2094
+ workingStatements = parallelUseMemoizePass(workingStatements, ctx, name, creations, [], null);
2095
+ jsxNodes = parallelUseWalkJsx(jsxNodes, ctx, name, creations, warmChildren, [], new Set());
2096
+ const warm = buildWarmArtifacts(node, ctx, name, creations, warmChildren);
2097
+ warmThunk = warm.thunk;
2098
+ ctx._pendingWarm = warm.warmSrc;
2099
+ }
2100
+ workingStatements = rewriteParallelUse(workingStatements, ctx, name, warmThunk);
2101
+ }
2102
+ const rewritten = workingStatements
1729
2103
  .map((s) => rewriteHookCalls(s, ctx, name))
1730
2104
  .map((s) => rewriteJsxValues(s, ctx));
1731
2105
  const setupCode = rewritten.map((s) => ' ' + printNode(s).replace(/\n/g, '\n ')).join('\n');
@@ -1743,9 +2117,33 @@ function ssrCompileBody(node, ctx, name, cssHash, cssEntries, parentNs = 'html')
1743
2117
  // block). The server must match per form — flag value position so ssrEmitComponent
1744
2118
  // emits descriptor children for `.tsx`, keeping the server/client block counts
1745
2119
  // identical (a mismatch desyncs the hydration cursor). Restored after this body.
2120
+ //
2121
+ // SYNTHETIC subs (ssrCompileSub passes the statement ARRAY: @if/@for/@switch/@try
2122
+ // branches and `__schildren` component children) are always TEMPLATE position —
2123
+ // the client compiles those branches through the template walk (componentSlot +
2124
+ // markChildrenBlock render-fns) even when the enclosing body is a `return <jsx>`
2125
+ // value body. Resetting to value here made ssrEmitComponent take the descriptor
2126
+ // path inside every sub, which both desynced the block count from the client AND
2127
+ // silently DROPPED directive-block children of nested components (lowerJsxChild
2128
+ // cannot lower an @if to a descriptor) — a `<C>@if (…) { … }</C>` nested one sub
2129
+ // deep rendered `<C>` childless.
1746
2130
  const prevValuePos = ctx._tsxValuePos;
1747
- ctx._tsxValuePos = !(node.body && node.body.type === 'JSXCodeBlock');
2131
+ ctx._tsxValuePos = Array.isArray(node.body)
2132
+ ? false
2133
+ : !(node.body && node.body.type === 'JSXCodeBlock');
2134
+ // M3 inherit-range (mirror of the client's planJsx stamp — the SAME
2135
+ // inheritSoleCompRoot predicate over the same normalized roots, so the
2136
+ // client slot borrows exactly where the server skips the frame pair):
2137
+ // a `@{}` body whose sole output is one component call emits that call
2138
+ // with `inherit=true` → ssrComponent skips the `<!--[-->…<!--]-->` frame
2139
+ // wrap (the parent's own pair already bounds it). Synthetic sub-bodies
2140
+ // (statement arrays) never qualify. Consumed by ssrEmitComponent at the
2141
+ // root emit, before it recurses into props/children.
2142
+ const prevInheritRoot = ctx._ssrInheritRoot;
2143
+ ctx._ssrInheritRoot =
2144
+ !!(node.body && node.body.type === 'JSXCodeBlock') && inheritSoleCompRoot(bodyNodes, ctx);
1748
2145
  const htmlExpr = ssrEmitNodes(bodyNodes, ctx, name, inlinedSubs, parentNs, cssHash);
2146
+ ctx._ssrInheritRoot = prevInheritRoot;
1749
2147
  ctx._tsxValuePos = prevValuePos;
1750
2148
 
1751
2149
  let cssLines = '';
@@ -1899,14 +2297,20 @@ function ssrEmitNode(node, ctx, name, inlinedSubs, parentNs, cssHash, nlGuard =
1899
2297
  function ssrEmitElement(node, ctx, name, inlinedSubs, parentNs, cssHash) {
1900
2298
  const tag = elementTagName(node);
1901
2299
  rejectVoidElementContent(tag, node, ctx);
2300
+ rejectTextareaValueChildren(tag, node, ctx);
1902
2301
  const attrs = node.attributes || node.openingElement?.attributes || [];
1903
- const selfNs = nsForSelf(node, parentNs);
1904
- const childNs = nsForChildren(node, selfNs);
2302
+ // NB: the ns helpers take the TAG STRING (passing the node silently returns
2303
+ // the inherited ns — svg subtrees would never enter the svg namespace).
2304
+ const selfNs = nsForSelf(tag, parentNs);
2305
+ const childNs = nsForChildren(tag, selfNs);
1905
2306
 
1906
2307
  // `parts` are JS expressions concatenated with `+`. `lit` accumulates the
1907
2308
  // current static run so adjacent literals fold into one quoted chunk.
2309
+ // `<option>` builds its ATTRS-ONLY expression here — ssrOption assembles
2310
+ // the whole tag at runtime so an enclosing controlled `<select>` scope can
2311
+ // mark it ` selected` (see the option branch at the bottom).
1908
2312
  const parts = [];
1909
- let lit = '<' + tag;
2313
+ let lit = tag === 'option' ? '' : '<' + tag;
1910
2314
  const flush = () => {
1911
2315
  if (lit) {
1912
2316
  parts.push(JSON.stringify(lit));
@@ -1914,6 +2318,17 @@ function ssrEmitElement(node, ctx, name, inlinedSubs, parentNs, cssHash) {
1914
2318
  }
1915
2319
  };
1916
2320
 
2321
+ // Controlled-form serialization state (mirrors the client helpers — see the
2322
+ // controlled section of runtime.server.ts): <input> maps `defaultValue`/
2323
+ // `defaultChecked` onto the native attrs and routes dynamic value/checked
2324
+ // through dedicated serializers; <textarea> routes value/defaultValue into
2325
+ // the CONTENT position; <select> feeds them to the option-projection scope
2326
+ // (never an attribute); <option> captures its `value` for the scope compare.
2327
+ let ctlValue = null; // textarea/select captured `value` expr
2328
+ let ctlDefault = null; // textarea/select captured `defaultValue` expr
2329
+ let selMultiple = 'false'; // select `multiple` expr (constant or temp)
2330
+ let optValue = null; // option `value` expr (constant or temp); null = no value attr
2331
+
1917
2332
  const firstSpreadIdx = attrs.findIndex(
1918
2333
  (a) => a.type === 'SpreadAttribute' || a.type === 'JSXSpreadAttribute',
1919
2334
  );
@@ -1960,13 +2375,13 @@ function ssrEmitElement(node, ctx, name, inlinedSubs, parentNs, cssHash) {
1960
2375
  // Events and refs have no server semantics — dropped.
1961
2376
  if (rawAttrName === 'ref') continue;
1962
2377
  if (isEventAttrName(rawAttrName)) continue;
1963
- // Custom elements keep `htmlFor` VERBATIM (React parity — they get raw
1964
- // props, no `for` alias; `className`→`class` still applies); ssrAttr
1965
- // applies the same gate for dynamic values.
1966
- const attrName =
1967
- rawAttrName === 'htmlFor' && tag.includes('-')
1968
- ? rawAttrName
1969
- : normalizeJsxAttrName(rawAttrName);
2378
+ // `autoFocus` never serializes (React DOM server parity — the client
2379
+ // focuses at its mount commit; custom elements keep raw props).
2380
+ if (rawAttrName === 'autoFocus' && !tag.includes('-')) continue;
2381
+ // Custom elements keep names VERBATIM (React parity — they get raw props,
2382
+ // no alias tables; `className`→`class` still applies); ssrAttr applies
2383
+ // the same gate for dynamic values.
2384
+ const attrName = normalizeJsxAttrName(rawAttrName, tag);
1970
2385
  const val = attr.value;
1971
2386
  const isAfterSpread = firstSpreadIdx !== -1 && attrI > firstSpreadIdx;
1972
2387
 
@@ -1979,6 +2394,112 @@ function ssrEmitElement(node, ctx, name, inlinedSubs, parentNs, cssHash) {
1979
2394
  continue;
1980
2395
  }
1981
2396
 
2397
+ // ── Controlled form props (see the state block above the loop) ──
2398
+ if (
2399
+ (tag === 'input' || tag === 'textarea' || tag === 'select') &&
2400
+ (attrName === 'value' ||
2401
+ attrName === 'defaultValue' ||
2402
+ (tag === 'input' && (attrName === 'checked' || attrName === 'defaultChecked')))
2403
+ ) {
2404
+ const ctlInner =
2405
+ val == null ? null : val.type === 'JSXExpressionContainer' ? val.expression : val;
2406
+ if (tag === 'input') {
2407
+ // defaultValue/defaultChecked serialize as the NATIVE attrs;
2408
+ // dynamic value/checked go through serializers that mirror the
2409
+ // client helpers byte-for-byte (value={false} → value="false",
2410
+ // checked truthy → bare presence).
2411
+ const isCheck = attrName === 'checked' || attrName === 'defaultChecked';
2412
+ if (ctlInner === null && !isAfterSpread) {
2413
+ // Bare boolean prop (`<input checked/>`) → static presence
2414
+ // (`value` bare mirrors the client's String(true)).
2415
+ lit += isCheck ? ' checked' : ' value="true"';
2416
+ continue;
2417
+ }
2418
+ if (ctlInner !== null && ctlInner.type === 'Literal' && !isAfterSpread) {
2419
+ const lv = ctlInner.value;
2420
+ if (isCheck) {
2421
+ if (lv != null && lv !== false) lit += ' checked';
2422
+ } else if (lv != null) {
2423
+ lit += ` value="${escapeAttr(String(lv))}"`;
2424
+ }
2425
+ continue;
2426
+ }
2427
+ flush();
2428
+ const ctlExpr =
2429
+ ctlInner === null ? 'true' : printExprWithTsrx(ctlInner, ctx, name, inlinedSubs);
2430
+ if (isCheck) {
2431
+ ctx.runtimeNeeded.add('ssrCheckedAttr');
2432
+ parts.push(`_$ssrCheckedAttr(${ctlExpr})`);
2433
+ } else {
2434
+ ctx.runtimeNeeded.add('ssrValueAttr');
2435
+ parts.push(`_$ssrValueAttr(${ctlExpr})`);
2436
+ }
2437
+ continue;
2438
+ }
2439
+ // textarea / select: value/defaultValue never serialize as attributes —
2440
+ // captured for the content position (textarea) / projection scope (select).
2441
+ const ctlExpr =
2442
+ ctlInner === null ? 'true' : printExprWithTsrx(ctlInner, ctx, name, inlinedSubs);
2443
+ if (attrName === 'value') ctlValue = ctlExpr;
2444
+ else ctlDefault = ctlExpr;
2445
+ continue;
2446
+ }
2447
+ if (tag === 'select' && attrName === 'multiple') {
2448
+ // Serialize the attribute normally AND capture the value for the
2449
+ // option-projection scope — a dynamic value binds to a temp so the
2450
+ // expression evaluates once.
2451
+ if (val == null) {
2452
+ selMultiple = 'true';
2453
+ lit += ' multiple';
2454
+ continue;
2455
+ }
2456
+ const mInner = val.type === 'JSXExpressionContainer' ? val.expression : val;
2457
+ if (mInner.type === 'Literal' && !isAfterSpread) {
2458
+ selMultiple = mInner.value ? 'true' : 'false';
2459
+ if (mInner.value === true) lit += ' multiple';
2460
+ else if (typeof mInner.value === 'string') lit += ` multiple="${escapeAttr(mInner.value)}"`;
2461
+ else if (typeof mInner.value === 'number') lit += ` multiple="${mInner.value}"`;
2462
+ continue;
2463
+ }
2464
+ const tmp = `__sp${spreadTemps.length}`;
2465
+ spreadTemps.push({
2466
+ tempName: tmp,
2467
+ argExpr: printExprWithTsrx(mInner, ctx, name, inlinedSubs),
2468
+ });
2469
+ selMultiple = tmp;
2470
+ flush();
2471
+ ctx.runtimeNeeded.add('ssrAttr');
2472
+ parts.push(`_$ssrAttr('multiple', ${tmp}, ${JSON.stringify(tag)})`);
2473
+ continue;
2474
+ }
2475
+ if (tag === 'option' && attrName === 'value') {
2476
+ // The option's value feeds BOTH the attribute and the select-scope
2477
+ // compare — a dynamic value binds to a temp for single evaluation.
2478
+ if (val == null) {
2479
+ optValue = '""';
2480
+ lit += ' value';
2481
+ continue;
2482
+ }
2483
+ const oInner = val.type === 'JSXExpressionContainer' ? val.expression : val;
2484
+ if (oInner.type === 'Literal' && !isAfterSpread) {
2485
+ optValue = JSON.stringify(String(oInner.value));
2486
+ if (typeof oInner.value === 'string') lit += ` value="${escapeAttr(oInner.value)}"`;
2487
+ else if (typeof oInner.value === 'number') lit += ` value="${oInner.value}"`;
2488
+ else if (oInner.value === true) lit += ' value';
2489
+ continue;
2490
+ }
2491
+ const tmp = `__sp${spreadTemps.length}`;
2492
+ spreadTemps.push({
2493
+ tempName: tmp,
2494
+ argExpr: printExprWithTsrx(oInner, ctx, name, inlinedSubs),
2495
+ });
2496
+ optValue = tmp;
2497
+ flush();
2498
+ ctx.runtimeNeeded.add('ssrAttr');
2499
+ parts.push(`_$ssrAttr('value', ${tmp}, ${JSON.stringify(tag)})`);
2500
+ continue;
2501
+ }
2502
+
1982
2503
  // Boolean attribute (no value) → present.
1983
2504
  if (val == null) {
1984
2505
  lit += ' ' + attrName;
@@ -2005,15 +2526,10 @@ function ssrEmitElement(node, ctx, name, inlinedSubs, parentNs, cssHash) {
2005
2526
  }
2006
2527
 
2007
2528
  // Static literal (and not after a spread) → inline into the tag.
2529
+ // bakeStaticAttr applies the shared React-parity value tables (client
2530
+ // bake stays byte-identical — hydration parity).
2008
2531
  if (!isAfterSpread && inner.type === 'Literal') {
2009
- if (typeof inner.value === 'string') lit += ` ${attrName}="${escapeAttr(inner.value)}"`;
2010
- else if (typeof inner.value === 'number') lit += ` ${attrName}="${inner.value}"`;
2011
- else if (typeof inner.value === 'boolean' && attrName.startsWith('aria-')) {
2012
- // `aria-*` is ENUMERATED (React parity, mirroring ssrAttr): a boolean
2013
- // literal serialises as "true"/"false" — `aria-hidden={false}` must
2014
- // render, not drop out via the generic boolean handling.
2015
- lit += ` ${attrName}="${inner.value}"`;
2016
- } else if (inner.value === true) lit += ` ${attrName}`;
2532
+ lit += bakeStaticAttr(attrName, inner.value, tag);
2017
2533
  continue;
2018
2534
  }
2019
2535
 
@@ -2053,8 +2569,8 @@ function ssrEmitElement(node, ctx, name, inlinedSubs, parentNs, cssHash) {
2053
2569
  return finalize();
2054
2570
  }
2055
2571
 
2056
- lit += '>';
2057
- const normChildren = normalizeChildren(node.children || []);
2572
+ if (tag !== 'option') lit += '>'; // option: ssrOption assembles the tag (attrs-only here)
2573
+ const normChildren = normalizeChildren(node.children || [], childNs === 'svg');
2058
2574
  // Only-child renderable `{expr}` → markerless `ssrChildText` (mirrors the client's
2059
2575
  // markerless `childTextHole` mount: a primitive is the host's bare text, an object
2060
2576
  // still gets a `<!--[-->…<!--]-->` block). Must match the client's only-child
@@ -2086,6 +2602,40 @@ function ssrEmitElement(node, ctx, name, inlinedSubs, parentNs, cssHash) {
2086
2602
  nlGuardFirst,
2087
2603
  );
2088
2604
  }
2605
+ // Controlled `<textarea value/defaultValue>`: the prop IS the content
2606
+ // (children were rejected at compile time) — value wins over defaultValue,
2607
+ // a nullish value falls through to the default (the client cascade).
2608
+ if (tag === 'textarea' && (ctlValue !== null || ctlDefault !== null)) {
2609
+ ctx.runtimeNeeded.add('ssrTextareaValue');
2610
+ const src =
2611
+ ctlValue !== null && ctlDefault !== null
2612
+ ? `(${ctlValue}) ?? (${ctlDefault})`
2613
+ : (ctlValue ?? ctlDefault);
2614
+ childrenExpr = `_$ssrTextareaValue(${src})`;
2615
+ }
2616
+ // Controlled `<select value/defaultValue>`: push the option-projection
2617
+ // scope around the children serialization — every compiled/de-opt
2618
+ // `<option>` inside (across component boundaries and @for bodies; SSR is a
2619
+ // synchronous nested call tree) consults it via ssrOption.
2620
+ if (tag === 'select' && (ctlValue !== null || ctlDefault !== null)) {
2621
+ ctx.runtimeNeeded.add('ssrSelectScope');
2622
+ childrenExpr = `_$ssrSelectScope(${ctlValue ?? 'void 0'}, ${ctlDefault ?? 'void 0'}, ${selMultiple}, () => (${childrenExpr}))`;
2623
+ }
2624
+ // `<option>`: assemble via ssrOption so an active select scope can mark it
2625
+ // ` selected` (returns a plain `<option …>` when no scope is active).
2626
+ if (tag === 'option') {
2627
+ let contentExpr = childrenExpr;
2628
+ if (htmlSources.length > 0) {
2629
+ ctx.runtimeNeeded.add('ssrInnerHtml');
2630
+ contentExpr = `(_$ssrInnerHtml([${htmlSources.join(', ')}]) ?? (${childrenExpr}))`;
2631
+ }
2632
+ flush();
2633
+ const attrsExpr = parts.length > 0 ? parts.join(' + ') : "''";
2634
+ parts.length = 0;
2635
+ ctx.runtimeNeeded.add('ssrOption');
2636
+ parts.push(`_$ssrOption(${optValue ?? 'void 0'}, ${attrsExpr}, ${contentExpr})`);
2637
+ return finalize();
2638
+ }
2089
2639
  if (htmlSources.length > 0) {
2090
2640
  // Raw HTML (explicit and/or spread-supplied) wins over children when present
2091
2641
  // at runtime (last source wins); otherwise the children render.
@@ -2102,6 +2652,12 @@ function ssrEmitElement(node, ctx, name, inlinedSubs, parentNs, cssHash) {
2102
2652
  }
2103
2653
 
2104
2654
  function ssrEmitComponent(node, ctx, name, inlinedSubs, cssHash) {
2655
+ // M3 inherit-range: consume the body-root flag ONCE, before this component's
2656
+ // props/children compile below (they recurse into ssrEmitNodes/ssrCompileSub
2657
+ // and must not inherit it). Set by ssrCompileBody only for the sole
2658
+ // comp-call root of a `@{}` body — which is exactly this emit.
2659
+ const inherit = ctx._ssrInheritRoot === true;
2660
+ ctx._ssrInheritRoot = false;
2105
2661
  const compExpr = tagExpr(node);
2106
2662
  const attrs = node.attributes || node.openingElement?.attributes || [];
2107
2663
  const propParts = [];
@@ -2167,11 +2723,18 @@ function ssrEmitComponent(node, ctx, name, inlinedSubs, cssHash) {
2167
2723
  // that. Mirrors the client `@{}` convention (componentSlot + a render fn).
2168
2724
  const sub = ssrCompileSub(node.children, ctx, '__schildren', [], cssHash, 'html');
2169
2725
  inlinedSubs.push(sub.fn + ';');
2170
- propParts.push(`"children": ${sub.fnName}`);
2726
+ // Tag the server children-block like the client does (see the client
2727
+ // emission in lowerComponentCall): a consumer's `typeof children ===
2728
+ // 'function' && !isChildrenBlock(children)` render-prop check must agree
2729
+ // on BOTH runtimes. Untagged, a binding (e.g. the router Link) INVOKES
2730
+ // the block as a render prop server-side, gets its HTML string back, and
2731
+ // the enclosing hole escapes that markup into visible text.
2732
+ ctx.runtimeNeeded.add('markChildrenBlock');
2733
+ propParts.push(`"children": _$markChildrenBlock(${sub.fnName})`);
2171
2734
  }
2172
2735
  }
2173
2736
  ctx.runtimeNeeded.add('ssrComponent');
2174
- return `_$ssrComponent(__s, ${compExpr}, { ${propParts.join(', ')} })`;
2737
+ return `_$ssrComponent(__s, ${compExpr}, { ${propParts.join(', ')} }${inherit ? ', true' : ''})`;
2175
2738
  }
2176
2739
 
2177
2740
  // ---------------------------------------------------------------------------
@@ -2566,12 +3129,20 @@ function compileComponent(node, ctx, options) {
2566
3129
  ctx.knownStringLocals = prevKnownStr;
2567
3130
  }
2568
3131
 
3132
+ // parallelUse warm plan: attached to the INNER function object (not the
3133
+ // module const) so the component's own body — where the function-
3134
+ // expression name shadows the const — resolves `_$warmChild(Self, …)` to
3135
+ // an object that carries the plan. hmr() forwards `__warm` from the
3136
+ // wrapped fn onto its wrapper for cross-module references.
3137
+ const warmedFn = ctx._pendingWarm ? `Object.assign(${fn}, { __warm: ${ctx._pendingWarm} })` : fn;
3138
+ ctx._pendingWarm = null;
3139
+
2569
3140
  // HMR-wrap exported components inline so the binding stays a `const` (no
2570
3141
  // reassignment dance needed). The wrapper preserves the user-facing
2571
3142
  // function-name identity by NAMING the inner FunctionExpression — `hmr`
2572
3143
  // returns a wrapper that delegates to whatever fn is currently committed,
2573
3144
  // and `module.Foo[HMR].update(...)` swaps it on each accept.
2574
- const valueExpr = hmrWrap && isExported ? `_$hmr(${fn})` : fn;
3145
+ const valueExpr = hmrWrap && isExported ? `_$hmr(${warmedFn})` : warmedFn;
2575
3146
  if (isDefault) {
2576
3147
  return `const ${name} = ${valueExpr};\nexport default ${name};`;
2577
3148
  }
@@ -2641,6 +3212,29 @@ function compileFunctionBody(node, ctx, name, parentNs = 'html', cssHash = null,
2641
3212
  );
2642
3213
  }
2643
3214
 
3215
+ // parallelUse: the parallel-`use()` pipeline (docs/suspense-parallel-use-plan.md)
3216
+ // slots in HERE — after autoCallback (so memoized creations aren't re-wrapped),
3217
+ // before rewriteHookCalls (so the _$useMemo/_$useBatch calls it emits are
3218
+ // compiler-aliased, not user identifiers). Top-level component bodies run the
3219
+ // full pipeline: Pass A memoizes creations across the body AND the directive
3220
+ // arms of the render tree (arms hoist into sub-bodies later, already
3221
+ // transformed), the warm plan is derived from that same analysis, and Pass B
3222
+ // hoists+batches. Sub-bodies (hoisted @try/@if arms via hoistBodyHelper's
3223
+ // legacy path) arrive pre-memoized and run Pass B only.
3224
+ if (ctx.parallelUse) {
3225
+ let warmThunk = null;
3226
+ if (options && options.autoCallback) {
3227
+ const creations = [];
3228
+ const warmChildren = [];
3229
+ workingStatements = parallelUseMemoizePass(workingStatements, ctx, name, creations, [], null);
3230
+ jsxNodes = parallelUseWalkJsx(jsxNodes, ctx, name, creations, warmChildren, [], new Set());
3231
+ const warm = buildWarmArtifacts(node, ctx, name, creations, warmChildren);
3232
+ warmThunk = warm.thunk;
3233
+ ctx._pendingWarm = warm.warmSrc;
3234
+ }
3235
+ workingStatements = rewriteParallelUse(workingStatements, ctx, name, warmThunk);
3236
+ }
3237
+
2644
3238
  // Rewrite hook calls and `<tsrx>` blocks in statements before printing them.
2645
3239
  // A `<tsrx>` block at expression position (e.g. `const f = <tsrx>...</tsrx>`)
2646
3240
  // is hoisted as a render function in inlinedSubs and replaced with an
@@ -2684,7 +3278,14 @@ function compileFunctionBody(node, ctx, name, parentNs = 'html', cssHash = null,
2684
3278
  const prevFDC = ctx._foldedDirectiveCalls;
2685
3279
  if (node.body && node.body.foldedDirectives)
2686
3280
  ctx._foldedDirectiveCalls = node.body.foldedDirectives;
3281
+ // M3 inherit-range: only a real `@{ … }` (JSXCodeBlock) component body spans
3282
+ // its block's whole range — synthetic sub-bodies (@if/@for/@try arms,
3283
+ // children render-fns) pass statement arrays and stay unflagged. planJsx
3284
+ // consumes the flag once (nested planJsx calls see it cleared).
3285
+ const prevInheritBody = ctx._inheritBody;
3286
+ ctx._inheritBody = !!(node.body && node.body.type === 'JSXCodeBlock');
2687
3287
  const plan = planJsx(jsxNodes, ctx, name, inlinedSubs, parentNs, cssHash);
3288
+ ctx._inheritBody = prevInheritBody;
2688
3289
  ctx._foldedDirectiveCalls = prevFDC;
2689
3290
 
2690
3291
  const lines = [];
@@ -2722,26 +3323,846 @@ function compileFunctionBody(node, ctx, name, parentNs = 'html', cssHash = null,
2722
3323
  lines.push(plan.mount);
2723
3324
  lines.push(` }`);
2724
3325
  }
2725
- if (plan.everyRender) lines.push(plan.everyRender);
3326
+ if (plan.everyRender) lines.push(plan.everyRender);
3327
+ }
3328
+ if (plan.after) lines.push(plan.after);
3329
+ // Hoisted `<title>`/`<meta>`/`<link>` → headBlock into document.head
3330
+ // (out-of-band; re-applied each render for reactivity, removed on unmount).
3331
+ if (plan.head) lines.push(plan.head);
3332
+
3333
+ // PROPS-FIRST convention: `(…userProps, __s, __extra)`. The scope is the 2nd arg
3334
+ // (a placeholder leads when there are no user params), so a plain function
3335
+ // `App(props)` binds `props`, while compiled bodies still read `__s` by name.
3336
+ const sig = params ? `${params}, __s, __extra` : `__props, __s, __extra`;
3337
+ return `function ${name}(${sig}) {\n const __block = __s.block;\n${lines.join('\n')}\n}`;
3338
+ }
3339
+
3340
+ // ===========================================================================
3341
+ // Parallel use() — docs/suspense-parallel-use-plan.md
3342
+ // ===========================================================================
3343
+
3344
+ // The parallel-`use()` pipeline (gated on ctx.parallelUse). Three cooperating
3345
+ // transforms reconstruct Solid/Ripple's "fetch starts at creation, suspension
3346
+ // happens at read" property for React-shaped `use()` code:
3347
+ //
3348
+ // Pass A (top-level component bodies, incl. directive-arm bodies BEFORE
3349
+ // they're hoisted): wrap each non-trivial `use(<expr>)` argument in a
3350
+ // slot-keyed `_$useMemo(() => <expr>, [deps], _h$N)` creation. Deps are
3351
+ // one-level member paths of the expression's free variables (props.id,
3352
+ // fetchFn), so refetch happens exactly when inputs change and a replay
3353
+ // can never mint a fresh promise. Loops and nested functions are NOT
3354
+ // entered: loop iterations share one slot symbol, so a memoized creation
3355
+ // there would fresh-promise every render and re-suspend forever.
3356
+ //
3357
+ // Pass B (every function body): find maximal runs of `use()` declaration
3358
+ // statements (const/let interleaves only, taint-tracked), hoist each
3359
+ // run's creations into `__pu$N` temps above the first unwrap, and emit
3360
+ // `_$useBatch([...temps], warmThunk?)` so the boundary suspends ONCE on
3361
+ // the whole stratum instead of once per promise. Unwrap order (and with
3362
+ // it hydration-seed order) is preserved.
3363
+ //
3364
+ // Warm plan (top-level bodies): from the SAME analysis, child component
3365
+ // slots whose reachability guards and props are provably independent of
3366
+ // every non-param local become `_$warmChild` calls in the first batch's
3367
+ // warm thunk, and the component gets a compiled `Comp.__warm` fetch plan
3368
+ // (its own warm-safe creations + guarded child warm calls) so warming
3369
+ // recurses down the tree — the whole descendant fetch tree starts in the
3370
+ // first attempt.
3371
+ //
3372
+ // With the flag off, output is byte-identical (pinned by
3373
+ // tests/compile-parallel-use.test.ts).
3374
+
3375
+ // Names bound by a binding pattern (params, declarator ids). Mirrors the
3376
+ // pattern handling of collectFreeIdentifiers' collectBindings.
3377
+ function collectPatternNames(pat, into) {
3378
+ if (!pat) return into;
3379
+ switch (pat.type) {
3380
+ case 'Identifier':
3381
+ into.add(pat.name);
3382
+ break;
3383
+ case 'ObjectPattern':
3384
+ for (const p of pat.properties || []) {
3385
+ if (p.type === 'RestElement') collectPatternNames(p.argument, into);
3386
+ else collectPatternNames(p.value, into);
3387
+ }
3388
+ break;
3389
+ case 'ArrayPattern':
3390
+ for (const el of pat.elements || []) collectPatternNames(el, into);
3391
+ break;
3392
+ case 'AssignmentPattern':
3393
+ collectPatternNames(pat.left, into);
3394
+ break;
3395
+ case 'RestElement':
3396
+ collectPatternNames(pat.argument, into);
3397
+ break;
3398
+ }
3399
+ return into;
3400
+ }
3401
+
3402
+ // Strip TS value-preserving wrappers (`x as T`, `x!`, `<T>x`, `x satisfies T`).
3403
+ function unwrapTsExpr(n) {
3404
+ while (
3405
+ n &&
3406
+ (n.type === 'TSAsExpression' ||
3407
+ n.type === 'TSNonNullExpression' ||
3408
+ n.type === 'TSTypeAssertion' ||
3409
+ n.type === 'TSSatisfiesExpression' ||
3410
+ n.type === 'ParenthesizedExpression')
3411
+ ) {
3412
+ n = n.expression;
3413
+ }
3414
+ return n;
3415
+ }
3416
+
3417
+ // Dep extraction for a memoized creation: one-level member paths for free
3418
+ // identifiers used as `obj.prop` (→ `props.id`, so a fresh props OBJECT with
3419
+ // unchanged fields doesn't refetch), bare identifiers otherwise. Scope-aware:
3420
+ // identifiers bound inside nested functions don't become deps.
3421
+ function collectDepPaths(expr) {
3422
+ const deps = [];
3423
+ const seen = new Set();
3424
+ const push = (node, key) => {
3425
+ if (seen.has(key)) return;
3426
+ seen.add(key);
3427
+ deps.push(node);
3428
+ };
3429
+ walk(expr, new Set());
3430
+ return deps;
3431
+
3432
+ function walk(n, bound) {
3433
+ if (!n || typeof n !== 'object') return;
3434
+ if (Array.isArray(n)) {
3435
+ for (const x of n) walk(x, bound);
3436
+ return;
3437
+ }
3438
+ switch (n.type) {
3439
+ case 'Identifier':
3440
+ if (!bound.has(n.name)) push({ type: 'Identifier', name: n.name }, n.name);
3441
+ return;
3442
+ case 'MemberExpression':
3443
+ if (!n.computed && n.object.type === 'Identifier' && n.property.type === 'Identifier') {
3444
+ if (!bound.has(n.object.name)) {
3445
+ const key = n.object.name + '.' + n.property.name;
3446
+ push(
3447
+ {
3448
+ type: 'MemberExpression',
3449
+ object: { type: 'Identifier', name: n.object.name },
3450
+ property: { type: 'Identifier', name: n.property.name },
3451
+ computed: false,
3452
+ optional: false,
3453
+ },
3454
+ key,
3455
+ );
3456
+ }
3457
+ return;
3458
+ }
3459
+ walk(n.object, bound);
3460
+ if (n.computed) walk(n.property, bound);
3461
+ return;
3462
+ case 'FunctionExpression':
3463
+ case 'ArrowFunctionExpression': {
3464
+ const inner = new Set(bound);
3465
+ for (const p of n.params || []) collectPatternNames(p, inner);
3466
+ walk(n.body, inner);
3467
+ return;
3468
+ }
3469
+ case 'Property':
3470
+ if (n.computed) walk(n.key, bound);
3471
+ walk(n.value, bound);
3472
+ return;
3473
+ case 'VariableDeclarator':
3474
+ walk(n.init, bound);
3475
+ return;
3476
+ default:
3477
+ for (const k in n) {
3478
+ if (k === 'loc' || k === 'start' || k === 'end' || k === 'metadata') continue;
3479
+ walk(n[k], bound);
3480
+ }
3481
+ }
3482
+ }
3483
+ }
3484
+
3485
+ // A `use()` argument that needs no memoization: already-stable references
3486
+ // (identifiers, static member chains) and literals.
3487
+ function isTrivialUseArg(n) {
3488
+ n = unwrapTsExpr(n);
3489
+ if (!n) return true;
3490
+ if (n.type === 'Identifier' || n.type === 'Literal') return true;
3491
+ if (n.type === 'MemberExpression' && !n.computed) return isTrivialUseArg(n.object);
3492
+ return false;
3493
+ }
3494
+
3495
+ const LOOP_TYPES = new Set([
3496
+ 'ForStatement',
3497
+ 'ForOfStatement',
3498
+ 'ForInStatement',
3499
+ 'WhileStatement',
3500
+ 'DoWhileStatement',
3501
+ ]);
3502
+ const FN_TYPES = new Set(['FunctionExpression', 'FunctionDeclaration', 'ArrowFunctionExpression']);
3503
+
3504
+ // Is this statement a `const/let x = use(<arg>)` declaration (or a bare
3505
+ // `use(<arg>)` expression statement)? Returns the use CallExpression or null.
3506
+ function useCallOfStatement(stmt) {
3507
+ let call = null;
3508
+ if (
3509
+ stmt.type === 'VariableDeclaration' &&
3510
+ (stmt.kind === 'const' || stmt.kind === 'let') &&
3511
+ stmt.declarations &&
3512
+ stmt.declarations.length === 1
3513
+ ) {
3514
+ call = unwrapTsExpr(stmt.declarations[0].init);
3515
+ } else if (stmt.type === 'ExpressionStatement') {
3516
+ call = unwrapTsExpr(stmt.expression);
3517
+ }
3518
+ if (
3519
+ call &&
3520
+ call.type === 'CallExpression' &&
3521
+ call.callee.type === 'Identifier' &&
3522
+ call.callee.name === 'use' &&
3523
+ call.arguments.length >= 1
3524
+ ) {
3525
+ return call;
3526
+ }
3527
+ return null;
3528
+ }
3529
+
3530
+ // ── Pass A: memoize use() argument creations ───────────────────────────────
3531
+ //
3532
+ // Walks a statement array (recursing into if/blocks, NOT into loops or nested
3533
+ // functions), rewriting each non-trivial `use(<expr>)` argument to
3534
+ // `_$useMemo(() => <expr>, [deps], _h$N)` in place. Records every creation
3535
+ // with its guard chain for the warm plan. Returns a new array.
3536
+ function parallelUseMemoizePass(stmts, ctx, componentName, creations, guards, locals) {
3537
+ return stmts.map((stmt) => rewriteStmt(stmt));
3538
+
3539
+ function rewriteStmt(stmt) {
3540
+ if (!stmt || typeof stmt !== 'object') return stmt;
3541
+ if (LOOP_TYPES.has(stmt.type) || FN_TYPES.has(stmt.type)) return stmt;
3542
+ const call = useCallOfStatement(stmt);
3543
+ if (call) {
3544
+ const rewritten = rewriteUseCall(call);
3545
+ if (rewritten === call) return stmt;
3546
+ if (stmt.type === 'VariableDeclaration') {
3547
+ return {
3548
+ ...stmt,
3549
+ declarations: [{ ...stmt.declarations[0], init: rewritten }],
3550
+ };
3551
+ }
3552
+ return { ...stmt, expression: rewritten };
3553
+ }
3554
+ if (stmt.type === 'IfStatement') {
3555
+ return {
3556
+ ...stmt,
3557
+ consequent: rewriteStmt(stmt.consequent),
3558
+ alternate: stmt.alternate ? rewriteStmt(stmt.alternate) : stmt.alternate,
3559
+ };
3560
+ }
3561
+ if (stmt.type === 'BlockStatement') {
3562
+ return {
3563
+ ...stmt,
3564
+ body: parallelUseMemoizePass(stmt.body, ctx, componentName, creations, guards, locals),
3565
+ };
3566
+ }
3567
+ return stmt;
3568
+ }
3569
+
3570
+ function rewriteUseCall(call) {
3571
+ const arg = unwrapTsExpr(call.arguments[0]);
3572
+ if (isTrivialUseArg(arg)) return call;
3573
+ const symVar = allocHookSymbol(ctx, `${componentName}.use.memo#${ctx.nextHookSymId}`);
3574
+ const deps = collectDepPaths(arg);
3575
+ // Server mirror: `puMemo` — keyed CROSS-PASS creation cache (a fresh
3576
+ // SSRScope per pass makes client useMemo semantics useless there).
3577
+ const memoHelper = ctx.mode === 'server' ? 'puMemo' : 'useMemo';
3578
+ ctx.runtimeNeeded.add(memoHelper);
3579
+ creations.push({ symVar, expr: arg, deps, guards: [...guards], locals });
3580
+ const memoCall = {
3581
+ type: 'CallExpression',
3582
+ callee: { type: 'Identifier', name: `_$${memoHelper}` },
3583
+ arguments: [
3584
+ { type: 'ArrowFunctionExpression', params: [], expression: true, async: false, body: arg },
3585
+ { type: 'ArrayExpression', elements: deps },
3586
+ { type: 'Identifier', name: symVar },
3587
+ ],
3588
+ optional: false,
3589
+ };
3590
+ return { ...call, arguments: [memoCall, ...call.arguments.slice(1)] };
3591
+ }
3592
+ }
3593
+
3594
+ // Pass A over the RENDER tree: memoize statements inside directive-arm bodies
3595
+ // (they hoist into sub-bodies later, already transformed) and collect
3596
+ // child-component warm candidates with their guard chains. Recursion stops at
3597
+ // @for/@switch arms (v1) and does not enter component children. `locals`
3598
+ // accumulates arm-scoped bindings (e.g. `const a = use(…)` inside a @try
3599
+ // body) so warm-safety can see them — they are NOT in
3600
+ // ctx.currentComponentLocals, which only covers the top body.
3601
+ function parallelUseWalkJsx(nodes, ctx, componentName, creations, warmChildren, guards, locals) {
3602
+ return nodes.map((n) => walkNode(n));
3603
+
3604
+ function walkNode(node) {
3605
+ if (!node || typeof node !== 'object') return node;
3606
+ switch (node.type) {
3607
+ case 'JSXElement': {
3608
+ const nameNode = node.openingElement && node.openingElement.name;
3609
+ const isComponent =
3610
+ nameNode && nameNode.type === 'JSXIdentifier' && /^[A-Z]/.test(nameNode.name);
3611
+ if (isComponent) {
3612
+ collectWarmChild(node, nameNode.name);
3613
+ return node; // component children render inside the child — don't descend
3614
+ }
3615
+ return {
3616
+ ...node,
3617
+ children: parallelUseWalkJsx(
3618
+ node.children || [],
3619
+ ctx,
3620
+ componentName,
3621
+ creations,
3622
+ warmChildren,
3623
+ guards,
3624
+ locals,
3625
+ ),
3626
+ };
3627
+ }
3628
+ case 'JSXFragment':
3629
+ return {
3630
+ ...node,
3631
+ children: parallelUseWalkJsx(
3632
+ node.children || [],
3633
+ ctx,
3634
+ componentName,
3635
+ creations,
3636
+ warmChildren,
3637
+ guards,
3638
+ locals,
3639
+ ),
3640
+ };
3641
+ case 'JSXIfExpression': {
3642
+ const not = (e) => ({ type: 'UnaryExpression', operator: '!', prefix: true, argument: e });
3643
+ const consequent = walkArm(node.consequent, [...guards, node.test]);
3644
+ const alternate = node.alternate
3645
+ ? walkArm(node.alternate, [...guards, not(node.test)])
3646
+ : node.alternate;
3647
+ return { ...node, consequent, alternate };
3648
+ }
3649
+ case 'JSXTryExpression': {
3650
+ // The try arm renders whenever the component renders — no extra
3651
+ // guard. @pending/@catch arms are exceptional paths: no warming.
3652
+ const out = { ...node };
3653
+ if (node.block) out.block = walkArm(node.block, guards);
3654
+ return out;
3655
+ }
3656
+ default:
3657
+ // @for / @switch arms: v1 — no memoization, no warming (loop slot
3658
+ // sharing; switch guards deferred). Everything else is inert.
3659
+ return node;
3660
+ }
3661
+ }
3662
+
3663
+ function walkArm(arm, armGuards) {
3664
+ if (!arm || arm.type !== 'BlockStatement') return arm;
3665
+ // Arm-scoped bindings become visible to warm-safety for everything in
3666
+ // this arm (conservatively hoisted: order within the arm is ignored).
3667
+ const armLocals = new Set(locals);
3668
+ for (const entry of arm.body) {
3669
+ if (entry.type === 'VariableDeclaration') {
3670
+ for (const d of entry.declarations || []) collectPatternNames(d.id, armLocals);
3671
+ }
3672
+ }
3673
+ // Arm bodies interleave statements and JSX nodes; route each entry to
3674
+ // the right walker.
3675
+ const body = arm.body.map((entry) => {
3676
+ if (
3677
+ entry.type === 'JSXElement' ||
3678
+ entry.type === 'JSXFragment' ||
3679
+ entry.type === 'JSXIfExpression' ||
3680
+ entry.type === 'JSXTryExpression'
3681
+ ) {
3682
+ return parallelUseWalkJsx(
3683
+ [entry],
3684
+ ctx,
3685
+ componentName,
3686
+ creations,
3687
+ warmChildren,
3688
+ armGuards,
3689
+ armLocals,
3690
+ )[0];
3691
+ }
3692
+ return parallelUseMemoizePass(
3693
+ [entry],
3694
+ ctx,
3695
+ componentName,
3696
+ creations,
3697
+ armGuards,
3698
+ armLocals,
3699
+ )[0];
3700
+ });
3701
+ return { ...arm, body };
3702
+ }
3703
+
3704
+ function collectWarmChild(node, compName) {
3705
+ const attrs = node.openingElement.attributes || [];
3706
+ if ((node.children || []).length > 0) return; // children render inside the child — skip
3707
+ const props = [];
3708
+ for (const a of attrs) {
3709
+ if (a.type !== 'JSXAttribute' || a.name.type !== 'JSXIdentifier') return; // spread etc.
3710
+ const key = a.name.name;
3711
+ if (key === 'ref' || key === 'key') return; // instance-wired props — skip this slot
3712
+ let value;
3713
+ if (a.value == null) value = { type: 'Literal', value: true, raw: 'true' };
3714
+ else if (a.value.type === 'JSXExpressionContainer') value = a.value.expression;
3715
+ else value = a.value; // Literal
3716
+ props.push({ key, value });
3717
+ }
3718
+ warmChildren.push({ compName, props, guards: [...guards], locals });
3719
+ }
3720
+ }
3721
+
3722
+ // Does this expression subtree contain JSX (or a TSRX directive)? Such
3723
+ // expressions cannot be re-printed into a warm plan — they only exist in
3724
+ // lowered form inside the real render path.
3725
+ function containsJsxNode(n) {
3726
+ if (!n || typeof n !== 'object') return false;
3727
+ if (Array.isArray(n)) return n.some(containsJsxNode);
3728
+ if (typeof n.type === 'string' && n.type.startsWith('JSX')) return true;
3729
+ for (const k in n) {
3730
+ if (k === 'loc' || k === 'start' || k === 'end' || k === 'metadata') continue;
3731
+ if (containsJsxNode(n[k])) return true;
3732
+ }
3733
+ return false;
3734
+ }
3735
+
3736
+ // Warm-safety: every free identifier of the expression is a component param
3737
+ // or module-scope (NOT a non-param local — component-body OR directive-arm
3738
+ // scoped; those may not exist or may be suspended-data-derived at warm time),
3739
+ // and the expression is plain JS (no JSX — descriptors only exist lowered).
3740
+ function isWarmSafeExpr(expr, paramNames, componentLocals, armLocals) {
3741
+ if (containsJsxNode(expr)) return false;
3742
+ const free = collectFreeIdentifiers(expr, []);
3743
+ for (const id of free) {
3744
+ if (paramNames.has(id)) continue;
3745
+ if (componentLocals && componentLocals.has(id)) return false;
3746
+ if (armLocals && armLocals.has(id)) return false;
3747
+ }
3748
+ return true;
3749
+ }
3750
+
3751
+ // ── Pass B: run detection + hoist + batch ───────────────────────────────────
3752
+ function rewriteParallelUse(statements, ctx, componentName, warmThunk) {
3753
+ let firstBatch = true;
3754
+ return transformList(statements);
3755
+
3756
+ function transformList(stmts) {
3757
+ const out = [];
3758
+ let run = null; // { members: [{stmt, call?, creation?}], names: Set }
3759
+ const flush = () => {
3760
+ if (!run) return;
3761
+ emitRun(run, out);
3762
+ run = null;
3763
+ };
3764
+ for (const stmt of stmts) {
3765
+ const call = stmt && typeof stmt === 'object' ? useCallOfStatement(stmt) : null;
3766
+ if (call) {
3767
+ const creation = unwrapTsExpr(call.arguments[0]);
3768
+ const free = collectFreeIdentifiers(creation, []);
3769
+ let conflict = false;
3770
+ if (run) {
3771
+ for (const id of free) {
3772
+ if (run.names.has(id)) {
3773
+ conflict = true;
3774
+ break;
3775
+ }
3776
+ }
3777
+ }
3778
+ if (conflict) flush();
3779
+ if (!run) run = { members: [], names: new Set() };
3780
+ run.members.push({ stmt, call, creation });
3781
+ if (stmt.type === 'VariableDeclaration') {
3782
+ collectPatternNames(stmt.declarations[0].id, run.names);
3783
+ }
3784
+ continue;
3785
+ }
3786
+ if (
3787
+ run &&
3788
+ stmt &&
3789
+ stmt.type === 'VariableDeclaration' &&
3790
+ (stmt.kind === 'const' || stmt.kind === 'let')
3791
+ ) {
3792
+ // Interleaved declaration: allowed in a run, but its bindings join
3793
+ // the no-hoist-past set (a later creation referencing them cannot
3794
+ // hoist above this statement).
3795
+ run.members.push({ stmt });
3796
+ for (const d of stmt.declarations || []) collectPatternNames(d.id, run.names);
3797
+ continue;
3798
+ }
3799
+ flush();
3800
+ // Recurse into conditional blocks so a guarded use() run batches
3801
+ // within its own block (loops/functions stay untouched).
3802
+ if (stmt && stmt.type === 'IfStatement') {
3803
+ out.push({
3804
+ ...stmt,
3805
+ consequent: transformStmtBlock(stmt.consequent),
3806
+ alternate: stmt.alternate ? transformStmtBlock(stmt.alternate) : stmt.alternate,
3807
+ });
3808
+ continue;
3809
+ }
3810
+ if (stmt && stmt.type === 'BlockStatement') {
3811
+ out.push({ ...stmt, body: transformList(stmt.body) });
3812
+ continue;
3813
+ }
3814
+ out.push(stmt);
3815
+ }
3816
+ flush();
3817
+ return out;
3818
+ }
3819
+
3820
+ function transformStmtBlock(s) {
3821
+ if (!s) return s;
3822
+ if (s.type === 'BlockStatement') return { ...s, body: transformList(s.body) };
3823
+ return s;
3824
+ }
3825
+
3826
+ function emitRun(run, out) {
3827
+ const uses = run.members.filter((m) => m.call);
3828
+ if (uses.length === 0) {
3829
+ for (const m of run.members) out.push(m.stmt);
3830
+ return;
3831
+ }
3832
+ // Hoist each creation into a temp, batch, then re-emit the original
3833
+ // statements with creations swapped for the temps. Unwrap order (and
3834
+ // hydration-seed order with it) is untouched.
3835
+ const temps = [];
3836
+ const tempOf = new Map();
3837
+ for (const m of uses) {
3838
+ const name = `__pu$${ctx.nextPuId++}`;
3839
+ temps.push({ name, init: m.creation });
3840
+ tempOf.set(m.call, name);
3841
+ }
3842
+ for (const t of temps) {
3843
+ out.push({
3844
+ type: 'VariableDeclaration',
3845
+ kind: 'const',
3846
+ declarations: [
3847
+ {
3848
+ type: 'VariableDeclarator',
3849
+ id: { type: 'Identifier', name: t.name },
3850
+ init: t.init,
3851
+ },
3852
+ ],
3853
+ });
3854
+ }
3855
+ // Server mirror: `puBatch` registers every unresolved thenable of the run
3856
+ // with the render loop and suspends ONCE (identity-resolved on the next
3857
+ // pass — see runtime.server.ts).
3858
+ const batchHelper = ctx.mode === 'server' ? 'puBatch' : 'useBatch';
3859
+ ctx.runtimeNeeded.add(batchHelper);
3860
+ const batchArgs = [
3861
+ {
3862
+ type: 'ArrayExpression',
3863
+ elements: temps.map((t) => ({ type: 'Identifier', name: t.name })),
3864
+ },
3865
+ ];
3866
+ if (firstBatch && warmThunk) batchArgs.push(warmThunk);
3867
+ firstBatch = false;
3868
+ out.push({
3869
+ type: 'ExpressionStatement',
3870
+ expression: {
3871
+ type: 'CallExpression',
3872
+ callee: { type: 'Identifier', name: `_$${batchHelper}` },
3873
+ arguments: batchArgs,
3874
+ optional: false,
3875
+ },
3876
+ });
3877
+ for (const m of run.members) {
3878
+ if (!m.call) {
3879
+ out.push(m.stmt);
3880
+ continue;
3881
+ }
3882
+ const tempId = { type: 'Identifier', name: tempOf.get(m.call) };
3883
+ const newCall = { ...m.call, arguments: [tempId, ...m.call.arguments.slice(1)] };
3884
+ if (m.stmt.type === 'VariableDeclaration') {
3885
+ out.push({
3886
+ ...m.stmt,
3887
+ declarations: [{ ...m.stmt.declarations[0], init: newCall }],
3888
+ });
3889
+ } else {
3890
+ out.push({ ...m.stmt, expression: newCall });
3891
+ }
3892
+ }
2726
3893
  }
2727
- if (plan.after) lines.push(plan.after);
2728
- // Hoisted `<title>`/`<meta>`/`<link>` → headBlock into document.head
2729
- // (out-of-band; re-applied each render for reactivity, removed on unmount).
2730
- if (plan.head) lines.push(plan.head);
3894
+ }
2731
3895
 
2732
- // PROPS-FIRST convention: `(…userProps, __s, __extra)`. The scope is the 2nd arg
2733
- // (a placeholder leads when there are no user params), so a plain function
2734
- // `App(props)` binds `props`, while compiled bodies still read `__s` by name.
2735
- const sig = params ? `${params}, __s, __extra` : `__props, __s, __extra`;
2736
- return `function ${name}(${sig}) {\n const __block = __s.block;\n${lines.join('\n')}\n}`;
3896
+ // Build the warm thunk AST (child warm calls for the first in-body batch) +
3897
+ // the `Comp.__warm` source (creations + child calls) for a top-level body.
3898
+ function buildWarmArtifacts(node, ctx, componentName, creations, warmChildren) {
3899
+ const paramNames = new Set();
3900
+ for (const p of node.params || []) collectPatternNames(p, paramNames);
3901
+ const locals = ctx.currentComponentLocals;
3902
+
3903
+ const guardOk = (guards, armLocals) =>
3904
+ guards.every((g) => isWarmSafeExpr(g, paramNames, locals, armLocals));
3905
+ const andChain = (guards) =>
3906
+ guards.length === 0
3907
+ ? null
3908
+ : guards.reduce(
3909
+ (acc, g) =>
3910
+ acc ? { type: 'LogicalExpression', operator: '&&', left: acc, right: g } : g,
3911
+ null,
3912
+ );
3913
+
3914
+ const warmMemos = creations.filter(
3915
+ (c) => guardOk(c.guards, c.locals) && isWarmSafeExpr(c.expr, paramNames, locals, c.locals),
3916
+ );
3917
+ const warmKids = warmChildren.filter(
3918
+ (w) =>
3919
+ guardOk(w.guards, w.locals) &&
3920
+ !paramNames.has(w.compName) &&
3921
+ !(locals && locals.has(w.compName)) &&
3922
+ !(w.locals && w.locals.has(w.compName)) &&
3923
+ w.props.every((p) => isWarmSafeExpr(p.value, paramNames, locals, w.locals)),
3924
+ );
3925
+ if (warmKids.length === 0 && warmMemos.length === 0) return { thunk: null, warmSrc: null };
3926
+
3927
+ const stmtFor = (guards, callExpr) => {
3928
+ const g = andChain(guards);
3929
+ const call = { type: 'ExpressionStatement', expression: callExpr };
3930
+ return g ? { type: 'IfStatement', test: g, consequent: call, alternate: null } : call;
3931
+ };
3932
+ const memoCall = (c) => ({
3933
+ type: 'CallExpression',
3934
+ callee: { type: 'Identifier', name: '_$warmMemo' },
3935
+ arguments: [
3936
+ { type: 'ArrowFunctionExpression', params: [], expression: true, async: false, body: c.expr },
3937
+ { type: 'ArrayExpression', elements: c.deps },
3938
+ { type: 'Identifier', name: c.symVar },
3939
+ ],
3940
+ optional: false,
3941
+ });
3942
+ const childCall = (w) => ({
3943
+ type: 'CallExpression',
3944
+ callee: { type: 'Identifier', name: '_$warmChild' },
3945
+ arguments: [
3946
+ { type: 'Identifier', name: w.compName },
3947
+ {
3948
+ type: 'ObjectExpression',
3949
+ properties: w.props.map((p) => ({
3950
+ type: 'Property',
3951
+ key: { type: 'Identifier', name: p.key },
3952
+ value: p.value,
3953
+ kind: 'init',
3954
+ method: false,
3955
+ shorthand: false,
3956
+ computed: false,
3957
+ })),
3958
+ },
3959
+ ],
3960
+ optional: false,
3961
+ });
3962
+
3963
+ if (warmMemos.length > 0) ctx.runtimeNeeded.add('warmMemo');
3964
+ if (warmKids.length > 0) ctx.runtimeNeeded.add('warmChild');
3965
+
3966
+ // In-body warm thunk: children only — the body's own creations already ran
3967
+ // as real memos by the time the batch throws.
3968
+ const thunk =
3969
+ warmKids.length === 0
3970
+ ? null
3971
+ : {
3972
+ type: 'ArrowFunctionExpression',
3973
+ params: [],
3974
+ expression: false,
3975
+ async: false,
3976
+ body: {
3977
+ type: 'BlockStatement',
3978
+ body: warmKids.map((w) => stmtFor(w.guards, childCall(w))),
3979
+ },
3980
+ };
3981
+
3982
+ // The hoisted fetch plan: creations + child calls, params destructured
3983
+ // from the incoming props object. Single-param components only (the norm).
3984
+ // Returned as a bare arrow — compileComponent attaches it to the INNER
3985
+ // function object via Object.assign, so the component's own body (where
3986
+ // the function-expression name shadows the module const) sees it too;
3987
+ // hmr() forwards it from the wrapped fn onto the HMR wrapper.
3988
+ let warmSrc = null;
3989
+ if ((node.params || []).length <= 1 && (warmMemos.length > 0 || warmKids.length > 0)) {
3990
+ const bodyStmts = [
3991
+ ...warmMemos.map((c) => stmtFor(c.guards, memoCall(c))),
3992
+ ...warmKids.map((w) => stmtFor(w.guards, childCall(w))),
3993
+ ];
3994
+ const destructure =
3995
+ node.params.length === 1 ? `\tconst ${printNode(node.params[0])} = __wp;\n` : '';
3996
+ const body = bodyStmts.map((s) => '\t' + printNode(s).replace(/\n/g, '\n\t')).join('\n');
3997
+ warmSrc = `(__wp) => {\n${destructure}${body}\n}`;
3998
+ }
3999
+ return { thunk, warmSrc };
2737
4000
  }
2738
4001
 
2739
4002
  // ===========================================================================
2740
4003
  // Hook-call rewriting
2741
4004
  // ===========================================================================
2742
4005
 
4006
+ // Plain JS loop statements (display word for the diagnostic). A TEMPLATE `@for`
4007
+ // also parses to a ForOfStatement — told apart by its JSX body, the same
4008
+ // classification isJsxNode uses — and is the SUPPORTED way to loop hooks: each
4009
+ // item renders in its own block scope (mountItem → renderBlock swaps
4010
+ // CURRENT_SCOPE per item), so per-item hooks get per-item state.
4011
+ const JS_LOOP_WORD = {
4012
+ ForStatement: 'for',
4013
+ ForInStatement: 'for…in',
4014
+ ForOfStatement: 'for…of',
4015
+ WhileStatement: 'while',
4016
+ DoWhileStatement: 'do…while',
4017
+ };
4018
+
4019
+ // A SLOT-KEYED hook call: a builtin base hook, or a custom hook by the
4020
+ // `use[A-Z]` convention (identifier or method form) — the same match
4021
+ // rewriteHookCalls slots below. `useContext` (keyed by context identity) and
4022
+ // bare `use` (keyed by per-render call order — `block.__thenableIdx` on the
4023
+ // client, the frame occurrence counter in runtime.server.ts) are NOT
4024
+ // slot-keyed, so they genuinely work in loops and are exempt.
4025
+ function slotKeyedHookName(n) {
4026
+ if (n.type !== 'CallExpression') return null;
4027
+ if (n.callee.type === 'Identifier') {
4028
+ const name = n.callee.name;
4029
+ if (HOOK_NAMES.has(name)) return name;
4030
+ if (/^use[A-Z]/.test(name) && name !== 'useContext') return name;
4031
+ return null;
4032
+ }
4033
+ if (
4034
+ !n.optional &&
4035
+ n.callee.type === 'MemberExpression' &&
4036
+ !n.callee.computed &&
4037
+ n.callee.property.type === 'Identifier' &&
4038
+ /^use[A-Z]/.test(n.callee.property.name) &&
4039
+ n.callee.property.name !== 'useContext'
4040
+ ) {
4041
+ return n.callee.property.name;
4042
+ }
4043
+ return null;
4044
+ }
4045
+
4046
+ // REJECT a slot-keyed hook lexically inside a plain JS loop. Hooks are keyed by
4047
+ // a per-call-site symbol, so every iteration of the loop would hit the ONE slot
4048
+ // assigned to that call site: useState/useReducer would share a single state
4049
+ // cell across all iterations, useMemo would recompute every iteration (each
4050
+ // iteration's deps overwrite the previous entry, only the last survives), and
4051
+ // slot-keyed effects would collide the same way — all silently. The scan skips
4052
+ // ONLY nested DEFERRED function boundaries (a function declared in the loop may
4053
+ // be a local component or a deferred callback — each component instance gets
4054
+ // its own scope). A function that provably EXECUTES during the iteration is
4055
+ // walked into: an IIFE callee (incl. `.call`/`.apply` on a function
4056
+ // expression) and an inline callback to a known synchronous array-iteration
4057
+ // method (`.map`, `.forEach`, …) — its hooks run once per iteration and
4058
+ // collide exactly like inline ones. Directive constructs inside the loop
4059
+ // subtree are NOT exempt either: their bodies do compile to helper render fns,
4060
+ // but the construct's own per-call-site slot (ifBlock/forBlock state on
4061
+ // `scope.slots`) repeats each iteration exactly like a hook slot does, so
4062
+ // hooks reached through them collide all the same. Every LEGIT template
4063
+ // directive is protected before the scan starts — the guard in
4064
+ // rewriteHookCalls fires only on plain-JS loop statements, and a template
4065
+ // `@for` (ForOfStatement with a JSX body) is excluded there, so hooks in
4066
+ // template positions (including `.map()`-to-`@for` children) never reach this
4067
+ // walker.
4068
+ const SYNC_ITERATION_METHODS = new Set([
4069
+ 'map',
4070
+ 'forEach',
4071
+ 'filter',
4072
+ 'flatMap',
4073
+ 'reduce',
4074
+ 'reduceRight',
4075
+ 'some',
4076
+ 'every',
4077
+ 'find',
4078
+ 'findIndex',
4079
+ 'findLast',
4080
+ 'findLastIndex',
4081
+ 'sort',
4082
+ 'toSorted',
4083
+ ]);
4084
+ function isFunctionNode(n) {
4085
+ return (
4086
+ n &&
4087
+ (n.type === 'FunctionDeclaration' ||
4088
+ n.type === 'FunctionExpression' ||
4089
+ n.type === 'ArrowFunctionExpression')
4090
+ );
4091
+ }
4092
+ function rejectHookInJsLoop(loop, ctx, componentName) {
4093
+ const seen = new WeakSet();
4094
+ // Function nodes that run during the loop iteration itself — marked when
4095
+ // their enclosing CallExpression is visited (parents visit before children),
4096
+ // so the boundary check below can let the walk continue into their bodies.
4097
+ const syncInvoked = new WeakSet();
4098
+ function walk(n) {
4099
+ if (!n) return;
4100
+ if (Array.isArray(n)) {
4101
+ for (const x of n) walk(x);
4102
+ return;
4103
+ }
4104
+ if (typeof n !== 'object') return;
4105
+ const t = n.type;
4106
+ if (!t || seen.has(n)) return;
4107
+ seen.add(n);
4108
+ if (t === 'CallExpression') {
4109
+ // IIFE: `(() => …)()` — the callee body executes in place.
4110
+ if (isFunctionNode(n.callee)) syncInvoked.add(n.callee);
4111
+ if (n.callee.type === 'MemberExpression' && !n.callee.computed) {
4112
+ const prop = n.callee.property;
4113
+ // `(function () { … }).call(this)` / `.apply(...)` — IIFE variants.
4114
+ if (
4115
+ prop.type === 'Identifier' &&
4116
+ (prop.name === 'call' || prop.name === 'apply') &&
4117
+ isFunctionNode(n.callee.object)
4118
+ ) {
4119
+ syncInvoked.add(n.callee.object);
4120
+ }
4121
+ // `xs.map(cb)` and friends — the callback runs synchronously, once
4122
+ // per element, during this iteration. (Template-position `.map()`
4123
+ // children never sit inside a plain JS loop, so the legit
4124
+ // map-to-`@for` pattern can't reach here.)
4125
+ if (prop.type === 'Identifier' && SYNC_ITERATION_METHODS.has(prop.name)) {
4126
+ for (const a of n.arguments) if (isFunctionNode(a)) syncInvoked.add(a);
4127
+ }
4128
+ }
4129
+ }
4130
+ if (isFunctionNode(n) && !syncInvoked.has(n)) return;
4131
+ const hook = slotKeyedHookName(n);
4132
+ if (hook !== null) {
4133
+ const l = (n.loc && n.loc.start) || (loop.loc && loop.loc.start);
4134
+ const at = l
4135
+ ? ` (${ctx.mapSourceName ? ctx.mapSourceName + ':' : ''}${l.line}:${l.column})`
4136
+ : '';
4137
+ throw new Error(
4138
+ `\`${hook}\` is called inside a \`${JS_LOOP_WORD[loop.type]}\` loop in ${componentName}. ` +
4139
+ `Hooks are keyed by call site, so every iteration would share this call site's ONE ` +
4140
+ `hook slot — state, memo, and effect entries would silently collide across ` +
4141
+ `iterations. Loop in the template with the keyed \`@for\` directive instead (each ` +
4142
+ `item renders in its own scope, so per-item hooks get per-item state), or extract ` +
4143
+ `the loop body into a child component. \`use()\` and \`useContext\` are exempt ` +
4144
+ `(call-order / context-identity keyed, not slot-keyed).${at}`,
4145
+ );
4146
+ }
4147
+ for (const key in n) {
4148
+ if (AST_WALK_SKIP_KEYS.has(key)) continue;
4149
+ walk(n[key]);
4150
+ }
4151
+ }
4152
+ walk(loop);
4153
+ }
4154
+
2743
4155
  function rewriteHookCalls(node, ctx, componentName) {
2744
4156
  return mapAst(node, (n) => {
4157
+ // A plain JS loop must not contain slot-keyed hook calls — reject before
4158
+ // slotting. (A template `@for` is also a ForOfStatement; its JSX body tells
4159
+ // it apart, and it is the supported way to loop hooks.)
4160
+ if (
4161
+ JS_LOOP_WORD[n.type] !== undefined &&
4162
+ !(n.type === 'ForOfStatement' && bodyContainsJsx(n.body))
4163
+ ) {
4164
+ rejectHookInJsLoop(n, ctx, componentName);
4165
+ }
2745
4166
  if (n.type === 'CallExpression' && n.callee.type === 'Identifier') {
2746
4167
  const name = n.callee.name;
2747
4168
  // Three kinds of call get a trailing per-call-site slot symbol:
@@ -2756,7 +4177,8 @@ function rewriteHookCalls(node, ctx, componentName) {
2756
4177
  // (or the same hook twice) stay independent.
2757
4178
  //
2758
4179
  // NB: a `use*` NAME is reserved for hooks (React's convention) — a non-hook
2759
- // function named like one gets a harmless extra trailing argument.
4180
+ // function named like one gets a harmless extra trailing argument (though
4181
+ // inside a plain JS loop the convention is enforced: rejectHookInJsLoop).
2760
4182
  const isBuiltin = HOOK_NAMES.has(name);
2761
4183
  const isCustom = /^use[A-Z]/.test(name) && name !== 'useContext';
2762
4184
  const isServerUse = name === 'use' && ctx.mode === 'server';
@@ -2811,6 +4233,49 @@ function rewriteHookCalls(node, ctx, componentName) {
2811
4233
  };
2812
4234
  }
2813
4235
  }
4236
+ // METHOD-style custom hooks — `route.useLoaderData()`, `api.useSearch()`
4237
+ // (React-ecosystem object-carried hooks: TanStack Route/RouteApi accessors
4238
+ // and the like). Same `use[A-Z]` convention, applied to the PROPERTY name.
4239
+ // The callee is a member access, so the plain withSlot form would sever
4240
+ // `this`; instead the WHOLE call is wrapped in a thunk —
4241
+ // `_$withSlot(sym, () => obj.useX(...args, sym))` — which pushes the
4242
+ // call-site path symbol for the hook's inner base hooks while the trailing
4243
+ // `sym` still reaches slot-forwarding bindings (splitSlot convention).
4244
+ if (
4245
+ n.type === 'CallExpression' &&
4246
+ !n.optional &&
4247
+ n.callee.type === 'MemberExpression' &&
4248
+ !n.callee.computed &&
4249
+ n.callee.property.type === 'Identifier' &&
4250
+ /^use[A-Z]/.test(n.callee.property.name) &&
4251
+ n.callee.property.name !== 'useContext'
4252
+ ) {
4253
+ const debug = `${componentName}.${n.callee.property.name}#${ctx.nextHookSymId}`;
4254
+ const symVar = allocHookSymbol(ctx, debug);
4255
+ ctx.runtimeNeeded.add('withSlot');
4256
+ const object = rewriteHookCalls(n.callee.object, ctx, componentName);
4257
+ const args = n.arguments.map((a) => rewriteHookCalls(a, ctx, componentName));
4258
+ return {
4259
+ type: 'CallExpression',
4260
+ callee: { type: 'Identifier', name: '_$withSlot' },
4261
+ arguments: [
4262
+ { type: 'Identifier', name: symVar },
4263
+ {
4264
+ type: 'ArrowFunctionExpression',
4265
+ params: [],
4266
+ expression: true,
4267
+ async: false,
4268
+ body: {
4269
+ type: 'CallExpression',
4270
+ callee: { ...n.callee, object },
4271
+ arguments: [...args, { type: 'Identifier', name: symVar }],
4272
+ optional: false,
4273
+ },
4274
+ },
4275
+ ],
4276
+ optional: false,
4277
+ };
4278
+ }
2814
4279
  return null;
2815
4280
  });
2816
4281
  }
@@ -2845,14 +4310,44 @@ function compileReturnJsxFunction(node, ctx, options) {
2845
4310
  generator: false,
2846
4311
  body: { type: 'BlockStatement', body: newStatements },
2847
4312
  };
2848
- let code = printNode(fn);
4313
+ // Print with esrap's real per-token map (same output bytes as printNode) so
4314
+ // this function contributes segments to the module map — compile() drains
4315
+ // them via ctx._setupMaps, exactly like a component's setup statements.
4316
+ // Chained consumers (e.g. @octanejs/mdx's two-stage .mdx map) need segments
4317
+ // on these lines; a map-less print would compose to an empty chain.
4318
+ const printed = printNodeWithMap(fn, ctx);
4319
+ let code = printed.code;
4320
+ const mappings = printed.mappings;
2849
4321
  if (compInlinedSubs.length) {
2850
4322
  // Helpers are hoisted function declarations → position-independent; splice them
2851
4323
  // in right after the function's opening `{` so they're in the component scope.
2852
4324
  const i = code.indexOf('{');
2853
4325
  const subs = compInlinedSubs.map((s) => ' ' + s.replace(/\n/g, '\n ')).join('\n');
4326
+ // The splice inserts `'\n' + subs` after the `{`: every printed line below
4327
+ // the splice line shifts down by the inserted line count. Keep the map in
4328
+ // sync by inserting that many empty mapping lines at the same point.
4329
+ // Decoded rows are dense up to the LAST line with segments, so when the
4330
+ // array ends at (or before) the splice line, every shifted line has no
4331
+ // segments and there is nothing to realign — skip the padding rather than
4332
+ // append useless empty rows.
4333
+ const spliceLine = countNewlines(code.slice(0, i + 1));
4334
+ const inserted = 1 + countNewlines(subs);
4335
+ if (mappings.length > spliceLine + 1) {
4336
+ mappings.splice(spliceLine + 1, 0, ...Array.from({ length: inserted }, () => []));
4337
+ }
2854
4338
  code = code.slice(0, i + 1) + '\n' + subs + code.slice(i + 1);
2855
4339
  }
4340
+ // Thread the mappings back to compile() through the same side-channel the
4341
+ // component path uses (drained by drainSetupMaps at the emit site). The
4342
+ // `export ` prefix shifts only line 0's columns; `export default` appends a
4343
+ // trailing (unmapped) line and shifts nothing.
4344
+ ctx._setupMaps =
4345
+ options && options.export
4346
+ ? [
4347
+ { fnRelLine: 0, colShift: 'export '.length, mappings: mappings.slice(0, 1) },
4348
+ { fnRelLine: 1, colShift: 0, mappings: mappings.slice(1) },
4349
+ ]
4350
+ : [{ fnRelLine: 0, colShift: 0, mappings }];
2856
4351
  if (options && options.default) return `${code}\nexport default ${name};`;
2857
4352
  if (options && options.export) return `export ${code}`;
2858
4353
  return code;
@@ -3039,6 +4534,19 @@ function extractFragment(node, ctx, holeProps) {
3039
4534
  ic.condExpr = `props.${condHole}`;
3040
4535
  ic.thenHelper = `props.${thenHole}`;
3041
4536
  ic.elseHelper = elseHoleName ? `props.${elseHoleName}` : null;
4537
+ // Phase 2: the hoisted helpers' env values are COMPONENT-scope
4538
+ // identifiers — thread the whole tuple as one array hole so the
4539
+ // renderer-side call passes current values (same as deps for @for).
4540
+ if (ic.envNames && ic.envNames.length) {
4541
+ const envHole = `h${holeProps.length}`;
4542
+ holeProps.push(
4543
+ objectProp(envHole, {
4544
+ type: 'ArrayExpression',
4545
+ elements: ic.envNames.map((n) => ({ type: 'Identifier', name: n })),
4546
+ }),
4547
+ );
4548
+ ic.envExpr = `props.${envHole}`;
4549
+ }
3042
4550
  fc.directiveCalls.ifCalls.push(ic);
3043
4551
  newChildren.push({
3044
4552
  type: 'FoldedDirective',
@@ -3079,9 +4587,10 @@ function extractFragment(node, ctx, holeProps) {
3079
4587
  holeProps.push(objectProp(emptyHole, { type: 'Identifier', name: rec.emptyHelper }));
3080
4588
  rec.emptyHelper = `props.${emptyHole}`;
3081
4589
  }
3082
- if (rec.depEligible && rec.depNames.length) {
4590
+ if (rec.depNames.length) {
3083
4591
  // Thread each dep value as its own hole so the reconciler's deps-equality
3084
- // check sees the component-scope values, not undefined renderer locals.
4592
+ // check (and the Phase 2 env stamp deps doubles as the helpers' env
4593
+ // tuple) sees the component-scope values, not undefined renderer locals.
3085
4594
  rec.depNames = rec.depNames.map((dn) => {
3086
4595
  const depHole = `h${holeProps.length}`;
3087
4596
  holeProps.push(objectProp(depHole, { type: 'Identifier', name: dn }));
@@ -3129,6 +4638,17 @@ function extractFragment(node, ctx, holeProps) {
3129
4638
  holeProps.push(objectProp(defHole, { type: 'Identifier', name: rec.defaultHelper }));
3130
4639
  rec.defaultHelper = `props.${defHole}`;
3131
4640
  }
4641
+ // Phase 2: env tuple hole (see the @if fold above).
4642
+ if (rec.envNames && rec.envNames.length) {
4643
+ const envHole = `h${holeProps.length}`;
4644
+ holeProps.push(
4645
+ objectProp(envHole, {
4646
+ type: 'ArrayExpression',
4647
+ elements: rec.envNames.map((n) => ({ type: 'Identifier', name: n })),
4648
+ }),
4649
+ );
4650
+ rec.envExpr = `props.${envHole}`;
4651
+ }
3132
4652
  fc.directiveCalls.switchCalls.push(rec);
3133
4653
  newChildren.push({
3134
4654
  type: 'FoldedDirective',
@@ -3165,6 +4685,17 @@ function extractFragment(node, ctx, holeProps) {
3165
4685
  holeProps.push(objectProp(pendHole, { type: 'Identifier', name: rec.pendingHelper }));
3166
4686
  rec.pendingHelper = `props.${pendHole}`;
3167
4687
  }
4688
+ // Phase 2: env tuple hole (see the @if fold above).
4689
+ if (rec.envNames && rec.envNames.length) {
4690
+ const envHole = `h${holeProps.length}`;
4691
+ holeProps.push(
4692
+ objectProp(envHole, {
4693
+ type: 'ArrayExpression',
4694
+ elements: rec.envNames.map((n) => ({ type: 'Identifier', name: n })),
4695
+ }),
4696
+ );
4697
+ rec.envExpr = `props.${envHole}`;
4698
+ }
3168
4699
  fc.directiveCalls.tryCalls.push(rec);
3169
4700
  newChildren.push({
3170
4701
  type: 'FoldedDirective',
@@ -3331,7 +4862,18 @@ function lowerJsxChild(child, ctx) {
3331
4862
  const e = lowerJsxChild(c, ctx);
3332
4863
  if (e !== null) els.push(e);
3333
4864
  }
3334
- return { type: 'ArrayExpression', elements: els };
4865
+ // A fragment's children are FIXED siblings (React's "static children"
4866
+ // `jsxs` — which React never key-warns): tag the array so the de-opt list
4867
+ // keys it by index silently, reserving the missing-key warning for
4868
+ // runtime-built arrays (`.map()` results). Emitted in BOTH modes — the
4869
+ // server export is the identity (`ssrChild` just renders the array).
4870
+ ctx.runtimeNeeded.add('positionalChildren');
4871
+ return {
4872
+ type: 'CallExpression',
4873
+ callee: { type: 'Identifier', name: rtAlias('positionalChildren') },
4874
+ arguments: [{ type: 'ArrayExpression', elements: els }],
4875
+ optional: false,
4876
+ };
3335
4877
  }
3336
4878
  return null; // Comment / unknown — drop.
3337
4879
  }
@@ -3417,17 +4959,42 @@ function jsxElementToCreateElement(node, ctx) {
3417
4959
  };
3418
4960
  }
3419
4961
 
4962
+ // Short, unique, path-free slot description for non-HMR output: a djb2 hash of
4963
+ // the module filename + the per-module hook index. The DESCRIPTION is
4964
+ // load-bearing beyond debugging: resolveSlot/currentPathSlot (runtime) compose
4965
+ // a base hook's effective slot inside custom hooks by CONCATENATING slot
4966
+ // descriptions into a Symbol.for key — a bare `Symbol()` (description
4967
+ // undefined) collapses every composed path to "undefined|undefined" and
4968
+ // collides custom-hook state across call sites (broke the router's useStore →
4969
+ // website hydration). So every emitted slot must carry a unique description;
4970
+ // this one is ~10 chars and keeps the absolute module path out of bundles.
4971
+ export function hookSlotHash(filename) {
4972
+ const src = filename || '<anon>';
4973
+ let h = 5381;
4974
+ for (let i = 0; i < src.length; i++) h = (Math.imul(h, 33) + src.charCodeAt(i)) | 0;
4975
+ return (h >>> 0).toString(36);
4976
+ }
4977
+
3420
4978
  function allocHookSymbol(ctx, debugName) {
3421
4979
  const id = ctx.nextHookSymId++;
3422
4980
  const name = `_h$${id}`;
3423
- // Use Symbol.for(stableKey) so re-imports under HMR produce the SAME Symbol
3424
- // identity, which keeps the existing hooks Map keys valid across body
3425
- // swaps. The stable key embeds the source filename so symbols don't
3426
- // collide across modules. `debugName` includes the component name + hook
3427
- // name + call-site index stable provided the user doesn't reorder hooks
3428
- // between renders (which would violate React's rules anyway).
3429
- const stableKey = `octane:${ctx.filename || '<anon>'}:${debugName}`;
3430
- ctx.hoistedHelpers.push(`const ${name} = Symbol.for(${JSON.stringify(stableKey)});`);
4981
+ if (ctx.hmr) {
4982
+ // HMR (dev serve): Symbol.for(stableKey) so re-imports produce the SAME
4983
+ // Symbol identity, which keeps the existing hooks Map keys valid across
4984
+ // body swaps. The stable key embeds the source filename so symbols don't
4985
+ // collide across modules. `debugName` includes the component name + hook
4986
+ // name + call-site index stable provided the user doesn't reorder hooks
4987
+ // between renders (which would violate React's rules anyway).
4988
+ const stableKey = `octane:${ctx.filename || '<anon>'}:${debugName}`;
4989
+ ctx.hoistedHelpers.push(`const ${name} = Symbol.for(${JSON.stringify(stableKey)});`);
4990
+ } else {
4991
+ // No HMR (prod builds, SSR, tests): nothing re-imports the module
4992
+ // expecting registry identity, so a plain Symbol suffices — but it MUST
4993
+ // carry a unique description (see hookSlotHash above). ~10 chars vs the
4994
+ // ~100-char registry key, and no file path in shipped bundles.
4995
+ if (ctx._hookHash === undefined) ctx._hookHash = hookSlotHash(ctx.filename);
4996
+ ctx.hoistedHelpers.push(`const ${name} = Symbol(${JSON.stringify(`${ctx._hookHash}#${id}`)});`);
4997
+ }
3431
4998
  return name;
3432
4999
  }
3433
5000
 
@@ -3593,7 +5160,10 @@ function emitHeadServer(headNodes, ctx) {
3593
5160
  * makeSwitchCall consume
3594
5161
  * - Anything else → passed through
3595
5162
  */
3596
- function normalizeChildren(nodes) {
5163
+ // `inSvg`: the children being normalized sit inside an SVG-namespace subtree.
5164
+ // SVG has its own `<title>` (the accessibility tooltip element) — it must stay
5165
+ // where it is, NOT hoist to document.head (React 19 makes the same exception).
5166
+ function normalizeChildren(nodes, inSvg = false) {
3597
5167
  const out = [];
3598
5168
  if (!nodes) return out;
3599
5169
  for (const n of nodes) {
@@ -3662,10 +5232,10 @@ function normalizeChildren(nodes) {
3662
5232
  const refInner =
3663
5233
  refVal && refVal.type === 'JSXExpressionContainer' ? refVal.expression : refVal;
3664
5234
  out.push({ type: 'FragmentStart', refExpr: refInner });
3665
- out.push(...normalizeChildren(n.children || []));
5235
+ out.push(...normalizeChildren(n.children || [], inSvg));
3666
5236
  out.push({ type: 'FragmentEnd' });
3667
5237
  } else {
3668
- out.push(...normalizeChildren(n.children || []));
5238
+ out.push(...normalizeChildren(n.children || [], inSvg));
3669
5239
  }
3670
5240
  continue;
3671
5241
  }
@@ -3700,8 +5270,9 @@ function normalizeChildren(nodes) {
3700
5270
  // `<title>`/`<meta>`/`<link>` → hoist to the document-head channel (NOT
3701
5271
  // body DOM). Kept in `out` as a synthetic node so planJsx / ssrCompileBody
3702
5272
  // can partition it out and emit it via headBlock (client) / ssrHeadEl
3703
- // (server). Lifted from wherever they appear in the output.
3704
- if (isHoistableHeadElementNode(n)) {
5273
+ // (server). Lifted from wherever they appear in the output — EXCEPT an
5274
+ // SVG `<title>`, which is the SVG tooltip element and stays in place.
5275
+ if (isHoistableHeadElementNode(n) && !(inSvg && jsxTagName(n) === 'title')) {
3705
5276
  out.push({ type: 'HeadHoist', element: n });
3706
5277
  continue;
3707
5278
  }
@@ -3715,7 +5286,7 @@ function normalizeChildren(nodes) {
3715
5286
  loc: n.loc, // preserve element position for dev hydration LOC (component slots)
3716
5287
  });
3717
5288
  } else if (n.type === 'Tsx' || n.type === 'Tsrx' || n.type === 'JSXFragment') {
3718
- out.push(...normalizeChildren(n.children || []));
5289
+ out.push(...normalizeChildren(n.children || [], inSvg));
3719
5290
  } else if (n.type === 'JSXStyleElement') {
3720
5291
  // Drop a `<style>` block at child position — its CSS gets registered
3721
5292
  // via the @tsrx/core scoping pipeline (applyCssScoping / applyStyleMap);
@@ -3787,7 +5358,7 @@ function normalizeChildren(nodes) {
3787
5358
  if (body.length === 0 && render === null) continue;
3788
5359
  if (body.length === 0 && render !== null) {
3789
5360
  // Recurse — render is a single JSX node, treat as a sibling child.
3790
- out.push(...normalizeChildren([render]));
5361
+ out.push(...normalizeChildren([render], inSvg));
3791
5362
  } else {
3792
5363
  throw new Error(
3793
5364
  '`@{ … }` with setup statements is not supported at JSX child position. ' +
@@ -4010,7 +5581,21 @@ const TS_TYPE_PROPS = [
4010
5581
  function stripTsOnlyWrappers(node) {
4011
5582
  if (node === null || typeof node !== 'object') return node;
4012
5583
  if (Array.isArray(node)) {
4013
- for (let i = 0; i < node.length; i++) node[i] = stripTsOnlyWrappers(node[i]);
5584
+ for (let i = node.length - 1; i >= 0; i--) {
5585
+ // Type-only STATEMENTS nested below module scope (a `type X = …` or
5586
+ // `interface I {}` inside a function body) never hit the top-level
5587
+ // `ast.body` filter, and stripping their annotations below would
5588
+ // leave esrap an alias with a nulled typeAnnotation (crash) — drop
5589
+ // the whole statement here instead. The matched node types are
5590
+ // statement-only, so pruning from any AST array is safe (sparse
5591
+ // ArrayExpression holes are `null` and isTypeOnlyStatement keeps
5592
+ // them). Checked BEFORE the per-node strip so `declare` is intact.
5593
+ if (isTypeOnlyStatement(node[i])) {
5594
+ node.splice(i, 1);
5595
+ } else {
5596
+ node[i] = stripTsOnlyWrappers(node[i]);
5597
+ }
5598
+ }
4014
5599
  return node;
4015
5600
  }
4016
5601
  if (
@@ -4114,6 +5699,13 @@ function planJsx(jsxNodesRaw, ctx, componentName, inlinedSubs, parentNs = 'html'
4114
5699
  // through the wrapper path; HTML-contributing nodes are at `_root.childNodes[i]`.
4115
5700
  const single =
4116
5701
  jsxNodes.length === 1 && jsxNodes[0].type === 'Element' && !isComponentTag(jsxNodes[0]);
5702
+ // M3 inherit-range: consume the caller's body-form flag ONCE — nested planJsx
5703
+ // runs (directive arm bodies compiled during this walk) must not see it. The
5704
+ // sole-comp-root predicate is shared with the server compile
5705
+ // (inheritSoleCompRoot), so the client stamp and the server pair-skip agree
5706
+ // by construction; the stamped cc emits componentSlot(..., inherit=true).
5707
+ const inheritRoot = ctx._inheritBody === true && inheritSoleCompRoot(jsxNodes, ctx);
5708
+ ctx._inheritBody = false;
4117
5709
  // Top-level control-flow directives (@if/@for/@switch/@try/<Activity>). In a
4118
5710
  // body that ALSO has static template roots, each construct emits a `<!>`
4119
5711
  // anchor at its child index (mirroring the in-element mixed-children path)
@@ -4179,6 +5771,12 @@ function planJsx(jsxNodesRaw, ctx, componentName, inlinedSubs, parentNs = 'html'
4179
5771
  }
4180
5772
  }
4181
5773
  partsHtml.push(part);
5774
+ // M3 inherit-range: stamp the sole comp-call root's cc. Its own entry is
5775
+ // the LAST one its emitNodeHtml pushed (nested children/prop ccs are
5776
+ // pushed first, before makeCompCall returns to the root push).
5777
+ if (inheritRoot && compCalls.length > 0) {
5778
+ compCalls[compCalls.length - 1].inheritRange = true;
5779
+ }
4182
5780
  // Advance the child index only when the node actually contributed template
4183
5781
  // HTML (an element / text / `<!>` anchor). Component calls and un-anchored
4184
5782
  // constructs contribute none — advancing for them would shift every later
@@ -4193,15 +5791,19 @@ function planJsx(jsxNodesRaw, ctx, componentName, inlinedSubs, parentNs = 'html'
4193
5791
  const noTemplate = html === '';
4194
5792
 
4195
5793
  const mountLines = [];
4196
- // Initialize `_b` as a LOCAL only commit to `__s.slots[0]` at the
4197
- // VERY END of the mount path. If anything thrown mid-mount (e.g. a `use()`
4198
- // call suspending or a child render throwing), the scope's binding bag
4199
- // stays `undefined` and the next attempt re-enters the mount branch from
4200
- // scratch instead of mistakenly hitting the update branch with a half-
4201
- // populated bag (which would crash setText / setAttribute on undefined
4202
- // slot references).
4203
- // Control-flow-only bodies carry no bag, so skip its allocation/commit entirely.
4204
- if (!noTemplate) mountLines.push(` _b = {};`);
5794
+ // The binding bag is allocated in ONE shot at the very END of the mount path
5795
+ // by a shared runtime arity factory (`_$bagN(__s, root, v0, v1, …)` see
5796
+ // makeBag): the mount statements fill pre-declared LOCALS (`_m0, _m1, …`,
5797
+ // declared by the placeholder below once the field count is known), and the
5798
+ // factory builds the object literal from the real values (final hidden class
5799
+ // + real field representations at allocation), inserts the root, and commits
5800
+ // `__s.slots[0]` — commit LAST, so a throw mid-mount (a `use()` suspending, a
5801
+ // child render throwing) leaves the bag `undefined` and the next attempt
5802
+ // re-enters the mount branch instead of updating a half-populated bag.
5803
+ // Control-flow-only bodies carry no bag, so all of it is skipped.
5804
+ const bag = makeBag();
5805
+ const BAG_LOCALS_PLACEHOLDER = ` /*__bagLocals__*/`;
5806
+ if (!noTemplate) mountLines.push(BAG_LOCALS_PLACEHOLDER);
4205
5807
 
4206
5808
  let elementVars;
4207
5809
  let ensureVar;
@@ -4216,12 +5818,26 @@ function planJsx(jsxNodesRaw, ctx, componentName, inlinedSubs, parentNs = 'html'
4216
5818
  // the inner root.
4217
5819
  // - SVG/MathML multi-root: pass ns + frag=1; runtime wraps and returns
4218
5820
  // the wrap itself (caller drains its children — no <octane-frag>).
5821
+ // Multi-root fragments at an HTML parent imply SVG only when EVERY element
5822
+ // root does (an all-SVG fragment — e.g. portal children `<rect/><g/>` — must
5823
+ // parse in foreign content; a MIXED fragment can't share one wrapper, so it
5824
+ // keeps HTML and the SVG-only roots would mis-parse — reject? No: mixed
5825
+ // fragments at ambiguous positions are user error the browser also mangles).
5826
+ const elementRoots = single ? null : jsxNodes.filter((n) => elementTagName(n) !== null);
5827
+ const fragImpliesSvg =
5828
+ !single && elementRoots.length > 0 && elementRoots.every(isNonHtmlRootTag);
4219
5829
  const isHtmlNs =
4220
5830
  parentNs === 'html' &&
4221
5831
  (single
4222
- ? !isNonHtmlRootTag(jsxNodes[0]) // <svg>/<math> as the root means non-HTML ns
4223
- : true);
4224
- const tplNs = isHtmlNs ? 'html' : single ? nsForRootTag(jsxNodes[0], parentNs) : parentNs;
5832
+ ? !isNonHtmlRootTag(jsxNodes[0]) // svg/math/SVG-only root means non-HTML ns
5833
+ : !fragImpliesSvg);
5834
+ const tplNs = isHtmlNs
5835
+ ? 'html'
5836
+ : single
5837
+ ? nsForRootTag(jsxNodes[0], parentNs)
5838
+ : parentNs === 'html' && fragImpliesSvg
5839
+ ? 'svg'
5840
+ : parentNs;
4225
5841
  const flag = nsFlag(tplNs);
4226
5842
  const fragArg = !single && flag !== 0 ? 1 : 0;
4227
5843
  const tplHtml = single || flag !== 0 ? html : `<octane-frag>${html}</octane-frag>`;
@@ -4380,6 +5996,9 @@ function planJsx(jsxNodesRaw, ctx, componentName, inlinedSubs, parentNs = 'html'
4380
5996
  if (b.kind === 'text') ctx.runtimeNeeded.add('htextSwap');
4381
5997
  if (b.kind === 'textOnlyChild') ctx.runtimeNeeded.add('htext');
4382
5998
  if (b.kind === 'attr') ctx.runtimeNeeded.add('setAttribute');
5999
+ if (CONTROLLED_KIND_HELPERS[b.kind] !== undefined) {
6000
+ ctx.runtimeNeeded.add(CONTROLLED_KIND_HELPERS[b.kind]);
6001
+ }
4383
6002
  if (b.kind === 'class') {
4384
6003
  if (b.ns && b.ns !== 'html') {
4385
6004
  // SVG/MathML `className` is read-only — use setClassAttr, which sets the
@@ -4389,6 +6008,12 @@ function planJsx(jsxNodesRaw, ctx, componentName, inlinedSubs, parentNs = 'html'
4389
6008
  }
4390
6009
  if (b.kind === 'style') ctx.runtimeNeeded.add('setStyle');
4391
6010
  if (b.kind === 'formAction') ctx.runtimeNeeded.add('setFormAction');
6011
+ if (b.kind === 'event-bundle') {
6012
+ // 3b: mount builds the descriptor via evtN, update mutates via evtNu.
6013
+ const arity = b.argExprs.length <= 2 ? String(b.argExprs.length) : 'N';
6014
+ ctx.runtimeNeeded.add(`evt${arity}`);
6015
+ ctx.runtimeNeeded.add(`evt${arity}u`);
6016
+ }
4392
6017
  if (b.kind === 'spread') {
4393
6018
  ctx.runtimeNeeded.add('setSpread');
4394
6019
  ctx.runtimeNeeded.add('queueRefDetach'); // unmount-detach of a spread-supplied ref
@@ -4410,8 +6035,8 @@ function planJsx(jsxNodesRaw, ctx, componentName, inlinedSubs, parentNs = 'html'
4410
6035
  b.endElVar = ensureVar(b.endPath);
4411
6036
  }
4412
6037
  // `htextSwap` (sibling text hole) detaches its `<!>`; defer it past all walks (see above).
4413
- if (b.kind === 'text') deferredTextMounts.push(emitBindingMount(b, elVar));
4414
- else mountLines.push(emitBindingMount(b, elVar));
6038
+ if (b.kind === 'text') deferredTextMounts.push(emitBindingMount(b, elVar, bag));
6039
+ else mountLines.push(emitBindingMount(b, elVar, bag));
4415
6040
  }
4416
6041
  // Shared construct mount-loop (@for/@if/component/@try/@switch/portal):
4417
6042
  // resolve each record's host element, stash it on the bag under the
@@ -4425,12 +6050,12 @@ function planJsx(jsxNodesRaw, ctx, componentName, inlinedSubs, parentNs = 'html'
4425
6050
  for (const c of list) {
4426
6051
  const elVar = ensureVar(c.hostPath || []);
4427
6052
  c.elVar = elVar;
4428
- if (!noTemplate) mountLines.push(` _b._${hostKey}$${c.id} = ${elVar};`);
6053
+ if (!noTemplate) mountLines.push(` ${bag.local(`_${hostKey}$${c.id}`)} = ${elVar};`);
4429
6054
  if (!noTemplate && stampLoc) stampHostLoc(elVar, c.hostPath, c.loc);
4430
6055
  if (anchorKey !== null && c.anchorPath) {
4431
6056
  const anchorVar = ensureVar(c.anchorPath);
4432
6057
  c.anchorVar = anchorVar;
4433
- mountLines.push(` _b._${anchorKey}$${c.id} = ${anchorVar};`);
6058
+ mountLines.push(` ${bag.local(`_${anchorKey}$${c.id}`)} = ${anchorVar};`);
4434
6059
  }
4435
6060
  if (each) each(c);
4436
6061
  }
@@ -4442,11 +6067,11 @@ function planJsx(jsxNodesRaw, ctx, componentName, inlinedSubs, parentNs = 'html'
4442
6067
  each: (cc) => {
4443
6068
  // A renderable `{expr}` child in a TEMPLATE body (has a bag) uses the inline
4444
6069
  // text-hole fast path — cache its text node (`_chv`) + last value (`_chp`) on
4445
- // the bag so updates do a direct `setText` like a `.tsrx` text binding. Init
4446
- // in the mount branch so the bag shape stays monomorphic.
6070
+ // the bag so updates do a direct `setText` like a `.tsrx` text binding.
6071
+ // Const-seeded straight into the bag factory args no mount statement.
4447
6072
  if (cc.isChild && !noTemplate) {
4448
- mountLines.push(` _b._chv$${cc.id} = null;`);
4449
- mountLines.push(` _b._chp$${cc.id} = undefined;`);
6073
+ bag.constField(`_chv$${cc.id}`, 'null');
6074
+ bag.constField(`_chp$${cc.id}`, 'undefined');
4450
6075
  }
4451
6076
  },
4452
6077
  });
@@ -4462,21 +6087,66 @@ function planJsx(jsxNodesRaw, ctx, componentName, inlinedSubs, parentNs = 'html'
4462
6087
  for (const line of deferredTextMounts) mountLines.push(line);
4463
6088
 
4464
6089
  if (!noTemplate) {
4465
- if (single) {
4466
- mountLines.push(` __block.parentNode.insertBefore(_root, __block.endMarker);`);
4467
- } else {
6090
+ // Allocate + insert + commit in ONE shared-factory call, LAST — see the
6091
+ // matching comment at the bag-locals placeholder above. `_$bagN(__s, root,
6092
+ // v0, …)` builds `{a: v0, b: v1, …}` (final hidden class + real values at
6093
+ // allocation), inserts `root` before the block's end marker (skipped for
6094
+ // the multi-root null-root form — drainFrag placed the content), commits
6095
+ // `__s.slots[0]`, and returns the bag for the after-lines below.
6096
+ if (!single) {
4468
6097
  // Multi-root: drain the <octane-frag> wrapper's children into the live
4469
6098
  // parent via the runtime helper — a hydration-aware no-op when clone()
4470
6099
  // adopted the server content in place (virtual wrapper).
4471
6100
  ctx.runtimeNeeded.add('drainFrag');
4472
6101
  mountLines.push(` _$drainFrag(_root, __block.parentNode, __block.endMarker);`);
4473
6102
  }
6103
+ const rootArg = single ? '_root' : 'null';
6104
+ const args = bag.fields.map((f) => f.constExpr ?? f.local);
6105
+ if (bag.fields.length <= BAG_FACTORY_MAX) {
6106
+ ctx.runtimeNeeded.add(`bag${bag.fields.length}`);
6107
+ mountLines.push(
6108
+ ` _b = _$bag${bag.fields.length}(__s, ${rootArg}${args.length ? ', ' + args.join(', ') : ''});`,
6109
+ );
6110
+ } else {
6111
+ // Spill path — beyond the shared-factory arities: one inline literal
6112
+ // (still real values, single allocation) through the generic commit.
6113
+ ctx.runtimeNeeded.add('bagOf');
6114
+ mountLines.push(
6115
+ ` _b = _$bagOf(__s, ${rootArg}, { ${bag.fields.map((f) => `${f.name}: ${f.constExpr ?? f.local}`).join(', ')} });`,
6116
+ );
6117
+ }
6118
+ // REF MANIFEST (compiled-output plan, ref-manifest phase): bodies with
6119
+ // ref-carrying bindings stamp a module-scope constant on the scope so the
6120
+ // runtime's suspense-hide walk (detachSubtreeRefs) finds the bag fields
6121
+ // WITHOUT a key scan — flat [kind, field, elField] triads ('r' element
6122
+ // ref / 's' spread / 'f' fragment ref, whose third slot is unused).
6123
+ {
6124
+ const rm = [];
6125
+ for (const b of elementBindings) {
6126
+ if (b.kind === 'ref') {
6127
+ rm.push('r', bag.letter(`_ref$${b.id}`), bag.letter(`_el$${b.id}`));
6128
+ } else if (b.kind === 'spread') {
6129
+ rm.push('s', bag.letter(`_sp$${b.id}`), bag.letter(`_el$${b.id}`));
6130
+ } else if (b.kind === 'fragmentRef') {
6131
+ rm.push('f', bag.letter(`_fi$${b.id}`), '');
6132
+ }
6133
+ }
6134
+ if (rm.length > 0) {
6135
+ const rmName = `_rm$${ctx.nextHelperId++}`;
6136
+ ctx.hoistedHelpers.push(
6137
+ `const ${rmName} = [${rm.map((x) => JSON.stringify(x)).join(', ')}];`,
6138
+ );
6139
+ mountLines.push(` __s.refFields = ${rmName};`);
6140
+ }
6141
+ }
6142
+ // Declare the mount locals the factory args read — patched into the
6143
+ // placeholder now that the field set is complete.
6144
+ const locals = bag.fields.filter((f) => f.local !== null).map((f) => f.local);
6145
+ const init = mountLines.indexOf(BAG_LOCALS_PLACEHOLDER);
6146
+ if (init === -1) throw new Error('octane compiler: bag locals placeholder missing');
6147
+ if (locals.length > 0) mountLines[init] = ` let ${locals.join(', ')};`;
6148
+ else mountLines.splice(init, 1);
4474
6149
  }
4475
- // Commit the binding bag to the scope LAST — see the matching comment at
4476
- // the `_b = {}` initialization above. Reaching here means every binding
4477
- // and the DOM range have been successfully constructed, so future
4478
- // renders can safely take the update branch keyed on `__s.slots[0]`.
4479
- if (!noTemplate) mountLines.push(` __s.slots[0] = _b;`);
4480
6150
 
4481
6151
  // Update. Deferred property-writes run their diff EVERY render (it does the
4482
6152
  // mount-time write too); everything else diffs only on re-render (the `else`
@@ -4485,7 +6155,7 @@ function planJsx(jsxNodesRaw, ctx, componentName, inlinedSubs, parentNs = 'html'
4485
6155
  const updateLines = [];
4486
6156
  const everyRenderLines = [];
4487
6157
  for (const b of elementBindings) {
4488
- const code = emitBindingUpdate(b);
6158
+ const code = emitBindingUpdate(b, bag);
4489
6159
  if (!code) continue;
4490
6160
  if (b.deferred) everyRenderLines.push(code);
4491
6161
  else updateLines.push(code);
@@ -4543,18 +6213,29 @@ function planJsx(jsxNodesRaw, ctx, componentName, inlinedSubs, parentNs = 'html'
4543
6213
  // and content never lands after later siblings).
4544
6214
  // - The host is a real in-template element: no anchor — append into it
4545
6215
  // (insertBefore(_, null) === appendChild).
4546
- // Returns the anchor EXPRESSION, or null for the append case.
6216
+ // Returns the anchor EXPRESSION, or null for the append case. The after-lines
6217
+ // run right after the mount/update branches where `_b` holds the committed
6218
+ // bag, so bag reads go through `_b.<letter>` (shorter than `__s.slots[0].…`).
4547
6219
  const anchorExprFor = (c, anchorKey) =>
4548
6220
  c.anchorVar
4549
- ? `__s.slots[0]._${anchorKey}$${c.id}`
6221
+ ? `_b.${bag.letter(`_${anchorKey}$${c.id}`)}`
4550
6222
  : !isElHost(c.elVar)
4551
6223
  ? '__block.endMarker'
4552
6224
  : null;
6225
+ // Host expression for a construct's slot call — the bag-stashed host element,
6226
+ // or the block's own parentNode for bagless (control-flow-only) bodies.
6227
+ const hostExprFor = (key) => (noTemplate ? '__block.parentNode' : `_b.${bag.letter(key)}`);
6228
+ // Phase 2: a construct's env argument — the captured-locals tuple its
6229
+ // hoisted helpers destructure from `__extra`. Folded records carry a
6230
+ // pre-built `props.hN` expression (the fold threads the values through the
6231
+ // fragment renderer's props); inline records emit the identifier array.
6232
+ const envExprFor = (c) =>
6233
+ c.envExpr ?? (c.envNames && c.envNames.length ? `[${c.envNames.join(', ')}]` : null);
4553
6234
  for (const fc of forCalls) {
4554
6235
  ctx.runtimeNeeded.add('forBlock');
4555
6236
  const slotIndex = fc.slotIndex;
4556
6237
  // Control-flow-only bodies have no bag: the host is __block.parentNode directly.
4557
- const hostExpr = noTemplate ? '__block.parentNode' : '__s.slots[0]._for$' + fc.id;
6238
+ const hostExpr = hostExprFor(`_for$${fc.id}`);
4558
6239
  // flags: bit 0 = pure (auto-memo), bit 1 = singleRoot (skip per-item markers),
4559
6240
  // bit 2 = depEligible (runtime compares deps array, upgrades to pure
4560
6241
  // for survivors when deps unchanged this render),
@@ -4574,8 +6255,12 @@ function planJsx(jsxNodesRaw, ctx, componentName, inlinedSubs, parentNs = 'html'
4574
6255
  const anchorExpr = anchorExprFor(fc, 'forAnchor');
4575
6256
  const hasAnchor = anchorExpr !== null;
4576
6257
  const hasEmpty = fc.emptyHelper && fc.emptyHelper !== 'null';
4577
- const flagsPart = flags || hasEmpty || hasAnchor ? ', ' + (flags || 0) : '';
4578
- const depsPart = fc.depEligible
6258
+ // deps doubles as the Phase 2 env tuple emitted whenever the item/empty
6259
+ // helpers captured anything, not only for the dep-pure promotion. A deps
6260
+ // arg forces the flags placeholder too (positional alignment).
6261
+ const hasDeps = fc.depNames.length > 0;
6262
+ const flagsPart = flags || hasDeps || hasEmpty || hasAnchor ? ', ' + (flags || 0) : '';
6263
+ const depsPart = hasDeps
4579
6264
  ? `, [${fc.depNames.join(', ')}]`
4580
6265
  : hasEmpty || hasAnchor
4581
6266
  ? ', undefined'
@@ -4589,15 +6274,18 @@ function planJsx(jsxNodesRaw, ctx, componentName, inlinedSubs, parentNs = 'html'
4589
6274
  }
4590
6275
  for (const ic of ifCalls) {
4591
6276
  const slotIndex = ic.slotIndex;
4592
- const hostExpr = noTemplate ? '__block.parentNode' : '__s.slots[0]._ifHost$' + ic.id;
6277
+ const hostExpr = hostExprFor(`_ifHost$${ic.id}`);
4593
6278
  // Anchor selection — see anchorExprFor.
4594
6279
  const ifAnchor = anchorExprFor(ic, 'ifAnchor');
4595
6280
  const anchorArg = ifAnchor ? `, ${ifAnchor}` : '';
6281
+ const ifEnv = envExprFor(ic);
6282
+ // env is positional AFTER anchor — backfill an `undefined` anchor slot.
6283
+ const ifEnvArg = ifEnv ? (anchorArg ? `, ${ifEnv}` : `, undefined, ${ifEnv}`) : '';
4596
6284
  if (ic.activity) {
4597
6285
  ctx.runtimeNeeded.add('activityBlock');
4598
6286
  pushAfter(
4599
6287
  ic.id,
4600
- ` _$activityBlock(__s, ${slotIndex}, ${hostExpr}, (${ic.modeExpr}), ${ic.thenHelper}${anchorArg});`,
6288
+ ` _$activityBlock(__s, ${slotIndex}, ${hostExpr}, (${ic.modeExpr}), ${ic.thenHelper}${anchorArg}${ifEnvArg});`,
4601
6289
  );
4602
6290
  continue;
4603
6291
  }
@@ -4605,12 +6293,12 @@ function planJsx(jsxNodesRaw, ctx, componentName, inlinedSubs, parentNs = 'html'
4605
6293
  const elseArg = ic.elseHelper || 'null';
4606
6294
  pushAfter(
4607
6295
  ic.id,
4608
- ` _$ifBlock(__s, ${slotIndex}, ${hostExpr}, (${ic.condExpr}), ${ic.thenHelper}, ${elseArg}${anchorArg});`,
6296
+ ` _$ifBlock(__s, ${slotIndex}, ${hostExpr}, (${ic.condExpr}), ${ic.thenHelper}, ${elseArg}${anchorArg}${ifEnvArg});`,
4609
6297
  );
4610
6298
  }
4611
6299
  for (const cc of compCalls) {
4612
6300
  const slotIndex = cc.slotIndex;
4613
- const hostExpr = noTemplate ? '__block.parentNode' : '__s.slots[0]._compHost$' + cc.id;
6301
+ const hostExpr = hostExprFor(`_compHost$${cc.id}`);
4614
6302
  // Renderable `{expr}` hole — dispatch the value at runtime (component /
4615
6303
  // element → block; primitive → text; nullish/boolean/'' → nothing). Shares
4616
6304
  // the host/`<!>`-anchor resolution + hole-aware hydration walk with real
@@ -4623,9 +6311,11 @@ function planJsx(jsxNodesRaw, ctx, componentName, inlinedSubs, parentNs = 'html'
4623
6311
  if (cc.onlyChildText && !noTemplate) {
4624
6312
  ctx.runtimeNeeded.add('setText');
4625
6313
  ctx.runtimeNeeded.add('childTextHole');
6314
+ const chp = `_b.${bag.letter(`_chp$${cc.id}`)}`;
6315
+ const chv = `_b.${bag.letter(`_chv$${cc.id}`)}`;
4626
6316
  pushAfter(
4627
6317
  cc.id,
4628
- ` { const _v = (${cc.valueExpr}); if (_b._chp$${cc.id} !== _v) { _b._chp$${cc.id} = _v; const _t = _b._chv$${cc.id}; if (_t != null && typeof _v !== 'object' && typeof _v !== 'function') _$setText(_t, _v); else _b._chv$${cc.id} = _$childTextHole(__s, ${slotIndex}, ${hostExpr}, _v, _t); } }`,
6318
+ ` { const _v = (${cc.valueExpr}); if (${chp} !== _v) { ${chp} = _v; const _t = ${chv}; if (_t != null && typeof _v !== 'object' && typeof _v !== 'function') _$setText(_t, _v); else ${chv} = _$childTextHole(__s, ${slotIndex}, ${hostExpr}, _v, _t); } }`,
4629
6319
  );
4630
6320
  continue;
4631
6321
  }
@@ -4654,9 +6344,29 @@ function planJsx(jsxNodesRaw, ctx, componentName, inlinedSubs, parentNs = 'html'
4654
6344
  // When the slot has its OWN `<!>` placeholder, tell textHole/childSlot to
4655
6345
  // reuse it as the end marker (no second comment minted) — `ownEnd`.
4656
6346
  const ownEndArg = cc.anchorVar ? ', true' : '';
6347
+ const chp = `_b.${bag.letter(`_chp$${cc.id}`)}`;
6348
+ const chv = `_b.${bag.letter(`_chv$${cc.id}`)}`;
6349
+ pushAfter(
6350
+ cc.id,
6351
+ ` { const _v = (${cc.valueExpr}); if (${chp} !== _v) { ${chp} = _v; const _t = ${chv}; if (_t != null && typeof _v !== 'object' && typeof _v !== 'function') _$setText(_t, _v); else ${chv} = _$textHole(__s, ${slotIndex}, ${hostExpr}, _v, ${anchorExpr}${ownEndArg}); } }`,
6352
+ );
6353
+ continue;
6354
+ }
6355
+ // M3 inherit-range: the sole comp-call root of a `@{}` body — the slot
6356
+ // BORROWS the enclosing block's marker range (10th positional arg), so it
6357
+ // mints nothing and the server skips the child's frame pair at the same
6358
+ // site (ssrEmitComponent reads the same predicate). Supersedes lite (the
6359
+ // borrow needs a real Block behind the slot) and singleRoot (the borrow
6360
+ // already elides every marker, and hydration must NOT expect a server
6361
+ // pair). The anchor stays: it is the runtime's fallback insert position
6362
+ // when the borrow is declined (incoherent parent regime) and the probe
6363
+ // anchor for transition swaps.
6364
+ if (cc.inheritRange) {
6365
+ ctx.runtimeNeeded.add('componentSlot');
6366
+ const inheritAnchor = anchorExprFor(cc, 'compAnchor') ?? 'undefined';
4657
6367
  pushAfter(
4658
6368
  cc.id,
4659
- ` { const _v = (${cc.valueExpr}); if (_b._chp$${cc.id} !== _v) { _b._chp$${cc.id} = _v; const _t = _b._chv$${cc.id}; if (_t != null && typeof _v !== 'object' && typeof _v !== 'function') _$setText(_t, _v); else _b._chv$${cc.id} = _$textHole(__s, ${slotIndex}, ${hostExpr}, _v, ${anchorExpr}${ownEndArg}); } }`,
6369
+ ` _$componentSlot(__s, ${slotIndex}, ${hostExpr}, ${cc.compExpr}, ${cc.propsExpr}, ${inheritAnchor}, undefined, undefined, true);`,
4660
6370
  );
4661
6371
  continue;
4662
6372
  }
@@ -4694,11 +6404,13 @@ function planJsx(jsxNodesRaw, ctx, componentName, inlinedSubs, parentNs = 'html'
4694
6404
  keyArg = `, (${cc.keyExpr})`;
4695
6405
  }
4696
6406
  // singleRoot is the 8th positional arg (after anchor, key). It's gated on
4697
- // no-key, so backfill anchor + key placeholders to land it in the right slot.
6407
+ // no-key, so backfill anchor + key placeholders to land it in the right
6408
+ // slot. `true` = proven same-module single-element root; `2` = the
6409
+ // cross-module sentinel (runtime checks the callee's $$singleRoot stamp).
4698
6410
  let singleRootArg = '';
4699
- if (cc.singleRoot) {
6411
+ if (cc.singleRoot || cc.maybeSingleRoot) {
4700
6412
  if (anchorArg === '') anchorArg = ', undefined';
4701
- singleRootArg = ', undefined, true';
6413
+ singleRootArg = cc.singleRoot ? ', undefined, true' : ', undefined, 2';
4702
6414
  }
4703
6415
  pushAfter(
4704
6416
  cc.id,
@@ -4707,39 +6419,44 @@ function planJsx(jsxNodesRaw, ctx, componentName, inlinedSubs, parentNs = 'html'
4707
6419
  }
4708
6420
  for (const pc of ctx._portalCalls) {
4709
6421
  const slotIndex = pc.slotIndex;
4710
- const hostExpr = noTemplate ? '__block.parentNode' : '__s.slots[0]._portalHost$' + pc.id;
6422
+ const hostExpr = hostExprFor(`_portalHost$${pc.id}`);
4711
6423
  ctx.runtimeNeeded.add('portal');
6424
+ const portalEnv = envExprFor(pc);
4712
6425
  pushAfter(
4713
6426
  pc.id,
4714
- ` _$portal(__s, ${slotIndex}, ${pc.targetExpr}, ${pc.bodyExpr}, ${pc.propsExpr}, ${hostExpr});`,
6427
+ ` _$portal(__s, ${slotIndex}, ${pc.targetExpr}, ${pc.bodyExpr}, ${pc.propsExpr}, ${hostExpr}${portalEnv ? `, ${portalEnv}` : ''});`,
4715
6428
  );
4716
6429
  }
4717
6430
  // Restore the outer plan's portal-call list — pairs with the save above.
4718
6431
  ctx._portalCalls = _prevPortalCalls;
4719
6432
  for (const tc of tryCalls) {
4720
6433
  const slotIndex = tc.slotIndex;
4721
- const hostExpr = noTemplate ? '__block.parentNode' : '__s.slots[0]._tryHost$' + tc.id;
6434
+ const hostExpr = hostExprFor(`_tryHost$${tc.id}`);
4722
6435
  ctx.runtimeNeeded.add('tryBlock');
4723
6436
  // Anchor selection — see anchorExprFor (mirrors ifBlock, including the
4724
6437
  // __block.endMarker fallback for a body that is ONLY a @try).
4725
6438
  const tryAnchor = anchorExprFor(tc, 'tryAnchor');
4726
6439
  const tryAnchorArg = tryAnchor ? `, ${tryAnchor}` : '';
6440
+ const tryEnv = envExprFor(tc);
6441
+ const tryEnvArg = tryEnv ? (tryAnchorArg ? `, ${tryEnv}` : `, undefined, ${tryEnv}`) : '';
4727
6442
  pushAfter(
4728
6443
  tc.id,
4729
- ` _$tryBlock(__s, ${slotIndex}, ${hostExpr}, ${tc.tryHelper}, ${tc.catchHelper}, ${tc.pendingHelper}${tryAnchorArg});`,
6444
+ ` _$tryBlock(__s, ${slotIndex}, ${hostExpr}, ${tc.tryHelper}, ${tc.catchHelper}, ${tc.pendingHelper}${tryAnchorArg}${tryEnvArg});`,
4730
6445
  );
4731
6446
  }
4732
6447
  for (const sc of ctx._switchCalls) {
4733
6448
  const slotIndex = sc.slotIndex;
4734
- const hostExpr = noTemplate ? '__block.parentNode' : '__s.slots[0]._switchHost$' + sc.id;
6449
+ const hostExpr = hostExprFor(`_switchHost$${sc.id}`);
4735
6450
  ctx.runtimeNeeded.add('switchBlock');
4736
6451
  // Anchor selection — see anchorExprFor (mirrors ifBlock, including the
4737
6452
  // __block.endMarker fallback for a body that is ONLY a @switch).
4738
6453
  const switchAnchor = anchorExprFor(sc, 'switchAnchor');
4739
6454
  const anchorArg = switchAnchor ? `, ${switchAnchor}` : '';
6455
+ const swEnv = envExprFor(sc);
6456
+ const swEnvArg = swEnv ? (anchorArg ? `, ${swEnv}` : `, undefined, ${swEnv}`) : '';
4740
6457
  pushAfter(
4741
6458
  sc.id,
4742
- ` _$switchBlock(__s, ${slotIndex}, ${hostExpr}, (${sc.discExpr}), ${sc.casesArrayExpr}, ${sc.defaultHelper}${anchorArg});`,
6459
+ ` _$switchBlock(__s, ${slotIndex}, ${hostExpr}, (${sc.discExpr}), ${sc.casesArrayExpr}, ${sc.defaultHelper}${anchorArg}${swEnvArg});`,
4743
6460
  );
4744
6461
  }
4745
6462
  // Restore the outer plan's switch-call list — pairs with the save above.
@@ -4752,19 +6469,23 @@ function planJsx(jsxNodesRaw, ctx, componentName, inlinedSubs, parentNs = 'html'
4752
6469
  // Restore the outer plan's per-element LOC map — pairs with the save at planJsx top.
4753
6470
  ctx._elemLocs = _prevElemLocs;
4754
6471
 
6472
+ const updateJoined = updateLines.join('\n');
6473
+ const everyRenderJoined = everyRenderLines.join('\n');
6474
+ const afterJoined = afterCalls
6475
+ .map((c, i) => ({ ...c, i }))
6476
+ .sort((a, b) => a.id - b.id || a.i - b.i)
6477
+ .map((c) => c.line)
6478
+ .join('\n');
6479
+
4755
6480
  return {
4756
6481
  // Does this body carry a binding bag (`__s.slots[0]`)? Control-flow-only
4757
6482
  // bodies don't → no `let _b … if (undefined) … else {}` wrapper in the
4758
6483
  // assembled body (the slot calls use __block.parentNode directly).
4759
6484
  hasBag: !noTemplate,
4760
6485
  mount: mountLines.join('\n'),
4761
- update: updateLines.join('\n'),
4762
- everyRender: everyRenderLines.join('\n'),
4763
- after: afterCalls
4764
- .map((c, i) => ({ ...c, i }))
4765
- .sort((a, b) => a.id - b.id || a.i - b.i)
4766
- .map((c) => c.line)
4767
- .join('\n'),
6486
+ update: updateJoined,
6487
+ everyRender: everyRenderJoined,
6488
+ after: afterJoined,
4768
6489
  head: headEmit,
4769
6490
  // DEV ONLY (`''` in prod → no body emission, byte-identical output): a structured
4770
6491
  // `{ slotIndex: [line, column] }` literal for hydration-mismatch warnings + a future
@@ -4791,20 +6512,81 @@ function buildLocsLiteral(constructs) {
4791
6512
  // event-bundle "stable bundle" hoisting is a separate, guarded optimization).
4792
6513
  const DEFERRABLE_MOUNT_KINDS = new Set(['attr', 'class', 'style', 'formAction', 'htmlOnlyChild']);
4793
6514
 
6515
+ // Per-body binding-bag registry. Fields are keyed by the historical long name
6516
+ // (`_el$3`, `_prev$3`, …) but EMIT as single characters (`a`, `b`, …) — bag
6517
+ // field names are object properties, so unlike locals a minifier can never
6518
+ // shorten them; 1-char names are the shipped-bytes win. Each field is either
6519
+ // LOCAL-backed (the mount path assigns a pre-declared `_mN` local; the runtime
6520
+ // bag factory receives it positionally) or CONST-seeded (`null`/`undefined`
6521
+ // seeds pass straight to the factory — no local, no mount statement).
6522
+ // Registration order = mount-write order = the factory's positional args =
6523
+ // the letter sequence, so `_$bagN(s, root, v0, v1, …)` builds `{a: v0, b: v1,
6524
+ // …}` with every field carrying its REAL mount value. `letter()` throws for a
6525
+ // field that was never mount-registered — an update/slot-call line referencing
6526
+ // an unmounted field is a compiler bug, surfaced at compile time.
6527
+ const BAG_LC = 'abcdefghijklmnopqrstuvwxyz';
6528
+ const BAG_UC = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
6529
+ // Highest shared-factory arity (bag0..bag16 in the runtime); bigger bags fall
6530
+ // back to `bagOf(__s, root, { … })` with an inline literal of real values.
6531
+ const BAG_FACTORY_MAX = 16;
6532
+ function bagLetter(i) {
6533
+ if (i < 26) return BAG_LC[i];
6534
+ if (i < 52) return BAG_UC[i - 26];
6535
+ return `f${i}`;
6536
+ }
6537
+ function makeBag() {
6538
+ const fields = [];
6539
+ const byKey = new Map();
6540
+ const reg = (key, constExpr) => {
6541
+ let r = byKey.get(key);
6542
+ if (r === undefined) {
6543
+ const i = fields.length;
6544
+ r = {
6545
+ key,
6546
+ // Every field gets a letter — incl. ref-carrying kinds since the
6547
+ // ref-manifest phase: the runtime's suspense-hide walk discovers
6548
+ // them through the compiled `__s.refFields` manifest, not by key
6549
+ // prefix, so nothing needs a long name anymore.
6550
+ name: bagLetter(i),
6551
+ local: constExpr === undefined ? `_m${i}` : null,
6552
+ constExpr: constExpr === undefined ? null : constExpr,
6553
+ };
6554
+ fields.push(r);
6555
+ byKey.set(key, r);
6556
+ }
6557
+ return r;
6558
+ };
6559
+ return {
6560
+ /** Mount-write target for `key` — the pre-declared local. */
6561
+ local: (key) => reg(key, undefined).local,
6562
+ /** Seed `key` with a constant expression (no local, no mount write). */
6563
+ constField: (key, expr) => {
6564
+ reg(key, expr);
6565
+ },
6566
+ /** Emitted bag property name for `key` (update/slot-call reads+writes). */
6567
+ letter: (key) => {
6568
+ const r = byKey.get(key);
6569
+ if (r === undefined) throw new Error(`octane compiler: bag field ${key} never mounted`);
6570
+ return r.name;
6571
+ },
6572
+ fields,
6573
+ };
6574
+ }
6575
+
4794
6576
  // Mount for a DEFERRED property-write binding: store the element ref + seed the
4795
6577
  // diff field to `undefined`. The every-render diff then performs the actual
4796
6578
  // write — including on the first render, since the `undefined` seed makes its
4797
6579
  // `_prev !== _v` guard fire, and `setAttribute(el, name, undefined)` /
4798
6580
  // `setClassName(el, undefined)` no-op on a freshly-cloned element (so the output
4799
6581
  // is byte-identical to the old unconditional mount write).
4800
- function emitDeferredMount(b, elVar) {
6582
+ function emitDeferredMount(b, elVar, bag) {
4801
6583
  // `style` diffs on `_sty`; attr / class / formAction / htmlOnlyChild on `_prev`.
4802
- const field = b.kind === 'style' ? `_sty$${b.id}` : `_prev$${b.id}`;
4803
- return ` { _b._el$${b.id} = ${elVar}; _b.${field} = undefined; }`;
6584
+ bag.constField(b.kind === 'style' ? `_sty$${b.id}` : `_prev$${b.id}`, 'undefined');
6585
+ return ` ${bag.local(`_el$${b.id}`)} = ${elVar};`;
4804
6586
  }
4805
6587
 
4806
- function emitBindingMount(b, elVar) {
4807
- if (b.deferred) return emitDeferredMount(b, elVar);
6588
+ function emitBindingMount(b, elVar, bag) {
6589
+ if (b.deferred) return emitDeferredMount(b, elVar, bag);
4808
6590
  // `suppressHydrationWarning`: stamp a JS flag (NOT a DOM attribute) the runtime reads to
4809
6591
  // keep the server value + skip the warning on a hydration mismatch for this element.
4810
6592
  if (b.kind === 'suppress') return ` ${elVar}.__oct_suppress = true;`;
@@ -4818,8 +6600,8 @@ function emitBindingMount(b, elVar) {
4818
6600
  // hydration mismatch re-render).
4819
6601
  return ` {
4820
6602
  const _v = ${E};
4821
- _b._txt$${b.id} = _$htext(${elVar}, _v);
4822
- _b._prev$${b.id} = _v;
6603
+ ${bag.local(`_txt$${b.id}`)} = _$htext(${elVar}, _v);
6604
+ ${bag.local(`_prev$${b.id}`)} = _v;
4823
6605
  }`;
4824
6606
  }
4825
6607
  case 'htmlOnlyChild': {
@@ -4827,8 +6609,8 @@ function emitBindingMount(b, elVar) {
4827
6609
  return ` {
4828
6610
  const _v = ${E};
4829
6611
  ${elVar}.innerHTML = (_v == null ? '' : ${coerce});
4830
- _b._el$${b.id} = ${elVar};
4831
- _b._prev$${b.id} = _v;
6612
+ ${bag.local(`_el$${b.id}`)} = ${elVar};
6613
+ ${bag.local(`_prev$${b.id}`)} = _v;
4832
6614
  }`;
4833
6615
  }
4834
6616
  case 'text': {
@@ -4841,18 +6623,38 @@ function emitBindingMount(b, elVar) {
4841
6623
  // htextSwap coerces the value itself, so the mount is a bare call.
4842
6624
  return ` {
4843
6625
  const _v = ${E};
4844
- _b._txt$${b.id} = _$htextSwap(${elVar}, _v);
4845
- _b._prev$${b.id} = _v;
6626
+ ${bag.local(`_txt$${b.id}`)} = _$htextSwap(${elVar}, _v);
6627
+ ${bag.local(`_prev$${b.id}`)} = _v;
4846
6628
  }`;
4847
6629
  }
4848
6630
  case 'attr': {
4849
6631
  return ` {
4850
6632
  const _v = ${E};
4851
6633
  _$setAttribute(${elVar}, ${JSON.stringify(b.name)}, _v);
4852
- _b._el$${b.id} = ${elVar};
4853
- _b._prev$${b.id} = _v;
6634
+ ${bag.local(`_el$${b.id}`)} = ${elVar};
6635
+ ${bag.local(`_prev$${b.id}`)} = _v;
6636
+ }`;
6637
+ }
6638
+ case 'value':
6639
+ case 'checked':
6640
+ case 'selectValue':
6641
+ case 'defaultValue':
6642
+ case 'defaultChecked': {
6643
+ // Controlled form props: property helper, NO `_prev$` cache — the
6644
+ // helper diffs against the DOM (which the user mutates), and the
6645
+ // update must re-run every render to reassert drift (React's
6646
+ // controlled contract). Not in DEFERRABLE_MOUNT_KINDS: the mount
6647
+ // runs inside the hydration window and arms the element.
6648
+ return ` {
6649
+ _$${CONTROLLED_KIND_HELPERS[b.kind]}(${elVar}, ${E});
6650
+ ${bag.local(`_el$${b.id}`)} = ${elVar};
4854
6651
  }`;
4855
6652
  }
6653
+ case 'autoFocus': {
6654
+ // Mount-only (React ignores later autoFocus changes); the focus
6655
+ // itself fires at commit, after the tree is connected.
6656
+ return ` _$setAutoFocus(${elVar}, ${E});`;
6657
+ }
4856
6658
  case 'class': {
4857
6659
  // On SVG/MathML hosts the `className` property is read-only — fall back
4858
6660
  // to setAttribute. Compile-time choice, zero runtime branching.
@@ -4861,16 +6663,16 @@ function emitBindingMount(b, elVar) {
4861
6663
  return ` {
4862
6664
  const _v = ${E};
4863
6665
  ${setter};
4864
- _b._el$${b.id} = ${elVar};
4865
- _b._prev$${b.id} = _v;
6666
+ ${bag.local(`_el$${b.id}`)} = ${elVar};
6667
+ ${bag.local(`_prev$${b.id}`)} = _v;
4866
6668
  }`;
4867
6669
  }
4868
6670
  case 'style': {
4869
6671
  return ` {
4870
6672
  const _v = ${E};
4871
6673
  _$setStyle(${elVar}, _v, undefined);
4872
- _b._el$${b.id} = ${elVar};
4873
- _b._sty$${b.id} = _v;
6674
+ ${bag.local(`_el$${b.id}`)} = ${elVar};
6675
+ ${bag.local(`_sty$${b.id}`)} = _v;
4874
6676
  }`;
4875
6677
  }
4876
6678
  case 'spread': {
@@ -4881,16 +6683,19 @@ function emitBindingMount(b, elVar) {
4881
6683
  // (queueRefDetach: unmount cleanups run mid-render, and a state-setter
4882
6684
  // ref firing null synchronously can render before a replacement
4883
6685
  // element's deferred attach — commit-phase detach batches the two).
6686
+ // The cleanup closure reads the bag through the captured `_b` — the bag
6687
+ // exists by the time any cleanup runs (committed at mount end), and the
6688
+ // `_sp$` field is re-written by updates, so the read must be live.
4884
6689
  return ` {
4885
6690
  const _v = ${E};
4886
6691
  _$setSpread(${elVar}, _v, undefined, __s);
4887
- _b._el$${b.id} = ${elVar};
4888
- _b._sp$${b.id} = _v;
4889
- __s.cleanups.push(() => { const _sp = _b._sp$${b.id}; if (_sp != null && _sp.ref != null) _$queueRefDetach(_sp.ref, _b._el$${b.id}); });
6692
+ ${bag.local(`_el$${b.id}`)} = ${elVar};
6693
+ ${bag.local(`_sp$${b.id}`)} = _v;
6694
+ __s.cleanups.push(() => { const _sp = _b.${bag.letter(`_sp$${b.id}`)}; if (_sp != null && _sp.ref != null) _$queueRefDetach(_sp.ref, _b.${bag.letter(`_el$${b.id}`)}); });
4890
6695
  }`;
4891
6696
  }
4892
6697
  case 'event': {
4893
- return ` _b._el$${b.id} = ${elVar};
6698
+ return ` ${bag.local(`_el$${b.id}`)} = ${elVar};
4894
6699
  ${elVar}[${JSON.stringify(b.slotKey)}] = (${b.expr});`;
4895
6700
  }
4896
6701
  case 'formAction': {
@@ -4900,21 +6705,22 @@ function emitBindingMount(b, elVar) {
4900
6705
  return ` {
4901
6706
  const _v = ${E};
4902
6707
  _$setFormAction(${elVar}, ${JSON.stringify(b.name)}, _v, undefined);
4903
- _b._el$${b.id} = ${elVar};
4904
- _b._prev$${b.id} = _v;
6708
+ ${bag.local(`_el$${b.id}`)} = ${elVar};
6709
+ ${bag.local(`_prev$${b.id}`)} = _v;
4905
6710
  }`;
4906
6711
  }
4907
6712
  case 'event-bundle': {
4908
- // Build a `{ fn, args }` bundle and stash fn + each arg in slots so the
4909
- // update path can identity-diff and skip the reassignment on no-op.
4910
- const argSlots = b.argExprs.map((_e, i) => `_b._a$${b.id}$${i}`);
4911
- const argInit = b.argExprs.map((e, i) => `_b._a$${b.id}$${i} = (${e});`).join(' ');
4912
- return ` {
4913
- _b._el$${b.id} = ${elVar};
4914
- _b._fn$${b.id} = (${b.fnExpr});
4915
- ${argInit}
4916
- ${elVar}[${JSON.stringify(b.slotKey)}] = { fn: _b._fn$${b.id}, args: [${argSlots.join(', ')}] };
4917
- }`;
6713
+ // 3b (docs/compiled-output-optimization-plan.md): ONE shared-helper call
6714
+ // builds the `{ fn, args }` descriptor, assigns the element's event slot,
6715
+ // and returns the descriptor the ONLY bag field this binding needs (the
6716
+ // update path mutates the descriptor in place; dispatch reads `el[key]`
6717
+ // per event, so the mutation is observed without re-assignment).
6718
+ const n = b.argExprs.length;
6719
+ const argsPart = b.argExprs.map((e) => `, (${e})`).join('');
6720
+ if (n <= 2) {
6721
+ return ` ${bag.local(`_ev$${b.id}`)} = _$evt${n}(${elVar}, ${JSON.stringify(b.slotKey)}, (${b.fnExpr})${argsPart});`;
6722
+ }
6723
+ return ` ${bag.local(`_ev$${b.id}`)} = _$evtN(${elVar}, ${JSON.stringify(b.slotKey)}, (${b.fnExpr}), [${b.argExprs.map((e) => `(${e})`).join(', ')}]);`;
4918
6724
  }
4919
6725
  case 'ref': {
4920
6726
  // attachRef handles all three supported shapes: callback (function),
@@ -4931,12 +6737,14 @@ function emitBindingMount(b, elVar) {
4931
6737
  // bound element rides along as the cleanup target, so a callback ref
4932
6738
  // shared across elements (ref={registerItem} on every @for row)
4933
6739
  // releases ITS row's React-19 cleanup, not another row's.
6740
+ // Both deferred closures read through the captured `_b` (committed by the
6741
+ // time attach/cleanup run); `_ref$` must be a LIVE read — updates re-point it.
4934
6742
  return ` {
4935
6743
  const _r = (${b.expr});
4936
- _b._ref$${b.id} = _r;
4937
- _b._el$${b.id} = ${elVar};
4938
- _$queueRefAttach(__s, () => _$attachRef(_r, _b._el$${b.id}));
4939
- __s.cleanups.push(() => _$queueRefDetach(_b._ref$${b.id}, _b._el$${b.id}));
6744
+ ${bag.local(`_ref$${b.id}`)} = _r;
6745
+ ${bag.local(`_el$${b.id}`)} = ${elVar};
6746
+ _$queueRefAttach(__s, () => _$attachRef(_r, _b.${bag.letter(`_el$${b.id}`)}));
6747
+ __s.cleanups.push(() => _$queueRefDetach(_b.${bag.letter(`_ref$${b.id}`)}, _b.${bag.letter(`_el$${b.id}`)}));
4940
6748
  }`;
4941
6749
  }
4942
6750
  case 'fragmentRef': {
@@ -4948,39 +6756,53 @@ function emitBindingMount(b, elVar) {
4948
6756
  // the ref + destroys the instance on unmount.
4949
6757
  return ` {
4950
6758
  const _r = (${b.expr});
4951
- _b._fi$${b.id} = _$mountFragmentRef(__s, ${elVar}, ${b.endElVar}, _r);
6759
+ ${bag.local(`_fi$${b.id}`)} = _$mountFragmentRef(__s, ${elVar}, ${b.endElVar}, _r);
4952
6760
  }`;
4953
6761
  }
4954
6762
  }
4955
6763
  return '';
4956
6764
  }
4957
6765
 
4958
- function emitBindingUpdate(b) {
6766
+ function emitBindingUpdate(b, bag) {
4959
6767
  const E = `(${b.expr})`;
6768
+ // 1-char bag field names (see makeBag) — resolved from the same registry the
6769
+ // mount pass registered them in; an unmounted field throws at compile time.
6770
+ const F = (prefix) => `_b.${bag.letter(`${prefix}$${b.id}`)}`;
4960
6771
  switch (b.kind) {
4961
6772
  case 'textOnlyChild':
4962
6773
  case 'text': {
4963
- return ` { const _v = ${E}; if (_b._prev$${b.id} !== _v) { _$setText(_b._txt$${b.id}, _v); _b._prev$${b.id} = _v; } }`;
6774
+ return ` { const _v = ${E}; if (${F('_prev')} !== _v) { _$setText(${F('_txt')}, _v); ${F('_prev')} = _v; } }`;
4964
6775
  }
4965
6776
  case 'htmlOnlyChild': {
4966
6777
  const coerce = b.knownString ? '_v' : 'String(_v)';
4967
- return ` { const _v = ${E}; if (_b._prev$${b.id} !== _v) { _b._el$${b.id}.innerHTML = (_v == null ? '' : ${coerce}); _b._prev$${b.id} = _v; } }`;
6778
+ return ` { const _v = ${E}; if (${F('_prev')} !== _v) { ${F('_el')}.innerHTML = (_v == null ? '' : ${coerce}); ${F('_prev')} = _v; } }`;
4968
6779
  }
4969
6780
  case 'attr': {
4970
- return ` { const _v = ${E}; if (_b._prev$${b.id} !== _v) { _$setAttribute(_b._el$${b.id}, ${JSON.stringify(b.name)}, _v); _b._prev$${b.id} = _v; } }`;
6781
+ return ` { const _v = ${E}; if (${F('_prev')} !== _v) { _$setAttribute(${F('_el')}, ${JSON.stringify(b.name)}, _v); ${F('_prev')} = _v; } }`;
6782
+ }
6783
+ case 'value':
6784
+ case 'checked':
6785
+ case 'selectValue':
6786
+ case 'defaultValue':
6787
+ case 'defaultChecked': {
6788
+ // Deliberately UNGUARDED (no `_prev$` compare): a controlled prop
6789
+ // reasserts on every commit — the helper's DOM-diff makes an
6790
+ // unchanged value free, and a prev-guard would skip exactly the
6791
+ // "unrelated re-render while the DOM drifted" reassert case.
6792
+ return ` _$${CONTROLLED_KIND_HELPERS[b.kind]}(${F('_el')}, ${E});`;
4971
6793
  }
4972
6794
  case 'class': {
4973
6795
  const setter =
4974
6796
  b.ns && b.ns !== 'html'
4975
- ? `_$setClassAttr(_b._el$${b.id}, _v)`
4976
- : `_$setClassName(_b._el$${b.id}, _v)`;
4977
- return ` { const _v = ${E}; if (_b._prev$${b.id} !== _v) { ${setter}; _b._prev$${b.id} = _v; } }`;
6797
+ ? `_$setClassAttr(${F('_el')}, _v)`
6798
+ : `_$setClassName(${F('_el')}, _v)`;
6799
+ return ` { const _v = ${E}; if (${F('_prev')} !== _v) { ${setter}; ${F('_prev')} = _v; } }`;
4978
6800
  }
4979
6801
  case 'style': {
4980
6802
  // Object styles need per-prop diffing — call setStyle even when the
4981
6803
  // reference is unchanged it'd just no-op via the internal diff. We DO
4982
6804
  // skip identity matches to avoid the call overhead.
4983
- return ` { const _v = ${E}; if (_b._sty$${b.id} !== _v) { _$setStyle(_b._el$${b.id}, _v, _b._sty$${b.id}); _b._sty$${b.id} = _v; } }`;
6805
+ return ` { const _v = ${E}; if (${F('_sty')} !== _v) { _$setStyle(${F('_el')}, _v, ${F('_sty')}); ${F('_sty')} = _v; } }`;
4984
6806
  }
4985
6807
  case 'spread': {
4986
6808
  // setSpread does its own per-key diffing internally and handles cleanup
@@ -4989,33 +6811,25 @@ function emitBindingUpdate(b) {
4989
6811
  // `__s` rides along on updates too so a spread-supplied ref's attach is
4990
6812
  // deferred to commit (after all queued detaches) — same phasing as the
4991
6813
  // direct `ref` binding above.
4992
- return ` { const _v = ${E}; if (_b._sp$${b.id} !== _v) { _$setSpread(_b._el$${b.id}, _v, _b._sp$${b.id}, __s); _b._sp$${b.id} = _v; } }`;
6814
+ return ` { const _v = ${E}; if (${F('_sp')} !== _v) { _$setSpread(${F('_el')}, _v, ${F('_sp')}, __s); ${F('_sp')} = _v; } }`;
4993
6815
  }
4994
6816
  case 'event': {
4995
- return ` _b._el$${b.id}[${JSON.stringify(b.slotKey)}] = (${b.expr});`;
6817
+ return ` ${F('_el')}[${JSON.stringify(b.slotKey)}] = (${b.expr});`;
4996
6818
  }
4997
6819
  case 'formAction': {
4998
- return ` { const _v = ${E}; if (_b._prev$${b.id} !== _v) { _$setFormAction(_b._el$${b.id}, ${JSON.stringify(b.name)}, _v, _b._prev$${b.id}); _b._prev$${b.id} = _v; } }`;
6820
+ return ` { const _v = ${E}; if (${F('_prev')} !== _v) { _$setFormAction(${F('_el')}, ${JSON.stringify(b.name)}, _v, ${F('_prev')}); ${F('_prev')} = _v; } }`;
4999
6821
  }
5000
6822
  case 'event-bundle': {
5001
- // Diff fn + each arg against the per-slot cache. Only rebuild + assign
5002
- // the bundle when something actually changed keyed-list survivors with
5003
- // unchanged item refs skip everything.
5004
- const fnVar = `_fn`,
5005
- argVars = b.argExprs.map((_e, i) => `_a${i}`);
5006
- const reads =
5007
- `const ${fnVar} = (${b.fnExpr}); ` +
5008
- b.argExprs.map((e, i) => `const ${argVars[i]} = (${e});`).join(' ');
5009
- const cmps = [`_b._fn$${b.id} !== ${fnVar}`]
5010
- .concat(b.argExprs.map((_e, i) => `_b._a$${b.id}$${i} !== ${argVars[i]}`))
5011
- .join(' || ');
5012
- const writes = [`_b._fn$${b.id} = ${fnVar};`]
5013
- .concat(b.argExprs.map((_e, i) => `_b._a$${b.id}$${i} = ${argVars[i]};`))
5014
- .concat([
5015
- `_b._el$${b.id}[${JSON.stringify(b.slotKey)}] = { fn: ${fnVar}, args: [${argVars.join(', ')}] };`,
5016
- ])
5017
- .join(' ');
5018
- return ` { ${reads} if (${cmps}) { ${writes} } }`;
6823
+ // 3b: mutate the mount-built descriptor in place branch-free (two
6824
+ // plain field writes cost less than the old compare + rebuild +
6825
+ // re-assign, and keyed-list survivors were already skipped one level
6826
+ // up by the pure/deps short-circuit).
6827
+ const n = b.argExprs.length;
6828
+ const argsPart = b.argExprs.map((e) => `, (${e})`).join('');
6829
+ if (n <= 2) {
6830
+ return ` _$evt${n}u(${F('_ev')}, (${b.fnExpr})${argsPart});`;
6831
+ }
6832
+ return ` _$evtNu(${F('_ev')}, (${b.fnExpr}), [${b.argExprs.map((e) => `(${e})`).join(', ')}]);`;
5019
6833
  }
5020
6834
  case 'ref': {
5021
6835
  // Ref expression identity may change across renders. React 19: detach
@@ -5033,11 +6847,11 @@ function emitBindingUpdate(b) {
5033
6847
  // callback ref.
5034
6848
  return ` {
5035
6849
  const _r = (${b.expr});
5036
- if (_r !== _b._ref$${b.id}) {
5037
- const _old = _b._ref$${b.id};
5038
- if (_old != null) _$queueRefDetach(_old, _b._el$${b.id});
5039
- if (_r != null) _$queueRefAttach(__s, () => _$attachRef(_r, _b._el$${b.id}));
5040
- _b._ref$${b.id} = _r;
6850
+ if (_r !== ${F('_ref')}) {
6851
+ const _old = ${F('_ref')};
6852
+ if (_old != null) _$queueRefDetach(_old, ${F('_el')});
6853
+ if (_r != null) _$queueRefAttach(__s, () => _$attachRef(_r, ${F('_el')}));
6854
+ ${F('_ref')} = _r;
5041
6855
  }
5042
6856
  }`;
5043
6857
  }
@@ -5050,7 +6864,7 @@ function emitBindingUpdate(b) {
5050
6864
  // detaches the new ref.
5051
6865
  return ` {
5052
6866
  const _r = (${b.expr});
5053
- const _fi = _b._fi$${b.id};
6867
+ const _fi = ${F('_fi')};
5054
6868
  if (_fi && _r !== _fi._currentRef) {
5055
6869
  if (_fi._currentRef != null) _$queueRefDetach(_fi._currentRef, _fi);
5056
6870
  if (_r != null) _$queueRefAttach(__s, () => _$attachRef(_r, _fi));
@@ -5242,6 +7056,7 @@ function emitElementHtml(
5242
7056
  const tag = node.id?.name || node.openingElement?.name?.name;
5243
7057
  if (!tag) throw new Error('Element without tag');
5244
7058
  rejectVoidElementContent(tag, node, ctx);
7059
+ rejectTextareaValueChildren(tag, node, ctx);
5245
7060
 
5246
7061
  // The host element's own namespace (e.g. `<svg>` is in SVG ns even if its
5247
7062
  // parent context is HTML); its descendants' inherited ns may differ
@@ -5295,7 +7110,7 @@ function emitElementHtml(
5295
7110
  if (!isFalse) bindings.push({ id: bindings.length, kind: 'suppress', path });
5296
7111
  continue;
5297
7112
  }
5298
- const attrName = normalizeJsxAttrName(rawAttrName);
7113
+ const attrName = normalizeJsxAttrName(rawAttrName, tag);
5299
7114
 
5300
7115
  const val = attr.value;
5301
7116
  // If this attr comes AFTER a spread, we MUST emit as a binding (later wins).
@@ -5357,6 +7172,42 @@ function emitElementHtml(
5357
7172
  continue;
5358
7173
  }
5359
7174
 
7175
+ // Controlled form props ALWAYS compile to property bindings — static
7176
+ // literals and bare booleans (`<input checked/>`) included; nothing
7177
+ // bakes into the template HTML (see controlledKindFor).
7178
+ const ctlKind = controlledKindFor(tag, attrName);
7179
+ if (ctlKind !== null) {
7180
+ let ctlExpr;
7181
+ if (val == null) {
7182
+ ctlExpr = 'true';
7183
+ } else {
7184
+ const ctlInner = val.type === 'JSXExpressionContainer' ? val.expression : val;
7185
+ ctlExpr = printExprWithTsrx(ctlInner, ctx, componentName, inlinedSubs);
7186
+ }
7187
+ bindings.push({ id: bindings.length, kind: ctlKind, expr: ctlExpr, path, ns: hostNs });
7188
+ continue;
7189
+ }
7190
+ // `autoFocus` never bakes/writes an attribute — React parity: the
7191
+ // runtime focuses the element in its mount commit (setAutoFocus).
7192
+ if (attrName === 'autoFocus' && !tag.includes('-')) {
7193
+ bindings.push({
7194
+ id: bindings.length,
7195
+ kind: 'autoFocus',
7196
+ expr:
7197
+ val == null
7198
+ ? 'true'
7199
+ : printExprWithTsrx(
7200
+ val.type === 'JSXExpressionContainer' ? val.expression : val,
7201
+ ctx,
7202
+ componentName,
7203
+ inlinedSubs,
7204
+ ),
7205
+ path,
7206
+ ns: hostNs,
7207
+ });
7208
+ continue;
7209
+ }
7210
+
5360
7211
  if (val == null) {
5361
7212
  if (isAfterSpread) {
5362
7213
  // Boolean attr after spread → emit as `true` binding.
@@ -5398,19 +7249,11 @@ function emitElementHtml(
5398
7249
 
5399
7250
  // Static literal value? Inline into HTML — UNLESS we're after a spread,
5400
7251
  // in which case we MUST emit as a binding so source order is preserved.
7252
+ // bakeStaticAttr applies the shared React-parity value tables (aria-*/
7253
+ // enumerated/data-* booleans stringify, boolean attrs canonicalize to
7254
+ // `attr=""`/absent, booleans on non-boolean attrs drop).
5401
7255
  if (inner.type === 'Literal' && !isAfterSpread) {
5402
- if (typeof inner.value === 'string') {
5403
- attrHtml += ` ${attrName}="${escapeAttr(inner.value)}"`;
5404
- } else if (typeof inner.value === 'number') {
5405
- attrHtml += ` ${attrName}="${inner.value}"`;
5406
- } else if (typeof inner.value === 'boolean' && attrName.startsWith('aria-')) {
5407
- // `aria-*` is ENUMERATED (React parity, mirroring the runtime
5408
- // setAttribute / ssrAttr): a boolean literal bakes as the string
5409
- // "true"/"false" — `aria-hidden={false}` must render, not drop.
5410
- attrHtml += ` ${attrName}="${inner.value}"`;
5411
- } else if (inner.value === true) {
5412
- attrHtml += ` ${attrName}`;
5413
- }
7256
+ attrHtml += bakeStaticAttr(attrName, inner.value, tag);
5414
7257
  continue;
5415
7258
  }
5416
7259
 
@@ -5500,7 +7343,7 @@ function emitElementHtml(
5500
7343
 
5501
7344
  let html = `<${tag}${attrHtml}>`;
5502
7345
 
5503
- const children = normalizeChildren(node.children || []);
7346
+ const children = normalizeChildren(node.children || [], childNs === 'svg');
5504
7347
  // Special case: a single Text child (only-child text fast path).
5505
7348
  if (children.length === 1 && children[0].type === 'Text') {
5506
7349
  const txtChild = children[0];
@@ -5865,9 +7708,21 @@ function makePortalCall(callNode, ctx, componentName, inlinedSubs, parentNs, css
5865
7708
  // block, so it must be hoisted here — otherwise the raw JSX would be
5866
7709
  // printed verbatim into the emitted portal() call (invalid output).
5867
7710
  let bodyExpr;
7711
+ let envNames = null;
5868
7712
  const bt = bodyArg ? bodyArg.type : null;
5869
7713
  if (bt === 'Element' || bt === 'Fragment' || bt === 'JSXElement' || bt === 'JSXFragment') {
5870
- bodyExpr = hoistBodyHelper(ctx, inlinedSubs, '__portal', [bodyArg], [], parentNs, cssHash);
7714
+ // Phase 2: hoisted portal body + env tuple (see hoistBodyHelper).
7715
+ envNames = unionEnv(ctx, [{ stmts: [bodyArg], params: [] }]);
7716
+ bodyExpr = hoistBodyHelper(
7717
+ ctx,
7718
+ inlinedSubs,
7719
+ '__portal',
7720
+ [bodyArg],
7721
+ [],
7722
+ parentNs,
7723
+ cssHash,
7724
+ envNames,
7725
+ );
5871
7726
  } else {
5872
7727
  bodyExpr = printExprWithTsrx(bodyArg, ctx, componentName, inlinedSubs);
5873
7728
  }
@@ -5876,28 +7731,118 @@ function makePortalCall(callNode, ctx, componentName, inlinedSubs, parentNs, css
5876
7731
  return {
5877
7732
  id: ctx.nextHelperId++,
5878
7733
  loc: devLoc(ctx, callNode),
7734
+ envNames,
5879
7735
  bodyExpr,
5880
7736
  targetExpr,
5881
7737
  propsExpr,
5882
7738
  };
5883
7739
  }
5884
7740
 
7741
+ // Phase 2 (docs/compiled-output-optimization-plan.md): captured parent locals
7742
+ // for a construct body helper about to be HOISTED to module scope — the free
7743
+ // identifiers of the body (its own params/locals excluded by the scope-aware
7744
+ // walker) intersected with the locals visible at the call site: the enclosing
7745
+ // component's locals, extended per enclosing hoisted helper (see
7746
+ // hoistBodyHelper). `__pu$N` parallel-use temps are compiler-generated
7747
+ // component-body locals minted AFTER collectComponentLocals ran, so they are
7748
+ // matched by name shape. Returns null when there is no component context —
7749
+ // the caller then keeps the legacy inline (closure) placement.
7750
+ function helperCaptures(ctx, stmts, params) {
7751
+ if (!ctx.currentComponentLocals) return null;
7752
+ const scope = new Set();
7753
+ for (const p of params || []) collectBindings(p, scope);
7754
+ const free = collectFreeIdentifiers({ type: 'BlockStatement', body: stmts }, scope);
7755
+ const env = [];
7756
+ for (const n of free) {
7757
+ if (ctx.currentComponentLocals.has(n) || /^__pu\$\d+$/.test(n)) env.push(n);
7758
+ }
7759
+ env.sort();
7760
+ return env;
7761
+ }
7762
+
7763
+ // A construct's helpers (then+else, all switch cases, try+pending+catch,
7764
+ // item+empty) share ONE env tuple — `block.extra` is per construct block and
7765
+ // every helper destructures the same layout — so the emitted array is the
7766
+ // sorted UNION of each body's captures. Null propagates (no component
7767
+ // context → all of the construct's helpers stay inline).
7768
+ function unionEnv(ctx, bodies) {
7769
+ let all = null;
7770
+ for (const b of bodies) {
7771
+ if (!b) continue;
7772
+ const c = helperCaptures(ctx, b.stmts, b.params);
7773
+ if (c === null) return null;
7774
+ if (all === null) all = new Set();
7775
+ for (const n of c) all.add(n);
7776
+ }
7777
+ return all === null ? null : [...all].sort();
7778
+ }
7779
+
5885
7780
  // Hoist a construct sub-body (an @if/@else branch, an `<Activity>`/@try/
5886
- // @pending/@catch/@switch-case body, component children, a @for item/@empty
5887
- // body) as an inlined helper: wrap the statements in the legacy synthetic
5888
- // `Component` shape compileFunctionBody understands, compile it, and push the
5889
- // result into the surrounding component's `inlinedSubs` the helper is
5890
- // emitted INSIDE the component function, so its closures capture the parent's
5891
- // locals. Returns the generated helper name.
5892
- function hoistBodyHelper(ctx, inlinedSubs, prefix, stmts, params, parentNs, cssHash) {
7781
+ // @pending/@catch/@switch-case body, a @for item/@empty body, a portal body)
7782
+ // as a helper. Phase 2: with `envNames` (the construct's shared env union,
7783
+ // possibly empty) the helper is hoisted to MODULE scope — zero per-render
7784
+ // closure allocations and captured parent locals arrive through the
7785
+ // `__extra` ABI slot (renderBlock forwards `block.extra` as the third body
7786
+ // arg; the compiled call site passes the current values every parent render):
7787
+ //
7788
+ // function __then$0(__props, __s, __extra) { const [label] = __extra; … }
7789
+ //
7790
+ // `envNames: null` keeps the legacy placement — the helper is emitted INSIDE
7791
+ // the component function so its closures capture the parent's locals
7792
+ // lexically (component children `__children$N` still use this: they are
7793
+ // invoked through props, not through a construct block, so there is no
7794
+ // block.extra channel to ride).
7795
+ function hoistBodyHelper(ctx, inlinedSubs, prefix, stmts, params, parentNs, cssHash, envNames) {
5893
7796
  const helperName = `${prefix}$${ctx.nextHelperId++}`;
7797
+ let bodyStmts = stmts;
7798
+ if (envNames && envNames.length > 0) {
7799
+ // Destructure the construct's shared env tuple. The layout is the UNION
7800
+ // across the construct's helpers, so every helper destructures the same
7801
+ // slots (names a body doesn't use are harmless consts).
7802
+ bodyStmts = [
7803
+ {
7804
+ type: 'VariableDeclaration',
7805
+ kind: 'const',
7806
+ declarations: [
7807
+ {
7808
+ type: 'VariableDeclarator',
7809
+ id: {
7810
+ type: 'ArrayPattern',
7811
+ elements: envNames.map((n) => ({ type: 'Identifier', name: n })),
7812
+ },
7813
+ init: { type: 'Identifier', name: '__extra' },
7814
+ },
7815
+ ],
7816
+ },
7817
+ ...stmts,
7818
+ ];
7819
+ }
5894
7820
  const fake = {
5895
7821
  type: 'Component',
5896
7822
  id: { type: 'Identifier', name: helperName },
5897
7823
  params: params || [],
5898
- body: stmts,
7824
+ body: bodyStmts,
5899
7825
  };
5900
- inlinedSubs.push(compileFunctionBody(fake, ctx, helperName, parentNs, cssHash) + ';');
7826
+ if (envNames == null) {
7827
+ inlinedSubs.push(compileFunctionBody(fake, ctx, helperName, parentNs, cssHash) + ';');
7828
+ return helperName;
7829
+ }
7830
+ // Module-scope placement. Nested constructs compiled INSIDE this body
7831
+ // compute THEIR captures against the names visible at their call sites —
7832
+ // which live in THIS compiled body: the component's locals extended with
7833
+ // this helper's params, env destructure, and body-level locals. (A nested
7834
+ // helper's env values are emitted as plain identifiers at its call site.)
7835
+ const prevLocals = ctx.currentComponentLocals;
7836
+ const extended = new Set(prevLocals);
7837
+ for (const n of collectComponentLocals(fake)) extended.add(n);
7838
+ ctx.currentComponentLocals = extended;
7839
+ let code;
7840
+ try {
7841
+ code = compileFunctionBody(fake, ctx, helperName, parentNs, cssHash);
7842
+ } finally {
7843
+ ctx.currentComponentLocals = prevLocals;
7844
+ }
7845
+ ctx.hoistedHelpers.push(code + ';');
5901
7846
  return helperName;
5902
7847
  }
5903
7848
 
@@ -5911,6 +7856,16 @@ function makeIfCall(node, ctx, inlinedSubs, parentNs = 'html', cssHash = null) {
5911
7856
 
5912
7857
  const thenStmts =
5913
7858
  node.consequent.type === 'BlockStatement' ? node.consequent.body : [node.consequent];
7859
+ const elseStmts = node.alternate
7860
+ ? node.alternate.type === 'BlockStatement'
7861
+ ? node.alternate.body
7862
+ : [node.alternate]
7863
+ : null;
7864
+ // Phase 2: one shared env tuple for both branches (see unionEnv).
7865
+ const envNames = unionEnv(ctx, [
7866
+ { stmts: thenStmts, params: [] },
7867
+ elseStmts && { stmts: elseStmts, params: [] },
7868
+ ]);
5914
7869
  const thenHelperName = hoistBodyHelper(
5915
7870
  ctx,
5916
7871
  inlinedSubs,
@@ -5919,13 +7874,21 @@ function makeIfCall(node, ctx, inlinedSubs, parentNs = 'html', cssHash = null) {
5919
7874
  [],
5920
7875
  parentNs,
5921
7876
  cssHash,
7877
+ envNames,
5922
7878
  );
5923
7879
 
5924
7880
  let elseHelperName = null;
5925
- if (node.alternate) {
5926
- const elseStmts =
5927
- node.alternate.type === 'BlockStatement' ? node.alternate.body : [node.alternate];
5928
- elseHelperName = hoistBodyHelper(ctx, inlinedSubs, '__else', elseStmts, [], parentNs, cssHash);
7881
+ if (elseStmts) {
7882
+ elseHelperName = hoistBodyHelper(
7883
+ ctx,
7884
+ inlinedSubs,
7885
+ '__else',
7886
+ elseStmts,
7887
+ [],
7888
+ parentNs,
7889
+ cssHash,
7890
+ envNames,
7891
+ );
5929
7892
  }
5930
7893
 
5931
7894
  return {
@@ -5933,6 +7896,7 @@ function makeIfCall(node, ctx, inlinedSubs, parentNs = 'html', cssHash = null) {
5933
7896
  loc: devLoc(ctx, node),
5934
7897
  condExpr,
5935
7898
  condTest: node.test, // raw test AST — the fold threads it as a `props.hN` hole
7899
+ envNames,
5936
7900
  thenHelper: thenHelperName,
5937
7901
  elseHelper: elseHelperName,
5938
7902
  hostPath: null,
@@ -5946,6 +7910,8 @@ function makeIfCall(node, ctx, inlinedSubs, parentNs = 'html', cssHash = null) {
5946
7910
  // every parent render (like ifBlock's cond); a missing mode defaults to visible.
5947
7911
  function makeActivityCall(node, ctx, inlinedSubs, parentNs = 'html', cssHash = null) {
5948
7912
  const modeExpr = node.mode ? printExpr(node.mode) : "'visible'";
7913
+ // Phase 2: hoisted body + env tuple (see hoistBodyHelper).
7914
+ const envNames = unionEnv(ctx, [{ stmts: node.children, params: [] }]);
5949
7915
  const bodyHelperName = hoistBodyHelper(
5950
7916
  ctx,
5951
7917
  inlinedSubs,
@@ -5954,12 +7920,14 @@ function makeActivityCall(node, ctx, inlinedSubs, parentNs = 'html', cssHash = n
5954
7920
  [],
5955
7921
  parentNs,
5956
7922
  cssHash,
7923
+ envNames,
5957
7924
  );
5958
7925
  return {
5959
7926
  id: ctx.nextHelperId++,
5960
7927
  loc: devLoc(ctx, node),
5961
7928
  activity: true,
5962
7929
  modeExpr,
7930
+ envNames,
5963
7931
  thenHelper: bodyHelperName,
5964
7932
  elseHelper: null,
5965
7933
  hostPath: null,
@@ -6000,7 +7968,12 @@ function isComponentTag(node) {
6000
7968
  // codegen path as `<Foo>` / `<ctx.Provider>`.
6001
7969
  if (name.type === 'JSXExpressionContainer' && name.isDynamic === true) return true;
6002
7970
  if (name.type === 'Identifier' || name.type === 'JSXIdentifier') {
6003
- return typeof name.name === 'string' && /^[A-Z]/.test(name.name);
7971
+ if (typeof name.name !== 'string') return false;
7972
+ // JSX semantics (Babel/TS `isCompatTag`): an identifier tag is a HOST
7973
+ // string tag only when it starts with a lowercase ASCII letter (or isn't
7974
+ // a plain identifier, e.g. `<my-element>`); everything else — `<Foo>`,
7975
+ // `<_Inner>`, `<$Inner>` — is a component REFERENCE.
7976
+ return !/^[a-z]/.test(name.name) && !name.name.includes('-');
6004
7977
  }
6005
7978
  return false;
6006
7979
  }
@@ -6158,6 +8131,9 @@ function makeCompCall(
6158
8131
  // Compile children as a render function: (scope) => { renders JSX into scope }.
6159
8132
  // The function is inlined inside the parent component body so its closures
6160
8133
  // capture the parent's locals (props, state, etc.).
8134
+ // Phase 2 NOTE: children render-fns stay INLINE (envNames=null) — they are
8135
+ // invoked through props (childrenAsBody / render-prop checks), not through
8136
+ // a construct block, so there is no block.extra channel for captures.
6161
8137
  const childrenHelperName = hoistBodyHelper(
6162
8138
  ctx,
6163
8139
  inlinedSubs,
@@ -6166,8 +8142,14 @@ function makeCompCall(
6166
8142
  [],
6167
8143
  parentNs,
6168
8144
  cssHash,
8145
+ null,
6169
8146
  );
6170
- propParts.push(`"children": ${childrenHelperName}`);
8147
+ // Tag the children-block render fn so a consumer can tell it from a render-prop
8148
+ // function child (`<C>{(x) => …}</C>`, passed RAW above) — both are `typeof === 'function'`,
8149
+ // so React-ecosystem `typeof children === 'function'` checks need `isChildrenBlock` to
8150
+ // exclude compiled element/text children. See runtime `markChildrenBlock`/`isChildrenBlock`.
8151
+ ctx.runtimeNeeded.add('markChildrenBlock');
8152
+ propParts.push(`"children": _$markChildrenBlock(${childrenHelperName})`);
6171
8153
  }
6172
8154
 
6173
8155
  const propsExpr = `{ ${propParts.join(', ')} }`;
@@ -6185,18 +8167,29 @@ function makeCompCall(
6185
8167
  // client mount — no `comp`/`/comp` markers. (Lite components are already
6186
8168
  // markerless, so this only matters for the full path.)
6187
8169
  let singleRoot = false;
8170
+ // maybeSingleRoot: the call site qualifies syntactically but the callee is
8171
+ // CROSS-MODULE (not in componentInfo) — emit the `2` sentinel so the runtime
8172
+ // elides iff the callee carries the definition-site `$$singleRoot` stamp
8173
+ // (docs/comment-marker-elision-plan.md M1).
8174
+ let maybeSingleRoot = false;
6188
8175
  if (ctx.componentInfo && keyExpr == null) {
6189
8176
  const tagName = node.openingElement?.name || node.id || node.name;
6190
8177
  const isBareIdent =
6191
8178
  tagName && (tagName.type === 'Identifier' || tagName.type === 'JSXIdentifier');
6192
8179
  if (isBareIdent) {
8180
+ const hasSpread = propParts.some((p) => p.startsWith('...'));
8181
+ const hasChildrenProp = propParts.some((p) => p.startsWith('"children":'));
8182
+ const callSiteOk = !hasSpread && !hasChildrenProp;
6193
8183
  const calleeInfo = ctx.componentInfo.get(compExpr);
6194
8184
  if (calleeInfo) {
6195
- const hasSpread = propParts.some((p) => p.startsWith('...'));
6196
- const hasChildrenProp = propParts.some((p) => p.startsWith('"children":'));
6197
- const callSiteOk = !hasSpread && !hasChildrenProp;
6198
8185
  if (calleeInfo.eligible) liteEligible = callSiteOk;
6199
8186
  else if (calleeInfo.singleRoot) singleRoot = callSiteOk;
8187
+ } else if (ctx.importedNames !== undefined && ctx.importedNames.has(compExpr)) {
8188
+ // IMPORTED bindings only: immutable identity for the slot's whole
8189
+ // life. A local variable callee (`const Comp = cond ? A : B`) can
8190
+ // change identity per render — the markerless regime must not be
8191
+ // pinned to whichever component happened to mount first.
8192
+ maybeSingleRoot = callSiteOk;
6200
8193
  }
6201
8194
  }
6202
8195
  }
@@ -6209,6 +8202,7 @@ function makeCompCall(
6209
8202
  keyExpr,
6210
8203
  liteEligible,
6211
8204
  singleRoot,
8205
+ maybeSingleRoot,
6212
8206
  loc: devLoc(ctx, node),
6213
8207
  };
6214
8208
  }
@@ -6220,31 +8214,16 @@ function makeCompCall(
6220
8214
  function makeTryCall(node, ctx, inlinedSubs, parentNs = 'html', cssHash = null) {
6221
8215
  // node.block = try BlockStatement, node.handler = CatchClause (param, resetParam, body),
6222
8216
  // node.pending = optional BlockStatement (TSRX `pending { ... }`)
6223
- const tryHelperName = hoistBodyHelper(
6224
- ctx,
6225
- inlinedSubs,
6226
- '__try',
6227
- node.block.body,
6228
- [],
6229
- parentNs,
6230
- cssHash,
6231
- );
6232
-
6233
- // Optional `pending { ... }` arm — compiled like any sub-body.
6234
- let pendingHelperName = 'null';
6235
- if (node.pending && node.pending.body && node.pending.body.length > 0) {
6236
- pendingHelperName = hoistBodyHelper(
6237
- ctx,
6238
- inlinedSubs,
6239
- '__pending',
6240
- node.pending.body,
6241
- [],
6242
- parentNs,
6243
- cssHash,
6244
- );
6245
- }
6246
-
6247
- let catchHelperName = 'null';
8217
+ //
8218
+ // Phase 2: one shared env tuple for try/pending/catch (see unionEnv). The
8219
+ // catch body's `err`/`reset` destructure (from its own `__props` param) is
8220
+ // built FIRST and included in its analyzed statements, so those bind as
8221
+ // locals, not captures.
8222
+ const tryStmts = node.block.body;
8223
+ const pendingStmts =
8224
+ node.pending && node.pending.body && node.pending.body.length > 0 ? node.pending.body : null;
8225
+
8226
+ let catchBodyStmts = null;
6248
8227
  if (node.handler) {
6249
8228
  const handler = node.handler;
6250
8229
  const errName = handler.param?.name || '_err';
@@ -6287,19 +8266,58 @@ function makeTryCall(node, ctx, inlinedSubs, parentNs = 'html', cssHash = null)
6287
8266
  },
6288
8267
  ],
6289
8268
  };
8269
+ catchBodyStmts = [destructure, ...catchStmts];
8270
+ }
8271
+
8272
+ const catchParams = [{ type: 'Identifier', name: '__props' }];
8273
+ const envNames = unionEnv(ctx, [
8274
+ { stmts: tryStmts, params: [] },
8275
+ pendingStmts && { stmts: pendingStmts, params: [] },
8276
+ catchBodyStmts && { stmts: catchBodyStmts, params: catchParams },
8277
+ ]);
8278
+
8279
+ const tryHelperName = hoistBodyHelper(
8280
+ ctx,
8281
+ inlinedSubs,
8282
+ '__try',
8283
+ tryStmts,
8284
+ [],
8285
+ parentNs,
8286
+ cssHash,
8287
+ envNames,
8288
+ );
8289
+
8290
+ let pendingHelperName = 'null';
8291
+ if (pendingStmts) {
8292
+ pendingHelperName = hoistBodyHelper(
8293
+ ctx,
8294
+ inlinedSubs,
8295
+ '__pending',
8296
+ pendingStmts,
8297
+ [],
8298
+ parentNs,
8299
+ cssHash,
8300
+ envNames,
8301
+ );
8302
+ }
8303
+
8304
+ let catchHelperName = 'null';
8305
+ if (catchBodyStmts) {
6290
8306
  catchHelperName = hoistBodyHelper(
6291
8307
  ctx,
6292
8308
  inlinedSubs,
6293
8309
  '__catch',
6294
- [destructure, ...catchStmts],
6295
- [{ type: 'Identifier', name: '__props' }],
8310
+ catchBodyStmts,
8311
+ catchParams,
6296
8312
  parentNs,
6297
8313
  cssHash,
8314
+ envNames,
6298
8315
  );
6299
8316
  }
6300
8317
  return {
6301
8318
  id: ctx.nextHelperId++,
6302
8319
  loc: devLoc(ctx, node),
8320
+ envNames,
6303
8321
  tryHelper: tryHelperName,
6304
8322
  catchHelper: catchHelperName,
6305
8323
  pendingHelper: pendingHelperName,
@@ -6321,6 +8339,11 @@ function makeSwitchCall(node, ctx, inlinedSubs, parentNs = 'html', cssHash = nul
6321
8339
  const discExpr = printExpr(node.discriminant);
6322
8340
  const caseRecords = [];
6323
8341
  let defaultHelper = 'null';
8342
+ // Phase 2: one shared env tuple across every case + default (see unionEnv).
8343
+ const envNames = unionEnv(
8344
+ ctx,
8345
+ (node.cases || []).map((c) => ({ stmts: c.consequent || [], params: [] })),
8346
+ );
6324
8347
  for (const c of node.cases || []) {
6325
8348
  const stmts = c.consequent || [];
6326
8349
  const isDefault = c.test == null;
@@ -6332,6 +8355,7 @@ function makeSwitchCall(node, ctx, inlinedSubs, parentNs = 'html', cssHash = nul
6332
8355
  [],
6333
8356
  parentNs,
6334
8357
  cssHash,
8358
+ envNames,
6335
8359
  );
6336
8360
  if (isDefault) {
6337
8361
  defaultHelper = helperName;
@@ -6346,6 +8370,7 @@ function makeSwitchCall(node, ctx, inlinedSubs, parentNs = 'html', cssHash = nul
6346
8370
  loc: devLoc(ctx, node),
6347
8371
  discExpr,
6348
8372
  discNode: node.discriminant, // AST — the fold threads it as a `props.hN` hole
8373
+ envNames,
6349
8374
  casesArrayExpr,
6350
8375
  caseRecords, // { testNode, helper } per case — the fold builds the cases hole
6351
8376
  defaultHelper,
@@ -6378,11 +8403,11 @@ function makeForCall(node, ctx, inlinedSubs, parentNs = 'html', cssHash = null)
6378
8403
  // items.length === 0 the runtime mounts the empty branch in place of the
6379
8404
  // (empty) item list; transitioning items → 0 unmounts the chain and mounts
6380
8405
  // the empty body, and 0 → items does the reverse.
6381
- let emptyHelperName = 'null';
6382
- if (node.empty) {
6383
- const stmts = node.empty.type === 'BlockStatement' ? node.empty.body : [node.empty];
6384
- emptyHelperName = hoistBodyHelper(ctx, inlinedSubs, '__empty', stmts, [], parentNs, cssHash);
6385
- }
8406
+ const emptyStmts = node.empty
8407
+ ? node.empty.type === 'BlockStatement'
8408
+ ? node.empty.body
8409
+ : [node.empty]
8410
+ : null;
6386
8411
  const leftDeclId = node.left.declarations[0].id;
6387
8412
  const isDestructured = leftDeclId.type !== 'Identifier';
6388
8413
  // `itemName` is the identifier used in the body signature + keyFn. For a
@@ -6523,19 +8548,51 @@ function makeForCall(node, ctx, inlinedSubs, parentNs = 'html', cssHash = null)
6523
8548
  }
6524
8549
  }
6525
8550
  const hasNestedComp = containsComponentCallOrControlFlow(subStmts);
6526
- pure = !hasParentClosure && !hasHook && !hasNestedComp;
6527
- depEligible = !pure && hasParentClosure && !hasHook && !hasNestedComp;
8551
+ // A render-time CALL disqualifies the survivor short-circuit entirely: the
8552
+ // call can read state neither the item ref nor the deps tuple witnesses
8553
+ // (`header.column.getIsSorted()` flips while `header` stays the memoized
8554
+ // object), so a skipped body would render stale output — React re-runs
8555
+ // bodies unconditionally. Property reads stay eligible (the measured
8556
+ // js-framework-benchmark/dbmon wins are read-only bodies).
8557
+ const hasRenderCall = containsRenderCall(subStmts);
8558
+ pure = !hasParentClosure && !hasHook && !hasNestedComp && !hasRenderCall;
8559
+ depEligible = !pure && hasParentClosure && !hasHook && !hasNestedComp && !hasRenderCall;
6528
8560
  depNames.sort();
6529
8561
  }
6530
8562
 
8563
+ // Phase 2: ONE env tuple shared by the item + @empty helpers — it IS the
8564
+ // forBlock `deps` array (deps was already the captured-locals snapshot for
8565
+ // dep-pure promotion; the runtime additionally stamps it as `block.extra`
8566
+ // on every item/empty block). The union may widen deps with @empty-only
8567
+ // captures — a conservative, correct deps for promotion purposes.
8568
+ const itemAllStmts = [...indexInjection, ...destructureInjection, ...subStmts];
8569
+ const itemParams = [{ type: 'Identifier', name: itemName }];
8570
+ const envNames = unionEnv(ctx, [
8571
+ { stmts: itemAllStmts, params: itemParams },
8572
+ emptyStmts && { stmts: emptyStmts, params: [] },
8573
+ ]);
8574
+ let emptyHelperName = 'null';
8575
+ if (emptyStmts) {
8576
+ emptyHelperName = hoistBodyHelper(
8577
+ ctx,
8578
+ inlinedSubs,
8579
+ '__empty',
8580
+ emptyStmts,
8581
+ [],
8582
+ parentNs,
8583
+ cssHash,
8584
+ envNames,
8585
+ );
8586
+ }
6531
8587
  const itemHelperName = hoistBodyHelper(
6532
8588
  ctx,
6533
8589
  inlinedSubs,
6534
8590
  '__item',
6535
- [...indexInjection, ...destructureInjection, ...subStmts],
6536
- [{ type: 'Identifier', name: itemName }],
8591
+ itemAllStmts,
8592
+ itemParams,
6537
8593
  parentNs,
6538
8594
  cssHash,
8595
+ envNames,
6539
8596
  );
6540
8597
 
6541
8598
  // Single-root detection: when the body emits exactly one Element root and
@@ -6565,11 +8622,12 @@ function makeForCall(node, ctx, inlinedSubs, parentNs = 'html', cssHash = null)
6565
8622
  bodyHelper: itemHelperName,
6566
8623
  pure,
6567
8624
  singleRoot,
6568
- // DEP-PURE candidates emit `[dep0, dep1, ...]` as the deps arg in the
6569
- // forBlock call. Reconciler compares to its cached deps; if unchanged
6570
- // this render, treats the body as PURE for the survivor short-circuit.
8625
+ // The env union doubles as the deps array: emitted whenever the helpers
8626
+ // capture anything (Phase 2 the runtime stamps it as block.extra), and
8627
+ // ALSO compared for the dep-pure survivor short-circuit when depEligible.
8628
+ // depNames must be exactly the tuple layout the helpers destructure.
6571
8629
  depEligible,
6572
- depNames,
8630
+ depNames: envNames || depNames,
6573
8631
  // True only when the header binds NO `index <name>` — the body then can't
6574
8632
  // observe an item's position, so a pure reorder (same item ref, position
6575
8633
  // changed) need not re-render the survivor. Conservative: an index binding