@barefootjs/jsx 0.18.3 → 0.18.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/dist/adapters/parsed-expr-emitter.d.ts +41 -2
  2. package/dist/adapters/parsed-expr-emitter.d.ts.map +1 -1
  3. package/dist/expression-parser.d.ts +2 -1
  4. package/dist/expression-parser.d.ts.map +1 -1
  5. package/dist/index.d.ts +1 -1
  6. package/dist/index.d.ts.map +1 -1
  7. package/dist/index.js +172 -48
  8. package/dist/ir-to-client-js/collect-elements.d.ts.map +1 -1
  9. package/dist/ir-to-client-js/control-flow/plan/build-event-delegation.d.ts.map +1 -1
  10. package/dist/ir-to-client-js/control-flow/plan/event-delegation.d.ts +10 -0
  11. package/dist/ir-to-client-js/control-flow/plan/event-delegation.d.ts.map +1 -1
  12. package/dist/ir-to-client-js/control-flow/stringify/event-delegation.d.ts.map +1 -1
  13. package/dist/ir-to-client-js/csr-substitute.d.ts +1 -0
  14. package/dist/ir-to-client-js/csr-substitute.d.ts.map +1 -1
  15. package/dist/ir-to-client-js/html-template.d.ts.map +1 -1
  16. package/dist/ir-to-client-js/types.d.ts +9 -0
  17. package/dist/ir-to-client-js/types.d.ts.map +1 -1
  18. package/dist/ir-to-client-js/utils.d.ts +25 -0
  19. package/dist/ir-to-client-js/utils.d.ts.map +1 -1
  20. package/dist/jsx-to-ir.d.ts.map +1 -1
  21. package/dist/types.d.ts +66 -0
  22. package/dist/types.d.ts.map +1 -1
  23. package/package.json +2 -2
  24. package/src/__tests__/__snapshots__/doc-examples.test.ts.snap +1 -1
  25. package/src/__tests__/event-delegation-index-param.test.ts +130 -0
  26. package/src/__tests__/expression-parser.test.ts +38 -0
  27. package/src/__tests__/ir-const-resolution.test.ts +1 -1
  28. package/src/__tests__/ir-provider.test.ts +2 -2
  29. package/src/__tests__/ir-walker.test.ts +1 -0
  30. package/src/__tests__/materialize-getter-calls.test.ts +1 -0
  31. package/src/__tests__/tagged-template-interleave.test.ts +2 -2
  32. package/src/adapters/parsed-expr-emitter.ts +76 -1
  33. package/src/expression-parser.ts +59 -25
  34. package/src/index.ts +1 -1
  35. package/src/ir-to-client-js/collect-elements.ts +3 -0
  36. package/src/ir-to-client-js/control-flow/plan/build-event-delegation.ts +6 -0
  37. package/src/ir-to-client-js/control-flow/plan/event-delegation.ts +10 -0
  38. package/src/ir-to-client-js/control-flow/stringify/event-delegation.ts +31 -7
  39. package/src/ir-to-client-js/csr-substitute.ts +1 -1
  40. package/src/ir-to-client-js/html-template.ts +57 -11
  41. package/src/ir-to-client-js/types.ts +10 -0
  42. package/src/ir-to-client-js/utils.ts +34 -1
  43. package/src/jsx-to-ir.ts +110 -13
  44. package/src/types.ts +68 -0
@@ -67,11 +67,14 @@ export type ArrayMethod =
67
67
  | 'toLowerCase'
68
68
  | 'toUpperCase'
69
69
  | 'trim'
70
+ | 'trimStart'
71
+ | 'trimEnd'
70
72
  | 'toFixed'
71
73
  | 'split'
72
74
  | 'startsWith'
73
75
  | 'endsWith'
74
76
  | 'replace'
77
+ | 'replaceAll'
75
78
  | 'repeat'
76
79
  | 'padStart'
77
80
  | 'padEnd'
@@ -111,10 +114,16 @@ export interface ParsedExprEmitter {
111
114
  identifier(name: string): string
112
115
  literal(value: string | number | boolean | null, literalType: LiteralType): string
113
116
  call(callee: ParsedExpr, args: ParsedExpr[], emit: (e: ParsedExpr) => string): string
117
+ // `optional` is true for a `?.`-written access (`user?.name`); see the
118
+ // `ParsedExpr` `member` variant's docstring in `expression-parser.ts`
119
+ // for the single-hop caveat. Every adapter's `member()` implementation
120
+ // that doesn't need it (its lowering is already null-safe) is free to
121
+ // ignore the parameter.
114
122
  member(
115
123
  object: ParsedExpr,
116
124
  property: string,
117
125
  computed: boolean,
126
+ optional: boolean,
118
127
  emit: (e: ParsedExpr) => string,
119
128
  ): string
120
129
  // Element access with a non-literal index (`arr[index]`). The index
@@ -198,6 +207,72 @@ export interface ParsedExprEmitter {
198
207
  unsupported(raw: string, reason: string): string
199
208
  }
200
209
 
210
+ /**
211
+ * Whether an operand is string-typed, as far as the ParsedExpr tree can
212
+ * tell: a string literal, a template literal, a zero-arg getter call /
213
+ * `props.x` member whose name the adapter knows to be string-valued
214
+ * (`isStringName`, from adapter state), or a `+` chain that is itself a
215
+ * string concatenation. Promoted from the Mojo/Xslate adapters' local
216
+ * copies (their file header marked it a shared candidate) and extended
217
+ * with the template-literal and nested-`+` arms.
218
+ *
219
+ * Consumed by `===`/`!==` lowering on backends whose `==` is numeric
220
+ * (Perl `eq`/`ne`) and by `isStringConcatBinary` below.
221
+ */
222
+ export function isStringTypedOperand(expr: ParsedExpr, isStringName: (n: string) => boolean): boolean {
223
+ if (expr.kind === 'literal' && expr.literalType === 'string') return true
224
+ if (expr.kind === 'template-literal') return true
225
+ if (expr.kind === 'call' && expr.callee.kind === 'identifier' && expr.args.length === 0) {
226
+ return isStringName(expr.callee.name)
227
+ }
228
+ if (expr.kind === 'member' && expr.object.kind === 'identifier' && expr.object.name === 'props') {
229
+ return isStringName(expr.property)
230
+ }
231
+ if (expr.kind === 'binary' && expr.op === '+') {
232
+ return isStringTypedOperand(expr.left, isStringName) || isStringTypedOperand(expr.right, isStringName)
233
+ }
234
+ return false
235
+ }
236
+
237
+ /**
238
+ * Whether a `binary` node is JS STRING concatenation rather than numeric
239
+ * addition: `+` with at least one string-typed operand (#2176). JS `+`
240
+ * overloads on operand type; backends whose `+` is numeric-only coerce
241
+ * the strings — Perl renders `'Hello, ' + name` as 0, PHP fatals with
242
+ * "Unsupported operand types" — so their emitters must pick the
243
+ * language's concat operator (`.` / `~`) when this returns true. The
244
+ * decision is shared-layer semantics; each adapter only maps true to
245
+ * its own operator.
246
+ */
247
+ export function isStringConcatBinary(
248
+ op: string,
249
+ left: ParsedExpr,
250
+ right: ParsedExpr,
251
+ isStringName: (n: string) => boolean,
252
+ ): boolean {
253
+ return op === '+' && (isStringTypedOperand(left, isStringName) || isStringTypedOperand(right, isStringName))
254
+ }
255
+
256
+ /**
257
+ * Wrap an emitted binary/logical/ternary OPERAND in parentheses so the
258
+ * source grouping the `ParsedExpr` tree encodes survives infix
259
+ * re-emission (#2173). `(count() + 2) * 3` parses as
260
+ * `binary{*, binary{+}, 3}` — the tree is unambiguous, but an emitter
261
+ * that joins operands textually (`${l} ${op} ${r}`) re-exposes the
262
+ * text to the TARGET language's precedence, silently computing
263
+ * `count + 2 * 3`. Grouping is decided here, in the shared layer
264
+ * (the semantics), so adapters just call this on each operand — no
265
+ * per-language precedence table needed: parenthesizing a compound
266
+ * operand is universally valid, and leaf operands (identifiers,
267
+ * literals, calls, members) stay unwrapped so simple emissions remain
268
+ * byte-identical.
269
+ */
270
+ export function groupBinaryOperand(operand: ParsedExpr, emitted: string): string {
271
+ return operand.kind === 'binary' || operand.kind === 'logical' || operand.kind === 'conditional'
272
+ ? `(${emitted})`
273
+ : emitted
274
+ }
275
+
201
276
  /**
202
277
  * Single point of dispatch from `ParsedExpr.kind` to the adapter's
203
278
  * method. Adapters call this once at their entry point; the recursion
@@ -222,7 +297,7 @@ export function emitParsedExpr(expr: ParsedExpr, emitter: ParsedExprEmitter): st
222
297
  return emitter.call(expr.callee, expr.args, emit)
223
298
  }
224
299
  case 'member':
225
- return emitter.member(expr.object, expr.property, expr.computed, emit)
300
+ return emitter.member(expr.object, expr.property, expr.computed, expr.optional, emit)
226
301
  case 'index-access':
227
302
  return emitter.indexAccess(expr.object, expr.index, emit)
228
303
  case 'binary':
@@ -25,7 +25,21 @@ export type ParsedExpr =
25
25
  // canonical form in `value`.
26
26
  | { kind: 'literal'; value: string | number | boolean | null; literalType: 'string' | 'number' | 'boolean' | 'null'; raw?: string }
27
27
  | { kind: 'call'; callee: ParsedExpr; args: ParsedExpr[] }
28
- | { kind: 'member'; object: ParsedExpr; property: string; computed: boolean }
28
+ // `optional` is true for a `?.`-written access (`user?.name`,
29
+ // `arr?.[0]`) — the ONE hop that was actually optional-chained in JS
30
+ // source; a plain `.`/`[]` access is `false`. Adapters whose member
31
+ // lowering is already null-safe by construction (e.g. Jinja's `[]`
32
+ // subscript swallows a `None` receiver, Blade's `data_get` helper)
33
+ // ignore this field entirely; Go and ERB (whose native `.` access
34
+ // panics/raises on a nil/undefined receiver) route an `optional: true`
35
+ // access through a defensive form (`bf_get`, Ruby `&.`) instead. NOTE:
36
+ // this does NOT reproduce JS's whole-chain short-circuit for a
37
+ // MULTI-hop chain (`a?.b.c` also guards `.c` in JS once `a` is
38
+ // nullish) — only the single hop written with `?.` is marked; a
39
+ // following plain `.c` on a possibly-nullish `a?.b` is a known,
40
+ // untracked limitation on Go/ERB (not exercised by the current
41
+ // `optional-chaining-prop` fixture, which is single-hop only).
42
+ | { kind: 'member'; object: ParsedExpr; property: string; computed: boolean; optional: boolean }
29
43
  // Element access with a NON-literal index (`selected()[index]`,
30
44
  // `rows[i + 1]`). A literal-index access (`arr[0]`, `obj['key']`)
31
45
  // stays a `member` (computed) since the key is statically known and
@@ -88,11 +102,14 @@ export type ParsedExpr =
88
102
  | 'toLowerCase'
89
103
  | 'toUpperCase'
90
104
  | 'trim'
105
+ | 'trimStart'
106
+ | 'trimEnd'
91
107
  | 'toFixed'
92
108
  | 'split'
93
109
  | 'startsWith'
94
110
  | 'endsWith'
95
111
  | 'replace'
112
+ | 'replaceAll'
96
113
  | 'repeat'
97
114
  | 'padStart'
98
115
  | 'padEnd'
@@ -345,18 +362,17 @@ const UNSUPPORTED_METHODS = new Set([
345
362
  // `startsWith` / `endsWith` are no longer here — both lower via the
346
363
  // `array-method` IR + `bf_starts_with` / `bf_ends_with` (Go) and
347
364
  // `bf->starts_with` / `bf->ends_with` (Mojo). See #1448 Tier B.
348
- // `replace` is no longer here — the string-pattern form lowers via
349
- // the `array-method` IR + `bf_replace` (Go) / `bf->replace` (Mojo);
350
- // the regex-pattern form is refused at the parse arm below (it would
351
- // need the per-adapter regex-flavour decision). `replaceAll` stays
352
- // refused. See #1448 Tier B.
365
+ // `replace` / `replaceAll` are no longer here — the string-pattern
366
+ // form of each lowers via the `array-method` IR + `bf_replace` /
367
+ // `bf_replace_all` (Go) / `bf->replace` / `bf->replace_all` (Mojo);
368
+ // the regex-pattern form of EITHER is refused at the parse arm below
369
+ // (it would need the per-adapter regex-flavour decision).
353
370
  // `repeat` is no longer here — `String.prototype.repeat(n)` lowers via
354
371
  // the `array-method` IR + `bf_repeat` (Go) / `bf->repeat` (Mojo).
355
372
  // See #1448 Tier B.
356
373
  // `padStart` / `padEnd` are no longer here — both lower via the
357
374
  // `array-method` IR + `bf_pad_start` / `bf_pad_end` (Go) and
358
375
  // `bf->pad_start` / `bf->pad_end` (Mojo). See #1448 Tier B.
359
- 'replaceAll',
360
376
  'charAt', 'charCodeAt', 'codePointAt', 'normalize',
361
377
  'substring', 'substr', 'match', 'matchAll', 'search',
362
378
  ])
@@ -882,6 +898,16 @@ function convertNode(node: ts.Node, raw: string): ParsedExpr {
882
898
  if (callee.property === 'trim') {
883
899
  return { kind: 'array-method', method: 'trim', object: callee.object, args }
884
900
  }
901
+ // `.trimStart()` / `.trimEnd()` — the one-sided siblings of
902
+ // `.trim()` (#2183 follow-up). No array equivalent exists, so
903
+ // there's no receiver-type ambiguity to resolve (unlike `.slice`);
904
+ // each is a dedicated `array-method` variant (matching the
905
+ // `padStart`/`padEnd` precedent — separate members, not a shared
906
+ // member with a `side` flag) lowering to its own runtime helper
907
+ // per adapter, not `.trim()` with a flag.
908
+ if (callee.property === 'trimStart' || callee.property === 'trimEnd') {
909
+ return { kind: 'array-method', method: callee.property, object: callee.object, args }
910
+ }
885
911
  // `.toFixed(digits?)` — Number → fixed-decimal string. The digit
886
912
  // count (default 0) travels as the single arg; all adapters route
887
913
  // through a `to_fixed` runtime helper (Perl) / `fmt.Sprintf` (Go)
@@ -954,19 +980,25 @@ function convertNode(node: ts.Node, raw: string): ParsedExpr {
954
980
  // refused: `isSupported` maps a regex-pattern `.replace` to the
955
981
  // deferred-form BF101 reason — the Perl `s///` vs Go
956
982
  // `regexp.ReplaceAllString` flavour gap is the open design
957
- // question in #1448. `replaceAll` stays refused entirely.
983
+ // question in #1448. `.replaceAll(pattern, replacement)` shares
984
+ // this arm (#2182 follow-up): same arity/regex/object-literal
985
+ // gates, only the `method` tag and its runtime helper differ —
986
+ // `bf_replace_all` (Go, `strings.ReplaceAll`) / `bf->replace_all`
987
+ // (Mojo) replace EVERY occurrence, where `.replace`'s helpers
988
+ // replace only the first.
958
989
  //
959
990
  // Full JS arity: a third+ argument is ignored (the adapter reads
960
991
  // only the pattern + replacement). The one- and zero-argument
961
992
  // forms are refused: JS coerces the missing replacement (and
962
993
  // pattern) to the literal string "undefined", a degenerate result
963
994
  // (mirrors the `.includes()` / `.startsWith()` zero-arg refusal).
964
- if (callee.property === 'replace') {
995
+ if (callee.property === 'replace' || callee.property === 'replaceAll') {
996
+ const method = callee.property
965
997
  if (args.length < 2) {
966
998
  return {
967
999
  kind: 'unsupported',
968
1000
  raw,
969
- reason: `\`.replace(${args.length === 0 ? '' : 'pattern'})\` needs both a pattern and a replacement — JS coerces the missing argument to the string "undefined", a degenerate result. Pass both arguments, or pre-compute the value before the template.`,
1001
+ reason: `\`.${method}(${args.length === 0 ? '' : 'pattern'})\` needs both a pattern and a replacement — JS coerces the missing argument to the string "undefined", a degenerate result. Pass both arguments, or pre-compute the value before the template.`,
970
1002
  }
971
1003
  }
972
1004
  // A regex-literal pattern is the deferred form (the Perl `s///`
@@ -980,7 +1012,7 @@ function convertNode(node: ts.Node, raw: string): ParsedExpr {
980
1012
  // any template use with the deferred-form reason.
981
1013
  const patternNode = node.arguments[0]
982
1014
  if (patternNode && ts.isRegularExpressionLiteral(patternNode)) {
983
- return { kind: 'array-method', method: 'replace', object: callee.object, args }
1015
+ return { kind: 'array-method', method, object: callee.object, args }
984
1016
  }
985
1017
  // Treat an object-literal argument like `unsupported` — a `.replace`
986
1018
  // with an object pattern/replacement isn't lowerable, same as before
@@ -995,7 +1027,7 @@ function convertNode(node: ts.Node, raw: string): ParsedExpr {
995
1027
  const reason = badArg.kind === 'unsupported' ? badArg.reason : 'Unsupported syntax: ObjectLiteralExpression'
996
1028
  return { kind: 'unsupported', raw, reason }
997
1029
  }
998
- return { kind: 'array-method', method: 'replace', object: callee.object, args }
1030
+ return { kind: 'array-method', method, object: callee.object, args }
999
1031
  }
1000
1032
  // `.repeat(n)` — string → string (the receiver concatenated `n`
1001
1033
  // times). Go uses `bf_repeat` (`strings.Repeat`, clamping a
@@ -1072,7 +1104,7 @@ function convertNode(node: ts.Node, raw: string): ParsedExpr {
1072
1104
  const property = node.name.text
1073
1105
 
1074
1106
  // Return as normal member - filter.length is handled in adapter
1075
- return { kind: 'member', object, property, computed: false }
1107
+ return { kind: 'member', object, property, computed: false, optional: !!node.questionDotToken }
1076
1108
  }
1077
1109
 
1078
1110
  // Element access: items[0], obj['key']
@@ -1088,10 +1120,10 @@ function convertNode(node: ts.Node, raw: string): ParsedExpr {
1088
1120
  }
1089
1121
  // For simple number/string access, store as property
1090
1122
  if (ts.isNumericLiteral(argNode)) {
1091
- return { kind: 'member', object, property: argNode.text, computed: true }
1123
+ return { kind: 'member', object, property: argNode.text, computed: true, optional: !!node.questionDotToken }
1092
1124
  }
1093
1125
  if (ts.isStringLiteral(argNode)) {
1094
- return { kind: 'member', object, property: argNode.text, computed: true }
1126
+ return { kind: 'member', object, property: argNode.text, computed: true, optional: !!node.questionDotToken }
1095
1127
  }
1096
1128
  // Variable / expression index (`selected()[index]`, `rows[i + 1]`):
1097
1129
  // carry the index as its own ParsedExpr so the adapter can lower it
@@ -2063,7 +2095,7 @@ function substituteDestructuredFields(
2063
2095
  // one-hop chain identical to the pre-#1530 shape.
2064
2096
  let node: ParsedExpr = { kind: 'identifier', name: syntheticParam }
2065
2097
  for (const segment of entry.path) {
2066
- node = { kind: 'member', object: node, property: segment, computed: false }
2098
+ node = { kind: 'member', object: node, property: segment, computed: false, optional: false }
2067
2099
  }
2068
2100
  // Default value (#1531): wrap the accessor in `?? <default>` so
2069
2101
  // a missing field falls back to the user-supplied literal /
@@ -2088,9 +2120,10 @@ function substituteDestructuredFields(
2088
2120
  object: { kind: 'identifier', name: syntheticParam },
2089
2121
  property: e.property,
2090
2122
  computed: false,
2123
+ optional: e.optional,
2091
2124
  }
2092
2125
  }
2093
- return { kind: 'member', object: walk(e.object), property: e.property, computed: e.computed }
2126
+ return { kind: 'member', object: walk(e.object), property: e.property, computed: e.computed, optional: e.optional }
2094
2127
  case 'index-access':
2095
2128
  return { kind: 'index-access', object: walk(e.object), index: walk(e.index) }
2096
2129
  case 'binary':
@@ -2236,15 +2269,16 @@ function checkSupport(expr: ParsedExpr): SupportResult {
2236
2269
  }
2237
2270
 
2238
2271
  case 'array-method': {
2239
- // A regex-pattern `.replace` is carried structurally (a `regex` first
2240
- // arg) but is the deferred form (#1448) — no template language lowers it.
2241
- // Refuse with the dedicated reason rather than the generic standalone-regex
2242
- // message, preserving the diagnostic the parser used to emit directly.
2243
- if (expr.method === 'replace' && expr.args[0]?.kind === 'regex') {
2272
+ // A regex-pattern `.replace` / `.replaceAll` is carried structurally
2273
+ // (a `regex` first arg) but is the deferred form (#1448) — no
2274
+ // template language lowers it. Refuse with the dedicated reason
2275
+ // rather than the generic standalone-regex message, preserving the
2276
+ // diagnostic the parser used to emit directly.
2277
+ if ((expr.method === 'replace' || expr.method === 'replaceAll') && expr.args[0]?.kind === 'regex') {
2244
2278
  return {
2245
2279
  supported: false,
2246
2280
  reason:
2247
- 'String.prototype.replace supports only a string pattern + string replacement (the regex form is deferred); use a string pattern or wrap the expression in /* @client */',
2281
+ `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 */`,
2248
2282
  }
2249
2283
  }
2250
2284
  const objSupport = checkSupport(expr.object)
@@ -2893,7 +2927,7 @@ function inlineBinding(
2893
2927
  case 'call':
2894
2928
  return { kind: 'call', callee: walk(e.callee, enclosing), args: e.args.map(a => walk(a, enclosing)) }
2895
2929
  case 'member':
2896
- return { kind: 'member', object: walk(e.object, enclosing), property: e.property, computed: e.computed }
2930
+ return { kind: 'member', object: walk(e.object, enclosing), property: e.property, computed: e.computed, optional: e.optional }
2897
2931
  case 'index-access':
2898
2932
  return { kind: 'index-access', object: walk(e.object, enclosing), index: walk(e.index, enclosing) }
2899
2933
  case 'binary':
@@ -3281,7 +3315,7 @@ export function materializeGetterCalls(expr: ParsedExpr, names: ReadonlySet<stri
3281
3315
  alternate: rw(expr.alternate),
3282
3316
  }
3283
3317
  case 'member':
3284
- return { kind: 'member', object: rw(expr.object), property: expr.property, computed: expr.computed }
3318
+ return { kind: 'member', object: rw(expr.object), property: expr.property, computed: expr.computed, optional: expr.optional }
3285
3319
  case 'index-access':
3286
3320
  return { kind: 'index-access', object: rw(expr.object), index: rw(expr.index) }
3287
3321
  case 'template-literal':
package/src/index.ts CHANGED
@@ -85,7 +85,7 @@ export type {
85
85
  export { JsxAdapter } from './adapters/jsx-adapter.ts'
86
86
  export type { JsxAdapterConfig } from './adapters/jsx-adapter.ts'
87
87
  export { rewriteImportsForTemplate } from './adapters/template-imports.ts'
88
- export { emitParsedExpr } from './adapters/parsed-expr-emitter.ts'
88
+ export { emitParsedExpr, groupBinaryOperand, isStringTypedOperand, isStringConcatBinary } from './adapters/parsed-expr-emitter.ts'
89
89
  export type { ParsedExprEmitter, HigherOrderMethod, ArrayMethod, SortMethod, LiteralType } from './adapters/parsed-expr-emitter.ts'
90
90
  export { importsSearchParams, searchParamsLocalNames, envSignalLocalNames, envSignalReaderFor, ENV_SIGNAL_READERS, queryHrefLocalNames, matchSearchParamsMethodCall } from './adapters/env-signal.ts'
91
91
  export type { EnvSignalReader } from './adapters/env-signal.ts'
@@ -390,6 +390,7 @@ export function collectInnerLoops(
390
390
  bodyIsMultiRoot: n.bodyIsMultiRoot,
391
391
  bodyIsItemConditional: n.bodyIsItemConditional,
392
392
  iterationShape: n.iterationShape,
393
+ objectIteration: n.objectIteration,
393
394
  containerSlotId: scope.parentSlotId,
394
395
  template,
395
396
  mapPreamble: n.mapPreamble,
@@ -743,6 +744,7 @@ export function collectElements(
743
744
  bodyIsMultiRoot: l.bodyIsMultiRoot,
744
745
  bodyIsItemConditional: l.bodyIsItemConditional,
745
746
  iterationShape: l.iterationShape,
747
+ objectIteration: l.objectIteration,
746
748
  template,
747
749
  staticItemTemplate,
748
750
  skeletonTemplate,
@@ -1096,6 +1098,7 @@ function collectBranchLoops(
1096
1098
  bodyIsMultiRoot: n.bodyIsMultiRoot,
1097
1099
  bodyIsItemConditional: n.bodyIsItemConditional,
1098
1100
  iterationShape: n.iterationShape,
1101
+ objectIteration: n.objectIteration,
1099
1102
  template: childTemplate,
1100
1103
  containerSlotId: containerSlot,
1101
1104
  mapPreamble: n.mapPreamble ?? null,
@@ -34,6 +34,7 @@ export function buildDynamicLoopDelegationPlan(
34
34
  param: elem.param,
35
35
  paramBindings: elem.paramBindings,
36
36
  key: elem.key,
37
+ index: elem.index,
37
38
  mapPreamble: elem.mapPreamble ?? null,
38
39
  }),
39
40
  }
@@ -58,6 +59,7 @@ export function buildBranchLoopDelegationPlan(
58
59
  param: loop.param,
59
60
  paramBindings: loop.paramBindings,
60
61
  key: loop.key,
62
+ index: loop.index,
61
63
  mapPreamble: loop.mapPreamble ?? null,
62
64
  }),
63
65
  }
@@ -86,6 +88,7 @@ export function buildStaticArrayDelegationPlan(
86
88
  param: elem.param,
87
89
  mapPreamble: elem.mapPreamble ?? null,
88
90
  offset: elem.offset ?? null,
91
+ indexParam: elem.index ?? null,
89
92
  },
90
93
  }
91
94
  }
@@ -99,6 +102,7 @@ function buildKeyedOrIndexLookup(args: {
99
102
  param: string
100
103
  paramBindings: TopLevelLoop['paramBindings']
101
104
  key: string | null
105
+ index: string | null
102
106
  mapPreamble: string | null
103
107
  }): ItemLookup {
104
108
  const hasBindings = (args.paramBindings?.length ?? 0) > 0
@@ -116,6 +120,7 @@ function buildKeyedOrIndexLookup(args: {
116
120
  keyWithItem,
117
121
  mapPreamble: args.mapPreamble,
118
122
  hasBindings,
123
+ indexParam: args.index,
119
124
  }
120
125
  }
121
126
  return {
@@ -124,6 +129,7 @@ function buildKeyedOrIndexLookup(args: {
124
129
  param: args.param,
125
130
  mapPreamble: args.mapPreamble,
126
131
  hasBindings,
132
+ indexParam: args.index,
127
133
  }
128
134
  }
129
135
 
@@ -51,6 +51,12 @@ export interface KeyedItemLookup {
51
51
  arrayExpr: string
52
52
  /** Loop param identifier (or destructure pattern text — used as receiver name only). */
53
53
  param: string
54
+ /**
55
+ * Loop index param name (e.g. `i` from `.map((item, i) => ...)`), or `null`.
56
+ * When a delegated handler closes over it, the stringifier re-derives the
57
+ * index at dispatch time and binds it so the reference resolves (#2189).
58
+ */
59
+ indexParam: string | null
54
60
  /** Destructured-binding metadata. Determines TDZ-safe `__bfLoopItem` shape (#951). */
55
61
  paramBindings: TopLevelLoop['paramBindings']
56
62
  /**
@@ -72,6 +78,8 @@ export interface DynamicIndexItemLookup {
72
78
  param: string
73
79
  mapPreamble: string | null
74
80
  hasBindings: boolean
81
+ /** Loop index param name — see `KeyedItemLookup.indexParam` (#2189). */
82
+ indexParam: string | null
75
83
  }
76
84
 
77
85
  export interface StaticIndexItemLookup {
@@ -79,6 +87,8 @@ export interface StaticIndexItemLookup {
79
87
  arrayExpr: string
80
88
  param: string
81
89
  mapPreamble: string | null
90
+ /** Loop index param name — see `KeyedItemLookup.indexParam` (#2189). */
91
+ indexParam: string | null
82
92
  /**
83
93
  * Offset of the loop's items past its preceding container siblings. Its
84
94
  * terms are subtracted from the DOM child index to recover the array index,
@@ -33,6 +33,7 @@
33
33
  */
34
34
 
35
35
  import { toDomEventName, varSlotId, substituteLoopBindings, buildLoopChildIndexSubtraction, DATA_KEY, keyAttrName } from '../../utils.ts'
36
+ import { extractFreeIdentifiersFromText } from '../../csr-substitute.ts'
36
37
  import type {
37
38
  EventDelegationPlan,
38
39
  KeyedItemLookup,
@@ -60,6 +61,19 @@ function withTurn(call: string, componentName: string | undefined, childSlotId:
60
61
  return `beginTurn(${id}); try { ${call} } finally { endTurn() }`
61
62
  }
62
63
 
64
+ /**
65
+ * Bind the loop index under the user's param name for a delegated handler that
66
+ * closes over it (#2189). Returns `null` when there is no index param, the
67
+ * handler doesn't reference it (so unaffected loops keep byte-for-byte output),
68
+ * or the name already equals the in-scope index var (a self-alias). Uses AST
69
+ * free identifiers, not a token match, so `item.id` (property) doesn't count.
70
+ */
71
+ function indexBindingLine(handler: string, indexParam: string | null, indexExpr: string): string | null {
72
+ if (!indexParam || indexParam === indexExpr) return null
73
+ if (!extractFreeIdentifiersFromText(handler).has(indexParam)) return null
74
+ return `const ${indexParam} = ${indexExpr}`
75
+ }
76
+
63
77
  export function stringifyEventDelegation(lines: string[], plan: EventDelegationPlan): void {
64
78
  const { containerVar, events, itemLookup, profileComponentName } = plan
65
79
  const eventsByName = new Map<string, LoopChildEvent[]>()
@@ -112,10 +126,11 @@ function emitKeyedLookup(
112
126
  handlerCall: string,
113
127
  lookup: KeyedItemLookup,
114
128
  ): void {
115
- const { arrayExpr, param, keyWithItem, mapPreamble, hasBindings } = lookup
129
+ const { arrayExpr, param, keyWithItem, mapPreamble, hasBindings, indexParam } = lookup
116
130
 
117
131
  if (ev.nestedLoops.length === 0) {
118
132
  // Single-level keyed lookup.
133
+ const idxLine = indexBindingLine(ev.handler, indexParam, `${arrayExpr}.findIndex(item => String(${keyWithItem}) === key)`)
119
134
  ls.push(` const li = ${varSlotId(ev.childSlotId)}El.closest('[${DATA_KEY}]')`)
120
135
  ls.push(` if (li) {`)
121
136
  ls.push(` const key = li.getAttribute('${DATA_KEY}')`)
@@ -125,12 +140,14 @@ function emitKeyedLookup(
125
140
  ls.push(` if (__bfLoopItem) {`)
126
141
  ls.push(` const ${param} = __bfLoopItem`)
127
142
  if (mapPreamble) ls.push(` ${mapPreamble}`)
143
+ if (idxLine) ls.push(` ${idxLine}`)
128
144
  ls.push(` ${handlerCall}`)
129
145
  ls.push(` }`)
130
146
  } else {
131
147
  ls.push(` const ${param} = ${arrayExpr}.find(item => String(${keyWithItem}) === key)`)
132
148
  if (mapPreamble) ls.push(` ${mapPreamble}`)
133
- ls.push(` if (${param}) ${handlerCall}`)
149
+ if (idxLine) ls.push(` if (${param}) { ${idxLine}; ${handlerCall} }`)
150
+ else ls.push(` if (${param}) ${handlerCall}`)
134
151
  }
135
152
  ls.push(` }`)
136
153
  return
@@ -164,7 +181,9 @@ function emitKeyedLookup(
164
181
  const outerGuard = hasBindings ? '__bfLoopItem' : param
165
182
  const allParams = [outerGuard, ...ev.nestedLoops.map(n => n.param)]
166
183
  if (mapPreamble) ls.push(` ${mapPreamble}`)
167
- ls.push(` if (${allParams.join(' && ')}) ${handlerCall}`)
184
+ const idxLine = indexBindingLine(ev.handler, indexParam, `${arrayExpr}.findIndex(item => String(${keyWithItem}) === outerKey)`)
185
+ if (idxLine) ls.push(` if (${allParams.join(' && ')}) { ${idxLine}; ${handlerCall} }`)
186
+ else ls.push(` if (${allParams.join(' && ')}) ${handlerCall}`)
168
187
  }
169
188
 
170
189
  function emitDynamicIndexLookup(
@@ -173,7 +192,8 @@ function emitDynamicIndexLookup(
173
192
  handlerCall: string,
174
193
  lookup: DynamicIndexItemLookup,
175
194
  ): void {
176
- const { arrayExpr, param, mapPreamble, hasBindings } = lookup
195
+ const { arrayExpr, param, mapPreamble, hasBindings, indexParam } = lookup
196
+ const idxLine = indexBindingLine(ev.handler, indexParam, 'idx')
177
197
  ls.push(` const li = ${varSlotId(ev.childSlotId)}El.closest('li, [bf-i]')`)
178
198
  ls.push(` if (li && li.parentElement) {`)
179
199
  ls.push(` const idx = Array.from(li.parentElement.children).indexOf(li)`)
@@ -182,12 +202,14 @@ function emitDynamicIndexLookup(
182
202
  ls.push(` if (__bfLoopItem) {`)
183
203
  ls.push(` const ${param} = __bfLoopItem`)
184
204
  if (mapPreamble) ls.push(` ${mapPreamble}`)
205
+ if (idxLine) ls.push(` ${idxLine}`)
185
206
  ls.push(` ${handlerCall}`)
186
207
  ls.push(` }`)
187
208
  } else {
188
209
  ls.push(` const ${param} = ${arrayExpr}[idx]`)
189
210
  if (mapPreamble) ls.push(` ${mapPreamble}`)
190
- ls.push(` if (${param}) ${handlerCall}`)
211
+ if (idxLine) ls.push(` if (${param}) { ${idxLine}; ${handlerCall} }`)
212
+ else ls.push(` if (${param}) ${handlerCall}`)
191
213
  }
192
214
  ls.push(` }`)
193
215
  }
@@ -199,7 +221,8 @@ function emitStaticIndexLookup(
199
221
  lookup: StaticIndexItemLookup,
200
222
  containerVar: string,
201
223
  ): void {
202
- const { arrayExpr, param, mapPreamble, offset } = lookup
224
+ const { arrayExpr, param, mapPreamble, offset, indexParam } = lookup
225
+ const idxLine = indexBindingLine(ev.handler, indexParam, '__idx')
203
226
  ls.push(` let __el = ${varSlotId(ev.childSlotId)}El`)
204
227
  ls.push(` while (__el.parentElement && __el.parentElement !== ${containerVar}) __el = __el.parentElement`)
205
228
  ls.push(` if (__el.parentElement === ${containerVar}) {`)
@@ -207,6 +230,7 @@ function emitStaticIndexLookup(
207
230
  ls.push(` const __idx = Array.from(${containerVar}.children).indexOf(__el)${idxOffset}`)
208
231
  ls.push(` const ${param} = ${arrayExpr}[__idx]`)
209
232
  if (mapPreamble) ls.push(` ${mapPreamble}`)
210
- ls.push(` if (${param}) ${handlerCall}`)
233
+ if (idxLine) ls.push(` if (${param}) { ${idxLine}; ${handlerCall} }`)
234
+ else ls.push(` if (${param}) ${handlerCall}`)
211
235
  ls.push(` }`)
212
236
  }
@@ -323,7 +323,7 @@ export function applyPropsRewrite(text: string, propsObjectName: string | null):
323
323
  return text.replace(new RegExp(`\\b${propsObjectName}\\.`, 'g'), `${PROPS_PARAM}.`)
324
324
  }
325
325
 
326
- function extractFreeIdentifiersFromText(text: string): Set<string> {
326
+ export function extractFreeIdentifiersFromText(text: string): Set<string> {
327
327
  if (!text || text.trim().length === 0) return new Set()
328
328
  const sf = ts.createSourceFile(
329
329
  '__free_ids__.ts',