@barefootjs/jsx 0.32.0 → 0.33.0

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.
@@ -68,16 +68,19 @@ export type ParsedExpr =
68
68
  // `unsupported`.
69
69
  | { kind: 'regex'; raw: string }
70
70
  | { kind: 'array-literal'; elements: ParsedExpr[] }
71
- // Object literal `{ a: 1, b: x }` / shorthand `{ a }`. Carried so an
72
- // adapter that lowers an object *value* (Go `map[string]interface{}`,
73
- // Perl hashref) can emit from structure instead of re-parsing the
74
- // source with `ts.createSourceFile`. Only produced for plain literals:
75
- // every property is a non-computed `key: value` or shorthand `{ key }`.
76
- // Spreads, computed keys, methods, and getters/setters fall through to
77
- // `unsupported` (unchanged). `raw` is the original expression string
78
- // the same value the old `unsupported` fallback carried so an adapter
79
- // that does not yet consume `properties` stays byte-identical by
80
- // emitting it exactly as it emits `unsupported`. Extending the type
71
+ // Object literal `{ a: 1, b: x }` / shorthand `{ a }` / spread
72
+ // `{ ...t, a: 1 }` (#2696 Step 2). Carried so an adapter that lowers an
73
+ // object *value* (Go `map[string]interface{}`, Perl hashref) can emit
74
+ // from structure instead of re-parsing the source with
75
+ // `ts.createSourceFile`. `properties` is an ORDER-PRESERVING list of
76
+ // `prop` / `spread` entries order carries JS's override semantics
77
+ // (`{...t, k: v}` vs `{k: v, ...t}` differ in which value wins a shared
78
+ // key), so it is one list, never split into separate spread/prop
79
+ // arrays. A computed key, method, or getter/setter still falls through
80
+ // to `unsupported` (unchanged). `raw` is the original expression
81
+ // string — the same value the old `unsupported` fallback carried — so
82
+ // an adapter that does not yet consume `properties` stays byte-identical
83
+ // by emitting it exactly as it emits `unsupported`. Extending the type
81
84
  // adds a TS compile error in every exhaustive `ParsedExpr` switch, the
82
85
  // same drift defence used for `array-literal` / `array-method`.
83
86
  | { kind: 'object-literal'; properties: ObjectLiteralProperty[]; raw: string }
@@ -234,25 +237,45 @@ const _arrayMethodRegistryIsExhaustive: MissingFromArrayMethodRegistry extends n
234
237
  void _arrayMethodRegistryIsExhaustive
235
238
 
236
239
  /**
237
- * One property of an `object-literal` `ParsedExpr`. The key is the
238
- * resolved (non-computed) property name for `{ a: 1 }` and shorthand
239
- * `{ a }` it is `a`; for `{ 'a-b': 1 }` it is `a-b`. Computed keys
240
- * (`{ [k]: 1 }`) are not represented; such literals fall through to
241
- * `unsupported` at parse time.
240
+ * One entry of an `object-literal` `ParsedExpr`, in SOURCE ORDER — a `prop`
241
+ * (`{ a: 1 }` / shorthand `{ a }`) or a `spread` (`{ ...t }`, #2696 Step 2).
242
+ * Kept as one discriminated-union list rather than separate prop/spread
243
+ * arrays because JS's override semantics are POSITIONAL `{ ...t, a: 1 }`
244
+ * and `{ a: 1, ...t }` merge in opposite directions — and only a single
245
+ * ordered list can carry that. Every direct consumer of `properties`
246
+ * (emitters, `freeVarsInBody`, `inlineBinding`, the rewrite walkers,
247
+ * `objectLiteralTo*` adapter helpers, …) is exhaustive over `kind`, so a
248
+ * TS compile error surfaces any site this Step 2 change didn't already
249
+ * update.
242
250
  */
243
- export type ObjectLiteralProperty = {
244
- key: string
245
- // The syntactic kind of the key, since `key` normalises all three to a
246
- // string and so loses the distinction. A consumer that must treat a numeric
247
- // key (`{ 1: 'a' }`) differently from a same-text string key (`{ '1': 'a' }`)
248
- // reads this; most consumers ignore it. `identifier` for shorthand.
249
- keyKind?: 'identifier' | 'string' | 'numeric'
250
- // Shorthand `{ a }` (the value is the identifier `a`) vs explicit
251
- // `{ a: <value> }`. The `value` already carries the resolved tree
252
- // either way; this flag is kept for re-stringification fidelity.
253
- shorthand: boolean
254
- value: ParsedExpr
255
- }
251
+ export type ObjectLiteralProperty =
252
+ | {
253
+ kind: 'prop'
254
+ // The resolved (non-computed) property name for `{ a: 1 }` and
255
+ // shorthand `{ a }` it is `a`; for `{ 'a-b': 1 }` it is `a-b`.
256
+ // Computed keys (`{ [k]: 1 }`) are not represented; such literals
257
+ // fall through to `unsupported` at parse time.
258
+ key: string
259
+ // The syntactic kind of the key, since `key` normalises all three to a
260
+ // string and so loses the distinction. A consumer that must treat a
261
+ // numeric key (`{ 1: 'a' }`) differently from a same-text string key
262
+ // (`{ '1': 'a' }`) reads this; most consumers ignore it. `identifier`
263
+ // for shorthand.
264
+ keyKind?: 'identifier' | 'string' | 'numeric'
265
+ // Shorthand `{ a }` (the value is the identifier `a`) vs explicit
266
+ // `{ a: <value> }`. The `value` already carries the resolved tree
267
+ // either way; this flag is kept for re-stringification fidelity.
268
+ shorthand: boolean
269
+ value: ParsedExpr
270
+ }
271
+ | {
272
+ kind: 'spread'
273
+ // The spread SOURCE expression (`t` in `{ ...t }`) — carried, not
274
+ // pre-flattened, so a consumer that can't merge structurally
275
+ // (`checkSupport`, the walkers) can still recurse into it uniformly
276
+ // with every other value-position `ParsedExpr`.
277
+ expr: ParsedExpr
278
+ }
256
279
 
257
280
  /**
258
281
  * One comparison key inside a sort comparator. A simple
@@ -1000,7 +1023,7 @@ function convertNode(node: ts.Node, raw: string): ParsedExpr {
1000
1023
  // surface — the depth must be *expressible*, even if not
1001
1024
  // known until render time.
1002
1025
  const parsedDepth = convertNode(depthNode, raw)
1003
- if (checkSupport(parsedDepth).supported) {
1026
+ if (checkSupport(parsedDepth, 'rendered').supported) {
1004
1027
  depthExpr = parsedDepth
1005
1028
  flatDepth = 1 // unused placeholder — consumers must check `depthExpr` first
1006
1029
  } else {
@@ -1215,23 +1238,25 @@ function convertNode(node: ts.Node, raw: string): ParsedExpr {
1215
1238
  return { kind: 'array-literal', elements }
1216
1239
  }
1217
1240
 
1218
- // Object literal: { a: 1, b: x, shorthand }. Only plain literals are
1219
- // structured — every property must be a non-computed `key: value` or a
1220
- // shorthand `{ key }`. Anything else (spread, computed key, method,
1221
- // getter/setter) falls through to the generic `unsupported` fallback
1222
- // below, exactly as before this kind existed.
1241
+ // Object literal: { a: 1, b: x, shorthand, ...spread }. Every property
1242
+ // must be a non-computed `key: value`, a shorthand `{ key }`, or a
1243
+ // spread `{ ...expr }` (#2696 Step 2). Anything else (computed key,
1244
+ // method, getter/setter) falls through to the generic `unsupported`
1245
+ // fallback below, exactly as before this kind existed.
1223
1246
  if (ts.isObjectLiteralExpression(node)) {
1224
1247
  const properties: ObjectLiteralProperty[] = []
1225
1248
  for (const prop of node.properties) {
1226
1249
  if (ts.isPropertyAssignment(prop)) {
1227
1250
  const k = objectLiteralKeyName(prop.name)
1228
1251
  if (k === null) return { kind: 'unsupported', raw, reason: `Unsupported syntax: ${ts.SyntaxKind[node.kind]}` }
1229
- properties.push({ key: k.key, keyKind: k.keyKind, shorthand: false, value: convertNode(prop.initializer, raw) })
1252
+ properties.push({ kind: 'prop', key: k.key, keyKind: k.keyKind, shorthand: false, value: convertNode(prop.initializer, raw) })
1230
1253
  } else if (ts.isShorthandPropertyAssignment(prop)) {
1231
1254
  const key = prop.name.text
1232
- properties.push({ key, keyKind: 'identifier', shorthand: true, value: { kind: 'identifier', name: key } })
1255
+ properties.push({ kind: 'prop', key, keyKind: 'identifier', shorthand: true, value: { kind: 'identifier', name: key } })
1256
+ } else if (ts.isSpreadAssignment(prop)) {
1257
+ properties.push({ kind: 'spread', expr: convertNode(prop.expression, raw) })
1233
1258
  } else {
1234
- // Spread assignment, method, getter/setter — not a plain map.
1259
+ // Computed key, method, getter/setter — not a plain map.
1235
1260
  return { kind: 'unsupported', raw, reason: `Unsupported syntax: ${ts.SyntaxKind[node.kind]}` }
1236
1261
  }
1237
1262
  }
@@ -2362,25 +2387,69 @@ function getUnaryOperatorString(op: ts.PrefixUnaryOperator): string {
2362
2387
  // =============================================================================
2363
2388
 
2364
2389
  /**
2365
- * Check if a parsed expression is supported for SSR template conversion.
2390
+ * Check if a parsed expression is supported for SSR template conversion at
2391
+ * a RENDERED position — a standalone template expression (`{expr}`, an
2392
+ * attribute value, a condition test, …) whose value is what actually
2393
+ * reaches the page. See {@link isSupportedValue} for the sibling VALUE-
2394
+ * position entry point, and `checkSupport`'s `pos` parameter for why the two
2395
+ * never converge partway through a tree.
2366
2396
  */
2367
2397
  export function isSupported(expr: ParsedExpr): SupportResult {
2368
- return checkSupport(expr)
2398
+ return checkSupport(expr, 'rendered')
2369
2399
  }
2370
2400
 
2371
- function checkSupport(expr: ParsedExpr): SupportResult {
2401
+ /**
2402
+ * Check if a parsed expression is supported at a VALUE position — a
2403
+ * signal/memo initializer (`computeSsrSeedPlan`'s classify step: an
2404
+ * assignment, never a render). The one behavioural difference from
2405
+ * {@link isSupported}: an `object-literal` anywhere in the tree is admitted
2406
+ * when every property value is itself supported, instead of being refused
2407
+ * outright.
2408
+ */
2409
+ export function isSupportedValue(expr: ParsedExpr): SupportResult {
2410
+ return checkSupport(expr, 'value')
2411
+ }
2412
+
2413
+ /**
2414
+ * Where in the expression tree a node sits, for the ONE shape whose support
2415
+ * depends on it: `object-literal` (refused at `rendered`, admitted at `value`
2416
+ * when every property value is itself supported — see the `object-literal`
2417
+ * case). `pos` is set ONLY by the two entry points ({@link isSupported} /
2418
+ * {@link isSupportedValue}) and propagates UNCHANGED through every recursive
2419
+ * call, with no per-shape forcing — forcing a container's contents to
2420
+ * `value` would let an object literal survive a `.map()`/`array-method`/
2421
+ * array-literal reachable from a RENDERED entry point and reach a string/
2422
+ * equality position (`{[{a:1}]}`, `[{a:1}].join(',')`, `.includes({a:1})`)
2423
+ * that no adapter renders identically to JS — a new silent-divergence class,
2424
+ * not a capability. Pure inheritance keeps `isSupported`'s behaviour
2425
+ * byte-identical to before `pos` existed; only a `value`-ENTERED tree (a
2426
+ * signal/memo initializer) ever admits a populated object literal.
2427
+ */
2428
+ type ExprPos = 'rendered' | 'value'
2429
+
2430
+ function checkSupport(expr: ParsedExpr, pos: ExprPos): SupportResult {
2372
2431
  switch (expr.kind) {
2373
2432
  case 'unsupported':
2374
2433
  return { supported: false, reason: expr.reason }
2375
2434
 
2376
- // A bare object literal is still refused as a standalone template
2377
- // expression adapters that lower one as a *value* (Go map / Perl
2378
- // hashref) do so in their own emitter, like `array-literal`, not
2379
- // through this support gate. The reason string is the exact text the
2380
- // `unsupported` fallback produced before the `object-literal` kind
2381
- // existed, so diagnostics stay byte-identical (Roadmap A-1).
2382
- case 'object-literal':
2383
- return { supported: false, reason: 'Unsupported syntax: ObjectLiteralExpression' }
2435
+ case 'object-literal': {
2436
+ // See `ExprPos`'s doc: refused at `rendered` (byte-identical reason to
2437
+ // the pre-`pos` behaviour, Roadmap A-1); admitted at `value` when
2438
+ // every property value is itself supported. Property values inherit
2439
+ // `pos`, which is already `value` here written as `pos`, not the
2440
+ // literal `'value'`, for consistency with every other recursive call.
2441
+ if (pos !== 'value') {
2442
+ return { supported: false, reason: 'Unsupported syntax: ObjectLiteralExpression' }
2443
+ }
2444
+ for (const prop of expr.properties) {
2445
+ // A spread entry's SOURCE must itself be supported at `value`
2446
+ // position — the same criterion as an explicit property's value,
2447
+ // since the spread source is read (not rendered) at merge time.
2448
+ const propSupport = checkSupport(prop.kind === 'spread' ? prop.expr : prop.value, pos)
2449
+ if (!propSupport.supported) return propSupport
2450
+ }
2451
+ return { supported: true, level: 'L2' }
2452
+ }
2384
2453
 
2385
2454
  case 'identifier':
2386
2455
  return { supported: true, level: 'L1' }
@@ -2396,13 +2465,14 @@ function checkSupport(expr: ParsedExpr): SupportResult {
2396
2465
  return { supported: false, reason: 'Standalone arrow functions / regex literals are not supported' }
2397
2466
 
2398
2467
  case 'array-literal': {
2399
- // Array literal is lowerable iff every element is. Adapters that
2400
- // don't have an array-literal form in their template language
2401
- // (Go templates) still need to refuse it they do so in their
2402
- // own `arrayLiteral` emitter method, not here, because we can't
2403
- // see which adapter is consuming the IR at this point.
2468
+ // Array literal is lowerable iff every element is; elements inherit
2469
+ // `pos` (see `ExprPos`'s doc). Adapters that don't have an
2470
+ // array-literal form in their template language (Go templates) still
2471
+ // need to refuse it they do so in their own `arrayLiteral` emitter
2472
+ // method, not here, because we can't see which adapter is consuming
2473
+ // the IR at this point.
2404
2474
  for (const el of expr.elements) {
2405
- const elSupport = checkSupport(el)
2475
+ const elSupport = checkSupport(el, pos)
2406
2476
  if (!elSupport.supported) return elSupport
2407
2477
  }
2408
2478
  return { supported: true, level: 'L2' }
@@ -2421,20 +2491,22 @@ function checkSupport(expr: ParsedExpr): SupportResult {
2421
2491
  `String.prototype.${expr.method} supports only a string pattern + string replacement (the regex form is deferred); use a string pattern or wrap the expression in /* @client */`,
2422
2492
  }
2423
2493
  }
2424
- const objSupport = checkSupport(expr.object)
2494
+ // Receiver/args inherit `pos` (see `ExprPos`'s doc).
2495
+ const objSupport = checkSupport(expr.object, pos)
2425
2496
  if (!objSupport.supported) return objSupport
2426
2497
  for (const arg of expr.args) {
2427
- const argSupport = checkSupport(arg)
2498
+ const argSupport = checkSupport(arg, pos)
2428
2499
  if (!argSupport.supported) return argSupport
2429
2500
  }
2430
2501
  // A dynamic `.flat(depth)` carries its depth expression outside
2431
- // `args` (see the `depthExpr` doc) — check it too. The parser
2432
- // already gated this at parse time (only a SUPPORTED depth
2433
- // expression becomes `depthExpr`), so this re-check is defensive:
2434
- // it keeps `isSupported` total for any `array-method`/`flat` node
2435
- // regardless of how it was constructed (e.g. by a rewrite walker).
2502
+ // `args` (see the `depthExpr` doc) — check it too, same inherited
2503
+ // `pos`. The parser already gated this at parse time (only a
2504
+ // SUPPORTED depth expression becomes `depthExpr`), so this re-check
2505
+ // is defensive: it keeps `isSupported`/`isSupportedValue` total for
2506
+ // any `array-method`/`flat` node regardless of how it was constructed
2507
+ // (e.g. by a rewrite walker).
2436
2508
  if (expr.method === 'flat' && expr.depthExpr) {
2437
- const depthSupport = checkSupport(expr.depthExpr)
2509
+ const depthSupport = checkSupport(expr.depthExpr, pos)
2438
2510
  if (!depthSupport.supported) return depthSupport
2439
2511
  }
2440
2512
  return { supported: true, level: 'L2' }
@@ -2453,11 +2525,16 @@ function checkSupport(expr: ParsedExpr): SupportResult {
2453
2525
  // `grep`, Go's `len (bf_filter_eval …)`) or surfaces BF101 at its
2454
2526
  // predicate fallback's exact degrade points (#2038) — a blanket refusal
2455
2527
  // here would break the faithful shapes (#1443 PR4).
2528
+ //
2529
+ // Receiver, callback body, and trailing args inherit `pos` (see
2530
+ // `ExprPos`'s doc) — a RENDERED `.map(t => ({...}))` stays refused
2531
+ // (the body value is what ends up rendered), while a VALUE-entered
2532
+ // one (a signal/memo initializer) admits it.
2456
2533
  const cb = asCallbackMethodCall(expr)
2457
2534
  if (cb) {
2458
- const objSupport = checkSupport(cb.object)
2535
+ const objSupport = checkSupport(cb.object, pos)
2459
2536
  if (!objSupport.supported) return objSupport
2460
- const bodySupport = checkSupport(cb.arrow.body)
2537
+ const bodySupport = checkSupport(cb.arrow.body, pos)
2461
2538
  if (!bodySupport.supported) {
2462
2539
  return {
2463
2540
  supported: false,
@@ -2466,14 +2543,15 @@ function checkSupport(expr: ParsedExpr): SupportResult {
2466
2543
  }
2467
2544
  }
2468
2545
  for (const rest of cb.args) {
2469
- const restSupport = checkSupport(rest)
2546
+ const restSupport = checkSupport(rest, pos)
2470
2547
  if (!restSupport.supported) return restSupport
2471
2548
  }
2472
2549
  return { supported: true, level: 'L5' }
2473
2550
  }
2474
2551
 
2475
- // Check if callee is supported
2476
- const calleeSupport = checkSupport(expr.callee)
2552
+ // Check if callee is supported. Not container contents — inherits
2553
+ // the CURRENT `pos` (see `ExprPos`'s doc).
2554
+ const calleeSupport = checkSupport(expr.callee, pos)
2477
2555
  if (!calleeSupport.supported) {
2478
2556
  return calleeSupport
2479
2557
  }
@@ -2502,9 +2580,11 @@ function checkSupport(expr: ParsedExpr): SupportResult {
2502
2580
  return { supported: true, level: 'L1' }
2503
2581
  }
2504
2582
 
2505
- // Other function calls - check args
2583
+ // Other function calls - check args. Not container contents of a
2584
+ // recognised callback/array-method (that path returned above) —
2585
+ // inherits the CURRENT `pos` (see `ExprPos`'s doc).
2506
2586
  for (const arg of expr.args) {
2507
- const argSupport = checkSupport(arg)
2587
+ const argSupport = checkSupport(arg, pos)
2508
2588
  if (!argSupport.supported) {
2509
2589
  return argSupport
2510
2590
  }
@@ -2513,7 +2593,8 @@ function checkSupport(expr: ParsedExpr): SupportResult {
2513
2593
  }
2514
2594
 
2515
2595
  case 'member': {
2516
- const objSupport = checkSupport(expr.object)
2596
+ // Not container contents — inherits the CURRENT `pos`.
2597
+ const objSupport = checkSupport(expr.object, pos)
2517
2598
  if (!objSupport.supported) {
2518
2599
  return objSupport
2519
2600
  }
@@ -2527,18 +2608,21 @@ function checkSupport(expr: ParsedExpr): SupportResult {
2527
2608
  case 'index-access': {
2528
2609
  // `arr[index]` — supported when both the receiver and the index
2529
2610
  // expression are themselves supported (the index is typically a
2530
- // loop variable or arithmetic over one). #1897 (data-table).
2531
- const objSupport = checkSupport(expr.object)
2611
+ // loop variable or arithmetic over one). #1897 (data-table). Neither
2612
+ // is container contents — both inherit the CURRENT `pos`.
2613
+ const objSupport = checkSupport(expr.object, pos)
2532
2614
  if (!objSupport.supported) return objSupport
2533
- const indexSupport = checkSupport(expr.index)
2615
+ const indexSupport = checkSupport(expr.index, pos)
2534
2616
  if (!indexSupport.supported) return indexSupport
2535
2617
  return { supported: true, level: 'L2' }
2536
2618
  }
2537
2619
 
2538
2620
  case 'binary': {
2539
- const leftSupport = checkSupport(expr.left)
2621
+ // Operands inherit the CURRENT `pos` (see `ExprPos`'s doc) — a binary
2622
+ // operand isn't container contents.
2623
+ const leftSupport = checkSupport(expr.left, pos)
2540
2624
  if (!leftSupport.supported) return leftSupport
2541
- const rightSupport = checkSupport(expr.right)
2625
+ const rightSupport = checkSupport(expr.right, pos)
2542
2626
  if (!rightSupport.supported) return rightSupport
2543
2627
 
2544
2628
  // Comparison operators are L3
@@ -2555,7 +2639,8 @@ function checkSupport(expr: ParsedExpr): SupportResult {
2555
2639
  }
2556
2640
 
2557
2641
  case 'unary': {
2558
- const argSupport = checkSupport(expr.argument)
2642
+ // Inherits the CURRENT `pos` — not container contents.
2643
+ const argSupport = checkSupport(expr.argument, pos)
2559
2644
  if (!argSupport.supported) return argSupport
2560
2645
 
2561
2646
  // Negation is L4
@@ -2571,7 +2656,8 @@ function checkSupport(expr: ParsedExpr): SupportResult {
2571
2656
  }
2572
2657
 
2573
2658
  case 'logical': {
2574
- const leftSupport = checkSupport(expr.left)
2659
+ // Operands inherit the CURRENT `pos` — not container contents.
2660
+ const leftSupport = checkSupport(expr.left, pos)
2575
2661
  if (!leftSupport.supported) return leftSupport
2576
2662
 
2577
2663
  // `x ?? {}` — admit an EMPTY object-literal fallback as the right
@@ -2598,27 +2684,35 @@ function checkSupport(expr: ParsedExpr): SupportResult {
2598
2684
  return { supported: true, level: 'L4' }
2599
2685
  }
2600
2686
 
2601
- const rightSupport = checkSupport(expr.right)
2687
+ const rightSupport = checkSupport(expr.right, pos)
2602
2688
  if (!rightSupport.supported) return rightSupport
2603
2689
 
2604
2690
  return { supported: true, level: 'L4' }
2605
2691
  }
2606
2692
 
2607
2693
  case 'conditional': {
2608
- const testSupport = checkSupport(expr.test)
2694
+ // Branches inherit the CURRENT `pos` (see `ExprPos`'s doc): whichever
2695
+ // branch is taken becomes the ternary's own value, so at a RENDERED
2696
+ // position both branches are themselves rendered — `cond ? {a:1} :
2697
+ // {b:2}` stays refused there, not admitted just for being a
2698
+ // sub-expression.
2699
+ const testSupport = checkSupport(expr.test, pos)
2609
2700
  if (!testSupport.supported) return testSupport
2610
- const consSupport = checkSupport(expr.consequent)
2701
+ const consSupport = checkSupport(expr.consequent, pos)
2611
2702
  if (!consSupport.supported) return consSupport
2612
- const altSupport = checkSupport(expr.alternate)
2703
+ const altSupport = checkSupport(expr.alternate, pos)
2613
2704
  if (!altSupport.supported) return altSupport
2614
2705
 
2615
2706
  return { supported: true, level: 'L4' }
2616
2707
  }
2617
2708
 
2618
2709
  case 'template-literal': {
2710
+ // Interpolated parts inherit the CURRENT `pos` — not container
2711
+ // contents (a template literal always stringifies its holes, so an
2712
+ // object-literal hole has no sensible rendering either way).
2619
2713
  for (const part of expr.parts) {
2620
2714
  if (part.type === 'expression') {
2621
- const partSupport = checkSupport(part.expr)
2715
+ const partSupport = checkSupport(part.expr, pos)
2622
2716
  if (!partSupport.supported) return partSupport
2623
2717
  }
2624
2718
  }
@@ -2935,7 +3029,7 @@ function isPureInit(e: ParsedExpr, pureCallNames?: ReadonlySet<string>): boolean
2935
3029
  case 'array-literal':
2936
3030
  return e.elements.every(pure)
2937
3031
  case 'object-literal':
2938
- return e.properties.every(p => pure(p.value))
3032
+ return e.properties.every(p => pure(p.kind === 'spread' ? p.expr : p.value))
2939
3033
  case 'call':
2940
3034
  // A zero-arg reactive getter read (`filter()`, `count()`) is idempotent;
2941
3035
  // any other call may be effectful or non-deterministic.
@@ -3019,7 +3113,7 @@ function usesPerPath(name: string, expr: ParsedExpr): { min: number; max: number
3019
3113
  }
3020
3114
  return add(walk(e.object), sum(e.args))
3021
3115
  case 'object-literal':
3022
- return sum(e.properties.map(p => p.value))
3116
+ return sum(e.properties.map(p => (p.kind === 'spread' ? p.expr : p.value)))
3023
3117
  case 'arrow':
3024
3118
  // A callback body may run any number of times (per element, or never).
3025
3119
  return walk(e.body).max > 0 ? { min: 0, max: Number.POSITIVE_INFINITY } : { min: 0, max: 0 }
@@ -3115,7 +3209,9 @@ export function inlineBinding(
3115
3209
  case 'object-literal':
3116
3210
  return {
3117
3211
  kind: 'object-literal',
3118
- properties: e.properties.map(p => ({ ...p, value: walk(p.value, enclosing) })),
3212
+ properties: e.properties.map(p =>
3213
+ p.kind === 'spread' ? { ...p, expr: walk(p.expr, enclosing) } : { ...p, value: walk(p.value, enclosing) },
3214
+ ),
3119
3215
  raw: e.raw,
3120
3216
  }
3121
3217
  case 'literal':
@@ -3487,7 +3583,7 @@ export function materializeGetterCalls(expr: ParsedExpr, names: ReadonlySet<stri
3487
3583
  return {
3488
3584
  kind: 'object-literal',
3489
3585
  raw: expr.raw,
3490
- properties: expr.properties.map(p => ({ ...p, value: rw(p.value) })),
3586
+ properties: expr.properties.map(p => (p.kind === 'spread' ? { ...p, expr: rw(p.expr) } : { ...p, value: rw(p.value) })),
3491
3587
  }
3492
3588
  case 'arrow':
3493
3589
  return { kind: 'arrow', params: expr.params, body: rw(expr.body) }
@@ -3586,7 +3682,8 @@ export function freeVarsInBody(body: ParsedExpr, params: ReadonlySet<string>): s
3586
3682
  case 'object-literal':
3587
3683
  // Object *values* are references; keys are not. (Shorthand `{ x }`
3588
3684
  // carries the ref on its `value` identifier, which is visited here.)
3589
- for (const p of e.properties) visit(p.value, bound)
3685
+ // A spread's source expression is likewise a reference.
3686
+ for (const p of e.properties) visit(p.kind === 'spread' ? p.expr : p.value, bound)
3590
3687
  return
3591
3688
  case 'array-method':
3592
3689
  // `.includes(x)` / `.join(sep?)` are serializable ({@link
@@ -3693,7 +3790,7 @@ export function freeIdentifiers(expr: ParsedExpr): Set<string> | null {
3693
3790
  if (e.method === 'flat' && e.depthExpr && !visit(e.depthExpr, bound)) return false
3694
3791
  return true
3695
3792
  case 'object-literal':
3696
- for (const p of e.properties) if (!visit(p.value, bound)) return false
3793
+ for (const p of e.properties) if (!visit(p.kind === 'spread' ? p.expr : p.value, bound)) return false
3697
3794
  return true
3698
3795
  case 'arrow': {
3699
3796
  const inner = new Set(bound)
@@ -3869,11 +3966,25 @@ function toEvalNode(e: ParsedExpr): Record<string, unknown> | null {
3869
3966
  return { kind: 'array-literal', elements }
3870
3967
  }
3871
3968
  case 'object-literal': {
3969
+ // Each entry carries its own `kind` tag (`prop` | `spread`) — the SAME
3970
+ // discriminant `ObjectLiteralProperty` uses — so every backend
3971
+ // evaluator (Go `eval.go`, Perl `Evaluator.pm`, Python, Ruby, PHP,
3972
+ // Rust) decodes this exactly like the raw `ParsedExpr` shape the
3973
+ // `eval-vectors.json` golden corpus carries (#2696 Step 2): a `prop`
3974
+ // sets one key; a `spread` evaluates its `expr` and shallow-merges the
3975
+ // result's own keys (later entries win; a null/undefined/non-object
3976
+ // spread source is a no-op).
3872
3977
  const properties: Record<string, unknown>[] = []
3873
3978
  for (const p of e.properties) {
3979
+ if (p.kind === 'spread') {
3980
+ const spreadExpr = toEvalNode(p.expr)
3981
+ if (!spreadExpr) return null
3982
+ properties.push({ kind: 'spread', expr: spreadExpr })
3983
+ continue
3984
+ }
3874
3985
  const value = toEvalNode(p.value)
3875
3986
  if (!value) return null
3876
- properties.push({ key: p.key, value })
3987
+ properties.push({ kind: 'prop', key: p.key, value })
3877
3988
  }
3878
3989
  return { kind: 'object-literal', properties }
3879
3990
  }
package/src/index.ts CHANGED
@@ -93,7 +93,7 @@ export type {
93
93
  export { JsxAdapter } from './adapters/jsx-adapter.ts'
94
94
  export type { JsxAdapterConfig } from './adapters/jsx-adapter.ts'
95
95
  export { rewriteImportsForTemplate, rewriteDynamicImportsInSource } from './adapters/template-imports.ts'
96
- export { emitParsedExpr, groupBinaryOperand, isStringTypedOperand, isStringConcatBinary } from './adapters/parsed-expr-emitter.ts'
96
+ export { emitParsedExpr, groupBinaryOperand, groupObjectLiteralSegments, isStringTypedOperand, isStringConcatBinary } from './adapters/parsed-expr-emitter.ts'
97
97
  export type { ParsedExprEmitter, HigherOrderMethod, ArrayMethod, SortMethod, LiteralType } from './adapters/parsed-expr-emitter.ts'
98
98
  export { collectLoopBoundNames } from './adapters/loop-bound-names.ts'
99
99
  export { derivesScopeFromSlot } from './adapters/child-scope.ts'
@@ -211,7 +211,7 @@ export { ErrorCodes, createError, formatError, generateCodeFrame } from './error
211
211
  export { isValueReferenceIdentifier, collectValueReferencedNames } from './value-references.ts'
212
212
 
213
213
  // Expression Parser
214
- export { parseExpression, tsNodeToParsedExpr, asCallbackMethodCall, CALLBACK_METHODS, sortComparatorFromArrow, serializeParsedExpr, freeVarsInBody, freeIdentifiers, materializeGetterCalls, isSupported, exprToString, stringifyParsedExpr, identifierPath, parseBlockBody, parseBlockBodyTolerant, foldBlockToExpr, predicateTernaryToLogical, containsHigherOrder, extractArrowBodyExpression, parseStyleObjectEntries, hasUnsafeStyleValue, parseProviderObjectLiteral, type ProviderObjectMember, type FoldBlockOptions } from './expression-parser.ts'
214
+ export { parseExpression, tsNodeToParsedExpr, asCallbackMethodCall, CALLBACK_METHODS, sortComparatorFromArrow, serializeParsedExpr, freeVarsInBody, freeIdentifiers, materializeGetterCalls, isSupported, isSupportedValue, exprToString, stringifyParsedExpr, identifierPath, parseBlockBody, parseBlockBodyTolerant, foldBlockToExpr, predicateTernaryToLogical, containsHigherOrder, extractArrowBodyExpression, parseStyleObjectEntries, hasUnsafeStyleValue, parseProviderObjectLiteral, type ProviderObjectMember, type FoldBlockOptions } from './expression-parser.ts'
215
215
  export type { StyleObjectEntry } from './expression-parser.ts'
216
216
  export { PARSED_EXPR_KINDS, ARRAY_METHOD_NAMES } from './expression-parser.ts'
217
217
  export type { ParsedExpr, ObjectLiteralProperty, ParsedStatement, SortComparator, SortKey, FlatDepth, SupportLevel, SupportResult, TemplatePart } from './expression-parser.ts'
package/src/jsx-to-ir.ts CHANGED
@@ -3296,7 +3296,10 @@ function resolveCallbackMethodFunctionReferences(
3296
3296
  ...(e.method === 'flat' && e.depthExpr ? { depthExpr: visit(e.depthExpr, bound) } : {}),
3297
3297
  } as ParsedExpr
3298
3298
  case 'object-literal':
3299
- return { ...e, properties: e.properties.map(p => ({ ...p, value: visit(p.value, bound) })) }
3299
+ return {
3300
+ ...e,
3301
+ properties: e.properties.map(p => (p.kind === 'spread' ? { ...p, expr: visit(p.expr, bound) } : { ...p, value: visit(p.value, bound) })),
3302
+ }
3300
3303
  case 'arrow': {
3301
3304
  const inner = e.params.length === 0 ? bound : new Set([...bound, ...e.params])
3302
3305
  return { ...e, body: visit(e.body, inner) }
@@ -58,6 +58,10 @@ export function matchQueryHrefCall(
58
58
 
59
59
  const triples: QueryHrefTriple[] = []
60
60
  for (const p of obj.properties) {
61
+ // A spread (`{ ...extra, key: v }`, #2696 Step 2) has no static key to
62
+ // build a triple from — fall back to the generic lowering rather than
63
+ // dropping the spread's keys silently.
64
+ if (p.kind === 'spread') return null
61
65
  const v = p.value
62
66
  if (v.kind === 'conditional' && isOmitBranch(v.alternate)) {
63
67
  triples.push({ guard: v.test, key: p.key, value: v.consequent })
@@ -348,7 +348,7 @@ function checkExpr(
348
348
  for (const el of expr.elements) recurse(el)
349
349
  break
350
350
  case 'object-literal':
351
- for (const prop of expr.properties) recurse(prop.value)
351
+ for (const prop of expr.properties) recurse(prop.kind === 'spread' ? prop.expr : prop.value)
352
352
  break
353
353
  case 'array-method':
354
354
  recurse(expr.object)
@@ -647,12 +647,28 @@ function evalNode(node: ts.Expression, ctx: EvalContext): EvalResult {
647
647
  }
648
648
 
649
649
  if (ts.isPropertyAccessExpression(node)) {
650
+ const baseResult = evalNode(node.expression, ctx)
650
651
  // `props.X` / `props?.X` — read of a binding we know nothing about, so
651
652
  // resolve to `undefined`. Chained access (`a.b.c`) collapses the same way
652
653
  // because the base read is already undefined.
653
- const baseResult = evalNode(node.expression, ctx)
654
654
  if (baseResult === undefined) return undefined
655
- return UNRESOLVED
655
+ if (baseResult === UNRESOLVED || baseResult === null || typeof baseResult !== 'object') {
656
+ return UNRESOLVED
657
+ }
658
+ // Own-property reads are only faithful for a plain object. An array (or
659
+ // any other non-plain object) exposes prototype members (`.map`,
660
+ // `.length`) that `hasOwnProperty` would wrongly resolve to `undefined`
661
+ // instead of the real function/value (#2698 review).
662
+ const proto = Object.getPrototypeOf(baseResult)
663
+ if (proto !== Object.prototype && proto !== null) return UNRESOLVED
664
+ // A resolved plain-object base — an object-literal value, e.g. a
665
+ // `.map()` callback param bound to one element (#2696's `t.a`). Missing
666
+ // key → JS `undefined`, mirroring `isElementAccessExpression`'s own
667
+ // missing-key handling above.
668
+ const key = node.name.text
669
+ return Object.prototype.hasOwnProperty.call(baseResult, key)
670
+ ? (baseResult as Record<string, unknown>)[key]
671
+ : undefined
656
672
  }
657
673
 
658
674
  if (ts.isCallExpression(node)) {
@@ -665,6 +681,39 @@ function evalNode(node: ts.Expression, ctx: EvalContext): EvalResult {
665
681
  ) {
666
682
  return ctx.bindings[node.expression.text]
667
683
  }
684
+ // `<array>.map(cb)` — a single-param, expression-bodied arrow over a
685
+ // resolved array receiver; anything else (block body, multi-param,
686
+ // non-array receiver) stays `UNRESOLVED`, same conservative narrowing as
687
+ // every other arm here. A `derived` step with empty `frees` gets no
688
+ // in-template recompute (#2696), so this static value is what production
689
+ // actually reads.
690
+ if (
691
+ ts.isPropertyAccessExpression(node.expression) &&
692
+ node.expression.name.text === 'map' &&
693
+ node.arguments.length === 1
694
+ ) {
695
+ const arrow = node.arguments[0]
696
+ if (
697
+ ts.isArrowFunction(arrow) &&
698
+ arrow.parameters.length === 1 &&
699
+ ts.isIdentifier(arrow.parameters[0].name) &&
700
+ !ts.isBlock(arrow.body)
701
+ ) {
702
+ const recv = evalNode(node.expression.expression, ctx)
703
+ if (Array.isArray(recv)) {
704
+ const paramName = (arrow.parameters[0].name as ts.Identifier).text
705
+ const mapped: unknown[] = []
706
+ for (const item of recv) {
707
+ const localBindings: Record<string, EvalResult> = { ...ctx.bindings, [paramName]: item }
708
+ const v = evalNode(arrow.body as ts.Expression, { ...ctx, bindings: localBindings })
709
+ if (v === UNRESOLVED) return UNRESOLVED
710
+ mapped.push(v === undefined ? null : v)
711
+ }
712
+ return mapped
713
+ }
714
+ }
715
+ return UNRESOLVED
716
+ }
668
717
  // `<array>.join(<sep?>)` — evaluate when the receiver resolves to an array
669
718
  // and the separator (default `,`) is a string. Covers `stateClasses =
670
719
  // [...].join(' ')` (#checkbox). Other array methods stay unresolved.