@barefootjs/jsx 0.31.0 → 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
package/src/analyzer.ts CHANGED
@@ -8,9 +8,10 @@
8
8
 
9
9
  import ts from 'typescript'
10
10
  import type { ImportSpecifier, TypeInfo, ParamInfo, ReactiveFactoryInfo, DeclinedReactiveFactory, RequiredFactoryImport, FactoryRenameSite, SourceLocation } from './types.ts'
11
- import { parseExpression, parseBlockBodyTolerant, foldBlockToExpr } from './expression-parser.ts'
11
+ import { parseExpression, parseBlockBodyTolerant, foldBlockToExpr, tsNodeToParsedExpr } from './expression-parser.ts'
12
12
  import type { CallbackBodyAcceptor } from './adapters/interface.ts'
13
13
  import { rewriteBarePropRefs } from './prop-rewrite.ts'
14
+ import { buildPropAliasMap } from './props-binding.ts'
14
15
  import { incrementCounter } from './instrumentation.ts'
15
16
  import {
16
17
  type AnalyzerContext,
@@ -1273,7 +1274,8 @@ function collectSignal(node: ts.VariableDeclaration, ctx: AnalyzerContext): void
1273
1274
  if (!ctx.propsObjectName && callExpr.arguments[0]) {
1274
1275
  const propNames = new Set(ctx.propsParams.map(p => p.name))
1275
1276
  if (propNames.size > 0) {
1276
- templateInitialValue = rewriteBarePropRefs(initialValue, callExpr.arguments[0], propNames)
1277
+ const propAliases = buildPropAliasMap(ctx.propsParams)
1278
+ templateInitialValue = rewriteBarePropRefs(initialValue, callExpr.arguments[0], propNames, undefined, propAliases)
1277
1279
  }
1278
1280
  }
1279
1281
 
@@ -1613,7 +1615,8 @@ function collectMemo(node: ts.VariableDeclaration, ctx: AnalyzerContext): void {
1613
1615
  if (!ctx.propsObjectName && callExpr.arguments[0]) {
1614
1616
  const propNames = new Set(ctx.propsParams.map(p => p.name))
1615
1617
  if (propNames.size > 0) {
1616
- templateComputation = rewriteBarePropRefs(computation, callExpr.arguments[0], propNames)
1618
+ const propAliases = buildPropAliasMap(ctx.propsParams)
1619
+ templateComputation = rewriteBarePropRefs(computation, callExpr.arguments[0], propNames, undefined, propAliases)
1617
1620
  }
1618
1621
  }
1619
1622
 
@@ -3129,9 +3132,15 @@ function collectConstant(
3129
3132
  let templateValue: string | undefined
3130
3133
  if (value && ctx.propsParams.length > 0 && node.initializer) {
3131
3134
  let propNames: Set<string>
3135
+ // Local → caller-facing key (#2524 CSR half) — only Case 1 propsParams
3136
+ // entries can carry `sourceName`; Case 2's alias set below is keyed by
3137
+ // the destructured-from-`props.X` local name, which by its own
3138
+ // `isPureAlias` construction always equals the source key.
3139
+ let propAliases: Map<string, string> | undefined
3132
3140
  if (!ctx.propsObjectName) {
3133
3141
  // Case 1: destructured-arg — all propsParams are bare locals.
3134
3142
  propNames = new Set(ctx.propsParams.map(p => p.name))
3143
+ propAliases = buildPropAliasMap(ctx.propsParams)
3135
3144
  } else {
3136
3145
  // Case 2: `(props)`-arg — restrict to body-destructured pure aliases
3137
3146
  // of `props.X` (entries pushed by the body-destructure expansion at
@@ -3157,7 +3166,7 @@ function collectConstant(
3157
3166
  )
3158
3167
  }
3159
3168
  if (propNames.size > 0) {
3160
- const rewritten = rewriteBarePropRefs(value, node.initializer, propNames)
3169
+ const rewritten = rewriteBarePropRefs(value, node.initializer, propNames, undefined, propAliases)
3161
3170
  if (rewritten !== undefined) {
3162
3171
  templateValue = rewritten
3163
3172
  // For the body-destructure case, also rewrite the init-body
@@ -3307,6 +3316,12 @@ function extractProps(param: ts.ParameterDeclaration, ctx: AnalyzerContext): voi
3307
3316
  const resolvedType: TypeInfo = member?.type ?? { kind: 'unknown', raw: 'unknown' }
3308
3317
 
3309
3318
  const defaultContainsArrow = element.initializer ? nodeContainsArrow(element.initializer) : false
3319
+ // Structured mirror of `defaultValue`, from the binding element's OWN
3320
+ // `initializer` node — `tsNodeToParsedExpr` converts the already-parsed
3321
+ // AST directly, no re-parse of the `defaultValue` text (same convention
3322
+ // as `SignalInfo.parsed`). An unsupported shape leaves `parsed` unset;
3323
+ // consumers fall back to `defaultValue` text.
3324
+ const parsedDefault = element.initializer ? tsNodeToParsedExpr(element.initializer) : undefined
3310
3325
  ctx.propsParams.push({
3311
3326
  name: localName,
3312
3327
  type: resolvedType,
@@ -3314,6 +3329,7 @@ function extractProps(param: ts.ParameterDeclaration, ctx: AnalyzerContext): voi
3314
3329
  // the caller may omit the prop.
3315
3330
  optional: !!member?.optional || !!element.initializer,
3316
3331
  defaultValue,
3332
+ ...(parsedDefault && parsedDefault.kind !== 'unsupported' && { parsed: parsedDefault }),
3317
3333
  defaultContainsArrow: defaultContainsArrow || undefined,
3318
3334
  // Only aliased bindings carry the source key — see ParamInfo.sourceName.
3319
3335
  ...(sourcePropName !== localName && { sourceName: sourcePropName }),
@@ -3631,21 +3647,47 @@ function inferTypeFromValue(value: string): TypeInfo {
3631
3647
  return { kind: 'unknown', raw: 'unknown' }
3632
3648
  }
3633
3649
 
3650
+ /**
3651
+ * Identifiers that appear as a direct call (`name(...)`) in `code`,
3652
+ * collected off a real TS parse — never a regex over the raw text, which
3653
+ * false-matches inside comments and string literals (the repo-wide
3654
+ * "no regex over JS syntax" rule). A doc comment mentioning
3655
+ * `otherSignal()` inside a memo body must NOT become a dependency
3656
+ * (phantom `internalValue`/`controlledValue` deps on slider's
3657
+ * `percentage`, #2581 review). A raw token scan is not enough either:
3658
+ * without a parser driving `reScanTemplateToken`, everything between a
3659
+ * substitution's closing `}` and the next backtick is mis-lexed as code
3660
+ * (and the template tail swallows real code after it), so a read
3661
+ * following a `${...}` template — xyflow's `visibleClass` reading
3662
+ * `animated()` after the `${BF_FLOW_EDGE_SELECTED}` line — would vanish.
3663
+ */
3664
+ function collectCalledIdentifiers(code: string): Set<string> {
3665
+ const called = new Set<string>()
3666
+ const sf = ts.createSourceFile('__deps__.tsx', code, ts.ScriptTarget.Latest, false, ts.ScriptKind.TSX)
3667
+ const visit = (node: ts.Node): void => {
3668
+ if (ts.isCallExpression(node) && ts.isIdentifier(node.expression)) {
3669
+ called.add(node.expression.text)
3670
+ }
3671
+ ts.forEachChild(node, visit)
3672
+ }
3673
+ visit(sf)
3674
+ return called
3675
+ }
3676
+
3634
3677
  function extractDependencies(code: string, ctx: AnalyzerContext): string[] {
3678
+ const called = collectCalledIdentifiers(code)
3635
3679
  const deps: string[] = []
3636
3680
 
3637
3681
  // Find signal getter calls: signalName()
3638
3682
  for (const signal of ctx.signals) {
3639
- const pattern = new RegExp(`\\b${signal.getter}\\s*\\(`, 'g')
3640
- if (pattern.test(code)) {
3683
+ if (called.has(signal.getter)) {
3641
3684
  deps.push(signal.getter)
3642
3685
  }
3643
3686
  }
3644
3687
 
3645
3688
  // Find memo calls: memoName()
3646
3689
  for (const memo of ctx.memos) {
3647
- const pattern = new RegExp(`\\b${memo.name}\\s*\\(`, 'g')
3648
- if (pattern.test(code)) {
3690
+ if (called.has(memo.name)) {
3649
3691
  deps.push(memo.name)
3650
3692
  }
3651
3693
  }
package/src/compiler.ts CHANGED
@@ -11,6 +11,7 @@ import type {
11
11
  CompileResult,
12
12
  FileOutput,
13
13
  } from './types.ts'
14
+ import ts from 'typescript'
14
15
  import type { TemplateAdapter } from './adapters/interface.ts'
15
16
  import { analyzeComponent, listComponentFunctions, createProgramForFile, needsTypeBasedDetection } from './analyzer.ts'
16
17
  import { jsxToIR } from './jsx-to-ir.ts'
@@ -21,12 +22,13 @@ import { emitModuleLevelDeclarations } from './ir-to-client-js/emit-module-level
21
22
  import { RUNTIME_MODULE, detectUsedImports as detectUsedImportsFromCode, makeValueUsageTest } from './ir-to-client-js/imports.ts'
22
23
  import { setActiveComponentScope, computeFileScope } from './ir-to-client-js/component-scope.ts'
23
24
  import { generateModuleExports, collectInlineExportedNames } from './module-exports.ts'
24
- import { applyCssLayerPrefix } from './css-layer-prefixer.ts'
25
+ import { applyCssLayerPrefix, applyCssLayerPrefixToFile } from './css-layer-prefixer.ts'
25
26
  import { preprocessInlineJsxCallbacks } from './preprocess-inline-jsx-callbacks.ts'
26
27
  import { extractSsrDefaults } from './ssr-defaults.ts'
27
28
  import { computeSsrSeedPlan } from './ssr-seed-plan.ts'
28
29
  import { checkRichTypeMethodCalls } from './rich-type-refusal.ts'
29
- import { ErrorCodes } from './errors.ts'
30
+ import { ErrorCodes, createError } from './errors.ts'
31
+ import { collectComponentNamesFromIR } from './ir-to-client-js/child-components.ts'
30
32
 
31
33
  /**
32
34
  * Extended compile options with required adapter
@@ -163,11 +165,88 @@ function compileMultipleComponents(
163
165
  // call for why this must run before adapter.generate/generateClientJs.
164
166
  decideClientOnlyElision(componentIR.root)
165
167
 
166
- if (options.cssLayerPrefix) {
167
- applyCssLayerPrefix(componentIR, options.cssLayerPrefix)
168
+ entries.push({ componentIR, ctx })
169
+ }
170
+
171
+ // BF048 — refuse loudly when an emitted component template references a
172
+ // same-file sibling that was in `componentNames` (so `listComponentFunctions`
173
+ // treated it as a component to compile — the 'use client' branch of #932
174
+ // does exactly that for multi-return JSX dispatch shapes) but never made
175
+ // it into `entries` (its own `analyzeComponent`/`jsxToIR` pass produced no
176
+ // usable IR — most commonly a top-level `switch` dispatch, which
177
+ // `visitComponentBody` preserves as a verbatim init statement rather than
178
+ // folding into `conditionalReturns`, so `ctx.jsxReturn` stays null).
179
+ // Pre-fix this compiled clean and the emitted `renderChild`/`initChild`/
180
+ // `createComponent` call threw `ReferenceError: <Name> is not defined` at
181
+ // SSR/hydrate time (#2556) — the silence itself is the bug. Detection
182
+ // walks the IR component-reference graph (the same walk that drives
183
+ // `@bf-child` import markers, `collectComponentNamesFromIR`), never the
184
+ // emitted template text.
185
+ //
186
+ // Restricted to TOP-LEVEL declarations: `listComponentFunctions` recurses
187
+ // into function bodies, so `componentNames` also carries component-scope
188
+ // local factories (`const Inner = () => <span/>` inside a component).
189
+ // Those never compile to standalone templates either, but their call
190
+ // sites are handled by the JSX-function-inlining pass — flagging them
191
+ // here would fail legal programs (the ir-dynamic-tag / resolver-alias
192
+ // corpus shapes). Only a name declared at module top level can be the
193
+ // dropped-sibling shape this diagnostic exists for.
194
+ //
195
+ // Restricted to 'use client' FILES: only the 'use client' branch of #932
196
+ // widens `listComponentFunctions` to multi-return dispatch shapes; in a
197
+ // non-client file an uncompiled sibling is preserved verbatim (that is
198
+ // the escape hatch the message below recommends), so flagging it there
199
+ // would fail exactly the programs the fix tells users to write.
200
+ if (entries.some(e => e.componentIR.metadata.isClientComponent)) {
201
+ const topLevelNames = new Set<string>()
202
+ {
203
+ const sf = ts.createSourceFile(filePath, source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX)
204
+ for (const stmt of sf.statements) {
205
+ if (ts.isFunctionDeclaration(stmt) && stmt.name) topLevelNames.add(stmt.name.text)
206
+ else if (ts.isVariableStatement(stmt)) {
207
+ for (const d of stmt.declarationList.declarations) {
208
+ if (ts.isIdentifier(d.name)) topLevelNames.add(d.name.text)
209
+ }
210
+ }
211
+ }
212
+ }
213
+ const compiledNames = new Set(entries.map(e => e.componentIR.metadata.componentName))
214
+ const uncompiledSiblings = new Set(
215
+ componentNames.filter(name => !compiledNames.has(name) && topLevelNames.has(name)),
216
+ )
217
+ if (uncompiledSiblings.size > 0) {
218
+ for (const { componentIR } of entries) {
219
+ const referenced = new Set<string>()
220
+ collectComponentNamesFromIR([componentIR.root], referenced)
221
+ for (const name of referenced) {
222
+ if (!uncompiledSiblings.has(name)) continue
223
+ errors.push(createError(
224
+ ErrorCodes.SIBLING_COMPONENT_NOT_COMPILED,
225
+ componentIR.root.loc,
226
+ {
227
+ message:
228
+ `Component '${componentIR.metadata.componentName}' references sibling ` +
229
+ `'<${name}>', which did not compile to a template in this 'use client' file ` +
230
+ `(likely a multi-return JSX dispatch — a \`switch\` or \`if\`/\`else\` chain ` +
231
+ `across multiple JSX-returning branches). This reference would throw ` +
232
+ `\`ReferenceError: ${name} is not defined\` at SSR/hydrate time. Extract '${name}' ` +
233
+ `to a separate non-"use client" file (where #932 preserves it verbatim), or ` +
234
+ `rewrite it as a single-return ternary/conditional chain so the component ` +
235
+ `pipeline can compile it.`,
236
+ },
237
+ ))
238
+ }
239
+ }
168
240
  }
241
+ }
169
242
 
170
- entries.push({ componentIR, ctx })
243
+ // CSS layer prefixing must be FILE-WIDE: per-IR application diverges the
244
+ // per-component copies of a shared module-scope constant (prefixed in the
245
+ // components whose class attrs reference it, raw elsewhere), and module
246
+ // shape emission (#2570) would then declare the constant twice. See
247
+ // `applyCssLayerPrefixToFile`.
248
+ if (options.cssLayerPrefix) {
249
+ applyCssLayerPrefixToFile(entries.map(e => e.componentIR), options.cssLayerPrefix)
171
250
  }
172
251
 
173
252
  // BF050 is a per-FILE diagnostic (it points at the brand-package import
@@ -220,13 +299,35 @@ function compileMultipleComponents(
220
299
  }
221
300
  }
222
301
 
223
- // Module-scope statements (e.g. SSR-side context bindings) are file-wide:
224
- // every component in the same source file generates the same block, so we
225
- // collect via exact-string dedup and emit once at the file level rather
226
- // than per component (per-line dedup of imports drops repeated lines like
227
- // closing `})` that recur across multiple multi-line bindings).
228
- const moduleConstantsSet = new Set<string>()
229
- const moduleConstantsOrdered: string[] = []
302
+ // Module-scope statements (types, consts, functions, SSR-side context
303
+ // bindings) are file-wide, but each component's IR may carry a different
304
+ // SUBSET the analyzer collects the module declarations lexically
305
+ // preceding the component, so in a file that interleaves constants with
306
+ // component functions the blocks are unequal prefixes of one another
307
+ // (carousel does this). Whole-block string dedup would then emit every
308
+ // prefix and redeclare each name (TS2300/TS2451), while per-LINE dedup
309
+ // would corrupt multi-line declarations (repeated `})` lines collapse).
310
+ // So dedup at the TOP-LEVEL-STATEMENT level via a TS parse of each block
311
+ // — the repo-wide idiom for splitting emitted source (never regex/line
312
+ // matching). Identical statements re-collected across components dedup
313
+ // to their first occurrence, preserving source order.
314
+ const moduleStatementSeen = new Set<string>()
315
+ const moduleStatementsOrdered: string[] = []
316
+ const collectModuleStatements = (block: string): void => {
317
+ const sf = ts.createSourceFile(
318
+ '__bf_module_decls.tsx',
319
+ block,
320
+ ts.ScriptTarget.Latest,
321
+ /* setParentNodes */ false,
322
+ ts.ScriptKind.TSX,
323
+ )
324
+ for (const stmt of sf.statements) {
325
+ const text = stmt.getText(sf)
326
+ if (moduleStatementSeen.has(text)) continue
327
+ moduleStatementSeen.add(text)
328
+ moduleStatementsOrdered.push(text)
329
+ }
330
+ }
230
331
 
231
332
  // Component-name scope: rewrite `hydrate` / `renderChild` / `initChild` /
232
333
  // `createComponent` / `upsertChild` keys for non-exported helpers
@@ -271,6 +372,7 @@ function compileMultipleComponents(
271
372
  componentIR,
272
373
  fileWideInlineExported,
273
374
  options.rewriteRelativeImport,
375
+ { skipValueDeclarations: adapterOutput.sections.moduleConstantsIncludeExports },
274
376
  )
275
377
 
276
378
  const s = adapterOutput.sections
@@ -278,10 +380,7 @@ function compileMultipleComponents(
278
380
  const types = s.types
279
381
  const component = s.component + (s.defaultExport || '')
280
382
  const mc = s.moduleConstants
281
- if (mc && !moduleConstantsSet.has(mc)) {
282
- moduleConstantsSet.add(mc)
283
- moduleConstantsOrdered.push(mc)
284
- }
383
+ if (mc) collectModuleStatements(mc)
285
384
 
286
385
  allOutputs.push({
287
386
  componentName: componentIR.metadata.componentName,
@@ -415,7 +514,7 @@ function compileMultipleComponents(
415
514
  // Combine all components
416
515
  const combinedTemplate = [
417
516
  mergedImports,
418
- moduleConstantsOrdered.join('\n\n'),
517
+ moduleStatementsOrdered.join('\n\n'),
419
518
  uniqueTypes.join('\n\n'),
420
519
  uniqueModuleExports.length > 0 ? uniqueModuleExports.join('\n') : '',
421
520
  ...allOutputs.map(o => o.component),
@@ -721,7 +820,9 @@ export function compileJSX(
721
820
  if (adapter.templatesPerComponent) {
722
821
  content = adapterOutput.template
723
822
  } else {
724
- const moduleExports = generateModuleExports(componentIR, undefined, options.rewriteRelativeImport)
823
+ const moduleExports = generateModuleExports(componentIR, undefined, options.rewriteRelativeImport, {
824
+ skipValueDeclarations: s.moduleConstantsIncludeExports,
825
+ })
725
826
  content = [s.imports, s.moduleConstants ?? '', s.types, moduleExports, s.component]
726
827
  .filter(Boolean).join('\n\n') + (s.defaultExport || '')
727
828
  }
@@ -80,9 +80,87 @@ export function prefixConstantValue(rawValue: string, layerName: string): string
80
80
  * Prefixes static class attributes and class-related constant values.
81
81
  */
82
82
  export function applyCssLayerPrefix(ir: ComponentIR, layerName: string): void {
83
+ applyCssLayerPrefixToFile([ir], layerName)
84
+ }
85
+
86
+ /**
87
+ * File-wide variant: apply the prefix across ALL of a file's component IRs
88
+ * with ONE union of referenced constants.
89
+ *
90
+ * Each component's IR carries its own `ConstantInfo` copies of the file's
91
+ * module-scope constants, and per-IR application prefixed only the copies
92
+ * the component's own class attributes referenced. That was invisible while
93
+ * every component re-declared the constants inside its body, but module
94
+ * shape emission (#2570) hoists them to ONE module-scope declaration —
95
+ * per-component divergence then emits the same constant twice with
96
+ * different values ("tabsClasses has already been declared", tabs in the
97
+ * site/ui build). Applying the union keeps every IR's copy byte-identical,
98
+ * so the compiler's statement-level dedup collapses them to a single,
99
+ * consistently-prefixed declaration.
100
+ */
101
+ export function applyCssLayerPrefixToFile(irs: readonly ComponentIR[], layerName: string): void {
83
102
  const referencedConstants = new Set<string>()
84
- const constantNames = new Set(ir.metadata.localConstants.map(c => c.name))
103
+ const constantNames = new Set<string>()
104
+ for (const ir of irs) {
105
+ for (const c of ir.metadata.localConstants) constantNames.add(c.name)
106
+ }
107
+
108
+ // Walk every IR tree and process className/class attributes, collecting
109
+ // referenced constants into the shared union.
110
+ for (const ir of irs) {
111
+ collectAndPrefixAttrs(ir, layerName, referencedConstants, constantNames)
112
+ }
113
+
114
+ // Resolve transitive references (constants referencing other constants) —
115
+ // against every IR's constant list, since a name may only carry a value
116
+ // in the IRs whose lexical prefix includes it.
117
+ let changed = true
118
+ while (changed) {
119
+ changed = false
120
+ for (const constName of [...referencedConstants]) {
121
+ for (const ir of irs) {
122
+ const constant = ir.metadata.localConstants.find(c => c.name === constName)
123
+ if (!constant || !constant.value) continue
124
+ for (const id of extractIdentifiers(constant.value)) {
125
+ if (constantNames.has(id) && !referencedConstants.has(id)) {
126
+ referencedConstants.add(id)
127
+ changed = true
128
+ }
129
+ }
130
+ }
131
+ }
132
+ }
85
133
 
134
+ // Apply prefixing to every IR's copy of each referenced constant.
135
+ //
136
+ // `typedValue` (the type-annotation-preserving initializer text used by
137
+ // `preserveTypes` emitters — Hono's `generateModuleScopeDeclarations` /
138
+ // SSR body block prefer `typedValue ?? value`) is a separate string that
139
+ // must be prefixed in lockstep with `value`, or an `as const` / annotated
140
+ // class-map constant is emitted unprefixed even though the class
141
+ // attributes referencing it were prefixed (#2575). `prefixConstantValue`
142
+ // only rewrites string-literal contents (see its docstring), so an `as
143
+ // const` suffix or type annotation elsewhere in the text passes through
144
+ // unchanged.
145
+ for (const ir of irs) {
146
+ for (const constant of ir.metadata.localConstants) {
147
+ if (!referencedConstants.has(constant.name)) continue
148
+ if (constant.value) {
149
+ constant.value = prefixConstantValue(constant.value, layerName)
150
+ }
151
+ if (constant.typedValue) {
152
+ constant.typedValue = prefixConstantValue(constant.typedValue, layerName)
153
+ }
154
+ }
155
+ }
156
+ }
157
+
158
+ function collectAndPrefixAttrs(
159
+ ir: ComponentIR,
160
+ layerName: string,
161
+ referencedConstants: Set<string>,
162
+ constantNames: ReadonlySet<string>,
163
+ ): void {
86
164
  // Walk IR tree and process className/class attributes
87
165
  walkIR(ir.root, (node) => {
88
166
  if (node.type !== 'element') return
@@ -118,28 +196,6 @@ export function applyCssLayerPrefix(ir: ComponentIR, layerName: string): void {
118
196
  }
119
197
  })
120
198
 
121
- // Resolve transitive references (constants referencing other constants)
122
- let changed = true
123
- while (changed) {
124
- changed = false
125
- for (const constName of [...referencedConstants]) {
126
- const constant = ir.metadata.localConstants.find(c => c.name === constName)
127
- if (!constant || !constant.value) continue
128
- for (const id of extractIdentifiers(constant.value)) {
129
- if (constantNames.has(id) && !referencedConstants.has(id)) {
130
- referencedConstants.add(id)
131
- changed = true
132
- }
133
- }
134
- }
135
- }
136
-
137
- // Apply prefixing to referenced constants
138
- for (const constant of ir.metadata.localConstants) {
139
- if (referencedConstants.has(constant.name) && constant.value) {
140
- constant.value = prefixConstantValue(constant.value, layerName)
141
- }
142
- }
143
199
  }
144
200
 
145
201
  // =============================================================================
@@ -168,7 +224,7 @@ function prefixIRTemplateParts(parts: IRTemplatePart[], layerName: string): void
168
224
  function collectConstantRefs(
169
225
  expr: string,
170
226
  refs: Set<string>,
171
- validNames: Set<string>,
227
+ validNames: ReadonlySet<string>,
172
228
  ): void {
173
229
  for (const id of extractIdentifiers(expr)) {
174
230
  if (validNames.has(id)) {
package/src/errors.ts CHANGED
@@ -43,6 +43,16 @@ export const ErrorCodes = {
43
43
  // ReferenceError at hydrate. Fail loud with workaround pointers
44
44
  // (#1414 cell 5).
45
45
  JSX_BRANCH_LOCAL_IN_CALLBACK: 'BF047',
46
+ // A `'use client'` file's emitted template references a same-file
47
+ // sibling component (`<NavIcon .../>`, a `.map()` child, …) whose own
48
+ // body never produced a template — most commonly a multi-return JSX
49
+ // dispatch (`switch` / `if`-`else` chain) that `compileMultipleComponents`
50
+ // silently dropped from its output. Pre-fix this compiled clean and threw
51
+ // `ReferenceError: <Name> is not defined` at SSR/hydrate time (#2556). The
52
+ // equivalent shape in a NON-"use client" file is fine — #932 preserves the
53
+ // helper verbatim instead of compiling it as a component — so this code
54
+ // fires only for the client-component compilation path.
55
+ SIBLING_COMPONENT_NOT_COMPILED: 'BF048',
46
56
 
47
57
  // Import errors (BF050-BF059)
48
58
  SHARED_PROGRAM_REQUIRED: 'BF050',
@@ -146,6 +156,14 @@ const errorMessages: Record<ErrorCode, string> = {
146
156
  "JSX-typed local declared inside an `if`-block cannot be referenced from a callback body (ref / event handler). " +
147
157
  "Render it as a child instead: `<div ref={...}>{local}</div>`.",
148
158
 
159
+ [ErrorCodes.SIBLING_COMPONENT_NOT_COMPILED]:
160
+ "Referenced component did not compile to a template, so this reference would throw " +
161
+ "`ReferenceError` at render time. Multi-return JSX dispatch (a `switch` or `if`/`else` " +
162
+ "chain across multiple JSX-returning branches) cannot compile as a component in a " +
163
+ "'use client' file. Extract it to a separate non-\"use client\" file (where it is preserved " +
164
+ "verbatim, see #932), or rewrite it as a single-return ternary/conditional chain so the " +
165
+ "component pipeline can compile it.",
166
+
149
167
  [ErrorCodes.SHARED_PROGRAM_REQUIRED]:
150
168
  'Shared ts.Program required for type-based reactivity classification. This source imports a Reactive<T>-branded library (e.g. @barefootjs/form) whose getters cannot be classified by regex alone. Pass `options.program` (built via `createProgramForCorpus`) so the analyzer can resolve the brand through the TypeChecker.',
151
169
 
package/src/html-types.ts CHANGED
@@ -248,6 +248,30 @@ export interface SVGPresentationAttributes {
248
248
  filter?: string
249
249
  }
250
250
 
251
+ // ============================================================================
252
+ // SVG Root (`<svg>`) Attributes
253
+ // ============================================================================
254
+
255
+ /**
256
+ * Attributes for the root `<svg>` element, narrowed to `SVGSVGElement` for
257
+ * `ref`. A curated subset mirroring the intrinsic `svg` entry — not the full
258
+ * SVG attribute surface.
259
+ *
260
+ * Public so components that render an `<svg>` as their root and accept
261
+ * passthrough props (icons, spinners, and similar) can type their props with
262
+ * this instead of `HTMLBaseAttributes` — spreading `HTMLBaseAttributes` onto
263
+ * an `<svg>` fails to type-check because its `ref` is `(element: HTMLElement)
264
+ * => void`, incompatible with the `<svg>` intrinsic's
265
+ * `(element: SVGSVGElement) => void` (#2573).
266
+ */
267
+ export type SVGSVGAttributes = SVGBaseAttributes & SVGPresentationAttributes & {
268
+ viewBox?: string
269
+ xmlns?: string
270
+ width?: number | string
271
+ height?: number | string
272
+ ref?: (element: SVGSVGElement) => void
273
+ }
274
+
251
275
  /**
252
276
  * Marker reference attributes shared by `<path>`, `<line>`, `<polyline>`,
253
277
  * and `<polygon>`. Accepts both camelCase and kebab-case spellings.
package/src/index.ts CHANGED
@@ -9,7 +9,10 @@ export { compileJSX, buildMetadata } from './compiler.ts'
9
9
  export type { CompileResult, CompileOptions, CompileOptionsWithAdapter, FileOutput } from './compiler.ts'
10
10
 
11
11
  // SSR template-variable defaults (manifest seeds for stash-based adapters)
12
- export { extractSsrDefaults } from './ssr-defaults.ts'
12
+ export { extractSsrDefaults, deriveStashFromDefaults } from './ssr-defaults.ts'
13
+
14
+ // Shared props-destructure binding + alias-map helpers (#2524)
15
+ export { propsDestructureBinding, buildPropAliasMap, isIdentifierName } from './props-binding.ts'
13
16
  export type { SsrDefault } from './ssr-defaults.ts'
14
17
 
15
18
  // Backend-neutral SSR seed plan (in-template derived signal/memo seeding)
@@ -358,4 +361,7 @@ export type {
358
361
  ImgHTMLAttributes,
359
362
  LabelHTMLAttributes,
360
363
  OptionHTMLAttributes,
364
+
365
+ // SVG attributes (for components whose root is an `<svg>`, e.g. icons)
366
+ SVGSVGAttributes,
361
367
  } from './html-types.ts'
@@ -933,8 +933,11 @@ function collectFromElement(element: IRElement, ctx: ClientJsContext, insideCond
933
933
  // alone was wrong on both counts — it keys on HTML attr names
934
934
  // (`aria-invalid` ≠ source key `error`) and omits event handlers.
935
935
  // (#1467)
936
+ // `applyRestAttrs` filters `Object.keys(_p)` — which is always
937
+ // caller-keyed (#2524 CSR half) — so the exclude list must use
938
+ // the caller-facing key too, not the local binding name.
936
939
  const consumedKeys =
937
- spreadVal === elemRestName ? ctx.propsParams.map(p => p.name) : []
940
+ spreadVal === elemRestName ? ctx.propsParams.map(p => p.sourceName ?? p.name) : []
938
941
  const staticAttrKeys = element.attrs
939
942
  .filter(a => a.name !== '...')
940
943
  .map(a => a.name)
@@ -91,10 +91,12 @@ export function rewriteDestructuredPropsInExpr(expr: string, ctx: ClientJsContex
91
91
  const pattern = new RegExp(`(?<![-.])\\b${prop.name}\\b`, 'g')
92
92
  if (!pattern.test(result)) continue
93
93
 
94
+ // `_p` is always keyed by the caller-facing name (#2524 CSR half).
95
+ const callerKey = prop.sourceName ?? prop.name
94
96
  const defaultVal = prop.defaultValue
95
97
  const replacement = defaultVal
96
- ? `(${PROPS_PARAM}.${prop.name} ?? ${prop.defaultContainsArrow ? `(${defaultVal})` : defaultVal})`
97
- : `${PROPS_PARAM}.${prop.name}`
98
+ ? `(${PROPS_PARAM}.${callerKey} ?? ${prop.defaultContainsArrow ? `(${defaultVal})` : defaultVal})`
99
+ : `${PROPS_PARAM}.${callerKey}`
98
100
  result = result.replace(new RegExp(`(?<![-.])\\b${prop.name}\\b`, 'g'), replacement)
99
101
  }
100
102
 
@@ -25,9 +25,10 @@ export function emitPropsEventHandlers(
25
25
  for (const handlerName of usedFunctions) {
26
26
  if (localNames.has(handlerName)) continue
27
27
  if (neededProps.has(handlerName)) continue
28
- const isProp = ctx.propsParams.some(p => p.name === handlerName)
29
- if (!isProp) continue
30
- lines.push(` const ${handlerName} = ${PROPS_PARAM}.${handlerName}`)
28
+ const prop = ctx.propsParams.find(p => p.name === handlerName)
29
+ if (!prop) continue
30
+ // `_p` is always keyed by the caller-facing name (#2524 CSR half).
31
+ lines.push(` const ${handlerName} = ${PROPS_PARAM}.${prop.sourceName ?? handlerName}`)
31
32
  addedAny = true
32
33
  }
33
34
  if (addedAny) lines.push('')
@@ -41,23 +41,26 @@ export function emitPropsExtraction(
41
41
 
42
42
  for (const propName of neededProps) {
43
43
  const prop = ctx.propsParams.find(p => p.name === propName)
44
+ // `_p` is always keyed by the caller-facing name (#2524 CSR half); the
45
+ // local binding on the left of `const` stays `propName`.
46
+ const callerKey = prop?.sourceName ?? propName
44
47
  const usage = propUsage.get(propName)
45
48
  const defaultVal = prop?.defaultValue
46
49
  if (defaultVal) {
47
50
  // `props.onInput ?? () => {}` is a syntax error — `??` binds tighter
48
51
  // than the arrow head. Wrap arrow defaults in parens.
49
52
  const wrappedDefault = prop?.defaultContainsArrow ? `(${defaultVal})` : defaultVal
50
- lines.push(` const ${propName} = ${PROPS_PARAM}.${propName} ?? ${wrappedDefault}`)
53
+ lines.push(` const ${propName} = ${PROPS_PARAM}.${callerKey} ?? ${wrappedDefault}`)
51
54
  } else if (usage?.usedAsLoopArray) {
52
- lines.push(` const ${propName} = ${PROPS_PARAM}.${propName} ?? []`)
55
+ lines.push(` const ${propName} = ${PROPS_PARAM}.${callerKey} ?? []`)
53
56
  } else if (propHasPropertyAccess(usage) && !propsUsedAsConditions.has(propName)) {
54
- lines.push(` const ${propName} = ${PROPS_PARAM}.${propName} ?? {}`)
57
+ lines.push(` const ${propName} = ${PROPS_PARAM}.${callerKey} ?? {}`)
55
58
  } else {
56
59
  // No synthesized default for a defaultless optional (`{ size }:
57
60
  // { size?: number }`): the JS binding is `undefined` when absent, and
58
61
  // a zero default would diverge from SSR (`size ?? 1` seeds 1
59
62
  // server-side; a `_p.size ?? 0` extraction would hydrate to 0).
60
- lines.push(` const ${propName} = ${PROPS_PARAM}.${propName}`)
63
+ lines.push(` const ${propName} = ${PROPS_PARAM}.${callerKey}`)
61
64
  }
62
65
  }
63
66
  lines.push('')
@@ -159,7 +159,8 @@ function resolveSignalInitialValue(
159
159
  // Case 2b: synthesize the prop accessor + default.
160
160
  const prop = lookups.propByName.get(controlled.propName)
161
161
  const defaultVal = prop?.defaultValue ?? inferDefaultValue(signal.type)
162
- return `${PROPS_PARAM}.${controlled.propName} ?? ${defaultVal}`
162
+ // `_p` is always keyed by the caller-facing name (#2524 CSR half).
163
+ return `${PROPS_PARAM}.${prop?.sourceName ?? controlled.propName} ?? ${defaultVal}`
163
164
  }
164
165
 
165
166
  // Case 3: non-controlled `propsObjectName.X` (only when destructured-
@@ -181,9 +182,11 @@ function buildControlledSignalEffect(
181
182
  if (!signal.setter) return null // read-only — no sync needed
182
183
 
183
184
  const prop = lookups.propByName.get(controlled.propName)
185
+ // `_p` is always keyed by the caller-facing name (#2524 CSR half).
186
+ const callerKey = prop?.sourceName ?? controlled.propName
184
187
  const accessorExpr = prop?.defaultValue
185
- ? `(${PROPS_PARAM}.${controlled.propName} ?? ${prop.defaultValue})`
186
- : `${PROPS_PARAM}.${controlled.propName}`
188
+ ? `(${PROPS_PARAM}.${callerKey} ?? ${prop.defaultValue})`
189
+ : `${PROPS_PARAM}.${callerKey}`
187
190
 
188
191
  return { setter: signal.setter, accessorExpr }
189
192
  }