@llui/compiler 0.5.1 → 0.5.2

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
@@ -104,7 +104,7 @@ const ELEMENT_HELPERS = new Set([
104
104
  'ul',
105
105
  'video',
106
106
  ]);
107
- export function transformLlui(source, _filename, devMode = false, emitAgentMetadata = false, mcpPort = 5200, verbose = false, typeSources, preExtracted, crossFilePaths) {
107
+ export function transformLlui(source, _filename, devMode = false, emitAgentMetadata = false, mcpPort = 5200, verbose = false, typeSources, preExtracted, crossFilePaths, crossFileOpaque = false) {
108
108
  // Use the caller-provided filename so any module reading `sf.fileName`
109
109
  // (e.g. `componentMetaModule` emitting `__componentMeta: { file }`)
110
110
  // sees the real path instead of a placeholder. The monolith's inline
@@ -164,9 +164,13 @@ export function transformLlui(source, _filename, devMode = false, emitAgentMetad
164
164
  // function is generated per-component, so bit assignments in other files
165
165
  // won't match. Files without component() get FULL_MASK on all bindings.
166
166
  const fileHasComponent = hasComponentDef(sourceFile, lluiImport);
167
- const { lo: fieldBits, hi: fieldBitsHi } = fileHasComponent
167
+ const { lo: fieldBits, hi: fieldBitsHi, opaque: fileLocalOpaque, } = fileHasComponent
168
168
  ? collectDeps(source, crossFilePaths)
169
- : { lo: new Map(), hi: new Map() };
169
+ : { lo: new Map(), hi: new Map(), opaque: false };
170
+ // Union the file-local opaque flag with the cross-file flag from the
171
+ // vite-plugin's walker — either can independently mandate a
172
+ // whole-state sentinel in `__prefixes`.
173
+ const hasOpaqueAccessor = fileLocalOpaque || crossFileOpaque;
170
174
  if (verbose && fileHasComponent) {
171
175
  const pairs = [...fieldBits.entries()]
172
176
  .map(([path, bit]) => `${path}=${bit === -1 ? 'FULL' : bit}`)
@@ -357,6 +361,7 @@ export function transformLlui(source, _filename, devMode = false, emitAgentMetad
357
361
  fieldBits,
358
362
  fieldBitsHi,
359
363
  lluiImport,
364
+ hasOpaqueAccessor,
360
365
  }));
361
366
  // structuralMaskModule injects `__mask` into each()/branch()/scope()/show()
362
367
  // options. Activated when the file has any low-word reactive paths
@@ -1356,6 +1361,15 @@ export function computeAccessorMask(accessor, fieldBits, visited = new Set(), fi
1356
1361
  let mask = 0;
1357
1362
  let maskHi = 0;
1358
1363
  let readsState = false;
1364
+ // The state value flows into an expression we can't statically trace
1365
+ // (function-arg / imported / destructured / method callee, or a
1366
+ // dynamic `s[expr]` lookup). The callee may read any field; the
1367
+ // dynamic key may index any field. Any non-empty precise mask we'd
1368
+ // compute from the visible direct reads alone is "clean but wrong":
1369
+ // a reducer that narrowly touches only the opaquely-read field
1370
+ // produces a dirty bit that `mask & dirty` zeroes, silently skipping
1371
+ // the binding. Conservative correctness: bail to FULL_MASK.
1372
+ let opaqueStateFlow = false;
1359
1373
  // `inNestedFn` gates only the delegation-recursion. Property-access
1360
1374
  // path extraction happens everywhere — inner-arrow callbacks like
1361
1375
  // `s.items.filter((i) => i.includes(s.filter))` close over our
@@ -1369,8 +1383,48 @@ export function computeAccessorMask(accessor, fieldBits, visited = new Set(), fi
1369
1383
  // like `text((_s) => \`$${item.x.toLocaleString()}\`)` was how
1370
1384
  // this bug first surfaced in the persistent-layout example work.
1371
1385
  const parent = node.parent;
1372
- if (ts.isIdentifier(node) && node.text === stateParam && (!parent || !ts.isParameter(parent))) {
1373
- readsState = true;
1386
+ // Every appearance of the state identifier `s` is one of:
1387
+ // - the parameter binding itself — ignore
1388
+ // - the root of `s.x.y…` (PropertyAccessExpression) — tracked by the PAE walker
1389
+ // - the root of `s['literal']`/`s[0]` — tracked by element-access (literal key only)
1390
+ // - arg0 of `helper(s)` with an Identifier callee — handled by the delegation branch below
1391
+ // - anything else (spread, return, ternary branch, template span,
1392
+ // NewExpression arg, TaggedTemplate value, const alias, arg1+ of any call,
1393
+ // method-call arg `obj.f(s)`, dynamic key `s[expr]`, type assertion,
1394
+ // parenthesized, …) — opaque: state has leaked
1395
+ //
1396
+ // The leak cases can't be reasoned about statically — the receiver
1397
+ // may read any field of `s`. A "precise" mask built from sibling
1398
+ // direct reads alone hides every field reachable only through the
1399
+ // leak, and (mask & dirty) silently skips updates. Conservative
1400
+ // correctness: any opaque flow forces FULL_MASK.
1401
+ if (ts.isIdentifier(node) && node.text === stateParam) {
1402
+ const isBinding = !!parent && ts.isParameter(parent);
1403
+ if (!isBinding) {
1404
+ readsState = true;
1405
+ let isTracked = false;
1406
+ if (parent) {
1407
+ if (ts.isPropertyAccessExpression(parent) && parent.expression === node) {
1408
+ isTracked = true;
1409
+ }
1410
+ else if (ts.isElementAccessExpression(parent) && parent.expression === node) {
1411
+ isTracked =
1412
+ ts.isStringLiteralLike(parent.argumentExpression) ||
1413
+ ts.isNumericLiteral(parent.argumentExpression);
1414
+ }
1415
+ else if (ts.isCallExpression(parent) &&
1416
+ ts.isIdentifier(parent.expression) &&
1417
+ parent.arguments[0] === node &&
1418
+ !NON_DELEGATION_HELPERS.has(parent.expression.text)) {
1419
+ // The delegation branch below either recurses into the
1420
+ // resolved body or sets opaqueStateFlow explicitly when
1421
+ // unresolvable — don't pre-empt that decision here.
1422
+ isTracked = true;
1423
+ }
1424
+ }
1425
+ if (!isTracked)
1426
+ opaqueStateFlow = true;
1427
+ }
1374
1428
  }
1375
1429
  if (ts.isPropertyAccessExpression(node)) {
1376
1430
  // When there's no parent we can't tell if this is the top of a
@@ -1437,6 +1491,14 @@ export function computeAccessorMask(accessor, fieldBits, visited = new Set(), fi
1437
1491
  if (inner.readsState)
1438
1492
  readsState = true;
1439
1493
  }
1494
+ else {
1495
+ // Callee is a function parameter, imported binding, or
1496
+ // destructured local — `resolveAccessorBody` couldn't pin
1497
+ // it to a local declaration. The body could read any
1498
+ // field of `s`, so a precise mask from sibling direct
1499
+ // reads alone is unsafe.
1500
+ opaqueStateFlow = true;
1501
+ }
1440
1502
  }
1441
1503
  }
1442
1504
  }
@@ -1444,6 +1506,14 @@ export function computeAccessorMask(accessor, fieldBits, visited = new Set(), fi
1444
1506
  ts.forEachChild(node, (child) => walk(child, inNestedFn || enteringNested));
1445
1507
  }
1446
1508
  walk(accessor.body, false);
1509
+ if (opaqueStateFlow) {
1510
+ // Both words FULL_MASK: the whole-state sentinel emitted by
1511
+ // `core-synthesis.ts:buildPrefixesProp` may land in either the
1512
+ // low or high word depending on the file's prefix count. The
1513
+ // runtime gate `(mask & dirty) | (maskHi & dirtyHi)` only catches
1514
+ // the sentinel bit when the binding's mask covers BOTH words.
1515
+ return { mask: 0xffffffff | 0, maskHi: 0xffffffff | 0, readsState: true };
1516
+ }
1447
1517
  if (mask === 0 && maskHi === 0 && readsState) {
1448
1518
  return { mask: 0xffffffff | 0, maskHi: 0, readsState: true };
1449
1519
  }