@barefootjs/jsx 0.17.1 → 0.18.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.
Files changed (79) hide show
  1. package/dist/adapters/interface.d.ts +20 -4
  2. package/dist/adapters/interface.d.ts.map +1 -1
  3. package/dist/adapters/parsed-expr-emitter.d.ts +3 -1
  4. package/dist/adapters/parsed-expr-emitter.d.ts.map +1 -1
  5. package/dist/analyzer-context.d.ts.map +1 -1
  6. package/dist/analyzer.d.ts.map +1 -1
  7. package/dist/augment-inherited-props.d.ts +19 -0
  8. package/dist/augment-inherited-props.d.ts.map +1 -1
  9. package/dist/compiler.d.ts.map +1 -1
  10. package/dist/expression-parser.d.ts +1 -0
  11. package/dist/expression-parser.d.ts.map +1 -1
  12. package/dist/index.d.ts +19 -3
  13. package/dist/index.d.ts.map +1 -1
  14. package/dist/index.js +815 -251
  15. package/dist/ir-to-client-js/collect-elements.d.ts.map +1 -1
  16. package/dist/ir-to-client-js/compute-inlinability.d.ts.map +1 -1
  17. package/dist/ir-to-client-js/control-flow/plan/build-loop.d.ts.map +1 -1
  18. package/dist/ir-to-client-js/control-flow/plan/loop.d.ts +9 -0
  19. package/dist/ir-to-client-js/control-flow/plan/loop.d.ts.map +1 -1
  20. package/dist/ir-to-client-js/control-flow/stringify/loop.d.ts.map +1 -1
  21. package/dist/ir-to-client-js/control-flow/stringify/template-parse.d.ts +19 -0
  22. package/dist/ir-to-client-js/control-flow/stringify/template-parse.d.ts.map +1 -1
  23. package/dist/ir-to-client-js/emit-registration.d.ts +4 -2
  24. package/dist/ir-to-client-js/emit-registration.d.ts.map +1 -1
  25. package/dist/ir-to-client-js/html-template.d.ts +41 -0
  26. package/dist/ir-to-client-js/html-template.d.ts.map +1 -1
  27. package/dist/ir-to-client-js/types.d.ts +22 -1
  28. package/dist/ir-to-client-js/types.d.ts.map +1 -1
  29. package/dist/jsx-to-ir.d.ts.map +1 -1
  30. package/dist/loop-destructure.d.ts +55 -18
  31. package/dist/loop-destructure.d.ts.map +1 -1
  32. package/dist/lowering-registry.d.ts +13 -0
  33. package/dist/lowering-registry.d.ts.map +1 -1
  34. package/dist/relocate.d.ts +28 -0
  35. package/dist/relocate.d.ts.map +1 -1
  36. package/dist/ssr-defaults.d.ts.map +1 -1
  37. package/dist/types.d.ts +68 -0
  38. package/dist/types.d.ts.map +1 -1
  39. package/package.json +2 -2
  40. package/src/__tests__/__snapshots__/doc-examples.test.ts.snap +284 -12
  41. package/src/__tests__/augment-inherited-props.test.ts +96 -0
  42. package/src/__tests__/compiler-runtime-contract.test.ts +11 -1
  43. package/src/__tests__/compiler-stress-1244.test.ts +13 -4
  44. package/src/__tests__/csr-substitution-safety-divergence.test.ts +137 -0
  45. package/src/__tests__/destructured-map-params.test.ts +11 -1
  46. package/src/__tests__/expression-parser.test.ts +63 -2
  47. package/src/__tests__/ir-sort-comparator.test.ts +261 -0
  48. package/src/__tests__/loop-destructure.test.ts +313 -0
  49. package/src/__tests__/loop-hoisted-template.test.ts +235 -0
  50. package/src/__tests__/props-destructuring.test.ts +110 -0
  51. package/src/__tests__/serialize-parsed-expr.test.ts +70 -3
  52. package/src/__tests__/ssr-defaults.test.ts +20 -0
  53. package/src/__tests__/staged-ir/11-template-primitive-registry.test.ts +231 -1
  54. package/src/__tests__/tagged-template-interleave.test.ts +268 -0
  55. package/src/__tests__/unsupported-expression.test.ts +194 -7
  56. package/src/adapters/interface.ts +20 -4
  57. package/src/adapters/parsed-expr-emitter.ts +19 -2
  58. package/src/analyzer-context.ts +20 -0
  59. package/src/analyzer.ts +74 -1
  60. package/src/augment-inherited-props.ts +139 -9
  61. package/src/compiler.ts +4 -0
  62. package/src/expression-parser.ts +237 -56
  63. package/src/index.ts +23 -1
  64. package/src/ir-to-client-js/collect-elements.ts +15 -1
  65. package/src/ir-to-client-js/compute-inlinability.ts +6 -1
  66. package/src/ir-to-client-js/control-flow/plan/build-loop.ts +1 -0
  67. package/src/ir-to-client-js/control-flow/plan/loop.ts +9 -0
  68. package/src/ir-to-client-js/control-flow/stringify/loop.ts +30 -8
  69. package/src/ir-to-client-js/control-flow/stringify/template-parse.ts +30 -0
  70. package/src/ir-to-client-js/emit-registration.ts +4 -2
  71. package/src/ir-to-client-js/html-template.ts +198 -1
  72. package/src/ir-to-client-js/index.ts +1 -0
  73. package/src/ir-to-client-js/types.ts +22 -0
  74. package/src/jsx-to-ir.ts +409 -24
  75. package/src/loop-destructure.ts +89 -36
  76. package/src/lowering-registry.ts +16 -0
  77. package/src/relocate.ts +201 -14
  78. package/src/ssr-defaults.ts +34 -32
  79. package/src/types.ts +65 -0
@@ -99,18 +99,40 @@ export type ParsedExpr =
99
99
  object: ParsedExpr
100
100
  args: ParsedExpr[]
101
101
  }
102
- // `.flat(depth?)` (#1448 Tier C). The flatten depth is validated and
103
- // normalised into a structured `FlatDepth` at parse time — the literal
104
- // never reaches `args`, so adapters fold via a runtime helper instead of
105
- // re-inspecting the depth argument. A non-literal depth refuses with
106
- // BF101 (the depth must be known at template time). See the `.flat` arm
107
- // in `convertNode`.
102
+ // `.flat(depth?)` (#1448 Tier C, dynamic depth #2094). The flatten depth
103
+ // is validated at parse time — a literal integer / `Infinity` /
104
+ // unary-minus-literal normalises into a structured `FlatDepth` and the
105
+ // literal never reaches `args`, so adapters fold via a runtime helper
106
+ // instead of re-inspecting the depth argument. A non-literal depth that
107
+ // itself parses to a SUPPORTED `ParsedExpr` (a numeric prop, signal read,
108
+ // arithmetic, …) becomes a DYNAMIC depth: `depthExpr` carries that
109
+ // expression and `flatDepth` is a meaningless placeholder (`1`) — every
110
+ // consumer must check `depthExpr` first. A depth expression that itself
111
+ // doesn't resolve (an `unsupported` shape) still refuses with BF101 (the
112
+ // depth must be *expressible*, even if not known until render time). See
113
+ // the `.flat` arm in `convertNode`.
114
+ //
115
+ // Runtime coercion contract for a dynamic depth (JS `ToIntegerOrInfinity`,
116
+ // pinned by the `flat_dynamic` golden-vector cases): truncate toward
117
+ // zero; a NaN-producing value coerces to `0`; negative coerces to `0`
118
+ // (JS: negative depth never recurses, same as `0` — a shallow copy);
119
+ // `Infinity` / a huge finite value flattens fully. This is intentionally
120
+ // NOT the same runtime helper as the literal-depth path: the literal path
121
+ // pre-normalises `Infinity` to a `-1` *sentinel* meaning "flatten fully"
122
+ // (see `FlatDepth`'s doc), and a genuinely dynamic value of `-1` means
123
+ // the OPPOSITE (JS: no flatten) — reusing one helper for both would
124
+ // silently invert that case. Each adapter therefore routes a dynamic
125
+ // depth through a SEPARATE runtime helper (Go: `bf_flat_dynamic` /
126
+ // `FlatDynamicDepth`, distinct from the literal path's `bf_flat` / `Flat`)
127
+ // that performs the coercion above from scratch, never reusing the
128
+ // literal path's `-1`-means-infinite convention.
108
129
  | {
109
130
  kind: 'array-method'
110
131
  method: 'flat'
111
132
  object: ParsedExpr
112
133
  args: []
113
134
  flatDepth: FlatDepth
135
+ depthExpr?: ParsedExpr
114
136
  }
115
137
  | { kind: 'unsupported'; raw: string; reason: string }
116
138
 
@@ -795,6 +817,7 @@ function convertNode(node: ts.Node, raw: string): ParsedExpr {
795
817
  if (callee.property === 'flat') {
796
818
  const depthNode = node.arguments[0]
797
819
  let flatDepth: FlatDepth
820
+ let depthExpr: ParsedExpr | undefined
798
821
  if (depthNode === undefined) {
799
822
  flatDepth = 1
800
823
  } else if (ts.isIdentifier(depthNode) && depthNode.text === 'Infinity') {
@@ -811,16 +834,34 @@ function convertNode(node: ts.Node, raw: string): ParsedExpr {
811
834
  n = -Number(depthNode.operand.text)
812
835
  }
813
836
  if (n === undefined || Number.isNaN(n)) {
814
- return {
815
- kind: 'unsupported',
816
- raw,
817
- reason: `\`.flat(depth)\` needs a literal integer or \`Infinity\` depth — a computed depth can't be resolved at template time. Use a literal depth, or pre-compute the value before the template.`,
837
+ // Not a literal — try a DYNAMIC depth: an expression that
838
+ // itself resolves to a supported `ParsedExpr` (a numeric prop,
839
+ // signal read, arithmetic, …) can be lowered through a runtime
840
+ // helper that coerces it at render time (JS
841
+ // `ToIntegerOrInfinity`; see the `depthExpr` doc on the
842
+ // `array-method`/`flat` type). Still refuse with BF101 when
843
+ // the depth expression itself is outside the supported
844
+ // surface — the depth must be *expressible*, even if not
845
+ // known until render time.
846
+ const parsedDepth = convertNode(depthNode, raw)
847
+ if (checkSupport(parsedDepth).supported) {
848
+ depthExpr = parsedDepth
849
+ flatDepth = 1 // unused placeholder — consumers must check `depthExpr` first
850
+ } else {
851
+ return {
852
+ kind: 'unsupported',
853
+ raw,
854
+ reason: `\`.flat(depth)\` needs a literal integer, \`Infinity\`, or a supported dynamic depth expression — this depth can't be resolved. Use a literal depth, a supported expression (prop/signal/arithmetic), or pre-compute the value before the template.`,
855
+ }
818
856
  }
857
+ } else {
858
+ const truncated = Math.trunc(n)
859
+ flatDepth = truncated < 0 ? 0 : truncated
819
860
  }
820
- const truncated = Math.trunc(n)
821
- flatDepth = truncated < 0 ? 0 : truncated
822
861
  }
823
- return { kind: 'array-method', method: 'flat', object: callee.object, args: [], flatDepth }
862
+ return depthExpr !== undefined
863
+ ? { kind: 'array-method', method: 'flat', object: callee.object, args: [], flatDepth, depthExpr }
864
+ : { kind: 'array-method', method: 'flat', object: callee.object, args: [], flatDepth }
824
865
  }
825
866
  // `.toLowerCase()` — string-only (the IR carries a value-builtin
826
867
  // tag, not a receiver-type discriminator, so the `array-method`
@@ -1869,6 +1910,7 @@ function validateRestUsage(
1869
1910
  case 'array-method':
1870
1911
  walk(e.object)
1871
1912
  for (const a of e.args) walk(a)
1913
+ if (e.method === 'flat' && e.depthExpr) walk(e.depthExpr)
1872
1914
  return
1873
1915
  case 'literal':
1874
1916
  case 'unsupported':
@@ -1981,6 +2023,7 @@ function collectIdentifiers(expr: ParsedExpr, out: Set<string>): void {
1981
2023
  case 'array-method':
1982
2024
  collectIdentifiers(expr.object, out)
1983
2025
  expr.args.forEach(e => collectIdentifiers(e, out))
2026
+ if (expr.method === 'flat' && expr.depthExpr) collectIdentifiers(expr.depthExpr, out)
1984
2027
  return
1985
2028
  case 'literal':
1986
2029
  case 'regex':
@@ -2078,8 +2121,16 @@ function substituteDestructuredFields(
2078
2121
  case 'array-method':
2079
2122
  if (e.method === 'flat') {
2080
2123
  // `flatDepth` is a normalised literal — no destructure refs to
2081
- // substitute. Preserve verbatim.
2082
- return { kind: 'array-method', method: 'flat', object: walk(e.object), args: [], flatDepth: e.flatDepth }
2124
+ // substitute. `depthExpr` (a dynamic depth) DOES need the same
2125
+ // substitution as any other value position.
2126
+ return {
2127
+ kind: 'array-method',
2128
+ method: 'flat',
2129
+ object: walk(e.object),
2130
+ args: [],
2131
+ flatDepth: e.flatDepth,
2132
+ ...(e.depthExpr ? { depthExpr: walk(e.depthExpr) } : {}),
2133
+ }
2083
2134
  }
2084
2135
  return { kind: 'array-method', method: e.method, object: walk(e.object), args: e.args.map(walk) }
2085
2136
  case 'literal':
@@ -2202,6 +2253,16 @@ function checkSupport(expr: ParsedExpr): SupportResult {
2202
2253
  const argSupport = checkSupport(arg)
2203
2254
  if (!argSupport.supported) return argSupport
2204
2255
  }
2256
+ // A dynamic `.flat(depth)` carries its depth expression outside
2257
+ // `args` (see the `depthExpr` doc) — check it too. The parser
2258
+ // already gated this at parse time (only a SUPPORTED depth
2259
+ // expression becomes `depthExpr`), so this re-check is defensive:
2260
+ // it keeps `isSupported` total for any `array-method`/`flat` node
2261
+ // regardless of how it was constructed (e.g. by a rewrite walker).
2262
+ if (expr.method === 'flat' && expr.depthExpr) {
2263
+ const depthSupport = checkSupport(expr.depthExpr)
2264
+ if (!depthSupport.supported) return depthSupport
2265
+ }
2205
2266
  return { supported: true, level: 'L2' }
2206
2267
  }
2207
2268
 
@@ -2338,6 +2399,31 @@ function checkSupport(expr: ParsedExpr): SupportResult {
2338
2399
  case 'logical': {
2339
2400
  const leftSupport = checkSupport(expr.left)
2340
2401
  if (!leftSupport.supported) return leftSupport
2402
+
2403
+ // `x ?? {}` — admit an EMPTY object-literal fallback as the right
2404
+ // operand of `??` only. A standalone object literal stays refused
2405
+ // (the `object-literal` case above), and a non-empty one here still
2406
+ // falls through to the general `checkSupport(expr.right)` refusal
2407
+ // below. This is deliberately narrow, scoped to the one shape that
2408
+ // needs it (chart's `<Ctx.Provider value={{ config: props.config ??
2409
+ // {} }}>`):
2410
+ // - `??` only, not `&&` / `||` — a `{}` operand there plays a
2411
+ // different (always-truthy) role that each adapter's
2412
+ // boolean/filter dispatcher already handles by treating an
2413
+ // object-literal leaf as an unconditional-truthy sentinel; that
2414
+ // path doesn't need a real emitted value the way `??`'s value
2415
+ // position does.
2416
+ // - EMPTY object literal only (`properties.length === 0`) — the
2417
+ // one caller of this shape never needs populated fallback keys,
2418
+ // and restricting to empty means every adapter's `objectLiteral`
2419
+ // value-emitter only has to special-case the zero-property case
2420
+ // (emit its language's empty dict/hashref literal) rather than
2421
+ // also lowering arbitrary member values through the general
2422
+ // expression pipeline for SSR/CSR parity.
2423
+ if (expr.op === '??' && expr.right.kind === 'object-literal' && expr.right.properties.length === 0) {
2424
+ return { supported: true, level: 'L4' }
2425
+ }
2426
+
2341
2427
  const rightSupport = checkSupport(expr.right)
2342
2428
  if (!rightSupport.supported) return rightSupport
2343
2429
 
@@ -2397,7 +2483,11 @@ export function containsHigherOrder(expr: ParsedExpr): boolean {
2397
2483
  case 'array-literal':
2398
2484
  return expr.elements.some(containsHigherOrder)
2399
2485
  case 'array-method':
2400
- return containsHigherOrder(expr.object) || expr.args.some(containsHigherOrder)
2486
+ return (
2487
+ containsHigherOrder(expr.object) ||
2488
+ expr.args.some(containsHigherOrder) ||
2489
+ (expr.method === 'flat' && expr.depthExpr !== undefined && containsHigherOrder(expr.depthExpr))
2490
+ )
2401
2491
  default:
2402
2492
  return false
2403
2493
  }
@@ -2750,7 +2840,10 @@ function usesPerPath(name: string, expr: ParsedExpr): { min: number; max: number
2750
2840
  case 'array-literal':
2751
2841
  return sum(e.elements)
2752
2842
  case 'array-method':
2753
- return add(walk(e.object), e.method === 'flat' ? { min: 0, max: 0 } : sum(e.args))
2843
+ if (e.method === 'flat') {
2844
+ return add(walk(e.object), e.depthExpr ? walk(e.depthExpr) : { min: 0, max: 0 })
2845
+ }
2846
+ return add(walk(e.object), sum(e.args))
2754
2847
  case 'object-literal':
2755
2848
  return sum(e.properties.map(p => p.value))
2756
2849
  case 'arrow':
@@ -2826,7 +2919,14 @@ function inlineBinding(
2826
2919
  return { kind: 'array-literal', elements: e.elements.map(el => walk(el, enclosing)) }
2827
2920
  case 'array-method':
2828
2921
  if (e.method === 'flat') {
2829
- return { kind: 'array-method', method: 'flat', object: walk(e.object, enclosing), args: [], flatDepth: e.flatDepth }
2922
+ return {
2923
+ kind: 'array-method',
2924
+ method: 'flat',
2925
+ object: walk(e.object, enclosing),
2926
+ args: [],
2927
+ flatDepth: e.flatDepth,
2928
+ ...(e.depthExpr ? { depthExpr: walk(e.depthExpr, enclosing) } : {}),
2929
+ }
2830
2930
  }
2831
2931
  return { kind: 'array-method', method: e.method, object: walk(e.object, enclosing), args: e.args.map(a => walk(a, enclosing)) }
2832
2932
  case 'object-literal':
@@ -3042,8 +3142,12 @@ export function exprToString(expr: ParsedExpr): string {
3042
3142
  return `[${expr.elements.map(exprToString).join(', ')}]`
3043
3143
  case 'array-method':
3044
3144
  if (expr.method === 'flat') {
3045
- // Preserve the normalised depth so diagnostics don't misleadingly
3046
- // print `.flat()` for a `.flat(2)` / `.flat(Infinity)` source.
3145
+ // Preserve the normalised / dynamic depth so diagnostics don't
3146
+ // misleadingly print `.flat()` for a `.flat(2)` / `.flat(Infinity)`
3147
+ // / `.flat(depthProp)` source.
3148
+ if (expr.depthExpr) {
3149
+ return `${exprToString(expr.object)}.flat(${exprToString(expr.depthExpr)})`
3150
+ }
3047
3151
  const d = expr.flatDepth
3048
3152
  const depthSrc = d === 'infinity' ? 'Infinity' : String(d)
3049
3153
  return `${exprToString(expr.object)}.flat(${d === 1 ? '' : depthSrc})`
@@ -3115,8 +3219,12 @@ export function stringifyParsedExpr(expr: ParsedExpr): string {
3115
3219
  return `[${expr.elements.map(stringifyParsedExpr).join(', ')}]`
3116
3220
  case 'array-method':
3117
3221
  if (expr.method === 'flat') {
3118
- // Round-trip the normalised depth back to JS for the CSR / Hono
3119
- // path: `'infinity'` → `Infinity`, `1` is left implicit (`.flat()`).
3222
+ // Round-trip the normalised / dynamic depth back to JS for the
3223
+ // CSR / Hono path: `'infinity'` → `Infinity`, `1` is left implicit
3224
+ // (`.flat()`), a dynamic depth round-trips its own expression.
3225
+ if (expr.depthExpr) {
3226
+ return `${stringifyParsedExpr(expr.object)}.flat(${stringifyParsedExpr(expr.depthExpr)})`
3227
+ }
3120
3228
  const d = expr.flatDepth
3121
3229
  const depthSrc = d === 'infinity' ? 'Infinity' : String(d)
3122
3230
  return `${stringifyParsedExpr(expr.object)}.flat(${d === 1 ? '' : depthSrc})`
@@ -3185,9 +3293,12 @@ export function materializeGetterCalls(expr: ParsedExpr, names: ReadonlySet<stri
3185
3293
  return { kind: 'array-literal', elements: expr.elements.map(rw) }
3186
3294
  case 'array-method':
3187
3295
  // `flat`'s `args` is always `[]` (the depth is carried structurally in
3188
- // `flatDepth`, not `args`) — still rewrite `object`, just skip the
3189
- // `args.map` that every other method needs.
3190
- if (expr.method === 'flat') return { ...expr, object: rw(expr.object) }
3296
+ // `flatDepth` / `depthExpr`, not `args`) — still rewrite `object` (and
3297
+ // `depthExpr`, when dynamic), just skip the `args.map` that every
3298
+ // other method needs.
3299
+ if (expr.method === 'flat') {
3300
+ return { ...expr, object: rw(expr.object), ...(expr.depthExpr ? { depthExpr: rw(expr.depthExpr) } : {}) }
3301
+ }
3191
3302
  return { ...expr, object: rw(expr.object), args: expr.args.map(rw) }
3192
3303
  case 'object-literal':
3193
3304
  return {
@@ -3240,30 +3351,36 @@ export function serializeParsedExpr(expr: ParsedExpr): string | null {
3240
3351
  */
3241
3352
  export function freeVarsInBody(body: ParsedExpr, params: ReadonlySet<string>): string[] {
3242
3353
  const found = new Set<string>()
3243
- const visit = (e: ParsedExpr): void => {
3354
+ // `bound` starts as the outer callback's own `params` and grows when the
3355
+ // walk descends into a nested `map`/`filter` callback arrow (#2094) — a
3356
+ // NESTED arrow's own params shadow the outer ones for its body only,
3357
+ // mirroring {@link freeIdentifiers}'s lexical scoping. Before #2094 a
3358
+ // serializable body never contained a nested arrow, so this parameter
3359
+ // didn't need to change per call; it does now.
3360
+ const visit = (e: ParsedExpr, bound: ReadonlySet<string>): void => {
3244
3361
  switch (e.kind) {
3245
3362
  case 'identifier':
3246
- if (!params.has(e.name)) found.add(e.name)
3363
+ if (!bound.has(e.name)) found.add(e.name)
3247
3364
  return
3248
3365
  case 'binary':
3249
3366
  case 'logical':
3250
- visit(e.left)
3251
- visit(e.right)
3367
+ visit(e.left, bound)
3368
+ visit(e.right, bound)
3252
3369
  return
3253
3370
  case 'unary':
3254
- visit(e.argument)
3371
+ visit(e.argument, bound)
3255
3372
  return
3256
3373
  case 'conditional':
3257
- visit(e.test)
3258
- visit(e.consequent)
3259
- visit(e.alternate)
3374
+ visit(e.test, bound)
3375
+ visit(e.consequent, bound)
3376
+ visit(e.alternate, bound)
3260
3377
  return
3261
3378
  case 'member':
3262
- visit(e.object)
3379
+ visit(e.object, bound)
3263
3380
  return
3264
3381
  case 'index-access':
3265
- visit(e.object)
3266
- visit(e.index)
3382
+ visit(e.object, bound)
3383
+ visit(e.index, bound)
3267
3384
  return
3268
3385
  case 'call':
3269
3386
  // A builtin callee (`String`/`Number`/`Boolean`, or `Math.<fn>`) is
@@ -3271,42 +3388,52 @@ export function freeVarsInBody(body: ParsedExpr, params: ReadonlySet<string>): s
3271
3388
  // captured free var. Visiting it would add `Math` / `String` to the
3272
3389
  // env, making the adapter emit an undefined `$Math` / `.Math` base_env
3273
3390
  // entry (Copilot review #2031). Skip the callee identifier in that
3274
- // case; the arguments are still real references and are visited.
3275
- if (evalBuiltinCalleeName(e.callee) === null) visit(e.callee)
3276
- e.args.forEach(visit)
3391
+ // case; the arguments are still real references and are visited
3392
+ // (a nested `.map(cb)` / `.filter(cb)` callback call's `args[0]` is
3393
+ // the callback `arrow`, visited below with its own params bound).
3394
+ if (evalBuiltinCalleeName(e.callee) === null) visit(e.callee, bound)
3395
+ e.args.forEach((a) => visit(a, bound))
3277
3396
  return
3278
3397
  case 'template-literal':
3279
- for (const p of e.parts) if (p.type === 'expression') visit(p.expr)
3398
+ for (const p of e.parts) if (p.type === 'expression') visit(p.expr, bound)
3280
3399
  return
3281
3400
  case 'array-literal':
3282
- e.elements.forEach(visit)
3401
+ e.elements.forEach((el) => visit(el, bound))
3283
3402
  return
3284
3403
  case 'object-literal':
3285
3404
  // Object *values* are references; keys are not. (Shorthand `{ x }`
3286
3405
  // carries the ref on its `value` identifier, which is visited here.)
3287
- for (const p of e.properties) visit(p.value)
3406
+ for (const p of e.properties) visit(p.value, bound)
3288
3407
  return
3289
3408
  case 'array-method':
3290
- // Only `.includes(x)` is serializable ({@link toEvalNode}); its
3291
- // `object` (the receiver) and `args` (the needle) are the value
3292
- // positions serialized, so visit both when the tree reaches here
3293
- // with that method. Every other `array-method` is non-serializable
3294
- // and doesn't occur in a serializable body.
3295
- if (e.method === 'includes') {
3296
- visit(e.object)
3297
- e.args.forEach(visit)
3409
+ // `.includes(x)` / `.join(sep?)` are serializable ({@link
3410
+ // toEvalNode}); their `object` (the receiver) and `args` (needle /
3411
+ // separator) are the value positions serialized, so visit both when
3412
+ // the tree reaches here with either method. A nested `.map`/`.filter`
3413
+ // reaches this walk as a `call` (above), not an `array-method` — see
3414
+ // {@link asCallbackMethodCall}. Every other `array-method` is
3415
+ // non-serializable and doesn't occur in a serializable body.
3416
+ if (e.method === 'includes' || e.method === 'join') {
3417
+ visit(e.object, bound)
3418
+ e.args.forEach((a) => visit(a, bound))
3298
3419
  }
3299
3420
  return
3421
+ // A nested callback arrow (the `.map`/`.filter` callback argument,
3422
+ // #2094): its own params shadow the outer bound set for its body only.
3423
+ case 'arrow': {
3424
+ const inner = e.params.length === 0 ? bound : new Set([...bound, ...e.params])
3425
+ visit(e.body, inner)
3426
+ return
3427
+ }
3300
3428
  // Non-serializable kinds don't occur in a serializable body
3301
3429
  // (serializeParsedExpr returns null for them); nothing to collect.
3302
3430
  case 'literal':
3303
- case 'arrow':
3304
3431
  case 'regex':
3305
3432
  case 'unsupported':
3306
3433
  return
3307
3434
  }
3308
3435
  }
3309
- visit(body)
3436
+ visit(body, params)
3310
3437
  return [...found].sort()
3311
3438
  }
3312
3439
 
@@ -3374,6 +3501,7 @@ export function freeIdentifiers(expr: ParsedExpr): Set<string> | null {
3374
3501
  case 'array-method':
3375
3502
  if (!visit(e.object, bound)) return false
3376
3503
  for (const a of e.args) if (!visit(a, bound)) return false
3504
+ if (e.method === 'flat' && e.depthExpr && !visit(e.depthExpr, bound)) return false
3377
3505
  return true
3378
3506
  case 'object-literal':
3379
3507
  for (const p of e.properties) if (!visit(p.value, bound)) return false
@@ -3478,6 +3606,44 @@ function toEvalNode(e: ParsedExpr): Record<string, unknown> | null {
3478
3606
  return object && index ? { kind: 'index-access', object, index } : null
3479
3607
  }
3480
3608
  case 'call': {
3609
+ // A nested `.map(cb)` / `.filter(cb)` callback call (#2094) — the
3610
+ // SAME recognition `callbackMethod()` dispatch uses. Only these two
3611
+ // widen into the evaluator's surface: they are order-preserving,
3612
+ // per-element, and produce a bounded result, so the pure gate can
3613
+ // still reason about them structurally. `sort` / `reduce` /
3614
+ // `reduceRight` / `flat` / `flatMap` / `every` / `some` / `find*`
3615
+ // nested INSIDE an eval body stay refused — `asCallbackMethodCall`
3616
+ // recognizes them too, but they fall through to the generic
3617
+ // non-builtin-callee refusal below because `cb.method` doesn't match
3618
+ // either case here.
3619
+ const cb = asCallbackMethodCall(e)
3620
+ if (cb && (cb.method === 'map' || cb.method === 'filter')) {
3621
+ const object = toEvalNode(cb.object)
3622
+ if (!object) return null
3623
+ const body = toEvalNode(cb.arrow.body)
3624
+ if (!body) return null
3625
+ // `cb.arrow.params` is always plain identifier names (the `ParsedExpr`
3626
+ // `arrow` variant only carries destructure-rewritten / plain params —
3627
+ // an array-binding-pattern param stays `unsupported` upstream and
3628
+ // never reaches here). 1- and 2-param arrows are both supported for
3629
+ // `map`/`filter` (element, and index when declared); the Go/eval-cases
3630
+ // reference pins the 2-param contract.
3631
+ //
3632
+ // Re-serialized in the SAME `call` / `member` / `arrow` shape
3633
+ // `asCallbackMethodCall` recognizes — NOT a bespoke wrapper — because
3634
+ // the `eval-vectors.json` golden corpus carries the genuine
3635
+ // `ParsedExpr` `parseExpression` produces (unfiltered by
3636
+ // `toEvalNode`; see `eval-generate.ts`), which is exactly this
3637
+ // shape. Using the same shape here means the compiled-template
3638
+ // embedded body and the golden-vector corpus reach the evaluator in
3639
+ // one encoding, so a single `EvalNode`/`evaluate` recognition path
3640
+ // (Go `eval.go`, `eval-reference.ts`) covers both.
3641
+ return {
3642
+ kind: 'call',
3643
+ callee: { kind: 'member', object, property: cb.method, computed: false },
3644
+ args: [{ kind: 'arrow', params: cb.arrow.params, body }],
3645
+ }
3646
+ }
3481
3647
  // The evaluator executes only the builtin allowlist; a non-builtin callee
3482
3648
  // would evaluate to nil at runtime, so refuse it here (the purity gate).
3483
3649
  if (evalBuiltinCalleeName(e.callee) === null) return null
@@ -3523,18 +3689,33 @@ function toEvalNode(e: ParsedExpr): Record<string, unknown> | null {
3523
3689
  return { kind: 'object-literal', properties }
3524
3690
  }
3525
3691
  case 'array-method': {
3526
- // `.includes(x)` is the one `array-method` the evaluator executes
3692
+ // `.includes(x)` is one `array-method` the evaluator executes
3527
3693
  // (Go `eval.go` / Perl `Evaluator.pm`, includes support): the
3528
3694
  // receiver-type dispatch (array SameValueZero membership vs string
3529
3695
  // substring) happens at evaluator runtime, same as the SSR template
3530
- // lowering's `bf_includes` / `$bf->includes`. Every other
3531
- // `array-method` (`join`, `slice`, `flat`, …) is outside the
3532
- // evaluator's surface and refuses below.
3696
+ // lowering's `bf_includes` / `$bf->includes`.
3533
3697
  if (e.method === 'includes' && e.args.length === 1) {
3534
3698
  const object = toEvalNode(e.object)
3535
3699
  const arg = toEvalNode(e.args[0])
3536
3700
  return object && arg ? { kind: 'array-method', method: 'includes', object, args: [arg] } : null
3537
3701
  }
3702
+ // `.join(sep?)` (#2094) — 0 or 1 serializable separator arg. Lets a
3703
+ // nested `.map`/`.filter` chain feed straight into `.join(' ')` inside
3704
+ // an eval body (the #1938 blog-showcase shape:
3705
+ // `p.tags.map(t => '#' + t)` is joined by the OUTER `.join`, which is
3706
+ // already the caller-visible template expression, not inside the eval
3707
+ // body — this arm is for a `.join` that itself appears NESTED inside
3708
+ // an eval body, e.g. a `.map(p => p.tags.join(','))` projection).
3709
+ if (e.method === 'join' && e.args.length <= 1) {
3710
+ const object = toEvalNode(e.object)
3711
+ if (!object) return null
3712
+ if (e.args.length === 0) return { kind: 'array-method', method: 'join', object, args: [] }
3713
+ const sep = toEvalNode(e.args[0])
3714
+ return sep ? { kind: 'array-method', method: 'join', object, args: [sep] } : null
3715
+ }
3716
+ // Every other `array-method` (`slice`, `flat`, `flatMap` [handled as
3717
+ // its own `call` shape when it has an arrow, so not reached here],
3718
+ // …) is outside the evaluator's surface and refuses.
3538
3719
  return null
3539
3720
  }
3540
3721
  // Outside the evaluator's pure-expression surface — refuse so the caller
package/src/index.ts CHANGED
@@ -26,6 +26,9 @@ export type {
26
26
  IRConditional,
27
27
  IRLoop,
28
28
  IRLoopChildComponent,
29
+ LoopParamBinding,
30
+ LoopBindingPathSegment,
31
+ RestExcludeKey,
29
32
  IRComponent,
30
33
  IRFragment,
31
34
  IRSlot,
@@ -50,6 +53,8 @@ export type {
50
53
  TypeDefinition,
51
54
  SourceLocation,
52
55
  CompilerError,
56
+ ConformancePin,
57
+ ConformancePins,
53
58
  } from './types.ts'
54
59
 
55
60
  // Analyzer
@@ -89,6 +94,7 @@ export {
89
94
  getLoweringPlugins,
90
95
  prepareLoweringMatchers,
91
96
  matchLoweringCall,
97
+ isValidHelperId,
92
98
  __resetLoweringPluginsForTest,
93
99
  type LoweringPlugin,
94
100
  type LoweringNode,
@@ -260,6 +266,22 @@ export interface BuildOptions {
260
266
  * Forwarded to `compileJSX` as `CompileOptions.localImportPrefixes`.
261
267
  */
262
268
  localImportPrefixes?: string[]
269
+ /**
270
+ * How the CLI produces `barefoot.js` (the client runtime bundle):
271
+ * - `'treeshake'` (default) — bundle only the runtime exports this
272
+ * project's compiled client JS actually imports, plus a small
273
+ * always-kept public mount API (`render`, `hydrate`, etc.).
274
+ * - `'full'` — copy the entire prebuilt runtime bundle verbatim.
275
+ * See `@barefootjs/cli`'s `runtime-treeshake.ts` for the collector and
276
+ * `ALWAYS_KEEP_RUNTIME_EXPORTS` for the always-kept names.
277
+ */
278
+ runtimeBundle?: 'treeshake' | 'full'
279
+ /**
280
+ * Extra `@barefootjs/client*` export names to force-keep in `barefoot.js`
281
+ * under `runtimeBundle: 'treeshake'` — for names only ever referenced
282
+ * from hand-written page scripts the CLI never compiles.
283
+ */
284
+ runtimeKeep?: string[]
263
285
  }
264
286
 
265
287
  // AttrValue constructors
@@ -286,7 +308,7 @@ export type { StyleObjectEntry } from './expression-parser.ts'
286
308
  export type { ParsedExpr, ObjectLiteralProperty, ParsedStatement, SortComparator, SortKey, FlatDepth, SupportLevel, SupportResult, TemplatePart } from './expression-parser.ts'
287
309
  export { buildLoopChainExpr } from './loop-chain.ts'
288
310
  export type { LoopChainInputs } from './loop-chain.ts'
289
- export { isLowerableObjectRestDestructure } from './loop-destructure.ts'
311
+ export { isLowerableLoopDestructure, isLowerableObjectRestDestructure } from './loop-destructure.ts'
290
312
 
291
313
  // Debug analysis
292
314
  export {
@@ -6,7 +6,7 @@ import { type IRNode, type IRElement, type IRComponent, type IRLoop, type IRProp
6
6
  import type { ClientJsContext, ConditionalBranchChildComponent, ConditionalBranchReactiveAttr, BranchLoop, ConditionalBranchTextEffect, ConditionalElement, LoopChildBindings, LoopChildBranchSummary, LoopChildConditional, LoopOffset, NestedLoop } from './types.ts'
7
7
  import { attrValueToString, freeIdsFromRefs, quotePropName, PROPS_PARAM } from './utils.ts'
8
8
  import { classifyReactivity, decideWrapForAttr, decideWrapForChildProp, decideWrapFromAstFlags, collectEventHandlersFromIR, collectConditionalBranchEvents, collectConditionalBranchRefs, collectConditionalBranchChildComponents, collectLoopChildEventsWithNesting, collectLoopChildReactiveAttrs, collectLoopChildReactiveTexts, collectLoopChildRefs, emptyLoopChildBindings } from './reactivity.ts'
9
- import { irToHtmlTemplate, irToPlaceholderTemplate, irChildrenToJsExpr } from './html-template.ts'
9
+ import { irToHtmlTemplate, irToPlaceholderTemplate, irChildrenToJsExpr, buildLoopSkeletonTemplate } from './html-template.ts'
10
10
  import { expandDynamicPropValue, expandConstantForReactivity } from './prop-handling.ts'
11
11
  import { walkIR, stopAt } from './walker.ts'
12
12
  import { buildLoopChainExpr } from '../loop-chain.ts'
@@ -671,6 +671,7 @@ export function collectElements(
671
671
 
672
672
  let template = ''
673
673
  let staticItemTemplate: string | undefined
674
+ let skeletonTemplate: string | undefined
674
675
  if (l.childComponent) {
675
676
  template = '' // childComponent path uses createComponent directly
676
677
  // CSR materialize fallback (#1268): when the loop array references an
@@ -714,6 +715,18 @@ export function collectElements(
714
715
  staticItemTemplate = useElementReconciliation
715
716
  ? irToPlaceholderTemplate(l.children[0], buildRestSpreadNames(ctx), 0)
716
717
  : irToHtmlTemplate(l.children[0], buildRestSpreadNames(ctx), 0)
718
+ } else if (!useElementReconciliation && !l.bodyIsMultiRoot && !l.bodyIsItemConditional) {
719
+ // Hoisted shared-template fast path (perf): only for the plain
720
+ // `mapArray` shape — single-root, dynamic array, no element
721
+ // reconciliation. `buildLoopSkeletonTemplate` re-derives safety
722
+ // from the raw IR (spread attrs, conditionals, unslotted dynamic
723
+ // expressions, …) and returns `null` for anything it can't prove
724
+ // safe; the plan builder (`build-loop.ts`) falls back to the
725
+ // per-row `template` above whenever this stays `undefined`.
726
+ skeletonTemplate = buildLoopSkeletonTemplate(l.children[0], {
727
+ reactiveAttrKeys: new Set(bindings.reactiveAttrs.map(a => `${a.childSlotId}::${a.attrName}`)),
728
+ reactiveTextSlotIds: new Set(bindings.reactiveTexts.map(t => t.slotId)),
729
+ }) ?? undefined
717
730
  }
718
731
  }
719
732
 
@@ -732,6 +745,7 @@ export function collectElements(
732
745
  iterationShape: l.iterationShape,
733
746
  template,
734
747
  staticItemTemplate,
748
+ skeletonTemplate,
735
749
  childEventHandlers: childHandlers,
736
750
  bindings,
737
751
  childComponent: l.childComponent,
@@ -64,7 +64,12 @@ export function buildEnvFromCtx(ctx: ClientJsContext): RelocateEnv {
64
64
  effects: ctx.effects,
65
65
  onMounts: ctx.onMounts,
66
66
  initStatements: ctx.initStatements,
67
- imports: [],
67
+ // Real component imports (#2069) — `buildRelocateEnvFromIR` calls
68
+ // `prepareLoweringMatchers(metadata)` on this reconstructed object,
69
+ // and plugin `prepare()` resolves local import names from
70
+ // `metadata.imports`. `[]` here would silently disable every
71
+ // import-aware LoweringPlugin for the client-JS inline-safety gate.
72
+ imports: ctx.imports,
68
73
  templateImports: [],
69
74
  namedExports: [],
70
75
  localFunctions: ctx.localFunctions,
@@ -102,6 +102,7 @@ export function buildPlainLoopPlan(elem: TopLevelLoop, profileComponentName?: st
102
102
  indexParam: elem.index || '__idx',
103
103
  mapPreambleWrapped: elem.mapPreamble ? wrap(elem.mapPreamble) : '',
104
104
  template: elem.template,
105
+ skeletonTemplate: elem.skeletonTemplate,
105
106
  reactiveEffects: hasReactive ? buildLoopReactiveEffectsPlan(elem, profileComponentName) : null,
106
107
  childRefs: buildChildRefBindings(elem.bindings.refs, elem.param, elem.paramBindings),
107
108
  bodyIsMultiRoot: elem.bodyIsMultiRoot ?? false,
@@ -94,6 +94,15 @@ interface PlainLoopVariant extends DynamicLoopCommon {
94
94
  mapPreambleWrapped: string
95
95
  /** HTML template string for one item. */
96
96
  template: string
97
+ /**
98
+ * Shared once-per-loop skeleton template (perf) — see
99
+ * `buildLoopSkeletonTemplate` (html-template.ts). When present, the
100
+ * stringifier declares it once (before the `mapArray` call) and clones
101
+ * from it instead of re-parsing `template`'s interpolated innerHTML per
102
+ * row. `undefined` means the loop body wasn't proven safe to hoist; the
103
+ * stringifier falls back to the legacy per-row `emitTemplateCloneInline`.
104
+ */
105
+ skeletonTemplate?: string
97
106
  /** Resolved reactive-effects plan — null forces the single-line renderItem shape. */
98
107
  reactiveEffects: ReactiveEffectsPlan | null
99
108
  /**