octane 0.1.20 → 0.1.22

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.
@@ -3936,6 +3936,78 @@ function containsAutoMemoUnsafeStructure(stmts) {
3936
3936
  return found;
3937
3937
  }
3938
3938
 
3939
+ /**
3940
+ * Bundling MOVES an argument's evaluation out of the arrow body and into the
3941
+ * component body, so it now runs on mount and on every update instead of once
3942
+ * per event. That rewrite is only sound for an expression that is
3943
+ * side-effect-free and O(1)-ish to evaluate: `() => setData(makeData(1000))`
3944
+ * otherwise rebuilds the whole dataset on every unrelated render, and the user
3945
+ * never sees a call they can attribute to the click that did not happen.
3946
+ *
3947
+ * The whitelist is deliberately narrower than "pure", for two reasons.
3948
+ *
3949
+ * A fresh identity per evaluation defeats the runtime arg diff the bundle
3950
+ * exists for — an `ArrayExpression`/`ObjectExpression`/arrow arg can never
3951
+ * compare equal, so it would pay a per-render allocation to skip nothing.
3952
+ * Regex is rejected for the same reason `isInvariantLiteral` rejects it.
3953
+ *
3954
+ * The line is drawn at VALUE STABILITY, not at "provably pure". An accepted
3955
+ * expression must yield at render time what it would have yielded at click
3956
+ * time, and must not do unbounded or author-visible work to get there. A
3957
+ * property read can reach a getter and `a + b` can reach `valueOf` — but that
3958
+ * is the standing premise of the optimization, not a new risk: `select(row.id)`
3959
+ * is the shape it was built for, and refusing property reads would leave it
3960
+ * with nothing to optimize. Refusing arithmetic while accepting `row.id` would
3961
+ * draw the same line in two places, so both stay.
3962
+ *
3963
+ * What cannot stay are the expressions that break value stability outright:
3964
+ * a CALL does unbounded work and can be observed happening (`makeData(1000)`
3965
+ * rebuilt a whole dataset per render), a fresh array/object/regex allocates an
3966
+ * identity that can never compare equal, and an assignment or `++` mutates.
3967
+ *
3968
+ * `.current` is rejected because a ref genuinely returns the WRONG VALUE here,
3969
+ * not merely an early one: `queueRefAttach` runs AFTER the mount that reads it,
3970
+ * so a hoisted `ref.current` hands the first click the `null` it held before
3971
+ * the ref was attached. Computed members fail closed for the same reason —
3972
+ * `ref[key]` can spell `current` without saying so, and
3973
+ * `isAutoMemoCalculationDependency` already refuses every computed key.
3974
+ */
3975
+ function isDeferralSafeBundleArg(node) {
3976
+ const value = unwrapTsExpr(node);
3977
+ if (!value) return false;
3978
+ switch (value.type) {
3979
+ case 'Literal':
3980
+ return isInvariantLiteral(value);
3981
+ case 'Identifier':
3982
+ return true;
3983
+ case 'ChainExpression':
3984
+ return isDeferralSafeBundleArg(value.expression);
3985
+ case 'MemberExpression':
3986
+ // Computed keys fail closed: the key is only known at runtime, so
3987
+ // `ref[k]` can reach `.current` without naming it.
3988
+ if (value.computed) return false;
3989
+ if (value.property?.name === 'current') return false;
3990
+ return isDeferralSafeBundleArg(value.object);
3991
+ case 'TemplateLiteral':
3992
+ // A fresh string still compares by VALUE, so the arg diff works.
3993
+ return (value.expressions || []).every(isDeferralSafeBundleArg);
3994
+ case 'UnaryExpression':
3995
+ // `delete` mutates; the rest only read their operand.
3996
+ return value.operator !== 'delete' && isDeferralSafeBundleArg(value.argument);
3997
+ case 'BinaryExpression':
3998
+ case 'LogicalExpression':
3999
+ return isDeferralSafeBundleArg(value.left) && isDeferralSafeBundleArg(value.right);
4000
+ case 'ConditionalExpression':
4001
+ return (
4002
+ isDeferralSafeBundleArg(value.test) &&
4003
+ isDeferralSafeBundleArg(value.consequent) &&
4004
+ isDeferralSafeBundleArg(value.alternate)
4005
+ );
4006
+ default:
4007
+ return false;
4008
+ }
4009
+ }
4010
+
3939
4011
  /**
3940
4012
  * `() => fn(a, b, …)` — a zero-param arrow whose body is a single
3941
4013
  * function call. Returns `{ callee, args }` if so, else null. Used to compile
@@ -3967,8 +4039,9 @@ function detectStableEventBundle(node) {
3967
4039
  if (!body || body.type !== 'CallExpression') return null;
3968
4040
  // Identifier callees only — see the receiver-loss note above.
3969
4041
  if (!body.callee || body.callee.type !== 'Identifier') return null;
3970
- // Bail if any arg is a spread — bundle args are positional only.
3971
- if (body.arguments.some((a) => a.type === 'SpreadElement')) return null;
4042
+ // Bail if any arg is a spread — bundle args are positional only. Everything
4043
+ // else has to survive being hoisted to render time; see the note above.
4044
+ if (!body.arguments.every(isDeferralSafeBundleArg)) return null;
3972
4045
  return { callee: body.callee, args: body.arguments };
3973
4046
  }
3974
4047
 
@@ -3995,11 +4068,22 @@ function isConditionalJsx(node) {
3995
4068
  );
3996
4069
  }
3997
4070
 
3998
- /** Wrap an expression as a BlockStatement body, so makeIfCall can consume it. */
4071
+ /** Wrap a ternary arm as a BlockStatement body, so makeIfCall can consume it. */
3999
4072
  function wrapAsBlockStmt(node) {
4000
4073
  if (!node) return null;
4001
4074
  // null / Literal(null) / Literal(false) → no branch
4002
4075
  if (node.type === 'Literal' && (node.value === null || node.value === false)) return null;
4076
+ if (!isJsxLike(node)) {
4077
+ // A non-JSX arm (`{cond ? xs.map(…) : <Jsx/>}`, a string, a variable
4078
+ // holding an element, a nested ternary) is the branch's render VALUE. As
4079
+ // a bare statement the branch body would evaluate-and-discard it, so wrap
4080
+ // it as the authored-equivalent `<>{expr}</>` — the branch renders the
4081
+ // value through the fragment's child hole. The hole sits at the
4082
+ // fragment's ROOT, a value position on both compilers: a nested ternary
4083
+ // stays a value hole here (it is NOT re-claimed into a nested ifBlock),
4084
+ // which is why the server claim requires host-child position too.
4085
+ node = inheritOriginLoc(b.jsx_fragment([b.jsx_expression_container(node, node)]), node);
4086
+ }
4003
4087
  return b.block([node]);
4004
4088
  }
4005
4089
 
@@ -6062,6 +6146,7 @@ function compileInternal(source, filename, options, analyzedAst, mode, bundlerMe
6062
6146
  nextHookMemoCacheId: 0, // unique non-index slots property per compiled render function
6063
6147
  currentInvariantLocals: null, // Set<string> of component-lifetime-stable local values
6064
6148
  currentEventInvariantLocals: null, // Set<string> safe to retain in native event slots
6149
+ currentBodyIsComponentScope: false, // planning the component body itself, not a nested arm
6065
6150
  currentProfileComponentId: null,
6066
6151
  knownStringLocals: null, // Set<string> of provably-string locals (text-hole inference)
6067
6152
  nextHookSymId: 0,
@@ -7482,7 +7567,15 @@ function ssrCompileBodyWithMapTemps(
7482
7567
  ((!!(node.body && node.body.type === 'JSXCodeBlock') && !returnedOutput) ||
7483
7568
  returnedFragmentRoot) &&
7484
7569
  inheritSoleCompRoot(bodyNodes, ctx);
7570
+ // Body/sub ROOTS (including fragment-root children) are not host-element
7571
+ // children: the client routes rich holes there through the de-opt value
7572
+ // path (a portal has no host to stamp — see the emitNodeHtml root branch),
7573
+ // so the ternary claim must stay off until an element's own children walk
7574
+ // (ssrEmitElement) turns the flag on.
7575
+ const prevHostChildPos = ctx._ssrHostChildPos;
7576
+ ctx._ssrHostChildPos = false;
7485
7577
  const htmlExpr = ssrEmitNodes(bodyNodes, ctx, name, inlinedSubs, parentNs, cssHash, componentNs);
7578
+ ctx._ssrHostChildPos = prevHostChildPos;
7486
7579
  ctx._ssrInheritRoot = prevInheritRoot;
7487
7580
  ctx._returnedFragmentTemplate = prevReturnedFragmentTemplate;
7488
7581
  ctx._tsxValuePos = prevValuePos;
@@ -8587,6 +8680,11 @@ function ssrEmitElement(node, ctx, name, inlinedSubs, parentNs, cssHash, compone
8587
8680
  // pre/textarea/listing: the parser eats a '\n' right after the opening tag —
8588
8681
  // the first text part must protect a leading newline (see ssrEmitNodes).
8589
8682
  const nlGuardFirst = tag === 'pre' || tag === 'textarea' || tag === 'listing';
8683
+ // These children sit directly under a host element — the one position
8684
+ // where the client claims `{cond ? A : B}` holes (emitElementHtml). Root
8685
+ // positions reset this in ssrCompileBody.
8686
+ const prevHostChildPos = ctx._ssrHostChildPos;
8687
+ ctx._ssrHostChildPos = true;
8590
8688
  childrenExpr = ssrEmitNodes(
8591
8689
  normChildren,
8592
8690
  ctx,
@@ -8597,6 +8695,7 @@ function ssrEmitElement(node, ctx, name, inlinedSubs, parentNs, cssHash, compone
8597
8695
  childComponentNs,
8598
8696
  nlGuardFirst,
8599
8697
  );
8698
+ ctx._ssrHostChildPos = prevHostChildPos;
8600
8699
  }
8601
8700
  // `children=` and spread-held children are content props, not attributes.
8602
8701
  // With no nested JSX children, the last present writer renders as the host's
@@ -8925,18 +9024,35 @@ function ssrCompileSub(
8925
9024
  ) {
8926
9025
  const fnName = `${baseName}$${ctx.nextHelperId++}`;
8927
9026
  const synth = { params: paramNodes || [], body: bodyStmts };
8928
- const fn = ssrCompileBody(
8929
- synth,
8930
- ctx,
8931
- fnName,
8932
- cssHash,
8933
- [],
8934
- parentNs || 'html',
8935
- false,
8936
- componentNs,
8937
- returnedFragmentTemplate,
8938
- returnedFragmentRoot,
8939
- );
9027
+ // Returned-tree mirror subs (`__sfragment`): extractFragment folds EVERY
9028
+ // expression hole in the mirrored tree into a descriptor value hole on the
9029
+ // client (`props.hN` + childTextHole), even holes sitting inside host
9030
+ // elements. Mark them so ssrEmitTsrxExpression keeps `{cond ? A : B}` on
9031
+ // ssrChild throughout the mirror, matching that fold. Directive-arm subs
9032
+ // reset the flag through this same save/restore, so an @if INSIDE a mirror
9033
+ // claims again, exactly like the client. (Component `__schildren` subs are
9034
+ // NOT folded wholesale — only their ROOT holes are the children-as-props
9035
+ // value, which `_ssrHostChildPos` already leaves unclaimed — while a hole
9036
+ // inside a host element within children claims like any template child.)
9037
+ const prevFoldedExprHoles = ctx._ssrFoldedExprHoles;
9038
+ ctx._ssrFoldedExprHoles = baseName === '__sfragment';
9039
+ let fn;
9040
+ try {
9041
+ fn = ssrCompileBody(
9042
+ synth,
9043
+ ctx,
9044
+ fnName,
9045
+ cssHash,
9046
+ [],
9047
+ parentNs || 'html',
9048
+ false,
9049
+ componentNs,
9050
+ returnedFragmentTemplate,
9051
+ returnedFragmentRoot,
9052
+ );
9053
+ } finally {
9054
+ ctx._ssrFoldedExprHoles = prevFoldedExprHoles;
9055
+ }
8940
9056
  return { fnName, fn };
8941
9057
  }
8942
9058
 
@@ -8944,10 +9060,19 @@ function ssrEmitIf(node, ctx, name, inlinedSubs, parentNs, cssHash, componentNs)
8944
9060
  // rewriteHookCalls: key any `use(thenable)` in the @if test (it bypasses the
8945
9061
  // setup rewrite, so without a stable key it collides with sibling/body use()).
8946
9062
  const testExpr = rewriteHookCalls(node.test, ctx, name);
8947
- const thenStmts =
8948
- node.consequent.type === 'BlockStatement' ? node.consequent.body : [node.consequent];
8949
- const thenSub = ssrCompileSub(thenStmts, ctx, '__sif', [], cssHash, parentNs, componentNs);
8950
- inlinedSubs.push(thenSub.fn);
9063
+ // A null consequent (`{cond ? null : <Jsx/>}` lowered by wrapAsBlockStmt)
9064
+ // means "no then branch". It must emit like a MISSING @else — plain '' with
9065
+ // no inner arm range — because the hydrating client renders that branch with
9066
+ // a null body and adopts an empty slot range.
9067
+ const thenStmts = node.consequent
9068
+ ? node.consequent.type === 'BlockStatement'
9069
+ ? node.consequent.body
9070
+ : [node.consequent]
9071
+ : null;
9072
+ const thenSub = thenStmts
9073
+ ? ssrCompileSub(thenStmts, ctx, '__sif', [], cssHash, parentNs, componentNs)
9074
+ : null;
9075
+ if (thenSub) inlinedSubs.push(thenSub.fn);
8951
9076
  let elseCall = ssrHtmlTemplate([], node, ctx);
8952
9077
  let elseFnName = null;
8953
9078
  if (node.alternate) {
@@ -8968,26 +9093,33 @@ function ssrEmitIf(node, ctx, name, inlinedSubs, parentNs, cssHash, componentNs)
8968
9093
  ctx.runtimeNeeded.add('ssrBlock');
8969
9094
  ctx.runtimeNeeded.add('ssrControl');
8970
9095
  ctx.runtimeNeeded.add('ssrArm');
8971
- registerDirectiveOrigin(ctx, node, ['_$ssrControl', '_$ssrArm', thenSub.fnName, elseFnName]);
9096
+ registerDirectiveOrigin(ctx, node, [
9097
+ '_$ssrControl',
9098
+ '_$ssrArm',
9099
+ thenSub ? thenSub.fnName : null,
9100
+ elseFnName,
9101
+ ]);
8972
9102
  // Nested ranges: the OUTER ssrBlock is the if-slot; the INNER one wraps the
8973
9103
  // taken branch's content. The client adopts BOTH on hydration (slot = outer,
8974
9104
  // branch = inner) so no comment markers are inserted — byte-for-byte, exactly
8975
9105
  // like @for. The not-taken arm emits no inner range (just `''`).
8976
- const thenInner = ssrCall(
8977
- 'ssrArm',
8978
- [
8979
- b.literal('then', '"then"'),
8980
- ssrThunk(
8981
- ssrCall(
8982
- 'ssrBlock',
8983
- [ssrSubCall(thenSub.fnName, [b.id('undefined')], node.consequent)],
8984
- node.consequent,
8985
- ),
9106
+ const thenInner = thenSub
9107
+ ? ssrCall(
9108
+ 'ssrArm',
9109
+ [
9110
+ b.literal('then', '"then"'),
9111
+ ssrThunk(
9112
+ ssrCall(
9113
+ 'ssrBlock',
9114
+ [ssrSubCall(thenSub.fnName, [b.id('undefined')], node.consequent)],
9115
+ node.consequent,
9116
+ ),
9117
+ node.consequent,
9118
+ ),
9119
+ ],
8986
9120
  node.consequent,
8987
- ),
8988
- ],
8989
- node.consequent,
8990
- );
9121
+ )
9122
+ : ssrHtmlTemplate([], node, ctx);
8991
9123
  const elseInner = node.alternate
8992
9124
  ? ssrCall(
8993
9125
  'ssrArm',
@@ -9441,6 +9573,30 @@ function ssrEmitTsrxExpression(node, ctx, name, inlinedSubs, parentNs, cssHash,
9441
9573
  ctx.runtimeNeeded.add('ssrPortal');
9442
9574
  return ssrCall('ssrPortal', [], node);
9443
9575
  }
9576
+ if (
9577
+ node.returnedJsxValue !== true &&
9578
+ ctx._tsxValuePos !== true &&
9579
+ ctx._ssrFoldedExprHoles !== true &&
9580
+ ctx._ssrHostChildPos === true &&
9581
+ isConditionalJsx(expr)
9582
+ ) {
9583
+ // Mirror the client's claim EXACTLY: only emitElementHtml's children
9584
+ // walk lowers `{cond ? A : B}` with a JSX arm to an @if, so both sides
9585
+ // compile each arm as an ordinary arm body and the hydration shapes
9586
+ // (control key, arm ranges, a keyed `.map` claimed inside a value arm)
9587
+ // agree by construction. Everywhere else the hole is a VALUE on the
9588
+ // client and must stay on ssrChild here: returned `.tsx` trees
9589
+ // (`_tsxValuePos`), returned-tree mirrors whose holes extractFragment
9590
+ // folds to `props.hN` (`_ssrFoldedExprHoles`), and every non-host-child
9591
+ // position — body/arm/fragment roots and component-children roots,
9592
+ // where a rich hole rides the de-opt value path because a portal there
9593
+ // has no host to stamp (`_ssrHostChildPos`).
9594
+ const asIf = {
9595
+ ...b.if(expr.test, wrapAsBlockStmt(expr.consequent), wrapAsBlockStmt(expr.alternate)),
9596
+ loc: expr.loc, // same devLoc/control-key position as the client's claim
9597
+ };
9598
+ return ssrEmitIf(asIf, ctx, name, inlinedSubs, parentNs, cssHash, componentNs);
9599
+ }
9444
9600
  ctx.runtimeNeeded.add('ssrChild');
9445
9601
  // rewriteHookCalls first (key any `use(thenable)` in the hole — it bypasses the
9446
9602
  // setup rewrite), then rewriteJsxValues (lower nested JSX to createElement).
@@ -10381,6 +10537,11 @@ function compileFunctionBody(node, ctx, name, parentNs = 'html', cssHash = null,
10381
10537
  }
10382
10538
  ctx.currentInvariantLocals = invariantLocals;
10383
10539
  ctx.currentEventInvariantLocals = eventInvariantLocals;
10540
+ // Same gate `findMountEventCallbackSinks` uses: the lifetime proof below is
10541
+ // defined relative to the COMPONENT's scope, so it is only sound while
10542
+ // planning that scope's own JSX.
10543
+ const prevBodyIsComponentScope = ctx.currentBodyIsComponentScope;
10544
+ ctx.currentBodyIsComponentScope = options?.autoCallback === true;
10384
10545
  // M3 inherit-range: only a real `@{ … }` (JSXCodeBlock) component body spans
10385
10546
  // its block's whole range — synthetic sub-bodies (@if/@for/@try arms,
10386
10547
  // children render-fns) pass statement arrays and stay unflagged. planJsx
@@ -10414,6 +10575,7 @@ function compileFunctionBody(node, ctx, name, parentNs = 'html', cssHash = null,
10414
10575
  }
10415
10576
  ctx.currentInvariantLocals = prevInvariantLocals;
10416
10577
  ctx.currentEventInvariantLocals = prevEventInvariantLocals;
10578
+ ctx.currentBodyIsComponentScope = prevBodyIsComponentScope;
10417
10579
  ctx._inheritBody = prevInheritBody;
10418
10580
  ctx._fnOrigin = prevFnOrigin;
10419
10581
  ctx._foldedDirectiveCalls = prevFDC;
@@ -10985,6 +11147,49 @@ function isEventHandlerInvariantExpr(node, ctx) {
10985
11147
  );
10986
11148
  }
10987
11149
 
11150
+ /**
11151
+ * An inline `onClick={() => …}` arrow is rebuilt and reassigned to its DOM slot
11152
+ * on every render. When nothing the arrow reads can change, that write is dead
11153
+ * work: the handler can be installed once at mount and left alone, which is
11154
+ * already what a NAMED handler gets through `findMountEventCallbackSinks`.
11155
+ * Recognising the inline form closes the gap between the two spellings.
11156
+ *
11157
+ * "Nothing it reads can change" means every free identifier is either proven
11158
+ * event-invariant for this component (a useState setter, a ref object, a
11159
+ * useEffectEvent wrapper, …) or is not a component local at all — module scope,
11160
+ * an import, or a global, each fixed for the module's lifetime. That second
11161
+ * clause is the same inference `isArrowStableOver` makes.
11162
+ *
11163
+ * Sound only while planning the component body's own JSX.
11164
+ * `collectComponentLocals` deliberately ignores nested blocks, so inside a
11165
+ * `@for` item body the loop variable is absent from the set and would read as
11166
+ * module scope — and a keyed survivor can be handed a different item without
11167
+ * remounting, which would freeze the first item's capture in the slot forever.
11168
+ */
11169
+ function isMountStableInlineHandler(node, ctx) {
11170
+ if (ctx.hmr || ctx.profile || !ctx.currentBodyIsComponentScope) return false;
11171
+ const value = unwrapTsExpr(node);
11172
+ // A FunctionExpression is reachable through its own binding name and carries
11173
+ // its own `this`/`arguments`; only the arrow form is a pure lexical capture.
11174
+ if (value?.type !== 'ArrowFunctionExpression') return false;
11175
+ const locals = ctx.currentComponentLocals;
11176
+ if (!locals) return false;
11177
+ const paramScope = new Set();
11178
+ for (const p of value.params || []) collectBindings(p, paramScope);
11179
+ // Params are walked alongside the body: their names are already bound in
11180
+ // `paramScope`, but a default (`(e, x = n) => …`) is an ordinary expression
11181
+ // that runs per call and can reach a changing local.
11182
+ for (const name of collectFreeIdentifiers([value.body, ...(value.params || [])], paramScope)) {
11183
+ // `arguments` is the render call's own, and a DIRECT `eval` resolves
11184
+ // component locals this walk cannot see — either would tie the installed
11185
+ // closure to whatever the first render happened to hold.
11186
+ if (name === 'arguments' || name === 'eval') return false;
11187
+ if (!locals.has(name)) continue;
11188
+ if (ctx.currentEventInvariantLocals?.has(name) !== true) return false;
11189
+ }
11190
+ return true;
11191
+ }
11192
+
10988
11193
  // Object/array/function literals allocate a new identity on every evaluation,
10989
11194
  // so an identity diff can never skip their update. This currently feeds the
10990
11195
  // class binding path, where dropping the dead previous-value field preserves
@@ -19408,7 +19613,8 @@ function emitElementHtml(
19408
19613
  slotKey,
19409
19614
  ns: hostNs,
19410
19615
  dev: ctx.dev,
19411
- mountOnly: isEventHandlerInvariantExpr(inner, ctx),
19616
+ mountOnly:
19617
+ isEventHandlerInvariantExpr(inner, ctx) || isMountStableInlineHandler(inner, ctx),
19412
19618
  });
19413
19619
  }
19414
19620
  } else if (attrName === 'class') {
@@ -20169,10 +20375,16 @@ function hoistBodyHelper(
20169
20375
  // ===========================================================================
20170
20376
 
20171
20377
  function makeIfCall(node, ctx, inlinedSubs, parentNs = 'html', cssHash = null) {
20172
- // node.test, node.consequent (BlockStatement | Element), node.alternate (BlockStatement | IfStatement | null)
20173
-
20174
- const thenStmts =
20175
- node.consequent.type === 'BlockStatement' ? node.consequent.body : [node.consequent];
20378
+ // node.test, node.consequent (BlockStatement | Element | null), node.alternate (BlockStatement | IfStatement | null)
20379
+ // A null consequent (`{cond ? null : <Jsx/>}` lowered by wrapAsBlockStmt)
20380
+ // means "no then branch" — same contract as the null alternate: the helper
20381
+ // slot compiles to `null` and the runtime renders an empty branch.
20382
+
20383
+ const thenStmts = node.consequent
20384
+ ? node.consequent.type === 'BlockStatement'
20385
+ ? node.consequent.body
20386
+ : [node.consequent]
20387
+ : null;
20176
20388
  const elseStmts = node.alternate
20177
20389
  ? node.alternate.type === 'BlockStatement'
20178
20390
  ? node.alternate.body
@@ -20180,20 +20392,22 @@ function makeIfCall(node, ctx, inlinedSubs, parentNs = 'html', cssHash = null) {
20180
20392
  : null;
20181
20393
  // Phase 2: one shared env tuple for both branches (see unionEnv).
20182
20394
  const envNames = unionEnv(ctx, [
20183
- { stmts: thenStmts, params: [] },
20395
+ thenStmts && { stmts: thenStmts, params: [] },
20184
20396
  elseStmts && { stmts: elseStmts, params: [] },
20185
20397
  ]);
20186
- const thenHelperName = hoistBodyHelper(
20187
- ctx,
20188
- inlinedSubs,
20189
- '__then',
20190
- thenStmts,
20191
- [],
20192
- parentNs,
20193
- cssHash,
20194
- envNames,
20195
- directiveKeywordOrigin(ctx, node),
20196
- );
20398
+ const thenHelperName = thenStmts
20399
+ ? hoistBodyHelper(
20400
+ ctx,
20401
+ inlinedSubs,
20402
+ '__then',
20403
+ thenStmts,
20404
+ [],
20405
+ parentNs,
20406
+ cssHash,
20407
+ envNames,
20408
+ directiveKeywordOrigin(ctx, node),
20409
+ )
20410
+ : null;
20197
20411
 
20198
20412
  let elseHelperName = null;
20199
20413
  if (elseStmts) {
package/dist/runtime.js CHANGED
@@ -315,6 +315,13 @@ function flushTransitionActionBatch(batch) {
315
315
  );
316
316
  scheduleRender(block);
317
317
  }
318
+ if (batch.updates.size > 0) {
319
+ const retained = [];
320
+ for (const update of batch.updates.values()) {
321
+ if (!update.block.disposed) retained.push(update);
322
+ }
323
+ if (retained.length > 0) FLUSHED_TRANSITION_UPDATES.push(retained);
324
+ }
318
325
  batch.updates.clear();
319
326
  if (IN_FLIGHT_TRANSITION_ACTION_BATCH === batch) {
320
327
  IN_FLIGHT_TRANSITION_ACTION_BATCH = null;
@@ -331,6 +338,183 @@ const HELD_TRANSITIONS = /* @__PURE__ */ new Set();
331
338
  const STAGED_REVEALS = /* @__PURE__ */ new Set();
332
339
  let flushingStagedReveals = false;
333
340
  let deferringStagedRevealEffects = false;
341
+ let ACTIVE_TRANSITION_ATTEMPT = null;
342
+ let FLUSHED_TRANSITION_UPDATES = [];
343
+ let HELD_SYNC_TRANSITION = null;
344
+ let PROMOTED_PU_SWAPS = null;
345
+ let PROMOTED_WARM_HARVEST = null;
346
+ function beginTransitionAttempt(block) {
347
+ if (block.pendingMode !== "transition" || ACTIVE_TRANSITION_ATTEMPT !== null) return null;
348
+ TRANSITION_JOURNAL ??= [];
349
+ TRANSITION_JOURNAL_BAGS ??= /* @__PURE__ */ new Set();
350
+ TRANSITION_JOURNAL_DEPTH++;
351
+ const attempt = {
352
+ origin: block,
353
+ journalCheckpoint: TRANSITION_JOURNAL.length,
354
+ effects: [effectQueues[0].length, effectQueues[1].length, effectQueues[2].length],
355
+ effectEvents: effectEventQueue.length,
356
+ effectEventActions: effectEventCommitActions.length,
357
+ stores: storeSyncQueue.length,
358
+ refAttach: refAttachQueue.length,
359
+ refDetach: refDetachQueue.length,
360
+ effectDeps: snapshotSubtreeEffectDeps(block),
361
+ heldSlots: null,
362
+ puSwaps: null
363
+ };
364
+ ACTIVE_TRANSITION_ATTEMPT = attempt;
365
+ return attempt;
366
+ }
367
+ function endTransitionAttempt(attempt) {
368
+ if (attempt === null) return;
369
+ ACTIVE_TRANSITION_ATTEMPT = null;
370
+ const held = attempt.heldSlots;
371
+ if (held === null || held.size === 0) {
372
+ PROMOTED_PU_SWAPS = null;
373
+ PROMOTED_WARM_HARVEST = null;
374
+ }
375
+ let entries = null;
376
+ if (held !== null && held.size > 0) {
377
+ entries = [];
378
+ for (let i = FLUSHED_TRANSITION_UPDATES.length - 1; i >= 0; i--) {
379
+ const group = FLUSHED_TRANSITION_UPDATES[i];
380
+ let single = true;
381
+ for (let k = 0; k < group.length; k++) {
382
+ if (group[k].block !== attempt.origin) {
383
+ single = false;
384
+ break;
385
+ }
386
+ }
387
+ if (!single) continue;
388
+ for (let k = 0; k < group.length; k++) entries.push(group[k]);
389
+ FLUSHED_TRANSITION_UPDATES.splice(i, 1);
390
+ }
391
+ }
392
+ if (held !== null && held.size > 0 && entries !== null && entries.length > 0) {
393
+ rollbackTransitionJournal(attempt.journalCheckpoint);
394
+ for (let phase = 0; phase < 3; phase++) {
395
+ effectQueues[phase].length = attempt.effects[phase];
396
+ }
397
+ effectEventQueue.length = attempt.effectEvents;
398
+ effectEventCommitActions.length = attempt.effectEventActions;
399
+ for (let i = attempt.stores; i < storeSyncQueue.length; i++) {
400
+ storeSyncQueue[i].queued = false;
401
+ }
402
+ storeSyncQueue.length = attempt.stores;
403
+ refAttachQueue.length = attempt.refAttach;
404
+ refDetachQueue.length = attempt.refDetach;
405
+ restoreSubtreeEffectDeps(attempt.origin, attempt.effectDeps);
406
+ const continuing = PROMOTED_PU_SWAPS !== null || PROMOTED_WARM_HARVEST !== null;
407
+ let puSwaps = attempt.puSwaps;
408
+ if (continuing) {
409
+ if (PROMOTED_PU_SWAPS !== null) {
410
+ puSwaps = puSwaps === null ? PROMOTED_PU_SWAPS : PROMOTED_PU_SWAPS.concat(puSwaps);
411
+ PROMOTED_PU_SWAPS = null;
412
+ }
413
+ } else if (puSwaps !== null) {
414
+ for (let i = puSwaps.length - 1; i >= 0; i--) {
415
+ const [hooks, slot, prev] = puSwaps[i];
416
+ if (prev === void 0) hooks.delete(slot);
417
+ else hooks.set(slot, prev);
418
+ }
419
+ }
420
+ for (let i = 0; i < entries.length; i++) {
421
+ entries[i].slot.value = entries[i].baseValue;
422
+ }
423
+ let warmHarvest = PROMOTED_WARM_HARVEST;
424
+ PROMOTED_WARM_HARVEST = null;
425
+ if (warmHarvest !== null) {
426
+ for (let i = 0; i < warmHarvest.length; i++) warmHarvest[i].taken = false;
427
+ }
428
+ const harvest = (scope) => {
429
+ const cache = scope.block.__warmCache;
430
+ if (cache !== void 0) {
431
+ for (const [slot, list] of cache) {
432
+ for (let i = 0; i < list.length; i++) {
433
+ const entry = list[i];
434
+ if (entry.available) {
435
+ (warmHarvest ??= []).push({
436
+ slot,
437
+ deps: entry.deps,
438
+ value: entry.value,
439
+ taken: false
440
+ });
441
+ }
442
+ }
443
+ }
444
+ }
445
+ forEachSubtreeChild(scope, harvest);
446
+ };
447
+ harvest(attempt.origin);
448
+ HELD_SYNC_TRANSITION = {
449
+ origin: attempt.origin,
450
+ entries,
451
+ puSwaps,
452
+ warmHarvest,
453
+ holders: new Set(held)
454
+ };
455
+ if (!continuing) scheduleRender(attempt.origin);
456
+ }
457
+ if (--TRANSITION_JOURNAL_DEPTH === 0) {
458
+ TRANSITION_JOURNAL = null;
459
+ TRANSITION_JOURNAL_BAGS = null;
460
+ flushParkedItems();
461
+ }
462
+ }
463
+ function journalPuEntry(scope, slot, next) {
464
+ const attempt = ACTIVE_TRANSITION_ATTEMPT;
465
+ if (attempt === null) return;
466
+ const hooks = ensureHooks(scope);
467
+ (attempt.puSwaps ??= []).push([hooks, slot, hooks.get(slot), next]);
468
+ }
469
+ function heldSyncCellsIntact(state) {
470
+ const held = HELD_SYNC_TRANSITION;
471
+ if (held === null || !held.holders.has(state)) return false;
472
+ for (let i = 0; i < held.entries.length; i++) {
473
+ const entry = held.entries[i];
474
+ if (!Object.is(entry.slot.value, entry.baseValue)) return false;
475
+ }
476
+ return true;
477
+ }
478
+ function promoteHeldSyncTransition() {
479
+ const held = HELD_SYNC_TRANSITION;
480
+ if (held === null) return false;
481
+ HELD_SYNC_TRANSITION = null;
482
+ const puSwaps = held.puSwaps;
483
+ if (puSwaps !== null) {
484
+ for (let i = 0; i < puSwaps.length; i++) {
485
+ const [hooks, slot, , next] = puSwaps[i];
486
+ hooks.set(slot, next);
487
+ }
488
+ PROMOTED_PU_SWAPS = PROMOTED_PU_SWAPS === null ? puSwaps : PROMOTED_PU_SWAPS.concat(puSwaps);
489
+ }
490
+ PROMOTED_WARM_HARVEST = held.warmHarvest;
491
+ const promoted = [];
492
+ TRANSITION_DEPTH++;
493
+ try {
494
+ for (let i = 0; i < held.entries.length; i++) {
495
+ const entry = held.entries[i];
496
+ if (!Object.is(entry.slot.value, entry.baseValue)) continue;
497
+ entry.slot.value = entry.value;
498
+ if (!entry.block.disposed) {
499
+ promoted.push(entry);
500
+ scheduleRender(entry.block);
501
+ }
502
+ }
503
+ } finally {
504
+ TRANSITION_DEPTH--;
505
+ }
506
+ if (promoted.length > 0) FLUSHED_TRANSITION_UPDATES.push(promoted);
507
+ return true;
508
+ }
509
+ function discardHeldSyncTransition(state) {
510
+ const held = HELD_SYNC_TRANSITION;
511
+ if (held === null || !held.holders.delete(state)) return;
512
+ if (held.holders.size === 0) {
513
+ HELD_SYNC_TRANSITION = null;
514
+ PROMOTED_PU_SWAPS = null;
515
+ PROMOTED_WARM_HARVEST = null;
516
+ }
517
+ }
334
518
  const JOURNAL_TEXT = 0;
335
519
  const JOURNAL_ATTR = 1;
336
520
  const JOURNAL_BAG = 2;
@@ -1007,7 +1191,12 @@ function drainQueue() {
1007
1191
  block.drainStamp = drainId;
1008
1192
  block.drainRenders = 1;
1009
1193
  }
1010
- renderBlock(block);
1194
+ const attempt = beginTransitionAttempt(block);
1195
+ try {
1196
+ renderBlock(block);
1197
+ } finally {
1198
+ endTransitionAttempt(attempt);
1199
+ }
1011
1200
  } catch (err) {
1012
1201
  try {
1013
1202
  handleRenderError(block, err);
@@ -1040,6 +1229,7 @@ function flush() {
1040
1229
  }
1041
1230
  function flushWork() {
1042
1231
  inFlush = true;
1232
+ const clearRetainedTransitionUpdates = FLUSHED_TRANSITION_UPDATES.length > 0;
1043
1233
  const viewTransitionDriver = VIEW_TRANSITION_DRIVER;
1044
1234
  const clearViewTransitionTypes = viewTransitionDriver?.shouldClearTypesAfterFlush() === true;
1045
1235
  try {
@@ -1049,6 +1239,8 @@ function flushWork() {
1049
1239
  if (pendingError !== null) throw pendingError.err;
1050
1240
  } finally {
1051
1241
  inFlush = false;
1242
+ if (clearRetainedTransitionUpdates || FLUSHED_TRANSITION_UPDATES.length > 0)
1243
+ FLUSHED_TRANSITION_UPDATES.length = 0;
1052
1244
  if (typeof __OCTANE_PROFILE_ENABLED__ !== "undefined" && __OCTANE_PROFILE_ENABLED__)
1053
1245
  __devtoolsNotifyFlush();
1054
1246
  if (clearViewTransitionTypes) viewTransitionDriver.clearTypes();
@@ -2766,16 +2958,19 @@ function useMemo(compute, deps, slot) {
2766
2958
  if (WARM_EVER && d !== void 0) {
2767
2959
  const adopted = adoptWarmValue(s, d);
2768
2960
  if (adopted !== WARM_MISS) {
2769
- ensureHooks(scope).set(s, {
2961
+ const adoptedEntry = {
2770
2962
  deps: d,
2771
2963
  value: adopted,
2772
2964
  warmEpisode: CURRENT_WARM_EPISODE
2773
- });
2965
+ };
2966
+ if (ACTIVE_TRANSITION_ATTEMPT !== null) journalPuEntry(scope, s, adoptedEntry);
2967
+ ensureHooks(scope).set(s, adoptedEntry);
2774
2968
  return adopted;
2775
2969
  }
2776
2970
  }
2777
2971
  const value = compute.apply(null, d ?? []);
2778
2972
  const entry = { deps: d, value };
2973
+ if (ACTIVE_TRANSITION_ATTEMPT !== null) journalPuEntry(scope, s, entry);
2779
2974
  ensureHooks(scope).set(s, entry);
2780
2975
  if (d !== void 0 && recordRealWarmMemo(s, d, entry)) {
2781
2976
  entry.warmEpisode = CURRENT_WARM_EPISODE;
@@ -4332,6 +4527,16 @@ function adoptWarmValue(slot, deps) {
4332
4527
  }
4333
4528
  b = b.parentBlock;
4334
4529
  }
4530
+ const harvestList = HELD_SYNC_TRANSITION?.warmHarvest ?? PROMOTED_WARM_HARVEST;
4531
+ if (harvestList !== null && harvestList !== void 0) {
4532
+ for (let i = 0; i < harvestList.length; i++) {
4533
+ const entry = harvestList[i];
4534
+ if (!entry.taken && entry.slot === slot && !depsChanged(entry.deps, deps)) {
4535
+ entry.taken = true;
4536
+ return entry.value;
4537
+ }
4538
+ }
4539
+ }
4335
4540
  return WARM_MISS;
4336
4541
  }
4337
4542
  const puMiss = /* @__PURE__ */ Symbol("octane.pu.miss");
@@ -4344,11 +4549,13 @@ function puHit(slot, entry) {
4344
4549
  function puAdopt(slot, deps) {
4345
4550
  const adopted = adoptWarmValue(slot, deps);
4346
4551
  if (adopted === WARM_MISS) return puMiss;
4347
- ensureHooks(CURRENT_SCOPE).set(slot, {
4552
+ const adoptedEntry = {
4348
4553
  deps,
4349
4554
  value: adopted,
4350
4555
  warmEpisode: CURRENT_WARM_EPISODE
4351
- });
4556
+ };
4557
+ if (ACTIVE_TRANSITION_ATTEMPT !== null) journalPuEntry(CURRENT_SCOPE, slot, adoptedEntry);
4558
+ ensureHooks(CURRENT_SCOPE).set(slot, adoptedEntry);
4352
4559
  return adopted;
4353
4560
  }
4354
4561
  function puTake0(slot) {
@@ -4403,6 +4610,7 @@ function puTake4(slot, d0, d1, d2, d3) {
4403
4610
  }
4404
4611
  function puPub(slot, value, ...deps) {
4405
4612
  const entry = { deps, value };
4613
+ if (ACTIVE_TRANSITION_ATTEMPT !== null) journalPuEntry(CURRENT_SCOPE, slot, entry);
4406
4614
  ensureHooks(CURRENT_SCOPE).set(slot, entry);
4407
4615
  if (recordRealWarmMemo(slot, deps, entry)) entry.warmEpisode = CURRENT_WARM_EPISODE;
4408
4616
  return value;
@@ -7848,7 +8056,8 @@ function createScopedValue(readElement) {
7848
8056
  const resolve = () => {
7849
8057
  const scope = CURRENT_SCOPE;
7850
8058
  const epoch = COMPILER_CACHE_CONTEXT_EPOCH;
7851
- if (resolved === void 0 || resolvedScope !== scope || resolvedEpoch !== epoch) {
8059
+ const sameScope = resolvedScope === scope || resolvedScope !== null && scope !== null && scope.block.parentBlock === resolvedScope.block && scope.$$ctxValues === null;
8060
+ if (resolved === void 0 || !sameScope || resolvedEpoch !== epoch) {
7852
8061
  const next = readElement();
7853
8062
  if (next.key === null && KEYED_ELEMENT_DESCRIPTORS.has(next)) {
7854
8063
  KEYED_ELEMENT_DESCRIPTORS.add(descriptor);
@@ -7856,6 +8065,8 @@ function createScopedValue(readElement) {
7856
8065
  resolvedScope = scope;
7857
8066
  resolvedEpoch = epoch;
7858
8067
  resolved = next;
8068
+ } else if (resolvedScope !== scope) {
8069
+ resolvedScope = scope;
7859
8070
  }
7860
8071
  return resolved;
7861
8072
  };
@@ -7894,7 +8105,7 @@ function createScopedElement(type, props, readChildren) {
7894
8105
  const children = () => {
7895
8106
  const scope = CURRENT_SCOPE;
7896
8107
  const epoch = COMPILER_CACHE_CONTEXT_EPOCH;
7897
- const sameScope = resolvedScope === scope || resolvedScope !== null && scope !== null && scope.block.parentBlock === resolvedScope.block;
8108
+ const sameScope = resolvedScope === scope || resolvedScope !== null && scope !== null && scope.block.parentBlock === resolvedScope.block && scope.$$ctxValues === null;
7898
8109
  if (!resolved || !sameScope || resolvedEpoch !== epoch) {
7899
8110
  const nextChildren = readChildren();
7900
8111
  resolvedScope = scope;
@@ -9721,6 +9932,10 @@ function childSlot(parentScope, slotKey, domParent, value, anchor, ownEnd, ownsH
9721
9932
  if (preparedList !== null) {
9722
9933
  if (state.forSlot === null) {
9723
9934
  if (hydration === null && !upgradeArmed) clearChildContent(state);
9935
+ if (state.end !== null && state.end.parentNode === null) {
9936
+ state.end = null;
9937
+ state.start = null;
9938
+ }
9724
9939
  if (state.end === null) {
9725
9940
  state.end = document.createComment("");
9726
9941
  domParent.insertBefore(state.end, null);
@@ -10563,8 +10778,10 @@ function tryBlock(parentScope, slotKey, domParent, tryBody, catchBody, pendingBo
10563
10778
  const journalCheckpoint = armTransitionJournal(s);
10564
10779
  try {
10565
10780
  renderBlock(s.tryBlock);
10566
- releaseHeldTransition(s);
10567
- s.pendingThenable = null;
10781
+ if (!heldSyncCellsIntact(s)) {
10782
+ releaseHeldTransition(s);
10783
+ s.pendingThenable = null;
10784
+ }
10568
10785
  } catch (err) {
10569
10786
  if (isHostContextRequest(err)) throw err;
10570
10787
  if (isSuspenseException(err)) {
@@ -10790,6 +11007,7 @@ function showTryBlock(state) {
10790
11007
  }
10791
11008
  }
10792
11009
  function releaseHeldTransition(state) {
11010
+ discardHeldSyncTransition(state);
10793
11011
  if (state.transitionHeld) {
10794
11012
  state.transitionHeld = false;
10795
11013
  tickTransitionCount(-1);
@@ -10804,6 +11022,9 @@ function handleSuspense(state, thenable, sourceBlock, journalCheckpoint = -1) {
10804
11022
  const isTransition = sourceBlock.currentRenderMode === "transition";
10805
11023
  if ((isTransition || state.transitionHeld) && state.hasResolved && state.branch === 1 && state.hiddenDom === null) {
10806
11024
  rollbackTransitionJournal(journalCheckpoint);
11025
+ if (ACTIVE_TRANSITION_ATTEMPT !== null) {
11026
+ (ACTIVE_TRANSITION_ATTEMPT.heldSlots ??= /* @__PURE__ */ new Set()).add(state);
11027
+ }
10807
11028
  if (!state.transitionHeld) {
10808
11029
  state.transitionHeld = true;
10809
11030
  tickTransitionCount(1);
@@ -10920,6 +11141,9 @@ function refreshPendingBody(state) {
10920
11141
  }
10921
11142
  function swapToPendingFallback(state) {
10922
11143
  if (!state.pendingBody || state.branch !== 1 || !state.tryBlock) return;
11144
+ if (HELD_SYNC_TRANSITION !== null && HELD_SYNC_TRANSITION.holders.has(state)) {
11145
+ promoteHeldSyncTransition();
11146
+ }
10923
11147
  hideTryContentAndMountPending(state);
10924
11148
  }
10925
11149
  function commitResume(state) {
@@ -11275,6 +11499,20 @@ function rebaseOffscreenCaptureSeq(capture) {
11275
11499
  }
11276
11500
  function flushStagedReveals() {
11277
11501
  if (flushingStagedReveals) return;
11502
+ if (HELD_SYNC_TRANSITION !== null) {
11503
+ let allVisible = true;
11504
+ for (const holder of HELD_SYNC_TRANSITION.holders) {
11505
+ if (holder.hiddenDom !== null) {
11506
+ allVisible = false;
11507
+ break;
11508
+ }
11509
+ }
11510
+ if (allVisible && promoteHeldSyncTransition()) {
11511
+ STAGED_REVEALS.clear();
11512
+ flush();
11513
+ return;
11514
+ }
11515
+ }
11278
11516
  flushingStagedReveals = true;
11279
11517
  try {
11280
11518
  const run = () => {
@@ -11970,10 +12208,31 @@ function renderBranchSlot(parentScope, slotKey, state, domParent, next, body, ma
11970
12208
  let bStart;
11971
12209
  let bEnd;
11972
12210
  let borrowed = false;
12211
+ let rebuild = false;
12212
+ let inner = null;
12213
+ let innerEnd = null;
11973
12214
  if (hydration !== null && hydration.isOpen(state.start.nextSibling)) {
11974
- bStart = state.start.nextSibling;
11975
- bEnd = hydration.close(bStart);
11976
- hydration.node = bStart.nextSibling;
12215
+ inner = state.start.nextSibling;
12216
+ innerEnd = hydration.close(inner);
12217
+ if (innerEnd !== state.end && innerEnd.nextSibling !== state.end) {
12218
+ if (process.env.NODE_ENV !== "production") {
12219
+ const mmLoc = siteLoc(parentScope, slotKey);
12220
+ if (mmLoc)
12221
+ hydration.warnStructural(
12222
+ mmLoc,
12223
+ "a single branch range",
12224
+ hydration.describe(innerEnd.nextSibling)
12225
+ );
12226
+ }
12227
+ removeRange(state.start.nextSibling, state.end);
12228
+ inner = null;
12229
+ rebuild = true;
12230
+ }
12231
+ }
12232
+ if (inner !== null) {
12233
+ bStart = inner;
12234
+ bEnd = innerEnd;
12235
+ hydration.node = inner.nextSibling;
11977
12236
  } else {
11978
12237
  bStart = state.start;
11979
12238
  bEnd = state.end;
@@ -11992,7 +12251,12 @@ function renderBranchSlot(parentScope, slotKey, state, domParent, next, body, ma
11992
12251
  );
11993
12252
  if (borrowed) b.exclusiveMarkers = true;
11994
12253
  state.block = b;
11995
- renderBlock(b);
12254
+ if (rebuild) {
12255
+ hydration.suspend(() => renderBlock(b));
12256
+ hydration.node = state.end.nextSibling;
12257
+ } else {
12258
+ renderBlock(b);
12259
+ }
11996
12260
  } else if (hydration !== null && state.start.nextSibling !== state.end) {
11997
12261
  if (process.env.NODE_ENV !== "production") {
11998
12262
  const mmLoc = siteLoc(parentScope, slotKey);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "octane",
3
- "version": "0.1.20",
3
+ "version": "0.1.22",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "sideEffects": false,
@@ -119,11 +119,14 @@
119
119
  },
120
120
  "devDependencies": {
121
121
  "@tsrx/react": "^0.2.56",
122
+ "@wagmi/connectors": "8.0.25",
123
+ "@wagmi/core": "3.6.4",
122
124
  "esbuild": "^0.28.1",
123
125
  "happy-dom": "^20.11.0",
124
126
  "playwright": "^1.61.1",
125
127
  "react": "^19.2.7",
126
128
  "react-dom": "^19.2.7",
129
+ "viem": "2.55.10",
127
130
  "vite": "^8.1.5",
128
131
  "vitest": "^4.1.10"
129
132
  },