octane 0.1.16 → 0.1.17

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.
@@ -2174,6 +2174,96 @@ function rewriteAutoCallback(stmt, stable, componentLocals, ctx) {
2174
2174
  return modified ? { ...stmt, declarations: newDecls } : stmt;
2175
2175
  }
2176
2176
 
2177
+ // Auto-calculation: lower a derived `const X = <expr containing a render-time
2178
+ // call>` to `useMemo(() => <expr>, [deps])` so its identity is stable while its
2179
+ // inputs are. Without this, an idiomatic `const visible = todos.filter(...)`
2180
+ // allocates a fresh array every render, and every downstream consumer keyed on
2181
+ // its identity — an autoMemo region's dependency tuple, a memo() child's prop —
2182
+ // misses unconditionally. The region cache is only as good as the stability of
2183
+ // what it keys on.
2184
+ //
2185
+ // A calculation is admitted on exactly the callee rule that governs regions
2186
+ // (plainCalleeIsMemoizable): the receiver of a member call can return a new
2187
+ // answer while its own identity holds, and that hazard does not become safe
2188
+ // because the author named the result. `virtualizer.getVirtualItems()` in
2189
+ // examples/pulseboard is the same shape as `header.column.getIsSorted()` — its
2190
+ // window moves with scroll while the virtualizer instance stays put — so
2191
+ // caching it froze a virtualized list mid-scroll. One rule, both paths.
2192
+ //
2193
+ // The cost of that is real and accepted: `const visible = todos.filter(...)`
2194
+ // stays uncached, because `todos.filter` is a member call and this analysis
2195
+ // cannot yet prove the receiver is an immutable snapshot rather than a live
2196
+ // object. Recovering it needs receiver provenance (does the root trace to a
2197
+ // state binding?), which is a separate change with its own evidence.
2198
+ //
2199
+ // Scope: single-declarator `const` with an Identifier id whose init reaches a
2200
+ // render-time call (isPropCreationExpr — no JSX, no hook-shaped calls), whose
2201
+ // every call is an admitted projection, whose value the render tree actually
2202
+ // reads (memoizing a value nothing renders only adds cells), and whose
2203
+ // dependencies are all component locals. Bodies that already went through Pass
2204
+ // A′ arrive with hook-shaped inits and are skipped.
2205
+ function rewriteAutoCalculation(stmt, componentLocals, renderReadNames, ctx) {
2206
+ if (stmt.type !== 'VariableDeclaration' || stmt.kind !== 'const') return stmt;
2207
+ if (stmt.declarations?.length !== 1) return stmt;
2208
+ const decl = stmt.declarations[0];
2209
+ if (!decl || decl.id?.type !== 'Identifier' || !decl.init) return stmt;
2210
+ if (!renderReadNames.has(decl.id.name)) return stmt;
2211
+ const init = unwrapTsExpr(decl.init);
2212
+ // An arrow/function init is auto-callback's job, not ours.
2213
+ if (FN_TYPES.has(init?.type)) return stmt;
2214
+ if (!isPropCreationExpr(init, ctx)) return stmt;
2215
+ // Every call reached during render must be a proven value projection — the
2216
+ // same admission the region cache uses. A member call fails closed.
2217
+ if (containsRenderCall([init], ctx)) return stmt;
2218
+ const deps = [];
2219
+ const seen = new Set();
2220
+ for (const name of collectFreeIdentifiers(init, [])) {
2221
+ if (name === decl.id.name) return stmt; // self-referential; leave alone
2222
+ if (!componentLocals.has(name)) continue;
2223
+ if (seen.has(name)) continue;
2224
+ seen.add(name);
2225
+ deps.push(name);
2226
+ }
2227
+ // Every free name must be a witnessed component local. An unwitnessed
2228
+ // module/global read would make the cache freeze on something no dependency
2229
+ // can observe, which is a different bargain from the one above.
2230
+ for (const name of collectFreeIdentifiers(init, [])) {
2231
+ if (
2232
+ !componentLocals.has(name) &&
2233
+ !ctx.moduleFunctionDeclarations?.has(name) &&
2234
+ !ctx.importedNames?.has(name)
2235
+ ) {
2236
+ return stmt;
2237
+ }
2238
+ }
2239
+ return {
2240
+ ...stmt,
2241
+ declarations: [
2242
+ {
2243
+ ...decl,
2244
+ init: inheritOriginLoc(
2245
+ b.call(
2246
+ { ...b.id('useMemo'), _octaneGenerated: true },
2247
+ b.arrow([], init),
2248
+ b.array(deps.map((n) => b.id(n))),
2249
+ ),
2250
+ init,
2251
+ ),
2252
+ },
2253
+ ],
2254
+ };
2255
+ }
2256
+
2257
+ // Names the render tree actually reads, resolved against the scopes the
2258
+ // statement walker cannot see (nested functions, loops, `@for` item bindings,
2259
+ // directive-arm blocks) so a shadowing inner binding never nominates an
2260
+ // unrelated body const.
2261
+ function collectRenderReadNames(jsxNodes, ctx) {
2262
+ const into = new Set();
2263
+ collectUseArgumentRoots(jsxNodes, ctx, into, true, true);
2264
+ return into;
2265
+ }
2266
+
2177
2267
  function allocAutoMemoCell(ctx, dependencyCount) {
2178
2268
  const base = ctx.currentAutoMemoOffset || 0;
2179
2269
  const init = base + dependencyCount;
@@ -2858,10 +2948,19 @@ function containsRenderCall(stmts, memoCtx = null) {
2858
2948
  return found;
2859
2949
  }
2860
2950
 
2861
- const HOOK_CALLEE_NAME_RE = /^use[A-Z]/;
2951
+ // A hook call is identified by naming convention — the same signal React and
2952
+ // React Compiler key on — plus React's own `unstable_` staging prefix, which
2953
+ // bindings mirror (`unstable_useRouterState` in @octanejs/remix-router). The
2954
+ // prefix is enumerated rather than matched as "any `_use`" so an ordinary
2955
+ // helper cannot be mistaken for a hook by spelling alone.
2956
+ //
2957
+ // Getting this wrong in the permissive direction is not a staleness bug: a
2958
+ // cache wrapped around a hook call freezes its subscription and its state cell
2959
+ // for the life of the component.
2960
+ const HOOK_NAME_CONVENTION_RE = /^(?:unstable_)?use(?:$|[A-Z])/;
2862
2961
 
2863
2962
  function isHookCalleeName(name) {
2864
- return name === 'use' || HOOK_CALLEE_NAME_RE.test(name);
2963
+ return HOOK_NAME_CONVENTION_RE.test(name);
2865
2964
  }
2866
2965
 
2867
2966
  /**
@@ -9386,6 +9485,19 @@ function compileFunctionBody(node, ctx, name, parentNs = 'html', cssHash = null,
9386
9485
  creations,
9387
9486
  puParamNames,
9388
9487
  );
9488
+ // Auto-calculation runs AFTER Pass A′ so use()-fed chains keep their
9489
+ // Symbol-slot treatment (their rewritten inits are hook-shaped and are
9490
+ // skipped here), and BEFORE Pass A/B so the numeric-slot useMemo it mints is
9491
+ // lowered by the authored inline tier rather than re-wrapped. Client only:
9492
+ // a server render evaluates each body once, so a cache is pure overhead.
9493
+ if (ctx.currentComponentLocals && ctx.mode !== 'server') {
9494
+ const renderReadNames = collectRenderReadNames(jsxNodes, ctx);
9495
+ if (renderReadNames.size > 0) {
9496
+ workingStatements = workingStatements.map((statement) =>
9497
+ rewriteAutoCalculation(statement, ctx.currentComponentLocals, renderReadNames, ctx),
9498
+ );
9499
+ }
9500
+ }
9389
9501
  workingStatements = parallelUseMemoizePass(workingStatements, ctx, name, creations, [], null);
9390
9502
  jsxNodes = parallelUseWalkJsx(jsxNodes, ctx, name, creations, warmChildren, [], new Set());
9391
9503
  const warm = buildWarmArtifacts(node, ctx, name, creations, warmChildren);
@@ -10210,14 +10322,17 @@ function isPropCreationExpr(expr, ctx) {
10210
10322
  function isHookishCallee(callee) {
10211
10323
  if (callee.type === 'Identifier') {
10212
10324
  return (
10213
- /^use($|[A-Z])/.test(callee.name) || ctx?._parallelUseAliases?.has(callee.name) === true
10325
+ HOOK_NAME_CONVENTION_RE.test(callee.name) ||
10326
+ ctx?._parallelUseAliases?.has(callee.name) === true
10214
10327
  );
10215
10328
  }
10216
10329
  if (
10217
10330
  (callee.type === 'MemberExpression' || callee.type === 'OptionalMemberExpression') &&
10218
10331
  !callee.computed
10219
10332
  ) {
10220
- return callee.property.type === 'Identifier' && /^use($|[A-Z])/.test(callee.property.name);
10333
+ return (
10334
+ callee.property.type === 'Identifier' && HOOK_NAME_CONVENTION_RE.test(callee.property.name)
10335
+ );
10221
10336
  }
10222
10337
  return false;
10223
10338
  }
@@ -10632,8 +10747,14 @@ function memoizeUseFedCreations(stmts, jsxNodes, ctx, componentName, creations,
10632
10747
  // Deep scan (nested functions and render-tree nodes included — a consumer
10633
10748
  // anywhere makes stability load-bearing) for `use(<arg>)` arguments' free
10634
10749
  // identifier roots.
10635
- function collectUseArgumentRoots(root, ctx, into, startInRenderTree = false) {
10636
- walk(root, EMPTY_BOUND_SET, startInRenderTree);
10750
+ function collectUseArgumentRoots(
10751
+ root,
10752
+ ctx,
10753
+ into,
10754
+ startInRenderTree = false,
10755
+ seedRenderReads = false,
10756
+ ) {
10757
+ walk(root, EMPTY_BOUND_SET, startInRenderTree, false);
10637
10758
 
10638
10759
  // `bound` carries every name introduced by a scope the candidate collector
10639
10760
  // cannot reach: nested-function params + internal declarations, loop
@@ -10645,10 +10766,10 @@ function collectUseArgumentRoots(root, ctx, into, startInRenderTree = false) {
10645
10766
  // into memoization (which would wrongly stop it recreating per render).
10646
10767
  // Over-binding is the safe direction: a dropped seed just leaves that
10647
10768
  // chain unmemoized — today's behavior.
10648
- function walk(n, bound, inRenderTree) {
10769
+ function walk(n, bound, inRenderTree, deferred) {
10649
10770
  if (n == null || typeof n !== 'object') return;
10650
10771
  if (Array.isArray(n)) {
10651
- for (const child of n) walk(child, bound, inRenderTree);
10772
+ for (const child of n) walk(child, bound, inRenderTree, deferred);
10652
10773
  return;
10653
10774
  }
10654
10775
  if (
@@ -10665,7 +10786,10 @@ function collectUseArgumentRoots(root, ctx, into, startInRenderTree = false) {
10665
10786
  const inner = new Set(bound);
10666
10787
  for (const param of n.params || []) collectPatternNames(param, inner);
10667
10788
  collectScopeDeclaredNames(n.body, inner);
10668
- walk(n.body, inner, inRenderTree);
10789
+ // A function VALUE runs at invoke time, not while the template renders,
10790
+ // so names it reads are not render reads. use()-argument seeding still
10791
+ // descends (a use() inside a render prop is a real suspension point).
10792
+ walk(n.body, inner, inRenderTree, true);
10669
10793
  return;
10670
10794
  }
10671
10795
  if (LOOP_TYPES.has(n.type)) {
@@ -10674,7 +10798,7 @@ function collectUseArgumentRoots(root, ctx, into, startInRenderTree = false) {
10674
10798
  for (const key in n) {
10675
10799
  if (key === 'loc' || key === 'start' || key === 'end' || key.startsWith('_octane'))
10676
10800
  continue;
10677
- walk(n[key], inner, inRenderTree);
10801
+ walk(n[key], inner, inRenderTree, deferred);
10678
10802
  }
10679
10803
  return;
10680
10804
  }
@@ -10682,7 +10806,7 @@ function collectUseArgumentRoots(root, ctx, into, startInRenderTree = false) {
10682
10806
  const inner = new Set(bound);
10683
10807
  if (n.param) collectPatternNames(n.param, inner);
10684
10808
  collectScopeDeclaredNames(n.body, inner);
10685
- walk(n.body, inner, inRenderTree);
10809
+ walk(n.body, inner, inRenderTree, deferred);
10686
10810
  return;
10687
10811
  }
10688
10812
  if (n.type === 'JSXForExpression') {
@@ -10696,16 +10820,32 @@ function collectUseArgumentRoots(root, ctx, into, startInRenderTree = false) {
10696
10820
  collectPatternNames(n.left, inner);
10697
10821
  }
10698
10822
  if (n.index) collectPatternNames(n.index, inner);
10699
- walk(n.right, bound, true);
10700
- walk(n.key, inner, true);
10701
- walk(n.body, inner, true);
10702
- walk(n.empty, bound, true);
10823
+ walk(n.right, bound, true, deferred);
10824
+ walk(n.key, inner, true, deferred);
10825
+ walk(n.body, inner, true, deferred);
10826
+ walk(n.empty, bound, true, deferred);
10827
+ return;
10828
+ }
10829
+ if (
10830
+ seedRenderReads &&
10831
+ inRenderTree &&
10832
+ !deferred &&
10833
+ n.type === 'Identifier' &&
10834
+ !bound.has(n.name)
10835
+ ) {
10836
+ into.add(n.name);
10837
+ }
10838
+ if ((n.type === 'MemberExpression' || n.type === 'OptionalMemberExpression') && !n.computed) {
10839
+ // `row.length` reads `row`, not a binding called `length`. Skipping the
10840
+ // property also cannot hide a use() call: a non-computed property is an
10841
+ // Identifier by construction.
10842
+ walk(n.object, bound, inRenderTree, deferred);
10703
10843
  return;
10704
10844
  }
10705
10845
  if (inRenderTree && n.type === 'BlockStatement') {
10706
10846
  const inner = new Set(bound);
10707
10847
  collectScopeDeclaredNames(n, inner);
10708
- for (const child of n.body || []) walk(child, inner, true);
10848
+ for (const child of n.body || []) walk(child, inner, true, deferred);
10709
10849
  return;
10710
10850
  }
10711
10851
  // JSX reached at a setup VALUE position (`const el = <div>{use(x)}</div>`)
@@ -10716,7 +10856,7 @@ function collectUseArgumentRoots(root, ctx, into, startInRenderTree = false) {
10716
10856
  (n.type.startsWith('JSX') || n.type === 'Element' || n.type === 'Tsrx'));
10717
10857
  for (const key in n) {
10718
10858
  if (key === 'loc' || key === 'start' || key === 'end' || key.startsWith('_octane')) continue;
10719
- walk(n[key], bound, nextInRenderTree);
10859
+ walk(n[key], bound, nextInRenderTree, deferred);
10720
10860
  }
10721
10861
  }
10722
10862
  }
@@ -16057,9 +16197,11 @@ function propertyIsEnumerableCall(objNode, name) {
16057
16197
  );
16058
16198
  }
16059
16199
 
16060
- // `__s.cleanups.push(<fn>)`.
16200
+ // `(__s.cleanups ??= []).push(<fn>)` — the scope's cleanup array is lazily allocated (most
16201
+ // scopes never register one), so the registration site owns creating it.
16061
16202
  function cleanupsPush(fnNode) {
16062
- return b.stmt(b.call(b.member(b.member(b.id('__s'), 'cleanups'), 'push'), fnNode));
16203
+ const lazyArray = b.assignment('??=', b.member(b.id('__s'), 'cleanups'), b.array([]));
16204
+ return b.stmt(b.call(b.member(lazyArray, 'push'), fnNode));
16063
16205
  }
16064
16206
 
16065
16207
  // Mount for a DEFERRED property-write binding: store the element ref + seed the
@@ -8,7 +8,7 @@ export interface DevtoolsScopeLike {
8
8
  children: Array<{
9
9
  key: symbol | string | number;
10
10
  scope: DevtoolsScopeLike;
11
- }>;
11
+ }> | null;
12
12
  $$ctxValues?: Map<any, any> | null;
13
13
  }
14
14
  export interface DevtoolsTreeNode {
@@ -63,7 +63,7 @@ function buildNode(scope, key) {
63
63
  id,
64
64
  name: nameOf(scope, key),
65
65
  kind: scope.kind ?? "component",
66
- children: scope.children.map((c) => buildNode(c.scope, c.key))
66
+ children: scope.children === null ? [] : scope.children.map((c) => buildNode(c.scope, c.key))
67
67
  };
68
68
  }
69
69
  const hook = {
package/dist/runtime.d.ts CHANGED
@@ -52,7 +52,12 @@ export interface Scope {
52
52
  * `undefined` when null, identical to a Map.get miss.
53
53
  */
54
54
  hooks: Map<HookSlot, any> | null;
55
- cleanups: Cleanup[];
55
+ /**
56
+ * Teardown callbacks (ref detaches, listener removal, slot cleanup). Lazily allocated by
57
+ * `pushCleanup` — most scopes never register one, so the array is only paid for on demand,
58
+ * matching `hooks` / `effectSlots` / `_slots`.
59
+ */
60
+ cleanups: Cleanup[] | null;
56
61
  /**
57
62
  * This scope's effect slots in hook DECLARATION order (first-enqueue order —
58
63
  * the order the hooks ran in the scope's first render). unmountScope walks it
@@ -69,8 +74,10 @@ export interface Scope {
69
74
  * (NOT a Map): iteration is a plain indexed for-loop, and lookups are linear
70
75
  * scans — faster than `Map.get` for the typical N ≤ 8 case (most components
71
76
  * have a handful of static sub-component calls at most).
77
+ *
78
+ * Lazily allocated when the first child scope registers: a leaf component has none.
72
79
  */
73
- children: ChildScope[];
80
+ children: ChildScope[] | null;
74
81
  mounted: boolean;
75
82
  /**
76
83
  * Slot objects owned by this scope (ifBlockSlot, forBlockSlot, etc.).
package/dist/runtime.js CHANGED
@@ -664,7 +664,7 @@ function scheduleRender(block) {
664
664
  );
665
665
  }
666
666
  const renderPhaseSelf = CURRENT_BLOCK === block;
667
- const renderPhaseOther = CURRENT_BLOCK !== null && !renderPhaseSelf && TRANSITION_LISTENER_PUBLISH_DEPTH === 0;
667
+ const renderPhaseOther = CURRENT_BLOCK !== null && !renderPhaseSelf && TRANSITION_LISTENER_PUBLISH_DEPTH === 0 && EFFECT_EVENT_LIFECYCLE_DEPTH === 0;
668
668
  if (renderPhaseOther) {
669
669
  block.crossRenderUpdate = true;
670
670
  warnCrossComponentRenderUpdate(block, CURRENT_BLOCK);
@@ -1523,9 +1523,9 @@ class BlockImpl {
1523
1523
  this.currentRenderDeferred = false;
1524
1524
  this.inactive = false;
1525
1525
  this.hooks = null;
1526
- this.cleanups = [];
1526
+ this.cleanups = null;
1527
1527
  this.effectSlots = null;
1528
- this.children = [];
1528
+ this.children = null;
1529
1529
  this._slots = null;
1530
1530
  this.refFields = null;
1531
1531
  this.$$ctxValues = null;
@@ -1574,9 +1574,9 @@ class ScopeImpl {
1574
1574
  this.block = block;
1575
1575
  this.parent = parent;
1576
1576
  this.hooks = null;
1577
- this.cleanups = [];
1577
+ this.cleanups = null;
1578
1578
  this.effectSlots = null;
1579
- this.children = [];
1579
+ this.children = null;
1580
1580
  this._slots = null;
1581
1581
  this.refFields = null;
1582
1582
  this.slots = [];
@@ -1868,7 +1868,7 @@ function componentSlotLite(parentScope, slotKey, host, comp, props, anchor) {
1868
1868
  });
1869
1869
  }
1870
1870
  parentScope.slots[slotKey] = scope;
1871
- parentScope.children.push({ key: slotKey, scope });
1871
+ (parentScope.children ??= []).push({ key: slotKey, scope });
1872
1872
  } else {
1873
1873
  }
1874
1874
  const prevScope = CURRENT_SCOPE;
@@ -1984,15 +1984,17 @@ function unmountScope(scope, detachDom = true) {
1984
1984
  }
1985
1985
  function unmountScopeChildrenAndSlots(scope, detachDom) {
1986
1986
  const c = scope.cleanups;
1987
- for (let i = c.length - 1; i >= 0; i--) {
1988
- try {
1989
- runEffectLifecycleCallback(c[i]);
1990
- } catch (err) {
1991
- reportTeardownError(err);
1987
+ if (c !== null)
1988
+ for (let i = c.length - 1; i >= 0; i--) {
1989
+ try {
1990
+ runEffectLifecycleCallback(c[i]);
1991
+ } catch (err) {
1992
+ reportTeardownError(err);
1993
+ }
1992
1994
  }
1993
- }
1994
1995
  const children = scope.children;
1995
- for (let i = 0, n = children.length; i < n; i++) unmountScope(children[i].scope, detachDom);
1996
+ if (children !== null)
1997
+ for (let i = 0, n = children.length; i < n; i++) unmountScope(children[i].scope, detachDom);
1996
1998
  const slots = scope._slots;
1997
1999
  if (slots !== null) {
1998
2000
  for (let i = 0, n = slots.length; i < n; i++) {
@@ -2428,7 +2430,7 @@ function useEffectEvent(fn, slot) {
2428
2430
  s = { impl: fn, active: true };
2429
2431
  ensureHooks(scope).set(slot, s);
2430
2432
  const cell2 = s;
2431
- scope.cleanups.push(() => {
2433
+ (scope.cleanups ??= []).push(() => {
2432
2434
  cell2.active = false;
2433
2435
  });
2434
2436
  } else {
@@ -2459,6 +2461,15 @@ function createContext(defaultValue) {
2459
2461
  }
2460
2462
  scope.$$ctxValues.set(ctx, props.value);
2461
2463
  if (props.children != null) {
2464
+ const dialect = typeof props.children === "function" ? 1 : 2;
2465
+ const previous = scope.hooks?.get(CHILDREN_DIALECT_SLOT);
2466
+ if (previous !== dialect) {
2467
+ if (previous !== void 0) {
2468
+ resetScopeChildren(scope);
2469
+ if (scope.block.disposed) return;
2470
+ }
2471
+ ensureHooks(scope).set(CHILDREN_DIALECT_SLOT, dialect);
2472
+ }
2462
2473
  childrenAsBody(props.children)(void 0, scope, void 0);
2463
2474
  }
2464
2475
  };
@@ -2496,6 +2507,28 @@ function childrenAsBody(children) {
2496
2507
  childSlot(s, 0, s.block.parentNode, children, s.block.endMarker);
2497
2508
  };
2498
2509
  }
2510
+ const CHILDREN_DIALECT_SLOT = /* @__PURE__ */ Symbol("octane.childrenDialect");
2511
+ function resetScopeChildren(scope) {
2512
+ const block = scope.block;
2513
+ if (TEARDOWN_DEPTH === 0) {
2514
+ TEARDOWN_HANDLER = findTryHandler(block.parentBlock) ?? rendererRegionTryHandler(block);
2515
+ }
2516
+ TEARDOWN_DEPTH++;
2517
+ try {
2518
+ unmountScopeChildrenAndSlots(scope, true);
2519
+ const start = block.startMarker;
2520
+ removeRange(start !== null ? start.nextSibling : block.parentNode.firstChild, block.endMarker);
2521
+ scope.children = null;
2522
+ scope.cleanups = null;
2523
+ scope._slots = null;
2524
+ scope.effectSlots = null;
2525
+ scope.hooks = null;
2526
+ scope.slots = [];
2527
+ scope.refFields = null;
2528
+ } finally {
2529
+ if (--TEARDOWN_DEPTH === 0) dispatchTeardownErrors();
2530
+ }
2531
+ }
2499
2532
  const HYDRATE_ID_SLOT = /* @__PURE__ */ Symbol("octane.hydrate.id");
2500
2533
  const HYDRATE_SETUP_SLOT = /* @__PURE__ */ Symbol("octane.hydrate.setup");
2501
2534
  const HYDRATE_NOTIFY_SLOT = /* @__PURE__ */ Symbol("octane.hydrate.notify");
@@ -3372,7 +3405,7 @@ function bindRendererRegionOwner(props) {
3372
3405
  RENDERER_REGION_DOM_OWNERS.set(root, bridge);
3373
3406
  root.$$ctxCache?.clear();
3374
3407
  if (previous === void 0) {
3375
- root.cleanups.push(() => {
3408
+ (root.cleanups ??= []).push(() => {
3376
3409
  const current = RENDERER_REGION_DOM_BINDINGS.get(root);
3377
3410
  if (current === void 0) return;
3378
3411
  current.release();
@@ -5270,7 +5303,7 @@ function mountFragmentRef(scope, startMarker, endMarker, ref) {
5270
5303
  const fi = new FragmentInstance(scope.block, startMarker, endMarker);
5271
5304
  fi._currentRef = ref;
5272
5305
  queueRefAttach(scope, () => attachRef(fi._currentRef, fi));
5273
- scope.cleanups.push(() => {
5306
+ (scope.cleanups ??= []).push(() => {
5274
5307
  queueRefDetach(fi._currentRef, fi);
5275
5308
  fi._destroy();
5276
5309
  });
@@ -5889,7 +5922,7 @@ function headBlock(scope, slot, key, tag, attrs, text) {
5889
5922
  }
5890
5923
  state = { el: el2 };
5891
5924
  scope.slots[slot] = state;
5892
- scope.cleanups.push(() => {
5925
+ (scope.cleanups ??= []).push(() => {
5893
5926
  state.el.remove();
5894
5927
  scope.slots[slot] = void 0;
5895
5928
  });
@@ -8063,9 +8096,9 @@ function hostComponent(scope, slot, tag, props, childrenBody, anchor) {
8063
8096
  scope.slots[slot] = state;
8064
8097
  const childScope = new ScopeImpl(scope, block);
8065
8098
  state.childScope = childScope;
8066
- scope.children.push({ key: slot, scope: childScope });
8099
+ (scope.children ??= []).push({ key: slot, scope: childScope });
8067
8100
  block.parentNode.insertBefore(el2, anchor ?? block.endMarker);
8068
- scope.cleanups.push(() => queueRefDetach(state.ref, state.el));
8101
+ (scope.cleanups ??= []).push(() => queueRefDetach(state.ref, state.el));
8069
8102
  }
8070
8103
  const el = state.el;
8071
8104
  applyHostProps(el, props, scope, state);
@@ -8133,6 +8166,19 @@ function applyHostProps(el, props, scope, state) {
8133
8166
  if (process.env.NODE_ENV !== "production") queueDevFormDiagnostic(el, scope);
8134
8167
  }
8135
8168
  const DEOPT_DESC = /* @__PURE__ */ Symbol("octane.deoptDesc");
8169
+ function hasDeoptOwnedChild(el) {
8170
+ let n = getFirstChild(el);
8171
+ while (n !== null) {
8172
+ const rangeEnd = n.$$portalEnd;
8173
+ if (rangeEnd != null) {
8174
+ n = nodeAfterPortalRange(n, rangeEnd);
8175
+ continue;
8176
+ }
8177
+ if (n.$$deoptKey !== void 0 || getDeoptDesc(n) !== void 0) return true;
8178
+ n = getNextSibling(n);
8179
+ }
8180
+ return false;
8181
+ }
8136
8182
  function getDeoptDesc(n) {
8137
8183
  return n[DEOPT_DESC];
8138
8184
  }
@@ -8297,8 +8343,14 @@ function reconcileDeoptChildren(el, children, ownerBlock) {
8297
8343
  }
8298
8344
  return;
8299
8345
  }
8346
+ if (next.length === 0 && activeHydration() === null && !hasDeoptOwnedChild(el)) {
8347
+ return;
8348
+ }
8300
8349
  const owned = [];
8301
8350
  let hasForeign = false;
8351
+ let hydrationOwnsUnstamped;
8352
+ let byKey = null;
8353
+ const unstamped = [];
8302
8354
  let scan = getFirstChild(el);
8303
8355
  while (scan !== null) {
8304
8356
  const rangeEnd = scan.$$portalEnd;
@@ -8307,22 +8359,25 @@ function reconcileDeoptChildren(el, children, ownerBlock) {
8307
8359
  scan = nodeAfterPortalRange(scan, rangeEnd);
8308
8360
  continue;
8309
8361
  }
8362
+ const stampedKey = scan.$$deoptKey;
8363
+ const descriptor = stampedKey === void 0 ? getDeoptDesc(scan) : void 0;
8364
+ if (stampedKey === void 0 && descriptor === void 0 && !(hydrationOwnsUnstamped ??= activeHydration() !== null)) {
8365
+ hasForeign = true;
8366
+ scan = getNextSibling(scan);
8367
+ continue;
8368
+ }
8310
8369
  owned.push(scan);
8311
- scan = getNextSibling(scan);
8312
- }
8313
- let byKey = null;
8314
- const unstamped = [];
8315
- for (let i = 0; i < owned.length; i++) {
8316
- const n = owned[i];
8317
- const k = n.$$deoptKey ?? getDeoptDesc(n)?.key;
8318
- if (k != null) {
8370
+ const key = stampedKey ?? descriptor?.key;
8371
+ if (key != null) {
8319
8372
  if (byKey === null) byKey = /* @__PURE__ */ new Map();
8320
- if (!byKey.has(k)) {
8321
- byKey.set(k, n);
8373
+ if (!byKey.has(key)) {
8374
+ byKey.set(key, scan);
8375
+ scan = getNextSibling(scan);
8322
8376
  continue;
8323
8377
  }
8324
8378
  }
8325
- unstamped.push(n);
8379
+ unstamped.push(scan);
8380
+ scan = getNextSibling(scan);
8326
8381
  }
8327
8382
  let up = 0;
8328
8383
  const result = [];
@@ -8351,7 +8406,7 @@ function reconcileDeoptChildren(el, children, ownerBlock) {
8351
8406
  }
8352
8407
  for (let i = 0; i < result.length; i++) {
8353
8408
  const want = result[i];
8354
- const at = hasForeign ? liveOwnedChildAt(el, i) : existing[i] ?? null;
8409
+ const at = hasForeign ? liveOwnedChildAt(el, i, hydrationOwnsUnstamped === true) : existing[i] ?? null;
8355
8410
  if (at !== want) el.insertBefore(want, at);
8356
8411
  }
8357
8412
  }
@@ -8360,7 +8415,7 @@ function nodeAfterPortalRange(start, end) {
8360
8415
  while (m !== null && m !== end) m = getNextSibling(m);
8361
8416
  return getNextSibling(m ?? start);
8362
8417
  }
8363
- function liveOwnedChildAt(el, index) {
8418
+ function liveOwnedChildAt(el, index, adoptHydrationChildren = false) {
8364
8419
  let i = 0;
8365
8420
  let scan = getFirstChild(el);
8366
8421
  while (scan !== null) {
@@ -8369,6 +8424,10 @@ function liveOwnedChildAt(el, index) {
8369
8424
  scan = nodeAfterPortalRange(scan, rangeEnd);
8370
8425
  continue;
8371
8426
  }
8427
+ if (!adoptHydrationChildren && scan.$$deoptKey === void 0 && getDeoptDesc(scan) === void 0) {
8428
+ scan = getNextSibling(scan);
8429
+ continue;
8430
+ }
8372
8431
  if (i === index) return scan;
8373
8432
  i++;
8374
8433
  scan = getNextSibling(scan);
@@ -10417,7 +10476,7 @@ function useTransition(slot) {
10417
10476
  }
10418
10477
  };
10419
10478
  TRANSITION_LISTENERS.add(listener);
10420
- scope.cleanups.push(() => TRANSITION_LISTENERS.delete(listener));
10479
+ (scope.cleanups ??= []).push(() => TRANSITION_LISTENERS.delete(listener));
10421
10480
  }
10422
10481
  return [s.isPending, s.start];
10423
10482
  }
@@ -10519,7 +10578,7 @@ function useFormStatus(slot) {
10519
10578
  s = { form: null, listener: null };
10520
10579
  const slotRef = s;
10521
10580
  ensureHooks(scope).set(slot, slotRef);
10522
- scope.cleanups.push(() => {
10581
+ (scope.cleanups ??= []).push(() => {
10523
10582
  if (slotRef.form && slotRef.listener)
10524
10583
  FORM_STATUS_LISTENERS.get(slotRef.form)?.delete(slotRef.listener);
10525
10584
  });
@@ -10594,7 +10653,7 @@ function useOptimistic(passthrough, updateFnOrSlot, slot) {
10594
10653
  if (TRANSITION_PENDING_COUNT === 0 && slotRef.armed) clear();
10595
10654
  };
10596
10655
  TRANSITION_LISTENERS.add(listener);
10597
- scope.cleanups.push(() => TRANSITION_LISTENERS.delete(listener));
10656
+ (scope.cleanups ??= []).push(() => TRANSITION_LISTENERS.delete(listener));
10598
10657
  }
10599
10658
  s.updateFn = updateFn;
10600
10659
  let optimistic = passthrough;
@@ -11214,7 +11273,7 @@ function activityBlock(parentScope, slotKey, domParent, mode, body, anchor, env)
11214
11273
  }
11215
11274
  function forEachSubtreeChild(scope, visit, includeHiddenTry = true) {
11216
11275
  const children = scope.children;
11217
- for (let i = 0, n = children.length; i < n; i++) visit(children[i].scope);
11276
+ if (children !== null) for (let i = 0, n = children.length; i < n; i++) visit(children[i].scope);
11218
11277
  const slots = scope._slots;
11219
11278
  if (slots !== null) {
11220
11279
  for (let i = 0, n = slots.length; i < n; i++) {
@@ -11938,7 +11997,7 @@ function batchClearItems(state, oldItems) {
11938
11997
  range.deleteContents();
11939
11998
  }
11940
11999
  for (let b = state.head; b !== null; b = b.nextSibling) {
11941
- if (b.cleanups.length > 0 || b.children.length > 0 || b._slots !== null) {
12000
+ if (b.cleanups !== null || b.children !== null || b._slots !== null) {
11942
12001
  unmountBlock(b, false);
11943
12002
  } else {
11944
12003
  if (b.deoptNode !== null) detachDeoptTreeRefs(b.deoptNode, null);
@@ -12286,7 +12345,7 @@ function coalesceHydratedRanges(rootBlock, liteRanges) {
12286
12345
  if (group !== void 0) return group;
12287
12346
  }
12288
12347
  const registered = scope._slots;
12289
- if (registered !== null && registered.length === 1 && scope.children.length === 0 && registered[0].__kind === "childSlot" && registered[0].compactable && mayBorrowCandidate(registered[0])) {
12348
+ if (registered !== null && registered.length === 1 && scope.children === null && registered[0].__kind === "childSlot" && registered[0].compactable && mayBorrowCandidate(registered[0])) {
12290
12349
  return ownerGroups.get(registered[0]) ?? null;
12291
12350
  }
12292
12351
  return null;
@@ -12363,7 +12422,8 @@ function coalesceHydratedRanges(rootBlock, liteRanges) {
12363
12422
  }
12364
12423
  function visitScopeContents(scope) {
12365
12424
  const children = scope.children;
12366
- for (let i = 0; i < children.length; i++) visitNestedScope(children[i].scope);
12425
+ if (children !== null)
12426
+ for (let i = 0; i < children.length; i++) visitNestedScope(children[i].scope);
12367
12427
  const registered = scope._slots;
12368
12428
  if (registered === null) return;
12369
12429
  for (let i = 0; i < registered.length; i++) visitSlot(registered[i]);
@@ -137,6 +137,13 @@ export declare function ssrActivity(mode: string, render: () => string): string;
137
137
  * hydration recover server/client list-shape mismatches without extra nodes.
138
138
  */
139
139
  export declare function ssrForBlock(content: string, hasItems: boolean): string;
140
+ /**
141
+ * @internal Exported for direct testing: a conflating or wrong-width encoding is
142
+ * invisible through `prerender`, because occurrence tracking assigns list items
143
+ * distinct scopes independently of the key bytes. Not re-exported from
144
+ * `octane/server`.
145
+ */
146
+ export declare function encodeAsyncIdentityString(value: string): string;
140
147
  /** Compiler-emitted identity membrane for one @if/@switch/@for instance. */
141
148
  export declare function ssrControl<T>(siteKey: string, fn: () => T): T;
142
149
  /** Compiler-emitted identity membrane for one arm/item inside ssrControl. */
@@ -466,7 +473,12 @@ export declare function namespaceHeadElement(headKey: string, tag: string, attrs
466
473
  * - `html` — the rendered markup. Hoisted document metadata (`<title>`/`<meta>`/
467
474
  * `<link>`, collected via `ssrHeadEl`) is folded IN: spliced before `</head>`
468
475
  * when the render produced a document, otherwise prepended. (React folds head
469
- * resources into the document too, so there is no separate `head` channel.)
476
+ * resources into the document too, which is why folding is the default.)
477
+ * - `head`, the hoisted metadata on its own, present ONLY under
478
+ * `headChannel: 'separate'`; `html` then excludes it. For hosts that render
479
+ * into a `<head>`-bearing template they own rather than rendering the
480
+ * document, where the fold has no `</head>` to target and would otherwise
481
+ * prepend metadata into the body. See `RenderOptions.headChannel`.
470
482
  * - `css` — the scoped stylesheets of the components that rendered, as
471
483
  * ready-to-place `<style data-octane="hash">…</style>` tags (one per hash,
472
484
  * deduped). Kept as its own field because octane has scoped CSS that React core
@@ -478,6 +490,7 @@ export declare function namespaceHeadElement(headKey: string, tag: string, attrs
478
490
  export interface RenderResult {
479
491
  html: string;
480
492
  css: string;
493
+ head?: string;
481
494
  }
482
495
  /** Options accepted by the buffered render entry points (React-shaped subset). */
483
496
  export interface RenderOptions {
@@ -502,6 +515,22 @@ export interface RenderOptions {
502
515
  * renders only (`prerender`).
503
516
  */
504
517
  timeoutMs?: number;
518
+ /**
519
+ * Where hoisted `<title>`/`<meta>`/`<link>` go.
520
+ *
521
+ * `'fold'` (default) keeps React's resource-hoisting shape: the metadata is
522
+ * spliced into `html` before `</head>`, or prepended when the render is not a
523
+ * document. `'separate'` withholds it from `html`/the streamed shell and hands
524
+ * it over on its own, `RenderResult.head` for the buffered renderers,
525
+ * `StreamOptions.onHeadReady` for the streaming ones.
526
+ *
527
+ * A host that renders into a `<head>`-bearing template it owns (rather than
528
+ * rendering the document itself) needs `'separate'`: the fold has no
529
+ * `</head>` to find in a body-only render, so it would prepend the metadata
530
+ * into the body, where a `<title>` loses to the template's and a canonical or
531
+ * description is ignored outright.
532
+ */
533
+ headChannel?: 'fold' | 'separate';
505
534
  }
506
535
  export declare function setSsrSuspenseTimeout(ms: number): void;
507
536
  export declare function getSsrSuspenseTimeout(): number;
@@ -668,6 +697,19 @@ export interface StreamOptions extends RenderOptions {
668
697
  onShellReady?: () => void;
669
698
  onShellError?: (err: unknown) => void;
670
699
  onAllReady?: () => void;
700
+ /**
701
+ * Receives the shell's hoisted `<title>`/`<meta>`/`<link>` under
702
+ * `headChannel: 'separate'`, called once BEFORE the shell is written and
703
+ * therefore before `onShellReady` and before `renderToReadableStream`'s
704
+ * promise resolves, so a host still has time to place the metadata in the
705
+ * template prefix it writes ahead of the render stream. Never called under
706
+ * the default `'fold'`, where the metadata rides the shell.
707
+ *
708
+ * Only the shell's metadata: head elements hoisted from inside a Suspense
709
+ * boundary that streams later are re-created client-side on hydration (see
710
+ * docs/ssr.md).
711
+ */
712
+ onHeadReady?: (head: string) => void;
671
713
  /** Merge externally-produced HTML into the stream (see StreamInjectionSource). */
672
714
  injection?: StreamInjectionSource;
673
715
  }
@@ -756,10 +756,15 @@ function ssrActivity(mode, render) {
756
756
  function ssrForBlock(content, hasItems) {
757
757
  return MARKERS ? (hasItems ? FOR_BLOCK_OPEN_ITEMS : FOR_BLOCK_OPEN_EMPTY) + content + BLOCK_CLOSE : content;
758
758
  }
759
+ const ASCII_ASYNC_IDENTITY_UNITS = [];
760
+ for (let code = 0; code < 128; code++) {
761
+ ASCII_ASYNC_IDENTITY_UNITS.push(code.toString(16).padStart(4, "0"));
762
+ }
759
763
  function encodeAsyncIdentityString(value) {
760
764
  let encoded = "";
761
765
  for (let i = 0; i < value.length; i++) {
762
- encoded += value.charCodeAt(i).toString(16).padStart(4, "0");
766
+ const code = value.charCodeAt(i);
767
+ encoded += code < 128 ? ASCII_ASYNC_IDENTITY_UNITS[code] : code.toString(16).padStart(4, "0");
763
768
  }
764
769
  return encoded;
765
770
  }
@@ -3035,16 +3040,27 @@ async function runBuffered(component, props, options, nonceAttr) {
3035
3040
  if (resolved.pu.touched !== void 0) resolved.pu.touched = /* @__PURE__ */ new Set();
3036
3041
  }
3037
3042
  }
3038
- function passToResult(pass, nonceAttr) {
3043
+ function passToResult(pass, nonceAttr, separateHead = false) {
3039
3044
  let body = pass.body;
3040
3045
  if (pass.serial.length > 0) body += serializeSuspenseSeeds(pass.serial, nonceAttr);
3046
+ if (separateHead) {
3047
+ return {
3048
+ html: pass.vtCandidates ? vtSsrStrip(body) : body,
3049
+ css: pass.css,
3050
+ head: pass.vtCandidates ? vtSsrStrip(pass.head) : pass.head
3051
+ };
3052
+ }
3041
3053
  const html = spliceHead(body, pass.head);
3042
3054
  return { html: pass.vtCandidates ? vtSsrStrip(html) : html, css: pass.css };
3043
3055
  }
3044
3056
  async function prerender(entryComponent, props, options) {
3045
3057
  const component = entryComponent;
3046
3058
  const nonceAttr = nonceAttrOf(options);
3047
- return passToResult(await runBuffered(component, props, options, nonceAttr), nonceAttr);
3059
+ return passToResult(
3060
+ await runBuffered(component, props, options, nonceAttr),
3061
+ nonceAttr,
3062
+ options?.headChannel === "separate"
3063
+ );
3048
3064
  }
3049
3065
  let HOSTED_FOREIGN_CONTEXT_READER = null;
3050
3066
  function readHostedForeignContext(usable, api) {
@@ -3122,7 +3138,7 @@ function renderToString(entryComponent, props, options) {
3122
3138
  options?.onError?.(err);
3123
3139
  throw err;
3124
3140
  }
3125
- return passToResult(pass, nonceAttr);
3141
+ return passToResult(pass, nonceAttr, options?.headChannel === "separate");
3126
3142
  }
3127
3143
  function renderToStaticMarkup(entryComponent, props, options) {
3128
3144
  const component = entryComponent;
@@ -3146,6 +3162,13 @@ function renderToStaticMarkup(entryComponent, props, options) {
3146
3162
  options?.onError?.(err);
3147
3163
  throw err;
3148
3164
  }
3165
+ if (options?.headChannel === "separate") {
3166
+ return {
3167
+ html: pass.vtCandidates ? vtSsrStrip(pass.body) : pass.body,
3168
+ css: pass.css,
3169
+ head: pass.vtCandidates ? vtSsrStrip(pass.head) : pass.head
3170
+ };
3171
+ }
3149
3172
  const html = spliceHead(pass.body, pass.head);
3150
3173
  return { html: pass.vtCandidates ? vtSsrStrip(html) : html, css: pass.css };
3151
3174
  }
@@ -3685,6 +3708,9 @@ async function runStream(component, props, options, sink) {
3685
3708
  emittedCss.add(hash);
3686
3709
  leadingStyles += '<style data-octane="' + hash + '"' + nonceAttr + ">" + escapeEntireInlineStyleContent(sheet) + "</style>";
3687
3710
  }
3711
+ const separateHead = options?.headChannel === "separate";
3712
+ if (separateHead) options?.onHeadReady?.(pass.vtCandidates ? vtSsrStrip(pass.head) : pass.head);
3713
+ const shellHead = separateHead ? "" : pass.head;
3688
3714
  const documentRoot = isDocumentRoot(pass.body);
3689
3715
  let shell = documentRoot ? "<!DOCTYPE html>" : "";
3690
3716
  let heldDocumentTail = "";
@@ -3694,12 +3720,12 @@ async function runStream(component, props, options, sink) {
3694
3720
  heldDocumentTail = pass.body.slice(tailStart);
3695
3721
  const bodyHtml = pass.body.slice(0, tailStart);
3696
3722
  const headInsert = documentHeadInsertionPoint(bodyHtml);
3697
- shell += headInsert !== -1 ? bodyHtml.slice(0, headInsert) + leadingStyles + pass.head + bodyHtml.slice(headInsert) : leadingStyles + pass.head + bodyHtml;
3723
+ shell += headInsert !== -1 ? bodyHtml.slice(0, headInsert) + leadingStyles + shellHead + bodyHtml.slice(headInsert) : leadingStyles + shellHead + bodyHtml;
3698
3724
  } else {
3699
- shell += leadingStyles + pass.head + pass.body;
3725
+ shell += leadingStyles + shellHead + pass.body;
3700
3726
  }
3701
3727
  } else {
3702
- shell += leadingStyles + pass.head + pass.body;
3728
+ shell += leadingStyles + shellHead + pass.body;
3703
3729
  }
3704
3730
  if (pass.serial.length > 0) shell += serializeSuspenseSeeds(pass.serial, nonceAttr);
3705
3731
  const anyPending = stream.boundaries.size > 0;
@@ -4272,6 +4298,7 @@ export {
4272
4298
  createElement,
4273
4299
  createHostedServerSession,
4274
4300
  createPortal,
4301
+ encodeAsyncIdentityString,
4275
4302
  escapeAttr,
4276
4303
  escapeHtml,
4277
4304
  flushSync,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "octane",
3
- "version": "0.1.16",
3
+ "version": "0.1.17",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "sideEffects": false,