@barefootjs/jsx 0.18.4 → 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 (37) hide show
  1. package/dist/adapters/parsed-expr-emitter.d.ts +2 -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.js +141 -44
  6. package/dist/ir-to-client-js/collect-elements.d.ts.map +1 -1
  7. package/dist/ir-to-client-js/control-flow/plan/build-event-delegation.d.ts.map +1 -1
  8. package/dist/ir-to-client-js/control-flow/plan/event-delegation.d.ts +10 -0
  9. package/dist/ir-to-client-js/control-flow/plan/event-delegation.d.ts.map +1 -1
  10. package/dist/ir-to-client-js/control-flow/stringify/event-delegation.d.ts.map +1 -1
  11. package/dist/ir-to-client-js/csr-substitute.d.ts +1 -0
  12. package/dist/ir-to-client-js/csr-substitute.d.ts.map +1 -1
  13. package/dist/ir-to-client-js/html-template.d.ts.map +1 -1
  14. package/dist/ir-to-client-js/types.d.ts +9 -0
  15. package/dist/ir-to-client-js/types.d.ts.map +1 -1
  16. package/dist/ir-to-client-js/utils.d.ts +25 -0
  17. package/dist/ir-to-client-js/utils.d.ts.map +1 -1
  18. package/dist/jsx-to-ir.d.ts.map +1 -1
  19. package/dist/types.d.ts +66 -0
  20. package/dist/types.d.ts.map +1 -1
  21. package/package.json +2 -2
  22. package/src/__tests__/event-delegation-index-param.test.ts +130 -0
  23. package/src/__tests__/expression-parser.test.ts +38 -0
  24. package/src/__tests__/ir-walker.test.ts +1 -0
  25. package/src/__tests__/materialize-getter-calls.test.ts +1 -0
  26. package/src/adapters/parsed-expr-emitter.ts +10 -1
  27. package/src/expression-parser.ts +59 -25
  28. package/src/ir-to-client-js/collect-elements.ts +3 -0
  29. package/src/ir-to-client-js/control-flow/plan/build-event-delegation.ts +6 -0
  30. package/src/ir-to-client-js/control-flow/plan/event-delegation.ts +10 -0
  31. package/src/ir-to-client-js/control-flow/stringify/event-delegation.ts +31 -7
  32. package/src/ir-to-client-js/csr-substitute.ts +1 -1
  33. package/src/ir-to-client-js/html-template.ts +57 -11
  34. package/src/ir-to-client-js/types.ts +10 -0
  35. package/src/ir-to-client-js/utils.ts +34 -1
  36. package/src/jsx-to-ir.ts +92 -9
  37. package/src/types.ts +68 -0
@@ -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':
@@ -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',
@@ -10,7 +10,7 @@ import { nameForRegistryRef } from './component-scope.ts'
10
10
  import { assertNever } from './walker.ts'
11
11
  import { buildSignalMemoEnv, csrSubstitute, applyPropsRewrite, type CsrEnv } from './csr-substitute.ts'
12
12
  import type { ClientJsContext } from './types.ts'
13
- import { BF_PARENT_SCOPE_PLACEHOLDER, BF_SCOPE } from '@barefootjs/shared'
13
+ import { BF_PARENT_SCOPE_PLACEHOLDER, BF_SCOPE, escapeHtml } from '@barefootjs/shared'
14
14
  import { buildLoopChainExpr } from '../loop-chain.ts'
15
15
 
16
16
  /**
@@ -145,6 +145,42 @@ function applyIterationShape(
145
145
  callbackParam: `(${node.param})`,
146
146
  }
147
147
  }
148
+ // `objectIteration` (#2168 object-entries-map): reconstruct the STATIC
149
+ // `Object.entries/keys/values(x)` call the compiler stripped at IR-build
150
+ // time (`isObjectIteratorCall`, `jsx-to-ir.ts`) — `arrayExpr` here is just
151
+ // `x` (the plain object), so the client, unlike a template adapter,
152
+ // re-wraps it in real JS to get the actual entries/keys/values array
153
+ // (this runs in a real JS engine, so no per-language lowering is needed).
154
+ //
155
+ // Unlike the array `iterationShape` case, the 'entries' ARRAY wrap does
156
+ // NOT require `node.index` — that field is only populated for a CLEAN
157
+ // 2-identifier destructure (`([word, n]) => …`); an elided/nested
158
+ // pattern (`([, cfg]) => …`) falls through to the generic `paramBindings`
159
+ // machinery instead (`node.param` stays the raw destructure TEXT, e.g.
160
+ // `"[, cfg]"`, which is already a syntactically valid callback param that
161
+ // correctly destructures a `[key, value]` pair — see the trailing
162
+ // fallback below). Only the ARRAY needs wrapping in that case; the
163
+ // callback param is unaffected either way.
164
+ if (node.objectIteration === 'entries') {
165
+ return {
166
+ array: `Object.entries(${arrayExpr})`,
167
+ callbackParam: node.index
168
+ ? `([${node.index}, ${node.param}])`
169
+ : `(${node.param}${indexParam})`,
170
+ }
171
+ }
172
+ if (node.objectIteration === 'keys') {
173
+ return {
174
+ array: `Object.keys(${arrayExpr})`,
175
+ callbackParam: `(${node.param})`,
176
+ }
177
+ }
178
+ if (node.objectIteration === 'values') {
179
+ return {
180
+ array: `Object.values(${arrayExpr})`,
181
+ callbackParam: `(${node.param})`,
182
+ }
183
+ }
148
184
  return { array: arrayExpr, callbackParam: `(${node.param}${indexParam})` }
149
185
  }
150
186
 
@@ -313,7 +349,7 @@ function renderTemplateAttrPart(
313
349
  case 'boolean-attr':
314
350
  return attrName
315
351
  case 'literal':
316
- return `${attrName}="${v.value}"`
352
+ return `${attrName}="${escapeHtml(v.value)}"`
317
353
  case 'expression': {
318
354
  const valExpr = wrap(v.expr)
319
355
  return templateAttrExpr(attrName, valExpr, v.presenceOrUndefined)
@@ -550,7 +586,9 @@ export function irToHtmlTemplate(node: IRNode, restSpreadNames?: Set<string>, lo
550
586
  }
551
587
 
552
588
  case 'text':
553
- return node.value
589
+ // IRText carries the entity-DECODED value; this string is parsed
590
+ // as HTML (template.innerHTML), so re-escape for the HTML parser.
591
+ return escapeHtml(node.value)
554
592
 
555
593
  case 'expression':
556
594
  if (node.expr === 'null' || node.expr === 'undefined') return ''
@@ -757,7 +795,7 @@ export function buildLoopSkeletonTemplate(node: IRNode, safe: LoopSkeletonSafeSl
757
795
  const v = a.value
758
796
  switch (v.kind) {
759
797
  case 'literal':
760
- attrParts.push(`${toHtmlAttrName(a.name)}="${v.value}"`)
798
+ attrParts.push(`${toHtmlAttrName(a.name)}="${escapeHtml(v.value)}"`)
761
799
  break
762
800
  case 'boolean-attr':
763
801
  attrParts.push(toHtmlAttrName(a.name))
@@ -796,7 +834,9 @@ export function buildLoopSkeletonTemplate(node: IRNode, safe: LoopSkeletonSafeSl
796
834
  }
797
835
 
798
836
  case 'text':
799
- return node.value
837
+ // IRText carries the entity-DECODED value; this string is parsed
838
+ // as HTML (template.innerHTML), so re-escape for the HTML parser.
839
+ return escapeHtml(node.value)
800
840
 
801
841
  case 'expression':
802
842
  if (node.expr === 'null' || node.expr === 'undefined') return ''
@@ -870,7 +910,9 @@ export function irToPlaceholderTemplate(node: IRNode, restSpreadNames?: Set<stri
870
910
  }
871
911
 
872
912
  case 'text':
873
- return node.value
913
+ // IRText carries the entity-DECODED value; this string is parsed
914
+ // as HTML (template.innerHTML), so re-escape for the HTML parser.
915
+ return escapeHtml(node.value)
874
916
 
875
917
  case 'expression':
876
918
  if (node.expr === 'null' || node.expr === 'undefined') return ''
@@ -1228,7 +1270,7 @@ function irToComponentTemplateWithOpts(node: IRNode, opts: TemplateOptions): str
1228
1270
  return templateAttrExpr(keyName, transformExpr(tmplStr))
1229
1271
  }
1230
1272
  case 'literal':
1231
- return `${keyName}="${v.value}"`
1273
+ return `${keyName}="${escapeHtml(v.value)}"`
1232
1274
  default:
1233
1275
  return ''
1234
1276
  }
@@ -1238,7 +1280,7 @@ function irToComponentTemplateWithOpts(node: IRNode, opts: TemplateOptions): str
1238
1280
  case 'boolean-attr':
1239
1281
  return attrName
1240
1282
  case 'literal':
1241
- return `${attrName}="${v.value}"`
1283
+ return `${attrName}="${escapeHtml(v.value)}"`
1242
1284
  case 'expression':
1243
1285
  return templateAttrExpr(attrName, transformExpr(v.expr, v.templateExpr), v.presenceOrUndefined)
1244
1286
  case 'template': {
@@ -1269,7 +1311,9 @@ function irToComponentTemplateWithOpts(node: IRNode, opts: TemplateOptions): str
1269
1311
  }
1270
1312
 
1271
1313
  case 'text':
1272
- return node.value
1314
+ // IRText carries the entity-DECODED value; this string is parsed
1315
+ // as HTML (template.innerHTML), so re-escape for the HTML parser.
1316
+ return escapeHtml(node.value)
1273
1317
 
1274
1318
  case 'expression':
1275
1319
  if (node.expr === 'null' || node.expr === 'undefined') return ''
@@ -1806,7 +1850,7 @@ function generateCsrTemplateWithOpts(node: IRNode, opts: TemplateOptions): strin
1806
1850
  case 'boolean-attr':
1807
1851
  return attrName
1808
1852
  case 'literal':
1809
- return `${attrName}="${v.value}"`
1853
+ return `${attrName}="${escapeHtml(v.value)}"`
1810
1854
  case 'expression':
1811
1855
  return templateAttrExpr(attrName, transformExpr(v.expr, v.templateExpr), v.presenceOrUndefined)
1812
1856
  case 'template': {
@@ -1837,7 +1881,9 @@ function generateCsrTemplateWithOpts(node: IRNode, opts: TemplateOptions): strin
1837
1881
  }
1838
1882
 
1839
1883
  case 'text':
1840
- return node.value
1884
+ // IRText carries the entity-DECODED value; this string is parsed
1885
+ // as HTML (template.innerHTML), so re-escape for the HTML parser.
1886
+ return escapeHtml(node.value)
1841
1887
 
1842
1888
  case 'expression':
1843
1889
  if (node.expr === 'null' || node.expr === 'undefined') return ''
@@ -283,6 +283,16 @@ export interface LoopCore {
283
283
  * (#1448 Tier B). Threaded from `IRLoop.iterationShape`.
284
284
  */
285
285
  iterationShape?: 'entries' | 'keys'
286
+
287
+ /**
288
+ * Object iteration shape from the STATIC `Object.entries(x)` /
289
+ * `.keys(x)` / `.values(x)` call form (#2168 object-entries-map) —
290
+ * distinct from {@link iterationShape} for the same reason `IRLoop`'s
291
+ * own field is (see that field's docstring, `packages/jsx/src/types.ts`):
292
+ * `x` is a plain object, not an array. Threaded from
293
+ * `IRLoop.objectIteration`.
294
+ */
295
+ objectIteration?: 'entries' | 'keys' | 'values'
286
296
  }
287
297
 
288
298
  /**
@@ -147,6 +147,38 @@ export function exhaustiveAttrValue(value: never): never {
147
147
  throw new Error(`Unhandled AttrValue kind: ${JSON.stringify(value)}`)
148
148
  }
149
149
 
150
+ /**
151
+ * Reconstruct the `Object.entries/keys/values(x)` call the compiler
152
+ * stripped off at IR-build time (`isObjectIteratorCall`, `jsx-to-ir.ts`)
153
+ * for the CLIENT's array expression — unlike a template adapter, the
154
+ * client runs real JS, so it just re-wraps the plain object `x` (the
155
+ * loop's `array`) to get back the actual iterable `mapArray` needs.
156
+ *
157
+ * Deliberately does NOT also handle the array-only `iterationShape`
158
+ * (`arr.entries()`/`.keys()`) — that shape's `mapArray` callback already
159
+ * gets what it needs from `mapArray`'s own native `(value, index)`
160
+ * signature (the compiler synthesizes `param`/`index` to match those two
161
+ * positions directly), so wrapping the array there would double up. See
162
+ * `IRLoop.objectIteration`'s docstring (`types.ts`) for why the two
163
+ * fields are distinct, and `applyIterationShape` in `html-template.ts`
164
+ * (a different consumer — the CSR template-literal's own inline `.map()`
165
+ * — which does handle both fields, since its shape doesn't reuse
166
+ * `mapArray`'s positional signature).
167
+ *
168
+ * The callback's OWN head (`__bfItem` vs a plain param name) is unrelated
169
+ * and unaffected — that's `destructureLoopParam`'s job
170
+ * (`control-flow/shared.ts`), driven entirely by `param`/`paramBindings`.
171
+ */
172
+ export function applyObjectIterationWrap(
173
+ node: { objectIteration?: 'entries' | 'keys' | 'values' },
174
+ arrayExpr: string,
175
+ ): string {
176
+ if (node.objectIteration === 'entries') return `Object.entries(${arrayExpr})`
177
+ if (node.objectIteration === 'keys') return `Object.keys(${arrayExpr})`
178
+ if (node.objectIteration === 'values') return `Object.values(${arrayExpr})`
179
+ return arrayExpr
180
+ }
181
+
150
182
  /**
151
183
  * Build the chained array expression for reconcileList. Thin
152
184
  * adapter over `buildLoopChainExpr` that unpacks the collected
@@ -156,12 +188,13 @@ export function exhaustiveAttrValue(value: never): never {
156
188
  * branch preserves the chain (#1434).
157
189
  */
158
190
  export function buildChainedArrayExpr(elem: TopLevelLoop | BranchLoop): string {
159
- return buildLoopChainExpr({
191
+ const chained = buildLoopChainExpr({
160
192
  base: elem.array,
161
193
  sortComparator: elem.sortComparator,
162
194
  filterPredicate: elem.filterPredicate,
163
195
  chainOrder: elem.chainOrder,
164
196
  })
197
+ return applyObjectIterationWrap(elem, chained)
165
198
  }
166
199
 
167
200
  /**