@barefootjs/jsx 0.20.0 → 0.21.2

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.
@@ -186,6 +186,53 @@ type MissingFromKindRegistry = Exclude<ParsedExpr['kind'], (typeof PARSED_EXPR_K
186
186
  const _kindRegistryIsExhaustive: MissingFromKindRegistry extends never ? true : never = true
187
187
  void _kindRegistryIsExhaustive
188
188
 
189
+ /**
190
+ * Runtime registry of the catalogued `array-method` names — the positive
191
+ * counterpart to `UNSUPPORTED_METHODS`'s negative gate, and the denominator
192
+ * the coverage ledger's array-method floor test (`coverage-map.test.ts`)
193
+ * requires a covering fixture for. This is the catalogue half of the
194
+ * change-time coupling rule (`spec/subset-conformance.md` / `CLAUDE.md`):
195
+ * the exhaustiveness pin below makes adding a method to the `array-method`
196
+ * union without listing it here a compile error, and the floor test then
197
+ * makes shipping a listed method with no fixture a test failure.
198
+ */
199
+ export const ARRAY_METHOD_NAMES = [
200
+ 'join',
201
+ 'includes',
202
+ 'indexOf',
203
+ 'lastIndexOf',
204
+ 'at',
205
+ 'concat',
206
+ 'slice',
207
+ 'reverse',
208
+ 'toReversed',
209
+ 'toLowerCase',
210
+ 'toUpperCase',
211
+ 'trim',
212
+ 'trimStart',
213
+ 'trimEnd',
214
+ 'toFixed',
215
+ 'split',
216
+ 'startsWith',
217
+ 'endsWith',
218
+ 'replace',
219
+ 'replaceAll',
220
+ 'repeat',
221
+ 'padStart',
222
+ 'padEnd',
223
+ 'flat',
224
+ ] as const satisfies ReadonlyArray<Extract<ParsedExpr, { kind: 'array-method' }>['method']>
225
+
226
+ // Exhaustiveness pin: adding a method to the `array-method` union (either
227
+ // variant) without listing it in `ARRAY_METHOD_NAMES` fails to compile here
228
+ // (same drift defence as `PARSED_EXPR_KINDS`).
229
+ type MissingFromArrayMethodRegistry = Exclude<
230
+ Extract<ParsedExpr, { kind: 'array-method' }>['method'],
231
+ (typeof ARRAY_METHOD_NAMES)[number]
232
+ >
233
+ const _arrayMethodRegistryIsExhaustive: MissingFromArrayMethodRegistry extends never ? true : never = true
234
+ void _arrayMethodRegistryIsExhaustive
235
+
189
236
  /**
190
237
  * One property of an `object-literal` `ParsedExpr`. The key is the
191
238
  * resolved (non-computed) property name — for `{ a: 1 }` and shorthand
package/src/index.ts CHANGED
@@ -322,7 +322,7 @@ export { ErrorCodes, createError, formatError, generateCodeFrame } from './error
322
322
  // Expression Parser
323
323
  export { parseExpression, tsNodeToParsedExpr, asCallbackMethodCall, CALLBACK_METHODS, sortComparatorFromArrow, serializeParsedExpr, freeVarsInBody, freeIdentifiers, materializeGetterCalls, isSupported, exprToString, stringifyParsedExpr, identifierPath, parseBlockBody, parseBlockBodyTolerant, foldBlockToExpr, predicateTernaryToLogical, containsHigherOrder, extractArrowBodyExpression, parseStyleObjectEntries, hasUnsafeStyleValue, parseProviderObjectLiteral, type ProviderObjectMember, type FoldBlockOptions } from './expression-parser.ts'
324
324
  export type { StyleObjectEntry } from './expression-parser.ts'
325
- export { PARSED_EXPR_KINDS } from './expression-parser.ts'
325
+ export { PARSED_EXPR_KINDS, ARRAY_METHOD_NAMES } from './expression-parser.ts'
326
326
  export type { ParsedExpr, ObjectLiteralProperty, ParsedStatement, SortComparator, SortKey, FlatDepth, SupportLevel, SupportResult, TemplatePart } from './expression-parser.ts'
327
327
  export { buildLoopChainExpr } from './loop-chain.ts'
328
328
  export type { LoopChainInputs } from './loop-chain.ts'
@@ -4,11 +4,15 @@
4
4
  * client-only expressions, and reactive component prop bindings.
5
5
  */
6
6
 
7
- import type { AttrMeta } from '../types.ts'
7
+ import ts from 'typescript'
8
+ import type { AttrMeta, IRMetadata } from '../types.ts'
8
9
  import { isBooleanAttr } from '../html-constants.ts'
9
10
  import type { ClientJsContext } from './types.ts'
10
11
  import { toHtmlAttrName, varSlotId, PROPS_PARAM } from './utils.ts'
11
12
  import { createTemplateAwareStringProtector } from './html-template.ts'
13
+ import { datePlugin, DATE_METHODS } from '../date-lowering.ts'
14
+ import { tsNodeToParsedExpr } from '../expression-parser.ts'
15
+ import type { LoweringMatcher } from '../lowering-registry.ts'
12
16
 
13
17
  /**
14
18
  * Profile mode (#1690, SR3/SR4): the id appended to a DOM-binding effect so the
@@ -95,8 +99,104 @@ export function rewriteDestructuredPropsInExpr(expr: string, ctx: ClientJsContex
95
99
  return restore(result)
96
100
  }
97
101
 
102
+ /**
103
+ * Bind `datePlugin`'s matcher (#2292) for this component's reactive-text
104
+ * emission. Reads the same four `EvidenceMetadata` fields
105
+ * (`rich-type-evidence.ts`) `jsx-to-ir.ts`'s `getDateLoweringMatcher` reads
106
+ * off the analyzer for the STATIC template path, so a prop-method call
107
+ * re-evaluated inside a `createEffect` lowers to the `date` helper under
108
+ * the exact same evidence the static template and every SSR adapter use.
109
+ * `ctx.propsType` is optional on `ClientJsContext` (threaded through only
110
+ * for this purpose, `index.ts`'s `createContext`); a context predating
111
+ * #2292 — or a hand-built test fixture — simply carries no Date evidence,
112
+ * so this returns null and callers emit the expression unchanged.
113
+ */
114
+ function getReactiveDateLoweringMatcher(ctx: ClientJsContext): LoweringMatcher | null {
115
+ if (!ctx.propsType) return null
116
+ const metadataSlice: Pick<IRMetadata, 'propsType' | 'propsObjectName' | 'propsParams' | 'typeDefinitions'> = {
117
+ propsType: ctx.propsType,
118
+ propsObjectName: ctx.propsObjectName,
119
+ propsParams: ctx.propsParams,
120
+ typeDefinitions: ctx.typeDefinitions ?? [],
121
+ }
122
+ return datePlugin.prepare(metadataSlice as unknown as IRMetadata)
123
+ }
124
+
125
+ /**
126
+ * Reactive-path counterpart to `jsx-to-ir.ts`'s `lowerDateCalls` (#2292):
127
+ * without this, a Date-typed prop's catalogued accessor call re-evaluated
128
+ * inside `createEffect` (the Solid-style wrap-by-default fallback for any
129
+ * expression containing a function call, #937) still called the RAW
130
+ * `.toISOString()` / etc. on the hydrated STRING prop value and threw —
131
+ * the static template alone wasn't enough to fix hydration.
132
+ *
133
+ * `expr` here (`IRExpression.expr`, threaded through
134
+ * `ctx.dynamicElements`) is ALREADY the bare-identifier source form the
135
+ * SAME `createEffect` body closes over via the destructured-prop shim
136
+ * (`const createdAt = _p.createdAt ?? {}` at the top of `init()`) — so
137
+ * unlike the static-template path, the receiver does NOT need a `_p.`
138
+ * prefix: swapping the raw call for `date(<receiver>, "<op>")` is enough.
139
+ *
140
+ * No live `ts.Node` survives into this phase (`expr` is a plain string),
141
+ * so this re-parses it fresh via `ts.createSourceFile` — the same
142
+ * technique `expression-parser.ts`'s `parseExpression` uses — rather than
143
+ * a regex scan (per CLAUDE.md's structural-parsing rule): every candidate
144
+ * span comes from walking the freshly-parsed AST, and `matcher(...)` is
145
+ * the SAME `datePlugin` matcher the static path and every SSR adapter
146
+ * bind, so a call lowers here iff it would lower there too.
147
+ */
148
+ function lowerDateCallsInReactiveExpr(expr: string, matcher: LoweringMatcher | null): string {
149
+ if (!matcher) return expr
150
+ let sourceFile: ts.SourceFile
151
+ try {
152
+ sourceFile = ts.createSourceFile('__reactive_expr__.ts', `(${expr});`, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS)
153
+ } catch {
154
+ return expr
155
+ }
156
+ const stmt = sourceFile.statements[0]
157
+ if (!stmt || !ts.isExpressionStatement(stmt)) return expr
158
+ const root = ts.isParenthesizedExpression(stmt.expression) ? stmt.expression.expression : stmt.expression
159
+
160
+ const candidates: ts.CallExpression[] = []
161
+ const visit = (n: ts.Node): void => {
162
+ if (
163
+ ts.isCallExpression(n) &&
164
+ n.arguments.length === 0 &&
165
+ ts.isPropertyAccessExpression(n.expression) &&
166
+ !n.expression.questionDotToken &&
167
+ DATE_METHODS.has(n.expression.name.text)
168
+ ) {
169
+ candidates.push(n)
170
+ }
171
+ ts.forEachChild(n, visit)
172
+ }
173
+ visit(root)
174
+ if (candidates.length === 0) return expr
175
+
176
+ // Template-aware: protect quoted strings AND template-literal static
177
+ // segments (leaving `${…}` interpolations exposed) so the non-global
178
+ // `.replace` can't rewrite a backtick constant that coincidentally
179
+ // matches the call text before the real call site (Copilot review, #2294).
180
+ const { protect, restore } = createTemplateAwareStringProtector()
181
+ let result = protect(expr)
182
+ for (const call of candidates) {
183
+ const propAccess = call.expression as ts.PropertyAccessExpression
184
+ const node = matcher(tsNodeToParsedExpr(propAccess), [])
185
+ if (!node || node.kind !== 'helper-call' || node.helper !== 'date') continue
186
+ const op = propAccess.name.text
187
+ const receiverText = propAccess.expression.getText(sourceFile)
188
+ const matchText = call.getText(sourceFile)
189
+ // Replacer-function form: a `$` sequence in `receiverText` would
190
+ // otherwise be reinterpreted as a `String.replace` pattern token and
191
+ // corrupt the output (repo precedent, #2285).
192
+ result = result.replace(matchText, () => `date(${receiverText}, "${op}")`)
193
+ }
194
+ return restore(result)
195
+ }
196
+
98
197
  /** Emit createEffect blocks that update text nodes for reactive expressions. */
99
198
  export function emitDynamicTextUpdates(lines: string[], ctx: ClientJsContext): void {
199
+ const dateLoweringMatcher = getReactiveDateLoweringMatcher(ctx)
100
200
  // Group elements by expression to consolidate effects with same dependencies
101
201
  const byExpression = new Map<string, typeof ctx.dynamicElements>()
102
202
  for (const elem of ctx.dynamicElements) {
@@ -107,7 +207,8 @@ export function emitDynamicTextUpdates(lines: string[], ctx: ClientJsContext): v
107
207
  byExpression.get(key)!.push(elem)
108
208
  }
109
209
 
110
- for (const [expr, elems] of byExpression) {
210
+ for (const [rawExpr, elems] of byExpression) {
211
+ const expr = lowerDateCallsInReactiveExpr(rawExpr, dateLoweringMatcher)
111
212
  // Separate conditional vs non-conditional elements
112
213
  const conditionalElems = elems.filter(e => e.insideConditional)
113
214
  const normalElems = elems.filter(e => !e.insideConditional)
@@ -17,6 +17,10 @@ export const RUNTIME_IMPORT_CANDIDATES = [
17
17
  'tAfter',
18
18
  // Profile mode (#1690, SR3) — turn-boundary markers around event handlers.
19
19
  'beginTurn', 'endTurn',
20
+ // Catalogued `Date` lowering (#2274/#2292) — the client counterpart to
21
+ // every SSR adapter's `date` runtime helper (`date-lowering.ts`'s
22
+ // `datePlugin`).
23
+ 'date',
20
24
  ] as const
21
25
 
22
26
  /** @deprecated Use RUNTIME_IMPORT_CANDIDATES */
@@ -175,6 +175,8 @@ function createContext(
175
175
  propsParams: ir.metadata.propsParams,
176
176
  propsObjectName: ir.metadata.propsObjectName,
177
177
  restPropsName: ir.metadata.restPropsName,
178
+ propsType: ir.metadata.propsType,
179
+ typeDefinitions: ir.metadata.typeDefinitions,
178
180
 
179
181
  interactiveElements: [],
180
182
  dynamicElements: [],
@@ -20,6 +20,8 @@ import type {
20
20
  ParamInfo,
21
21
  CompilerError,
22
22
  ImportInfo,
23
+ TypeInfo,
24
+ TypeDefinition,
23
25
  } from '../types.ts'
24
26
  import type { CsrInlinabilityMap } from './csr-substitute.ts'
25
27
  import type { SkeletonSlotPaths } from './html-template.ts'
@@ -67,6 +69,19 @@ export interface ClientJsContext {
67
69
  propsParams: ParamInfo[]
68
70
  propsObjectName: string | null
69
71
  restPropsName: string | null
72
+ /**
73
+ * Threaded through (alongside `propsParams` above) so the reactive-effect
74
+ * emitter can bind its own `datePlugin` matcher (#2292) — see
75
+ * `emit-reactive.ts`'s `getReactiveDateLoweringMatcher`. Mirrors the
76
+ * `EvidenceMetadata` slice (`rich-type-evidence.ts`) the SSR adapters and
77
+ * `jsx-to-ir.ts`'s static-template lowering both consult; nothing else in
78
+ * this file reads a Date-typed prop's shape. Optional (rather than a hard
79
+ * requirement alongside `propsParams`) so existing hand-built
80
+ * `ClientJsContext` test fixtures that predate #2292 keep type-checking
81
+ * without every call site listing these two fields.
82
+ */
83
+ propsType?: TypeInfo | null
84
+ typeDefinitions?: TypeDefinition[]
70
85
 
71
86
  // Collected elements
72
87
  interactiveElements: InteractiveElement[]
package/src/jsx-to-ir.ts CHANGED
@@ -30,6 +30,7 @@ import {
30
30
  type SourceLocation,
31
31
  type TypeInfo,
32
32
  type OriginInfo,
33
+ type IRMetadata,
33
34
  isReactiveOrigin,
34
35
  AttrValueOf,
35
36
  } from './types.ts'
@@ -46,6 +47,9 @@ import {
46
47
  } from './prop-rewrite.ts'
47
48
  import { resolveFreeRefs, isNameBound as isNameBoundInEnv, type BindingEnvironment } from './free-refs.ts'
48
49
  import { computeFileScope } from './ir-to-client-js/component-scope.ts'
50
+ import { createTemplateAwareStringProtector } from './ir-to-client-js/html-template.ts'
51
+ import { datePlugin, DATE_METHODS } from './date-lowering.ts'
52
+ import type { LoweringMatcher } from './lowering-registry.ts'
49
53
  import { extractFreeIdentifiersFromNode, initializerShapeContainsJsx } from './analyzer.ts'
50
54
  import { iterateJsTokens, replaceInExprContexts } from './scanner/js-scanner.ts'
51
55
  import { toHTMLAttrName, decodeEntities } from '@barefootjs/shared'
@@ -164,6 +168,13 @@ interface TransformContext {
164
168
  * aliased `import { Async as Boundary }` maps `<Boundary>` to the built-in.
165
169
  */
166
170
  _clientBuiltinTags?: Map<string, ClientBuiltinTag>
171
+ /**
172
+ * Cached `datePlugin` matcher (#2292), bound once to this component's
173
+ * metadata. `undefined` = not yet computed; `null` = computed and
174
+ * inactive (this component's props never reach a `Date`, `datePlugin`'s
175
+ * own `prepare` gate). See `getDateLoweringMatcher`.
176
+ */
177
+ _dateLoweringMatcher?: LoweringMatcher | null
167
178
  }
168
179
 
169
180
  /**
@@ -285,14 +296,128 @@ function exprHasFunctionCalls(expr: ts.Expression): boolean {
285
296
  return found
286
297
  }
287
298
 
299
+ /**
300
+ * Bind (and cache on `ctx`) `datePlugin`'s matcher (#2292) for this
301
+ * component. Reuses the SAME `LoweringPlugin` the SSR adapters bind via
302
+ * `prepareLoweringMatchers` — not a re-implementation of its receiver-type
303
+ * resolution — so a call lowers on the client iff `datePlugin` would lower
304
+ * it on the SSR path (parity is mandatory per #2292).
305
+ *
306
+ * `datePlugin.prepare` only reads `propsType` / `propsObjectName` /
307
+ * `propsParams` / `typeDefinitions` off its `IRMetadata` parameter (see
308
+ * `rich-type-evidence.ts`'s `EvidenceMetadata` — the `Pick` of exactly
309
+ * those four fields). `ctx.analyzer` carries live, fully-populated values
310
+ * for all four by the time any expression is transformed (analysis runs
311
+ * to completion before `jsxToIR`'s AST walk begins), so a slice of just
312
+ * those fields is sufficient — the cast bridges that narrower shape to
313
+ * the wider `IRMetadata` parameter type every lowering plugin declares.
314
+ */
315
+ function getDateLoweringMatcher(ctx: TransformContext): LoweringMatcher | null {
316
+ if (ctx._dateLoweringMatcher === undefined) {
317
+ const a = ctx.analyzer
318
+ const metadataSlice: Pick<IRMetadata, 'propsType' | 'propsObjectName' | 'propsParams' | 'typeDefinitions'> = {
319
+ propsType: a.propsType,
320
+ propsObjectName: a.propsObjectName,
321
+ propsParams: a.propsParams,
322
+ typeDefinitions: a.typeDefinitions,
323
+ }
324
+ ctx._dateLoweringMatcher = datePlugin.prepare(metadataSlice as unknown as IRMetadata)
325
+ }
326
+ return ctx._dateLoweringMatcher
327
+ }
328
+
329
+ /**
330
+ * Client-side counterpart to `datePlugin` (#2274 was SSR-only; #2292
331
+ * closes the gap). The client emitter (`ir-to-client-js/`) emits raw,
332
+ * prop-rewritten source strings and never consults the lowering registry
333
+ * (its module doc) — so left alone, a Date-typed prop's catalogued
334
+ * accessor call leaks through as `_p.createdAt.toISOString()`, which
335
+ * throws at hydration: props are JSON round-tripped with no type-aware
336
+ * revival (`hydrate.ts`'s `parseProps`), so the prop arrives as its ISO
337
+ * string, not a `Date` instance.
338
+ *
339
+ * Walks `expr`'s AST for zero-arg calls to a `DATE_METHODS` name (a cheap
340
+ * syntactic pre-filter) and confirms each candidate against the SAME
341
+ * `datePlugin` matcher the SSR adapters use, via `tsNodeToParsedExpr` —
342
+ * the identical receiver-type resolution, not a re-implementation, so a
343
+ * call lowers here iff it would lower on the SSR path. A match splices
344
+ * `date(<receiver>, "<op>")` in place of the raw call; `imports.ts`'s
345
+ * `detectUsedImports` regex-scans the emitted `date(` call against
346
+ * `RUNTIME_IMPORT_CANDIDATES` to auto-import the runtime helper.
347
+ *
348
+ * The splice is a plain (non-global) text `.replace` per candidate, run
349
+ * in AST (left-to-right, source) order — not a JS-parsing regex, since
350
+ * every candidate span comes from walking `expr`'s real AST first. This
351
+ * is safe specifically because any call the matcher accepts has, by
352
+ * construction, no TS-only syntax anywhere in its own span: the matcher
353
+ * only resolves evidence through a bare identifier or a non-computed
354
+ * member chain (`resolveReceiverType`'s two supported `ParsedExpr`
355
+ * shapes), and the call itself takes zero arguments. So `ctx.getJS` of
356
+ * that one sub-node — which strips only type syntax — is guaranteed
357
+ * byte-identical to its raw source slice, and thus guaranteed to appear
358
+ * verbatim as a contiguous substring of `text` regardless of unrelated
359
+ * type-stripping elsewhere in the enclosing expression. String spans in
360
+ * `text` are protected first (`createTemplateAwareStringProtector` — both
361
+ * quoted strings AND template-literal *static* segments, leaving `${…}`
362
+ * interpolations exposed) so a coincidentally-identical string constant —
363
+ * e.g. a backtick `` `createdAt.toISOString()` `` sitting before the real
364
+ * call — can never be mistaken for a call site by the non-global
365
+ * `.replace`.
366
+ */
367
+ function lowerDateCalls(text: string, expr: ts.Node, ctx: TransformContext): string {
368
+ const matcher = getDateLoweringMatcher(ctx)
369
+ if (!matcher) return text
370
+
371
+ const candidates: ts.CallExpression[] = []
372
+ function visit(n: ts.Node) {
373
+ if (
374
+ ts.isCallExpression(n) &&
375
+ n.arguments.length === 0 &&
376
+ ts.isPropertyAccessExpression(n.expression) &&
377
+ !n.expression.questionDotToken &&
378
+ DATE_METHODS.has(n.expression.name.text)
379
+ ) {
380
+ candidates.push(n)
381
+ }
382
+ ts.forEachChild(n, visit)
383
+ }
384
+ visit(expr)
385
+ if (candidates.length === 0) return text
386
+
387
+ const { protect, restore } = createTemplateAwareStringProtector()
388
+ let result = protect(text)
389
+ for (const call of candidates) {
390
+ const propAccess = call.expression as ts.PropertyAccessExpression
391
+ const node = matcher(tsNodeToParsedExpr(propAccess), [])
392
+ if (!node || node.kind !== 'helper-call' || node.helper !== 'date') continue
393
+ const op = propAccess.name.text
394
+ const receiverText = ctx.getJS(propAccess.expression)
395
+ const matchText = ctx.getJS(call)
396
+ // Replacer-function form: a `$` sequence in `receiverText` (a prop named
397
+ // `$1`, say) would otherwise be reinterpreted as a `String.replace`
398
+ // pattern token and corrupt the output (repo precedent, #2285).
399
+ result = result.replace(matchText, () => `date(${receiverText}, "${op}")`)
400
+ }
401
+ return restore(result)
402
+ }
403
+
288
404
  /**
289
405
  * Rewrite bare destructured prop references in expression text.
290
406
  * Thin wrapper that caches prop names on ctx and delegates to the shared core.
291
407
  * Returns undefined if no rewriting is needed (SolidJS-style or no props).
292
408
  */
293
409
  function rewriteBarePropRefs(text: string, expr: ts.Node, ctx: TransformContext): string | undefined {
410
+ // #2292: lower a Date-typed prop's catalogued accessor call BEFORE the
411
+ // bare-prop-name rewrite below, so the receiver identifier still picks
412
+ // up the usual `_p.` prefix (destructured mode) or falls through to the
413
+ // CSR template emitter's separate `props.` → `_p.` rewrite
414
+ // (`html-template.ts`'s `transformExpr`, props-object mode). Runs
415
+ // unconditionally — ahead of the `propNames` gate — because Date
416
+ // evidence comes from `ctx.analyzer.propsType`, independent of whether
417
+ // this component destructures its props.
418
+ const dateLowered = lowerDateCalls(text, expr, ctx)
294
419
  let propNames = getDestructuredPropNames(ctx)
295
- if (!propNames) return undefined
420
+ if (!propNames) return dateLowered === text ? undefined : dateLowered
296
421
  // #2222: a name bound as an enclosing loop callback's item/index param
297
422
  // refers to the loop binding, not the prop, at THIS transform position —
298
423
  // `ctx.loopParams` is the live loop-param set (destructured binding
@@ -303,7 +428,7 @@ function rewriteBarePropRefs(text: string, expr: ts.Node, ctx: TransformContext)
303
428
  // its set on ctx and must not be mutated.
304
429
  if (ctx.loopParams.size > 0) {
305
430
  const filtered = new Set([...propNames].filter(n => !ctx.loopParams.has(n)))
306
- if (filtered.size === 0) return undefined
431
+ if (filtered.size === 0) return dateLowered === text ? undefined : dateLowered
307
432
  propNames = filtered
308
433
  }
309
434
  // #1425: union any prop refs that `expr` reaches via branch-local
@@ -316,7 +441,7 @@ function rewriteBarePropRefs(text: string, expr: ts.Node, ctx: TransformContext)
316
441
  // `_branchScopePropDeps` at branch entry; here we just walk `expr`
317
442
  // for references to those locals and union the matching dep sets.
318
443
  const extraPropRefs = collectBranchLocalPropRefsViaSubstitution(expr, ctx)
319
- return rewriteBarePropRefsCore(text, expr, propNames, extraPropRefs)
444
+ return rewriteBarePropRefsCore(dateLowered, expr, propNames, extraPropRefs)
320
445
  }
321
446
 
322
447
  /**
@@ -4493,6 +4618,37 @@ function getAttributeValue(attr: ts.JsxAttribute, ctx: TransformContext): AttrVa
4493
4618
  }
4494
4619
  }
4495
4620
 
4621
+ // Bare `attr={record[key]}` (#2300): an element access whose base is a
4622
+ // local const object-literal `Record` indexed by a prop, written directly
4623
+ // as ANY string-attribute value rather than inside a template literal
4624
+ // (`class={record[key]}` is the motivating class-composition case, but this
4625
+ // is attribute-agnostic — it fires for any qualifying attribute). Lift it
4626
+ // into the SAME `lookup` part the `${record[key]}` template-literal form
4627
+ // produces (`tryResolveTemplateSpanFromConst` handles both), so every
4628
+ // adapter renders it through the shared, already-working lookup path
4629
+ // instead of a raw index-access that the typed / strict backends (Go,
4630
+ // minijinja, ERB, Jinja) mishandle for a function-local const — it is not
4631
+ // a prop field, so those emit an unpopulated `.Record`/nil lookup and the
4632
+ // value renders empty (or errors). `tryResolveTemplateSpanFromConst`
4633
+ // returns null for anything but the `IDENT[KEY]` → all-string-`Record`
4634
+ // shape, so any other element access falls through to the bare-expression
4635
+ // path unchanged.
4636
+ // Only a DYNAMIC key qualifies (a prop reference, the #2300 shape). A
4637
+ // static string / numeric literal key (`paths['icon']`) stays on the
4638
+ // bare-expression path — the adapters already resolve a constant-key index,
4639
+ // and it must remain a plain `expression` attr (jsx-to-ir regression pin),
4640
+ // not a single-case `lookup`.
4641
+ if (
4642
+ ts.isElementAccessExpression(expr) &&
4643
+ !ts.isStringLiteralLike(expr.argumentExpression) &&
4644
+ !ts.isNumericLiteral(expr.argumentExpression)
4645
+ ) {
4646
+ const parts = tryResolveTemplateSpanFromConst(expr, ctx)
4647
+ if (parts) {
4648
+ return AttrValueOf.template(parts)
4649
+ }
4650
+ }
4651
+
4496
4652
  // `className={classes}` where `classes` is a local const bound to
4497
4653
  // a template literal — resolve the template literal here and let
4498
4654
  // adapters render the structured form. This is the cva-style
@@ -78,7 +78,7 @@ function isNullishArm(t: TypeInfo): boolean {
78
78
  return t.kind === 'unknown' && (t.raw === 'null' || t.raw === 'undefined')
79
79
  }
80
80
 
81
- function stripUnion(type: TypeInfo | null): TypeInfo | null {
81
+ export function stripUnion(type: TypeInfo | null): TypeInfo | null {
82
82
  if (!type || type.kind !== 'union' || !type.unionTypes) return type
83
83
  const nonNullish = type.unionTypes.filter((t) => !isNullishArm(t))
84
84
  return nonNullish.length === 1 ? stripUnion(nonNullish[0]) : type
@@ -93,7 +93,7 @@ function stripUnion(type: TypeInfo | null): TypeInfo | null {
93
93
  * reference, which `typeNodeToTypeInfo` intentionally resolves to
94
94
  * `{ kind: 'interface', raw }` with no member walk of its own.
95
95
  */
96
- function derefNamedType(type: TypeInfo, meta: EvidenceMetadata): TypeInfo {
96
+ export function derefNamedType(type: TypeInfo, meta: EvidenceMetadata): TypeInfo {
97
97
  if (type.kind !== 'interface') return type
98
98
  if (type.properties && type.properties.length > 0) return type
99
99
  const name = baseTypeName(type.raw)