octane 0.1.21 → 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
 
@@ -6073,6 +6146,7 @@ function compileInternal(source, filename, options, analyzedAst, mode, bundlerMe
6073
6146
  nextHookMemoCacheId: 0, // unique non-index slots property per compiled render function
6074
6147
  currentInvariantLocals: null, // Set<string> of component-lifetime-stable local values
6075
6148
  currentEventInvariantLocals: null, // Set<string> safe to retain in native event slots
6149
+ currentBodyIsComponentScope: false, // planning the component body itself, not a nested arm
6076
6150
  currentProfileComponentId: null,
6077
6151
  knownStringLocals: null, // Set<string> of provably-string locals (text-hole inference)
6078
6152
  nextHookSymId: 0,
@@ -10463,6 +10537,11 @@ function compileFunctionBody(node, ctx, name, parentNs = 'html', cssHash = null,
10463
10537
  }
10464
10538
  ctx.currentInvariantLocals = invariantLocals;
10465
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;
10466
10545
  // M3 inherit-range: only a real `@{ … }` (JSXCodeBlock) component body spans
10467
10546
  // its block's whole range — synthetic sub-bodies (@if/@for/@try arms,
10468
10547
  // children render-fns) pass statement arrays and stay unflagged. planJsx
@@ -10496,6 +10575,7 @@ function compileFunctionBody(node, ctx, name, parentNs = 'html', cssHash = null,
10496
10575
  }
10497
10576
  ctx.currentInvariantLocals = prevInvariantLocals;
10498
10577
  ctx.currentEventInvariantLocals = prevEventInvariantLocals;
10578
+ ctx.currentBodyIsComponentScope = prevBodyIsComponentScope;
10499
10579
  ctx._inheritBody = prevInheritBody;
10500
10580
  ctx._fnOrigin = prevFnOrigin;
10501
10581
  ctx._foldedDirectiveCalls = prevFDC;
@@ -11067,6 +11147,49 @@ function isEventHandlerInvariantExpr(node, ctx) {
11067
11147
  );
11068
11148
  }
11069
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
+
11070
11193
  // Object/array/function literals allocate a new identity on every evaluation,
11071
11194
  // so an identity diff can never skip their update. This currently feeds the
11072
11195
  // class binding path, where dropping the dead previous-value field preserves
@@ -19490,7 +19613,8 @@ function emitElementHtml(
19490
19613
  slotKey,
19491
19614
  ns: hostNs,
19492
19615
  dev: ctx.dev,
19493
- mountOnly: isEventHandlerInvariantExpr(inner, ctx),
19616
+ mountOnly:
19617
+ isEventHandlerInvariantExpr(inner, ctx) || isMountStableInlineHandler(inner, ctx),
19494
19618
  });
19495
19619
  }
19496
19620
  } 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.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
  },