@barefootjs/jsx 0.33.0 → 0.33.1

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.map +1 -1
  2. package/dist/errors.d.ts +1 -0
  3. package/dist/errors.d.ts.map +1 -1
  4. package/dist/index.js +145 -40
  5. package/dist/ir-to-client-js/collect-elements.d.ts.map +1 -1
  6. package/dist/ir-to-client-js/control-flow/plan/build-inner-loop.d.ts.map +1 -1
  7. package/dist/ir-to-client-js/control-flow/plan/build-loop-child-arm.d.ts +8 -0
  8. package/dist/ir-to-client-js/control-flow/plan/build-loop-child-arm.d.ts.map +1 -1
  9. package/dist/ir-to-client-js/control-flow/plan/build-reactive-effects.d.ts.map +1 -1
  10. package/dist/ir-to-client-js/control-flow/plan/inner-loop.d.ts +12 -0
  11. package/dist/ir-to-client-js/control-flow/plan/inner-loop.d.ts.map +1 -1
  12. package/dist/ir-to-client-js/control-flow/stringify/inner-loop.d.ts +1 -0
  13. package/dist/ir-to-client-js/control-flow/stringify/inner-loop.d.ts.map +1 -1
  14. package/dist/ir-to-client-js/control-flow/stringify/lazy-row.d.ts.map +1 -1
  15. package/dist/ir-to-client-js/element-refs.d.ts.map +1 -1
  16. package/dist/ir-to-client-js/emit-reactive.d.ts.map +1 -1
  17. package/dist/ir-to-client-js/imports.d.ts +2 -2
  18. package/dist/ir-to-client-js/imports.d.ts.map +1 -1
  19. package/dist/ir-to-client-js/phases/provider-and-child-inits.d.ts.map +1 -1
  20. package/dist/ir-to-client-js/types.d.ts +21 -0
  21. package/dist/ir-to-client-js/types.d.ts.map +1 -1
  22. package/package.json +2 -2
  23. package/src/__tests__/__snapshots__/doc-examples.test.ts.snap +2 -4
  24. package/src/__tests__/child-components-in-map.test.ts +11 -3
  25. package/src/__tests__/client-js-generation.test.ts +37 -1
  26. package/src/__tests__/inline-jsx-callback.test.ts +55 -0
  27. package/src/__tests__/ir-jsx-props.test.ts +148 -0
  28. package/src/__tests__/issue-2705-branch-inner-loop-container.test.ts +91 -0
  29. package/src/__tests__/markup-prop-brand.test.ts +49 -0
  30. package/src/__tests__/nested-loop-conditional.test.ts +20 -11
  31. package/src/__tests__/return-through-local-var.test.ts +269 -0
  32. package/src/analyzer.ts +71 -0
  33. package/src/errors.ts +17 -1
  34. package/src/ir-to-client-js/collect-elements.ts +31 -20
  35. package/src/ir-to-client-js/control-flow/plan/build-inner-loop.ts +19 -0
  36. package/src/ir-to-client-js/control-flow/plan/build-loop-child-arm.ts +22 -3
  37. package/src/ir-to-client-js/control-flow/plan/build-reactive-effects.ts +5 -2
  38. package/src/ir-to-client-js/control-flow/plan/inner-loop.ts +12 -0
  39. package/src/ir-to-client-js/control-flow/stringify/inner-loop.ts +9 -0
  40. package/src/ir-to-client-js/control-flow/stringify/lazy-row.ts +7 -1
  41. package/src/ir-to-client-js/element-refs.ts +8 -0
  42. package/src/ir-to-client-js/emit-reactive.ts +91 -23
  43. package/src/ir-to-client-js/imports.ts +5 -0
  44. package/src/ir-to-client-js/index.ts +4 -0
  45. package/src/ir-to-client-js/phases/provider-and-child-inits.ts +5 -1
  46. package/src/ir-to-client-js/types.ts +21 -0
  47. package/src/jsx-to-ir.ts +157 -8
@@ -553,7 +553,13 @@ function seedDiffersExpr(target: string, a: LazyRowAttrBinding): string {
553
553
  if (a.attrName === 'dangerouslySetInnerHTML' || html === 'dangerouslySetInnerHTML') return 'true'
554
554
  if (html === 'style') return `${target}.getAttribute('style') !== styleToCss(__x)`
555
555
  if (html === 'class') return `${target}.getAttribute('class') !== (__x != null ? String(__x) : null)`
556
- if (html === 'value') return `${target}.value !== String(__x)`
556
+ // Mirrors `emitValueUpdateStatements`'s runtime `'value' in target` gate
557
+ // (#2716): a target with no native `.value` property never gets one
558
+ // written, so the seed-diff must compare against the ATTRIBUTE it would
559
+ // actually receive there, not the (absent) property.
560
+ if (html === 'value') {
561
+ return `('value' in ${target} ? ${target}.value !== String(__x) : ${target}.getAttribute('value') !== String(__x))`
562
+ }
557
563
  if (isBooleanAttr(html)) return `${target}.${html} !== !!(__x)`
558
564
  if (a.meta.presenceOrUndefined) {
559
565
  // Compare the VALUE the writer would produce, not just presence.
@@ -71,6 +71,14 @@ export function generateElementRefs(ctx: ClientJsContext): string {
71
71
  regularSlots.delete(slotId)
72
72
  }
73
73
 
74
+ // The component's own `comment: true` root slot needs no `$c` ref at
75
+ // all — its consumers (provider-and-child-inits.ts, emit-reactive.ts)
76
+ // reference `__scope` directly instead (#2649, see
77
+ // `ClientJsContext.commentScopeRootSlotId`'s docstring).
78
+ if (ctx.commentScopeRootSlotId) {
79
+ componentSlots.delete(ctx.commentScopeRootSlotId)
80
+ }
81
+
74
82
  if (regularSlots.size === 0 && componentSlots.size === 0) return ''
75
83
 
76
84
  const refLines: string[] = []
@@ -28,6 +28,57 @@ function bindingIdArg(ctx: ClientJsContext, slotId: string | undefined): string
28
28
  return `, ${JSON.stringify(`${ctx.componentName}#binding:${slotId}`)}`
29
29
  }
30
30
 
31
+ /**
32
+ * Generate statements that write a `value` HTML ATTRIBUTE the developer
33
+ * wrote directly on an element (`<div value={x}>`, a loop row's `<li
34
+ * value={x}>`, …) — SSR renders that same attribute, so hydration keeping
35
+ * it in sync is exactly the contract. Gated at runtime to elements that
36
+ * ALREADY expose a native `.value` IDL property (`'value' in target` —
37
+ * form controls, but also e.g. `<li value>`; the same duck-type check
38
+ * `applyRestAttrs` uses, deliberately not a tag-name allowlist), because
39
+ * for those `setAttribute('value', x)` only sets the INITIAL HTML
40
+ * attribute and the live property is required after user interaction; any
41
+ * other element falls back to a plain attribute write, which still matches
42
+ * what SSR rendered there. Writing the live property unconditionally would
43
+ * plant an expando SSR never had — a hydrated/SSR DOM-state divergence and a
44
+ * hazard for anything that duck-types form controls via `'value' in el`
45
+ * (#2716).
46
+ *
47
+ * NOT for the child-component-root `value` MIRROR (`emitReactivePropBindings`
48
+ * / `emitReactiveChildProps` reflecting a named prop onto a child's root
49
+ * element) — that mechanism has no SSR-rendered counterpart at all
50
+ * regardless of prop name, so an attribute fallback there would itself
51
+ * plant a fresh SSR/hydrate divergence; see `emitChildValueMirrorStatements`.
52
+ */
53
+ function emitValueUpdateStatements(target: string, expression: string): string[] {
54
+ return [
55
+ `const __val = String(${expression})`,
56
+ `if ('value' in ${target}) { if (${target}.value !== __val) ${target}.value = __val } else { ${target}.setAttribute('value', __val) }`,
57
+ ]
58
+ }
59
+
60
+ /**
61
+ * `value`-prop write for the CHILD-ROOT MIRROR mechanism
62
+ * (`emitReactivePropBindings` / `emitReactiveChildProps`, both reactively
63
+ * reflect a parent-passed NAMED PROP onto a child component's root DOM
64
+ * element). Unlike a developer-authored `value=` attribute
65
+ * (`emitValueUpdateStatements`), this mirror has NO SSR-rendered
66
+ * counterpart at all — SSR never puts a `value` attribute on a child's
67
+ * root just because the parent passed a `value` prop. So a root WITHOUT a
68
+ * native `.value` property gets NOTHING written — not even an attribute
69
+ * (confirmed against the oracle's structural-HTML comparison, #2716: an
70
+ * attribute fallback here reintroduced a fresh SSR/hydrate divergence one
71
+ * layer down from the IDL-property expando this replaces). A root that
72
+ * already exposes `.value` (`'value' in target`, same duck-type gate as
73
+ * the direct-attribute case) still gets the live controlled-value
74
+ * property.
75
+ */
76
+ function emitChildValueMirrorStatements(target: string, expression: string): string[] {
77
+ return [
78
+ `if ('value' in ${target}) { const __val = String(${expression}); if (${target}.value !== __val) ${target}.value = __val }`,
79
+ ]
80
+ }
81
+
31
82
  /**
32
83
  * Generate JS statements to update a DOM attribute reactively.
33
84
  * Centralizes the attribute-type dispatch (value, class, boolean, presence, generic)
@@ -55,10 +106,7 @@ export function emitAttrUpdate(target: string, attrName: string, expression: str
55
106
  ]
56
107
  }
57
108
  if (htmlName === 'value') {
58
- return [
59
- `const __val = String(${expression})`,
60
- `if (${target}.value !== __val) ${target}.value = __val`,
61
- ]
109
+ return emitValueUpdateStatements(target, expression)
62
110
  }
63
111
  if (isBooleanAttr(htmlName)) {
64
112
  return [`${target}.${htmlName} = !!(${expression})`]
@@ -462,37 +510,43 @@ export function emitReactivePropBindings(lines: string[], ctx: ClientJsContext):
462
510
  }
463
511
 
464
512
  for (const [slotId, props] of propsBySlot) {
465
- const v = varSlotId(slotId)
466
- lines.push(` if (_${v}) {`)
513
+ // The component's own `comment: true` root child IS `__scope` — no
514
+ // `$c` ref was declared for it (element-refs.ts), so reference
515
+ // `__scope` directly instead of a `_sN` var that doesn't exist
516
+ // (#2649, see `ClientJsContext.commentScopeRootSlotId`'s docstring).
517
+ const ref = slotId === ctx.commentScopeRootSlotId ? '__scope' : `_${varSlotId(slotId)}`
518
+ lines.push(` if (${ref}) {`)
467
519
  for (const prop of props) {
468
520
  const value = `${prop.expression}()`
469
521
  if (prop.propName === 'selected') {
470
522
  if (prop.componentName === 'TabsContent') {
471
- lines.push(` _${v}.setAttribute('data-state', ${value} ? 'active' : 'inactive')`)
523
+ lines.push(` ${ref}.setAttribute('data-state', ${value} ? 'active' : 'inactive')`)
472
524
  lines.push(` if (${value}) {`)
473
- lines.push(` _${v}.classList.remove('hidden')`)
525
+ lines.push(` ${ref}.classList.remove('hidden')`)
474
526
  lines.push(` } else {`)
475
- lines.push(` _${v}.classList.add('hidden')`)
527
+ lines.push(` ${ref}.classList.add('hidden')`)
476
528
  lines.push(` }`)
477
529
  } else {
478
530
  // Update data-state and aria-selected attributes.
479
531
  // Visual styling is driven by CSS data-[state=active/inactive]: selectors.
480
- lines.push(` _${v}.setAttribute('aria-selected', String(${value}))`)
481
- lines.push(` _${v}.setAttribute('data-state', ${value} ? 'active' : 'inactive')`)
482
- lines.push(` _${v}.setAttribute('tabindex', ${value} ? '0' : '-1')`)
532
+ lines.push(` ${ref}.setAttribute('aria-selected', String(${value}))`)
533
+ lines.push(` ${ref}.setAttribute('data-state', ${value} ? 'active' : 'inactive')`)
534
+ lines.push(` ${ref}.setAttribute('tabindex', ${value} ? '0' : '-1')`)
483
535
  }
484
- // Use DOM property assignment for value and boolean attrs.
485
- // setAttribute('value', x) only sets the initial HTML attribute; after user
486
- // interaction the DOM property diverges, so .value = x is required.
536
+ // Use DOM property assignment for value and boolean attrs, but only
537
+ // on genuine form controls see `emitChildValueMirrorStatements`'s
538
+ // docstring (#2716). `ref` here is a named prop's MIRROR target
539
+ // element (a child component's arbitrary root), not necessarily a
540
+ // form control, and this mirror has no SSR-rendered counterpart to
541
+ // fall back to.
487
542
  // Boolean attrs (disabled, checked, etc.) treat any attribute presence as
488
543
  // truthy, so setAttribute('disabled', 'false') still disables the element.
489
544
  } else if (prop.propName === 'value') {
490
- lines.push(` const __val = String(${value})`)
491
- lines.push(` if (_${v}.value !== __val) _${v}.value = __val`)
545
+ for (const stmt of emitChildValueMirrorStatements(ref, value)) lines.push(` ${stmt}`)
492
546
  } else if (isBooleanAttr(prop.propName)) {
493
- lines.push(` _${v}.${prop.propName} = !!(${value})`)
547
+ lines.push(` ${ref}.${prop.propName} = !!(${value})`)
494
548
  } else {
495
- lines.push(` _${v}.setAttribute('${prop.propName}', String(${value}))`)
549
+ lines.push(` ${ref}.setAttribute('${prop.propName}', String(${value}))`)
496
550
  }
497
551
  }
498
552
  lines.push(` }`)
@@ -520,13 +574,27 @@ export function emitReactiveChildProps(lines: string[], ctx: ClientJsContext): v
520
574
 
521
575
  for (const [, props] of propsByComponent) {
522
576
  const first = props[0]
577
+ // The component's own `comment: true` root child IS `__scope` — query
578
+ // it directly rather than through `$c`, which cannot tell "I already
579
+ // am this slot" apart from a coincidentally-matching descendant
580
+ // (#2649, see `ClientJsContext.commentScopeRootSlotId`'s docstring).
581
+ const isCommentRoot = first.slotId !== null && first.slotId === ctx.commentScopeRootSlotId
523
582
  const varSuffix = first.slotId ? varSlotId(first.slotId).replace(/-/g, '_') : first.componentName
524
- const varName = `__${first.componentName}_${varSuffix}El`
525
- const selectorArg = first.slotId ? first.slotId : first.componentName
526
- lines.push(` const [${varName}] = $c(__scope, '${selectorArg}')`)
583
+ const varName = isCommentRoot ? '__scope' : `__${first.componentName}_${varSuffix}El`
584
+ if (!isCommentRoot) {
585
+ const selectorArg = first.slotId ? first.slotId : first.componentName
586
+ lines.push(` const [${varName}] = $c(__scope, '${selectorArg}')`)
587
+ }
527
588
  lines.push(` if (${varName}) {`)
528
589
  for (const prop of props) {
529
- for (const stmt of emitAttrUpdate(varName, prop.attrName, prop.expression, prop)) {
590
+ // `value` is the CHILD-ROOT MIRROR case, not a developer-authored
591
+ // attribute — route it through the no-SSR-fallback helper instead
592
+ // of `emitAttrUpdate`'s generic (attribute-fallback) dispatch;
593
+ // see `emitChildValueMirrorStatements`'s docstring (#2716).
594
+ const stmts = toHtmlAttrName(prop.attrName) === 'value'
595
+ ? emitChildValueMirrorStatements(varName, prop.expression)
596
+ : emitAttrUpdate(varName, prop.attrName, prop.expression, prop)
597
+ for (const stmt of stmts) {
530
598
  lines.push(` ${stmt}`)
531
599
  }
532
600
  }
@@ -11,6 +11,11 @@ import { identifierCallPattern } from '../identifier-pattern.ts'
11
11
  export const RUNTIME_IMPORT_CANDIDATES = [
12
12
  'createSignal', 'createMemo', 'createEffect', 'onCleanup', 'onMount',
13
13
  'hydrate', 'insert', 'getLoopChildren', 'getLoopNodes', 'mapArray', 'mapArrayAnchored', 'mapArrayLazy', 'patchLeaf', 'createDisposableEffect',
14
+ // Resolves the real DOM container for a loop nested inside a loop-row
15
+ // conditional's branch when the conditional's wrapper element carries no
16
+ // `bf="<slot>"` marker of its own (#2705) — see `findCondContainer`'s
17
+ // docstring (runtime/insert.ts) for why the marker collector can't see it.
18
+ 'findCondContainer',
14
19
  'createComponent', 'renderChild', 'registerComponent', 'registerTemplate', 'initChild', 'upsertChild',
15
20
  // Connects a template-clone loop row before the body's tail runs, so a child
16
21
  // that inits inside it resolves context against real ancestors rather than
@@ -185,6 +185,10 @@ function createContext(
185
185
  refElements: [],
186
186
  childInits: [],
187
187
  deferredChildSlots: new Set(),
188
+ // Mirrors `emit-registration.ts`'s `isCommentScope` component-root
189
+ // disjunct (not the fragment disjunct — that case is disambiguated at
190
+ // runtime by `commentScopeRegistry`, not by this slot-id shortcut).
191
+ commentScopeRootSlotId: ir.root.type === 'component' ? ir.root.slotId : null,
188
192
  reactiveProps: [],
189
193
  reactiveChildProps: [],
190
194
  reactiveAttrs: [],
@@ -39,7 +39,11 @@ export function emitProviderAndChildInits(lines: string[], ctx: ClientJsContext)
39
39
  lines.push(` upsertChild(__scope, '${registryName}', '${child.slotId}', ${child.propsExpr})`)
40
40
  continue
41
41
  }
42
- const scopeRef = child.slotId ? `_${varSlotId(child.slotId)}` : '__scope'
42
+ // The component's own `comment: true` root child IS `__scope` (no
43
+ // separate DOM node exists for it to be looked up at) — see
44
+ // `ClientJsContext.commentScopeRootSlotId`'s docstring (#2649).
45
+ const isCommentRoot = child.slotId !== null && child.slotId === ctx.commentScopeRootSlotId
46
+ const scopeRef = !child.slotId || isCommentRoot ? '__scope' : `_${varSlotId(child.slotId)}`
43
47
  lines.push(` initChild('${registryName}', ${scopeRef}, ${child.propsExpr})`)
44
48
  }
45
49
  }
@@ -102,6 +102,27 @@ export interface ClientJsContext {
102
102
  * registration-template emit so both agree on which children defer.
103
103
  */
104
104
  deferredChildSlots: Set<string>
105
+ /**
106
+ * Slot id of the component's own root, when the ENTIRE render is a single
107
+ * child component call (`ir.root.type === 'component'` — the IR shape is
108
+ * the sole source of truth; `emit-registration.ts`'s `isCommentScope`
109
+ * derives the def's `comment: true` from the same shape). Such a child
110
+ * never gets its own DOM node: `materializeComponent`/`renderChild` leave
111
+ * `bf-s` unset and the child's markup becomes `__scope` itself (#2649).
112
+ * A generic `$c(__scope, slotId)` lookup for this one slot is therefore
113
+ * not just redundant but actively ambiguous — a genuine grandchild
114
+ * nested inside it can derive a `bf-s` whose suffix collides with this
115
+ * same slot id (see `component.ts`'s `_parentScopeId` push), and
116
+ * `$cSingle` cannot tell "I already am this slot" apart from "a
117
+ * coincidentally-matching descendant is this slot" from the DOM alone.
118
+ * Emission sites that would otherwise query this slot via `$c`
119
+ * (`element-refs.ts`, `provider-and-child-inits.ts`,
120
+ * `emit-reactive.ts`) use `__scope` directly instead, sidestepping the
121
+ * ambiguity entirely rather than trying to make the query precise
122
+ * enough to resolve it. `null` for a component whose root is a regular
123
+ * element/fragment (the overwhelmingly common case).
124
+ */
125
+ commentScopeRootSlotId: string | null
105
126
  reactiveProps: ReactiveComponentProp[]
106
127
  reactiveChildProps: ReactiveChildProp[]
107
128
  reactiveAttrs: ReactiveAttribute[]
package/src/jsx-to-ir.ts CHANGED
@@ -1066,7 +1066,37 @@ function buildIRRoot(analyzer: AnalyzerContext): IRNode | null {
1066
1066
  // scope; the inner IR must not double-mark a nested element as root.
1067
1067
  ctx.isRoot = false
1068
1068
  const ir = transformJsxExpression(jsxReturn, ctx)
1069
- if (ir === null) return null
1069
+ if (ir === null) {
1070
+ // BF027 (#2720): a bare identifier at return position that names a
1071
+ // local `const`/`let` PROVEN to hold JSX (`jsxConstants` — pure JSX
1072
+ // literal; `inlineableJsxConsts` — a JSX-shaped ternary/`&&`/`||`/`??`)
1073
+ // is the "returned JSX through a local variable" shape. Return position
1074
+ // deliberately does not resolve identifiers the way JSX-child position
1075
+ // does (see the #547/#1409 inlining above), so the scalar-leaf fallback
1076
+ // would otherwise drop this component with zero files and zero
1077
+ // diagnostics. Scoped to identifiers already proven JSX-holding by
1078
+ // those two maps so an ordinary non-JSX return (`return 42`, `return
1079
+ // someHelperResult`) — including from a PascalCase-but-not-a-component
1080
+ // export the analyzer's syntactic component detector still matches —
1081
+ // stays silent exactly as before.
1082
+ if (
1083
+ ts.isIdentifier(jsxReturn) &&
1084
+ (analyzer.jsxConstants.has(jsxReturn.text) || analyzer.inlineableJsxConsts.has(jsxReturn.text))
1085
+ ) {
1086
+ analyzer.errors.push(createError(
1087
+ ErrorCodes.RETURN_VALUE_NOT_JSX,
1088
+ getSourceLocation(jsxReturn, analyzer.sourceFile, analyzer.filePath),
1089
+ {
1090
+ message:
1091
+ `Component '${analyzer.componentName ?? '(unknown)'}' return value is not recognized ` +
1092
+ `as JSX — return the JSX expression directly instead of binding it to a local variable ` +
1093
+ `first (\`return ${jsxReturn.text}\` after \`const ${jsxReturn.text} = <jsx/>\` is not ` +
1094
+ `resolved at return position).`,
1095
+ },
1096
+ ))
1097
+ }
1098
+ return null
1099
+ }
1070
1100
  return wrapInScopeElement(ir)
1071
1101
  }
1072
1102
 
@@ -6999,6 +7029,100 @@ function getStringValue(node: ts.Expression): string | null {
6999
7029
  // Component Props Processing
7000
7030
  // =============================================================================
7001
7031
 
7032
+ /**
7033
+ * Strip every legal-in-.tsx TRANSPARENT TS wrapper around an expression —
7034
+ * parens, `as`, `satisfies`, and postfix non-null `!` — repeatedly, so a
7035
+ * stack of them (`(x as any)!`) unwraps in one call. None of these change
7036
+ * the RUNTIME value; they are compile-time-only annotations TypeScript
7037
+ * erases, so a caller checking "is this JSX" (or "does this ternary/array
7038
+ * wrap JSX") must see through all of them to avoid false negatives (#2703
7039
+ * Copilot review on #2667: `header={cond ? (<a/> as any) : (<b/> as any)}`
7040
+ * unwrapped only parens, so the `as`-wrapped shape slipped past the naked-
7041
+ * wrapper refusal and still spliced raw JSX into the client bundle).
7042
+ * Angle-bracket type assertions (`<any>x`) are illegal in `.tsx` — the
7043
+ * `<` is always JSX — so there is no fourth wrapper kind to handle here.
7044
+ */
7045
+ function unwrapTransparentTsWrappers(node: ts.Expression): ts.Expression {
7046
+ let n = node
7047
+ while (
7048
+ ts.isParenthesizedExpression(n) ||
7049
+ ts.isAsExpression(n) ||
7050
+ ts.isSatisfiesExpression(n) ||
7051
+ ts.isNonNullExpression(n)
7052
+ ) {
7053
+ n = n.expression
7054
+ }
7055
+ return n
7056
+ }
7057
+
7058
+ /**
7059
+ * Whether `node` (a `ConditionalExpression` or `ArrayLiteralExpression`
7060
+ * already confirmed by the caller) has JSX syntax somewhere inside it —
7061
+ * a ternary arm that is (or recursively resolves to) a JSX element/
7062
+ * fragment, or an array element that is one. Used by #2667's naked-
7063
+ * wrapper refusal: a ternary/array prop initializer with NO JSX inside
7064
+ * (`disabled={cond ? a : b}`, `items={[a, b]}`) is ordinary and must keep
7065
+ * compiling exactly as before — only the JSX-carrying shape is refused.
7066
+ *
7067
+ * Deliberately narrow, mirroring `expressionContainsJsx`'s sibling scope
7068
+ * in `analyzer.ts`-adjacent code: only descends through transparent TS
7069
+ * wrappers (`unwrapTransparentTsWrappers`), `ConditionalExpression` arms,
7070
+ * and `ArrayLiteralExpression` elements (spread elements unwrapped too)
7071
+ * — the two wrapper shapes the issue names, each itself possibly
7072
+ * TS-wrapped (`(cond ? <a/> : <b/>) as any`, `cond ? (<a/> as any) : <b/>`,
7073
+ * #2703). It does not chase JSX through arbitrary call arguments, object
7074
+ * literals, or logical expressions; those are out of this refusal's
7075
+ * scope and stay on the pre-existing (working, non-JSX) expression path.
7076
+ */
7077
+ function expressionWrapsJsx(node: ts.Expression): boolean {
7078
+ const n = unwrapTransparentTsWrappers(node)
7079
+ if (ts.isJsxElement(n) || ts.isJsxSelfClosingElement(n) || ts.isJsxFragment(n)) return true
7080
+ if (ts.isConditionalExpression(n)) {
7081
+ return expressionWrapsJsx(n.whenTrue) || expressionWrapsJsx(n.whenFalse)
7082
+ }
7083
+ if (ts.isArrayLiteralExpression(n)) {
7084
+ return n.elements.some((el) => expressionWrapsJsx(ts.isSpreadElement(el) ? el.expression : el))
7085
+ }
7086
+ return false
7087
+ }
7088
+
7089
+ /**
7090
+ * Refuse a component prop whose initializer is a ternary/array literally
7091
+ * wrapping JSX (#2667) — see `expressionWrapsJsx`'s docstring and the call
7092
+ * site above for why this can't fall through to the plain `expression`
7093
+ * AttrValue path (raw JSX syntax would splice into the emitted client
7094
+ * JS) and why the fragment-wrap escape is NOT offered here (BF021 message
7095
+ * below explains the unsoundness inline; see #2667's tracking issue for
7096
+ * the full door-inventory finding).
7097
+ */
7098
+ function reportNakedJsxWrapperProp(
7099
+ ctx: TransformContext,
7100
+ attr: ts.JsxAttribute,
7101
+ propName: string,
7102
+ jsxExpr: ts.Expression,
7103
+ ): void {
7104
+ const shape = ts.isConditionalExpression(jsxExpr) ? 'a ternary' : 'an array literal'
7105
+ ctx.analyzer.errors.push(
7106
+ createError(ErrorCodes.UNSUPPORTED_JSX_PATTERN, getSourceLocation(attr, ctx.sourceFile, ctx.filePath), {
7107
+ message:
7108
+ `Prop '${propName}' is ${shape} wrapping JSX (${jsxExpr.getText(ctx.sourceFile)}). ` +
7109
+ `This shape is not compiled — only a JSX element/fragment given DIRECTLY as the prop value is.`,
7110
+ suggestion: {
7111
+ message:
7112
+ `Move the conditional/array out of the prop position: compute it in a local ` +
7113
+ `const and pass it as the component's children instead of a named prop ` +
7114
+ `(e.g. const ${propName} = ${jsxExpr.getText(ctx.sourceFile)}; <Comp>{${propName}}</Comp>). ` +
7115
+ `Wrapping the ternary/array in a fragment at the prop position ` +
7116
+ `(${propName}={<>{${jsxExpr.getText(ctx.sourceFile)}}</>}) is NOT a safe escape here: it compiles, ` +
7117
+ `but the child's own reactive prop getter receives the branch's HTML unbranded and re-escapes it as ` +
7118
+ `text on the child's very next reactive run, corrupting the DOM (a narrower gap #2651's door ` +
7119
+ `inventory left open — tracked separately).`,
7120
+ escape: [{ kind: 'rewrite' }],
7121
+ },
7122
+ }),
7123
+ )
7124
+ }
7125
+
7002
7126
  function processComponentProps(
7003
7127
  attributes: ts.JsxAttributes,
7004
7128
  ctx: TransformContext
@@ -7015,14 +7139,14 @@ function processComponentProps(
7015
7139
 
7016
7140
  const name = attr.name.getText(ctx.sourceFile)
7017
7141
 
7018
- // JSX element/fragment as prop value: controls={<select />} or
7019
- // controls={(<div/>)}. Carried as a `jsx-children` AttrValue variant
7020
- // so adapters render the JSX inline rather than passing a string.
7142
+ // JSX element/fragment as prop value: controls={<select />},
7143
+ // controls={(<div/>)}, or controls={<div/> as any} (#2703 the
7144
+ // entity is still directly JSX; `as`/`satisfies`/`!` are type-only
7145
+ // and erased, so this must classify identically to the bare form).
7146
+ // Carried as a `jsx-children` AttrValue variant so adapters render
7147
+ // the JSX inline rather than passing a string.
7021
7148
  if (attr.initializer && ts.isJsxExpression(attr.initializer) && attr.initializer.expression) {
7022
- let jsxExpr = attr.initializer.expression
7023
- while (ts.isParenthesizedExpression(jsxExpr)) {
7024
- jsxExpr = jsxExpr.expression
7025
- }
7149
+ const jsxExpr = unwrapTransparentTsWrappers(attr.initializer.expression)
7026
7150
  if (ts.isJsxElement(jsxExpr) || ts.isJsxSelfClosingElement(jsxExpr) || ts.isJsxFragment(jsxExpr)) {
7027
7151
  const prevInsideComponentChildren = ctx.insideComponentChildren
7028
7152
  ctx.insideComponentChildren = true
@@ -7037,6 +7161,31 @@ function processComponentProps(
7037
7161
  continue
7038
7162
  }
7039
7163
  }
7164
+
7165
+ // #2667: a ternary or array LITERALLY WRAPPING JSX at this prop
7166
+ // position (`header={cond ? <a/> : <b/>}`, `header={[<a/>, <b/>]}`)
7167
+ // is neither the direct-element shape above nor a plain value
7168
+ // expression — it only classifies as `jsx-children` when the JSX is
7169
+ // hoisted behind a fragment (`header={<>{cond ? <a/> : <b/>}</>}`,
7170
+ // `unwrapHoistedFragment` above). Left undetected, this falls to the
7171
+ // plain `expression` path below, which stringifies the initializer's
7172
+ // SOURCE TEXT — literal JSX syntax spliced into the emitted client
7173
+ // JS (invalid at runtime; the #2651 door inventory's discovery).
7174
+ // Refuse loudly instead of guessing a lowering: the fragment-wrap
7175
+ // escape this diagnostic once considered recommending turns out to
7176
+ // be unsound for the same shape (`isSingleElementJsxChildren`'s
7177
+ // docstring in `ir-to-client-js/collect-elements.ts` — a
7178
+ // conditional-in-fragment reaches `initChild`'s getter UNbranded,
7179
+ // corrupting the child's DOM the moment its own reactive effect
7180
+ // first reads the prop), so only the children-passthrough escape is
7181
+ // offered.
7182
+ if (
7183
+ (ts.isConditionalExpression(jsxExpr) || ts.isArrayLiteralExpression(jsxExpr)) &&
7184
+ expressionWrapsJsx(jsxExpr)
7185
+ ) {
7186
+ reportNakedJsxWrapperProp(ctx, attr, name, jsxExpr)
7187
+ continue
7188
+ }
7040
7189
  }
7041
7190
 
7042
7191
  let value = getAttributeValue(attr, ctx)