@barefootjs/jsx 0.19.1 → 0.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/dist/analyzer.d.ts.map +1 -1
  2. package/dist/builtin-lowering-plugins.d.ts.map +1 -1
  3. package/dist/compiler.d.ts.map +1 -1
  4. package/dist/date-lowering.d.ts +33 -0
  5. package/dist/date-lowering.d.ts.map +1 -0
  6. package/dist/index.js +663 -218
  7. package/dist/ir-to-client-js/emit-reactive.d.ts.map +1 -1
  8. package/dist/ir-to-client-js/generate-init.d.ts.map +1 -1
  9. package/dist/ir-to-client-js/imports.d.ts +2 -2
  10. package/dist/ir-to-client-js/imports.d.ts.map +1 -1
  11. package/dist/ir-to-client-js/reactivity.d.ts.map +1 -1
  12. package/dist/ir-to-client-js/types.d.ts +14 -1
  13. package/dist/ir-to-client-js/types.d.ts.map +1 -1
  14. package/dist/jsx-to-ir.d.ts.map +1 -1
  15. package/dist/rich-type-evidence.d.ts +67 -0
  16. package/dist/rich-type-evidence.d.ts.map +1 -0
  17. package/dist/rich-type-refusal.d.ts +35 -0
  18. package/dist/rich-type-refusal.d.ts.map +1 -0
  19. package/dist/types.d.ts +9 -0
  20. package/dist/types.d.ts.map +1 -1
  21. package/package.json +2 -2
  22. package/src/__tests__/client-js-generation.test.ts +59 -0
  23. package/src/__tests__/date-lowering.test.ts +232 -0
  24. package/src/__tests__/nested-loop-reactive-attrs.test.ts +56 -0
  25. package/src/__tests__/rich-type-method-refusal.test.ts +323 -0
  26. package/src/analyzer.ts +21 -2
  27. package/src/builtin-lowering-plugins.ts +2 -1
  28. package/src/compiler.ts +3 -0
  29. package/src/date-lowering.ts +117 -0
  30. package/src/ir-to-client-js/emit-reactive.ts +103 -2
  31. package/src/ir-to-client-js/generate-init.ts +11 -4
  32. package/src/ir-to-client-js/imports.ts +4 -0
  33. package/src/ir-to-client-js/index.ts +2 -0
  34. package/src/ir-to-client-js/reactivity.ts +13 -2
  35. package/src/ir-to-client-js/types.ts +15 -0
  36. package/src/jsx-to-ir.ts +128 -3
  37. package/src/rich-type-evidence.ts +159 -0
  38. package/src/rich-type-refusal.ts +311 -0
  39. package/src/types.ts +9 -0
package/src/compiler.ts CHANGED
@@ -24,6 +24,7 @@ import { applyCssLayerPrefix } from './css-layer-prefixer.ts'
24
24
  import { preprocessInlineJsxCallbacks } from './preprocess-inline-jsx-callbacks.ts'
25
25
  import { extractSsrDefaults } from './ssr-defaults.ts'
26
26
  import { computeSsrSeedPlan } from './ssr-seed-plan.ts'
27
+ import { checkRichTypeMethodCalls } from './rich-type-refusal.ts'
27
28
 
28
29
  /**
29
30
  * Extended compile options with required adapter
@@ -137,6 +138,7 @@ function compileMultipleComponents(
137
138
  }
138
139
 
139
140
  componentIR.metadata.clientAnalysis = analyzeClientNeeds(componentIR)
141
+ checkRichTypeMethodCalls(componentIR.root, componentIR.metadata, errors)
140
142
 
141
143
  if (options.cssLayerPrefix) {
142
144
  applyCssLayerPrefix(componentIR, options.cssLayerPrefix)
@@ -615,6 +617,7 @@ export function compileJSX(
615
617
 
616
618
  // Pre-compute client JS analysis for adapter optimization
617
619
  componentIR.metadata.clientAnalysis = analyzeClientNeeds(componentIR)
620
+ checkRichTypeMethodCalls(componentIR.root, componentIR.metadata, errors)
618
621
 
619
622
  // Cross-file @client signal sources: identify which import sources
620
623
  // need `.client.js` path rewriting in the client bundle.
@@ -0,0 +1,117 @@
1
+ /**
2
+ * `Date` lowering plugin (#2274) — the first catalogued entry in the rich-type
3
+ * lowering seam #2273 left open (`rich-type-refusal.ts`'s module doc: "the
4
+ * seam #2274 and later plugins use to catalogue a rich-type API without
5
+ * touching this module"). A zero-arg call to one of `DATE_METHODS` on a
6
+ * receiver resolved (via `resolveReceiverType`) to a `Date`-typed prop lowers
7
+ * to a backend-neutral `helper-call` node on the `date` helper
8
+ * (`date(recv, op)`, spec/template-helpers.md); every adapter already renders
9
+ * `helper-call` generically (#2069), so no adapter-specific code is needed
10
+ * here — only the runtime `date` helper each backend ships.
11
+ *
12
+ * v1 scope is deliberately prop-rooted only: the matcher resolves receivers
13
+ * with `EMPTY_BINDINGS` (no loop-item / arrow-param context), mirroring
14
+ * `rich-type-refusal.ts`'s own top-level walk. A loop item's `.toISOString()`
15
+ * therefore stays BF021-refused rather than silently falling back to a
16
+ * generic (and wrong) lowering — widening to loop bindings is a deliberate
17
+ * future step, not an oversight.
18
+ */
19
+
20
+ import type { IRMetadata, TypeInfo } from './types.ts'
21
+ import type { ParsedExpr } from './expression-parser.ts'
22
+ import type { LoweringNode, LoweringPlugin } from './lowering-registry.ts'
23
+ import { resolveReceiverType, baseTypeName, stripUnion, derefNamedType } from './rich-type-evidence.ts'
24
+
25
+ /**
26
+ * Host rich-type names (`rich-type-evidence.ts`'s `HOST_RICH_TYPE_NAMES`)
27
+ * that additionally have a catalogued lowering — currently just `Date`.
28
+ * `analyzer.ts`'s `collectMemberTypes` widening keys off this set (not
29
+ * `HOST_RICH_TYPE_NAMES` wholesale) so a destructured prop only gets a real
30
+ * TypeInfo when a plugin actually exists to consume it; adding a future
31
+ * lowering (`Map`, …) is a one-line addition here, not a second gate to keep
32
+ * in sync.
33
+ */
34
+ export const CATALOGUED_RICH_TYPE_NAMES: ReadonlySet<string> = new Set(['Date'])
35
+
36
+ /** Zero-arg `Date.prototype` methods the `date` helper catalogues (spec/template-helpers.md). */
37
+ export const DATE_METHODS: ReadonlySet<string> = new Set([
38
+ 'getUTCFullYear',
39
+ 'getUTCMonth',
40
+ 'getUTCDate',
41
+ 'getUTCHours',
42
+ 'getUTCMinutes',
43
+ 'getUTCSeconds',
44
+ 'getTime',
45
+ 'toISOString',
46
+ ])
47
+
48
+ type Bindings = ReadonlyMap<string, TypeInfo | null>
49
+ const EMPTY_BINDINGS: Bindings = new Map()
50
+
51
+ /**
52
+ * Whether `type` (or a property reachable from it through non-array,
53
+ * non-computed member chains) can ever resolve to a `Date` — the same
54
+ * receiver shapes `resolveReceiverType` walks for an actual call site. Used
55
+ * only as `prepare`'s cheap activation gate: a `false` here must never miss a
56
+ * receiver the matcher could later prove Date-typed (that would silently drop
57
+ * the lowering and mis-flag BF021), so this over-approximates by recursing
58
+ * into every nested object-shaped property, not just direct ones.
59
+ *
60
+ * `seen` guards a self-referential named type (`interface Tree { self: Tree
61
+ * }`) from an infinite walk; keyed by the type's own name so two distinct
62
+ * properties of the SAME named type aren't short-circuited against each
63
+ * other.
64
+ */
65
+ function typeReachesDate(type: TypeInfo | null, meta: IRMetadata, seen: Set<string>): boolean {
66
+ const stripped = stripUnion(type)
67
+ if (!stripped) return false
68
+ // `kind: 'object'` is an INLINE type literal (`{ createdAt: Date }` — the
69
+ // shape `propsType` itself takes for the common case) and already carries
70
+ // `properties` directly; `kind: 'interface'` is a NAMED reference (`Date`
71
+ // itself, or a local `interface Props { … }`) that needs the Date-name
72
+ // check plus `derefNamedType` to reach its member list. Anything else
73
+ // (primitive/array/union/…) is a dead end.
74
+ if (stripped.kind === 'interface') {
75
+ const name = baseTypeName(stripped.raw)
76
+ if (name === 'Date') return true
77
+ if (seen.has(name)) return false
78
+ seen.add(name)
79
+ } else if (stripped.kind !== 'object') {
80
+ return false
81
+ }
82
+ const deref = derefNamedType(stripped, meta)
83
+ if (!deref.properties) return false
84
+ return deref.properties.some((p) => typeReachesDate(p.type, meta, seen))
85
+ }
86
+
87
+ /**
88
+ * `datePlugin`'s matcher: recognises `<Date-typed receiver>.<method>()` per
89
+ * the module doc, or declines (null) for anything else — a non-member
90
+ * callee, a non-catalogued method name, a call with arguments (every
91
+ * catalogued method is zero-arg on `Date.prototype`), or a receiver that
92
+ * doesn't resolve to `Date` (unknown, a different host rich type, or a
93
+ * same-named local `typeDefinitions` entry shadowing the built-in — mirrors
94
+ * `rich-type-refusal.ts`'s `inFileShadow` check).
95
+ */
96
+ function matchDateCall(callee: ParsedExpr, args: readonly ParsedExpr[], metadata: IRMetadata): LoweringNode | null {
97
+ if (callee.kind !== 'member' || callee.computed) return null
98
+ if (args.length !== 0 || !DATE_METHODS.has(callee.property)) return null
99
+ const receiverType = resolveReceiverType(callee.object, metadata, EMPTY_BINDINGS)
100
+ if (!receiverType || receiverType.kind !== 'interface') return null
101
+ const typeName = baseTypeName(receiverType.raw)
102
+ if (typeName !== 'Date') return null
103
+ if (metadata.typeDefinitions.some((d) => d.name === typeName)) return null
104
+ return {
105
+ kind: 'helper-call',
106
+ helper: 'date',
107
+ args: [callee.object, { kind: 'literal', value: callee.property, literalType: 'string' }],
108
+ }
109
+ }
110
+
111
+ export const datePlugin: LoweringPlugin = {
112
+ name: 'date',
113
+ prepare(metadata) {
114
+ if (!metadata.propsType || !typeReachesDate(metadata.propsType, metadata, new Set())) return null
115
+ return (callee, args) => matchDateCall(callee, args, metadata)
116
+ },
117
+ }
@@ -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)
@@ -99,16 +99,23 @@ export function generateInitFunction(
99
99
  let generatedCode = rewritePropsObjectRef(lines.join('\n'), ctx.propsObjectName)
100
100
  generatedCode += '\n' + hydrateLine
101
101
 
102
- const allImportLines = resolveFinalImports(generatedCode, ir, localImportPrefixes)
102
+ // Substitute module-level declarations BEFORE import detection: a
103
+ // module-level helper's body (e.g. `buildSheetVMs` calling
104
+ // `computeSheetGeometry`) only exists in `moduleConstantsCode`, so
105
+ // scanning `generatedCode` first would miss any import referenced
106
+ // only from that body and silently drop it (#2283).
103
107
  const moduleConstantsCode = emitModuleLevelDeclarations(
104
108
  classification.moduleLevelConstants,
105
109
  classification.moduleLevelFunctions,
106
110
  classification.moduleLevelSignals,
107
111
  classification.moduleLevelMemos,
108
112
  )
113
+ // Replacer-function form: a plain replacement string would let literal
114
+ // `$&`/`$1`/`$$` sequences in user helper bodies or import paths be
115
+ // reinterpreted by `String.replace`'s special-pattern handling.
116
+ const codeWithModuleConstants = generatedCode.replace(MODULE_CONSTANTS_PLACEHOLDER, () => moduleConstantsCode)
117
+ const allImportLines = resolveFinalImports(codeWithModuleConstants, ir, localImportPrefixes)
109
118
 
110
- return generatedCode
111
- .replace(IMPORT_PLACEHOLDER, allImportLines)
112
- .replace(MODULE_CONSTANTS_PLACEHOLDER, moduleConstantsCode)
119
+ return codeWithModuleConstants.replace(IMPORT_PLACEHOLDER, () => allImportLines)
113
120
  }
114
121
 
@@ -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: [],
@@ -529,8 +529,19 @@ export function collectLoopChildReactiveTexts(
529
529
  const originFreeIds = freeIdsFromRefs(n.origin?.freeRefs)
530
530
  const expanded = expandConstantForReactivity(n.expr, ctx, originFreeIds)
531
531
  // Include if expression reads signals OR references the loop parameter
532
- // (loop param becomes a signal accessor via per-item signals).
533
- if (classifyReactivity(expanded.expr, ctx, loopParam, loopParamBindings, expanded.freeIds).kind === 'none') return
532
+ // (loop param becomes a signal accessor via per-item signals). Falls
533
+ // back to the Solid-style AST-flag wrap decision — mirroring
534
+ // `collectLoopChildReactiveAttrs`'s `callsReactiveGetters` /
535
+ // `hasFunctionCalls` fallback (#1673) and the top-level text path's
536
+ // `decideWrapFromAstFlags` gate (`collectElements`'s `expression`
537
+ // handler) — so a loop-item text read through an opaque helper
538
+ // (`textAt(i)` where `const textAt = (i) => rows()[i]`, which
539
+ // `classifyReactivity` can't see through) still gets an update
540
+ // effect instead of silently freezing at its SSR value (#2282).
541
+ const reactive =
542
+ classifyReactivity(expanded.expr, ctx, loopParam, loopParamBindings, expanded.freeIds).kind !== 'none'
543
+ || decideWrapFromAstFlags(n).wrap
544
+ if (!reactive) return
534
545
  texts.push({
535
546
  slotId: n.slotId,
536
547
  expression: expanded.expr,
@@ -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
  /**