@barefootjs/jsx 0.31.1 → 0.31.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.
Files changed (73) hide show
  1. package/dist/adapters/interface.d.ts +11 -0
  2. package/dist/adapters/interface.d.ts.map +1 -1
  3. package/dist/adapters/jsx-adapter.d.ts +92 -1
  4. package/dist/adapters/jsx-adapter.d.ts.map +1 -1
  5. package/dist/adapters/test-adapter.d.ts.map +1 -1
  6. package/dist/analyzer.d.ts.map +1 -1
  7. package/dist/compiler.d.ts.map +1 -1
  8. package/dist/css-layer-prefixer.d.ts +16 -0
  9. package/dist/css-layer-prefixer.d.ts.map +1 -1
  10. package/dist/errors.d.ts +1 -0
  11. package/dist/errors.d.ts.map +1 -1
  12. package/dist/html-types.d.ts +19 -0
  13. package/dist/html-types.d.ts.map +1 -1
  14. package/dist/index.d.ts +3 -2
  15. package/dist/index.d.ts.map +1 -1
  16. package/dist/index.js +1402 -1124
  17. package/dist/ir-to-client-js/collect-elements.d.ts.map +1 -1
  18. package/dist/ir-to-client-js/emit-reactive.d.ts.map +1 -1
  19. package/dist/ir-to-client-js/phases/props-event-handlers.d.ts.map +1 -1
  20. package/dist/ir-to-client-js/phases/props-extraction.d.ts.map +1 -1
  21. package/dist/ir-to-client-js/utils.d.ts +6 -4
  22. package/dist/ir-to-client-js/utils.d.ts.map +1 -1
  23. package/dist/jsx-runtime/index.d.ts +2 -8
  24. package/dist/jsx-runtime/index.d.ts.map +1 -1
  25. package/dist/jsx-to-ir.d.ts.map +1 -1
  26. package/dist/module-exports.d.ts +9 -1
  27. package/dist/module-exports.d.ts.map +1 -1
  28. package/dist/prop-rewrite.d.ts +7 -2
  29. package/dist/prop-rewrite.d.ts.map +1 -1
  30. package/dist/props-binding.d.ts +40 -0
  31. package/dist/props-binding.d.ts.map +1 -0
  32. package/dist/relocate.d.ts +9 -0
  33. package/dist/relocate.d.ts.map +1 -1
  34. package/dist/ssr-defaults.d.ts +43 -0
  35. package/dist/ssr-defaults.d.ts.map +1 -1
  36. package/dist/template-parts.d.ts +53 -0
  37. package/dist/template-parts.d.ts.map +1 -0
  38. package/dist/types.d.ts +10 -0
  39. package/dist/types.d.ts.map +1 -1
  40. package/package.json +2 -2
  41. package/src/__tests__/adapter-output.test.ts +8 -4
  42. package/src/__tests__/aliased-destructured-prop-csr.test.ts +112 -0
  43. package/src/__tests__/css-layer-prefixer.test.ts +72 -0
  44. package/src/__tests__/form-control-value-ssr.test.ts +48 -3
  45. package/src/__tests__/memo-deps-comments.test.ts +99 -0
  46. package/src/__tests__/multi-return-sibling-diagnostic.test.ts +241 -0
  47. package/src/__tests__/ssr-defaults.test.ts +124 -1
  48. package/src/__tests__/staged-ir/08-relocate-unit.test.ts +1 -0
  49. package/src/__tests__/staged-ir/11-template-primitive-registry.test.ts +1 -0
  50. package/src/adapters/interface.ts +11 -0
  51. package/src/adapters/jsx-adapter.ts +266 -4
  52. package/src/adapters/test-adapter.ts +13 -10
  53. package/src/analyzer.ts +50 -8
  54. package/src/compiler.ts +119 -18
  55. package/src/css-layer-prefixer.ts +80 -24
  56. package/src/errors.ts +18 -0
  57. package/src/html-types.ts +24 -0
  58. package/src/index.ts +7 -1
  59. package/src/ir-to-client-js/collect-elements.ts +4 -1
  60. package/src/ir-to-client-js/emit-reactive.ts +4 -2
  61. package/src/ir-to-client-js/phases/props-event-handlers.ts +4 -3
  62. package/src/ir-to-client-js/phases/props-extraction.ts +7 -4
  63. package/src/ir-to-client-js/plan/build-declaration-emit.ts +6 -3
  64. package/src/ir-to-client-js/utils.ts +5 -24
  65. package/src/jsx-runtime/index.ts +2 -7
  66. package/src/jsx-to-ir.ts +75 -42
  67. package/src/module-exports.ts +11 -2
  68. package/src/prop-rewrite.ts +25 -5
  69. package/src/props-binding.ts +70 -0
  70. package/src/relocate.ts +19 -2
  71. package/src/ssr-defaults.ts +70 -0
  72. package/src/template-parts.ts +81 -0
  73. package/src/types.ts +10 -0
@@ -13,7 +13,8 @@
13
13
  // computations that derive from those signals (`count() * 2`).
14
14
 
15
15
  import { describe, test, expect } from 'bun:test'
16
- import { extractSsrDefaults } from '../ssr-defaults'
16
+ import { extractSsrDefaults, deriveStashFromDefaults } from '../ssr-defaults'
17
+ import type { SsrDefault } from '../ssr-defaults'
17
18
  import { analyzeComponent } from '../analyzer'
18
19
  import { buildMetadata } from '../compiler'
19
20
 
@@ -253,3 +254,125 @@ describe('extractSsrDefaults', () => {
253
254
  expect(defaults?.classes).toEqual({ value: 'a b c d tail' })
254
255
  })
255
256
  })
257
+
258
+ // TS twin of the Ruby/Python/PHP/Perl/Rust `derive*FromDefaults` runtime
259
+ // ports (#2524 SSR half). Matches `derive_vars_from_defaults`'s edge cases
260
+ // exactly — see that Ruby method's docstring
261
+ // (packages/adapter-erb/lib/barefoot_js.rb:337-360) for the reference
262
+ // semantics this mirrors.
263
+ describe('deriveStashFromDefaults', () => {
264
+ test('aliased prop: resolves the CALLER-facing propName, not the local key', () => {
265
+ // `{ n: count }` — extractSsrDefaults keys the entry by the LOCAL
266
+ // binding (`count`) with `propName: 'n'`. The caller supplies `n`.
267
+ const defaults: Record<string, SsrDefault> = {
268
+ count: { propName: 'n', value: null },
269
+ }
270
+ expect(deriveStashFromDefaults(defaults, { n: 7 })).toEqual({ count: 7 })
271
+ })
272
+
273
+ test('un-aliased prop: propName equals the local key, resolves the same way', () => {
274
+ const defaults: Record<string, SsrDefault> = {
275
+ n: { propName: 'n', value: null },
276
+ }
277
+ expect(deriveStashFromDefaults(defaults, { n: 7 })).toEqual({ n: 7 })
278
+ })
279
+
280
+ test('caller omits the prop: falls back to the static default value', () => {
281
+ const defaults: Record<string, SsrDefault> = {
282
+ count: { propName: 'n', value: 3 },
283
+ }
284
+ expect(deriveStashFromDefaults(defaults, {})).toEqual({ count: 3 })
285
+ })
286
+
287
+ test('caller supplies null / undefined for the propName: falls back to the static value', () => {
288
+ // Mirrors every runtime port's "present AND defined" check — an
289
+ // explicit null/undefined caller value does not count as an override.
290
+ const defaults: Record<string, SsrDefault> = {
291
+ count: { propName: 'n', value: 3 },
292
+ }
293
+ expect(deriveStashFromDefaults(defaults, { n: null })).toEqual({ count: 3 })
294
+ expect(deriveStashFromDefaults(defaults, { n: undefined })).toEqual({ count: 3 })
295
+ })
296
+
297
+ test('caller supplies a falsy-but-defined propName value: the caller value wins', () => {
298
+ // `0` / `''` / `false` are legitimate override values, not "absent".
299
+ const defaults: Record<string, SsrDefault> = {
300
+ count: { propName: 'n', value: 99 },
301
+ }
302
+ expect(deriveStashFromDefaults(defaults, { n: 0 })).toEqual({ count: 0 })
303
+ })
304
+
305
+ test('propName-less entry (signal / memo local): always uses the static value', () => {
306
+ // A caller cannot override an internal signal/memo by construction —
307
+ // even a same-named caller prop must not leak in.
308
+ const defaults: Record<string, SsrDefault> = {
309
+ doubled: { value: 10 },
310
+ }
311
+ expect(deriveStashFromDefaults(defaults, { doubled: 999 })).toEqual({ doubled: 10 })
312
+ })
313
+
314
+ test('isRestProps entry: prefers the caller-assembled rest bag under its OWN key', () => {
315
+ const defaults: Record<string, SsrDefault> = {
316
+ rest: { isRestProps: true, value: {} },
317
+ }
318
+ expect(deriveStashFromDefaults(defaults, { rest: { href: '/x' } })).toEqual({
319
+ rest: { href: '/x' },
320
+ })
321
+ })
322
+
323
+ test('isRestProps entry: falls back to the static value (normally {}) when the caller supplied none', () => {
324
+ const defaults: Record<string, SsrDefault> = {
325
+ rest: { isRestProps: true, value: {} },
326
+ }
327
+ expect(deriveStashFromDefaults(defaults, {})).toEqual({ rest: {} })
328
+ })
329
+
330
+ test('isRestProps entry: an explicit `undefined` caller value still counts as supplied', () => {
331
+ // Unlike the ordinary propName branch (nullish check), the isRestProps
332
+ // branch tests presence via `in` — the rest bag is a caller-assembled
333
+ // aggregate, not a single scalar with a meaningful "absent" state, so
334
+ // an explicit `undefined` still wins over the static fallback instead
335
+ // of falling through to it.
336
+ const defaults: Record<string, SsrDefault> = {
337
+ rest: { isRestProps: true, value: { fallback: true } },
338
+ }
339
+ const result = deriveStashFromDefaults(defaults, { rest: undefined })
340
+ expect('rest' in result).toBe(true)
341
+ expect(result.rest).toBeUndefined()
342
+ })
343
+
344
+ test('a bare (non-object) entry passes through as-is', () => {
345
+ // Defensive parity with every runtime port's `ref($d) eq 'HASH'` /
346
+ // `isinstance(d, dict)` guard — never emitted by `extractSsrDefaults`
347
+ // itself, but a caller may hand this a manifest round-tripped through a
348
+ // generic JSON domain.
349
+ const defaults = { flag: true } as unknown as Record<string, SsrDefault>
350
+ expect(deriveStashFromDefaults(defaults, {})).toEqual({ flag: true })
351
+ })
352
+
353
+ test('a literal null entry hits the same non-object passthrough guard', () => {
354
+ // `d === null` is checked ALONGSIDE `typeof d !== 'object'` (not just
355
+ // the latter) — `typeof null === 'object'` in JS, so without the
356
+ // explicit `d === null` check a null entry would wrongly fall into the
357
+ // object-shaped branches below and throw reading `d.isRestProps`.
358
+ const defaults = { flag: null } as unknown as Record<string, SsrDefault>
359
+ expect(deriveStashFromDefaults(defaults, {})).toEqual({ flag: null })
360
+ })
361
+
362
+ test('mixed defaults map resolves every entry kind independently', () => {
363
+ const defaults: Record<string, SsrDefault> = {
364
+ count: { propName: 'n', value: null },
365
+ text: { propName: 'text', value: null },
366
+ doubled: { value: 0 },
367
+ rest: { isRestProps: true, value: {} },
368
+ }
369
+ expect(
370
+ deriveStashFromDefaults(defaults, { n: 7, rest: { extra: 'x' } }),
371
+ ).toEqual({
372
+ count: 7,
373
+ text: null,
374
+ doubled: 0,
375
+ rest: { extra: 'x' },
376
+ })
377
+ })
378
+ })
@@ -20,6 +20,7 @@ function envWith(
20
20
  propsForLift: new Set(
21
21
  bindings.filter(([, k]) => k === 'prop').map(([n]) => n),
22
22
  ),
23
+ propSourceNames: options?.propSourceNames ?? new Map(),
23
24
  propsObjectName: options?.propsObjectName ?? 'props',
24
25
  allowFallback: options?.allowFallback ?? true,
25
26
  }
@@ -35,6 +35,7 @@ function envWith(
35
35
  propsForLift: new Set(
36
36
  bindings.filter(([, k]) => k === 'prop').map(([n]) => n),
37
37
  ),
38
+ propSourceNames: options?.propSourceNames ?? new Map(),
38
39
  propsObjectName: options?.propsObjectName ?? 'props',
39
40
  allowFallback: options?.allowFallback ?? true,
40
41
  templatePrimitives: options?.templatePrimitives,
@@ -28,6 +28,17 @@ export interface TemplateSections {
28
28
  * component in a source file.
29
29
  */
30
30
  moduleConstants?: string
31
+ /**
32
+ * When true, the `moduleConstants` section already carries the module's
33
+ * EXPORTED value declarations (consts/functions, `export` keyword
34
+ * included) interleaved in source order — so the compiler must NOT
35
+ * re-emit them via `generateModuleExports` (only `export { … }`
36
+ * specifier blocks remain its job). JSX adapters set this; keeping the
37
+ * exported and non-exported declarations in one source-ordered section
38
+ * is what prevents a non-exported const that reads an exported one from
39
+ * being emitted above it (a module-load TDZ crash — #2570's input-otp).
40
+ */
41
+ moduleConstantsIncludeExports?: boolean
31
42
  }
32
43
 
33
44
  export interface AdapterOutput {
@@ -6,15 +6,19 @@
6
6
  */
7
7
 
8
8
  import type {
9
+ AttrValue,
9
10
  ComponentIR,
10
11
  IRNode,
12
+ IRTemplatePart,
11
13
  ImportSpecifier,
12
14
  } from '../types.ts'
15
+ import { templatePartsToJsExpr } from '../template-parts.ts'
13
16
  import { BF_SCOPE, BF_SLOT, BF_COND } from '@barefootjs/shared'
14
17
  import { BaseAdapter } from './interface.ts'
15
18
  import type { CallbackBodyAcceptor } from './interface.ts'
16
19
  import { ENV_SIGNAL_CLIENT_FACTORY } from './env-signal.ts'
17
20
  import { formatParamWithType, findReachableNames } from '../module-exports.ts'
21
+ import { extractFreeIdentifiersFromText } from '../ir-to-client-js/csr-substitute.ts'
18
22
 
19
23
  export interface JsxAdapterConfig {
20
24
  /** Use typed versions (typedInitialValue, etc.) for type-safe .tsx output */
@@ -144,11 +148,15 @@ export abstract class JsxAdapter extends BaseAdapter {
144
148
  const initialValue = rawInitialValue.trim().startsWith('{') ? `(${rawInitialValue})` : rawInitialValue
145
149
 
146
150
  // When preserveTypes and typedInitialValue is absent but signal.type has a meaningful
147
- // type from a generic parameter, add a type assertion to prevent TS inference issues
151
+ // type from a generic parameter, add a type assertion to prevent TS inference issues.
152
+ // A bare `object` raw is the analyzer's coarse KIND, not a real type — asserting
153
+ // `as object` only destroys the initializer's inferred literal type (a
154
+ // `{ x: 0, y: 0 }` signal getter stops matching `() => { x: number; y: number }`).
148
155
  const needsTypeAssertion = preserveTypes
149
156
  && !signal.typedInitialValue
150
157
  && signal.type.kind !== 'unknown'
151
158
  && signal.type.kind !== 'primitive'
159
+ && signal.type.raw !== 'object'
152
160
  if (needsTypeAssertion) {
153
161
  lines.push(` const ${signal.getter} = () => ${initialValue} as ${signal.type.raw}`)
154
162
  } else {
@@ -173,12 +181,24 @@ export abstract class JsxAdapter extends BaseAdapter {
173
181
  lines.push(` const ${memo.name} = ${computation}`)
174
182
  }
175
183
 
176
- // Include local constants — skip unreachable ones (only used in event handlers)
184
+ // Include local constants — skip unreachable ones (only used in event
185
+ // handlers). Genuinely module-scope constants are NOT localised here:
186
+ // they're emitted at module scope by `generateModuleScopeDeclarations`
187
+ // so module-scope types that reference them (via `typeof`) keep
188
+ // resolving, matching the client bundle's `emitModuleLevelDeclarations`
189
+ // (#2570). `moduleScopeDeclarationNames` — not the raw `isModule` flag
190
+ // — decides the split; see its docstring.
191
+ const moduleScopeNames = this.moduleScopeDeclarationNames(ir)
177
192
  for (const constant of ir.metadata.localConstants) {
178
193
  if (constant.isExported) continue
194
+ if (moduleScopeNames.has(constant.name)) continue
179
195
  const keyword = constant.declarationKind ?? 'const'
180
196
  if (!constant.value) {
181
- lines.push(` ${keyword} ${constant.name}`)
197
+ // No initializer (e.g. `let emblaApi: EmblaCarouselType | undefined`)
198
+ // — carry the declared type annotation through so `.tsx` output
199
+ // doesn't fall back to implicit `any` (TS7034/TS7005, #2573).
200
+ const typeAnnotation = preserveTypes && constant.type ? `: ${constant.type.raw}` : ''
201
+ lines.push(` ${keyword} ${constant.name}${typeAnnotation}`)
182
202
  continue
183
203
  }
184
204
  const value = constant.value.trim()
@@ -196,8 +216,11 @@ export abstract class JsxAdapter extends BaseAdapter {
196
216
  lines.push(` ${keyword} ${constant.name} = ${constValue}`)
197
217
  }
198
218
 
199
- // Include local functions — skip unreachable ones (only used in event handlers)
219
+ // Include local functions — skip unreachable ones (only used in event
220
+ // handlers). Genuinely module-scope functions stay at module scope,
221
+ // same as constants above.
200
222
  for (const func of localFunctions) {
223
+ if (moduleScopeNames.has(func.name)) continue
201
224
  if (!reachable.has(func.name)) continue
202
225
  // Prefer the source-verbatim signature when types are preserved so
203
226
  // type-predicate annotations (`element is { tag: unknown; … }`) and
@@ -219,6 +242,207 @@ export abstract class JsxAdapter extends BaseAdapter {
219
242
  return lines.join('\n')
220
243
  }
221
244
 
245
+ // ===========================================================================
246
+ // Module-Scope Declarations
247
+ // ===========================================================================
248
+
249
+ /**
250
+ * Names that genuinely live at module scope in the emitted template.
251
+ *
252
+ * The analyzer's `isModule` flag is NOT a lexical-scope oracle: the
253
+ * module walker recurses into component bodies, so a component-body
254
+ * helper (calendar's `renderMonthGrid`, xyflow's edge handlers) can
255
+ * carry `isModule: true` while closing over component state. Hoisting
256
+ * such a helper breaks every reference (TS2304/TS2552 — and worse,
257
+ * wrong runtime scope). So candidates are demoted by a forward-
258
+ * reachability fixpoint, mirroring the client bundle's
259
+ * `computeDeclarationScopes` (`ir-to-client-js/compute-scope.ts`): a
260
+ * candidate that references component scope — a signal getter/setter, a
261
+ * memo, a prop, a body const/function, or an already-demoted candidate
262
+ * — is component-scoped, transitively. References are REAL identifier
263
+ * nodes from a TS AST walk (`extractFreeIdentifiersFromText`), not text
264
+ * matches — a component-scope name occurring inside a candidate's
265
+ * string literal (`[data-state="open"]` vs a body const `open`) is not
266
+ * a reference and must not demote it.
267
+ *
268
+ * EXPORTED candidates are never demoted: `export` is only legal at
269
+ * module top level, so an exported declaration is lexically module-
270
+ * scoped by construction — and a spurious demotion would make it
271
+ * vanish entirely (the body loop skips exported declarations and
272
+ * `generateModuleExports` is told to skip value declarations).
273
+ *
274
+ * Memoized per IR: `generateModuleScopeDeclarations` (module emission)
275
+ * and `generateSignalInitializers` (body emission) must agree on the
276
+ * split or a declaration is emitted twice or not at all.
277
+ */
278
+ private readonly moduleScopeNamesCache = new WeakMap<ComponentIR, Set<string>>()
279
+
280
+ protected moduleScopeDeclarationNames(ir: ComponentIR): Set<string> {
281
+ const cached = this.moduleScopeNamesCache.get(ir)
282
+ if (cached) return cached
283
+
284
+ const componentScope = new Set<string>()
285
+ for (const sig of ir.metadata.signals) {
286
+ if (sig.isModule) continue
287
+ componentScope.add(sig.getter)
288
+ if (sig.setter) componentScope.add(sig.setter)
289
+ }
290
+ for (const memo of ir.metadata.memos) {
291
+ if (!memo.isModule) componentScope.add(memo.name)
292
+ }
293
+ for (const p of ir.metadata.propsParams) componentScope.add(p.name)
294
+ if (ir.metadata.propsObjectName) componentScope.add(ir.metadata.propsObjectName)
295
+ if (ir.metadata.restPropsName) componentScope.add(ir.metadata.restPropsName)
296
+ for (const c of ir.metadata.localConstants) {
297
+ if (!c.isModule) componentScope.add(c.name)
298
+ }
299
+ for (const f of ir.metadata.localFunctions) {
300
+ if (!f.isModule) componentScope.add(f.name)
301
+ }
302
+
303
+ // Exported declarations join the emit set unconditionally (see the
304
+ // docstring); only non-exported candidates enter the demotion
305
+ // fixpoint, each carrying its REAL free-identifier set.
306
+ const exported = new Set<string>()
307
+ const candidates = new Map<string, ReadonlySet<string>>()
308
+ for (const c of ir.metadata.localConstants) {
309
+ if (!c.isModule) continue
310
+ // JSX-valued consts are inlined at their usage sites at IR level
311
+ // (#547/#569) — same skip as the client's `classifyConstant`.
312
+ if (c.isJsx || c.isJsxFunction) continue
313
+ if (c.isExported) {
314
+ exported.add(c.name)
315
+ continue
316
+ }
317
+ // The analyzer precomputes the value's free identifiers; fall back
318
+ // to the same AST walk for values it didn't cover.
319
+ candidates.set(c.name, c.freeIdentifiers ?? extractFreeIdentifiersFromText(c.value ?? ''))
320
+ }
321
+ for (const f of ir.metadata.localFunctions) {
322
+ if (!f.isModule) continue
323
+ // JSX-returning helpers (single- and multi-return) are inlined at
324
+ // their call sites (#569/#932); emitting them too would resurrect
325
+ // them as dead module-scope declarations — and the multi-return
326
+ // bodies carry raw source JSX that must not reach the template
327
+ // verbatim. Same skips as the client's `computeDeclarationScopes`.
328
+ if (f.isJsxFunction || f.isMultiReturnJsxHelper) continue
329
+ if (f.isExported) {
330
+ exported.add(f.name)
331
+ continue
332
+ }
333
+ // Wrap as an ARROW so the extractor's parameter shadowing applies
334
+ // (it tracks arrow params only) — param references then don't count
335
+ // as free, matching the client fixpoint's `refs.delete(p.name)`.
336
+ const params = f.typedParams !== undefined
337
+ ? f.typedParams
338
+ : f.params.map(formatParamWithType).join(', ')
339
+ candidates.set(f.name, extractFreeIdentifiersFromText(`(${params}) => ${f.body}`))
340
+ }
341
+
342
+ const referencesAny = (refs: ReadonlySet<string>, names: ReadonlySet<string>): boolean => {
343
+ for (const ref of refs) {
344
+ if (names.has(ref)) return true
345
+ }
346
+ return false
347
+ }
348
+ let changed = true
349
+ while (changed) {
350
+ changed = false
351
+ for (const [name, refs] of candidates) {
352
+ if (referencesAny(refs, componentScope)) {
353
+ candidates.delete(name)
354
+ componentScope.add(name)
355
+ changed = true
356
+ }
357
+ }
358
+ }
359
+
360
+ const result = new Set([...exported, ...candidates.keys()])
361
+ this.moduleScopeNamesCache.set(ir, result)
362
+ return result
363
+ }
364
+
365
+ /**
366
+ * Module-scope type, constant, and function declarations for the
367
+ * emitted template, kept at MODULE scope in SOURCE ORDER — the emitted
368
+ * module preserves the source module's shape, as the client bundle
369
+ * already does (`emitModuleLevelDeclarations` in `ir-to-client-js`).
370
+ * Root cure for the #2570 family: type declarations are re-emitted
371
+ * verbatim at module scope, so any value they reference through
372
+ * `typeof` (a type alias's `keyof typeof strokePaths`, a props
373
+ * annotation's `keyof typeof modes`) must be declared there too.
374
+ * Localising those values into each component body — the previous
375
+ * shape — failed the query with TS2304, and an unresolved
376
+ * `keyof typeof` degrades to `keyof any`, silently widening the type
377
+ * to `string | number | symbol` and taking every downstream check
378
+ * with it.
379
+ *
380
+ * EXPORTED module declarations are emitted here too (with their
381
+ * `export` keyword), interleaved with the non-exported ones in source
382
+ * order — not split off to `generateModuleExports`' section, which is
383
+ * emitted after this one and so would put an exported const AFTER a
384
+ * non-exported const that reads it (input-otp's `patternPresets`
385
+ * reading `REGEXP_ONLY_DIGITS`): a module-load TDZ crash, not just
386
+ * TS2448. The compiler skips value declarations in
387
+ * `generateModuleExports` when `moduleConstantsIncludeExports` is set
388
+ * on the sections.
389
+ *
390
+ * Emission is deliberately UNFILTERED by per-component reachability: an
391
+ * unused module declaration in the emitted template is harmless and
392
+ * matches the source module. In a multi-component file each component's
393
+ * adapter output carries its own block (the analyzer collects the module
394
+ * declarations lexically preceding the component, so blocks can be
395
+ * unequal prefixes); the compiler merges them with top-level-STATEMENT
396
+ * dedup, so shared declarations land exactly once in source order.
397
+ *
398
+ * `new WeakMap()` bindings stay client-only, and exported
399
+ * `createContext()` bindings stay unemitted, exactly as before.
400
+ */
401
+ protected generateModuleScopeDeclarations(ir: ComponentIR): string {
402
+ const { preserveTypes } = this.jsxConfig
403
+ const moduleNames = this.moduleScopeDeclarationNames(ir)
404
+ const entries: Array<{ line: number, text: string }> = []
405
+
406
+ for (const t of ir.metadata.typeDefinitions) {
407
+ entries.push({ line: t.loc.start.line, text: t.definition })
408
+ }
409
+
410
+ for (const c of ir.metadata.localConstants) {
411
+ if (!c.isModule || !moduleNames.has(c.name)) continue
412
+ const keyword = c.declarationKind ?? 'const'
413
+ const exportKw = c.isExported ? 'export ' : ''
414
+ if (!c.value) {
415
+ entries.push({ line: c.loc.start.line, text: `${exportKw}${keyword} ${c.name}` })
416
+ continue
417
+ }
418
+ const trimmed = c.value.trim()
419
+ if (/^new WeakMap\b/.test(trimmed)) continue
420
+ if (c.isExported && /^createContext\b/.test(trimmed)) continue
421
+ const value = preserveTypes ? (c.typedValue ?? c.value) : c.value
422
+ entries.push({ line: c.loc.start.line, text: `${exportKw}${keyword} ${c.name} = ${value}` })
423
+ }
424
+
425
+ for (const f of ir.metadata.localFunctions) {
426
+ if (!f.isModule || !moduleNames.has(f.name)) continue
427
+ const params = preserveTypes && f.typedParams !== undefined
428
+ ? f.typedParams
429
+ : f.params.map(formatParamWithType).join(', ')
430
+ const returnAnnotation = preserveTypes && f.typedReturnType
431
+ ? `: ${f.typedReturnType}`
432
+ : ''
433
+ const body = preserveTypes ? (f.typedBody ?? f.body) : f.body
434
+ const asyncKw = f.isAsync ? 'async ' : ''
435
+ const exportKw = f.isExported ? 'export ' : ''
436
+ entries.push({
437
+ line: f.loc.start.line,
438
+ text: `${exportKw}${asyncKw}function ${f.name}(${params})${returnAnnotation} ${body}`,
439
+ })
440
+ }
441
+
442
+ entries.sort((a, b) => a.line - b.line)
443
+ return entries.map(e => e.text).join('\n')
444
+ }
445
+
222
446
  // ===========================================================================
223
447
  // Raw Node Rendering
224
448
  // ===========================================================================
@@ -233,6 +457,44 @@ export abstract class JsxAdapter extends BaseAdapter {
233
457
  return this.renderNode(node)
234
458
  }
235
459
 
460
+ // ===========================================================================
461
+ // Template Part Rendering
462
+ // ===========================================================================
463
+
464
+ /**
465
+ * Render a structured `template` variant's parts as JS template-literal
466
+ * source for this adapter's .tsx output, carrying the `preserveTypes`
467
+ * index annotation on inlined `lookup` records (#2565 — see
468
+ * `lookupPartToJsExpr`).
469
+ */
470
+ protected renderTemplatePartsAsJs(parts: readonly IRTemplatePart[]): string {
471
+ return templatePartsToJsExpr(parts, { typed: this.jsxConfig.preserveTypes })
472
+ }
473
+
474
+ /**
475
+ * The JS source for an `expression` attribute / component-prop value.
476
+ *
477
+ * A component-prop `template` is collapsed into an `expression` at IR
478
+ * construction time (component props are runtime values, not HTML
479
+ * attribute bodies), which drops it out of `renderTemplatePartsAsJs`'s
480
+ * reach — the same inlined-record index, minus the type annotation
481
+ * (#2565). The collapse keeps its `parts`, so re-render from those when
482
+ * `expr` is still EXACTLY the neutral collapse. Any later rewrite of
483
+ * `expr` (a presence peel, a prop-ref rewrite) fails the identity check
484
+ * and wins, so this can only ever add the annotation, never undo a
485
+ * downstream edit.
486
+ */
487
+ protected expressionValueToJs(value: Extract<AttrValue, { kind: 'expression' }>): string {
488
+ if (
489
+ this.jsxConfig.preserveTypes &&
490
+ value.parts &&
491
+ value.expr === templatePartsToJsExpr(value.parts)
492
+ ) {
493
+ return this.renderTemplatePartsAsJs(value.parts)
494
+ }
495
+ return value.expr
496
+ }
497
+
236
498
  // ===========================================================================
237
499
  // Hydration Markers
238
500
  // ===========================================================================
@@ -5,6 +5,7 @@
5
5
  * Generates simple JSX output without framework-specific features.
6
6
  */
7
7
 
8
+ import ts from 'typescript'
8
9
  import type {
9
10
  ComponentIR,
10
11
  IRNode,
@@ -20,6 +21,7 @@ import type {
20
21
  import type { AdapterOutput, TemplateSections } from './interface.ts'
21
22
  import { type JsxAdapterConfig, JsxAdapter } from './jsx-adapter.ts'
22
23
  import { rewriteImportsForTemplate } from './template-imports.ts'
24
+ import { propsDestructureBinding } from '../props-binding.ts'
23
25
 
24
26
  export class TestAdapter extends JsxAdapter {
25
27
  name = 'test'
@@ -31,6 +33,7 @@ export class TestAdapter extends JsxAdapter {
31
33
  this.componentName = ir.metadata.componentName
32
34
 
33
35
  const imports = this.generateImports(ir)
36
+ const moduleConstants = this.generateModuleScopeDeclarations(ir)
34
37
  const types = this.generateTypes(ir)
35
38
  const component = this.generateComponent(ir)
36
39
 
@@ -43,10 +46,12 @@ export class TestAdapter extends JsxAdapter {
43
46
  types: types || '',
44
47
  component,
45
48
  defaultExport,
49
+ moduleConstants,
50
+ moduleConstantsIncludeExports: true,
46
51
  }
47
52
 
48
53
  // Assemble template for backward compat
49
- const template = [imports, types, component].filter(Boolean).join('\n\n') + defaultExport
54
+ const template = [imports, moduleConstants, types, component].filter(Boolean).join('\n\n') + defaultExport
50
55
 
51
56
  return {
52
57
  template,
@@ -84,9 +89,9 @@ export class TestAdapter extends JsxAdapter {
84
89
  generateTypes(ir: ComponentIR): string | null {
85
90
  const lines: string[] = []
86
91
 
87
- for (const typeDef of ir.metadata.typeDefinitions) {
88
- lines.push(typeDef.definition)
89
- }
92
+ // Source type declarations are emitted once at module scope by
93
+ // `generateModuleScopeDeclarations` (#2570) — only the synthesized
94
+ // hydration alias is per-component.
90
95
 
91
96
  // Only generate PropsWithHydration when destructured-props pattern uses it
92
97
  const propsTypeName = ir.metadata.propsType?.raw
@@ -122,8 +127,10 @@ export class TestAdapter extends JsxAdapter {
122
127
  const bodyRefText = [jsxBody, signalInits, scopeIdLine].join('\n')
123
128
  const bfScopeAlias = /\b__bfScope\b/.test(bodyRefText) ? '__bfScope' : '__bfScope: _bfScope'
124
129
 
130
+ // `key: local` rename-aware bindings, shared with the Hono SSR
131
+ // destructure — one renderer, zero drift (`propsDestructureBinding`).
125
132
  const propsParams = ir.metadata.propsParams
126
- .map((p: ParamInfo) => (p.defaultValue ? `${p.name} = ${p.defaultValue}` : p.name))
133
+ .map((p: ParamInfo) => propsDestructureBinding(p))
127
134
  .join(', ')
128
135
 
129
136
  const restPropsName = ir.metadata.restPropsName
@@ -325,11 +332,7 @@ export class TestAdapter extends JsxAdapter {
325
332
  // Simple stringifier for `template`-kind values; tests only need a
326
333
  // recognisable JSX shape, not byte-exact reproduction.
327
334
  const v = value as { kind: 'template'; parts: import('../types.ts').IRTemplatePart[] }
328
- return '`' + v.parts.map(p => {
329
- if (p.type === 'string') return p.value
330
- if (p.type === 'ternary') return `\${${p.condition} ? '${p.whenTrue}' : '${p.whenFalse}'}`
331
- return `\${(${JSON.stringify(p.cases)})[${p.key}]}`
332
- }).join('') + '`'
335
+ return this.renderTemplatePartsAsJs(v.parts)
333
336
  }
334
337
 
335
338
  private renderComponentProps(comp: IRComponent): string {