@llui/vite-plugin 0.0.40 → 0.0.42

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/dist/transform.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import ts from 'typescript';
2
2
  import { collectDeps } from './collect-deps.js';
3
+ import { resolveLocalConstInitializer, resolveAccessorBody, isMemoCallWithArrowArg, } from './accessor-resolver.js';
3
4
  import { extractMsgSchema, extractEffectSchema, isRichField, } from './msg-schema.js';
4
5
  import { extractMsgAnnotations } from './msg-annotations.js';
5
6
  import { extractStateSchema } from './state-schema.js';
@@ -94,66 +95,68 @@ const PROP_KEYS = new Set([
94
95
  'innerHTML',
95
96
  'textContent',
96
97
  ]);
98
+ function isStaticPrimitiveLiteral(expr) {
99
+ return (ts.isStringLiteral(expr) ||
100
+ ts.isNumericLiteral(expr) ||
101
+ ts.isNoSubstitutionTemplateLiteral(expr) ||
102
+ expr.kind === ts.SyntaxKind.TrueKeyword ||
103
+ expr.kind === ts.SyntaxKind.FalseKeyword ||
104
+ expr.kind === ts.SyntaxKind.NullKeyword);
105
+ }
97
106
  /**
98
- * Walk from `use` outward toward the source file root, looking for a
99
- * `const <name> = <initializer>` (or `let`/`var`) declaration that binds
100
- * this identifier in an enclosing scope. Returns the initializer if found.
101
- *
102
- * This enables reactive-binding detection for hoisted accessors:
103
- *
104
- * const cls = (s: State) => isActive(item, s.path) ? 'on' : 'off'
105
- * return a({ class: cls }, ...)
106
- *
107
- * Without resolution, `class: cls` would fall through the static path
108
- * and emit `__e.className = cls`, coercing the function to its source
109
- * string. With resolution, we see that `cls` is an arrow and emit a
110
- * binding exactly as if the arrow had been inlined.
111
- *
112
- * Lifetime rules:
113
- * - Only single-binding `const`/`let`/`var` declarations with an
114
- * initializer are considered. No destructuring, no multi-declarator
115
- * statements (too easy to get wrong without a type checker).
116
- * - Later reassignments are NOT tracked — if the identifier is `let`
117
- * and gets reassigned, the resolution is unreliable. We conservatively
118
- * refuse to resolve `let` bindings for now (arrow-valued accessors
119
- * are ~always `const` in practice).
120
- * - The declaration must dominate the use (same block or an enclosing
121
- * one). TypeScript's block semantics mean walking up parent blocks
122
- * is sufficient.
107
+ * Classify a reactive-prop value. See `ResolvedReactiveValue` for the
108
+ * contract. Returns `null` only when the value is none of the recognized
109
+ * shapes (caller can fall back to its own branches currently only
110
+ * `tryTransformElementCall` does this for `isPerItemFieldAccess` /
111
+ * `isHoistedPerItem`).
123
112
  */
124
- function resolveLocalConstInitializer(use) {
125
- const name = use.text;
126
- let node = use;
127
- while (node.parent) {
128
- const parent = node.parent;
129
- // Scan statements of an enclosing block/source-file for a matching declaration
130
- let statements = null;
131
- if (ts.isBlock(parent) || ts.isSourceFile(parent) || ts.isModuleBlock(parent)) {
132
- statements = parent.statements;
113
+ function classifyReactiveValue(value) {
114
+ // Inline arrow / function expression at the call site
115
+ if (ts.isArrowFunction(value) || ts.isFunctionExpression(value)) {
116
+ return { kind: 'arrow', accessor: value, valueForBinding: value };
117
+ }
118
+ // Inline `memo(arrow)` at the call site
119
+ if (isMemoCallWithArrowArg(value)) {
120
+ return {
121
+ kind: 'memo-call',
122
+ accessor: value.arguments[0],
123
+ valueForBinding: value,
124
+ };
125
+ }
126
+ // Identifier — resolve and classify the resolved declaration
127
+ if (ts.isIdentifier(value)) {
128
+ const resolved = resolveLocalConstInitializer(value);
129
+ if (!resolved) {
130
+ // Imported / parameter / unbound — can't prove it's a primitive,
131
+ // can't prove it's a function. Caller must bail to runtime.
132
+ return { kind: 'bail' };
133
133
  }
134
- else if (ts.isCaseClause(parent) || ts.isDefaultClause(parent)) {
135
- statements = parent.statements;
134
+ if (ts.isArrowFunction(resolved) || ts.isFunctionExpression(resolved)) {
135
+ return { kind: 'arrow', accessor: resolved, valueForBinding: value };
136
136
  }
137
- if (statements) {
138
- for (const stmt of statements) {
139
- if (!ts.isVariableStatement(stmt))
140
- continue;
141
- // Skip `let` — reassignment would invalidate our resolution
142
- const flags = stmt.declarationList.flags;
143
- if (!(flags & ts.NodeFlags.Const))
144
- continue;
145
- if (stmt.declarationList.declarations.length !== 1)
146
- continue;
147
- const decl = stmt.declarationList.declarations[0];
148
- if (!ts.isIdentifier(decl.name) || decl.name.text !== name)
149
- continue;
150
- if (!decl.initializer)
151
- continue;
152
- return decl.initializer;
153
- }
137
+ if (ts.isFunctionDeclaration(resolved)) {
138
+ return { kind: 'fn-decl', accessor: resolved, valueForBinding: value };
139
+ }
140
+ if (isMemoCallWithArrowArg(resolved)) {
141
+ return {
142
+ kind: 'memo-call',
143
+ accessor: resolved.arguments[0],
144
+ valueForBinding: value,
145
+ };
154
146
  }
155
- node = parent;
147
+ if (isStaticPrimitiveLiteral(resolved)) {
148
+ return { kind: 'static-literal' };
149
+ }
150
+ // Resolved to something else (object/array/expression) — conservative
151
+ // bail. We don't know if the runtime value is a function; the runtime
152
+ // element helper handles both cases correctly.
153
+ return { kind: 'bail' };
154
+ }
155
+ // Static literals at the call site
156
+ if (isStaticPrimitiveLiteral(value)) {
157
+ return { kind: 'static-literal' };
156
158
  }
159
+ // CallExpression — caller decides (per-item, etc.)
157
160
  return null;
158
161
  }
159
162
  function classifyKind(key) {
@@ -821,70 +824,92 @@ function tryTransformElementCall(node, helpers, fieldBits, compiled, bailed, f)
821
824
  events.push(f.createArrayLiteralExpression([f.createStringLiteral(eventName), value]));
822
825
  continue;
823
826
  }
824
- // If the value is an Identifier that refers to a local const-bound
825
- // arrow/function, resolve it and treat it as if the arrow had been
826
- // inlined. Without this, `class: cls` where `const cls = (s) => ...`
827
- // falls through to the static path and emits `.className = cls`,
828
- // coercing the function to its source string in the DOM.
829
- if (ts.isIdentifier(value)) {
830
- const resolved = resolveLocalConstInitializer(value);
831
- if (resolved && (ts.isArrowFunction(resolved) || ts.isFunctionExpression(resolved))) {
832
- value = resolved;
833
- }
827
+ // Per-item shapes handled before the general classifier because
828
+ // they appear inside `each().render` callbacks where `item` is a
829
+ // closed-over per-row accessor (zero-arg). The resolver above can't
830
+ // see them; they're shape-matched syntactically.
831
+ if (isPerItemFieldAccess(value) || isHoistedPerItem(value)) {
832
+ const kind = classifyKind(key);
833
+ const resolvedKey = resolveKey(key, kind);
834
+ bindings.push(f.createArrayLiteralExpression([
835
+ createMaskLiteral(f, 0xffffffff | 0),
836
+ f.createStringLiteral(kind),
837
+ f.createStringLiteral(resolvedKey),
838
+ value,
839
+ ]));
840
+ continue;
834
841
  }
835
- // Reactive binding value is an arrow function or function expression
836
- if (ts.isArrowFunction(value) || ts.isFunctionExpression(value)) {
842
+ if (ts.isCallExpression(value) && isPerItemCall(value)) {
837
843
  const kind = classifyKind(key);
838
844
  const resolvedKey = resolveKey(key, kind);
839
- const { mask, readsState } = computeAccessorMask(value, fieldBits);
840
- // Zero-mask constant folding: accessor doesn't read state → treat as static
841
- if (mask === 0 && !readsState) {
842
- emitStaticProp(staticProps, f, kind, resolvedKey, f.createCallExpression(value, undefined, []));
843
- continue;
844
- }
845
845
  bindings.push(f.createArrayLiteralExpression([
846
- createMaskLiteral(f, mask),
846
+ createMaskLiteral(f, 0xffffffff | 0),
847
847
  f.createStringLiteral(kind),
848
848
  f.createStringLiteral(resolvedKey),
849
849
  value,
850
850
  ]));
851
851
  continue;
852
852
  }
853
- // Call expression check if it's a per-item accessor: item(t => t.field)
854
- if (ts.isCallExpression(value)) {
855
- if (isPerItemCall(value)) {
856
- // Emit as a binding with FULL_MASK — the accessor is the item() call itself
857
- const kind = classifyKind(key);
858
- const resolvedKey = resolveKey(key, kind);
859
- bindings.push(f.createArrayLiteralExpression([
860
- createMaskLiteral(f, 0xffffffff | 0),
861
- f.createStringLiteral(kind),
862
- f.createStringLiteral(resolvedKey),
863
- value,
864
- ]));
865
- continue;
866
- }
867
- // Unknown call expression — bail out
853
+ // Classify the value at a reactive-prop position:
854
+ // - inline arrow / fn-expr at the call site
855
+ // - inline `memo(arrow)` at the call site
856
+ // - Identifier referencing a const-bound arrow/fn-expr in scope
857
+ // - Identifier referencing a hoisted function declaration in scope
858
+ // - Identifier referencing `const x = memo(arrow)` in scope
859
+ // - Identifier referencing a static primitive literal
860
+ // - Anything else (imports, parameters, opaque expressions) — bail
861
+ // to runtime; the runtime helper handles `typeof v === 'function'`
862
+ // correctly for both function and primitive values.
863
+ const classified = classifyReactiveValue(value);
864
+ if (classified === null) {
865
+ // Unknown shape (a CallExpression that isn't memo/per-item, etc.)
866
+ // — historically bailed to runtime. Preserve that.
868
867
  bailed.add(localName);
869
868
  return null;
870
869
  }
871
- // Per-item property access: item.field equivalent to item(t => t.field)
872
- // Also matches hoisted __a0/__a1/… identifiers produced by dedup pass.
873
- if (isPerItemFieldAccess(value) || isHoistedPerItem(value)) {
870
+ if (classified.kind === 'bail') {
871
+ bailed.add(localName);
872
+ return null;
873
+ }
874
+ if (classified.kind === 'static-literal') {
875
+ // Fall through to emitStaticProp (`__e.disabled = X`). Safe because
876
+ // we proved X is a primitive.
877
+ const kind = classifyKind(key);
878
+ const resolvedKey = resolveKey(key, kind);
879
+ emitStaticProp(staticProps, f, kind, resolvedKey, value);
880
+ continue;
881
+ }
882
+ // 'arrow' | 'fn-decl' | 'memo-call' — emit as a binding tuple. Mask is
883
+ // analyzed from the resolved accessor body (or the inner arrow inside
884
+ // a memo() call); the value emitted into the binding tuple is what the
885
+ // runtime calls as `accessor(state)` — for inline arrows we keep the
886
+ // arrow itself (preserves the historical inlining behavior), for
887
+ // identifier-bound forms we keep the identifier so consumers see
888
+ // a single canonical reference (and `memo()` proxies aren't rebuilt
889
+ // per render).
890
+ {
874
891
  const kind = classifyKind(key);
875
892
  const resolvedKey = resolveKey(key, kind);
893
+ const { mask, readsState } = computeAccessorMask(classified.accessor, fieldBits);
894
+ // Zero-mask constant folding only applies to inline arrows whose body
895
+ // we can safely call at compile time. For identifier-bound forms
896
+ // (`accessor !== value`) we skip the fold — calling the identifier's
897
+ // declaration at compile time would be unsafe (different scope) and
898
+ // calling the identifier in the emitted output would defeat the point.
899
+ if (classified.kind === 'arrow' &&
900
+ classified.accessor === value &&
901
+ mask === 0 &&
902
+ !readsState) {
903
+ emitStaticProp(staticProps, f, kind, resolvedKey, f.createCallExpression(classified.accessor, undefined, []));
904
+ continue;
905
+ }
876
906
  bindings.push(f.createArrayLiteralExpression([
877
- createMaskLiteral(f, 0xffffffff | 0),
907
+ createMaskLiteral(f, mask === 0 && readsState ? 0xffffffff | 0 : mask),
878
908
  f.createStringLiteral(kind),
879
909
  f.createStringLiteral(resolvedKey),
880
- value,
910
+ classified.valueForBinding,
881
911
  ]));
882
- continue;
883
912
  }
884
- // Static prop
885
- const kind = classifyKind(key);
886
- const resolvedKey = resolveKey(key, kind);
887
- emitStaticProp(staticProps, f, kind, resolvedKey, value);
888
913
  }
889
914
  }
890
915
  // Build elSplit args
@@ -971,13 +996,18 @@ function tryInjectTextMask(node, lluiImport, viewHelperNames, viewHelperAliases,
971
996
  const firstArg = node.arguments[0];
972
997
  if (!firstArg)
973
998
  return null;
974
- // Only inject mask for accessor functions, not static strings
975
- if (!ts.isArrowFunction(firstArg) && !ts.isFunctionExpression(firstArg))
976
- return null;
977
999
  // Don't inject if mask already provided
978
1000
  if (node.arguments.length >= 2)
979
1001
  return null;
980
- const { mask } = computeAccessorMask(firstArg, fieldBits);
1002
+ // Resolve the accessor body — accepts inline arrows, `memo(arrow)`, or
1003
+ // identifier references to a const-bound arrow / `memo(...)` / function
1004
+ // declaration in scope. Anything else (static strings, opaque imports,
1005
+ // parameters) leaves the call as-is — the runtime falls back to
1006
+ // FULL_MASK, which is correct but slower.
1007
+ const accessor = resolveAccessorBody(firstArg);
1008
+ if (!accessor)
1009
+ return null;
1010
+ const { mask } = computeAccessorMask(accessor, fieldBits);
981
1011
  return f.createCallExpression(node.expression, node.typeArguments, [
982
1012
  firstArg,
983
1013
  createMaskLiteral(f, mask === 0 ? 0xffffffff | 0 : mask),
@@ -1020,9 +1050,11 @@ function tryInjectStructuralMask(node, viewHelperNames, viewHelperAliases, field
1020
1050
  if (ts.isPropertyAssignment(prop) &&
1021
1051
  ts.isIdentifier(prop.name) &&
1022
1052
  prop.name.text === driverProp) {
1023
- if (ts.isArrowFunction(prop.initializer) || ts.isFunctionExpression(prop.initializer)) {
1024
- driverAccessor = prop.initializer;
1025
- }
1053
+ // Same shape contract as `text()`'s first arg: inline arrow, inline
1054
+ // `memo(arrow)`, or identifier referencing a const-bound arrow /
1055
+ // memo / function declaration. Anything else leaves the call
1056
+ // unchanged — runtime falls back to FULL_MASK.
1057
+ driverAccessor = resolveAccessorBody(prop.initializer);
1026
1058
  break;
1027
1059
  }
1028
1060
  }
@@ -3392,16 +3424,31 @@ function isHoistedPerItem(node) {
3392
3424
  // mask = 0 + readsState = false → constant (can fold to static)
3393
3425
  // mask = 0 + readsState = true → unresolvable state access (FULL_MASK)
3394
3426
  // mask > 0 → precise mask
3395
- function computeAccessorMask(accessor, fieldBits) {
3427
+ // See `NON_DELEGATION_HELPERS` in collect-deps.ts — same set of names
3428
+ // that aren't followed when scanning for `helper(s)` delegation calls.
3429
+ const NON_DELEGATION_HELPERS = new Set(['sample', 'item', 'memo', 'text', 'unsafeHtml']);
3430
+ function computeAccessorMask(accessor, fieldBits, visited = new Set()) {
3431
+ if (visited.has(accessor))
3432
+ return { mask: 0, readsState: false };
3433
+ visited.add(accessor);
3396
3434
  if (accessor.parameters.length === 0)
3397
3435
  return { mask: 0xffffffff | 0, readsState: false };
3398
3436
  const paramName = accessor.parameters[0].name;
3399
3437
  if (!ts.isIdentifier(paramName))
3400
3438
  return { mask: 0xffffffff | 0, readsState: false };
3439
+ // FunctionDeclaration always has a body (we never resolve overloads here);
3440
+ // ArrowFunction's body may be a single expression. Both shapes are walked
3441
+ // identically by ts.forEachChild, so no special-casing is needed below.
3442
+ if (!accessor.body)
3443
+ return { mask: 0xffffffff | 0, readsState: false };
3401
3444
  const stateParam = paramName.text;
3402
3445
  let mask = 0;
3403
3446
  let readsState = false;
3404
- function walk(node) {
3447
+ // `inNestedFn` gates only the delegation-recursion. Property-access
3448
+ // path extraction happens everywhere — inner-arrow callbacks like
3449
+ // `s.items.filter((i) => i.includes(s.filter))` close over our
3450
+ // state, and their `s.filter` reads contribute to the mask.
3451
+ function walk(node, inNestedFn) {
3405
3452
  // `node.parent` can be undefined for synthetic nodes produced by
3406
3453
  // earlier AST-transform passes (the row-factory rewrite and the
3407
3454
  // per-item heuristic both build new sub-trees whose inner nodes
@@ -3429,8 +3476,17 @@ function computeAccessorMask(accessor, fieldBits) {
3429
3476
  mask |= bit;
3430
3477
  }
3431
3478
  else {
3479
+ // Match paths that overlap our chain in either direction:
3480
+ // - `path` extends `chain` — fieldBits has finer-grained paths
3481
+ // than we're reading (e.g. chain='user', fieldBits has
3482
+ // 'user.email').
3483
+ // - `chain` extends `path` — we're reading deeper than what
3484
+ // fieldBits tracks (e.g. chain='items.filter' from
3485
+ // `s.items.filter(...)`, fieldBits has 'items'). Both ends
3486
+ // must mask in: a change to `items` invalidates anything
3487
+ // downstream of it.
3432
3488
  for (const [path, b] of fieldBits) {
3433
- if (path.startsWith(chain + '.') || path === chain) {
3489
+ if (path === chain || path.startsWith(chain + '.') || chain.startsWith(path + '.')) {
3434
3490
  mask |= b;
3435
3491
  }
3436
3492
  }
@@ -3438,9 +3494,30 @@ function computeAccessorMask(accessor, fieldBits) {
3438
3494
  }
3439
3495
  }
3440
3496
  }
3441
- ts.forEachChild(node, walk);
3497
+ // Delegation: `helper(s)` where `s` matches our state param.
3498
+ // Recurse into the helper's body so its state-path reads
3499
+ // contribute to our mask. Only at top level — inside a nested
3500
+ // function body, `s` may be shadowed and the call isn't
3501
+ // unambiguously handing our state in.
3502
+ if (!inNestedFn && ts.isCallExpression(node) && ts.isIdentifier(node.expression)) {
3503
+ const calleeName = node.expression.text;
3504
+ if (!NON_DELEGATION_HELPERS.has(calleeName)) {
3505
+ const arg0 = node.arguments[0];
3506
+ if (arg0 && ts.isIdentifier(arg0) && arg0.text === stateParam) {
3507
+ const resolved = resolveAccessorBody(node.expression);
3508
+ if (resolved) {
3509
+ const inner = computeAccessorMask(resolved, fieldBits, visited);
3510
+ mask |= inner.mask;
3511
+ if (inner.readsState)
3512
+ readsState = true;
3513
+ }
3514
+ }
3515
+ }
3516
+ }
3517
+ const enteringNested = ts.isArrowFunction(node) || ts.isFunctionExpression(node) || ts.isFunctionDeclaration(node);
3518
+ ts.forEachChild(node, (child) => walk(child, inNestedFn || enteringNested));
3442
3519
  }
3443
- walk(accessor.body);
3520
+ walk(accessor.body, false);
3444
3521
  if (mask === 0 && readsState) {
3445
3522
  return { mask: 0xffffffff | 0, readsState: true };
3446
3523
  }