@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.
- package/dist/analyzer.d.ts.map +1 -1
- package/dist/errors.d.ts +1 -0
- package/dist/errors.d.ts.map +1 -1
- package/dist/index.js +145 -40
- package/dist/ir-to-client-js/collect-elements.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/plan/build-inner-loop.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/plan/build-loop-child-arm.d.ts +8 -0
- package/dist/ir-to-client-js/control-flow/plan/build-loop-child-arm.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/plan/build-reactive-effects.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/plan/inner-loop.d.ts +12 -0
- package/dist/ir-to-client-js/control-flow/plan/inner-loop.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/stringify/inner-loop.d.ts +1 -0
- package/dist/ir-to-client-js/control-flow/stringify/inner-loop.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/stringify/lazy-row.d.ts.map +1 -1
- package/dist/ir-to-client-js/element-refs.d.ts.map +1 -1
- package/dist/ir-to-client-js/emit-reactive.d.ts.map +1 -1
- package/dist/ir-to-client-js/imports.d.ts +2 -2
- package/dist/ir-to-client-js/imports.d.ts.map +1 -1
- package/dist/ir-to-client-js/phases/provider-and-child-inits.d.ts.map +1 -1
- package/dist/ir-to-client-js/types.d.ts +21 -0
- package/dist/ir-to-client-js/types.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/__snapshots__/doc-examples.test.ts.snap +2 -4
- package/src/__tests__/child-components-in-map.test.ts +11 -3
- package/src/__tests__/client-js-generation.test.ts +37 -1
- package/src/__tests__/inline-jsx-callback.test.ts +55 -0
- package/src/__tests__/ir-jsx-props.test.ts +148 -0
- package/src/__tests__/issue-2705-branch-inner-loop-container.test.ts +91 -0
- package/src/__tests__/markup-prop-brand.test.ts +49 -0
- package/src/__tests__/nested-loop-conditional.test.ts +20 -11
- package/src/__tests__/return-through-local-var.test.ts +269 -0
- package/src/analyzer.ts +71 -0
- package/src/errors.ts +17 -1
- package/src/ir-to-client-js/collect-elements.ts +31 -20
- package/src/ir-to-client-js/control-flow/plan/build-inner-loop.ts +19 -0
- package/src/ir-to-client-js/control-flow/plan/build-loop-child-arm.ts +22 -3
- package/src/ir-to-client-js/control-flow/plan/build-reactive-effects.ts +5 -2
- package/src/ir-to-client-js/control-flow/plan/inner-loop.ts +12 -0
- package/src/ir-to-client-js/control-flow/stringify/inner-loop.ts +9 -0
- package/src/ir-to-client-js/control-flow/stringify/lazy-row.ts +7 -1
- package/src/ir-to-client-js/element-refs.ts +8 -0
- package/src/ir-to-client-js/emit-reactive.ts +91 -23
- package/src/ir-to-client-js/imports.ts +5 -0
- package/src/ir-to-client-js/index.ts +4 -0
- package/src/ir-to-client-js/phases/provider-and-child-inits.ts +5 -1
- package/src/ir-to-client-js/types.ts +21 -0
- 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
|
-
|
|
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
|
-
|
|
466
|
-
|
|
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(`
|
|
523
|
+
lines.push(` ${ref}.setAttribute('data-state', ${value} ? 'active' : 'inactive')`)
|
|
472
524
|
lines.push(` if (${value}) {`)
|
|
473
|
-
lines.push(`
|
|
525
|
+
lines.push(` ${ref}.classList.remove('hidden')`)
|
|
474
526
|
lines.push(` } else {`)
|
|
475
|
-
lines.push(`
|
|
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(`
|
|
481
|
-
lines.push(`
|
|
482
|
-
lines.push(`
|
|
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
|
-
//
|
|
486
|
-
//
|
|
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(`
|
|
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(`
|
|
547
|
+
lines.push(` ${ref}.${prop.propName} = !!(${value})`)
|
|
494
548
|
} else {
|
|
495
|
-
lines.push(`
|
|
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
|
-
|
|
526
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
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 />}
|
|
7019
|
-
// controls={(<div/>)}
|
|
7020
|
-
//
|
|
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
|
-
|
|
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)
|