@barefootjs/jsx 0.33.2 → 0.33.4
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 +804 -445
- package/dist/ir-to-client-js/build-references.d.ts.map +1 -1
- package/dist/ir-to-client-js/collect-elements.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/loop.d.ts +2 -4
- package/dist/ir-to-client-js/control-flow/plan/loop.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/stringify/component-loop.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/control-flow.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 +63 -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__/fragment-body-loop-key.test.ts +95 -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__/multi-root-loop-body.test.ts +7 -3
- package/src/__tests__/preamble-declarations.test.ts +42 -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/build-references.ts +7 -0
- package/src/ir-to-client-js/collect-elements.ts +27 -5
- package/src/ir-to-client-js/control-flow/plan/build-component-loop.ts +19 -1
- package/src/ir-to-client-js/control-flow/plan/loop.ts +2 -4
- package/src/ir-to-client-js/control-flow/stringify/component-loop.ts +7 -0
- 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/control-flow.ts +12 -0
- package/src/ir-to-client-js/html-template.ts +174 -23
- 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 +592 -58
- package/src/props-binding.ts +51 -0
- package/src/types.ts +60 -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
|
|
@@ -3708,6 +3793,19 @@ function extractLoopKey(node: IRNode): string | null {
|
|
|
3708
3793
|
if (b === null) return null
|
|
3709
3794
|
return normalizeKeyExpr(a) === normalizeKeyExpr(b) ? a : null
|
|
3710
3795
|
}
|
|
3796
|
+
if (node.type === 'fragment') {
|
|
3797
|
+
// #2763: a `.map()` body returning a fragment (`<><li key={..}/><li/></>`)
|
|
3798
|
+
// reads the key off its FIRST element, not first node — same "first
|
|
3799
|
+
// element, not first node" rule as `IRElement.keyAttr`'s docstring and
|
|
3800
|
+
// `markCarrierIn`'s relay case, skipping whitespace-only text the way
|
|
3801
|
+
// `branchHasNoElement` does. Recursing through `extractLoopKey` (rather
|
|
3802
|
+
// than requiring `type === 'element'` directly) lets a nested
|
|
3803
|
+
// conditional/fragment first child resolve too.
|
|
3804
|
+
const first = node.children.find(
|
|
3805
|
+
(c) => !(c.type === 'text' && typeof c.value === 'string' && !c.value.trim())
|
|
3806
|
+
)
|
|
3807
|
+
return first ? extractLoopKey(first) : null
|
|
3808
|
+
}
|
|
3711
3809
|
return null
|
|
3712
3810
|
}
|
|
3713
3811
|
|
|
@@ -4023,7 +4121,7 @@ function leafIsWirelessElement(el: ts.JsxElement | ts.JsxSelfClosingElement): bo
|
|
|
4023
4121
|
if (ts.isJsxSpreadAttribute(attr)) { ok = false; return }
|
|
4024
4122
|
if (ts.isJsxAttribute(attr)) {
|
|
4025
4123
|
const name = attr.name.getText()
|
|
4026
|
-
if (
|
|
4124
|
+
if (isEventHandlerAttrName(name)) { ok = false; return }
|
|
4027
4125
|
}
|
|
4028
4126
|
}
|
|
4029
4127
|
}
|
|
@@ -4104,6 +4202,56 @@ function tagLoopItemRootComponents(nodes: readonly IRNode[]): void {
|
|
|
4104
4202
|
}
|
|
4105
4203
|
}
|
|
4106
4204
|
|
|
4205
|
+
/**
|
|
4206
|
+
* Attach the loop's resolved key ATTRIBUTE (name + value) to the element(s)
|
|
4207
|
+
* that will actually render it — the write-side twin of `extractLoopKey`,
|
|
4208
|
+
* which reads the SAME `key={}` JSX attribute (#2753: the SSR-side row-key
|
|
4209
|
+
* decision is resolved once here, so the 9 SSR adapters stop re-deriving the
|
|
4210
|
+
* attribute name from their own depth tracking, and stop disagreeing about
|
|
4211
|
+
* whether an unkeyed row gets one at all).
|
|
4212
|
+
*
|
|
4213
|
+
* Mutates in place — `tagLoopItemRootComponents`'s style, run immediately
|
|
4214
|
+
* after `key`/`depth` resolve, on the same node objects `children` already
|
|
4215
|
+
* owns — rather than rebuilding the tree immutably: unlike `markCarrierIn`
|
|
4216
|
+
* (which hands a fresh tree back to a caller holding the old one), this runs
|
|
4217
|
+
* on IR this same function just built and is about to return, so there is
|
|
4218
|
+
* no stale external reference to protect.
|
|
4219
|
+
*
|
|
4220
|
+
* Deliberately does NOT remove the literal `key` entry from `.attrs`: the
|
|
4221
|
+
* CSR/client-JS codegen path (`ir-to-client-js/html-template.ts`'s
|
|
4222
|
+
* `resolvedKeyAttrName` helper, #2763) reads it back off `.attrs` too, but
|
|
4223
|
+
* only emits `data-key` when THIS field — `node.keyAttr` — is actually set;
|
|
4224
|
+
* a raw `key` attribute the SSR side didn't recognize as a row key (e.g. a
|
|
4225
|
+
* fragment-bodied row before #2763's fix) no longer gets baked client-side
|
|
4226
|
+
* either. Adapters skip the raw `key` attr in their generic `renderAttributes`
|
|
4227
|
+
* loop instead (Hono's own JSX runtime already drops it silently either way).
|
|
4228
|
+
*
|
|
4229
|
+
* `component` bodies are left untouched: a `<Comp key={x}/>` row root's key
|
|
4230
|
+
* is a PROP (`renderComponentProps`'s own `data-key` prop-forwarding), not
|
|
4231
|
+
* an `IRElement` — a separate mechanism #2753 did not measure as broken and
|
|
4232
|
+
* this change does not touch.
|
|
4233
|
+
*/
|
|
4234
|
+
function applyLoopKeyAttr(node: IRNode, name: string, value: string): void {
|
|
4235
|
+
if (node.type === 'element') {
|
|
4236
|
+
node.keyAttr = { name, value }
|
|
4237
|
+
return
|
|
4238
|
+
}
|
|
4239
|
+
if (node.type === 'conditional') {
|
|
4240
|
+
applyLoopKeyAttr(node.whenTrue, name, value)
|
|
4241
|
+
applyLoopKeyAttr(node.whenFalse, name, value)
|
|
4242
|
+
return
|
|
4243
|
+
}
|
|
4244
|
+
if (node.type === 'fragment') {
|
|
4245
|
+
// Write-side twin of `extractLoopKey`'s fragment case: stamp the SAME
|
|
4246
|
+
// first-real-child this key was read from, not every child, matching
|
|
4247
|
+
// the "first element, not first node" rule.
|
|
4248
|
+
const first = node.children.find(
|
|
4249
|
+
(c) => !(c.type === 'text' && typeof c.value === 'string' && !c.value.trim())
|
|
4250
|
+
)
|
|
4251
|
+
if (first) applyLoopKeyAttr(first, name, value)
|
|
4252
|
+
}
|
|
4253
|
+
}
|
|
4254
|
+
|
|
4107
4255
|
function loopBodyItemConditional(children: IRNode[]): IRConditional | null {
|
|
4108
4256
|
const real = children.filter(
|
|
4109
4257
|
(c) => !(c.type === 'text' && typeof c.value === 'string' && !c.value.trim())
|
|
@@ -4542,7 +4690,7 @@ function transformMapCall(
|
|
|
4542
4690
|
// the conditional — the same carrier the single-return path uses. A
|
|
4543
4691
|
// JS-runtime adapter (and the browser under /* @client */) runs it
|
|
4544
4692
|
// verbatim.
|
|
4545
|
-
preamble = preambleFromValueStatements(pre, ctx)
|
|
4693
|
+
preamble = preambleFromValueStatements(pre, ctx, body.statements)
|
|
4546
4694
|
|
|
4547
4695
|
// Stage 2 of spec/callback-fidelity.md, narrowed by #2447. This used
|
|
4548
4696
|
// to refuse EVERY branch preamble on a DSL target, on the premise
|
|
@@ -4749,7 +4897,7 @@ function transformMapCall(
|
|
|
4749
4897
|
valueStmts.push(stmt)
|
|
4750
4898
|
}
|
|
4751
4899
|
if (valueStmts.length > 0) {
|
|
4752
|
-
preamble = preambleFromValueStatements(valueStmts, ctx)
|
|
4900
|
+
preamble = preambleFromValueStatements(valueStmts, ctx, body.statements)
|
|
4753
4901
|
|
|
4754
4902
|
// #2447. A DSL adapter cannot execute `preamble.segments` (JS
|
|
4755
4903
|
// text), so it lowers `declarations` — one per-row local each —
|
|
@@ -4946,6 +5094,20 @@ function transformMapCall(
|
|
|
4946
5094
|
? extractItemConditionalKey(itemConditional!)
|
|
4947
5095
|
: (children.length > 0 ? extractLoopKey(children[0]) : null)
|
|
4948
5096
|
|
|
5097
|
+
// #2753: resolve this row's key ATTRIBUTE (name + value) here, once, and
|
|
5098
|
+
// stamp it onto the element(s) that render it — `null` (unkeyed) means no
|
|
5099
|
+
// `IRElement.keyAttr` at all, which is the correct final answer for every
|
|
5100
|
+
// adapter and the client runtime alike, not a hint they still have to
|
|
5101
|
+
// gate. See `IRElement.keyAttr` / `applyLoopKeyAttr`'s docstrings.
|
|
5102
|
+
if (key !== null) {
|
|
5103
|
+
const resolvedKeyAttrName = keyAttrName(depth)
|
|
5104
|
+
if (bodyIsItemConditional) {
|
|
5105
|
+
applyLoopKeyAttr(itemConditional!, resolvedKeyAttrName, key)
|
|
5106
|
+
} else if (children.length > 0) {
|
|
5107
|
+
applyLoopKeyAttr(children[0], resolvedKeyAttrName, key)
|
|
5108
|
+
}
|
|
5109
|
+
}
|
|
5110
|
+
|
|
4949
5111
|
// Stage 3 / D5 (spec/callback-fidelity.md) — the keyFn is hoisted: `mapArray`
|
|
4950
5112
|
// computes it from the raw item BEFORE the callback body runs, so the key must
|
|
4951
5113
|
// be derivable from the item (and index), never from a value the preamble
|
|
@@ -5705,6 +5867,7 @@ function computePreambleReactiveNames(
|
|
|
5705
5867
|
function preambleFromValueStatements(
|
|
5706
5868
|
statements: readonly ts.Statement[],
|
|
5707
5869
|
ctx: TransformContext,
|
|
5870
|
+
bodyStatements: readonly ts.Statement[],
|
|
5708
5871
|
): MapCallbackPreamble {
|
|
5709
5872
|
const segments: PreambleSegment[] = []
|
|
5710
5873
|
const typedParts: string[] = []
|
|
@@ -5726,7 +5889,7 @@ function preambleFromValueStatements(
|
|
|
5726
5889
|
declaredNames: [...declared],
|
|
5727
5890
|
// Value-only preambles accumulate no JSX, so no child needs the array join.
|
|
5728
5891
|
builderNames: [],
|
|
5729
|
-
declarations: neutralPreambleDeclarations(statements, ctx) ?? undefined,
|
|
5892
|
+
declarations: neutralPreambleDeclarations(statements, ctx, bodyStatements) ?? undefined,
|
|
5730
5893
|
reactiveNames: reactiveNames.size > 0 ? [...reactiveNames] : undefined,
|
|
5731
5894
|
}
|
|
5732
5895
|
}
|
|
@@ -5743,6 +5906,7 @@ function preambleFromValueStatements(
|
|
|
5743
5906
|
function neutralPreambleDeclarations(
|
|
5744
5907
|
statements: readonly ts.Statement[],
|
|
5745
5908
|
ctx: TransformContext,
|
|
5909
|
+
bodyStatements: readonly ts.Statement[],
|
|
5746
5910
|
): PreambleValueDeclaration[] | null {
|
|
5747
5911
|
const out: PreambleValueDeclaration[] = []
|
|
5748
5912
|
for (const stmt of statements) {
|
|
@@ -5754,6 +5918,20 @@ function neutralPreambleDeclarations(
|
|
|
5754
5918
|
// no adapter has a single per-row local form for that.
|
|
5755
5919
|
if (!ts.isIdentifier(decl.name)) return null
|
|
5756
5920
|
if (!decl.initializer) return null
|
|
5921
|
+
// #2797: a function-valued local (an event handler hoisted out of the
|
|
5922
|
+
// JSX attribute position, e.g. `const handleClick = () => {...}`) has
|
|
5923
|
+
// no template-expression form on any backend — no DSL here has a
|
|
5924
|
+
// callable value. But every adapter's own SSR component-prop builder
|
|
5925
|
+
// already skips an event-handler prop (`isEventHandlerAttrName`)
|
|
5926
|
+
// outright, so a name read ONLY that way needs no SSR representation
|
|
5927
|
+
// at all — elide the declaration instead of refusing the whole
|
|
5928
|
+
// preamble. The client-JS preamble (`segments`, not `declarations`)
|
|
5929
|
+
// still carries the real closure; this only concerns what the DSL
|
|
5930
|
+
// SSR template sees.
|
|
5931
|
+
if (isFunctionLiteral(decl.initializer)) {
|
|
5932
|
+
if (everyReadIsEventHandlerPropValue(decl.name.text, bodyStatements)) continue
|
|
5933
|
+
return null
|
|
5934
|
+
}
|
|
5757
5935
|
const valueParsed = tsNodeToParsedExpr(decl.initializer)
|
|
5758
5936
|
// The same subset gate every other DSL-lowered expression passes
|
|
5759
5937
|
// through. An adapter that additionally cannot emit a supported shape
|
|
@@ -5768,7 +5946,74 @@ function neutralPreambleDeclarations(
|
|
|
5768
5946
|
})
|
|
5769
5947
|
}
|
|
5770
5948
|
}
|
|
5771
|
-
|
|
5949
|
+
// Not `out.length > 0 ? out : null` — an all-elided preamble legitimately
|
|
5950
|
+
// has zero declarations to emit (see MapCallbackPreamble.declarations'
|
|
5951
|
+
// docstring); `[]` is what tells the Phase-1 gate "fully declared."
|
|
5952
|
+
return out
|
|
5953
|
+
}
|
|
5954
|
+
|
|
5955
|
+
/** `const x = () => {...}` / `const x = function () {...}` — a callable value, never a template-expression shape. */
|
|
5956
|
+
function isFunctionLiteral(node: ts.Expression): boolean {
|
|
5957
|
+
return ts.isArrowFunction(node) || ts.isFunctionExpression(node)
|
|
5958
|
+
}
|
|
5959
|
+
|
|
5960
|
+
/**
|
|
5961
|
+
* Whether every read of `name` in `bodyStatements`, other than its own
|
|
5962
|
+
* binding site, sits inside an event-handler `JsxAttribute`'s
|
|
5963
|
+
* (`onClick`/`onInput`/...) value — i.e. `name={<ident>}` where the
|
|
5964
|
+
* attribute's own name passes {@link isEventHandlerAttrName}, the exact
|
|
5965
|
+
* predicate every DSL adapter's own SSR prop-builder already uses to skip an
|
|
5966
|
+
* event-handler prop (e.g. `packages/adapter-jinja/src/adapter/
|
|
5967
|
+
* jinja-adapter.ts`'s `renderComponent`, `packages/adapter-go-template/...`'s
|
|
5968
|
+
* `prop.isEventHandler` check).
|
|
5969
|
+
*
|
|
5970
|
+
* Walks the raw preamble+return syntax rather than the already-built row IR
|
|
5971
|
+
* (`children`) deliberately: `neutralPreambleDeclarations` runs on the
|
|
5972
|
+
* PREAMBLE statements specifically, and the IR built from the return
|
|
5973
|
+
* expression doesn't retain a per-occurrence link back to which SOURCE
|
|
5974
|
+
* identifier fed a given `IREvent`/`IRProp` — re-deriving from syntax here
|
|
5975
|
+
* keeps the safety check self-contained rather than threading that link
|
|
5976
|
+
* through the IR builder for one caller.
|
|
5977
|
+
*
|
|
5978
|
+
* Conservative: a property-access name (`x.name`), a nested declaration's own
|
|
5979
|
+
* binding, or any other read shape (a text/attr expression, a NON-event
|
|
5980
|
+
* child prop, a call) fails this and falls through to the ordinary refusal —
|
|
5981
|
+
* this only widens the shapes that are SAFE to elide, never narrows what was
|
|
5982
|
+
* already accepted. Uses `ts.forEachChild`'s own short-circuit (a truthy
|
|
5983
|
+
* return stops the walk) rather than a separate mutable flag.
|
|
5984
|
+
*/
|
|
5985
|
+
function everyReadIsEventHandlerPropValue(name: string, bodyStatements: readonly ts.Statement[]): boolean {
|
|
5986
|
+
const findDisallowedRead = (node: ts.Node): true | undefined => {
|
|
5987
|
+
if (ts.isIdentifier(node) && node.text === name) {
|
|
5988
|
+
const isBindingName = ts.isVariableDeclaration(node.parent) && node.parent.name === node
|
|
5989
|
+
const isPropertyName = ts.isPropertyAccessExpression(node.parent) && node.parent.name === node
|
|
5990
|
+
if (!isBindingName && !isPropertyName) {
|
|
5991
|
+
const jsxExpr = node.parent
|
|
5992
|
+
const attr = jsxExpr.parent
|
|
5993
|
+
const isEventHandlerAttrValue =
|
|
5994
|
+
ts.isJsxExpression(jsxExpr) &&
|
|
5995
|
+
ts.isJsxAttribute(attr) &&
|
|
5996
|
+
attr.initializer === jsxExpr &&
|
|
5997
|
+
ts.isIdentifier(attr.name) &&
|
|
5998
|
+
isEventHandlerAttrName(attr.name.text)
|
|
5999
|
+
if (!isEventHandlerAttrValue) return true
|
|
6000
|
+
}
|
|
6001
|
+
}
|
|
6002
|
+
return ts.forEachChild(node, findDisallowedRead)
|
|
6003
|
+
}
|
|
6004
|
+
return !bodyStatements.some(findDisallowedRead)
|
|
6005
|
+
}
|
|
6006
|
+
|
|
6007
|
+
/**
|
|
6008
|
+
* Whether a JSX attribute name is an event handler (`onClick`, `onInput`,
|
|
6009
|
+
* ...) rather than a plain attribute/prop. Shared by every site in this file
|
|
6010
|
+
* that needs the same call: native-element attribute extraction (routes into
|
|
6011
|
+
* `IREvent`, never rendered HTML), the preamble-verbatim-leaf wiring guard,
|
|
6012
|
+
* and {@link everyReadIsEventHandlerPropValue}'s elision safety check — one
|
|
6013
|
+
* decision, one implementation, per CLAUDE.md.
|
|
6014
|
+
*/
|
|
6015
|
+
function isEventHandlerAttrName(name: string): boolean {
|
|
6016
|
+
return /^on[A-Z]/.test(name)
|
|
5772
6017
|
}
|
|
5773
6018
|
|
|
5774
6019
|
/** Drop the trailing separator space from the final js segment. */
|
|
@@ -6122,7 +6367,7 @@ function processAttributes(
|
|
|
6122
6367
|
// they're wired up at hydration time (delegated event registration) and
|
|
6123
6368
|
// must not leak into rendered HTML. The DOM event name is lowercase
|
|
6124
6369
|
// (`click`, not `Click`), so strip the `on` prefix and downcase.
|
|
6125
|
-
if (
|
|
6370
|
+
if (isEventHandlerAttrName(rawName)) {
|
|
6126
6371
|
if (attr.initializer && ts.isJsxExpression(attr.initializer) && attr.initializer.expression) {
|
|
6127
6372
|
const eventName = rawName.slice(2).toLowerCase()
|
|
6128
6373
|
reportJsxBranchLocalInCallback(attr.initializer.expression, ctx)
|
|
@@ -6244,8 +6489,20 @@ function getAttributeValue(attr: ts.JsxAttribute, ctx: TransformContext): AttrVa
|
|
|
6244
6489
|
return AttrValueOf.expression('undefined')
|
|
6245
6490
|
}
|
|
6246
6491
|
|
|
6247
|
-
// Check for bare signal/memo identifier (BF044)
|
|
6248
|
-
|
|
6492
|
+
// Check for bare signal/memo identifier (BF044). `getAttributeValue`
|
|
6493
|
+
// serves BOTH a DOM element's attributes and a component's props
|
|
6494
|
+
// (two call sites above, in `processElementAttributes` and
|
|
6495
|
+
// `processComponentProps`), and only the element-attribute case is
|
|
6496
|
+
// a RENDERED position — a component prop is an opaque value handed
|
|
6497
|
+
// to the component, and this codebase's Context-Provider idiom
|
|
6498
|
+
// depends on that value being an uncalled accessor (`value={{
|
|
6499
|
+
// open }}`, ui/components/ui/select/index.tsx — calling it eagerly
|
|
6500
|
+
// there would freeze the value at provider-render time and break
|
|
6501
|
+
// every consumer's reactivity). So nested descent is gated on
|
|
6502
|
+
// element-vs-component, derived from the tag this attribute lives
|
|
6503
|
+
// on; the top-level bare-identifier check stays unconditional
|
|
6504
|
+
// either way, unchanged from this diagnostic's original behaviour.
|
|
6505
|
+
checkBareSignalOrMemoIdentifier(expr, ctx, { descendNested: isRenderedElementAttribute(attr, ctx) })
|
|
6249
6506
|
|
|
6250
6507
|
// Static style object: style={{ key: 'value', ... }} → CSS string at compile time
|
|
6251
6508
|
if (attr.name.getText(ctx.sourceFile) === 'style' && ts.isObjectLiteralExpression(expr)) {
|
|
@@ -7300,53 +7557,287 @@ function processComponentProps(
|
|
|
7300
7557
|
// =============================================================================
|
|
7301
7558
|
|
|
7302
7559
|
/**
|
|
7303
|
-
*
|
|
7304
|
-
*
|
|
7560
|
+
* Is `attr` an attribute on a DOM/SVG element's opening tag, as opposed
|
|
7561
|
+
* to a prop on a component? `JsxAttributes.parent` is always the
|
|
7562
|
+
* `JsxOpeningElement`/`JsxSelfClosingElement` the attribute list belongs
|
|
7563
|
+
* to (`JsxOpeningLikeElement`); its `tagName` follows the same
|
|
7564
|
+
* uppercase-means-component convention `transformSelfClosingElement`
|
|
7565
|
+
* already uses (`isComponent = /^[A-Z]/.test(tagName)`) — lowercase or
|
|
7566
|
+
* hyphenated (a custom element) is a real DOM node, capitalised or
|
|
7567
|
+
* dotted (`Select.Provider`) is a component.
|
|
7568
|
+
*
|
|
7569
|
+
* Feeds `checkBareSignalOrMemoIdentifier`'s `descendNested` gate: a
|
|
7570
|
+
* DOM attribute value is a RENDERED position (this is genuinely what
|
|
7571
|
+
* ends up in the DOM), so a bare getter buried inside it — `style={{
|
|
7572
|
+
* color: val }}` — is exactly as much a forgotten `()` as a top-level
|
|
7573
|
+
* one. A component prop is not rendered — it's an opaque value handed
|
|
7574
|
+
* to the component — and this codebase's Context-Provider idiom
|
|
7575
|
+
* depends on that value being an UNCALLED accessor
|
|
7576
|
+
* (`ui/components/ui/select/index.tsx`'s `value={{ open, ... }}`), so
|
|
7577
|
+
* nested descent must not fire there.
|
|
7578
|
+
*/
|
|
7579
|
+
function isRenderedElementAttribute(attr: ts.JsxAttribute, ctx: TransformContext): boolean {
|
|
7580
|
+
const tagName = attr.parent.parent.tagName.getText(ctx.sourceFile)
|
|
7581
|
+
return !/^[A-Z]/.test(tagName)
|
|
7582
|
+
}
|
|
7583
|
+
|
|
7584
|
+
/**
|
|
7585
|
+
* Check for a bare signal/memo getter reference in `expr`.
|
|
7586
|
+
* Emits BF044 when a signal/memo getter is passed without calling it.
|
|
7305
7587
|
* e.g., value={count} instead of value={count()}
|
|
7588
|
+
*
|
|
7589
|
+
* The top-level check — is `expr` itself a bare reactive identifier —
|
|
7590
|
+
* always runs, unchanged from this diagnostic's original behaviour
|
|
7591
|
+
* (`value={count}`, `<Foo x={count} />` keep firing regardless of
|
|
7592
|
+
* position). `descendNested` additionally opts into walking INSIDE a
|
|
7593
|
+
* composite expression — call arguments, template-literal spans,
|
|
7594
|
+
* ternary branches (condition + both arms), array/object literal
|
|
7595
|
+
* members, binary/logical operands, parenthesized wrappers — to catch
|
|
7596
|
+
* a bare reference buried anywhere in that reachable set (#2755,
|
|
7597
|
+
* #2751: this is the single upstream gate whose shallow top-level-only
|
|
7598
|
+
* check let both downstream bugs through). Callers opt in only for
|
|
7599
|
+
* RENDERED positions (a JSX text child; a DOM element's attribute value
|
|
7600
|
+
* via `isRenderedElementAttribute`) — never for a component prop, where
|
|
7601
|
+
* a bare getter is this codebase's deliberate Context-Provider idiom
|
|
7602
|
+
* (measured: 66 such uses across `ui/components/**`/`site/**`, all
|
|
7603
|
+
* legitimate, none a forgotten `()` — widening the gate to fire there
|
|
7604
|
+
* would be a correctness regression, not a fix).
|
|
7605
|
+
*
|
|
7606
|
+
* Two structural exclusions apply whenever descending, both required
|
|
7607
|
+
* so the walk doesn't false-positive on shapes that read the name
|
|
7608
|
+
* without evaluating its signal value:
|
|
7609
|
+
* - the callee of a zero-arg call (`val()`) is the CORRECT form and
|
|
7610
|
+
* is never visited as a bare reference — only the call's arguments
|
|
7611
|
+
* (there are none) would be descended into.
|
|
7612
|
+
* - a property-access chain's object is visited but its property
|
|
7613
|
+
* NAME never is, so `ctx.val` never fires when a signal `val`
|
|
7614
|
+
* exists — the exact bug class documented in this file's
|
|
7615
|
+
* `csr-substitute.ts` sibling walker, #1100.
|
|
7616
|
+
* JSX embedded inside the checked expression (a `.map()`/`.filter()`
|
|
7617
|
+
* callback returning JSX is the common shape) is NOT this walk's
|
|
7618
|
+
* territory either: `transformNode` independently descends into that
|
|
7619
|
+
* element's own attributes and children, each re-entering this same
|
|
7620
|
+
* check on its own real value expression. Descending into it here
|
|
7621
|
+
* would be actively wrong, not just redundant — a JSX element's `name`
|
|
7622
|
+
* nodes (an attribute's NAME, a tag name) are plain `ts.Identifier`s
|
|
7623
|
+
* with no marker distinguishing them from a value read, so without
|
|
7624
|
+
* this guard an attribute like `checked={x}` gets its own NAME flagged
|
|
7625
|
+
* whenever a same-named signal is in scope (measured directly against
|
|
7626
|
+
* `checkbox-demo.tsx` while building this walk).
|
|
7627
|
+
* A nested function/arrow body that rebinds the name shadows it for
|
|
7628
|
+
* everything under that body — modeled with a local bound-name stack,
|
|
7629
|
+
* same shape as `csrSubstituteOnce`'s `boundStack`
|
|
7630
|
+
* (`ir-to-client-js/csr-substitute.ts`), which solves this exact
|
|
7631
|
+
* member-access/shadowing pair of problems for the CSR-substitution
|
|
7632
|
+
* walk. Deliberately NOT threaded through the shared `BindingScope`
|
|
7633
|
+
* service (`scope/binding-scope.ts`) — that scope answers "what does
|
|
7634
|
+
* the surrounding loop/callback bind"; this walk only needs shadowing
|
|
7635
|
+
* introduced by nested function literals inside the single expression
|
|
7636
|
+
* being checked, the same reasoning `csrSubstituteOnce` already
|
|
7637
|
+
* documents for its own local `boundStack`.
|
|
7306
7638
|
*/
|
|
7307
7639
|
function checkBareSignalOrMemoIdentifier(
|
|
7308
7640
|
expr: ts.Expression,
|
|
7309
|
-
ctx: TransformContext
|
|
7641
|
+
ctx: TransformContext,
|
|
7642
|
+
options?: { descendNested?: boolean }
|
|
7310
7643
|
): void {
|
|
7311
|
-
|
|
7312
|
-
|
|
7313
|
-
const
|
|
7644
|
+
const reactiveNames = new Map<string, 'signal' | 'memo'>()
|
|
7645
|
+
for (const signal of ctx.analyzer.signals) reactiveNames.set(signal.getter, 'signal')
|
|
7646
|
+
for (const memo of ctx.analyzer.memos) reactiveNames.set(memo.name, 'memo')
|
|
7647
|
+
if (reactiveNames.size === 0) return
|
|
7314
7648
|
|
|
7315
|
-
|
|
7316
|
-
|
|
7317
|
-
|
|
7318
|
-
|
|
7319
|
-
|
|
7320
|
-
|
|
7321
|
-
|
|
7322
|
-
|
|
7323
|
-
|
|
7324
|
-
|
|
7325
|
-
|
|
7326
|
-
|
|
7327
|
-
)
|
|
7649
|
+
const report = (id: ts.Identifier, name: string, kind: 'signal' | 'memo'): void => {
|
|
7650
|
+
const label = kind === 'signal' ? 'Signal' : 'Memo'
|
|
7651
|
+
ctx.analyzer.errors.push(
|
|
7652
|
+
createError(ErrorCodes.SIGNAL_GETTER_NOT_CALLED,
|
|
7653
|
+
getSourceLocation(id, ctx.sourceFile, ctx.filePath),
|
|
7654
|
+
{
|
|
7655
|
+
message: `${label} getter '${name}' passed without calling it`,
|
|
7656
|
+
suggestion: {
|
|
7657
|
+
message: `${label} getters must be called to read the value. Use \`${name}()\` instead of \`${name}\`.`,
|
|
7658
|
+
replacement: `${name}()`,
|
|
7659
|
+
},
|
|
7660
|
+
}
|
|
7328
7661
|
)
|
|
7662
|
+
)
|
|
7663
|
+
}
|
|
7664
|
+
|
|
7665
|
+
// Top-level check — unchanged from this diagnostic's original
|
|
7666
|
+
// position-gating (fires unconditionally, everywhere), but NOW also
|
|
7667
|
+
// respects the ambient loop/callback `BindingScope` a shadowed name
|
|
7668
|
+
// needs — a pre-existing gap (the original single-line
|
|
7669
|
+
// `if (!ts.isIdentifier(expr)) return` never consulted scope either)
|
|
7670
|
+
// that stayed invisible only because a bare top-level identifier
|
|
7671
|
+
// shadowed by a `.map(name => ...)` row param never happened to be
|
|
7672
|
+
// exercised by any prior test. `ctx.scope` is the LIVE scope at this
|
|
7673
|
+
// JSX node — already entered for the current loop row/callback frame
|
|
7674
|
+
// by the time an attribute/child expression under it is checked
|
|
7675
|
+
// (`enterLoopRow`/`enterCallback`, `scope/binding-scope.ts`) — so
|
|
7676
|
+
// `isBound` here is a shadow-guard read in exactly the sense
|
|
7677
|
+
// `spec/compiler.md`'s `BindingScope` section describes: is a signal
|
|
7678
|
+
// getter of this name shadowed HERE, not "does this expression need
|
|
7679
|
+
// its own reactive slot".
|
|
7680
|
+
if (ts.isIdentifier(expr)) {
|
|
7681
|
+
const kind = reactiveNames.get(expr.text)
|
|
7682
|
+
if (kind && !ctx.scope.isBound(expr.text)) report(expr, expr.text, kind)
|
|
7683
|
+
return
|
|
7684
|
+
}
|
|
7685
|
+
|
|
7686
|
+
if (!options?.descendNested) return
|
|
7687
|
+
|
|
7688
|
+
const boundStack: Array<Set<string>> = []
|
|
7689
|
+
const isBound = (name: string): boolean => {
|
|
7690
|
+
for (let i = boundStack.length - 1; i >= 0; i--) {
|
|
7691
|
+
if (boundStack[i].has(name)) return true
|
|
7692
|
+
}
|
|
7693
|
+
return ctx.scope.isBound(name)
|
|
7694
|
+
}
|
|
7695
|
+
|
|
7696
|
+
/**
|
|
7697
|
+
* Visit the default VALUES in a binding pattern (`const { x = count } = obj`,
|
|
7698
|
+
* `(f = count) => …`), binding each name into `bound` as it is introduced.
|
|
7699
|
+
*
|
|
7700
|
+
* `collectBindingNames` deliberately walks only NAMES — a binding name is
|
|
7701
|
+
* not a reference — but a default value is an ordinary expression, so a bare
|
|
7702
|
+
* getter can hide there. Not hypothetical: measured, such a shape emits a
|
|
7703
|
+
* module-scope `template` thunk that references a component-scope binding
|
|
7704
|
+
* and throws `ReferenceError` on CSR mount, which is #2751's mechanism —
|
|
7705
|
+
* the class this check exists to close.
|
|
7706
|
+
*
|
|
7707
|
+
* Binds as it goes because JS binds left to right WITHIN a pattern too:
|
|
7708
|
+
* `({ a, b = a })` reads the already-initialized `a`, so `b`'s default must
|
|
7709
|
+
* be checked against a scope that already contains `a`. Visiting every
|
|
7710
|
+
* default first and binding afterwards would flag `a` as a bare signal read
|
|
7711
|
+
* whenever a pattern name happens to collide with a real signal — a false
|
|
7712
|
+
* positive, which is the direction that breaks working code.
|
|
7713
|
+
*/
|
|
7714
|
+
const visitBindingDefaults = (name: ts.BindingName, bound: Set<string>): void => {
|
|
7715
|
+
if (ts.isIdentifier(name)) {
|
|
7716
|
+
bound.add(name.text)
|
|
7329
7717
|
return
|
|
7330
7718
|
}
|
|
7719
|
+
for (const el of name.elements) {
|
|
7720
|
+
if (ts.isOmittedExpression(el)) continue
|
|
7721
|
+
if (el.initializer) visit(el.initializer)
|
|
7722
|
+
visitBindingDefaults(el.name, bound)
|
|
7723
|
+
}
|
|
7331
7724
|
}
|
|
7332
7725
|
|
|
7333
|
-
|
|
7334
|
-
if (
|
|
7335
|
-
|
|
7336
|
-
|
|
7337
|
-
|
|
7338
|
-
|
|
7339
|
-
|
|
7340
|
-
|
|
7341
|
-
|
|
7342
|
-
|
|
7343
|
-
|
|
7344
|
-
|
|
7345
|
-
|
|
7346
|
-
)
|
|
7726
|
+
const collectBindingNames = (name: ts.BindingName, out: Set<string>): void => {
|
|
7727
|
+
if (ts.isIdentifier(name)) out.add(name.text)
|
|
7728
|
+
else if (ts.isObjectBindingPattern(name)) {
|
|
7729
|
+
for (const el of name.elements) collectBindingNames(el.name, out)
|
|
7730
|
+
} else if (ts.isArrayBindingPattern(name)) {
|
|
7731
|
+
for (const el of name.elements) {
|
|
7732
|
+
if (!ts.isOmittedExpression(el)) collectBindingNames(el.name, out)
|
|
7733
|
+
}
|
|
7734
|
+
}
|
|
7735
|
+
}
|
|
7736
|
+
|
|
7737
|
+
const collectBlockDeclarations = (block: ts.Block, out: Set<string>): void => {
|
|
7738
|
+
for (const stmt of block.statements) {
|
|
7739
|
+
if (ts.isVariableStatement(stmt)) {
|
|
7740
|
+
for (const decl of stmt.declarationList.declarations) collectBindingNames(decl.name, out)
|
|
7741
|
+
} else if (ts.isFunctionDeclaration(stmt) && stmt.name) {
|
|
7742
|
+
out.add(stmt.name.text)
|
|
7743
|
+
}
|
|
7744
|
+
}
|
|
7745
|
+
}
|
|
7746
|
+
|
|
7747
|
+
function visit(node: ts.Node): void {
|
|
7748
|
+
// JSX embedded inside the checked expression — see docstring.
|
|
7749
|
+
if (
|
|
7750
|
+
ts.isJsxElement(node) || ts.isJsxSelfClosingElement(node) || ts.isJsxFragment(node)
|
|
7751
|
+
) {
|
|
7347
7752
|
return
|
|
7348
7753
|
}
|
|
7754
|
+
|
|
7755
|
+
// Type positions are not value positions. `({} as { count?: unknown })`
|
|
7756
|
+
// in a rendered position would otherwise read the type literal's property
|
|
7757
|
+
// name `count` as a bare reference to a signal of the same name and refuse
|
|
7758
|
+
// valid code — a false positive, the direction that breaks working code.
|
|
7759
|
+
// Covers every type node reachable from an expression: `as`/`satisfies`
|
|
7760
|
+
// types, type assertions, and generic type arguments.
|
|
7761
|
+
if (ts.isTypeNode(node)) return
|
|
7762
|
+
|
|
7763
|
+
// Zero-arg call with bare-identifier callee (`val()`): the correct
|
|
7764
|
+
// form. The callee is never visited as a bare reference; there are
|
|
7765
|
+
// no arguments to descend into either.
|
|
7766
|
+
if (ts.isCallExpression(node) && node.arguments.length === 0 && ts.isIdentifier(node.expression)) {
|
|
7767
|
+
return
|
|
7768
|
+
}
|
|
7769
|
+
|
|
7770
|
+
// Property access: `obj.prop` — visit `obj` (a real reference),
|
|
7771
|
+
// never `prop` (a member-access tail, not a value read). #1100.
|
|
7772
|
+
if (ts.isPropertyAccessExpression(node)) {
|
|
7773
|
+
visit(node.expression)
|
|
7774
|
+
return
|
|
7775
|
+
}
|
|
7776
|
+
|
|
7777
|
+
// Object literal key: `{ X: value }` — `X` is a key, not a read.
|
|
7778
|
+
if (ts.isPropertyAssignment(node)) {
|
|
7779
|
+
if (ts.isComputedPropertyName(node.name)) visit(node.name.expression)
|
|
7780
|
+
visit(node.initializer)
|
|
7781
|
+
return
|
|
7782
|
+
}
|
|
7783
|
+
|
|
7784
|
+
// Shorthand property: `{ X }` — `X` is both key and value, so the
|
|
7785
|
+
// value side IS a bare reference.
|
|
7786
|
+
if (ts.isShorthandPropertyAssignment(node)) {
|
|
7787
|
+
if (ts.isIdentifier(node.name) && !isBound(node.name.text)) {
|
|
7788
|
+
const kind = reactiveNames.get(node.name.text)
|
|
7789
|
+
if (kind) report(node.name, node.name.text, kind)
|
|
7790
|
+
}
|
|
7791
|
+
return
|
|
7792
|
+
}
|
|
7793
|
+
|
|
7794
|
+
// Nested function/arrow: bind params + block-scoped locals declared
|
|
7795
|
+
// in its body, recurse, unbind. A name shadowed here is a different
|
|
7796
|
+
// binding, not the signal/memo of the same name.
|
|
7797
|
+
if (ts.isArrowFunction(node) || ts.isFunctionExpression(node)) {
|
|
7798
|
+
const bound = new Set<string>()
|
|
7799
|
+
// Parameters bind LEFT TO RIGHT, and each default is evaluated against
|
|
7800
|
+
// the scope as it stands at that point: `(a, b = a) => …` reads the
|
|
7801
|
+
// already-bound parameter `a`, never an outer binding of the same name
|
|
7802
|
+
// (verified against V8). So `bound` is pushed FIRST and grows as the
|
|
7803
|
+
// loop walks — each default sees only the parameters before it.
|
|
7804
|
+
boundStack.push(bound)
|
|
7805
|
+
for (const p of node.parameters) {
|
|
7806
|
+
if (p.initializer) visit(p.initializer)
|
|
7807
|
+
visitBindingDefaults(p.name, bound)
|
|
7808
|
+
}
|
|
7809
|
+
if (node.body && ts.isBlock(node.body)) collectBlockDeclarations(node.body, bound)
|
|
7810
|
+
if (node.body) visit(node.body)
|
|
7811
|
+
boundStack.pop()
|
|
7812
|
+
return
|
|
7813
|
+
}
|
|
7814
|
+
|
|
7815
|
+
// Variable declaration: the binding name itself isn't a reference;
|
|
7816
|
+
// only its initializer can be.
|
|
7817
|
+
if (ts.isVariableDeclaration(node)) {
|
|
7818
|
+
if (node.initializer) visit(node.initializer)
|
|
7819
|
+
// The names this declaration introduces shadow outer ones for any LATER
|
|
7820
|
+
// default in the same pattern (`const { a, b = a } = obj`), so they bind
|
|
7821
|
+
// into a scope of their own rather than leaking into the enclosing one.
|
|
7822
|
+
const declared = new Set<string>()
|
|
7823
|
+
boundStack.push(declared)
|
|
7824
|
+
visitBindingDefaults(node.name, declared)
|
|
7825
|
+
boundStack.pop()
|
|
7826
|
+
return
|
|
7827
|
+
}
|
|
7828
|
+
|
|
7829
|
+
// Bare identifier reference — the case this check exists for.
|
|
7830
|
+
if (ts.isIdentifier(node)) {
|
|
7831
|
+
if (isBound(node.text)) return
|
|
7832
|
+
const kind = reactiveNames.get(node.text)
|
|
7833
|
+
if (kind) report(node, node.text, kind)
|
|
7834
|
+
return
|
|
7835
|
+
}
|
|
7836
|
+
|
|
7837
|
+
ts.forEachChild(node, visit)
|
|
7349
7838
|
}
|
|
7839
|
+
|
|
7840
|
+
visit(expr)
|
|
7350
7841
|
}
|
|
7351
7842
|
|
|
7352
7843
|
/**
|
|
@@ -7532,6 +8023,49 @@ function isPropsReference(expr: string, ctx: TransformContext, visited?: Set<str
|
|
|
7532
8023
|
return false
|
|
7533
8024
|
}
|
|
7534
8025
|
|
|
8026
|
+
/**
|
|
8027
|
+
* Does this element carry a `{...spread}` that forwards the caller's
|
|
8028
|
+
* leftover props (the destructured `...rest` binding or a whole `(props)`
|
|
8029
|
+
* parameter, through any alias hop)?
|
|
8030
|
+
*
|
|
8031
|
+
* Such a spread is the one attribute source whose keys are unknowable at
|
|
8032
|
+
* compile time, so neither template can carry it: the SSR adapters render
|
|
8033
|
+
* it through their own spread helper against real request data, and the
|
|
8034
|
+
* client's registration template drops it entirely, leaving the runtime's
|
|
8035
|
+
* `applyRestAttrs` as the only thing that can apply it after a pure CSR
|
|
8036
|
+
* mount. `applyRestAttrs` addresses its element by slot id, so an element
|
|
8037
|
+
* whose only dynamic attribute source is this spread still needs one —
|
|
8038
|
+
* otherwise `init` has no patch point, and every caller-supplied attribute
|
|
8039
|
+
* is silently absent under `createComponent` while SSR and hydration look
|
|
8040
|
+
* correct (#2754).
|
|
8041
|
+
*
|
|
8042
|
+
* Deliberately narrower than "has any spread": a spread of an ordinary
|
|
8043
|
+
* object (`{...extra}`) IS statically emitted into both templates, needs
|
|
8044
|
+
* no runtime application, and must not start allocating slot ids.
|
|
8045
|
+
*/
|
|
8046
|
+
function forwardsCallerRestProps(attrs: IRAttribute[], ctx: TransformContext): boolean {
|
|
8047
|
+
for (const attr of attrs) {
|
|
8048
|
+
if (attr.value.kind !== 'spread') continue
|
|
8049
|
+
const constants = restSpreadConstantValues(ctx)
|
|
8050
|
+
for (const name of new Set([attr.value.expr, attr.value.templateExpr])) {
|
|
8051
|
+
if (!name) continue
|
|
8052
|
+
if (resolveRestSpreadOriginCore(ctx.analyzer, constants, name) !== null) return true
|
|
8053
|
+
}
|
|
8054
|
+
}
|
|
8055
|
+
return false
|
|
8056
|
+
}
|
|
8057
|
+
|
|
8058
|
+
/** `ctx.analyzer.localConstants` indexed by name, memoized per component walk. */
|
|
8059
|
+
function restSpreadConstantValues(ctx: TransformContext): ReadonlyMap<string, string | undefined> {
|
|
8060
|
+
if (ctx._restSpreadConstantValues) return ctx._restSpreadConstantValues
|
|
8061
|
+
const byName = new Map<string, string | undefined>()
|
|
8062
|
+
for (const constant of ctx.analyzer.localConstants) {
|
|
8063
|
+
if (!byName.has(constant.name)) byName.set(constant.name, constant.value)
|
|
8064
|
+
}
|
|
8065
|
+
ctx._restSpreadConstantValues = byName
|
|
8066
|
+
return byName
|
|
8067
|
+
}
|
|
8068
|
+
|
|
7535
8069
|
/**
|
|
7536
8070
|
* Check if any attributes in the list are reactive (depend on signals/memos).
|
|
7537
8071
|
* Reactive attributes need a slotId so the client JS can update them.
|