@barefootjs/jsx 0.34.0 → 0.35.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-context.d.ts.map +1 -1
- package/dist/analyzer.d.ts.map +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1072 -903
- package/dist/ir-to-client-js/collect-elements.d.ts +22 -2
- package/dist/ir-to-client-js/collect-elements.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/plan/build-branch-loop.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/plan/build-component-loop.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/plan/build-composite-loop.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/plan/build-inner-loop.d.ts +14 -2
- 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-lazy-row.d.ts +11 -0
- package/dist/ir-to-client-js/control-flow/plan/build-lazy-row.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/plan/build-loop-child-arm.d.ts +6 -1
- 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-loop.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/plan/build-plain-row.d.ts +52 -0
- package/dist/ir-to-client-js/control-flow/plan/build-plain-row.d.ts.map +1 -0
- package/dist/ir-to-client-js/control-flow/plan/build-reactive-effects.d.ts +5 -2
- 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 +24 -44
- package/dist/ir-to-client-js/control-flow/plan/inner-loop.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/plan/lazy-conditional.d.ts +6 -7
- package/dist/ir-to-client-js/control-flow/plan/lazy-conditional.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/plan/lazy-preamble.d.ts +5 -7
- package/dist/ir-to-client-js/control-flow/plan/lazy-preamble.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/plan/lazy-row-eligibility.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/plan/loop-child-arm.d.ts +14 -0
- package/dist/ir-to-client-js/control-flow/plan/loop-child-arm.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/plan/loop.d.ts +10 -6
- package/dist/ir-to-client-js/control-flow/plan/loop.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/shared.d.ts +11 -9
- package/dist/ir-to-client-js/control-flow/shared.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/stringify/composite-loop.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/stringify/inner-loop.d.ts +6 -14
- 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 +1 -3
- package/dist/ir-to-client-js/control-flow/stringify/lazy-row.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/stringify/loop-child-arm.d.ts +19 -3
- package/dist/ir-to-client-js/control-flow/stringify/loop-child-arm.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/stringify/loop.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/stringify/reactive-effects.d.ts +5 -1
- package/dist/ir-to-client-js/control-flow/stringify/reactive-effects.d.ts.map +1 -1
- package/dist/ir-to-client-js/csr-substitute.d.ts +16 -0
- package/dist/ir-to-client-js/csr-substitute.d.ts.map +1 -1
- package/dist/ir-to-client-js/emit-reactive.d.ts +54 -0
- package/dist/ir-to-client-js/emit-reactive.d.ts.map +1 -1
- package/dist/ir-to-client-js/emit-registration.d.ts.map +1 -1
- package/dist/ir-to-client-js/html-template.d.ts +2 -2
- package/dist/ir-to-client-js/html-template.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/index.d.ts.map +1 -1
- package/dist/ir-to-client-js/markup-slots.d.ts +21 -0
- package/dist/ir-to-client-js/markup-slots.d.ts.map +1 -0
- package/dist/ir-to-client-js/reactivity.d.ts +62 -18
- package/dist/ir-to-client-js/reactivity.d.ts.map +1 -1
- package/dist/ir-to-client-js/safe-html.d.ts +158 -0
- package/dist/ir-to-client-js/safe-html.d.ts.map +1 -0
- package/dist/ir-to-client-js/types.d.ts +18 -2
- package/dist/ir-to-client-js/types.d.ts.map +1 -1
- package/dist/ir-to-client-js/utils.d.ts +34 -1
- package/dist/ir-to-client-js/utils.d.ts.map +1 -1
- package/dist/jsx-to-ir.d.ts.map +1 -1
- package/dist/prop-rewrite.d.ts +8 -3
- package/dist/prop-rewrite.d.ts.map +1 -1
- package/dist/props-binding.d.ts +22 -1
- package/dist/props-binding.d.ts.map +1 -1
- package/dist/reactivity-checker.d.ts +18 -0
- package/dist/reactivity-checker.d.ts.map +1 -1
- package/dist/ssr-defaults.d.ts.map +1 -1
- package/dist/types.d.ts +5 -10
- package/dist/types.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/__snapshots__/doc-examples.test.ts.snap +138 -22
- package/src/__tests__/binding-scope-ratchet.test.ts +25 -2
- package/src/__tests__/build-plain-row-consolidation.test.ts +120 -0
- package/src/__tests__/child-prop-fallback-wrap.test.ts +4 -2
- package/src/__tests__/client-template-escape-soundness.test.ts +538 -0
- package/src/__tests__/compiler-stress-1244.test.ts +8 -8
- package/src/__tests__/csr-template-loop-shadowing.test.ts +4 -2
- package/src/__tests__/flatmap-segments.test.ts +1 -1
- package/src/__tests__/issue-2868-cond-arm-tag-collision.test.ts +185 -0
- package/src/__tests__/issue-2869-attr-dedup-guard.test.ts +141 -0
- package/src/__tests__/lazy-conditional.test.ts +52 -12
- package/src/__tests__/lazy-preamble.test.ts +11 -11
- package/src/__tests__/lazy-row-eligibility.test.ts +8 -3
- package/src/__tests__/loop-child-reactive-attr-const-shadow.test.ts +25 -3
- package/src/__tests__/loop-index-reactivity.test.ts +208 -0
- package/src/__tests__/loop-item-conditional-codegen.test.ts +4 -2
- package/src/__tests__/map-arbitrary-body.test.ts +1 -1
- package/src/__tests__/map-body-brand-checker.test.ts +176 -0
- package/src/__tests__/nested-loop-index-param.test.ts +41 -8
- package/src/__tests__/nested-loop-non-outer-reactivity.test.ts +177 -0
- package/src/__tests__/nested-loop-plain.test.ts +39 -25
- package/src/__tests__/nested-loop-reactive-attrs.test.ts +16 -6
- package/src/__tests__/preamble-branch-arm-rerun.test.ts +135 -0
- package/src/__tests__/reactive-attrs-in-map.test.ts +13 -10
- package/src/__tests__/rewrite-destructured-props.test.ts +37 -0
- package/src/__tests__/safe-html-splice-door.test.ts +61 -0
- package/src/__tests__/ssr-defaults.test.ts +59 -0
- package/src/__tests__/style-css-var-reactive.test.ts +4 -1
- package/src/analyzer-context.ts +12 -10
- package/src/analyzer.ts +4 -0
- package/src/index.ts +8 -2
- package/src/ir-to-client-js/collect-elements.ts +126 -32
- package/src/ir-to-client-js/control-flow/plan/build-branch-loop.ts +20 -28
- package/src/ir-to-client-js/control-flow/plan/build-component-loop.ts +9 -7
- package/src/ir-to-client-js/control-flow/plan/build-composite-loop.ts +12 -6
- package/src/ir-to-client-js/control-flow/plan/build-inner-loop.ts +36 -59
- package/src/ir-to-client-js/control-flow/plan/build-lazy-row.ts +25 -4
- package/src/ir-to-client-js/control-flow/plan/build-loop-child-arm.ts +36 -9
- package/src/ir-to-client-js/control-flow/plan/build-loop.ts +35 -35
- package/src/ir-to-client-js/control-flow/plan/build-plain-row.ts +116 -0
- package/src/ir-to-client-js/control-flow/plan/build-reactive-effects.ts +23 -8
- package/src/ir-to-client-js/control-flow/plan/inner-loop.ts +24 -45
- package/src/ir-to-client-js/control-flow/plan/lazy-conditional.ts +11 -10
- package/src/ir-to-client-js/control-flow/plan/lazy-preamble.ts +11 -13
- package/src/ir-to-client-js/control-flow/plan/lazy-row-eligibility.ts +13 -3
- package/src/ir-to-client-js/control-flow/plan/loop-child-arm.ts +14 -0
- package/src/ir-to-client-js/control-flow/plan/loop.ts +10 -6
- package/src/ir-to-client-js/control-flow/shared.ts +40 -17
- package/src/ir-to-client-js/control-flow/stringify/composite-loop.ts +2 -1
- package/src/ir-to-client-js/control-flow/stringify/inner-loop.ts +20 -66
- package/src/ir-to-client-js/control-flow/stringify/insert.ts +4 -2
- package/src/ir-to-client-js/control-flow/stringify/lazy-row.ts +22 -15
- package/src/ir-to-client-js/control-flow/stringify/loop-child-arm.ts +53 -11
- package/src/ir-to-client-js/control-flow/stringify/loop.ts +4 -2
- package/src/ir-to-client-js/control-flow/stringify/reactive-effects.ts +29 -18
- package/src/ir-to-client-js/csr-substitute.ts +29 -0
- package/src/ir-to-client-js/emit-reactive.ts +92 -6
- package/src/ir-to-client-js/emit-registration.ts +2 -4
- package/src/ir-to-client-js/html-template.ts +67 -245
- package/src/ir-to-client-js/imports.ts +3 -0
- package/src/ir-to-client-js/index.ts +4 -4
- package/src/ir-to-client-js/markup-slots.ts +25 -0
- package/src/ir-to-client-js/reactivity.ts +73 -27
- package/src/ir-to-client-js/safe-html.ts +243 -0
- package/src/ir-to-client-js/types.ts +19 -3
- package/src/ir-to-client-js/utils.ts +58 -10
- package/src/jsx-to-ir.ts +136 -24
- package/src/prop-rewrite.ts +8 -4
- package/src/props-binding.ts +37 -1
- package/src/reactivity-checker.ts +60 -0
- package/src/ssr-defaults.ts +45 -0
- package/src/types.ts +5 -10
- package/src/__tests__/text-slot-escaping.test.ts +0 -65
package/src/jsx-to-ir.ts
CHANGED
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
type IRIfStatement,
|
|
19
19
|
type IRProvider,
|
|
20
20
|
type IRAttribute,
|
|
21
|
+
type ExpressionAttr,
|
|
21
22
|
type IREvent,
|
|
22
23
|
type IRProp,
|
|
23
24
|
type AttrValue,
|
|
@@ -44,7 +45,7 @@ import type { IRLoopSort, FunctionInfo, ConstantInfo } from './types.ts'
|
|
|
44
45
|
import { formatParamWithType } from './module-exports.ts'
|
|
45
46
|
import { createError, ErrorCodes, internalInvariant } from './errors.ts'
|
|
46
47
|
import { CLIENT_BUILTIN_SOURCE, isClientBuiltinName, type ClientBuiltinTag } from './builtins.ts'
|
|
47
|
-
import { containsReactiveExpression } from './reactivity-checker.ts'
|
|
48
|
+
import { containsReactiveExpression, collectReactiveBrandLeaves } from './reactivity-checker.ts'
|
|
48
49
|
import {
|
|
49
50
|
rewriteBarePropRefs as rewriteBarePropRefsCore,
|
|
50
51
|
collectAstPropRefs,
|
|
@@ -606,12 +607,14 @@ function collectBranchLocalPropRefsViaSubstitution(
|
|
|
606
607
|
let acc: Set<string> | undefined
|
|
607
608
|
function visit(n: ts.Node, parent?: ts.Node) {
|
|
608
609
|
if (ts.isIdentifier(n) && propDepsMap!.has(n.text)) {
|
|
609
|
-
// Same skip rules as `collectAstPropRefs` — only value
|
|
610
|
-
//
|
|
610
|
+
// Same skip rules as `collectAstPropRefs` — only value positions
|
|
611
|
+
// count, not object keys / property-access names. A shorthand
|
|
612
|
+
// property (`{ page }`) IS a value reference (#2828) — its name
|
|
613
|
+
// is simultaneously the key and the value — so it participates
|
|
614
|
+
// here too, same as `collectAstPropRefs`.
|
|
611
615
|
const isObjectKey = parent && ts.isPropertyAssignment(parent) && parent.name === n
|
|
612
|
-
const isShorthand = parent && ts.isShorthandPropertyAssignment(parent) && parent.name === n
|
|
613
616
|
const isAccessName = parent && ts.isPropertyAccessExpression(parent) && parent.name === n
|
|
614
|
-
if (!isObjectKey && !
|
|
617
|
+
if (!isObjectKey && !isAccessName) {
|
|
615
618
|
const deps = propDepsMap!.get(n.text)
|
|
616
619
|
if (deps && deps.size > 0) {
|
|
617
620
|
if (!acc) acc = new Set()
|
|
@@ -1484,15 +1487,13 @@ function lowerFormControlValueSsr(
|
|
|
1484
1487
|
|
|
1485
1488
|
if (tagName === 'textarea') {
|
|
1486
1489
|
if (children.length > 0) return
|
|
1490
|
+
// A plain text child: SSR engines escape text children natively, and
|
|
1491
|
+
// the client builders escape every child-position value by default
|
|
1492
|
+
// (`ir-to-client-js/safe-html.ts`, #2795) — nothing to mark here.
|
|
1487
1493
|
children.push({
|
|
1488
1494
|
type: 'expression',
|
|
1489
1495
|
expr,
|
|
1490
|
-
|
|
1491
|
-
// engines escape text children natively.
|
|
1492
|
-
templateExpr: `escapeText(${templateExpr ?? expr})`,
|
|
1493
|
-
// Init-scope builders can't just swap in `templateExpr` (its `_p.`
|
|
1494
|
-
// binding differs) — see `escapeInClientTemplate`'s docstring.
|
|
1495
|
-
escapeInClientTemplate: true,
|
|
1496
|
+
templateExpr,
|
|
1496
1497
|
typeInfo: null,
|
|
1497
1498
|
reactive: false,
|
|
1498
1499
|
slotId: null,
|
|
@@ -1502,7 +1503,7 @@ function lowerFormControlValueSsr(
|
|
|
1502
1503
|
return
|
|
1503
1504
|
}
|
|
1504
1505
|
|
|
1505
|
-
const selectedForLiteral = (optValue: string):
|
|
1506
|
+
const selectedForLiteral = (optValue: string): ExpressionAttr =>
|
|
1506
1507
|
AttrValueOf.expression(
|
|
1507
1508
|
`(${expr}) === ${JSON.stringify(optValue)}`,
|
|
1508
1509
|
templateExpr !== undefined
|
|
@@ -1519,35 +1520,129 @@ function lowerFormControlValueSsr(
|
|
|
1519
1520
|
// `classifyLazyBinding` already know how to place into both `applyItem`
|
|
1520
1521
|
// (row changed) and `applyOuter` (controlled value changed) — no new loop
|
|
1521
1522
|
// machinery, just one more per-row reactive attribute (#2466).
|
|
1522
|
-
const selectedForExpr = (optExpr: string, optTemplateExpr: string | undefined):
|
|
1523
|
+
const selectedForExpr = (optExpr: string, optTemplateExpr: string | undefined): ExpressionAttr =>
|
|
1523
1524
|
AttrValueOf.expression(
|
|
1524
1525
|
`(${expr}) === (${optExpr})`,
|
|
1525
1526
|
templateExpr !== undefined || optTemplateExpr !== undefined
|
|
1526
1527
|
? { templateExpr: `(${templateExpr ?? expr}) === (${optTemplateExpr ?? optExpr})` }
|
|
1527
1528
|
: undefined,
|
|
1528
1529
|
)
|
|
1530
|
+
// #2758: a bound value outside the option set makes SSR and hydration
|
|
1531
|
+
// disagree about what "no match" means. SSR (below) never marks any
|
|
1532
|
+
// `<option>` `selected`, so the browser falls back to its own default —
|
|
1533
|
+
// the FIRST option. Hydration's controlled-value effect then assigns
|
|
1534
|
+
// `select.value` directly (`emitValueUpdateStatements` in
|
|
1535
|
+
// `ir-to-client-js/emit-reactive.ts`), and a value matching no option
|
|
1536
|
+
// yields `selectedIndex = -1` (nothing selected). So the page renders
|
|
1537
|
+
// showing the first option, and the moment hydration runs the selection
|
|
1538
|
+
// visibly disappears.
|
|
1539
|
+
//
|
|
1540
|
+
// Resolution: treat the client's reading as correct ("no match" means no
|
|
1541
|
+
// selection) and make SSR able to express it. When every `<option>` here
|
|
1542
|
+
// is statically enumerable (no `.map()` loop, no conditional/component/
|
|
1543
|
+
// slot child, no author-supplied `selected`), collect each option's match
|
|
1544
|
+
// condition and, for a single-selection `<select>` (not `multiple`, not
|
|
1545
|
+
// `size` > 1 — a list-box already shows no implicit default, so SSR and
|
|
1546
|
+
// hydration already agree there), inject a hidden `disabled` placeholder
|
|
1547
|
+
// option whose own `selected` is the negation of every real option's
|
|
1548
|
+
// condition ORed together. It is only ever actually selected when nothing
|
|
1549
|
+
// else matched, so it changes nothing for the common case where the value
|
|
1550
|
+
// does match an option.
|
|
1551
|
+
//
|
|
1552
|
+
// Bails (leaves prior behaviour) whenever the option set isn't fully
|
|
1553
|
+
// statically known — a `.map()`-rendered list has an unknown number of
|
|
1554
|
+
// rows at compile time, so there is no finite OR to negate.
|
|
1555
|
+
const matchConditions: ExpressionAttr[] = []
|
|
1556
|
+
let optionSetIsDynamic = false
|
|
1557
|
+
|
|
1529
1558
|
const distribute = (nodes: IRNode[]): void => {
|
|
1530
1559
|
for (const n of nodes) {
|
|
1560
|
+
if (n.type === 'text') continue
|
|
1531
1561
|
if (n.type === 'element' && n.tag === 'option') {
|
|
1532
|
-
if (n.attrs.some(a => a.name === 'selected'))
|
|
1562
|
+
if (n.attrs.some(a => a.name === 'selected')) {
|
|
1563
|
+
optionSetIsDynamic = true
|
|
1564
|
+
continue
|
|
1565
|
+
}
|
|
1533
1566
|
const optValue = n.attrs.find(a => a.name === 'value')
|
|
1534
|
-
if (!optValue)
|
|
1567
|
+
if (!optValue) {
|
|
1568
|
+
// No `value` attr: the browser falls back to the option's text
|
|
1569
|
+
// content as its implicit value, which this pass can't compare
|
|
1570
|
+
// against without re-deriving that text — bail rather than
|
|
1571
|
+
// silently dropping this option out of the "no match" OR.
|
|
1572
|
+
optionSetIsDynamic = true
|
|
1573
|
+
continue
|
|
1574
|
+
}
|
|
1535
1575
|
if (optValue.value.kind === 'literal') {
|
|
1536
|
-
|
|
1576
|
+
const selected = selectedForLiteral(optValue.value.value)
|
|
1577
|
+
n.attrs.push({ name: 'selected', value: selected, loc: n.loc })
|
|
1578
|
+
matchConditions.push(selected)
|
|
1537
1579
|
} else if (optValue.value.kind === 'expression') {
|
|
1538
1580
|
const selected = selectedForExpr(optValue.value.expr, optValue.value.templateExpr)
|
|
1539
1581
|
n.attrs.push({ name: 'selected', value: selected, loc: n.loc })
|
|
1582
|
+
matchConditions.push(selected)
|
|
1583
|
+
} else {
|
|
1584
|
+
// A `value` shape neither builder above can compare (e.g. a
|
|
1585
|
+
// structured `template` part) — same bail, not a silent drop.
|
|
1586
|
+
optionSetIsDynamic = true
|
|
1540
1587
|
}
|
|
1541
|
-
} else if (
|
|
1542
|
-
n.
|
|
1543
|
-
|
|
1544
|
-
(
|
|
1545
|
-
|
|
1588
|
+
} else if (n.type === 'fragment' || (n.type === 'element' && n.tag === 'optgroup')) {
|
|
1589
|
+
distribute(n.children)
|
|
1590
|
+
} else if (n.type === 'loop') {
|
|
1591
|
+
// A `.map()`-rendered option list still needs its per-row `selected`
|
|
1592
|
+
// distributed (#2466, preserved below) — but its row count is
|
|
1593
|
+
// unknown at compile time, so it can't feed the "no match" OR.
|
|
1594
|
+
optionSetIsDynamic = true
|
|
1546
1595
|
distribute(n.children)
|
|
1596
|
+
} else {
|
|
1597
|
+
// Conditionals/if-statements, components, slots — an option here
|
|
1598
|
+
// may or may not exist at runtime, so the option set can't be
|
|
1599
|
+
// exhaustively enumerated at compile time.
|
|
1600
|
+
optionSetIsDynamic = true
|
|
1547
1601
|
}
|
|
1548
1602
|
}
|
|
1549
1603
|
}
|
|
1550
1604
|
distribute(children)
|
|
1605
|
+
|
|
1606
|
+
if (optionSetIsDynamic || matchConditions.length === 0) return
|
|
1607
|
+
if (isMultiSelection(attrs)) return
|
|
1608
|
+
|
|
1609
|
+
const orExpr = matchConditions.map(c => `(${c.expr})`).join(' || ')
|
|
1610
|
+
const orTemplateExpr = matchConditions.some(c => c.templateExpr !== undefined)
|
|
1611
|
+
? matchConditions.map(c => `(${c.templateExpr ?? c.expr})`).join(' || ')
|
|
1612
|
+
: undefined
|
|
1613
|
+
|
|
1614
|
+
children.unshift({
|
|
1615
|
+
type: 'element',
|
|
1616
|
+
tag: 'option',
|
|
1617
|
+
attrs: [
|
|
1618
|
+
{ name: 'value', value: AttrValueOf.literal(''), loc: valueAttr.loc },
|
|
1619
|
+
{ name: 'disabled', value: AttrValueOf.booleanAttr(), loc: valueAttr.loc },
|
|
1620
|
+
{ name: 'hidden', value: AttrValueOf.booleanAttr(), loc: valueAttr.loc },
|
|
1621
|
+
{
|
|
1622
|
+
name: 'selected',
|
|
1623
|
+
value: AttrValueOf.expression(
|
|
1624
|
+
`!(${orExpr})`,
|
|
1625
|
+
orTemplateExpr !== undefined ? { templateExpr: `!(${orTemplateExpr})` } : undefined,
|
|
1626
|
+
),
|
|
1627
|
+
loc: valueAttr.loc,
|
|
1628
|
+
},
|
|
1629
|
+
],
|
|
1630
|
+
events: [],
|
|
1631
|
+
ref: null,
|
|
1632
|
+
children: [],
|
|
1633
|
+
slotId: null,
|
|
1634
|
+
needsScope: false,
|
|
1635
|
+
loc: valueAttr.loc,
|
|
1636
|
+
})
|
|
1637
|
+
}
|
|
1638
|
+
|
|
1639
|
+
/** `<select multiple>` or `size` > 1: a list box has no implicit default
|
|
1640
|
+
* selection, so SSR (no option `selected`) and hydration (`selectedIndex`
|
|
1641
|
+
* left at whatever it was) already agree without a placeholder option. */
|
|
1642
|
+
function isMultiSelection(attrs: IRAttribute[]): boolean {
|
|
1643
|
+
if (attrs.some(a => a.name === 'multiple')) return true
|
|
1644
|
+
const sizeAttr = attrs.find(a => a.name === 'size')
|
|
1645
|
+
return sizeAttr?.value.kind === 'literal' && Number(sizeAttr.value.value) > 1
|
|
1551
1646
|
}
|
|
1552
1647
|
|
|
1553
1648
|
function transformHtmlElement(
|
|
@@ -2334,7 +2429,13 @@ function transformExpressionInner(
|
|
|
2334
2429
|
// A reactive brand-package condition (e.g. `form.field('x').error() &&
|
|
2335
2430
|
// …`) can't be SSR-evaluated, so defer the whole conditional rather
|
|
2336
2431
|
// than raising BF061 — same routing as a manual `/* @client */` (#1638).
|
|
2337
|
-
|
|
2432
|
+
// Check `ir.type` first: `shouldAutoDeferReactiveBrand` only matters for
|
|
2433
|
+
// IRConditional, but walks the checker over `expr` — for every other
|
|
2434
|
+
// shape (in particular IRLoop, whose `expr` is a `.map()` callback body
|
|
2435
|
+
// full of JSX) that walk is both wasted work and, historically, exactly
|
|
2436
|
+
// what tripped the getJS trust-boundary assertion on a JSX-bearing node
|
|
2437
|
+
// (#2867/#2830).
|
|
2438
|
+
if (ir.type === 'conditional' && (isClientOnly || shouldAutoDeferReactiveBrand(expr, ctx))) {
|
|
2338
2439
|
ir.clientOnly = true
|
|
2339
2440
|
if (!ir.slotId) {
|
|
2340
2441
|
ir.slotId = generateSlotId(ctx)
|
|
@@ -8115,10 +8216,21 @@ function isReactiveExpression(expr: string, ctx: TransformContext, astNode?: ts.
|
|
|
8115
8216
|
function shouldAutoDeferReactiveBrand(expr: ts.Expression, ctx: TransformContext): boolean {
|
|
8116
8217
|
const checker = ctx.analyzer.checker
|
|
8117
8218
|
if (!checker) return false
|
|
8118
|
-
|
|
8219
|
+
// Read only the brand-carrying leaf(s), never `expr` itself: `expr` can be
|
|
8220
|
+
// a `.map()` callback or a ternary/`&&` with JSX arms, and getJS() refuses
|
|
8221
|
+
// to stringify a JSX-bearing node (the write-side trust boundary — JSX
|
|
8222
|
+
// must travel as structured segments, never raw text). Each leaf is an
|
|
8223
|
+
// identifier / property access / call callee, none of which can contain
|
|
8224
|
+
// JSX, so getJS() is safe on them regardless of what the rest of `expr`
|
|
8225
|
+
// contains (#2867/#2830 — this used to call getJS(expr) directly and
|
|
8226
|
+
// throw whenever the checker was active and expr had JSX in it).
|
|
8227
|
+
const leaves = collectReactiveBrandLeaves(expr, checker)
|
|
8228
|
+
if (leaves.length === 0) return false
|
|
8119
8229
|
// Native signals/memos (incl. chained-const aliases) are SSR-derivable —
|
|
8120
8230
|
// leave them to the normal template path so their initial value renders.
|
|
8121
|
-
|
|
8231
|
+
// Preserves the original "any native ref anywhere defers to non-deferred"
|
|
8232
|
+
// semantics by checking every reactive-brand leaf, not just the first.
|
|
8233
|
+
if (leaves.some((leaf) => isSignalOrMemoReference(ctx.getJS(leaf), ctx))) return false
|
|
8122
8234
|
return true
|
|
8123
8235
|
}
|
|
8124
8236
|
|
package/src/prop-rewrite.ts
CHANGED
|
@@ -105,9 +105,14 @@ function isNonValuePosition(n: ts.Identifier, parent: ts.Node | undefined): bool
|
|
|
105
105
|
/**
|
|
106
106
|
* Walk an AST node for destructured-prop value references and add
|
|
107
107
|
* each found name to `out`. Same skip rules as the rewrite path —
|
|
108
|
-
* object-literal keys,
|
|
109
|
-
*
|
|
110
|
-
*
|
|
108
|
+
* object-literal keys, property-access names, and names shadowed by a
|
|
109
|
+
* binding inside `node` are excluded so only true value references get
|
|
110
|
+
* picked up. A shorthand property (`{ page }`) IS a value reference —
|
|
111
|
+
* its name is simultaneously the key and the value, and
|
|
112
|
+
* `applyScopedPropRefRewrite` below already expands it correctly
|
|
113
|
+
* (`{ page }` → `{ page: _p.page }`) — so it must be discovered here
|
|
114
|
+
* too, or the rewrite is never even attempted for text whose only prop
|
|
115
|
+
* reference is a shorthand one (#2828). Exported for callers that need
|
|
111
116
|
* the raw discovery set (e.g. the branch-local prop-dep cache from
|
|
112
117
|
* #1425).
|
|
113
118
|
*/
|
|
@@ -118,7 +123,6 @@ export function collectAstPropRefs(
|
|
|
118
123
|
): void {
|
|
119
124
|
walkWithScope(node, (n, parent, shadowed) => {
|
|
120
125
|
if (shadowed || !propNames.has(n.text)) return
|
|
121
|
-
if (parent && ts.isShorthandPropertyAssignment(parent) && parent.name === n) return
|
|
122
126
|
if (isNonValuePosition(n, parent)) return
|
|
123
127
|
out.add(n.text)
|
|
124
128
|
})
|
package/src/props-binding.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import ts from 'typescript'
|
|
2
|
-
import type { ParamInfo } from './types.ts'
|
|
2
|
+
import type { ConstantInfo, ParamInfo } from './types.ts'
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* Authoritative IdentifierName classification for a destructure-pattern
|
|
@@ -69,6 +69,42 @@ export function buildPropAliasMap(params: readonly ParamInfo[]): Map<string, str
|
|
|
69
69
|
return map
|
|
70
70
|
}
|
|
71
71
|
|
|
72
|
+
/**
|
|
73
|
+
* Local-name → caller-facing-key map for a BARE-PROPS-form component
|
|
74
|
+
* (`function Foo(props: Props)`) whose BODY destructures a prop under a
|
|
75
|
+
* different local name (`const { children: kids } = props`) — the
|
|
76
|
+
* body-level twin of `buildPropAliasMap` above, which only sees
|
|
77
|
+
* PARAMETER-destructuring aliases (`{ n: count }`, tracked via
|
|
78
|
+
* `ParamInfo.sourceName`). For the bare-props form, `propsParams` comes
|
|
79
|
+
* from the TYPE annotation (`extractPropsFromTypeMembers`) and has no
|
|
80
|
+
* notion of a body-level rename at all — but the analyzer already
|
|
81
|
+
* resolves such a destructuring statement into an ordinary
|
|
82
|
+
* `localConstants` entry whose `parsed` is a plain `props.<key>` member
|
|
83
|
+
* read (`const { children: kids } = props` → a `kids` local const valued
|
|
84
|
+
* `props.children`). Recognizing that shape here is enough to answer
|
|
85
|
+
* "what caller-facing prop key does this local alias" without a second,
|
|
86
|
+
* dedicated AST walk of the destructuring pattern itself (#2788).
|
|
87
|
+
*
|
|
88
|
+
* Returns an empty map for a parameter-destructuring component
|
|
89
|
+
* (`propsObjectName === null`) — that shape's aliasing is already fully
|
|
90
|
+
* covered by `buildPropAliasMap`.
|
|
91
|
+
*/
|
|
92
|
+
export function resolveBodyDestructuredPropAliases(
|
|
93
|
+
localConstants: readonly ConstantInfo[],
|
|
94
|
+
propsObjectName: string | null,
|
|
95
|
+
): Map<string, string> {
|
|
96
|
+
const aliases = new Map<string, string>()
|
|
97
|
+
if (propsObjectName === null) return aliases
|
|
98
|
+
for (const c of localConstants) {
|
|
99
|
+
if (c.isModule) continue
|
|
100
|
+
const m = c.parsed
|
|
101
|
+
if (m?.kind === 'member' && !m.computed && m.object.kind === 'identifier' && m.object.name === propsObjectName) {
|
|
102
|
+
aliases.set(c.name, m.property)
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return aliases
|
|
106
|
+
}
|
|
107
|
+
|
|
72
108
|
/**
|
|
73
109
|
* The props-parameter shape needed to answer "is `name` an actual local
|
|
74
110
|
* binding introduced by the props parameter" — as opposed to
|
|
@@ -191,3 +191,63 @@ export function containsReactiveExpression(node: ts.Node, checker: ts.TypeChecke
|
|
|
191
191
|
incrementCounter('reactivityChecks')
|
|
192
192
|
return brandTypeReactivityAnalyzer.analyze(node, checker).isReactive
|
|
193
193
|
}
|
|
194
|
+
|
|
195
|
+
/** Subtree JSX check — also used by analyzer-context.ts's getJS() guard. */
|
|
196
|
+
export function nodeContainsJsx(node: ts.Node): boolean {
|
|
197
|
+
if (ts.isJsxElement(node) || ts.isJsxSelfClosingElement(node) || ts.isJsxFragment(node)) return true
|
|
198
|
+
return ts.forEachChild(node, nodeContainsJsx) ?? false
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Every node that itself carries the `Reactive<T>` brand within `node`,
|
|
203
|
+
* without stopping at the first match (unlike `analyze`, which only needs
|
|
204
|
+
* one hit to answer "is this reactive"). A caller that needs to read the
|
|
205
|
+
* *text* of the reactive part(s) — e.g. to tell a brand-package wrapper
|
|
206
|
+
* apart from a plain native signal/memo call (#2867) — must not stringify
|
|
207
|
+
* `node` itself: for a `.map()` callback or a ternary with JSX arms, `node`
|
|
208
|
+
* is JSX-bearing, and the write-side trust boundary (`getJS()`,
|
|
209
|
+
* `analyzer-context.ts`) rejects producing text for a JSX-bearing node.
|
|
210
|
+
* Each returned node is JSX-free: property access and identifier leaves
|
|
211
|
+
* cannot contain JSX by construction; a call leaf is the whole call
|
|
212
|
+
* (preserving the `name(...)` shape native-signal/memo regex patterns
|
|
213
|
+
* match against) unless its arguments happen to contain JSX, in which case
|
|
214
|
+
* only the callee is returned instead.
|
|
215
|
+
*/
|
|
216
|
+
export function collectReactiveBrandLeaves(node: ts.Node, checker: ts.TypeChecker): ts.Node[] {
|
|
217
|
+
const leaves: ts.Node[] = []
|
|
218
|
+
const visit = (n: ts.Node): void => {
|
|
219
|
+
if (ts.isPropertyAccessExpression(n)) {
|
|
220
|
+
try {
|
|
221
|
+
if (isReactiveType(queryType(checker, n))) {
|
|
222
|
+
leaves.push(n)
|
|
223
|
+
return
|
|
224
|
+
}
|
|
225
|
+
} catch {
|
|
226
|
+
// fall through to walking the object part
|
|
227
|
+
}
|
|
228
|
+
visit(n.expression)
|
|
229
|
+
return
|
|
230
|
+
}
|
|
231
|
+
if (ts.isIdentifier(n)) {
|
|
232
|
+
try {
|
|
233
|
+
if (isReactiveType(queryType(checker, n))) leaves.push(n)
|
|
234
|
+
} catch {
|
|
235
|
+
// not reactive
|
|
236
|
+
}
|
|
237
|
+
return
|
|
238
|
+
}
|
|
239
|
+
if (ts.isCallExpression(n)) {
|
|
240
|
+
try {
|
|
241
|
+
if (isReactiveType(queryType(checker, n.expression))) {
|
|
242
|
+
leaves.push(nodeContainsJsx(n) ? n.expression : n)
|
|
243
|
+
return
|
|
244
|
+
}
|
|
245
|
+
} catch {
|
|
246
|
+
// fall through to walking children
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
ts.forEachChild(n, visit)
|
|
250
|
+
}
|
|
251
|
+
visit(node)
|
|
252
|
+
return leaves
|
|
253
|
+
}
|
package/src/ssr-defaults.ts
CHANGED
|
@@ -31,6 +31,8 @@
|
|
|
31
31
|
|
|
32
32
|
import ts from 'typescript'
|
|
33
33
|
import { extractFreeIdentifiersFromNode } from './analyzer.ts'
|
|
34
|
+
import { resolveGetterAliases, collectAliasableGetterNames } from './ir-to-client-js/csr-substitute.ts'
|
|
35
|
+
import { resolveBodyDestructuredPropAliases } from './props-binding.ts'
|
|
34
36
|
import type { IRMetadata } from './types.ts'
|
|
35
37
|
|
|
36
38
|
/**
|
|
@@ -311,6 +313,49 @@ export function extractSsrDefaults(metadata: IRMetadata): Record<string, SsrDefa
|
|
|
311
313
|
bindings[memo.name] = value
|
|
312
314
|
}
|
|
313
315
|
|
|
316
|
+
// #2813: a bare local-const alias of a signal/memo getter
|
|
317
|
+
// (`const items__alias = items`) is a DIFFERENT identifier from the
|
|
318
|
+
// getter it aliases, but a template-stash adapter's `.map()` loop over
|
|
319
|
+
// `items__alias()` looks up the stash entry BY THAT NAME — which the
|
|
320
|
+
// loops above never create, since they only seed the getter's own
|
|
321
|
+
// name. Reuses `resolveGetterAliases` (the same alias-hop walker
|
|
322
|
+
// #2778's CSR-template fix introduced) rather than a third alias-hop
|
|
323
|
+
// walker: every alias of a getter seeded above gets the SAME entry
|
|
324
|
+
// under its own name, so the stash has both `items` and `items__alias`
|
|
325
|
+
// pointing at one value. Env-signal getters are excluded from the
|
|
326
|
+
// getter-name set (they're never given an `out` entry above — an
|
|
327
|
+
// adapter's own env-signal binding seeds them instead).
|
|
328
|
+
{
|
|
329
|
+
const getterNames = collectAliasableGetterNames(metadata.signals, metadata.memos)
|
|
330
|
+
for (const [alias, origin] of resolveGetterAliases(metadata.localConstants ?? [], (n) => getterNames.has(n))) {
|
|
331
|
+
if (alias in out) continue
|
|
332
|
+
out[alias] = out[origin]
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// #2788: a bare-props-form component (`function Foo(props: Props)`)
|
|
337
|
+
// whose BODY destructures a prop under a different local name
|
|
338
|
+
// (`const { children: kids } = props`) has no `ParamInfo.sourceName` to
|
|
339
|
+
// key off above — `propsParams` here comes from the TYPE annotation
|
|
340
|
+
// (`extractPropsFromTypeMembers`), which has no notion of a body-level
|
|
341
|
+
// rename. `resolveBodyDestructuredPropAliases` recognizes such a
|
|
342
|
+
// destructuring statement's `localConstants` shape; seeding the LOCAL
|
|
343
|
+
// name too (alongside the caller-facing key already seeded above) is
|
|
344
|
+
// enough to fix what a template-stash adapter's compiled output
|
|
345
|
+
// actually reads (`v[:kids]` / `$kids`). Without this, a stash-based
|
|
346
|
+
// adapter reading the renamed local sees an undefined/nil value instead
|
|
347
|
+
// of the prop — fatal under Perl strict mode (Mojolicious), a
|
|
348
|
+
// nonexistent Go struct field, or (on an adapter whose runtime
|
|
349
|
+
// tolerates a missing key) a silent wrong render the moment a caller
|
|
350
|
+
// actually supplies a value. Parameter-destructuring aliases (`{ n:
|
|
351
|
+
// count }`) need no such entry — `sourceName`/`name` already handle
|
|
352
|
+
// that shape via `callerPropName` above.
|
|
353
|
+
for (const [local, callerKey] of resolveBodyDestructuredPropAliases(metadata.localConstants ?? [], metadata.propsObjectName)) {
|
|
354
|
+
if (local in out) continue
|
|
355
|
+
const origin = out[callerKey]
|
|
356
|
+
if (origin) out[local] = origin
|
|
357
|
+
}
|
|
358
|
+
|
|
314
359
|
// Bare-props-arg safety net: the prop block above covers every prop
|
|
315
360
|
// *declared* on the props type, but `propsParams` can miss props read
|
|
316
361
|
// through an untyped / inline-typed `props` object. A signal / memo
|
package/src/types.ts
CHANGED
|
@@ -404,18 +404,13 @@ export interface IRText {
|
|
|
404
404
|
export interface IRExpression {
|
|
405
405
|
type: 'expression'
|
|
406
406
|
expr: string
|
|
407
|
-
/** Pre-transformed expr with destructured prop refs rewritten to _p.xxx (for client JS templates). */
|
|
408
|
-
templateExpr?: string
|
|
409
407
|
/**
|
|
410
|
-
*
|
|
411
|
-
*
|
|
412
|
-
*
|
|
413
|
-
*
|
|
414
|
-
* Not read via `templateExpr` instead: that field also rebinds to
|
|
415
|
-
* `_p.xxx`, which drops a destructured prop's `?? {}` default when the
|
|
416
|
-
* builder's output lives in init scope (`client-js-generation.test.ts`).
|
|
408
|
+
* Pre-transformed expr with destructured prop refs rewritten to _p.xxx
|
|
409
|
+
* (for client JS templates). Carries ONLY that rewrite — never an
|
|
410
|
+
* escaping wrapper: client-side escaping is decided at the splice
|
|
411
|
+
* (`ir-to-client-js/safe-html.ts`), not baked into IR text (#2795).
|
|
417
412
|
*/
|
|
418
|
-
|
|
413
|
+
templateExpr?: string
|
|
419
414
|
/**
|
|
420
415
|
* Structured parse of `expr` (`parseExpression(expr.trim())`), attached once
|
|
421
416
|
* during IR construction so SSR adapters emit from the tree instead of each
|
|
@@ -1,65 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Text-slot HTML-escaping emit shape (#1694 + follow-up, #2651).
|
|
3
|
-
*
|
|
4
|
-
* Pins which interpolations the client template wraps in an escape call:
|
|
5
|
-
* - a plain, non-conditional dynamic text slot (`{stringValue}`) IS
|
|
6
|
-
* escaped — it becomes the slot's text content under `innerHTML`. Since
|
|
7
|
-
* #2651 this goes through `escapeTextOrMarkup`, not bare `escapeText`:
|
|
8
|
-
* the slot is claim-plan `kind: 'markup'` (the value may be a live
|
|
9
|
-
* `Node`, or a `bfMarkup()`-branded JSX-element-prop value), and
|
|
10
|
-
* `escapeTextOrMarkup` is a strict superset of `escapeText` for every
|
|
11
|
-
* non-branded value — this pin's actual escaping behaviour for a plain
|
|
12
|
-
* string is unchanged, only the call name changed to match the
|
|
13
|
-
* REACTIVE side's existing `escapeTextOrNode` classification;
|
|
14
|
-
* - a branch-slot expression (Child-position value inside a conditional
|
|
15
|
-
* `template()` arrow) is routed through `__bfSlot` and must NOT be
|
|
16
|
-
* wrapped in either escape call. `__bfSlot` returns raw
|
|
17
|
-
* `<!--bf-slot:N-->` markers for live nodes; escaping the whole call
|
|
18
|
-
* corrupts them and drops slotted content (the regression that broke
|
|
19
|
-
* `e2e-site-ui`). `__bfSlot` escapes its own plain-string path
|
|
20
|
-
* internally instead.
|
|
21
|
-
*/
|
|
22
|
-
|
|
23
|
-
import { describe, test, expect } from 'bun:test'
|
|
24
|
-
import { compileJSX } from '../compiler'
|
|
25
|
-
import { TestAdapter } from '../adapters/test-adapter'
|
|
26
|
-
|
|
27
|
-
const adapter = new TestAdapter()
|
|
28
|
-
|
|
29
|
-
function getClientJs(source: string, filename: string): string {
|
|
30
|
-
const result = compileJSX(source, filename, { adapter })
|
|
31
|
-
expect(result.errors.filter(e => e.severity === 'error')).toHaveLength(0)
|
|
32
|
-
const clientJs = result.files.find(f => f.type === 'clientJs')
|
|
33
|
-
expect(clientJs).toBeDefined()
|
|
34
|
-
return clientJs!.content
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
describe('text-slot escaping', () => {
|
|
38
|
-
test('a plain text slot is wrapped in escapeTextOrMarkup (#2651)', () => {
|
|
39
|
-
const clientJs = getClientJs(
|
|
40
|
-
`'use client'
|
|
41
|
-
export function Label({ text }: { text: string }) {
|
|
42
|
-
return <span>{text}</span>
|
|
43
|
-
}`,
|
|
44
|
-
'Label.tsx',
|
|
45
|
-
)
|
|
46
|
-
expect(clientJs).toMatch(/<!--bf:\w+-->\$\{escapeTextOrMarkup\(_p\.text\)\}<!--\/-->/)
|
|
47
|
-
})
|
|
48
|
-
|
|
49
|
-
test('a branch-slot expression is NOT wrapped in escapeTextOrMarkup or escapeText', () => {
|
|
50
|
-
const clientJs = getClientJs(
|
|
51
|
-
`'use client'
|
|
52
|
-
import { createSignal } from '@barefootjs/client'
|
|
53
|
-
export function Branch({ show }: { show: boolean }) {
|
|
54
|
-
const [t] = createSignal('hi')
|
|
55
|
-
return <div>{show ? <span>{t()}</span> : null}</div>
|
|
56
|
-
}`,
|
|
57
|
-
'Branch.tsx',
|
|
58
|
-
)
|
|
59
|
-
// The branch value goes through __bfSlot (raw markers preserved)…
|
|
60
|
-
expect(clientJs).toMatch(/\$\{__bfSlot\(/)
|
|
61
|
-
// …and must never be double-wrapped by either text-escape call.
|
|
62
|
-
expect(clientJs).not.toMatch(/escapeTextOrMarkup\(\s*__bfSlot/)
|
|
63
|
-
expect(clientJs).not.toMatch(/escapeText\(\s*__bfSlot/)
|
|
64
|
-
})
|
|
65
|
-
})
|