octane 0.1.21 → 0.1.23

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
 
@@ -4331,6 +4404,31 @@ export function hasOwnValueReturn(node) {
4331
4404
  return walk(body.body || []);
4332
4405
  }
4333
4406
 
4407
+ /**
4408
+ * Whether a statement list always completes abruptly, so control can never fall
4409
+ * past its end. Lets an outputless `@{ … }` body drop the tail return it would
4410
+ * otherwise synthesize, because the body's own returns already cover every path.
4411
+ *
4412
+ * Deliberately syntactic: `return`, `throw`, a block that ends abruptly, and an
4413
+ * if/else whose arms both do. Anything subtler keeps the tail, which is always
4414
+ * safe — the runtime reads a fallen-through `undefined` as "this body already
4415
+ * emitted its template", so the tail must stay wherever reachability is unproven.
4416
+ */
4417
+ function alwaysCompletesAbruptly(statements) {
4418
+ const last = statements[statements.length - 1];
4419
+ if (!last) return false;
4420
+ if (last.type === 'ReturnStatement' || last.type === 'ThrowStatement') return true;
4421
+ if (last.type === 'BlockStatement') return alwaysCompletesAbruptly(last.body || []);
4422
+ if (last.type === 'IfStatement') {
4423
+ return (
4424
+ !!last.alternate &&
4425
+ alwaysCompletesAbruptly([last.consequent]) &&
4426
+ alwaysCompletesAbruptly([last.alternate])
4427
+ );
4428
+ }
4429
+ return false;
4430
+ }
4431
+
4334
4432
  /**
4335
4433
  * A mixed shorthand's early return must always reach renderReturnedValue. The
4336
4434
  * runtime reserves `undefined` for a compiled-void body that already emitted its
@@ -6073,6 +6171,7 @@ function compileInternal(source, filename, options, analyzedAst, mode, bundlerMe
6073
6171
  nextHookMemoCacheId: 0, // unique non-index slots property per compiled render function
6074
6172
  currentInvariantLocals: null, // Set<string> of component-lifetime-stable local values
6075
6173
  currentEventInvariantLocals: null, // Set<string> safe to retain in native event slots
6174
+ currentBodyIsComponentScope: false, // planning the component body itself, not a nested arm
6076
6175
  currentProfileComponentId: null,
6077
6176
  knownStringLocals: null, // Set<string> of provably-string locals (text-hole inference)
6078
6177
  nextHookSymId: 0,
@@ -10463,6 +10562,11 @@ function compileFunctionBody(node, ctx, name, parentNs = 'html', cssHash = null,
10463
10562
  }
10464
10563
  ctx.currentInvariantLocals = invariantLocals;
10465
10564
  ctx.currentEventInvariantLocals = eventInvariantLocals;
10565
+ // Same gate `findMountEventCallbackSinks` uses: the lifetime proof below is
10566
+ // defined relative to the COMPONENT's scope, so it is only sound while
10567
+ // planning that scope's own JSX.
10568
+ const prevBodyIsComponentScope = ctx.currentBodyIsComponentScope;
10569
+ ctx.currentBodyIsComponentScope = options?.autoCallback === true;
10466
10570
  // M3 inherit-range: only a real `@{ … }` (JSXCodeBlock) component body spans
10467
10571
  // its block's whole range — synthetic sub-bodies (@if/@for/@try arms,
10468
10572
  // children render-fns) pass statement arrays and stay unflagged. planJsx
@@ -10483,10 +10587,20 @@ function compileFunctionBody(node, ctx, name, parentNs = 'html', cssHash = null,
10483
10587
  const shellOrigin = node.loc ? node : node.id?.loc ? node.id : prevFnOrigin;
10484
10588
  let plan = null;
10485
10589
  let returnedExpression = null;
10486
- if (returnedOutput) {
10487
- const rendered = jsxNodes[0];
10590
+ if (returnedOutput && jsxNodes.length === 0) {
10591
+ // A `@{ … }` body can carry value returns with NO trailing output node —
10592
+ // `@{ … return null }` while a component is being written, or a React-shaped
10593
+ // `return <jsx>` inside the block. There is no template to lower: the body's
10594
+ // own returns are the whole output, so the tail is a plain `null` covering
10595
+ // the fall-through path. When the body provably never falls through, that
10596
+ // tail is unreachable and is dropped. Statement returns already normalized
10597
+ // to `?? null`.
10598
+ if (!alwaysCompletesAbruptly(rewrittenStatements)) {
10599
+ returnedExpression = b.literal(null, 'null', node);
10600
+ }
10601
+ } else if (returnedOutput) {
10488
10602
  returnedExpression = lowerReturnJsx(
10489
- rewriteHookCalls(rendered, ctx, name, options?.localHookSlots === true),
10603
+ rewriteHookCalls(jsxNodes[0], ctx, name, options?.localHookSlots === true),
10490
10604
  ctx,
10491
10605
  inlinedSubs,
10492
10606
  cssHash,
@@ -10496,6 +10610,7 @@ function compileFunctionBody(node, ctx, name, parentNs = 'html', cssHash = null,
10496
10610
  }
10497
10611
  ctx.currentInvariantLocals = prevInvariantLocals;
10498
10612
  ctx.currentEventInvariantLocals = prevEventInvariantLocals;
10613
+ ctx.currentBodyIsComponentScope = prevBodyIsComponentScope;
10499
10614
  ctx._inheritBody = prevInheritBody;
10500
10615
  ctx._fnOrigin = prevFnOrigin;
10501
10616
  ctx._foldedDirectiveCalls = prevFDC;
@@ -11067,6 +11182,49 @@ function isEventHandlerInvariantExpr(node, ctx) {
11067
11182
  );
11068
11183
  }
11069
11184
 
11185
+ /**
11186
+ * An inline `onClick={() => …}` arrow is rebuilt and reassigned to its DOM slot
11187
+ * on every render. When nothing the arrow reads can change, that write is dead
11188
+ * work: the handler can be installed once at mount and left alone, which is
11189
+ * already what a NAMED handler gets through `findMountEventCallbackSinks`.
11190
+ * Recognising the inline form closes the gap between the two spellings.
11191
+ *
11192
+ * "Nothing it reads can change" means every free identifier is either proven
11193
+ * event-invariant for this component (a useState setter, a ref object, a
11194
+ * useEffectEvent wrapper, …) or is not a component local at all — module scope,
11195
+ * an import, or a global, each fixed for the module's lifetime. That second
11196
+ * clause is the same inference `isArrowStableOver` makes.
11197
+ *
11198
+ * Sound only while planning the component body's own JSX.
11199
+ * `collectComponentLocals` deliberately ignores nested blocks, so inside a
11200
+ * `@for` item body the loop variable is absent from the set and would read as
11201
+ * module scope — and a keyed survivor can be handed a different item without
11202
+ * remounting, which would freeze the first item's capture in the slot forever.
11203
+ */
11204
+ function isMountStableInlineHandler(node, ctx) {
11205
+ if (ctx.hmr || ctx.profile || !ctx.currentBodyIsComponentScope) return false;
11206
+ const value = unwrapTsExpr(node);
11207
+ // A FunctionExpression is reachable through its own binding name and carries
11208
+ // its own `this`/`arguments`; only the arrow form is a pure lexical capture.
11209
+ if (value?.type !== 'ArrowFunctionExpression') return false;
11210
+ const locals = ctx.currentComponentLocals;
11211
+ if (!locals) return false;
11212
+ const paramScope = new Set();
11213
+ for (const p of value.params || []) collectBindings(p, paramScope);
11214
+ // Params are walked alongside the body: their names are already bound in
11215
+ // `paramScope`, but a default (`(e, x = n) => …`) is an ordinary expression
11216
+ // that runs per call and can reach a changing local.
11217
+ for (const name of collectFreeIdentifiers([value.body, ...(value.params || [])], paramScope)) {
11218
+ // `arguments` is the render call's own, and a DIRECT `eval` resolves
11219
+ // component locals this walk cannot see — either would tie the installed
11220
+ // closure to whatever the first render happened to hold.
11221
+ if (name === 'arguments' || name === 'eval') return false;
11222
+ if (!locals.has(name)) continue;
11223
+ if (ctx.currentEventInvariantLocals?.has(name) !== true) return false;
11224
+ }
11225
+ return true;
11226
+ }
11227
+
11070
11228
  // Object/array/function literals allocate a new identity on every evaluation,
11071
11229
  // so an identity diff can never skip their update. This currently feeds the
11072
11230
  // class binding path, where dropping the dead previous-value field preserves
@@ -19490,7 +19648,8 @@ function emitElementHtml(
19490
19648
  slotKey,
19491
19649
  ns: hostNs,
19492
19650
  dev: ctx.dev,
19493
- mountOnly: isEventHandlerInvariantExpr(inner, ctx),
19651
+ mountOnly:
19652
+ isEventHandlerInvariantExpr(inner, ctx) || isMountStableInlineHandler(inner, ctx),
19494
19653
  });
19495
19654
  }
19496
19655
  } else if (attrName === 'class') {
package/dist/runtime.js CHANGED
@@ -8056,7 +8056,8 @@ function createScopedValue(readElement) {
8056
8056
  const resolve = () => {
8057
8057
  const scope = CURRENT_SCOPE;
8058
8058
  const epoch = COMPILER_CACHE_CONTEXT_EPOCH;
8059
- 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) {
8060
8061
  const next = readElement();
8061
8062
  if (next.key === null && KEYED_ELEMENT_DESCRIPTORS.has(next)) {
8062
8063
  KEYED_ELEMENT_DESCRIPTORS.add(descriptor);
@@ -8064,6 +8065,8 @@ function createScopedValue(readElement) {
8064
8065
  resolvedScope = scope;
8065
8066
  resolvedEpoch = epoch;
8066
8067
  resolved = next;
8068
+ } else if (resolvedScope !== scope) {
8069
+ resolvedScope = scope;
8067
8070
  }
8068
8071
  return resolved;
8069
8072
  };
@@ -8102,7 +8105,7 @@ function createScopedElement(type, props, readChildren) {
8102
8105
  const children = () => {
8103
8106
  const scope = CURRENT_SCOPE;
8104
8107
  const epoch = COMPILER_CACHE_CONTEXT_EPOCH;
8105
- 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;
8106
8109
  if (!resolved || !sameScope || resolvedEpoch !== epoch) {
8107
8110
  const nextChildren = readChildren();
8108
8111
  resolvedScope = scope;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "octane",
3
- "version": "0.1.21",
3
+ "version": "0.1.23",
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
  },