@barefootjs/jsx 0.33.1 → 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 (65) 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/expression-parser.d.ts +14 -0
  6. package/dist/expression-parser.d.ts.map +1 -1
  7. package/dist/index.d.ts +2 -2
  8. package/dist/index.d.ts.map +1 -1
  9. package/dist/index.js +817 -457
  10. package/dist/ir-to-client-js/collect-elements.d.ts.map +1 -1
  11. package/dist/ir-to-client-js/control-flow/stringify/loop-child-arm.d.ts.map +1 -1
  12. package/dist/ir-to-client-js/emit-registration.d.ts.map +1 -1
  13. package/dist/ir-to-client-js/html-template.d.ts +7 -7
  14. package/dist/ir-to-client-js/html-template.d.ts.map +1 -1
  15. package/dist/ir-to-client-js/imports.d.ts +60 -2
  16. package/dist/ir-to-client-js/imports.d.ts.map +1 -1
  17. package/dist/ir-to-client-js/index.d.ts.map +1 -1
  18. package/dist/ir-to-client-js/prop-handling.d.ts +30 -0
  19. package/dist/ir-to-client-js/prop-handling.d.ts.map +1 -1
  20. package/dist/ir-to-client-js/reactivity.d.ts +5 -0
  21. package/dist/ir-to-client-js/reactivity.d.ts.map +1 -1
  22. package/dist/ir-to-client-js/rewrite-props-object.d.ts +36 -8
  23. package/dist/ir-to-client-js/rewrite-props-object.d.ts.map +1 -1
  24. package/dist/ir-to-client-js/utils.d.ts +26 -2
  25. package/dist/ir-to-client-js/utils.d.ts.map +1 -1
  26. package/dist/jsx-to-ir.d.ts.map +1 -1
  27. package/dist/props-binding.d.ts +35 -0
  28. package/dist/props-binding.d.ts.map +1 -1
  29. package/dist/types.d.ts +51 -0
  30. package/dist/types.d.ts.map +1 -1
  31. package/package.json +2 -2
  32. package/src/__tests__/__snapshots__/doc-examples.test.ts.snap +165 -98
  33. package/src/__tests__/binding-scope-ratchet.test.ts +5 -1
  34. package/src/__tests__/child-component-ref-not-mirrored.test.ts +90 -0
  35. package/src/__tests__/client-js-generation.test.ts +11 -0
  36. package/src/__tests__/ir-to-client-js/imports.test.ts +107 -0
  37. package/src/__tests__/ir-to-client-js/merge-compiled-client-js-imports.test.ts +138 -0
  38. package/src/__tests__/issue-2723-prop-alias-reactivity.test.ts +124 -0
  39. package/src/__tests__/issue-2754-rest-spread-needs-slot.test.ts +85 -0
  40. package/src/__tests__/issue-2756-loop-row-honors-client-only.test.ts +173 -0
  41. package/src/__tests__/merge-template-imports.test.ts +41 -1
  42. package/src/__tests__/multi-component-shared-default-import.test.ts +55 -0
  43. package/src/__tests__/rewrite-props-object.test.ts +41 -4
  44. package/src/__tests__/root-key-relay.test.ts +170 -0
  45. package/src/__tests__/signal-getter-not-called.test.ts +149 -0
  46. package/src/__tests__/state-only-file-default-import.test.ts +47 -0
  47. package/src/analyzer.ts +36 -0
  48. package/src/compiler.ts +94 -104
  49. package/src/expression-parser.ts +26 -0
  50. package/src/index.ts +2 -2
  51. package/src/ir-to-client-js/collect-elements.ts +45 -30
  52. package/src/ir-to-client-js/control-flow/stringify/inner-loop.ts +6 -2
  53. package/src/ir-to-client-js/control-flow/stringify/loop-child-arm.ts +5 -2
  54. package/src/ir-to-client-js/emit-registration.ts +26 -7
  55. package/src/ir-to-client-js/generate-init.ts +1 -1
  56. package/src/ir-to-client-js/html-template.ts +130 -20
  57. package/src/ir-to-client-js/imports.ts +178 -5
  58. package/src/ir-to-client-js/index.ts +11 -4
  59. package/src/ir-to-client-js/prop-handling.ts +83 -0
  60. package/src/ir-to-client-js/reactivity.ts +59 -0
  61. package/src/ir-to-client-js/rewrite-props-object.ts +50 -10
  62. package/src/ir-to-client-js/utils.ts +30 -2
  63. package/src/jsx-to-ir.ts +523 -49
  64. package/src/props-binding.ts +51 -0
  65. package/src/types.ts +48 -0
@@ -10,6 +10,7 @@ import { PROPS_PARAM } from './utils.ts'
10
10
  import { computeInlinability, toLegacyInlinability } from './compute-inlinability.ts'
11
11
  import { canGenerateStaticTemplate, irToComponentTemplate, generateCsrTemplate, createStringProtector } from './html-template.ts'
12
12
  import { nameForRegistryRef } from './component-scope.ts'
13
+ import { resolveRestSpreadNames } from './prop-handling.ts'
13
14
 
14
15
  /**
15
16
  * Resolve chained references within a constants map.
@@ -191,14 +192,29 @@ export function emitRegistrationAndHydration(
191
192
  const propNamesForStaticCheck = new Set(ctx.propsParams.map((p) => p.name))
192
193
  const { inlinableConstants, unsafeLocalNames } = inlinability ?? buildInlinableConstants(ctx, graph, _ir.root)
193
194
 
194
- // Build rest spread names: these are rest/props spreads handled by applyRestAttrs, not spreadAttrs
195
- const restSpreadNames = new Set<string>()
196
- if (ctx.restPropsName) restSpreadNames.add(ctx.restPropsName)
197
- if (ctx.propsObjectName) restSpreadNames.add(ctx.propsObjectName)
195
+ // Build rest spread names: these are rest/props spreads handled by
196
+ // applyRestAttrs, not spreadAttrs — #2723: includes any `const x__alias
197
+ // = x` hop onto the rest/props binding (see `resolveRestSpreadNames`'s
198
+ // docstring in prop-handling.ts).
199
+ const restSpreadNames = resolveRestSpreadNames(ctx)
198
200
 
199
- const isCommentScope = (_ir.root.type === 'fragment'
200
- && (_ir.root as IRFragment).needsScopeComment)
201
- || _ir.root.type === 'component'
201
+ // Two distinct shapes share the `comment: true` (proxy-scoped) def flag,
202
+ // but need OPPOSITE runtime treatment of the def's own scope id
203
+ // (component.ts's `materializeComponent`, #2722):
204
+ // - `root.type === 'fragment'`: a genuine fragment root. Its rendered
205
+ // markup carries NO scope id of its own (SSR moves it into the
206
+ // wrapping `<!--bf-scope:-->` comment, `wrapWithScopeComment` in
207
+ // hono-adapter.ts) — CSR mount must generate one just the same, or
208
+ // every nested `renderChild()` call loses the parent-prefixed naming
209
+ // `_parentScopeId` provides and falls back to a random per-child id
210
+ // (#1627's fallback), diverging from SSR/hydrate.
211
+ // - `root.type === 'component'`: the render-prop / "root is a single
212
+ // child call" case (#2649). The child's OWN markup already carries
213
+ // ITS OWN real scope id — the wrapping comment marks a scope with no
214
+ // DOM presence of its own, and `materializeComponent` must leave
215
+ // `scopeId` null so it doesn't stamp over (or duplicate) the child's.
216
+ const isFragmentRoot = _ir.root.type === 'fragment' && !!(_ir.root as IRFragment).needsScopeComment
217
+ const isCommentScope = isFragmentRoot || _ir.root.type === 'component'
202
218
 
203
219
  // Build ComponentDef object for hydrate()
204
220
  const defParts: string[] = [`init: init${name}`]
@@ -232,6 +248,9 @@ export function emitRegistrationAndHydration(
232
248
  if (isCommentScope) {
233
249
  defParts.push('comment: true')
234
250
  }
251
+ if (isFragmentRoot) {
252
+ defParts.push('fragmentRoot: true')
253
+ }
235
254
 
236
255
  const registryKey = nameForRegistryRef(name)
237
256
  // When the registry key was file-scoped (`Name__<8hex>`, for a
@@ -97,7 +97,7 @@ export function generateInitFunction(
97
97
  // into the analyzer / IR construction stage and introduce a
98
98
  // `PropRewritten<T>` brand type so missing the rewrite becomes a
99
99
  // compile-time error. ---
100
- let generatedCode = rewritePropsObjectRef(lines.join('\n'), ctx.propsObjectName)
100
+ let generatedCode = rewritePropsObjectRef(lines.join('\n'), ctx.propsObjectName, ctx.restPropsName)
101
101
  generatedCode += '\n' + hydrateLine
102
102
 
103
103
  // Substitute module-level declarations BEFORE import detection: a
@@ -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
@@ -400,7 +477,7 @@ function renderTemplateAttrPart(
400
477
  attr: IRAttribute,
401
478
  attrName: string,
402
479
  wrap: (expr: string) => string,
403
- restSpreadNames?: Set<string>,
480
+ restSpreadNames?: ReadonlySet<string>,
404
481
  ): string {
405
482
  const v = attr.value
406
483
  switch (v.kind) {
@@ -667,7 +744,7 @@ function stripLeafKeyAttr(ir: IRNode): IRNode {
667
744
  */
668
745
  export function renderFlatMapClientBody(
669
746
  cb: Pick<FlatMapCallback, 'segments'>,
670
- restSpreadNames?: Set<string>,
747
+ restSpreadNames?: ReadonlySet<string>,
671
748
  ): string {
672
749
  return renderPreamble(cb, {
673
750
  textVariant: 'client',
@@ -699,7 +776,7 @@ export function flatMapCallbackHasKeyedLeaf(cb: Pick<FlatMapCallback, 'segments'
699
776
  */
700
777
  export function renderFlatMapProjectionClientBody(
701
778
  inner: Extract<IRNode, { type: 'loop' }>,
702
- restSpreadNames?: Set<string>,
779
+ restSpreadNames?: ReadonlySet<string>,
703
780
  ): string {
704
781
  const chained = applyLoopChain(inner)
705
782
  const params = inner.index ? `(${inner.param}, ${inner.index})` : `(${inner.param})`
@@ -747,7 +824,7 @@ function escapeLeafTextExpressions(ir: IRNode): IRNode {
747
824
  // docstring (`ir-to-client-js/utils.ts`) for why this stays outside #2482's
748
825
  // migration (it's the client-JS-emitter twin of the Go adapter's
749
826
  // `loopBindingStack`).
750
- export function irToHtmlTemplate(node: IRNode, restSpreadNames?: Set<string>, loopDepth = 0, loopParams?: ReadonlyArray<string | LoopParamSpec>, branchSlotsVar?: string, inHoistedChildren = false): string {
827
+ export function irToHtmlTemplate(node: IRNode, restSpreadNames?: ReadonlySet<string>, loopDepth = 0, loopParams?: ReadonlyArray<string | LoopParamSpec>, branchSlotsVar?: string, inHoistedChildren = false): string {
751
828
  const recurse = (n: IRNode): string => irToHtmlTemplate(n, restSpreadNames, loopDepth, loopParams, branchSlotsVar, inHoistedChildren)
752
829
  const wrapExpr = (expr: string) => wrapExprWithLoopParams(expr, loopParams)
753
830
  const wrapInterpolation = (expr: string): string => branchSlotsVar
@@ -757,13 +834,21 @@ export function irToHtmlTemplate(node: IRNode, restSpreadNames?: Set<string>, lo
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?: Set<string>, lo
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?: Set<string>, lo
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?: Set<string>, lo
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': {
@@ -1373,7 +1473,7 @@ function walkSkeletonPathChildren(
1373
1473
  * elements (`<div data-bf-ph="sN"></div>`) instead of renderChild() calls.
1374
1474
  * The placeholders are replaced with real createComponent() elements at runtime.
1375
1475
  */
1376
- export function irToPlaceholderTemplate(node: IRNode, restSpreadNames?: Set<string>, loopDepth = 0, loopParams?: ReadonlyArray<string | LoopParamSpec>): string {
1476
+ export function irToPlaceholderTemplate(node: IRNode, restSpreadNames?: ReadonlySet<string>, loopDepth = 0, loopParams?: ReadonlyArray<string | LoopParamSpec>): string {
1377
1477
  const recurse = (n: IRNode): string => irToPlaceholderTemplate(n, restSpreadNames, loopDepth, loopParams)
1378
1478
  const wrapExpr = (expr: string) => wrapExprWithLoopParams(expr, loopParams)
1379
1479
 
@@ -1381,6 +1481,10 @@ export function irToPlaceholderTemplate(node: IRNode, restSpreadNames?: Set<stri
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?: Set<stri
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': {
@@ -1637,7 +1745,7 @@ function isSingleRootElement(html: string): boolean {
1637
1745
  */
1638
1746
  export interface TemplateOptions {
1639
1747
  inlinableConstants?: Map<string, string>
1640
- restSpreadNames?: Set<string>
1748
+ restSpreadNames?: ReadonlySet<string>
1641
1749
  propsObjectName?: string | null
1642
1750
  /**
1643
1751
  * Names that exist only in the init-body scope (or were demoted to unsafe
@@ -1728,7 +1836,7 @@ export interface TemplateOptions {
1728
1836
  export function irToComponentTemplate(
1729
1837
  node: IRNode,
1730
1838
  inlinableConstants?: Map<string, string>,
1731
- restSpreadNames?: Set<string>,
1839
+ restSpreadNames?: ReadonlySet<string>,
1732
1840
  propsObjectName?: string | null,
1733
1841
  markupSlotIds?: ReadonlySet<string>
1734
1842
  ): string {
@@ -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': {
@@ -2133,7 +2242,7 @@ export function generateCsrTemplate(
2133
2242
  node: IRNode,
2134
2243
  inlinableConstants: Map<string, string> | undefined,
2135
2244
  ctx: ClientJsContext,
2136
- restSpreadNames?: Set<string>,
2245
+ restSpreadNames?: ReadonlySet<string>,
2137
2246
  propsObjectName?: string | null,
2138
2247
  unsafeLocalNames?: Set<string>,
2139
2248
  deferredChildSlots?: ReadonlySet<string>,
@@ -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': {
@@ -2,6 +2,7 @@
2
2
  * Import detection and DOM import management.
3
3
  */
4
4
 
5
+ import ts from 'typescript'
5
6
  import type { ComponentIR, IRNode } from '../types.ts'
6
7
  import { isClientBuiltinName } from '../builtins.ts'
7
8
  import { collectValueReferencedNames } from '../value-references.ts'
@@ -29,6 +30,9 @@ export const RUNTIME_IMPORT_CANDIDATES = [
29
30
  // the compiler-built HTML at the producer (renderChild / initChild props);
30
31
  // `escapeTextOrMarkup` unwraps it at the claim-plan-'markup' template slot.
31
32
  'bfMarkup', 'escapeTextOrMarkup',
33
+ // Nullish guard for a bare `{children}` passthrough splice (#2775) — the
34
+ // value is already-stringified markup, never escaped, just nullish-safe.
35
+ 'markupOrEmpty',
32
36
  'qsa', 'qsaItem', 'qsaChildScope', 'qsaChildScopes', 'upsertChildItem', '__slot', '__bfSlot', '__bfText',
33
37
  // Claim-plan interpreter (slot unification A2/A3, spec/slot-unification.md)
34
38
  // — the "one claim mechanism" that replaced `patchSlotRange` and
@@ -148,6 +152,168 @@ export function makeValueUsageTest(generatedCode: string): (localName: string) =
148
152
  }
149
153
  }
150
154
 
155
+ /**
156
+ * Render already-filtered-to-used specifier fragments for one import source
157
+ * into one or two legal import declaration lines. Shared by every call site
158
+ * that re-serializes an `ImportInfo`'s specifiers into client-JS import
159
+ * text — `collectExternalImports` below and the state-only-file client-JS
160
+ * path (`compiler.ts`'s single-component early return for a `.tsx` with no
161
+ * JSX return but exported `@client` module signals) — so the
162
+ * default/namespace handling lives in exactly one place.
163
+ *
164
+ * A default or namespace specifier needs its own import syntax
165
+ * (`import X from '...'` / `import * as X from '...'`), never the
166
+ * named-import braces a plain specifier gets — a plain `import { lock }
167
+ * from '...'` for a DEFAULT-imported `lock` compiles to a real, silently-
168
+ * wrong ESM import (no such named export) that only surfaces once a
169
+ * bundler actually resolves it (#2767 follow-up: a server component's own
170
+ * compiled init previously never reached a real Rollup graph, so this was
171
+ * unreachable until that gap closed).
172
+ *
173
+ * `import Default, { a, b } from '...'` is the only legal single-line
174
+ * pairing — a namespace specifier can't combine with named ones, but
175
+ * multiple import declarations for the same source are legal ESM, so a
176
+ * used namespace specifier always gets its own line.
177
+ */
178
+ export function renderUsedImportLines(
179
+ source: string,
180
+ usedDefault: string | null,
181
+ usedNamespace: string | null,
182
+ usedNamed: string[],
183
+ ): string[] {
184
+ const lines: string[] = []
185
+ const defaultAndNamed = [
186
+ usedDefault,
187
+ usedNamed.length > 0 ? `{ ${usedNamed.join(', ')} }` : null,
188
+ ].filter((part): part is string => part !== null).join(', ')
189
+ if (defaultAndNamed) lines.push(`import ${defaultAndNamed} from '${source}'`)
190
+ if (usedNamespace) lines.push(`import * as ${usedNamespace} from '${source}'`)
191
+ return lines
192
+ }
193
+
194
+ /**
195
+ * Merge multiple sibling components' compiled client-JS blobs (one file
196
+ * with several `export function`s, e.g. `compileMultipleComponents`'s two
197
+ * `.client.js` outputs) into one conflict-free block.
198
+ *
199
+ * Real top-level `ImportDeclaration` statements are found via a
200
+ * `ts.createSourceFile` AST walk — never a text/regex line scan — so a
201
+ * string or template-literal VALUE that merely contains a line starting
202
+ * with `import ` (a docs component embedding a code sample, say) can never
203
+ * be torn out of its literal and hoisted into the imports block. This
204
+ * mirrors `combine-client-js.ts`'s `parseAndMerge`, the established
205
+ * precedent for exactly this shape of parse (see that file's docstring
206
+ * and issue #1702, the regression it exists to prevent) — CLAUDE.md
207
+ * requires it for "compiled client JS" specifically. Reaching a bundler
208
+ * for the FIRST time is precisely what a plain server component newly
209
+ * promoted to a Rollup entry by `needsClientEntry` (#2767) now does, so a
210
+ * line-based scan here carries real risk, not just a style violation.
211
+ *
212
+ * Differs from `parseAndMerge` in two ways required by this call site:
213
+ * (1) default and named specifiers from the same source fold into ONE
214
+ * declaration via `renderUsedImportLines`'s rule, rather than surviving
215
+ * as separate verbatim, exact-string-deduped lines — the fold is what
216
+ * prevents the duplicate-binding `SyntaxError` two sibling components can
217
+ * otherwise produce for a shared default import (#2767 follow-up); (2) an
218
+ * unresolved `@bf-child:` placeholder import is KEPT (deduped by exact
219
+ * text, same as any other side-effect import), never dropped — unlike
220
+ * `parseAndMerge`'s parent-child inlining case, this merge runs inside
221
+ * `compileMultipleComponents`, BEFORE `@barefootjs/vite`'s `resolveId`
222
+ * gets a chance to rewrite the placeholder into a real module reference.
223
+ *
224
+ * Returns the fully assembled `<imports>\n\n<code…>` block ready to use
225
+ * as a `.client.js` file's content.
226
+ */
227
+ export function mergeCompiledClientJsImports(codeBlobs: string[]): string {
228
+ const sourceOrder: string[] = []
229
+ const namedBySource = new Map<string, Set<string>>()
230
+ const defaultBySource = new Map<string, string>()
231
+ const otherImports: string[] = []
232
+ const seenOther = new Set<string>()
233
+ const codeSections: string[] = []
234
+
235
+ const ensureSource = (source: string): Set<string> => {
236
+ if (!namedBySource.has(source)) {
237
+ namedBySource.set(source, new Set())
238
+ sourceOrder.push(source)
239
+ }
240
+ return namedBySource.get(source)!
241
+ }
242
+
243
+ for (const content of codeBlobs) {
244
+ const sourceFile = ts.createSourceFile(
245
+ 'combine.js',
246
+ content,
247
+ ts.ScriptTarget.Latest,
248
+ /*setParentNodes*/ false,
249
+ ts.ScriptKind.JS,
250
+ )
251
+ const importSpans: Array<[number, number]> = []
252
+
253
+ for (const stmt of sourceFile.statements) {
254
+ if (!ts.isImportDeclaration(stmt)) continue
255
+ const start = stmt.getStart(sourceFile)
256
+ const end = stmt.getEnd()
257
+ importSpans.push([start, end])
258
+
259
+ const clause = stmt.importClause
260
+ const bindings = clause?.namedBindings
261
+ const specifier = ts.isStringLiteral(stmt.moduleSpecifier) ? stmt.moduleSpecifier.text : ''
262
+ const isNamespace = !!bindings && ts.isNamespaceImport(bindings)
263
+ const isNamed = !!bindings && ts.isNamedImports(bindings)
264
+
265
+ // A namespace binding (`import * as NS from '…'`, or a combined
266
+ // `import Default, * as NS from '…'`) is never folded — it always
267
+ // falls through to the verbatim-keep branch below, same as
268
+ // `parseAndMerge`'s. Checking `isNamespace` FIRST (not just `!isNamed`)
269
+ // matters for the combined-with-default shape specifically: a naive
270
+ // `clause?.name || isNamed` would route it into the fold branch below
271
+ // on the strength of the default clause alone and silently drop the
272
+ // namespace half, since only `isNamed` is read there. No current
273
+ // producer of `clientJs` output emits that combined shape
274
+ // (`renderUsedImportLines` always splits a used default+namespace
275
+ // pair into two separate lines), but the classification must stay
276
+ // correct independent of that invariant.
277
+ if (!isNamespace && (clause?.name || isNamed)) {
278
+ // Default and/or named specifiers — fold by source.
279
+ const set = ensureSource(specifier)
280
+ if (clause?.name && !defaultBySource.has(specifier)) {
281
+ defaultBySource.set(specifier, clause.name.text)
282
+ }
283
+ if (isNamed) {
284
+ for (const el of (bindings as ts.NamedImports).elements) {
285
+ set.add(el.propertyName ? `${el.propertyName.text} as ${el.name.text}` : el.name.text)
286
+ }
287
+ }
288
+ } else {
289
+ // Namespace or side-effect import (including an unresolved
290
+ // `@bf-child:` placeholder) — kept verbatim, deduped by exact text.
291
+ const stmtText = content.slice(start, end)
292
+ if (!seenOther.has(stmtText)) {
293
+ seenOther.add(stmtText)
294
+ otherImports.push(stmtText)
295
+ }
296
+ }
297
+ }
298
+
299
+ let code = ''
300
+ let cursor = 0
301
+ for (const [start, end] of importSpans) {
302
+ code += content.slice(cursor, start)
303
+ cursor = end
304
+ }
305
+ code += content.slice(cursor)
306
+ code = code.trim()
307
+ if (code) codeSections.push(code)
308
+ }
309
+
310
+ const mergedImports = sourceOrder.flatMap(source =>
311
+ renderUsedImportLines(source, defaultBySource.get(source) ?? null, null, [...namedBySource.get(source)!]),
312
+ )
313
+
314
+ return [...mergedImports, ...otherImports, '', ...codeSections].join('\n')
315
+ }
316
+
151
317
  /**
152
318
  * Collect external (non-DOM, non-component) imports that are used in generated code.
153
319
  * These are third-party libraries like @barefootjs/form, zod, etc. that need to be
@@ -171,23 +337,30 @@ export function collectExternalImports(ir: ComponentIR, generatedCode: string, l
171
337
 
172
338
  // Check which specifiers are actually used in the generated code.
173
339
  // Skip component names — they are rendered via initChild(), not imported directly.
174
- const usedSpecs: string[] = []
340
+ const usedNamed: string[] = []
341
+ let usedDefault: string | null = null
342
+ let usedNamespace: string | null = null
175
343
  for (const spec of imp.specifiers) {
176
344
  // Per-specifier `import { type Foo }` has no value binding — #2432.
177
345
  if (spec.isTypeOnly) continue
178
346
  const localName = spec.alias || spec.name
179
347
  if (componentNames.has(localName)) continue
180
- if (isUsedAsValue(localName)) {
181
- usedSpecs.push(spec.alias ? `${spec.name} as ${spec.alias}` : spec.name)
348
+ if (!isUsedAsValue(localName)) continue
349
+ if (spec.isDefault) {
350
+ usedDefault = localName
351
+ } else if (spec.isNamespace) {
352
+ usedNamespace = localName
353
+ } else {
354
+ usedNamed.push(spec.alias ? `${spec.name} as ${spec.alias}` : spec.name)
182
355
  }
183
356
  }
184
357
 
185
- if (usedSpecs.length > 0) {
358
+ if (usedDefault || usedNamespace || usedNamed.length > 0) {
186
359
  let source = imp.source
187
360
  if (ir.metadata.clientSignalImportSources?.has(source)) {
188
361
  source = source.replace(/\.tsx?$/, '') + '.client.js'
189
362
  }
190
- importLines.push(`import { ${usedSpecs.join(', ')} } from '${source}'`)
363
+ importLines.push(...renderUsedImportLines(source, usedDefault, usedNamespace, usedNamed))
191
364
  }
192
365
  }
193
366
  return importLines
@@ -19,6 +19,7 @@ import { PROPS_PARAM } from './utils.ts'
19
19
  import { buildInlinableConstants, csrInlinableConstantsFromCtx } from './emit-registration.ts'
20
20
  import { buildEnvFromCtx } from './compute-inlinability.ts'
21
21
  import { nameForRegistryRef } from './component-scope.ts'
22
+ import { resolveRestSpreadNames } from './prop-handling.ts'
22
23
  import { IMPORT_PLACEHOLDER, RUNTIME_MODULE, detectUsedImports, collectExternalImports } from './imports.ts'
23
24
  import { isInlinableInTemplate } from '../relocate.ts'
24
25
  import { buildSourceMapFromIR, type SourceMapV3 } from './source-map.ts'
@@ -216,6 +217,11 @@ function needsClientJs(ctx: ClientJsContext): boolean {
216
217
  ctx.conditionalElements.length > 0 ||
217
218
  ctx.loopElements.length > 0 ||
218
219
  ctx.refElements.length > 0 ||
220
+ // An element forwarding the caller's leftover props needs `init` to
221
+ // run `applyRestAttrs` against it — that call is the ONLY thing that
222
+ // applies those attributes on a pure CSR mount, since neither template
223
+ // can carry a bag whose keys are unknown at compile time (#2754).
224
+ ctx.restAttrElements.length > 0 ||
219
225
  ctx.childInits.length > 0 ||
220
226
  ctx.reactiveAttrs.length > 0 ||
221
227
  ctx.clientOnlyElements.length > 0 ||
@@ -255,10 +261,11 @@ function generateTemplateOnlyMount(ir: ComponentIR, ctx: ClientJsContext): strin
255
261
  const graph = buildReferencesGraph(ctx, ir.root)
256
262
  const { inlinableConstants, unsafeLocalNames } = buildInlinableConstants(ctx, graph, ir.root)
257
263
 
258
- // Build rest spread names: these are rest/props spreads handled by applyRestAttrs, not spreadAttrs
259
- const restSpreadNames = new Set<string>()
260
- if (ctx.restPropsName) restSpreadNames.add(ctx.restPropsName)
261
- if (ctx.propsObjectName) restSpreadNames.add(ctx.propsObjectName)
264
+ // Build rest spread names: these are rest/props spreads handled by
265
+ // applyRestAttrs, not spreadAttrs — #2723: includes any `const x__alias
266
+ // = x` hop onto the rest/props binding (see `resolveRestSpreadNames`'s
267
+ // docstring in prop-handling.ts).
268
+ const restSpreadNames = resolveRestSpreadNames(ctx)
262
269
 
263
270
  let templateHtml: string | undefined
264
271