@barefootjs/jsx 0.33.2 → 0.33.3

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 (47) hide show
  1. package/dist/analyzer.d.ts +17 -0
  2. package/dist/analyzer.d.ts.map +1 -1
  3. package/dist/compiler.d.ts +21 -5
  4. package/dist/compiler.d.ts.map +1 -1
  5. package/dist/index.d.ts +1 -1
  6. package/dist/index.d.ts.map +1 -1
  7. package/dist/index.js +707 -431
  8. package/dist/ir-to-client-js/collect-elements.d.ts.map +1 -1
  9. package/dist/ir-to-client-js/control-flow/stringify/loop-child-arm.d.ts.map +1 -1
  10. package/dist/ir-to-client-js/html-template.d.ts.map +1 -1
  11. package/dist/ir-to-client-js/imports.d.ts +60 -2
  12. package/dist/ir-to-client-js/imports.d.ts.map +1 -1
  13. package/dist/ir-to-client-js/prop-handling.d.ts +4 -7
  14. package/dist/ir-to-client-js/prop-handling.d.ts.map +1 -1
  15. package/dist/ir-to-client-js/utils.d.ts +26 -2
  16. package/dist/ir-to-client-js/utils.d.ts.map +1 -1
  17. package/dist/jsx-to-ir.d.ts.map +1 -1
  18. package/dist/props-binding.d.ts +35 -0
  19. package/dist/props-binding.d.ts.map +1 -1
  20. package/dist/types.d.ts +50 -13
  21. package/dist/types.d.ts.map +1 -1
  22. package/package.json +2 -2
  23. package/src/__tests__/__snapshots__/doc-examples.test.ts.snap +145 -97
  24. package/src/__tests__/child-component-ref-not-mirrored.test.ts +90 -0
  25. package/src/__tests__/ir-to-client-js/imports.test.ts +107 -0
  26. package/src/__tests__/ir-to-client-js/merge-compiled-client-js-imports.test.ts +138 -0
  27. package/src/__tests__/issue-2754-rest-spread-needs-slot.test.ts +85 -0
  28. package/src/__tests__/issue-2756-loop-row-honors-client-only.test.ts +173 -0
  29. package/src/__tests__/merge-template-imports.test.ts +41 -1
  30. package/src/__tests__/multi-component-shared-default-import.test.ts +55 -0
  31. package/src/__tests__/root-key-relay.test.ts +170 -0
  32. package/src/__tests__/signal-getter-not-called.test.ts +149 -0
  33. package/src/__tests__/state-only-file-default-import.test.ts +47 -0
  34. package/src/analyzer.ts +36 -0
  35. package/src/compiler.ts +94 -104
  36. package/src/index.ts +1 -1
  37. package/src/ir-to-client-js/collect-elements.ts +27 -5
  38. package/src/ir-to-client-js/control-flow/stringify/inner-loop.ts +6 -2
  39. package/src/ir-to-client-js/control-flow/stringify/loop-child-arm.ts +5 -2
  40. package/src/ir-to-client-js/html-template.ts +122 -12
  41. package/src/ir-to-client-js/imports.ts +178 -5
  42. package/src/ir-to-client-js/index.ts +5 -0
  43. package/src/ir-to-client-js/prop-handling.ts +6 -17
  44. package/src/ir-to-client-js/utils.ts +30 -2
  45. package/src/jsx-to-ir.ts +480 -52
  46. package/src/props-binding.ts +51 -0
  47. package/src/types.ts +47 -13
package/src/compiler.ts CHANGED
@@ -19,7 +19,7 @@ import { stripClientBuiltinImports } from './builtins.ts'
19
19
  import { generateClientJs, generateClientJsWithSourceMap, analyzeClientNeeds } from './ir-to-client-js/index.ts'
20
20
  import { decideClientOnlyElision } from './ir-to-client-js/client-only-elision.ts'
21
21
  import { emitModuleLevelDeclarations } from './ir-to-client-js/emit-module-level.ts'
22
- import { RUNTIME_MODULE, detectUsedImports as detectUsedImportsFromCode, makeValueUsageTest } from './ir-to-client-js/imports.ts'
22
+ import { RUNTIME_MODULE, detectUsedImports as detectUsedImportsFromCode, makeValueUsageTest, renderUsedImportLines, mergeCompiledClientJsImports } from './ir-to-client-js/imports.ts'
23
23
  import { setActiveComponentScope, computeFileScope } from './ir-to-client-js/component-scope.ts'
24
24
  import { generateModuleExports, collectInlineExportedNames } from './module-exports.ts'
25
25
  import { applyCssLayerPrefix, applyCssLayerPrefixToFile } from './css-layer-prefixer.ts'
@@ -43,11 +43,27 @@ export interface CompileOptionsWithAdapter extends CompileOptions {
43
43
  * conflict-free block.
44
44
  *
45
45
  * Named value/type imports from the same source are folded into their first
46
- * occurrence (preserving line order and first-seen symbol order); every
47
- * other import form (side-effect, default, namespace) is kept in place and
48
- * de-duplicated by exact line. This ensures a symbol is never imported
49
- * twice across sibling components a redeclaration that Bun tolerates but
50
- * stricter ESM parsers (the Deno runtime that renders SSR templates) reject.
46
+ * occurrence (preserving line order and first-seen symbol order); a default
47
+ * or namespace import is likewise folded by source rather than deduplicated
48
+ * by exact line every sibling component that shares a module-scope import
49
+ * declaration compiles it independently, so the same default binding can
50
+ * legally arrive as `import cfg from 'x'` from one component and `import
51
+ * cfg, { helper } from 'x'` from another once only the SECOND one also uses
52
+ * a named specifier from the same statement. Folding by source (default
53
+ * name from the first occurrence that has one, named specifiers unioned)
54
+ * collapses both into one `import cfg, { helper } from 'x'` line; the two
55
+ * exact-line-deduplicated strings would otherwise both survive and
56
+ * redeclare `cfg` (#2767 follow-up — this shape was unreachable before a
57
+ * default/namespace-importing server component could become a real client
58
+ * bundle at all). A namespace specifier can't combine with named ones on
59
+ * one line, so it's folded to its own line, keyed by (source, local name)
60
+ * — see `renderUsedImportLines`'s docstring for the same split rendering
61
+ * rule `collectExternalImports` uses.
62
+ *
63
+ * Every other import form (side-effect) is kept in place and de-duplicated
64
+ * by exact line. This ensures a symbol is never imported twice across
65
+ * sibling components — a redeclaration that Bun tolerates but stricter ESM
66
+ * parsers (the Deno runtime that renders SSR templates) reject.
51
67
  *
52
68
  * For a single-component file the output is identical to the input order;
53
69
  * only repeated sibling imports collapse.
@@ -62,38 +78,70 @@ export interface CompileOptionsWithAdapter extends CompileOptions {
62
78
  export function mergeTemplateImports(lines: string[]): string {
63
79
  const result: string[] = []
64
80
  const valueIdx = new Map<string, number>()
81
+ const valueDefault = new Map<string, string>()
65
82
  const valueNames = new Map<string, Set<string>>()
66
83
  const typeIdx = new Map<string, number>()
67
84
  const typeNames = new Map<string, Set<string>>()
68
85
  const seenOther = new Set<string>()
69
86
 
70
- const fold = (
71
- src: string,
72
- rawNames: string,
73
- idx: Map<string, number>,
74
- names: Map<string, Set<string>>,
75
- render: (src: string, names: Set<string>) => string,
76
- ) => {
77
- if (!idx.has(src)) {
78
- idx.set(src, result.length)
79
- names.set(src, new Set())
87
+ const foldType = (src: string, rawNames: string) => {
88
+ if (!typeIdx.has(src)) {
89
+ typeIdx.set(src, result.length)
90
+ typeNames.set(src, new Set())
80
91
  result.push('')
81
92
  }
82
- const set = names.get(src)!
93
+ const set = typeNames.get(src)!
83
94
  for (const n of rawNames.split(',').map(s => s.trim()).filter(Boolean)) set.add(n)
84
- result[idx.get(src)!] = render(src, set)
95
+ result[typeIdx.get(src)!] = `import type { ${[...set].join(', ')} } from '${src}'`
96
+ }
97
+
98
+ const foldValue = (src: string, defaultName: string | null, rawNames: string | null) => {
99
+ if (!valueIdx.has(src)) {
100
+ valueIdx.set(src, result.length)
101
+ valueNames.set(src, new Set())
102
+ result.push('')
103
+ }
104
+ if (defaultName && !valueDefault.has(src)) valueDefault.set(src, defaultName)
105
+ if (rawNames) {
106
+ const set = valueNames.get(src)!
107
+ for (const n of rawNames.split(',').map(s => s.trim()).filter(Boolean)) set.add(n)
108
+ }
109
+ result[valueIdx.get(src)!] = renderUsedImportLines(
110
+ src,
111
+ valueDefault.get(src) ?? null,
112
+ null,
113
+ [...valueNames.get(src)!],
114
+ ).join('\n')
85
115
  }
86
116
 
87
117
  for (const raw of lines) {
88
118
  const line = raw.trim()
89
119
  if (!line) continue
90
120
  const typeMatch = line.match(/^import\s+type\s*\{([^}]+)\}\s*from\s*['"]([^'"]+)['"]\s*;?$/)
91
- const valueMatch = line.match(/^import\s*\{([^}]+)\}\s*from\s*['"]([^'"]+)['"]\s*;?$/)
92
- if (valueMatch) {
93
- fold(valueMatch[2], valueMatch[1], valueIdx, valueNames, (s, n) => `import { ${[...n].join(', ')} } from '${s}'`)
94
- } else if (typeMatch) {
95
- fold(typeMatch[2], typeMatch[1], typeIdx, typeNames, (s, n) => `import type { ${[...n].join(', ')} } from '${s}'`)
121
+ const namedMatch = line.match(/^import\s*\{([^}]+)\}\s*from\s*['"]([^'"]+)['"]\s*;?$/)
122
+ const defaultNamedMatch = line.match(/^import\s+([A-Za-z_$][\w$]*)\s*,\s*\{([^}]+)\}\s*from\s*['"]([^'"]+)['"]\s*;?$/)
123
+ const defaultOnlyMatch = line.match(/^import\s+([A-Za-z_$][\w$]*)\s*from\s*['"]([^'"]+)['"]\s*;?$/)
124
+
125
+ if (typeMatch) {
126
+ foldType(typeMatch[2], typeMatch[1])
127
+ } else if (namedMatch) {
128
+ foldValue(namedMatch[2], null, namedMatch[1])
129
+ } else if (defaultNamedMatch) {
130
+ foldValue(defaultNamedMatch[3], defaultNamedMatch[1], defaultNamedMatch[2])
131
+ } else if (defaultOnlyMatch) {
132
+ foldValue(defaultOnlyMatch[2], defaultOnlyMatch[1], null)
96
133
  } else if (!seenOther.has(line)) {
134
+ // Covers namespace imports (`import * as X from 'src'`) and
135
+ // side-effect imports alike — deduplicated by exact line, same as
136
+ // before. A namespace import can't combine with named specifiers on
137
+ // one line (see `renderUsedImportLines`), so two components that
138
+ // both import the SAME namespace binding from the SAME source
139
+ // always emit byte-identical lines and collapse here; two DIFFERENT
140
+ // local namespace names for the same source (unusual — would require
141
+ // sibling components to alias the same module-scope `import * as`
142
+ // declaration differently, which isn't possible for one shared
143
+ // declaration) are kept as separate lines rather than silently
144
+ // merged.
97
145
  seenOther.add(line)
98
146
  result.push(line)
99
147
  }
@@ -446,30 +494,9 @@ function compileMultipleComponents(
446
494
  }
447
495
  const clientJsOutputs = allOutputs.map(o => o.clientJs).filter(Boolean) as string[]
448
496
  if (clientJsOutputs.length > 0) {
449
- const importsBySource = new Map<string, Set<string>>()
450
- const otherImports: string[] = []
451
- const allCode: string[] = []
452
- for (const js of clientJsOutputs) {
453
- for (const line of js.split('\n')) {
454
- if (line.startsWith('import ')) {
455
- const match = line.match(/^import \{ ([^}]+) \} from ['"]([^'"]+)['"]$/)
456
- if (match) {
457
- const source = match[2]
458
- if (!importsBySource.has(source)) importsBySource.set(source, new Set())
459
- for (const n of match[1].split(',').map(n => n.trim())) importsBySource.get(source)!.add(n)
460
- } else if (!otherImports.includes(line)) {
461
- otherImports.push(line)
462
- }
463
- }
464
- }
465
- allCode.push(js.replace(/^import .+\n/gm, '').trim())
466
- }
467
- const mergedClientImports = [...importsBySource].map(([src, names]) =>
468
- `import { ${[...names].sort().join(', ')} } from '${src}'`
469
- )
470
497
  files.push({
471
498
  path: filePath.replace(/\.tsx?$/, '.client.js'),
472
- content: [...mergedClientImports, ...otherImports, '', ...allCode.filter(Boolean)].join('\n'),
499
+ content: mergeCompiledClientJsImports(clientJsOutputs),
473
500
  type: 'clientJs',
474
501
  })
475
502
  }
@@ -557,63 +584,14 @@ function compileMultipleComponents(
557
584
  })
558
585
  }
559
586
 
560
- // Combine client JS if any
587
+ // Combine client JS if any — see `mergeCompiledClientJsImports`'s
588
+ // docstring for why this AST-based merge (not a text/regex line scan)
589
+ // is required here specifically (#2767 follow-up).
561
590
  const clientJsOutputs = allOutputs.map(o => o.clientJs).filter(Boolean) as string[]
562
591
  if (clientJsOutputs.length > 0) {
563
- // Separate imports from code and merge imports by source
564
- const importsBySource = new Map<string, Set<string>>()
565
- const otherImports: string[] = []
566
- const allCode: string[] = []
567
-
568
- for (const js of clientJsOutputs) {
569
- const lines = js.split('\n')
570
- const codeLines: string[] = []
571
-
572
- for (const line of lines) {
573
- if (line.startsWith('import ')) {
574
- // Parse named imports: import { a, b } from 'source'
575
- const match = line.match(/^import \{ ([^}]+) \} from ['"]([^'"]+)['"]$/)
576
- if (match) {
577
- const names = match[1].split(',').map(n => n.trim())
578
- const source = match[2]
579
- if (!importsBySource.has(source)) {
580
- importsBySource.set(source, new Set())
581
- }
582
- const set = importsBySource.get(source)!
583
- for (const name of names) {
584
- set.add(name)
585
- }
586
- } else {
587
- // Other import styles (default, namespace, etc.)
588
- if (!otherImports.includes(line)) {
589
- otherImports.push(line)
590
- }
591
- }
592
- } else {
593
- codeLines.push(line)
594
- }
595
- }
596
-
597
- allCode.push(codeLines.join('\n').trim())
598
- }
599
-
600
- // Generate merged imports
601
- const mergedImports: string[] = []
602
- for (const [source, names] of importsBySource) {
603
- const sortedNames = [...names].sort()
604
- mergedImports.push(`import { ${sortedNames.join(', ')} } from '${source}'`)
605
- }
606
-
607
- const combinedClientJs = [
608
- ...mergedImports,
609
- ...otherImports,
610
- '',
611
- ...allCode.filter(Boolean),
612
- ].join('\n')
613
-
614
592
  files.push({
615
593
  path: filePath.replace(/\.tsx?$/, '.client.js'),
616
- content: combinedClientJs,
594
+ content: mergeCompiledClientJsImports(clientJsOutputs),
617
595
  type: 'clientJs',
618
596
  })
619
597
  }
@@ -735,7 +713,9 @@ export function compileJSX(
735
713
 
736
714
  // Preserve non-runtime user imports whose specifiers are referenced
737
715
  // in the generated body (e.g. an initializer that calls an imported
738
- // helper: `createSignal(defaultValue())`).
716
+ // helper: `createSignal(defaultValue())`). A default- or namespace-
717
+ // imported helper needs its own import syntax, not named braces —
718
+ // see `renderUsedImportLines`'s docstring.
739
719
  const externalImportLines: string[] = []
740
720
  const isUsedAsValue = makeValueUsageTest(body)
741
721
  for (const imp of ctx.imports) {
@@ -745,12 +725,22 @@ export function compileJSX(
745
725
  externalImportLines.push(`import '${imp.source}'`)
746
726
  continue
747
727
  }
748
- const used = imp.specifiers
749
- .filter(s => !s.isDefault && !s.isNamespace && !s.isTypeOnly && isUsedAsValue(s.alias || s.name))
750
- .map(s => s.alias ? `${s.name} as ${s.alias}` : s.name)
751
- if (used.length > 0) {
752
- externalImportLines.push(`import { ${used.join(', ')} } from '${imp.source}'`)
728
+ const usedNamed: string[] = []
729
+ let usedDefault: string | null = null
730
+ let usedNamespace: string | null = null
731
+ for (const s of imp.specifiers) {
732
+ if (s.isTypeOnly) continue
733
+ const localName = s.alias || s.name
734
+ if (!isUsedAsValue(localName)) continue
735
+ if (s.isDefault) {
736
+ usedDefault = localName
737
+ } else if (s.isNamespace) {
738
+ usedNamespace = localName
739
+ } else {
740
+ usedNamed.push(s.alias ? `${s.name} as ${s.alias}` : s.name)
741
+ }
753
742
  }
743
+ externalImportLines.push(...renderUsedImportLines(imp.source, usedDefault, usedNamespace, usedNamed))
754
744
  }
755
745
 
756
746
  const allImports = [runtimeImportLine, ...externalImportLines].filter(Boolean).join('\n')
package/src/index.ts CHANGED
@@ -66,7 +66,7 @@ export type {
66
66
  } from './types.ts'
67
67
 
68
68
  // Analyzer
69
- export { analyzeComponent, listComponentFunctions, listComponentFunctions as listExportedComponents, createProgramForFile, needsTypeBasedDetection, REACTIVE_PRIMITIVES, BROWSER_ONLY_CLIENT_APIS, type AnalyzerContext } from './analyzer.ts'
69
+ export { analyzeComponent, listComponentFunctions, listComponentFunctions as listExportedComponents, scanComponentFile, createProgramForFile, needsTypeBasedDetection, REACTIVE_PRIMITIVES, BROWSER_ONLY_CLIENT_APIS, type AnalyzerContext, type ComponentFileScan } from './analyzer.ts'
70
70
  export { createProgramForCorpus, type SharedProgramOptions } from './shared-program.ts'
71
71
 
72
72
  // JSX to IR transformer
@@ -13,6 +13,7 @@ import { extractFreeIdentifiersFromText } from './csr-substitute.ts'
13
13
  import { walkIR, stopAt } from './walker.ts'
14
14
  import { buildLoopChainExpr } from '../loop-chain.ts'
15
15
  import { identifierPattern } from '../identifier-pattern.ts'
16
+ import { classifyDOMProp } from '@barefootjs/shared'
16
17
 
17
18
  /** Expressions that render nothing (0 DOM nodes) — `&&` / `?:` empty branches. */
18
19
  const EMPTY_RENDER_EXPRS = new Set(['null', 'undefined', 'false', "''", '""', '``'])
@@ -581,11 +582,32 @@ function collectReactiveChildProps(node: IRComponent, ctx: ClientJsContext): voi
581
582
  for (const prop of node.props) {
582
583
  if (prop.name === '...' || prop.name.startsWith('...')) continue
583
584
  if (prop.value.kind === 'jsx-children') continue
584
- const isEventHandler =
585
- prop.name.startsWith('on') &&
586
- prop.name.length > 2 &&
587
- prop.name[2] === prop.name[2].toUpperCase()
588
- if (isEventHandler) continue
585
+ // `classifyDOMProp` is the single source of truth for "how does this prop
586
+ // reach the DOM?" (`packages/shared/src/dom-prop.ts`). `applyRestAttrs`
587
+ // reads it to build its own attribute set — everything whose kind is NOT
588
+ // `ref` / `event` / `skip`. This mirror is the compile-time twin of that
589
+ // loop, so it takes the same three exclusions from the same classifier
590
+ // rather than re-deciding them by hand (#2749: the hand-rolled `on[A-Z]`
591
+ // check covered events but not `ref`, so a `ref` on a child-component
592
+ // call site was mirrored as `setAttribute('ref', String(<fn source>))` —
593
+ // an attribute SSR never emits, failing the SSR-vs-hydrated snapshot and
594
+ // shadowing the real binding `initChild` was already passing correctly).
595
+ //
596
+ // Two small widenings come with reading the shared classifier, both
597
+ // intended. `skip` also covers a `children` prop reaching here as an
598
+ // expression rather than as the `jsx-children` variant handled above —
599
+ // `setAttribute('children', …)` was never a sensible mirror. And the
600
+ // classifier's event test requires an actual A-Z at index 2, where the
601
+ // old `name[2] === name[2].toUpperCase()` also matched non-letters
602
+ // (`on-foo`, `on1x`), which are not DOM events and now mirror normally.
603
+ //
604
+ // Why not also exclude `innerHTML` here: `dangerouslySetInnerHTML` as a
605
+ // reactive CHILD-COMPONENT prop is a separate, unmeasured shape — the
606
+ // element-level path is what the `dangerous-inner-html*` fixtures cover.
607
+ // Narrowing this mirror to the three kinds `applyRestAttrs` excludes keeps
608
+ // the change to the classification `ref` was already missing.
609
+ const domKind = classifyDOMProp(prop.name).kind
610
+ if (domKind === 'ref' || domKind === 'event' || domKind === 'skip') continue
589
611
  // Only `expression` / `template` variants drive reactive prop forwarding.
590
612
  if (prop.value.kind !== 'expression' && prop.value.kind !== 'template') continue
591
613
  const valueExpr = attrValueToString(prop.value)!
@@ -31,7 +31,7 @@
31
31
  * <indent>}) }
32
32
  */
33
33
 
34
- import { keyAttrName, profileBindingId, varSlotId } from '../../utils.ts'
34
+ import { keyAttrName, mapArrayKeyArgs, profileBindingId, varSlotId } from '../../utils.ts'
35
35
  import { emitComponentAndEventSetup } from '../shared.ts'
36
36
  import { emitAttrUpdate } from '../../emit-reactive.ts'
37
37
  import { emitMultiRootTemplateCloneLines, namespaceWrapForTemplate } from './template-parse.ts'
@@ -157,7 +157,11 @@ function emitReactive(lines: string[], inner: InnerLoopPlan, indent: string, pc:
157
157
  bodyIsMultiRoot: emit.bodyIsMultiRoot,
158
158
  })
159
159
  lines.push(`${indent} return __innerEl${uid}`)
160
- lines.push(`${indent}}, '${inner.markerId}'${profileBindingId(pc, inner.slotId)}) }`)
160
+ // #2753 Shape B: the runtime's own fallback stamp (a row whose renderItem
161
+ // didn't already set a key attribute — see `map-array.ts`) has no depth
162
+ // concept, so a nested keyed loop must tell it which name to check/write
163
+ // instead of the default `data-key`.
164
+ lines.push(`${indent}}, '${inner.markerId}'${mapArrayKeyArgs(profileBindingId(pc, inner.slotId), !!emit.wrappedKey, inner.keyDepth)}) }`)
161
165
  }
162
166
 
163
167
  function emitStatic(lines: string[], inner: InnerLoopPlan, indent: string, pc: string | undefined): void {
@@ -11,7 +11,7 @@
11
11
  * every nesting depth.
12
12
  */
13
13
 
14
- import { varSlotId, DATA_BF_PH, keyAttrName, profileBindingId } from '../../utils.ts'
14
+ import { varSlotId, DATA_BF_PH, keyAttrName, mapArrayKeyArgs, profileBindingId } from '../../utils.ts'
15
15
  import { emitComponentAndEventSetup } from '../shared.ts'
16
16
  import { emitAttrUpdate } from '../../emit-reactive.ts'
17
17
  import { namespaceWrapForTemplate } from './template-parse.ts'
@@ -180,7 +180,10 @@ export function stringifyBranchInnerLoops(
180
180
  stringifyLoopChildConditionals(lines, inner.nestedConditionals, `${indent} `, pc)
181
181
  }
182
182
  lines.push(`${indent} return __bel${uid}`)
183
- lines.push(`${indent}}, '${inner.markerId}'${profileBindingId(pc, inner.slotId)}) }`)
183
+ // #2753 Shape B: see the identical comment in `inner-loop.ts`
184
+ // `keyDepth` is always 1 for a branch-arm inner loop, so this only ever
185
+ // widens the trailing args when the loop is also keyed.
186
+ lines.push(`${indent}}, '${inner.markerId}'${mapArrayKeyArgs(profileBindingId(pc, inner.slotId), !!inner.wrappedKey, inner.keyDepth)}) }`)
184
187
  }
185
188
  }
186
189
 
@@ -2,7 +2,7 @@
2
2
  * IR → HTML template string generation and validation.
3
3
  */
4
4
 
5
- import type { AttrValue, FlatMapCallback, IRAttribute, IRNode, IRProp, MapCallbackPreamble } from '../types.ts'
5
+ import type { AttrValue, FlatMapCallback, IRAttribute, IRExpression, IRNode, IRProp, MapCallbackPreamble } from '../types.ts'
6
6
  import { isBooleanAttr } from '../html-constants.ts'
7
7
  import { toHtmlAttrName, attrValueToString, quotePropName, PROPS_PARAM, DATA_BF_PH, keyAttrName, loopStartMarker, loopEndMarker, loopItemMarker, freeIdsFromRefs, setIntersects, wrapExprWithLoopParams } from './utils.ts'
8
8
  import type { LoopParamSpec } from './utils.ts'
@@ -319,6 +319,16 @@ function escapeAttrValueExpr(valExpr: string): string {
319
319
  * bytes. Bare `${...}` interpolations — `{children}` passthrough and
320
320
  * `renderChild(...)` output — are pre-rendered HTML and must NOT be
321
321
  * escaped, so this is applied only at the four text-marker emit sites.
322
+ * The no-`slotId` fallthrough (every `case 'expression'` branch's final
323
+ * `return` in this file) is shared by several unrelated shapes besides
324
+ * `{children}` passthrough — an `escapeLeafTextExpressions`-wrapped
325
+ * preamble leaf, `lowerFormControlValueSsr`'s textarea initial value, an
326
+ * inlined constant, a `''`/`undefined` deferred placeholder — every one of
327
+ * which is either already escaped or a literal, and must reach the
328
+ * template untouched. `bareSpliceExpr` below is that branch's single door
329
+ * — the one place the fallthrough's decision is made — and it is what
330
+ * picks the genuine `{children}` reference back out for `markupOrEmpty`'s
331
+ * nullish guard (#2775); see its own docstring.
322
332
  * Hono escapes text content with the same set as attribute values
323
333
  * (`& " ' < >`), so `escapeText` delegates to the same operation.
324
334
  *
@@ -339,6 +349,73 @@ function escapeTextSlotExpr(innerExpr: string, isMarkup = false): string {
339
349
  return `${isMarkup ? 'escapeTextOrMarkup' : 'escapeText'}(${innerExpr})`
340
350
  }
341
351
 
352
+ /**
353
+ * Recognizes a JSX child-position expression that is exactly a reference to
354
+ * the reserved `children` prop — bare `children` (destructured) or
355
+ * `<receiver>.children` for any single-identifier receiver (`props.children`,
356
+ * a custom props-param name, a loop-scoped alias closing over props, ...).
357
+ * Checked against `node.expr` — the ORIGINAL source text, never a
358
+ * transformed/wrapped form — so it stays accurate regardless of which
359
+ * builder is asking, and regardless of any earlier pass
360
+ * (`escapeLeafTextExpressions`, `lowerFormControlValueSsr`) that may have
361
+ * wrapped an unrelated leaf.
362
+ *
363
+ * Deliberately LOOSER than `isTransparentFragment` (`jsx-to-ir.ts`), which
364
+ * answers the same underlying question one level up. That function runs on
365
+ * the TS AST and compares the expression text against an EXACT set —
366
+ * `children`, `props.children`, and the analyzer-resolved
367
+ * `${ctx.analyzer.propsObjectName}.children`. This layer works on IR and has
368
+ * no analyzer, so the resolved props name is not reachable here; matching any
369
+ * single-identifier receiver is the available approximation, chosen — not an
370
+ * inherited convention.
371
+ *
372
+ * The looseness costs nothing measurable. An unrelated `.children` member —
373
+ * a tree node's own `children` array, say — does not even arrive here: a
374
+ * reactive member expression is given a `slotId` and takes the escaped
375
+ * text-slot branch above, so it never reaches the bare-splice fallthrough
376
+ * this gates. And were one to arrive, the outcome is still benign: the
377
+ * branch never escaped its value either way, a non-nullish value is
378
+ * returned untouched, and a nullish one rendering `''` instead of the
379
+ * literal `"undefined"` is an improvement in its own right.
380
+ *
381
+ * Both the ORIGINAL source text and the RESOLVED expression are tested,
382
+ * because either one alone misses a shape. `node.expr` is the only form
383
+ * that does not vary between the four builders, so it stays the primary
384
+ * test; but it is the pre-substitution text, which for a
385
+ * destructured-and-renamed children (`const { children: kids } = props`)
386
+ * reads `kids` and matches nothing — while the resolved expression the
387
+ * emitter is about to splice already reads `(_p.children)`. Testing both
388
+ * closes that (#2786) without giving up `node.expr`'s stability.
389
+ */
390
+ function isChildrenPassthroughExpr(expr: string): boolean {
391
+ return /^([A-Za-z_$][\w$]*\.)?children$/.test(expr.trim())
392
+ }
393
+
394
+ /**
395
+ * The single door for the bare (no-`slotId`) expression splice — the
396
+ * counterpart to `escapeTextSlotExpr` for the branch that must NOT escape.
397
+ * All four `case 'expression'` builders in this file route through here so
398
+ * this decision exists in exactly one place: four copies that agree today
399
+ * are four that can drift apart tomorrow, and this file is where that has
400
+ * already happened (#2753 -> #2762).
401
+ *
402
+ * Only a genuine `{children}` passthrough gets `markupOrEmpty`'s nullish
403
+ * guard (#2775). Everything else this fallthrough hosts — an
404
+ * `escapeLeafTextExpressions`-wrapped preamble leaf,
405
+ * `lowerFormControlValueSsr`'s textarea initial value, an inlined constant,
406
+ * a `''`/`undefined` deferred placeholder — reaches the template exactly as
407
+ * it arrived, already escaped or a literal. Escaping is never correct here:
408
+ * the value is pre-rendered HTML, per `escapeTextSlotExpr`'s docstring.
409
+ */
410
+ function bareSpliceExpr(node: IRExpression, valueExpr: string): string {
411
+ // Strip the parens the emitter wraps a substituted expression in, so the
412
+ // resolved form is comparable to the bare source text.
413
+ const resolved = valueExpr.trim().replace(/^\(+|\)+$/g, '')
414
+ const isChildren =
415
+ isChildrenPassthroughExpr(node.expr) || isChildrenPassthroughExpr(resolved)
416
+ return !node.joinArrayChild && isChildren ? `markupOrEmpty(${valueExpr})` : valueExpr
417
+ }
418
+
342
419
  /**
343
420
  * `dangerouslySetInnerHTML={{ __html: E }}` makes the element's content its
344
421
  * raw innerHTML — the intentional, React-style escape hatch. Returns the
@@ -757,13 +834,21 @@ export function irToHtmlTemplate(node: IRNode, restSpreadNames?: ReadonlySet<str
757
834
  switch (node.type) {
758
835
  case 'element': {
759
836
  // Merge context shared with `irToComponentTemplate` /
760
- // `generateCsrTemplate`. `irToHtmlTemplate` does not honour
761
- // `clientOnly` (templates here are for conditionals / loops only),
762
- // and its spread rest-name detector uses `v.expr` directly (no
763
- // `templateExpr` fallback — those live on the SSR template path).
837
+ // `generateCsrTemplate`. Its spread rest-name detector uses
838
+ // `v.expr` directly (no `templateExpr` fallback those live on
839
+ // the SSR template path).
840
+ //
841
+ // Why not path-local `clientOnly`: this builder emits the row /
842
+ // branch markup that a freshly built row gets, while a row REUSED
843
+ // by hydration carries the SSR adapter's markup instead. So the
844
+ // two representations must agree, and `clientOnly` ("SSR omits it;
845
+ // the effect owns it") is the same statement on both sides. Baking
846
+ // the attribute in here made a rebuilt row carry an attribute an
847
+ // SSR-reused row never has — visible the moment a row-count change
848
+ // makes reused and rebuilt rows coexist in one list (#2756).
764
849
  const mergeCtx: MergeContext = {
765
850
  isFilteredSpread: (v) => !!restSpreadNames?.has(v.expr),
766
- honorClientOnly: false,
851
+ honorClientOnly: true,
767
852
  }
768
853
  const useMerge = shouldUseSpreadAttrsMerge(node.attrs, mergeCtx)
769
854
  const firstMergeableIdx = useMerge
@@ -780,6 +865,12 @@ export function irToHtmlTemplate(node: IRNode, restSpreadNames?: ReadonlySet<str
780
865
 
781
866
  const attrParts = node.attrs
782
867
  .map((a, idx) => {
868
+ // Deferred to the row's own `createEffect`, which the loop-row
869
+ // reactive-attr collector already registers for every
870
+ // `clientOnly` attr (`collect-elements.ts`). Emitting it here
871
+ // too would be redundant on a rebuilt row and absent on a
872
+ // hydrate-reused one (#2756).
873
+ if (a.clientOnly) return ''
783
874
  if (useMerge && isMergeableAttr(a, mergeCtx)) {
784
875
  // Only the first mergeable attr emits the merge call; the
785
876
  // others are already represented inside the merge object.
@@ -853,11 +944,19 @@ export function irToHtmlTemplate(node: IRNode, restSpreadNames?: ReadonlySet<str
853
944
  // separate rather than collapsed into a shared helper precisely
854
945
  // because their `clientOnly` semantics differ — see that function's
855
946
  // own comment (#2617).
947
+
948
+ // Escape only when the IR says so (`escapeInClientTemplate`) — most
949
+ // `${...}` here is already pre-rendered HTML. Never take
950
+ // `templateExpr` wholesale instead: it rebinds to `_p.xxx`, dropping
951
+ // the `?? {}` prop-defaulting guard in this builder's init scope
952
+ // (`client-js-generation.test.ts`).
953
+ const escapeForClient = (e: string): string =>
954
+ node.escapeInClientTemplate ? `escapeText(${e})` : e
856
955
  if (node.markerless) {
857
- const bare = wrapInterpolation(wrapExpr(node.expr))
956
+ const bare = escapeForClient(wrapInterpolation(wrapExpr(node.expr)))
858
957
  return `\${${bare}}`
859
958
  }
860
- const inner = wrapInterpolation(wrapExpr(node.expr))
959
+ const inner = escapeForClient(wrapInterpolation(wrapExpr(node.expr)))
861
960
  // Stage 3 / D4 — an element-array child ({out}) built by an arbitrary
862
961
  // .map() preamble is an array of HTML strings; join it rather than let
863
962
  // `${[...]}` `String`-comma-collapse it. Only reached on a JS-runtime
@@ -876,7 +975,8 @@ export function irToHtmlTemplate(node: IRNode, restSpreadNames?: ReadonlySet<str
876
975
  const slotted = branchSlotsVar || node.joinArrayChild ? valueExpr : escapeTextSlotExpr(valueExpr)
877
976
  return `<!--bf:${node.slotId}-->\${${slotted}}<!--/-->`
878
977
  }
879
- return `\${${valueExpr}}`
978
+ // Bare-splice fallthrough (no `slotId`, not an array-child join).
979
+ return `\${${bareSpliceExpr(node, valueExpr)}}`
880
980
  }
881
981
 
882
982
  case 'conditional': {
@@ -1381,6 +1481,10 @@ export function irToPlaceholderTemplate(node: IRNode, restSpreadNames?: Readonly
1381
1481
  case 'element': {
1382
1482
  const attrParts = node.attrs
1383
1483
  .map((a) => {
1484
+ // Same deferral as `irToHtmlTemplate` — this builder is the
1485
+ // composite-row twin of it, so a row it builds must carry the
1486
+ // same attributes a hydration-reused row does (#2756).
1487
+ if (a.clientOnly) return ''
1384
1488
  const attrName = a.name === '...'
1385
1489
  ? '...'
1386
1490
  : (a.name === 'key' ? keyAttrName(loopDepth) : toHtmlAttrName(a.name))
@@ -1416,7 +1520,11 @@ export function irToPlaceholderTemplate(node: IRNode, restSpreadNames?: Readonly
1416
1520
  if (node.slotId) {
1417
1521
  return `<!--bf:${node.slotId}-->\${${node.joinArrayChild ? value : escapeTextSlotExpr(wrapped)}}<!--/-->`
1418
1522
  }
1419
- return `\${${value}}`
1523
+ // Bare-splice fallthrough (no `slotId`) — this builder's composite-row
1524
+ // twin of `irToHtmlTemplate`'s `escapeForClient`, same "why not
1525
+ // templateExpr" reasoning (#2765).
1526
+ const spliced = bareSpliceExpr(node, value)
1527
+ return `\${${node.escapeInClientTemplate ? `escapeText(${spliced})` : spliced}}`
1420
1528
  }
1421
1529
 
1422
1530
  case 'conditional': {
@@ -1903,7 +2011,8 @@ function irToComponentTemplateWithOpts(node: IRNode, opts: TemplateOptions): str
1903
2011
  const isMarkup = opts.markupSlotIds?.has(node.slotId) ?? false
1904
2012
  return `<!--bf:${node.slotId}-->\${${node.joinArrayChild ? value : escapeTextSlotExpr(wrapped, isMarkup)}}<!--/-->`
1905
2013
  }
1906
- return `\${${value}}`
2014
+ // Bare-splice fallthrough (no `slotId`).
2015
+ return `\${${bareSpliceExpr(node, value)}}`
1907
2016
  }
1908
2017
 
1909
2018
  case 'conditional': {
@@ -2527,7 +2636,8 @@ function generateCsrTemplateWithOpts(node: IRNode, opts: TemplateOptions): strin
2527
2636
  const isMarkup = opts.markupSlotIds?.has(node.slotId) ?? false
2528
2637
  return `<!--bf:${node.slotId}-->\${${node.joinArrayChild ? value : escapeTextSlotExpr(expr, isMarkup)}}<!--/-->`
2529
2638
  }
2530
- return `\${${value}}`
2639
+ // Bare-splice fallthrough (no `slotId`).
2640
+ return `\${${bareSpliceExpr(node, value)}}`
2531
2641
  }
2532
2642
 
2533
2643
  case 'conditional': {