@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
@@ -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
  /**
package/src/jsx-to-ir.ts CHANGED
@@ -48,6 +48,7 @@ import { resolveFreeRefs, isNameBound as isNameBoundInEnv, type BindingEnvironme
48
48
  import { computeFileScope } from './ir-to-client-js/component-scope.ts'
49
49
  import { extractFreeIdentifiersFromNode, initializerShapeContainsJsx } from './analyzer.ts'
50
50
  import { iterateJsTokens, replaceInExprContexts } from './scanner/js-scanner.ts'
51
+ import { toHTMLAttrName, decodeEntities } from '@barefootjs/shared'
51
52
 
52
53
  // =============================================================================
53
54
  // Transform Context
@@ -81,6 +82,17 @@ interface TransformContext {
81
82
  _destructuredPropNames?: Set<string> | null
82
83
  /** Active loop parameter names for slotId assignment to loop-param-dependent expressions */
83
84
  loopParams: Set<string>
85
+ /**
86
+ * Count of enclosing `.map()` loops (0 = outermost), incremented/
87
+ * decremented in lockstep with entering/leaving `transformMapCall`.
88
+ * Unlike `loopParams` (a name Set that can gain several entries for
89
+ * ONE loop level via destructuring), this is a plain per-level
90
+ * counter — the single source of truth `IRLoop.depth` is stamped
91
+ * from, so every adapter's `data-key`/`data-key-N` suffix derives
92
+ * from one IR-computed value instead of each adapter re-deriving
93
+ * nesting depth its own way (#2168 nested-loop-outer-binding).
94
+ */
95
+ loopDepth: number
84
96
  /** Counter for async boundary IDs (a0, a1, ...) */
85
97
  asyncIdCounter: number
86
98
  /** Counter for <Region> structural index (0, 1, ...) within a file. */
@@ -394,6 +406,7 @@ function createTransformContext(analyzer: AnalyzerContext): TransformContext {
394
406
  isRoot: true,
395
407
  insideComponentChildren: false,
396
408
  loopParams: new Set(),
409
+ loopDepth: 0,
397
410
  patterns: {
398
411
  signals: analyzer.signals.map(s => ({
399
412
  getter: s.getter,
@@ -1562,7 +1575,12 @@ function transformText(node: ts.JsxText, ctx: TransformContext): IRText | null {
1562
1575
 
1563
1576
  return {
1564
1577
  type: 'text',
1565
- value: text,
1578
+ // JSX decodes character references at parse time (`&copy;` IS the
1579
+ // text `©`), so the IR carries the DECODED value — the semantics —
1580
+ // and each adapter re-escapes for its own emission context.
1581
+ // Decode AFTER whitespace normalization: `&nbsp;` yields U+00A0,
1582
+ // which `\s+` would otherwise collapse into a plain space.
1583
+ value: decodeEntities(text),
1566
1584
  loc: getSourceLocation(node, ctx.sourceFile, ctx.filePath),
1567
1585
  }
1568
1586
  }
@@ -2482,6 +2500,31 @@ function isIteratorShapeCall(
2482
2500
  return { array: node.expression.expression, shape: name }
2483
2501
  }
2484
2502
 
2503
+ /**
2504
+ * Check if a node is the STATIC `Object.entries(x)` / `Object.keys(x)` /
2505
+ * `Object.values(x)` call form (#2168 object-entries-map) — the
2506
+ * one-argument form where `x` is a plain object/Record being iterated,
2507
+ * as opposed to {@link isIteratorShapeCall}'s zero-arg instance-method
2508
+ * form (`arr.entries()`) on an actual array. Returns the object
2509
+ * expression (any expression — `props.x`, `x ?? {}`, not just a bare
2510
+ * identifier) and the iteration shape so `transformMapCall` can strip
2511
+ * the `Object.<method>(...)` wrapper and record it on the IRLoop as
2512
+ * `objectIteration` (see that field's docstring in `types.ts` for why
2513
+ * this is a distinct field from `iterationShape`, not a shared one).
2514
+ */
2515
+ function isObjectIteratorCall(
2516
+ node: ts.Expression,
2517
+ ): { object: ts.Expression; shape: 'entries' | 'keys' | 'values' } | null {
2518
+ if (!ts.isCallExpression(node)) return null
2519
+ if (!ts.isPropertyAccessExpression(node.expression)) return null
2520
+ if (!ts.isIdentifier(node.expression.expression)) return null
2521
+ if (node.expression.expression.text !== 'Object') return null
2522
+ if (node.arguments.length !== 1) return null
2523
+ const name = node.expression.name.text
2524
+ if (name !== 'entries' && name !== 'keys' && name !== 'values') return null
2525
+ return { object: node.arguments[0], shape: name }
2526
+ }
2527
+
2485
2528
  type SortExtractionResult = {
2486
2529
  result: IRLoopSort | null
2487
2530
  unsupportedReason?: string
@@ -3245,6 +3288,10 @@ function transformMapCall(
3245
3288
  // Capture nesting depth before we register this map's own params.
3246
3289
  // ctx.loopParams is populated by the *outer* map; if non-empty we are inside one.
3247
3290
  const isNested = ctx.loopParams.size > 0
3291
+ // This loop's own depth (0 = outermost) is however many enclosing
3292
+ // loops are already active, captured before `ctx.loopDepth` below is
3293
+ // bumped for THIS loop's own descendants.
3294
+ const depth = ctx.loopDepth
3248
3295
 
3249
3296
  const propAccess = node.expression as ts.PropertyAccessExpression
3250
3297
  const mapSource = propAccess.expression
@@ -3270,6 +3317,7 @@ function transformMapCall(
3270
3317
  let templateMapPreamble: string | undefined
3271
3318
  let typedMapPreamble: string | undefined
3272
3319
  let iterationShape: 'entries' | 'keys' | undefined
3320
+ let objectIteration: 'entries' | 'keys' | 'values' | undefined
3273
3321
 
3274
3322
  // Helper to set both array and templateArray
3275
3323
  const setArray = (node: ts.Expression) => {
@@ -3283,8 +3331,11 @@ function transformMapCall(
3283
3331
  // adapters emit the right loop variable bindings. `.values()` is a
3284
3332
  // no-op (same as plain `.map()`) so it's stripped but not recorded.
3285
3333
  // The inner expression (after stripping) feeds into the standard
3286
- // filter/sort chain detection below.
3287
- let chainSource = mapSource
3334
+ // filter/sort chain detection below. Widened to `ts.Expression` (not
3335
+ // narrowed to `mapSource`'s own `LeftHandSideExpression` type) since
3336
+ // `isObjectIteratorCall`'s stripped argument can be any expression
3337
+ // (`x ?? {}`, not just a `LeftHandSideExpression`).
3338
+ let chainSource: ts.Expression = mapSource
3288
3339
  const iteratorInfo = isIteratorShapeCall(mapSource)
3289
3340
  if (iteratorInfo) {
3290
3341
  chainSource = iteratorInfo.array
@@ -3294,6 +3345,18 @@ function transformMapCall(
3294
3345
  iterationShape = 'keys'
3295
3346
  }
3296
3347
  // 'values' is a no-op — same as plain .map()
3348
+ } else {
3349
+ // Detect the STATIC `Object.entries(x)` / `.keys(x)` / `.values(x)`
3350
+ // form (#2168 object-entries-map) — see `isObjectIteratorCall`'s and
3351
+ // `IRLoop.objectIteration`'s docstrings for why this is a SEPARATE
3352
+ // shape from the array-instance-method case above, not a shared one.
3353
+ // Unlike that case, `'values'` DOES need recording here (it isn't a
3354
+ // no-op: `x` itself isn't iterable as a plain object).
3355
+ const objectIteratorInfo = isObjectIteratorCall(mapSource)
3356
+ if (objectIteratorInfo) {
3357
+ chainSource = objectIteratorInfo.object
3358
+ objectIteration = objectIteratorInfo.shape
3359
+ }
3297
3360
  }
3298
3361
 
3299
3362
  const filterInfo = isFilterCall(chainSource)
@@ -3444,8 +3507,12 @@ function transformMapCall(
3444
3507
  // `.entries()` synthesises `[index, value]` — when the callback
3445
3508
  // destructures exactly two array elements, extract the names into
3446
3509
  // `index` and `param` so the loop renders with proper bindings and
3447
- // the BF104 destructure-param refusal doesn't fire.
3448
- if (iterationShape === 'entries' && ts.isArrayBindingPattern(firstParam.name)) {
3510
+ // the BF104 destructure-param refusal doesn't fire. `Object.entries(x)`
3511
+ // (`objectIteration === 'entries'`) synthesises the SAME `[key,
3512
+ // value]` 2-tuple shape — `index` just holds a string key instead
3513
+ // of a numeric position — so it reuses this exact extraction.
3514
+ const isEntriesShape = iterationShape === 'entries' || objectIteration === 'entries'
3515
+ if (isEntriesShape && ts.isArrayBindingPattern(firstParam.name)) {
3449
3516
  const elements = firstParam.name.elements.filter(
3450
3517
  el => !ts.isOmittedExpression(el),
3451
3518
  )
@@ -3484,7 +3551,7 @@ function transformMapCall(
3484
3551
  }
3485
3552
  }
3486
3553
  }
3487
- if (callback.parameters.length > 1 && iterationShape !== 'entries') {
3554
+ if (callback.parameters.length > 1 && iterationShape !== 'entries' && objectIteration !== 'entries') {
3488
3555
  const secondParam = callback.parameters[1]
3489
3556
  index = secondParam.name.getText(ctx.sourceFile)
3490
3557
  if (secondParam.type) {
@@ -3503,6 +3570,7 @@ function transformMapCall(
3503
3570
  ctx.loopParams.add(param)
3504
3571
  }
3505
3572
  if (index) ctx.loopParams.add(index)
3573
+ ctx.loopDepth++
3506
3574
 
3507
3575
  // Logical control flow (`cond && <X/>`, `a ?? themeLogo()`) as the map
3508
3576
  // body. This is not a JSX literal, ternary, or block, so without this
@@ -3625,6 +3693,7 @@ function transformMapCall(
3625
3693
  ctx.loopParams.delete(param)
3626
3694
  }
3627
3695
  if (index) ctx.loopParams.delete(index)
3696
+ ctx.loopDepth--
3628
3697
  }
3629
3698
 
3630
3699
  // If no JSX children were found (e.g., callback returns a function call),
@@ -3704,6 +3773,16 @@ function transformMapCall(
3704
3773
  !isSignalOrMemoArray(array, ctx)
3705
3774
  && !isDirectPropArray
3706
3775
  && !hasCalls
3776
+ // `objectIteration` (#2168 object-entries-map): `array` here is the
3777
+ // STRIPPED object expression (`Object.entries(x)`'s `x`), which can
3778
+ // itself be a static module-scope const object literal and would
3779
+ // otherwise satisfy every check above — but a plain OBJECT has no
3780
+ // `.forEach()`/`.map()` (the static-array client codegen's own
3781
+ // methods), unlike an actual array literal. Force the dynamic
3782
+ // `mapArray()` path instead, which this shape's client-JS array-expr
3783
+ // reconstruction (`applyObjectIterationWrap`, `ir-to-client-js/utils.ts`)
3784
+ // already handles correctly.
3785
+ && !objectIteration
3707
3786
 
3708
3787
  // Collect nested components for both static and dynamic arrays.
3709
3788
  // Static arrays: needed for initChild hydration.
@@ -3743,6 +3822,8 @@ function transformMapCall(
3743
3822
  sortComparator,
3744
3823
  chainOrder,
3745
3824
  iterationShape,
3825
+ objectIteration,
3826
+ depth,
3746
3827
  clientOnly: isClientOnly || undefined,
3747
3828
  mapPreamble,
3748
3829
  templateMapPreamble,
@@ -4066,13 +4147,13 @@ function processAttributes(
4066
4147
 
4067
4148
  if (!ts.isJsxAttribute(attr)) continue
4068
4149
 
4069
- const name = attr.name.getText(ctx.sourceFile)
4150
+ const rawName = attr.name.getText(ctx.sourceFile)
4070
4151
 
4071
4152
  // ref is captured separately (not pushed into `attrs`) because it never
4072
4153
  // appears in the rendered HTML — it's a compile-time binding from the
4073
4154
  // JSX call site to the runtime DOM element, surfaced via the IRElement
4074
4155
  // `ref` field for the client-JS emitter.
4075
- if (name === 'ref') {
4156
+ if (rawName === 'ref') {
4076
4157
  if (attr.initializer && ts.isJsxExpression(attr.initializer) && attr.initializer.expression) {
4077
4158
  reportJsxBranchLocalInCallback(attr.initializer.expression, ctx)
4078
4159
  ref = ctx.getJS(attr.initializer.expression)
@@ -4084,13 +4165,13 @@ function processAttributes(
4084
4165
  // they're wired up at hydration time (delegated event registration) and
4085
4166
  // must not leak into rendered HTML. The DOM event name is lowercase
4086
4167
  // (`click`, not `Click`), so strip the `on` prefix and downcase.
4087
- if (/^on[A-Z]/.test(name)) {
4168
+ if (/^on[A-Z]/.test(rawName)) {
4088
4169
  if (attr.initializer && ts.isJsxExpression(attr.initializer) && attr.initializer.expression) {
4089
- const eventName = name.slice(2).toLowerCase()
4170
+ const eventName = rawName.slice(2).toLowerCase()
4090
4171
  reportJsxBranchLocalInCallback(attr.initializer.expression, ctx)
4091
4172
  events.push({
4092
4173
  name: eventName,
4093
- originalAttr: name,
4174
+ originalAttr: rawName,
4094
4175
  handler: ctx.getJS(attr.initializer.expression),
4095
4176
  loc: getSourceLocation(attr, ctx.sourceFile, ctx.filePath),
4096
4177
  })
@@ -4098,6 +4179,19 @@ function processAttributes(
4098
4179
  continue
4099
4180
  }
4100
4181
 
4182
+ // Normalize the JSX prop spelling to the HTML/SVG attribute name ONCE,
4183
+ // here in Phase 1, so IRAttribute.name is already the name every
4184
+ // adapter emits verbatim (#2172): React-style HTML camelCase aliases
4185
+ // lower (`htmlFor` → `for`, `tabIndex` → `tabindex`, `readOnly` →
4186
+ // the BOOLEAN_ATTRS member `readonly`), SVG presentation attrs
4187
+ // kebab-case (`strokeWidth` → `stroke-width`), case-sensitive SVG XML
4188
+ // names (`viewBox`) and everything unknown (`data-*`, custom-element
4189
+ // attrs) pass through. Previously each adapter re-derived (at most)
4190
+ // `className` → `class` itself and every other alias leaked into the
4191
+ // emitted HTML as an unknown attribute the browser ignores. Intrinsic
4192
+ // elements only — component props (IRProp) keep the user's API names.
4193
+ const name = toHTMLAttrName(rawName)
4194
+
4101
4195
  let value = getAttributeValue(attr, ctx)
4102
4196
  let clientOnly: boolean | undefined
4103
4197
  if (attr.initializer && ts.isJsxExpression(attr.initializer) && attr.initializer.expression) {
@@ -4143,9 +4237,12 @@ function getAttributeValue(attr: ts.JsxAttribute, ctx: TransformContext): AttrVa
4143
4237
  return AttrValueOf.booleanAttr()
4144
4238
  }
4145
4239
 
4146
- // String literal: <div id="main" />
4240
+ // String literal: <div id="main" />. JSX decodes character references
4241
+ // in quoted attribute values just like in text children, so the IR
4242
+ // carries the decoded string (`title="Fish &amp; Chips"` IS the value
4243
+ // `Fish & Chips`); adapters re-escape on emit.
4147
4244
  if (ts.isStringLiteral(attr.initializer)) {
4148
- return AttrValueOf.literal(attr.initializer.text)
4245
+ return AttrValueOf.literal(decodeEntities(attr.initializer.text))
4149
4246
  }
4150
4247
 
4151
4248
  // Expression: <div class={className} />
package/src/types.ts CHANGED
@@ -574,6 +574,74 @@ export interface IRLoop {
574
574
  */
575
575
  iterationShape?: 'entries' | 'keys'
576
576
 
577
+ /**
578
+ * Pre-`.map()` object iteration (#2168 object-entries-map). Distinct
579
+ * from {@link iterationShape}, which is scoped ENTIRELY to an array's
580
+ * own zero-arg `.entries()`/`.keys()`/`.values()` methods — those
581
+ * synthesize a real numeric index off the array's position, and every
582
+ * adapter's consumption of `iterationShape` assumes an actual
583
+ * array/slice underneath.
584
+ *
585
+ * `objectIteration` instead records the STATIC `Object.entries(x)` /
586
+ * `Object.keys(x)` / `Object.values(x)` call form, where `x` is a
587
+ * plain object/Record (not an array): the "index" bound for `'entries'`
588
+ * is a STRING KEY, not a numeric position, and the collection an
589
+ * adapter must iterate is its native map/dict/hash type, not an
590
+ * array/slice. `transformMapCall` strips the `Object.<method>(...)`
591
+ * wrapper the same way it strips `arr.entries()` — `array`/`arrayParsed`
592
+ * end up holding just `x` — and records the shape here so each
593
+ * adapter's loop renderer picks the right native construct:
594
+ *
595
+ * - `'entries'` → both `index` (bound to the KEY) and `param` (bound
596
+ * to the VALUE), synthesized from the 2-element destructure the
597
+ * same way `iterationShape: 'entries'` is (see `jsx-to-ir.ts`'s
598
+ * `transformMapCall`) — e.g. Jinja `for k, v in x.items()`.
599
+ * - `'keys'` → `param` bound to the key only — e.g. Jinja
600
+ * `for k in x.keys()`.
601
+ * - `'values'` → `param` bound to the value only — e.g. Jinja
602
+ * `for v in x.values()`. Unlike the array case, `'values'` is NOT
603
+ * a no-op here: `Object.values(x)` genuinely differs from
604
+ * iterating `x` itself (`x` isn't iterable at all as a plain
605
+ * object), so it must be recorded.
606
+ *
607
+ * Iteration ORDER is native-map-dependent: Python `dict`/PHP
608
+ * array-object/Ruby `Hash` preserve the source object's insertion
609
+ * order (matching JS `Object.entries()` semantics exactly), so Jinja,
610
+ * Twig, Blade, and ERB lower directly to their native map/dict/hash
611
+ * iteration. Go's `map[string]T`, Rust's `BTreeMap` (deliberate design,
612
+ * see `num.rs`), and Perl's hash (Xslate/Mojolicious) have NO
613
+ * order-preserving native map type, so those four instead lower to a
614
+ * DETERMINISTIC SORTED-BY-KEY iteration — Go's `{{range}}` (the
615
+ * stdlib's own `fmtsort`), minijinja's `BTreeMap` (already sorted),
616
+ * Kolon's `.kv()`/`.keys()`/`.values()` (verified empirically sorted),
617
+ * and Mojolicious's explicit `sort keys %$hash` (mirroring the
618
+ * existing `spread_attrs`/`_style_to_css` convention in
619
+ * `BarefootJS.pm`). This is a documented, permanent known limitation
620
+ * relative to JS's insertion-order guarantee (not a follow-up TODO) —
621
+ * true insertion order is physically unrecoverable from those
622
+ * languages' native map types once constructed, so sorted order is the
623
+ * best available deterministic approximation, not an interim refusal.
624
+ *
625
+ * Only the OUTERMOST, unchained `Object.<method>(x).map(cb)` shape is
626
+ * recognized — mirroring `iterationShape`'s own scope, chaining
627
+ * (`Object.entries(x).filter(pred).map(cb)`) is not (yet) recognized
628
+ * either, same as the array case.
629
+ */
630
+ objectIteration?: 'entries' | 'keys' | 'values'
631
+
632
+ /**
633
+ * Count of enclosing `.map()` loops (0 = outermost, 1 = nested one
634
+ * level deep, ...). Adapters use this to derive the loop body's
635
+ * `key`/`data-key` attribute suffix — `depth > 0 ? 'data-key-' +
636
+ * depth : 'data-key'` — matching `keyAttrName()` in
637
+ * `ir-to-client-js/utils.ts`, which the CSR path and the Hono SSR
638
+ * adapter each already derive independently (a recursion counter and
639
+ * a push/pop stack respectively). Before this field, the 8 template
640
+ * (non-JS) adapters had no depth awareness at all and always emitted
641
+ * plain `data-key` on nested-loop items (#2168 nested-loop-outer-binding).
642
+ */
643
+ depth: number
644
+
577
645
  /**
578
646
  * When true, loop should be evaluated on client side only.
579
647
  * SSR adapters should skip rendering and output placeholder markers.