octane 0.1.4 → 0.1.5

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.
package/README.md CHANGED
@@ -6,9 +6,11 @@
6
6
 
7
7
  Octane is a fast, TypeScript-first UI framework, and the successor to
8
8
  [Inferno](https://github.com/infernojs/inferno). It gives you the React API you
9
- already know, a compiler that keeps the runtime small and fast, and no rules of
10
- hooks, so you can call hooks conditionally. This package ships both the runtime
11
- and the compiler, with the compiler exposed at `octane/compiler`.
9
+ already know, a compiler that keeps the runtime small and fast, no rules of
10
+ hooks, and no hand-maintained dependency arrays in the common case. Omit a hook's
11
+ dependency list and the compiler derives it from the closure; explicit arrays
12
+ retain React semantics, while `null` means every render. This package ships both
13
+ the runtime and compiler, with the compiler exposed at `octane/compiler`.
12
14
 
13
15
  For the full story, see the
14
16
  [main README](https://github.com/octanejs/octane#readme).
@@ -36,6 +36,7 @@ import {
36
36
  } from '@tsrx/core';
37
37
  import { print as esrapPrint } from 'esrap';
38
38
  import esrapTsx from 'esrap/languages/tsx';
39
+ import { applyHookDependencies } from './hook-deps.js';
39
40
 
40
41
  // DOM truth tables shared with the client/server runtimes (via constants.ts) —
41
42
  // static bakes and dynamic writes MUST agree on which attributes render, under
@@ -575,7 +576,7 @@ function collectComponentLocals(componentNode) {
575
576
  * memoising on.
576
577
  *
577
578
  * Stability sources:
578
- * - useState / useReducer setters (second destructured slot)
579
+ * - useState / useReducer setters and state getters (second/third slots)
579
580
  * - useRef returns (the ref object itself, not .current)
580
581
  * - useCallback / useEffectEvent returns
581
582
  * - Arrows previously declared in this body whose free vars are themselves
@@ -605,8 +606,20 @@ function computeStableLocals(statements, componentLocals) {
605
606
  decl.id.elements[1].type === 'Identifier'
606
607
  ) {
607
608
  stable.add(decl.id.elements[1].name);
608
- continue;
609
609
  }
610
+ // [_, _, getX] = useState(...) — the compiler-generated getter
611
+ // closes over the hook cell and is stable for that cell's lifetime.
612
+ if (
613
+ (callName === 'useState' || callName === 'useReducer') &&
614
+ decl.id.type === 'ArrayPattern' &&
615
+ decl.id.elements &&
616
+ decl.id.elements.length >= 3 &&
617
+ decl.id.elements[2] &&
618
+ decl.id.elements[2].type === 'Identifier'
619
+ ) {
620
+ stable.add(decl.id.elements[2].name);
621
+ }
622
+ if (callName === 'useState' || callName === 'useReducer') continue;
610
623
  // x = useRef(...) / useCallback(...) / useEffectEvent(...) — the
611
624
  // return value is stable for the lifetime of the component.
612
625
  if (
@@ -1430,6 +1443,11 @@ export function compile(source, filename, options) {
1430
1443
  // Normalize arrow-function components (`const X = () => @{…}`) to
1431
1444
  // FunctionDeclaration form so the component pipeline recognizes them.
1432
1445
  normalizeArrowComponents(ast);
1446
+ // Omitted dependency lists are compiler-owned: infer reactive captures
1447
+ // before any component splitting/hoisting so every lexical binding is still
1448
+ // visible to the shared TSRX/TSX analysis. Explicit arrays and `null` pass
1449
+ // through untouched.
1450
+ applyHookDependencies(ast, { filename });
1433
1451
  const hmrEnabled = !!(options && options.hmr);
1434
1452
  // Dev mode: emit dev-only hydration source-location metadata (a per-component
1435
1453
  // `__s.locs` table of structured {line,column} keyed by slot index + the module file
@@ -1897,6 +1915,10 @@ function compileServer(source, filename, options) {
1897
1915
  // Normalize arrow-function components (`const X = () => @{…}`) to
1898
1916
  // FunctionDeclaration form so the component pipeline recognizes them.
1899
1917
  normalizeArrowComponents(ast);
1918
+ // Mirror the client transform exactly. Effects are server no-ops, but
1919
+ // useMemo/useCallback execute during SSR and must receive the same inferred
1920
+ // dependency shape as hydration's client compile.
1921
+ applyHookDependencies(ast, { filename });
1900
1922
  const ctx = {
1901
1923
  filename,
1902
1924
  mode: 'server',
@@ -4152,7 +4174,86 @@ function rejectHookInJsLoop(loop, ctx, componentName) {
4152
4174
  walk(loop);
4153
4175
  }
4154
4176
 
4177
+ const STATE_GETTER_HELPERS = {
4178
+ useState: '__useStateWithGetter',
4179
+ useReducer: '__useReducerWithGetter',
4180
+ };
4181
+
4182
+ function arrayPatternObservesStateGetter(pattern) {
4183
+ const elements = pattern.elements || [];
4184
+ if (elements[2] != null) return true;
4185
+ // A rest before/at index 2 observes the getter even without an explicit
4186
+ // third binding: `[state, ...rest]` includes both update and getState.
4187
+ for (let i = 0; i <= 2 && i < elements.length; i++) {
4188
+ if (elements[i]?.type === 'RestElement') return true;
4189
+ }
4190
+ return false;
4191
+ }
4192
+
4193
+ function isTransparentStateTupleWrapper(node, child) {
4194
+ if (!node) return false;
4195
+ if (
4196
+ node.type === 'TSAsExpression' ||
4197
+ node.type === 'TSTypeAssertion' ||
4198
+ node.type === 'TSNonNullExpression' ||
4199
+ node.type === 'ParenthesizedExpression' ||
4200
+ node.type === 'ChainExpression'
4201
+ ) {
4202
+ return node.expression === child;
4203
+ }
4204
+ return false;
4205
+ }
4206
+
4207
+ // Source-level useState/useReducer tuples have a third getState member. Mark
4208
+ // calls that can observe it so rewriteHookCalls can select a specialized helper;
4209
+ // the ordinary two-item runtime path remains byte-for-byte for proven-dead
4210
+ // getters. Any escaping/ambiguous tuple is conservative and gets the full shape.
4211
+ function markStateGetterUsage(root) {
4212
+ const ancestors = [];
4213
+ function walk(node) {
4214
+ if (node == null || typeof node !== 'object') return;
4215
+ if (Array.isArray(node)) {
4216
+ for (const child of node) walk(child);
4217
+ return;
4218
+ }
4219
+ if (
4220
+ node.type === 'CallExpression' &&
4221
+ node.callee?.type === 'Identifier' &&
4222
+ STATE_GETTER_HELPERS[node.callee.name]
4223
+ ) {
4224
+ let child = node;
4225
+ let i = ancestors.length - 1;
4226
+ while (i >= 0 && isTransparentStateTupleWrapper(ancestors[i], child)) {
4227
+ child = ancestors[i--];
4228
+ }
4229
+ const parent = i >= 0 ? ancestors[i] : null;
4230
+ let observed = true;
4231
+ if (parent?.type === 'VariableDeclarator' && parent.init === child) {
4232
+ observed = parent.id.type !== 'ArrayPattern' || arrayPatternObservesStateGetter(parent.id);
4233
+ } else if (parent?.type === 'AssignmentExpression' && parent.right === child) {
4234
+ observed =
4235
+ parent.left.type !== 'ArrayPattern' || arrayPatternObservesStateGetter(parent.left);
4236
+ } else if (parent?.type === 'MemberExpression' && parent.object === child) {
4237
+ const p = parent.property;
4238
+ const index = parent.computed && p?.type === 'Literal' ? Number(p.value) : NaN;
4239
+ observed = index !== 0 && index !== 1;
4240
+ } else if (parent?.type === 'ExpressionStatement') {
4241
+ observed = false;
4242
+ }
4243
+ node._octaneStateGetter = observed;
4244
+ }
4245
+ ancestors.push(node);
4246
+ for (const key in node) {
4247
+ if (AST_WALK_SKIP_KEYS.has(key) || key === '_octaneStateGetter') continue;
4248
+ walk(node[key]);
4249
+ }
4250
+ ancestors.pop();
4251
+ }
4252
+ walk(root);
4253
+ }
4254
+
4155
4255
  function rewriteHookCalls(node, ctx, componentName) {
4256
+ markStateGetterUsage(node);
4156
4257
  return mapAst(node, (n) => {
4157
4258
  // A plain JS loop must not contain slot-keyed hook calls — reject before
4158
4259
  // slotting. (A template `@for` is also a ForOfStatement; its JSX body tells
@@ -4183,6 +4284,7 @@ function rewriteHookCalls(node, ctx, componentName) {
4183
4284
  const isCustom = /^use[A-Z]/.test(name) && name !== 'useContext';
4184
4285
  const isServerUse = name === 'use' && ctx.mode === 'server';
4185
4286
  if (isBuiltin || isCustom || isServerUse) {
4287
+ const getterHelper = n._octaneStateGetter ? STATE_GETTER_HELPERS[name] : null;
4186
4288
  // A builtin hook call site is USER code (the user's own identifier), so
4187
4289
  // its import stays bare — EXCEPT compiler-inserted calls (auto-callback's
4188
4290
  // `useCallback`), whose callee is renamed to the `_$` alias below so a
@@ -4190,6 +4292,7 @@ function rewriteHookCalls(node, ctx, componentName) {
4190
4292
  if (isBuiltin) {
4191
4293
  if (n.callee._octaneGenerated) ctx.runtimeNeeded.add(name);
4192
4294
  else ctx.userRuntimeNames.add(name);
4295
+ if (getterHelper !== null) ctx.runtimeNeeded.add(getterHelper);
4193
4296
  }
4194
4297
  if (isServerUse) ctx.userRuntimeNames.add('use');
4195
4298
  const debug = isServerUse
@@ -4226,9 +4329,12 @@ function rewriteHookCalls(node, ctx, componentName) {
4226
4329
  }
4227
4330
  return {
4228
4331
  ...n,
4229
- callee: n.callee._octaneGenerated
4230
- ? { type: 'Identifier', name: rtAlias(name) }
4231
- : n.callee,
4332
+ callee:
4333
+ getterHelper !== null
4334
+ ? { type: 'Identifier', name: rtAlias(getterHelper) }
4335
+ : n.callee._octaneGenerated
4336
+ ? { type: 'Identifier', name: rtAlias(name) }
4337
+ : n.callee,
4232
4338
  arguments: [...args, { type: 'Identifier', name: symVar }],
4233
4339
  };
4234
4340
  }
@@ -6315,7 +6421,7 @@ function planJsx(jsxNodesRaw, ctx, componentName, inlinedSubs, parentNs = 'html'
6315
6421
  const chv = `_b.${bag.letter(`_chv$${cc.id}`)}`;
6316
6422
  pushAfter(
6317
6423
  cc.id,
6318
- ` { const _v = (${cc.valueExpr}); if (${chp} !== _v) { ${chp} = _v; const _t = ${chv}; if (_t != null && typeof _v !== 'object' && typeof _v !== 'function') _$setText(_t, _v); else ${chv} = _$childTextHole(__s, ${slotIndex}, ${hostExpr}, _v, _t); } }`,
6424
+ ` { const _v = (${cc.valueExpr}); const _o = _v !== null && (typeof _v === 'object' || typeof _v === 'function'); if (_o || ${chp} !== _v) { ${chp} = _v; const _t = ${chv}; if (_t != null && !_o && _v !== null) _$setText(_t, _v); else ${chv} = _$childTextHole(__s, ${slotIndex}, ${hostExpr}, _v, _t); } }`,
6319
6425
  );
6320
6426
  continue;
6321
6427
  }
@@ -6338,7 +6444,11 @@ function planJsx(jsxNodesRaw, ctx, componentName, inlinedSubs, parentNs = 'html'
6338
6444
  // unchanged-skippable primitive already backed by a text node, do a direct
6339
6445
  // `setText` — exactly like a `.tsrx` `{… as string}` text binding. Objects /
6340
6446
  // functions (component / element / array), the first render, and mode
6341
- // switches go through `textHole` → the full `childSlot`.
6447
+ // switches go through `textHole` → the full `childSlot` — INCLUDING when
6448
+ // the value is identity-UNCHANGED: only childSlot's bail path refreshes
6449
+ // changed-context consumers below (a stable `{children}` passthrough under
6450
+ // a re-rendering Provider), so an inline identity skip would strand them.
6451
+ // Only unchanged primitives/null (no consumers possible) skip the call.
6342
6452
  ctx.runtimeNeeded.add('setText');
6343
6453
  ctx.runtimeNeeded.add('textHole');
6344
6454
  // When the slot has its OWN `<!>` placeholder, tell textHole/childSlot to
@@ -6348,7 +6458,7 @@ function planJsx(jsxNodesRaw, ctx, componentName, inlinedSubs, parentNs = 'html'
6348
6458
  const chv = `_b.${bag.letter(`_chv$${cc.id}`)}`;
6349
6459
  pushAfter(
6350
6460
  cc.id,
6351
- ` { const _v = (${cc.valueExpr}); if (${chp} !== _v) { ${chp} = _v; const _t = ${chv}; if (_t != null && typeof _v !== 'object' && typeof _v !== 'function') _$setText(_t, _v); else ${chv} = _$textHole(__s, ${slotIndex}, ${hostExpr}, _v, ${anchorExpr}${ownEndArg}); } }`,
6461
+ ` { const _v = (${cc.valueExpr}); const _o = _v !== null && (typeof _v === 'object' || typeof _v === 'function'); if (_o || ${chp} !== _v) { ${chp} = _v; const _t = ${chv}; if (_t != null && !_o && _v !== null) _$setText(_t, _v); else ${chv} = _$textHole(__s, ${slotIndex}, ${hostExpr}, _v, ${anchorExpr}${ownEndArg}); } }`,
6352
6462
  );
6353
6463
  continue;
6354
6464
  }