@barefootjs/jsx 0.33.2 → 0.33.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/analyzer.d.ts +17 -0
- package/dist/analyzer.d.ts.map +1 -1
- package/dist/compiler.d.ts +21 -5
- package/dist/compiler.d.ts.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +707 -431
- package/dist/ir-to-client-js/collect-elements.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/stringify/loop-child-arm.d.ts.map +1 -1
- package/dist/ir-to-client-js/html-template.d.ts.map +1 -1
- package/dist/ir-to-client-js/imports.d.ts +60 -2
- package/dist/ir-to-client-js/imports.d.ts.map +1 -1
- package/dist/ir-to-client-js/prop-handling.d.ts +4 -7
- package/dist/ir-to-client-js/prop-handling.d.ts.map +1 -1
- package/dist/ir-to-client-js/utils.d.ts +26 -2
- package/dist/ir-to-client-js/utils.d.ts.map +1 -1
- package/dist/jsx-to-ir.d.ts.map +1 -1
- package/dist/props-binding.d.ts +35 -0
- package/dist/props-binding.d.ts.map +1 -1
- package/dist/types.d.ts +50 -13
- package/dist/types.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/__snapshots__/doc-examples.test.ts.snap +145 -97
- package/src/__tests__/child-component-ref-not-mirrored.test.ts +90 -0
- package/src/__tests__/ir-to-client-js/imports.test.ts +107 -0
- package/src/__tests__/ir-to-client-js/merge-compiled-client-js-imports.test.ts +138 -0
- package/src/__tests__/issue-2754-rest-spread-needs-slot.test.ts +85 -0
- package/src/__tests__/issue-2756-loop-row-honors-client-only.test.ts +173 -0
- package/src/__tests__/merge-template-imports.test.ts +41 -1
- package/src/__tests__/multi-component-shared-default-import.test.ts +55 -0
- package/src/__tests__/root-key-relay.test.ts +170 -0
- package/src/__tests__/signal-getter-not-called.test.ts +149 -0
- package/src/__tests__/state-only-file-default-import.test.ts +47 -0
- package/src/analyzer.ts +36 -0
- package/src/compiler.ts +94 -104
- package/src/index.ts +1 -1
- package/src/ir-to-client-js/collect-elements.ts +27 -5
- package/src/ir-to-client-js/control-flow/stringify/inner-loop.ts +6 -2
- package/src/ir-to-client-js/control-flow/stringify/loop-child-arm.ts +5 -2
- package/src/ir-to-client-js/html-template.ts +122 -12
- package/src/ir-to-client-js/imports.ts +178 -5
- package/src/ir-to-client-js/index.ts +5 -0
- package/src/ir-to-client-js/prop-handling.ts +6 -17
- package/src/ir-to-client-js/utils.ts +30 -2
- package/src/jsx-to-ir.ts +480 -52
- package/src/props-binding.ts +51 -0
- package/src/types.ts +47 -13
package/src/jsx-to-ir.ts
CHANGED
|
@@ -49,7 +49,7 @@ import {
|
|
|
49
49
|
rewriteBarePropRefs as rewriteBarePropRefsCore,
|
|
50
50
|
collectAstPropRefs,
|
|
51
51
|
} from './prop-rewrite.ts'
|
|
52
|
-
import { buildPropAliasMap } from './props-binding.ts'
|
|
52
|
+
import { buildPropAliasMap, resolveRestSpreadOriginCore } from './props-binding.ts'
|
|
53
53
|
import { resolveFreeRefs, isNameBound as isNameBoundInEnv, type BindingEnvironment } from './free-refs.ts'
|
|
54
54
|
import { computeFileScope } from './ir-to-client-js/component-scope.ts'
|
|
55
55
|
import { createTemplateAwareStringProtector } from './ir-to-client-js/html-template.ts'
|
|
@@ -61,7 +61,7 @@ import { extractFreeIdentifiersFromNode, initializerShapeContainsJsx, extractMul
|
|
|
61
61
|
import { iterateJsTokens, replaceInExprContexts } from './scanner/js-scanner.ts'
|
|
62
62
|
import { reconstructAsSegments } from './strip-types.ts'
|
|
63
63
|
import { templatePartsToJsExpr } from './template-parts.ts'
|
|
64
|
-
import { toHTMLAttrName, decodeEntities } from '@barefootjs/shared'
|
|
64
|
+
import { toHTMLAttrName, decodeEntities, BF_KEY, keyAttrName } from '@barefootjs/shared'
|
|
65
65
|
import { BindingScope } from './scope/binding-scope.ts'
|
|
66
66
|
import { identifierPattern, identifierCallPattern } from './identifier-pattern.ts'
|
|
67
67
|
|
|
@@ -93,6 +93,8 @@ interface TransformContext {
|
|
|
93
93
|
_reactiveGetterNames?: Set<string>
|
|
94
94
|
/** Cached set of module-scope @client signal/memo names. */
|
|
95
95
|
_moduleClientSignalNames?: Set<string>
|
|
96
|
+
/** Cached `localConstants` index for `forwardsCallerRestProps`'s alias walk */
|
|
97
|
+
_restSpreadConstantValues?: ReadonlyMap<string, string | undefined>
|
|
96
98
|
/** Cached set of destructured prop names for AST-based rewriting */
|
|
97
99
|
_destructuredPropNames?: Set<string> | null
|
|
98
100
|
/**
|
|
@@ -982,9 +984,82 @@ function attachParsedExpressions(node: IRNode, analyzer: AnalyzerContext, bound:
|
|
|
982
984
|
}
|
|
983
985
|
}
|
|
984
986
|
|
|
987
|
+
/**
|
|
988
|
+
* Resolve #2753's "mechanism 2" `keyAttr`: the component's own possible
|
|
989
|
+
* render root(s) relay a key value an external caller supplies at runtime
|
|
990
|
+
* (a `data-key` prop / Rust `bf.data_key` field / client `createComponent`
|
|
991
|
+
* key argument) when THIS component is itself invoked as a caller's keyed
|
|
992
|
+
* loop row. Ported, once, from the `element.needsScope` test each of the 10
|
|
993
|
+
* SSR adapters previously applied at EMIT time, on every compile. Resolving
|
|
994
|
+
* it here means every adapter instead reads one IR field.
|
|
995
|
+
*
|
|
996
|
+
* The relay belongs on exactly the elements carrying `needsScope` — the
|
|
997
|
+
* elements that also carry `bf-s`, i.e. this component's rendered root(s).
|
|
998
|
+
* That is the same predicate, evaluated at the same set of elements, as the
|
|
999
|
+
* reference (Hono) adapter applied before #2762, and it matches what the
|
|
1000
|
+
* client runtime does for the CSR half of the contract: `renderChild` /
|
|
1001
|
+
* `materializeComponent` splice `data-key` onto the rendered markup's FIRST
|
|
1002
|
+
* element (component.ts), whatever wrapper nodes stand above it. A walk
|
|
1003
|
+
* that stops at the first non-element node instead of testing `needsScope`
|
|
1004
|
+
* throughout answers differently for every component whose root is a
|
|
1005
|
+
* `<Ctx.Provider>` — `transformProviderElement` passes `ctx.isRoot` through
|
|
1006
|
+
* to its children, so the provider's inner element is a rendered root and
|
|
1007
|
+
* gets `needsScope`, but the walk never reaches it (#2753 regression).
|
|
1008
|
+
*
|
|
1009
|
+
* `needsScope` is `ctx.isRoot` at element-transform time, and `ctx.isRoot`
|
|
1010
|
+
* is consumed by the first element/component/scope-comment fragment on the
|
|
1011
|
+
* way down, so nothing inside a loop body or a child component's slot
|
|
1012
|
+
* children can be true here. Visiting the whole tree therefore costs
|
|
1013
|
+
* nothing in reach but cannot go stale the way an enumeration of
|
|
1014
|
+
* "`isRoot`-transparent constructs" can.
|
|
1015
|
+
*
|
|
1016
|
+
* A `needsScopeComment` fragment root (#2732) is handled separately, at
|
|
1017
|
+
* `transformFragment` build time (`markDataKeyCarrier`): `ctx.isRoot` can be
|
|
1018
|
+
* true for at most one node in a component's whole tree, so that one shape
|
|
1019
|
+
* is fully resolved the moment the fragment itself is built and does not
|
|
1020
|
+
* need a second, tree-wide pass to find it again. Its children carry
|
|
1021
|
+
* `needsScope: false` by construction, so this walk's `n.needsScope` guard
|
|
1022
|
+
* naturally skips them without double-marking.
|
|
1023
|
+
*
|
|
1024
|
+
* The `!keyAttr` guard keeps mechanism 1 (`applyLoopKeyAttr`, an inline
|
|
1025
|
+
* `.map()` row root's own concretely-known key expression, resolved during
|
|
1026
|
+
* the transform) — strictly more useful than a relay marker — from being
|
|
1027
|
+
* overwritten.
|
|
1028
|
+
*/
|
|
1029
|
+
function resolveRootKeyAttr(node: IRNode | null): void {
|
|
1030
|
+
if (!node) return
|
|
1031
|
+
switch (node.type) {
|
|
1032
|
+
case 'element':
|
|
1033
|
+
if (node.needsScope && !node.keyAttr) node.keyAttr = { name: BF_KEY }
|
|
1034
|
+
for (const c of node.children) resolveRootKeyAttr(c)
|
|
1035
|
+
return
|
|
1036
|
+
case 'fragment':
|
|
1037
|
+
case 'component':
|
|
1038
|
+
case 'provider':
|
|
1039
|
+
case 'loop':
|
|
1040
|
+
for (const c of node.children) resolveRootKeyAttr(c)
|
|
1041
|
+
return
|
|
1042
|
+
case 'async':
|
|
1043
|
+
resolveRootKeyAttr(node.fallback)
|
|
1044
|
+
for (const c of node.children) resolveRootKeyAttr(c)
|
|
1045
|
+
return
|
|
1046
|
+
case 'conditional':
|
|
1047
|
+
resolveRootKeyAttr(node.whenTrue)
|
|
1048
|
+
resolveRootKeyAttr(node.whenFalse)
|
|
1049
|
+
return
|
|
1050
|
+
case 'if-statement':
|
|
1051
|
+
resolveRootKeyAttr(node.consequent)
|
|
1052
|
+
resolveRootKeyAttr(node.alternate)
|
|
1053
|
+
return
|
|
1054
|
+
}
|
|
1055
|
+
}
|
|
1056
|
+
|
|
985
1057
|
export function jsxToIR(analyzer: AnalyzerContext): IRNode | null {
|
|
986
1058
|
const root = buildIRRoot(analyzer)
|
|
987
|
-
if (root)
|
|
1059
|
+
if (root) {
|
|
1060
|
+
attachParsedExpressions(root, analyzer)
|
|
1061
|
+
resolveRootKeyAttr(root)
|
|
1062
|
+
}
|
|
988
1063
|
return root
|
|
989
1064
|
}
|
|
990
1065
|
|
|
@@ -1388,13 +1463,12 @@ function lowerFormControlValueSsr(
|
|
|
1388
1463
|
children.push({
|
|
1389
1464
|
type: 'expression',
|
|
1390
1465
|
expr,
|
|
1391
|
-
//
|
|
1392
|
-
//
|
|
1393
|
-
// value containing `</textarea>` would break out of the element on
|
|
1394
|
-
// CSR mount. Escape in the client-only template variant; `expr`
|
|
1395
|
-
// stays clean for the SSR adapters, whose template engines (and
|
|
1396
|
-
// hono/jsx) already escape text children natively.
|
|
1466
|
+
// Escaped for client string-building; `expr` stays raw since SSR
|
|
1467
|
+
// engines escape text children natively.
|
|
1397
1468
|
templateExpr: `escapeText(${templateExpr ?? expr})`,
|
|
1469
|
+
// Init-scope builders can't just swap in `templateExpr` (its `_p.`
|
|
1470
|
+
// binding differs) — see `escapeInClientTemplate`'s docstring.
|
|
1471
|
+
escapeInClientTemplate: true,
|
|
1398
1472
|
typeInfo: null,
|
|
1399
1473
|
reactive: false,
|
|
1400
1474
|
slotId: null,
|
|
@@ -1471,7 +1545,7 @@ function transformHtmlElement(
|
|
|
1471
1545
|
|
|
1472
1546
|
// Determine if this element needs a slot ID
|
|
1473
1547
|
// Elements need slotIds if they have: events, dynamic children, reactive attributes, or refs
|
|
1474
|
-
const needsSlot = events.length > 0 || hasDynamicContent(children) || hasReactiveAttributes(attrs, ctx) || ref !== null
|
|
1548
|
+
const needsSlot = events.length > 0 || hasDynamicContent(children) || hasReactiveAttributes(attrs, ctx) || ref !== null || forwardsCallerRestProps(attrs, ctx)
|
|
1475
1549
|
const slotId = needsSlot ? generateSlotId(ctx) : null
|
|
1476
1550
|
|
|
1477
1551
|
// Propagate slotId to loop children (they need to use parent's marker)
|
|
@@ -1527,7 +1601,7 @@ function transformSelfClosingElement(
|
|
|
1527
1601
|
lowerFormControlValueSsr(tagName, attrs, selfClosingChildren)
|
|
1528
1602
|
|
|
1529
1603
|
// Elements need slotIds if they have events, reactive attributes, or refs
|
|
1530
|
-
const needsSlot = events.length > 0 || hasReactiveAttributes(attrs, ctx) || ref !== null
|
|
1604
|
+
const needsSlot = events.length > 0 || hasReactiveAttributes(attrs, ctx) || ref !== null || forwardsCallerRestProps(attrs, ctx)
|
|
1531
1605
|
const slotId = needsSlot ? generateSlotId(ctx) : null
|
|
1532
1606
|
|
|
1533
1607
|
const needsScope = ctx.isRoot
|
|
@@ -1946,10 +2020,12 @@ function unwrapHoistedFragment(node: IRNode): IRNode {
|
|
|
1946
2020
|
|
|
1947
2021
|
// #2732: a `needsScopeComment` fragment's five hydration markers move to
|
|
1948
2022
|
// the wrapping comment, but `data-key` needs to stay on an element (see
|
|
1949
|
-
// `IRElement.
|
|
1950
|
-
//
|
|
2023
|
+
// `IRElement.keyAttr`'s docstring for why). Mark the first ELEMENT among
|
|
2024
|
+
// `children` — immutably (`{ ...el, keyAttr: { name: BF_KEY } }`), matching
|
|
1951
2025
|
// the rest of this file's post-hoc IR tagging (e.g. `unwrapHoistedFragment`)
|
|
1952
|
-
// rather than mutating the child in place.
|
|
2026
|
+
// rather than mutating the child in place. No `value`: this fragment relays
|
|
2027
|
+
// whatever key its OWN caller supplies at runtime (#2753's "mechanism 2" —
|
|
2028
|
+
// see `IRElement.keyAttr`), not a locally-known expression.
|
|
1953
2029
|
function markDataKeyCarrier(children: IRNode[]): IRNode[] {
|
|
1954
2030
|
for (let i = 0; i < children.length; i++) {
|
|
1955
2031
|
const marked = markCarrierIn(children[i])
|
|
@@ -1978,7 +2054,13 @@ function markDataKeyCarrier(children: IRNode[]): IRNode[] {
|
|
|
1978
2054
|
*/
|
|
1979
2055
|
function markCarrierIn(node: IRNode): IRNode | null {
|
|
1980
2056
|
if (node.type === 'element') {
|
|
1981
|
-
|
|
2057
|
+
// Never overwrite an element that already resolved a LOCAL loop key
|
|
2058
|
+
// (mechanism 1, `resolveLoopKeyAttr` below) — that can only happen if
|
|
2059
|
+
// this same element is simultaneously a `.map()` row root AND this
|
|
2060
|
+
// fragment's relay carrier, and the local, concretely-known expression
|
|
2061
|
+
// is strictly more useful than the relay marker.
|
|
2062
|
+
if ((node as IRElement).keyAttr) return null
|
|
2063
|
+
return { ...(node as IRElement), keyAttr: { name: BF_KEY } }
|
|
1982
2064
|
}
|
|
1983
2065
|
if (node.type === 'conditional') {
|
|
1984
2066
|
const cond = node as IRConditional
|
|
@@ -2171,8 +2253,11 @@ function transformExpressionInner(
|
|
|
2171
2253
|
return null
|
|
2172
2254
|
}
|
|
2173
2255
|
|
|
2174
|
-
// Check for bare signal/memo identifier (BF044)
|
|
2175
|
-
|
|
2256
|
+
// Check for bare signal/memo identifier (BF044). A JSX text child is
|
|
2257
|
+
// inherently a RENDERED position — unlike a component prop, `{expr}`
|
|
2258
|
+
// here always becomes literal rendered output, never an opaque value
|
|
2259
|
+
// handed to a consumer to call later — so nested descent is always on.
|
|
2260
|
+
checkBareSignalOrMemoIdentifier(expr, ctx, { descendNested: true })
|
|
2176
2261
|
|
|
2177
2262
|
// #547: Inline a JSX constant referenced by identifier. Unique to JSX-child
|
|
2178
2263
|
// position — conditional branches and return position don't resolve
|
|
@@ -4104,6 +4189,46 @@ function tagLoopItemRootComponents(nodes: readonly IRNode[]): void {
|
|
|
4104
4189
|
}
|
|
4105
4190
|
}
|
|
4106
4191
|
|
|
4192
|
+
/**
|
|
4193
|
+
* Attach the loop's resolved key ATTRIBUTE (name + value) to the element(s)
|
|
4194
|
+
* that will actually render it — the write-side twin of `extractLoopKey`,
|
|
4195
|
+
* which reads the SAME `key={}` JSX attribute (#2753: the SSR-side row-key
|
|
4196
|
+
* decision is resolved once here, so the 9 SSR adapters stop re-deriving the
|
|
4197
|
+
* attribute name from their own depth tracking, and stop disagreeing about
|
|
4198
|
+
* whether an unkeyed row gets one at all).
|
|
4199
|
+
*
|
|
4200
|
+
* Mutates in place — `tagLoopItemRootComponents`'s style, run immediately
|
|
4201
|
+
* after `key`/`depth` resolve, on the same node objects `children` already
|
|
4202
|
+
* owns — rather than rebuilding the tree immutably: unlike `markCarrierIn`
|
|
4203
|
+
* (which hands a fresh tree back to a caller holding the old one), this runs
|
|
4204
|
+
* on IR this same function just built and is about to return, so there is
|
|
4205
|
+
* no stale external reference to protect.
|
|
4206
|
+
*
|
|
4207
|
+
* Deliberately does NOT remove the literal `key` entry from `.attrs`: the
|
|
4208
|
+
* CSR/client-JS codegen path (`ir-to-client-js/html-template.ts`'s
|
|
4209
|
+
* `a.name === 'key' ? keyAttrName(loopDepth) : ...`) already reads it
|
|
4210
|
+
* directly and is not part of #2753's 16-site duplication — it derives the
|
|
4211
|
+
* same name from the same `keyAttrName()` single source of truth, just at a
|
|
4212
|
+
* different phase (client-template bake time, not SSR emit time). Adapters
|
|
4213
|
+
* skip the raw `key` attr in their generic `renderAttributes` loop instead
|
|
4214
|
+
* (Hono's own JSX runtime already drops it silently either way).
|
|
4215
|
+
*
|
|
4216
|
+
* `component` bodies are left untouched: a `<Comp key={x}/>` row root's key
|
|
4217
|
+
* is a PROP (`renderComponentProps`'s own `data-key` prop-forwarding), not
|
|
4218
|
+
* an `IRElement` — a separate mechanism #2753 did not measure as broken and
|
|
4219
|
+
* this change does not touch.
|
|
4220
|
+
*/
|
|
4221
|
+
function applyLoopKeyAttr(node: IRNode, name: string, value: string): void {
|
|
4222
|
+
if (node.type === 'element') {
|
|
4223
|
+
node.keyAttr = { name, value }
|
|
4224
|
+
return
|
|
4225
|
+
}
|
|
4226
|
+
if (node.type === 'conditional') {
|
|
4227
|
+
applyLoopKeyAttr(node.whenTrue, name, value)
|
|
4228
|
+
applyLoopKeyAttr(node.whenFalse, name, value)
|
|
4229
|
+
}
|
|
4230
|
+
}
|
|
4231
|
+
|
|
4107
4232
|
function loopBodyItemConditional(children: IRNode[]): IRConditional | null {
|
|
4108
4233
|
const real = children.filter(
|
|
4109
4234
|
(c) => !(c.type === 'text' && typeof c.value === 'string' && !c.value.trim())
|
|
@@ -4946,6 +5071,20 @@ function transformMapCall(
|
|
|
4946
5071
|
? extractItemConditionalKey(itemConditional!)
|
|
4947
5072
|
: (children.length > 0 ? extractLoopKey(children[0]) : null)
|
|
4948
5073
|
|
|
5074
|
+
// #2753: resolve this row's key ATTRIBUTE (name + value) here, once, and
|
|
5075
|
+
// stamp it onto the element(s) that render it — `null` (unkeyed) means no
|
|
5076
|
+
// `IRElement.keyAttr` at all, which is the correct final answer for every
|
|
5077
|
+
// adapter and the client runtime alike, not a hint they still have to
|
|
5078
|
+
// gate. See `IRElement.keyAttr` / `applyLoopKeyAttr`'s docstrings.
|
|
5079
|
+
if (key !== null) {
|
|
5080
|
+
const resolvedKeyAttrName = keyAttrName(depth)
|
|
5081
|
+
if (bodyIsItemConditional) {
|
|
5082
|
+
applyLoopKeyAttr(itemConditional!, resolvedKeyAttrName, key)
|
|
5083
|
+
} else if (children.length > 0) {
|
|
5084
|
+
applyLoopKeyAttr(children[0], resolvedKeyAttrName, key)
|
|
5085
|
+
}
|
|
5086
|
+
}
|
|
5087
|
+
|
|
4949
5088
|
// Stage 3 / D5 (spec/callback-fidelity.md) — the keyFn is hoisted: `mapArray`
|
|
4950
5089
|
// computes it from the raw item BEFORE the callback body runs, so the key must
|
|
4951
5090
|
// be derivable from the item (and index), never from a value the preamble
|
|
@@ -6244,8 +6383,20 @@ function getAttributeValue(attr: ts.JsxAttribute, ctx: TransformContext): AttrVa
|
|
|
6244
6383
|
return AttrValueOf.expression('undefined')
|
|
6245
6384
|
}
|
|
6246
6385
|
|
|
6247
|
-
// Check for bare signal/memo identifier (BF044)
|
|
6248
|
-
|
|
6386
|
+
// Check for bare signal/memo identifier (BF044). `getAttributeValue`
|
|
6387
|
+
// serves BOTH a DOM element's attributes and a component's props
|
|
6388
|
+
// (two call sites above, in `processElementAttributes` and
|
|
6389
|
+
// `processComponentProps`), and only the element-attribute case is
|
|
6390
|
+
// a RENDERED position — a component prop is an opaque value handed
|
|
6391
|
+
// to the component, and this codebase's Context-Provider idiom
|
|
6392
|
+
// depends on that value being an uncalled accessor (`value={{
|
|
6393
|
+
// open }}`, ui/components/ui/select/index.tsx — calling it eagerly
|
|
6394
|
+
// there would freeze the value at provider-render time and break
|
|
6395
|
+
// every consumer's reactivity). So nested descent is gated on
|
|
6396
|
+
// element-vs-component, derived from the tag this attribute lives
|
|
6397
|
+
// on; the top-level bare-identifier check stays unconditional
|
|
6398
|
+
// either way, unchanged from this diagnostic's original behaviour.
|
|
6399
|
+
checkBareSignalOrMemoIdentifier(expr, ctx, { descendNested: isRenderedElementAttribute(attr, ctx) })
|
|
6249
6400
|
|
|
6250
6401
|
// Static style object: style={{ key: 'value', ... }} → CSS string at compile time
|
|
6251
6402
|
if (attr.name.getText(ctx.sourceFile) === 'style' && ts.isObjectLiteralExpression(expr)) {
|
|
@@ -7300,53 +7451,287 @@ function processComponentProps(
|
|
|
7300
7451
|
// =============================================================================
|
|
7301
7452
|
|
|
7302
7453
|
/**
|
|
7303
|
-
*
|
|
7304
|
-
*
|
|
7454
|
+
* Is `attr` an attribute on a DOM/SVG element's opening tag, as opposed
|
|
7455
|
+
* to a prop on a component? `JsxAttributes.parent` is always the
|
|
7456
|
+
* `JsxOpeningElement`/`JsxSelfClosingElement` the attribute list belongs
|
|
7457
|
+
* to (`JsxOpeningLikeElement`); its `tagName` follows the same
|
|
7458
|
+
* uppercase-means-component convention `transformSelfClosingElement`
|
|
7459
|
+
* already uses (`isComponent = /^[A-Z]/.test(tagName)`) — lowercase or
|
|
7460
|
+
* hyphenated (a custom element) is a real DOM node, capitalised or
|
|
7461
|
+
* dotted (`Select.Provider`) is a component.
|
|
7462
|
+
*
|
|
7463
|
+
* Feeds `checkBareSignalOrMemoIdentifier`'s `descendNested` gate: a
|
|
7464
|
+
* DOM attribute value is a RENDERED position (this is genuinely what
|
|
7465
|
+
* ends up in the DOM), so a bare getter buried inside it — `style={{
|
|
7466
|
+
* color: val }}` — is exactly as much a forgotten `()` as a top-level
|
|
7467
|
+
* one. A component prop is not rendered — it's an opaque value handed
|
|
7468
|
+
* to the component — and this codebase's Context-Provider idiom
|
|
7469
|
+
* depends on that value being an UNCALLED accessor
|
|
7470
|
+
* (`ui/components/ui/select/index.tsx`'s `value={{ open, ... }}`), so
|
|
7471
|
+
* nested descent must not fire there.
|
|
7472
|
+
*/
|
|
7473
|
+
function isRenderedElementAttribute(attr: ts.JsxAttribute, ctx: TransformContext): boolean {
|
|
7474
|
+
const tagName = attr.parent.parent.tagName.getText(ctx.sourceFile)
|
|
7475
|
+
return !/^[A-Z]/.test(tagName)
|
|
7476
|
+
}
|
|
7477
|
+
|
|
7478
|
+
/**
|
|
7479
|
+
* Check for a bare signal/memo getter reference in `expr`.
|
|
7480
|
+
* Emits BF044 when a signal/memo getter is passed without calling it.
|
|
7305
7481
|
* e.g., value={count} instead of value={count()}
|
|
7482
|
+
*
|
|
7483
|
+
* The top-level check — is `expr` itself a bare reactive identifier —
|
|
7484
|
+
* always runs, unchanged from this diagnostic's original behaviour
|
|
7485
|
+
* (`value={count}`, `<Foo x={count} />` keep firing regardless of
|
|
7486
|
+
* position). `descendNested` additionally opts into walking INSIDE a
|
|
7487
|
+
* composite expression — call arguments, template-literal spans,
|
|
7488
|
+
* ternary branches (condition + both arms), array/object literal
|
|
7489
|
+
* members, binary/logical operands, parenthesized wrappers — to catch
|
|
7490
|
+
* a bare reference buried anywhere in that reachable set (#2755,
|
|
7491
|
+
* #2751: this is the single upstream gate whose shallow top-level-only
|
|
7492
|
+
* check let both downstream bugs through). Callers opt in only for
|
|
7493
|
+
* RENDERED positions (a JSX text child; a DOM element's attribute value
|
|
7494
|
+
* via `isRenderedElementAttribute`) — never for a component prop, where
|
|
7495
|
+
* a bare getter is this codebase's deliberate Context-Provider idiom
|
|
7496
|
+
* (measured: 66 such uses across `ui/components/**`/`site/**`, all
|
|
7497
|
+
* legitimate, none a forgotten `()` — widening the gate to fire there
|
|
7498
|
+
* would be a correctness regression, not a fix).
|
|
7499
|
+
*
|
|
7500
|
+
* Two structural exclusions apply whenever descending, both required
|
|
7501
|
+
* so the walk doesn't false-positive on shapes that read the name
|
|
7502
|
+
* without evaluating its signal value:
|
|
7503
|
+
* - the callee of a zero-arg call (`val()`) is the CORRECT form and
|
|
7504
|
+
* is never visited as a bare reference — only the call's arguments
|
|
7505
|
+
* (there are none) would be descended into.
|
|
7506
|
+
* - a property-access chain's object is visited but its property
|
|
7507
|
+
* NAME never is, so `ctx.val` never fires when a signal `val`
|
|
7508
|
+
* exists — the exact bug class documented in this file's
|
|
7509
|
+
* `csr-substitute.ts` sibling walker, #1100.
|
|
7510
|
+
* JSX embedded inside the checked expression (a `.map()`/`.filter()`
|
|
7511
|
+
* callback returning JSX is the common shape) is NOT this walk's
|
|
7512
|
+
* territory either: `transformNode` independently descends into that
|
|
7513
|
+
* element's own attributes and children, each re-entering this same
|
|
7514
|
+
* check on its own real value expression. Descending into it here
|
|
7515
|
+
* would be actively wrong, not just redundant — a JSX element's `name`
|
|
7516
|
+
* nodes (an attribute's NAME, a tag name) are plain `ts.Identifier`s
|
|
7517
|
+
* with no marker distinguishing them from a value read, so without
|
|
7518
|
+
* this guard an attribute like `checked={x}` gets its own NAME flagged
|
|
7519
|
+
* whenever a same-named signal is in scope (measured directly against
|
|
7520
|
+
* `checkbox-demo.tsx` while building this walk).
|
|
7521
|
+
* A nested function/arrow body that rebinds the name shadows it for
|
|
7522
|
+
* everything under that body — modeled with a local bound-name stack,
|
|
7523
|
+
* same shape as `csrSubstituteOnce`'s `boundStack`
|
|
7524
|
+
* (`ir-to-client-js/csr-substitute.ts`), which solves this exact
|
|
7525
|
+
* member-access/shadowing pair of problems for the CSR-substitution
|
|
7526
|
+
* walk. Deliberately NOT threaded through the shared `BindingScope`
|
|
7527
|
+
* service (`scope/binding-scope.ts`) — that scope answers "what does
|
|
7528
|
+
* the surrounding loop/callback bind"; this walk only needs shadowing
|
|
7529
|
+
* introduced by nested function literals inside the single expression
|
|
7530
|
+
* being checked, the same reasoning `csrSubstituteOnce` already
|
|
7531
|
+
* documents for its own local `boundStack`.
|
|
7306
7532
|
*/
|
|
7307
7533
|
function checkBareSignalOrMemoIdentifier(
|
|
7308
7534
|
expr: ts.Expression,
|
|
7309
|
-
ctx: TransformContext
|
|
7535
|
+
ctx: TransformContext,
|
|
7536
|
+
options?: { descendNested?: boolean }
|
|
7310
7537
|
): void {
|
|
7311
|
-
|
|
7312
|
-
|
|
7313
|
-
const
|
|
7538
|
+
const reactiveNames = new Map<string, 'signal' | 'memo'>()
|
|
7539
|
+
for (const signal of ctx.analyzer.signals) reactiveNames.set(signal.getter, 'signal')
|
|
7540
|
+
for (const memo of ctx.analyzer.memos) reactiveNames.set(memo.name, 'memo')
|
|
7541
|
+
if (reactiveNames.size === 0) return
|
|
7314
7542
|
|
|
7315
|
-
|
|
7316
|
-
|
|
7317
|
-
|
|
7318
|
-
|
|
7319
|
-
|
|
7320
|
-
|
|
7321
|
-
|
|
7322
|
-
|
|
7323
|
-
|
|
7324
|
-
|
|
7325
|
-
|
|
7326
|
-
|
|
7327
|
-
)
|
|
7543
|
+
const report = (id: ts.Identifier, name: string, kind: 'signal' | 'memo'): void => {
|
|
7544
|
+
const label = kind === 'signal' ? 'Signal' : 'Memo'
|
|
7545
|
+
ctx.analyzer.errors.push(
|
|
7546
|
+
createError(ErrorCodes.SIGNAL_GETTER_NOT_CALLED,
|
|
7547
|
+
getSourceLocation(id, ctx.sourceFile, ctx.filePath),
|
|
7548
|
+
{
|
|
7549
|
+
message: `${label} getter '${name}' passed without calling it`,
|
|
7550
|
+
suggestion: {
|
|
7551
|
+
message: `${label} getters must be called to read the value. Use \`${name}()\` instead of \`${name}\`.`,
|
|
7552
|
+
replacement: `${name}()`,
|
|
7553
|
+
},
|
|
7554
|
+
}
|
|
7328
7555
|
)
|
|
7556
|
+
)
|
|
7557
|
+
}
|
|
7558
|
+
|
|
7559
|
+
// Top-level check — unchanged from this diagnostic's original
|
|
7560
|
+
// position-gating (fires unconditionally, everywhere), but NOW also
|
|
7561
|
+
// respects the ambient loop/callback `BindingScope` a shadowed name
|
|
7562
|
+
// needs — a pre-existing gap (the original single-line
|
|
7563
|
+
// `if (!ts.isIdentifier(expr)) return` never consulted scope either)
|
|
7564
|
+
// that stayed invisible only because a bare top-level identifier
|
|
7565
|
+
// shadowed by a `.map(name => ...)` row param never happened to be
|
|
7566
|
+
// exercised by any prior test. `ctx.scope` is the LIVE scope at this
|
|
7567
|
+
// JSX node — already entered for the current loop row/callback frame
|
|
7568
|
+
// by the time an attribute/child expression under it is checked
|
|
7569
|
+
// (`enterLoopRow`/`enterCallback`, `scope/binding-scope.ts`) — so
|
|
7570
|
+
// `isBound` here is a shadow-guard read in exactly the sense
|
|
7571
|
+
// `spec/compiler.md`'s `BindingScope` section describes: is a signal
|
|
7572
|
+
// getter of this name shadowed HERE, not "does this expression need
|
|
7573
|
+
// its own reactive slot".
|
|
7574
|
+
if (ts.isIdentifier(expr)) {
|
|
7575
|
+
const kind = reactiveNames.get(expr.text)
|
|
7576
|
+
if (kind && !ctx.scope.isBound(expr.text)) report(expr, expr.text, kind)
|
|
7577
|
+
return
|
|
7578
|
+
}
|
|
7579
|
+
|
|
7580
|
+
if (!options?.descendNested) return
|
|
7581
|
+
|
|
7582
|
+
const boundStack: Array<Set<string>> = []
|
|
7583
|
+
const isBound = (name: string): boolean => {
|
|
7584
|
+
for (let i = boundStack.length - 1; i >= 0; i--) {
|
|
7585
|
+
if (boundStack[i].has(name)) return true
|
|
7586
|
+
}
|
|
7587
|
+
return ctx.scope.isBound(name)
|
|
7588
|
+
}
|
|
7589
|
+
|
|
7590
|
+
/**
|
|
7591
|
+
* Visit the default VALUES in a binding pattern (`const { x = count } = obj`,
|
|
7592
|
+
* `(f = count) => …`), binding each name into `bound` as it is introduced.
|
|
7593
|
+
*
|
|
7594
|
+
* `collectBindingNames` deliberately walks only NAMES — a binding name is
|
|
7595
|
+
* not a reference — but a default value is an ordinary expression, so a bare
|
|
7596
|
+
* getter can hide there. Not hypothetical: measured, such a shape emits a
|
|
7597
|
+
* module-scope `template` thunk that references a component-scope binding
|
|
7598
|
+
* and throws `ReferenceError` on CSR mount, which is #2751's mechanism —
|
|
7599
|
+
* the class this check exists to close.
|
|
7600
|
+
*
|
|
7601
|
+
* Binds as it goes because JS binds left to right WITHIN a pattern too:
|
|
7602
|
+
* `({ a, b = a })` reads the already-initialized `a`, so `b`'s default must
|
|
7603
|
+
* be checked against a scope that already contains `a`. Visiting every
|
|
7604
|
+
* default first and binding afterwards would flag `a` as a bare signal read
|
|
7605
|
+
* whenever a pattern name happens to collide with a real signal — a false
|
|
7606
|
+
* positive, which is the direction that breaks working code.
|
|
7607
|
+
*/
|
|
7608
|
+
const visitBindingDefaults = (name: ts.BindingName, bound: Set<string>): void => {
|
|
7609
|
+
if (ts.isIdentifier(name)) {
|
|
7610
|
+
bound.add(name.text)
|
|
7329
7611
|
return
|
|
7330
7612
|
}
|
|
7613
|
+
for (const el of name.elements) {
|
|
7614
|
+
if (ts.isOmittedExpression(el)) continue
|
|
7615
|
+
if (el.initializer) visit(el.initializer)
|
|
7616
|
+
visitBindingDefaults(el.name, bound)
|
|
7617
|
+
}
|
|
7331
7618
|
}
|
|
7332
7619
|
|
|
7333
|
-
|
|
7334
|
-
if (
|
|
7335
|
-
|
|
7336
|
-
|
|
7337
|
-
|
|
7338
|
-
|
|
7339
|
-
|
|
7340
|
-
|
|
7341
|
-
|
|
7342
|
-
|
|
7343
|
-
|
|
7344
|
-
|
|
7345
|
-
|
|
7346
|
-
)
|
|
7620
|
+
const collectBindingNames = (name: ts.BindingName, out: Set<string>): void => {
|
|
7621
|
+
if (ts.isIdentifier(name)) out.add(name.text)
|
|
7622
|
+
else if (ts.isObjectBindingPattern(name)) {
|
|
7623
|
+
for (const el of name.elements) collectBindingNames(el.name, out)
|
|
7624
|
+
} else if (ts.isArrayBindingPattern(name)) {
|
|
7625
|
+
for (const el of name.elements) {
|
|
7626
|
+
if (!ts.isOmittedExpression(el)) collectBindingNames(el.name, out)
|
|
7627
|
+
}
|
|
7628
|
+
}
|
|
7629
|
+
}
|
|
7630
|
+
|
|
7631
|
+
const collectBlockDeclarations = (block: ts.Block, out: Set<string>): void => {
|
|
7632
|
+
for (const stmt of block.statements) {
|
|
7633
|
+
if (ts.isVariableStatement(stmt)) {
|
|
7634
|
+
for (const decl of stmt.declarationList.declarations) collectBindingNames(decl.name, out)
|
|
7635
|
+
} else if (ts.isFunctionDeclaration(stmt) && stmt.name) {
|
|
7636
|
+
out.add(stmt.name.text)
|
|
7637
|
+
}
|
|
7638
|
+
}
|
|
7639
|
+
}
|
|
7640
|
+
|
|
7641
|
+
function visit(node: ts.Node): void {
|
|
7642
|
+
// JSX embedded inside the checked expression — see docstring.
|
|
7643
|
+
if (
|
|
7644
|
+
ts.isJsxElement(node) || ts.isJsxSelfClosingElement(node) || ts.isJsxFragment(node)
|
|
7645
|
+
) {
|
|
7347
7646
|
return
|
|
7348
7647
|
}
|
|
7648
|
+
|
|
7649
|
+
// Type positions are not value positions. `({} as { count?: unknown })`
|
|
7650
|
+
// in a rendered position would otherwise read the type literal's property
|
|
7651
|
+
// name `count` as a bare reference to a signal of the same name and refuse
|
|
7652
|
+
// valid code — a false positive, the direction that breaks working code.
|
|
7653
|
+
// Covers every type node reachable from an expression: `as`/`satisfies`
|
|
7654
|
+
// types, type assertions, and generic type arguments.
|
|
7655
|
+
if (ts.isTypeNode(node)) return
|
|
7656
|
+
|
|
7657
|
+
// Zero-arg call with bare-identifier callee (`val()`): the correct
|
|
7658
|
+
// form. The callee is never visited as a bare reference; there are
|
|
7659
|
+
// no arguments to descend into either.
|
|
7660
|
+
if (ts.isCallExpression(node) && node.arguments.length === 0 && ts.isIdentifier(node.expression)) {
|
|
7661
|
+
return
|
|
7662
|
+
}
|
|
7663
|
+
|
|
7664
|
+
// Property access: `obj.prop` — visit `obj` (a real reference),
|
|
7665
|
+
// never `prop` (a member-access tail, not a value read). #1100.
|
|
7666
|
+
if (ts.isPropertyAccessExpression(node)) {
|
|
7667
|
+
visit(node.expression)
|
|
7668
|
+
return
|
|
7669
|
+
}
|
|
7670
|
+
|
|
7671
|
+
// Object literal key: `{ X: value }` — `X` is a key, not a read.
|
|
7672
|
+
if (ts.isPropertyAssignment(node)) {
|
|
7673
|
+
if (ts.isComputedPropertyName(node.name)) visit(node.name.expression)
|
|
7674
|
+
visit(node.initializer)
|
|
7675
|
+
return
|
|
7676
|
+
}
|
|
7677
|
+
|
|
7678
|
+
// Shorthand property: `{ X }` — `X` is both key and value, so the
|
|
7679
|
+
// value side IS a bare reference.
|
|
7680
|
+
if (ts.isShorthandPropertyAssignment(node)) {
|
|
7681
|
+
if (ts.isIdentifier(node.name) && !isBound(node.name.text)) {
|
|
7682
|
+
const kind = reactiveNames.get(node.name.text)
|
|
7683
|
+
if (kind) report(node.name, node.name.text, kind)
|
|
7684
|
+
}
|
|
7685
|
+
return
|
|
7686
|
+
}
|
|
7687
|
+
|
|
7688
|
+
// Nested function/arrow: bind params + block-scoped locals declared
|
|
7689
|
+
// in its body, recurse, unbind. A name shadowed here is a different
|
|
7690
|
+
// binding, not the signal/memo of the same name.
|
|
7691
|
+
if (ts.isArrowFunction(node) || ts.isFunctionExpression(node)) {
|
|
7692
|
+
const bound = new Set<string>()
|
|
7693
|
+
// Parameters bind LEFT TO RIGHT, and each default is evaluated against
|
|
7694
|
+
// the scope as it stands at that point: `(a, b = a) => …` reads the
|
|
7695
|
+
// already-bound parameter `a`, never an outer binding of the same name
|
|
7696
|
+
// (verified against V8). So `bound` is pushed FIRST and grows as the
|
|
7697
|
+
// loop walks — each default sees only the parameters before it.
|
|
7698
|
+
boundStack.push(bound)
|
|
7699
|
+
for (const p of node.parameters) {
|
|
7700
|
+
if (p.initializer) visit(p.initializer)
|
|
7701
|
+
visitBindingDefaults(p.name, bound)
|
|
7702
|
+
}
|
|
7703
|
+
if (node.body && ts.isBlock(node.body)) collectBlockDeclarations(node.body, bound)
|
|
7704
|
+
if (node.body) visit(node.body)
|
|
7705
|
+
boundStack.pop()
|
|
7706
|
+
return
|
|
7707
|
+
}
|
|
7708
|
+
|
|
7709
|
+
// Variable declaration: the binding name itself isn't a reference;
|
|
7710
|
+
// only its initializer can be.
|
|
7711
|
+
if (ts.isVariableDeclaration(node)) {
|
|
7712
|
+
if (node.initializer) visit(node.initializer)
|
|
7713
|
+
// The names this declaration introduces shadow outer ones for any LATER
|
|
7714
|
+
// default in the same pattern (`const { a, b = a } = obj`), so they bind
|
|
7715
|
+
// into a scope of their own rather than leaking into the enclosing one.
|
|
7716
|
+
const declared = new Set<string>()
|
|
7717
|
+
boundStack.push(declared)
|
|
7718
|
+
visitBindingDefaults(node.name, declared)
|
|
7719
|
+
boundStack.pop()
|
|
7720
|
+
return
|
|
7721
|
+
}
|
|
7722
|
+
|
|
7723
|
+
// Bare identifier reference — the case this check exists for.
|
|
7724
|
+
if (ts.isIdentifier(node)) {
|
|
7725
|
+
if (isBound(node.text)) return
|
|
7726
|
+
const kind = reactiveNames.get(node.text)
|
|
7727
|
+
if (kind) report(node, node.text, kind)
|
|
7728
|
+
return
|
|
7729
|
+
}
|
|
7730
|
+
|
|
7731
|
+
ts.forEachChild(node, visit)
|
|
7349
7732
|
}
|
|
7733
|
+
|
|
7734
|
+
visit(expr)
|
|
7350
7735
|
}
|
|
7351
7736
|
|
|
7352
7737
|
/**
|
|
@@ -7532,6 +7917,49 @@ function isPropsReference(expr: string, ctx: TransformContext, visited?: Set<str
|
|
|
7532
7917
|
return false
|
|
7533
7918
|
}
|
|
7534
7919
|
|
|
7920
|
+
/**
|
|
7921
|
+
* Does this element carry a `{...spread}` that forwards the caller's
|
|
7922
|
+
* leftover props (the destructured `...rest` binding or a whole `(props)`
|
|
7923
|
+
* parameter, through any alias hop)?
|
|
7924
|
+
*
|
|
7925
|
+
* Such a spread is the one attribute source whose keys are unknowable at
|
|
7926
|
+
* compile time, so neither template can carry it: the SSR adapters render
|
|
7927
|
+
* it through their own spread helper against real request data, and the
|
|
7928
|
+
* client's registration template drops it entirely, leaving the runtime's
|
|
7929
|
+
* `applyRestAttrs` as the only thing that can apply it after a pure CSR
|
|
7930
|
+
* mount. `applyRestAttrs` addresses its element by slot id, so an element
|
|
7931
|
+
* whose only dynamic attribute source is this spread still needs one —
|
|
7932
|
+
* otherwise `init` has no patch point, and every caller-supplied attribute
|
|
7933
|
+
* is silently absent under `createComponent` while SSR and hydration look
|
|
7934
|
+
* correct (#2754).
|
|
7935
|
+
*
|
|
7936
|
+
* Deliberately narrower than "has any spread": a spread of an ordinary
|
|
7937
|
+
* object (`{...extra}`) IS statically emitted into both templates, needs
|
|
7938
|
+
* no runtime application, and must not start allocating slot ids.
|
|
7939
|
+
*/
|
|
7940
|
+
function forwardsCallerRestProps(attrs: IRAttribute[], ctx: TransformContext): boolean {
|
|
7941
|
+
for (const attr of attrs) {
|
|
7942
|
+
if (attr.value.kind !== 'spread') continue
|
|
7943
|
+
const constants = restSpreadConstantValues(ctx)
|
|
7944
|
+
for (const name of new Set([attr.value.expr, attr.value.templateExpr])) {
|
|
7945
|
+
if (!name) continue
|
|
7946
|
+
if (resolveRestSpreadOriginCore(ctx.analyzer, constants, name) !== null) return true
|
|
7947
|
+
}
|
|
7948
|
+
}
|
|
7949
|
+
return false
|
|
7950
|
+
}
|
|
7951
|
+
|
|
7952
|
+
/** `ctx.analyzer.localConstants` indexed by name, memoized per component walk. */
|
|
7953
|
+
function restSpreadConstantValues(ctx: TransformContext): ReadonlyMap<string, string | undefined> {
|
|
7954
|
+
if (ctx._restSpreadConstantValues) return ctx._restSpreadConstantValues
|
|
7955
|
+
const byName = new Map<string, string | undefined>()
|
|
7956
|
+
for (const constant of ctx.analyzer.localConstants) {
|
|
7957
|
+
if (!byName.has(constant.name)) byName.set(constant.name, constant.value)
|
|
7958
|
+
}
|
|
7959
|
+
ctx._restSpreadConstantValues = byName
|
|
7960
|
+
return byName
|
|
7961
|
+
}
|
|
7962
|
+
|
|
7535
7963
|
/**
|
|
7536
7964
|
* Check if any attributes in the list are reactive (depend on signals/memos).
|
|
7537
7965
|
* Reactive attributes need a slotId so the client JS can update them.
|