@barefootjs/jsx 0.17.1 → 0.18.0
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/adapters/interface.d.ts +20 -4
- package/dist/adapters/interface.d.ts.map +1 -1
- package/dist/adapters/parsed-expr-emitter.d.ts +3 -1
- package/dist/adapters/parsed-expr-emitter.d.ts.map +1 -1
- package/dist/analyzer-context.d.ts.map +1 -1
- package/dist/analyzer.d.ts.map +1 -1
- package/dist/augment-inherited-props.d.ts +19 -0
- package/dist/augment-inherited-props.d.ts.map +1 -1
- package/dist/compiler.d.ts.map +1 -1
- package/dist/expression-parser.d.ts +1 -0
- package/dist/expression-parser.d.ts.map +1 -1
- package/dist/index.d.ts +19 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +815 -251
- package/dist/ir-to-client-js/collect-elements.d.ts.map +1 -1
- package/dist/ir-to-client-js/compute-inlinability.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/loop.d.ts +9 -0
- package/dist/ir-to-client-js/control-flow/plan/loop.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/template-parse.d.ts +19 -0
- package/dist/ir-to-client-js/control-flow/stringify/template-parse.d.ts.map +1 -1
- package/dist/ir-to-client-js/emit-registration.d.ts +4 -2
- package/dist/ir-to-client-js/emit-registration.d.ts.map +1 -1
- package/dist/ir-to-client-js/html-template.d.ts +41 -0
- package/dist/ir-to-client-js/html-template.d.ts.map +1 -1
- package/dist/ir-to-client-js/types.d.ts +22 -1
- package/dist/ir-to-client-js/types.d.ts.map +1 -1
- package/dist/jsx-to-ir.d.ts.map +1 -1
- package/dist/loop-destructure.d.ts +55 -18
- package/dist/loop-destructure.d.ts.map +1 -1
- package/dist/lowering-registry.d.ts +13 -0
- package/dist/lowering-registry.d.ts.map +1 -1
- package/dist/relocate.d.ts +28 -0
- package/dist/relocate.d.ts.map +1 -1
- package/dist/ssr-defaults.d.ts.map +1 -1
- package/dist/types.d.ts +68 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/__snapshots__/doc-examples.test.ts.snap +284 -12
- package/src/__tests__/augment-inherited-props.test.ts +96 -0
- package/src/__tests__/compiler-runtime-contract.test.ts +11 -1
- package/src/__tests__/compiler-stress-1244.test.ts +13 -4
- package/src/__tests__/csr-substitution-safety-divergence.test.ts +137 -0
- package/src/__tests__/destructured-map-params.test.ts +11 -1
- package/src/__tests__/expression-parser.test.ts +63 -2
- package/src/__tests__/ir-sort-comparator.test.ts +261 -0
- package/src/__tests__/loop-destructure.test.ts +313 -0
- package/src/__tests__/loop-hoisted-template.test.ts +235 -0
- package/src/__tests__/props-destructuring.test.ts +110 -0
- package/src/__tests__/serialize-parsed-expr.test.ts +70 -3
- package/src/__tests__/ssr-defaults.test.ts +20 -0
- package/src/__tests__/staged-ir/11-template-primitive-registry.test.ts +231 -1
- package/src/__tests__/tagged-template-interleave.test.ts +268 -0
- package/src/__tests__/unsupported-expression.test.ts +194 -7
- package/src/adapters/interface.ts +20 -4
- package/src/adapters/parsed-expr-emitter.ts +19 -2
- package/src/analyzer-context.ts +20 -0
- package/src/analyzer.ts +74 -1
- package/src/augment-inherited-props.ts +139 -9
- package/src/compiler.ts +4 -0
- package/src/expression-parser.ts +237 -56
- package/src/index.ts +23 -1
- package/src/ir-to-client-js/collect-elements.ts +15 -1
- package/src/ir-to-client-js/compute-inlinability.ts +6 -1
- package/src/ir-to-client-js/control-flow/plan/build-loop.ts +1 -0
- package/src/ir-to-client-js/control-flow/plan/loop.ts +9 -0
- package/src/ir-to-client-js/control-flow/stringify/loop.ts +30 -8
- package/src/ir-to-client-js/control-flow/stringify/template-parse.ts +30 -0
- package/src/ir-to-client-js/emit-registration.ts +4 -2
- package/src/ir-to-client-js/html-template.ts +198 -1
- package/src/ir-to-client-js/index.ts +1 -0
- package/src/ir-to-client-js/types.ts +22 -0
- package/src/jsx-to-ir.ts +409 -24
- package/src/loop-destructure.ts +89 -36
- package/src/lowering-registry.ts +16 -0
- package/src/relocate.ts +201 -14
- package/src/ssr-defaults.ts +34 -32
- package/src/types.ts +65 -0
package/src/loop-destructure.ts
CHANGED
|
@@ -1,31 +1,60 @@
|
|
|
1
1
|
import type { IRLoop, IRNode, AttrValue, IRTemplatePart } from './types.ts'
|
|
2
2
|
|
|
3
|
-
const SIMPLE_FIELD = /^\.[A-Za-z_$][\w$]*$/
|
|
4
|
-
|
|
5
3
|
/**
|
|
6
|
-
* True when a loop's destructure param is
|
|
7
|
-
*
|
|
4
|
+
* True when a loop's `.map()` destructure param is one of the shapes this
|
|
5
|
+
* repo's per-adapter emitters can lower to a native accessor, WITHOUT
|
|
6
|
+
* relying on the JS/CSR runtime to evaluate an arbitrary residual object.
|
|
7
|
+
*
|
|
8
|
+
* Admitted (all via `LoopParamBinding.segments` — the structured, non-string
|
|
9
|
+
* accessor path built by `extractLoopParamBindings`; see #2087):
|
|
8
10
|
*
|
|
9
|
-
* `
|
|
11
|
+
* - fixed bindings at any depth / shape: `.field` (`{ id }`), array-index
|
|
12
|
+
* (`[k, v]`), and nested paths through either (`{ cells: [head] }`,
|
|
13
|
+
* `{ user: { name } }`) — anything the IR walker turned into a
|
|
14
|
+
* `segments` path. `segments` is required; a fixed binding with no
|
|
15
|
+
* `segments` is stale/foreign IR and refused conservatively.
|
|
16
|
+
* - array-rest bindings (`[first, ...tail]`): the lowered value IS the
|
|
17
|
+
* exact JS slice (`tail === item.slice(1)`), so there is no way for an
|
|
18
|
+
* adapter to observe a "wrong" value from any use of the name — no
|
|
19
|
+
* use-restriction scan needed, unlike object-rest below.
|
|
20
|
+
* - object-rest bindings (`{ id, ...rest }`) whose every use in the loop
|
|
21
|
+
* subtree is one of:
|
|
22
|
+
* (a) a member-access base (`rest.flag` / `rest?.flag` — the
|
|
23
|
+
* "read one field back off the rest" idiom), or
|
|
24
|
+
* (b) NEW: a spread attr (`{...rest}`) on an intrinsic ELEMENT node
|
|
25
|
+
* (`<li {...rest}>`) whose `expr` is *exactly* the rest name —
|
|
26
|
+
* "forward everything else onto this element" is a residual the
|
|
27
|
+
* adapters can express as "all attrs not already destructured",
|
|
28
|
+
* without ever materializing the residual object itself.
|
|
29
|
+
* A spread on a `component` or `provider` node still refuses — a
|
|
30
|
+
* component's own props lowering is a different code path with its own
|
|
31
|
+
* contract, not something this gate should reach into. A spread whose
|
|
32
|
+
* expr merely *contains* the name (`{...fn(rest)}`) still refuses too —
|
|
33
|
+
* that's an opaque call, not a literal forward.
|
|
10
34
|
*
|
|
11
|
-
*
|
|
12
|
-
* via member access** (`rest.flag`). The adapters lower the rest binding as an
|
|
13
|
-
* alias to the whole iteration item, which matches JS rest semantics only for
|
|
14
|
-
* member reads of non-consumed keys — so any other use must be refused:
|
|
35
|
+
* Still refused, and why:
|
|
15
36
|
*
|
|
16
|
-
* -
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
37
|
+
* - any OTHER use of an object-rest name (`String(rest)`, `{rest}` as a
|
|
38
|
+
* text/expression node, `onClick={() => fn(rest)}`, `{...fn(rest)}`)
|
|
39
|
+
* needs the actual residual *object*, which the non-JS template
|
|
40
|
+
* adapters can't build inline — only "read one field" (member access)
|
|
41
|
+
* and "spread all remaining attrs onto this element" (the new spread
|
|
42
|
+
* case) have an adapter-side answer that doesn't require constructing
|
|
43
|
+
* the value.
|
|
44
|
+
* - a chained `.filter().map(destructure)` needs the filter-param
|
|
45
|
+
* rewrite to retarget the synthetic per-item var; out of scope here.
|
|
46
|
+
* - a binding name (or `loop.index`) in the reserved `__bf_` namespace
|
|
47
|
+
* would collide with the synthetic per-item loop variable the SSR
|
|
48
|
+
* adapters emit (duplicate locals, ambiguous accessors).
|
|
49
|
+
* - computed property keys (`{ [k]: v }`) can't be expressed as any
|
|
50
|
+
* accessor path at all — they raise `BF025` at Phase 1 and never reach
|
|
51
|
+
* this gate (`loop.paramBindings` is simply absent).
|
|
23
52
|
*
|
|
24
|
-
* Unsupported shapes fall through to the adapters' BF104 diagnostic. The
|
|
25
|
-
* is conservative: an ambiguous use refuses (false-negative is safe —
|
|
26
|
-
* the existing build-time error rather than shipping wrong output).
|
|
53
|
+
* Unsupported shapes fall through to the adapters' BF104 diagnostic. The
|
|
54
|
+
* scan is conservative: an ambiguous use refuses (false-negative is safe —
|
|
55
|
+
* it keeps the existing build-time error rather than shipping wrong output).
|
|
27
56
|
*/
|
|
28
|
-
export function
|
|
57
|
+
export function isLowerableLoopDestructure(loop: IRLoop): boolean {
|
|
29
58
|
const bindings = loop.paramBindings
|
|
30
59
|
if (!bindings || bindings.length === 0) return false
|
|
31
60
|
if (loop.filterPredicate) return false
|
|
@@ -39,31 +68,49 @@ export function isLowerableObjectRestDestructure(loop: IRLoop): boolean {
|
|
|
39
68
|
}
|
|
40
69
|
for (const b of bindings) {
|
|
41
70
|
if (b.rest) {
|
|
42
|
-
|
|
43
|
-
|
|
71
|
+
// Both rest kinds require `segments` (the array-rest kind may
|
|
72
|
+
// legitimately have an empty one, at the loop root — `([...rest]) =>`).
|
|
73
|
+
if (!b.segments) return false
|
|
74
|
+
} else if (!b.segments || b.segments.length === 0) {
|
|
75
|
+
// Fixed binding with no structured path: stale/foreign IR built
|
|
76
|
+
// before `segments` existed. Refuse rather than guess from `path`.
|
|
44
77
|
return false
|
|
45
78
|
}
|
|
46
79
|
}
|
|
47
|
-
const
|
|
48
|
-
if (
|
|
49
|
-
return !restNamesMisused(loop,
|
|
80
|
+
const objectRestNames = bindings.filter(b => b.rest?.kind === 'object').map(b => b.name)
|
|
81
|
+
if (objectRestNames.length === 0) return true
|
|
82
|
+
return !restNamesMisused(loop, objectRestNames)
|
|
50
83
|
}
|
|
51
84
|
|
|
85
|
+
/** @deprecated Use {@link isLowerableLoopDestructure}. Kept as an alias so
|
|
86
|
+
* existing template-adapter imports keep compiling; the underlying gate now
|
|
87
|
+
* admits more shapes (array-index / nested paths / array-rest / the
|
|
88
|
+
* spread-onto-element case) than the name suggests — see #2087 Phase A.
|
|
89
|
+
*/
|
|
90
|
+
export const isLowerableObjectRestDestructure = isLowerableLoopDestructure
|
|
91
|
+
|
|
52
92
|
/**
|
|
53
93
|
* Walks the whole loop subtree (every node type, every expression-bearing
|
|
54
|
-
* field) and reports whether any rest name is referenced as
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
94
|
+
* field) and reports whether any object-rest name is referenced as
|
|
95
|
+
* something other than:
|
|
96
|
+
*
|
|
97
|
+
* - a member-access base (`rest.flag` / `rest?.flag`), or
|
|
98
|
+
* - a spread attr on an intrinsic ELEMENT node whose expr is *exactly*
|
|
99
|
+
* the rest name (`<li {...rest}>`) — the one new admitted shape.
|
|
100
|
+
*
|
|
101
|
+
* A spread expr on a `component` / `provider` node, or a spread whose expr
|
|
102
|
+
* merely contains the name, still counts as misuse (falls through to the
|
|
103
|
+
* generic bare-value-use regex below). A property of an unrelated object
|
|
104
|
+
* (`foo.rest`) is excluded by the lookbehind, same as before.
|
|
59
105
|
*
|
|
60
106
|
* The scan covers the gated loop's own non-children expression fields too
|
|
61
|
-
* (`array` / `key` / `mapPreamble` / `flatMapCallback` body) — a bare rest
|
|
62
|
-
* can surface there (e.g. `.map(({ ...rest }) => { const x = rest; … })`
|
|
107
|
+
* (`array` / `key` / `mapPreamble` / `flatMapCallback` body) — a bare rest
|
|
108
|
+
* use can surface there (e.g. `.map(({ ...rest }) => { const x = rest; … })`
|
|
63
109
|
* lifts `const x = rest` into `mapPreamble`), plus intrinsic element event
|
|
64
110
|
* handlers (`onClick={() => fn(rest)}`).
|
|
65
111
|
*/
|
|
66
112
|
function restNamesMisused(loop: IRLoop, names: string[]): boolean {
|
|
113
|
+
const nameSet = new Set(names)
|
|
67
114
|
const valueUse = names.map(
|
|
68
115
|
n => new RegExp(`(?<![\\w.$])${escapeRe(n)}(?!\\s*\\??\\.)(?![\\w$])`),
|
|
69
116
|
)
|
|
@@ -77,7 +124,13 @@ function restNamesMisused(loop: IRLoop, names: string[]): boolean {
|
|
|
77
124
|
}
|
|
78
125
|
}
|
|
79
126
|
}
|
|
80
|
-
|
|
127
|
+
// `isIntrinsicElementAttrs` distinguishes `<li {...rest}>` (element,
|
|
128
|
+
// admitted) from `<Child {...rest} />` / a provider's value prop
|
|
129
|
+
// (still refused) — same spread AttrValue shape, different node kind.
|
|
130
|
+
const attr = (v: AttrValue, isIntrinsicElementAttrs: boolean): void => {
|
|
131
|
+
if (v.kind === 'spread' && isIntrinsicElementAttrs && nameSet.has(v.expr.trim())) {
|
|
132
|
+
return
|
|
133
|
+
}
|
|
81
134
|
if (v.kind === 'expression' || v.kind === 'spread') {
|
|
82
135
|
check(v.expr)
|
|
83
136
|
check(v.templateExpr)
|
|
@@ -134,16 +187,16 @@ function restNamesMisused(loop: IRLoop, names: string[]): boolean {
|
|
|
134
187
|
if (node.alternate) visit(node.alternate)
|
|
135
188
|
break
|
|
136
189
|
case 'element':
|
|
137
|
-
node.attrs.forEach(a => attr(a.value))
|
|
190
|
+
node.attrs.forEach(a => attr(a.value, true))
|
|
138
191
|
node.events.forEach(e => check(e.handler))
|
|
139
192
|
node.children.forEach(visit)
|
|
140
193
|
break
|
|
141
194
|
case 'component':
|
|
142
|
-
node.props.forEach(p => attr(p.value))
|
|
195
|
+
node.props.forEach(p => attr(p.value, false))
|
|
143
196
|
node.children.forEach(visit)
|
|
144
197
|
break
|
|
145
198
|
case 'provider':
|
|
146
|
-
attr(node.valueProp.value)
|
|
199
|
+
attr(node.valueProp.value, false)
|
|
147
200
|
node.children.forEach(visit)
|
|
148
201
|
break
|
|
149
202
|
case 'fragment':
|
package/src/lowering-registry.ts
CHANGED
|
@@ -141,6 +141,22 @@ export function matchLoweringCall(
|
|
|
141
141
|
return null
|
|
142
142
|
}
|
|
143
143
|
|
|
144
|
+
/**
|
|
145
|
+
* Whether a `LoweringNode.helper` id is safe to splice directly into an
|
|
146
|
+
* adapter's runtime-helper naming convention (`bf_${helper}` in Go,
|
|
147
|
+
* `bf->${helper}` in Perl/Mojolicious, `$bf.${helper}` in Xslate/Kolon,
|
|
148
|
+
* `bf.${helper}` in Ruby/ERB and the Jinja-family adapters — Jinja,
|
|
149
|
+
* MiniJinja/Rust, Twig). Every adapter derives its helper's target-language
|
|
150
|
+
* name FROM the helper id (#2069) rather than keeping a per-id lookup
|
|
151
|
+
* table, so a plugin author could otherwise inject arbitrary syntax via a
|
|
152
|
+
* malformed id. A valid id is a plain identifier fragment —
|
|
153
|
+
* letters/digits/underscore, not starting with a digit — which is the only
|
|
154
|
+
* shape every target language's function/method-name grammar accepts.
|
|
155
|
+
*/
|
|
156
|
+
export function isValidHelperId(helper: string): boolean {
|
|
157
|
+
return /^[A-Za-z_][A-Za-z0-9_]*$/.test(helper)
|
|
158
|
+
}
|
|
159
|
+
|
|
144
160
|
/**
|
|
145
161
|
* Test-only: replace the registry contents wholesale. The double-underscore
|
|
146
162
|
* prefix marks it as an internal seam — tests use it to restore global state in
|
package/src/relocate.ts
CHANGED
|
@@ -19,6 +19,8 @@ import type {
|
|
|
19
19
|
TemplatePrimitiveRegistry,
|
|
20
20
|
TemplateCallAcceptor,
|
|
21
21
|
} from './adapters/interface.ts'
|
|
22
|
+
import { tsNodeToParsedExpr, type ParsedExpr } from './expression-parser.ts'
|
|
23
|
+
import { prepareLoweringMatchers, type LoweringMatcher } from './lowering-registry.ts'
|
|
22
24
|
|
|
23
25
|
export interface RelocateEnv {
|
|
24
26
|
/**
|
|
@@ -65,6 +67,33 @@ export interface RelocateEnv {
|
|
|
65
67
|
* in `templatePrimitives`. Returning true marks the call as accepted.
|
|
66
68
|
*/
|
|
67
69
|
acceptsTemplateCall?: TemplateCallAcceptor
|
|
70
|
+
/**
|
|
71
|
+
* Lowering-registry matchers (#2057) bound to this component's metadata
|
|
72
|
+
* (`prepareLoweringMatchers`), consulted by `isCallAcceptedByAdapter` as a
|
|
73
|
+
* third acceptance path alongside `templatePrimitives` /
|
|
74
|
+
* `acceptsTemplateCall`. A call recognised by ANY matcher (returns a
|
|
75
|
+
* non-null `LoweringNode`) is treated as adapter-accepted — the matcher
|
|
76
|
+
* result itself isn't used here (relocate only needs a boolean; the
|
|
77
|
+
* adapter's own render pass re-runs the matcher to get the node). Bound
|
|
78
|
+
* once per component (`buildRelocateEnvFromIR`), so checking it per call
|
|
79
|
+
* site is cheap. Undefined/empty behaves like pre-#2069 (no matcher path).
|
|
80
|
+
*/
|
|
81
|
+
loweringMatchers?: readonly LoweringMatcher[]
|
|
82
|
+
/**
|
|
83
|
+
* One-hop alias resolution table (#2069 R2): name → the bare identifier
|
|
84
|
+
* or dotted identifier-path text of its initializer, for local constants
|
|
85
|
+
* whose value is itself just another identifier/path reference
|
|
86
|
+
* (`const fmt = customSerialize`, `const f = Math.floor`) — NOT a call,
|
|
87
|
+
* object, or any other expression shape. `isCallAcceptedByAdapter` uses
|
|
88
|
+
* this to resolve a call's callee ONE hop before keying
|
|
89
|
+
* `templatePrimitives` / dispatching matchers, so a locally-aliased
|
|
90
|
+
* import or global still resolves to a registrable name. Deliberately
|
|
91
|
+
* shallow — an alias-to-alias chain (`const g = f` where `f` is itself
|
|
92
|
+
* an alias) is NOT resolved transitively; the shadow guard naturally
|
|
93
|
+
* rejects it because the one-hop target's own binding kind is
|
|
94
|
+
* `init-local`/`module-local` with no further resolution.
|
|
95
|
+
*/
|
|
96
|
+
aliasTargets?: ReadonlyMap<string, string>
|
|
68
97
|
}
|
|
69
98
|
|
|
70
99
|
export interface RelocateResult {
|
|
@@ -155,6 +184,34 @@ function decideAction(
|
|
|
155
184
|
}
|
|
156
185
|
|
|
157
186
|
if ((kind === 'init-local' || kind === 'sub-init-local') && toScope === 'template') {
|
|
187
|
+
// One-hop alias resolution (#2069 R2): `const fmt = customSerialize`
|
|
188
|
+
// is a pure identifier/path alias — its bare reference is
|
|
189
|
+
// interchangeable with the target text (checked BEFORE `env.inlinable`,
|
|
190
|
+
// which isn't populated yet when this runs from inside
|
|
191
|
+
// `computeInlinability`'s own Stage-1 classification loop; the alias
|
|
192
|
+
// table is built once at env-construction time so it's always ready).
|
|
193
|
+
// This resolves the CALLEE identifier itself (`fmt` → `customSerialize`
|
|
194
|
+
// wherever it's referenced) — a separate concern from
|
|
195
|
+
// `isCallAcceptedByAdapter`'s own one-hop resolution, which only
|
|
196
|
+
// decides whether a *call* is safe to inline, not how its callee text
|
|
197
|
+
// renders.
|
|
198
|
+
//
|
|
199
|
+
// Only substitute when the alias TARGET's own leftmost identifier is
|
|
200
|
+
// itself bare-visible at `toScope` (module-import / module-local /
|
|
201
|
+
// global) — this is what keeps the resolution to exactly one hop: an
|
|
202
|
+
// alias-of-an-alias (`const g = f` where `f` is itself just another
|
|
203
|
+
// local) has a target whose kind is `init-local`, which fails the
|
|
204
|
+
// visibility check below, so `g` falls through to the normal
|
|
205
|
+
// `env.inlinable` / fallback path instead of being blindly rewritten
|
|
206
|
+
// to the still-unreachable `f`.
|
|
207
|
+
const aliasTarget = env.aliasTargets?.get(name)
|
|
208
|
+
if (aliasTarget !== undefined) {
|
|
209
|
+
const targetLeftmost = aliasTarget.includes('.') ? aliasTarget.split('.')[0]! : aliasTarget
|
|
210
|
+
const targetKind = classify(targetLeftmost, env)
|
|
211
|
+
if (isVisibleIn(toScope, targetKind)) {
|
|
212
|
+
return { action: 'inline', rewrittenAs: aliasTarget }
|
|
213
|
+
}
|
|
214
|
+
}
|
|
158
215
|
// Try inline; fall back if not eligible.
|
|
159
216
|
const inlineForm = env.inlinable.get(name)
|
|
160
217
|
if (inlineForm !== undefined) {
|
|
@@ -341,10 +398,31 @@ export function isInlinableInTemplate(
|
|
|
341
398
|
* `obj` is a value of a particular TypeScript type, so method calls on
|
|
342
399
|
* arbitrary receivers (`props.name.toUpperCase()`) cannot be matched
|
|
343
400
|
* against type-anchored registry keys like `String.prototype.toUpperCase`.
|
|
344
|
-
* This
|
|
401
|
+
* This remains out of scope in V2 (#1187 R1) — users fall back to
|
|
345
402
|
* `/* @client *\/` for those cases.
|
|
403
|
+
*
|
|
404
|
+
* V2 (#2069): the registry is still identifier-path-only, but two things
|
|
405
|
+
* widen it beyond the raw path this function returns:
|
|
406
|
+
* 1. One-hop alias resolution (`isCallAcceptedByAdapter`) — a callee whose
|
|
407
|
+
* leftmost identifier is a local const aliasing another identifier/path
|
|
408
|
+
* (`const fmt = customSerialize`) resolves through that one hop before
|
|
409
|
+
* the path returned here is keyed against `templatePrimitives`.
|
|
410
|
+
* 2. A THIRD acceptance path alongside `templatePrimitives` /
|
|
411
|
+
* `acceptsTemplateCall`: `RelocateEnv.loweringMatchers` (#2057). A
|
|
412
|
+
* user-imported helper is never added to the string-keyed registry —
|
|
413
|
+
* it's recognised structurally by a `LoweringPlugin` (import-aware via
|
|
414
|
+
* `prepare(metadata)`), which accepts calls this function's textual
|
|
415
|
+
* path can't key at all (the plugin matches on the parsed callee/args
|
|
416
|
+
* shape, not a string).
|
|
346
417
|
*/
|
|
347
418
|
function getCalleeIdentifierPath(callee: ts.Expression): string | null {
|
|
419
|
+
// Unwrap `(expr)` — `csrSubstitute`'s AST splicer wraps every identifier
|
|
420
|
+
// substitution in parens (`(customSerialize)(x)` after a one-hop alias
|
|
421
|
+
// resolves `fmt` → `customSerialize`), so a callee that started as a bare
|
|
422
|
+
// identifier can arrive here re-parsed as a `ParenthesizedExpression`.
|
|
423
|
+
// Unwrapping is always safe: a paren never changes *which* identifier
|
|
424
|
+
// path a callee resolves to, only re-parse shape.
|
|
425
|
+
if (ts.isParenthesizedExpression(callee)) return getCalleeIdentifierPath(callee.expression)
|
|
348
426
|
if (ts.isIdentifier(callee)) return callee.text
|
|
349
427
|
if (ts.isPropertyAccessExpression(callee)) {
|
|
350
428
|
const left = getCalleeIdentifierPath(callee.expression)
|
|
@@ -363,6 +441,7 @@ function getCalleeIdentifierPath(callee: ts.Expression): string | null {
|
|
|
363
441
|
* (the shadowing case — the registry must not fire then).
|
|
364
442
|
*/
|
|
365
443
|
function getCalleeLeftmostIdentifier(callee: ts.Expression): string | null {
|
|
444
|
+
if (ts.isParenthesizedExpression(callee)) return getCalleeLeftmostIdentifier(callee.expression)
|
|
366
445
|
if (ts.isIdentifier(callee)) return callee.text
|
|
367
446
|
if (ts.isPropertyAccessExpression(callee)) {
|
|
368
447
|
return getCalleeLeftmostIdentifier(callee.expression)
|
|
@@ -386,33 +465,94 @@ const REGISTRY_SAFE_BINDING_KINDS: ReadonlySet<BindingKind> = new Set([
|
|
|
386
465
|
|
|
387
466
|
/**
|
|
388
467
|
* Whether `env`'s adapter promises it can render this call in template
|
|
389
|
-
* scope.
|
|
390
|
-
* then `acceptsTemplateCall`. Either match returns true.
|
|
468
|
+
* scope. Three independent acceptance paths, any of which is sufficient:
|
|
391
469
|
*
|
|
392
|
-
*
|
|
393
|
-
*
|
|
394
|
-
*
|
|
395
|
-
*
|
|
470
|
+
* 1. `templatePrimitives` — string-keyed identifier-path registry
|
|
471
|
+
* (`JSON.stringify`, `Math.floor`).
|
|
472
|
+
* 2. `acceptsTemplateCall` — broad predicate for full-JS-runtime adapters.
|
|
473
|
+
* 3. `loweringMatchers` (#2057/#2069) — structural `LoweringPlugin`
|
|
474
|
+
* recognition, for user-imported helpers that were never (and can
|
|
475
|
+
* never be) added to the string-keyed registry.
|
|
476
|
+
*
|
|
477
|
+
* Before any of the three checks, the callee path is resolved through
|
|
478
|
+
* `aliasTargets` ONE hop (#2069 R2): `const fmt = customSerialize; fmt(x)`
|
|
479
|
+
* keys/matches as `customSerialize`, not `fmt`. Resolution only replaces a
|
|
480
|
+
* BARE-identifier leftmost segment with a bare-identifier target — a
|
|
481
|
+
* dotted alias target (`Math.floor`) only feeds the string-keyed path
|
|
482
|
+
* (matchers structurally expect a real callee shape, not a synthesised
|
|
483
|
+
* dotted identifier).
|
|
484
|
+
*
|
|
485
|
+
* Shadow guard: rejects when the leftmost identifier of the (possibly
|
|
486
|
+
* alias-resolved) callee resolves to a local-ish binding kind. This
|
|
487
|
+
* prevents a local variable named after a registered primitive (e.g.
|
|
488
|
+
* `const JSON = props.config`) from accidentally activating the registry,
|
|
489
|
+
* and — because the guard runs AFTER alias resolution — also rejects an
|
|
490
|
+
* alias-to-alias chain (`const g = f` where `f` is itself an unresolved
|
|
491
|
+
* local) without needing special-case transitive-chain detection: the
|
|
492
|
+
* one-hop target `f` is still `init-local`/`module-local`-shadowed.
|
|
396
493
|
*/
|
|
397
494
|
function isCallAcceptedByAdapter(
|
|
398
495
|
call: ts.CallExpression,
|
|
399
496
|
env: RelocateEnv,
|
|
400
497
|
): boolean {
|
|
401
|
-
const
|
|
402
|
-
if (
|
|
498
|
+
const originalPath = getCalleeIdentifierPath(call.expression)
|
|
499
|
+
if (originalPath === null) return false
|
|
403
500
|
|
|
404
|
-
// Shadow guard. `undefined` (not in bindings) means truly global —
|
|
405
|
-
// safe; we let it through. A tracked binding must be in the safe set.
|
|
406
501
|
const leftmost = getCalleeLeftmostIdentifier(call.expression)
|
|
502
|
+
|
|
503
|
+
// One-hop alias resolution: replace the callee's LEFTMOST SEGMENT with
|
|
504
|
+
// its alias target when eligible. This resolves both a bare-identifier
|
|
505
|
+
// callee (`fmt(x)` → `customSerialize(x)`) and an aliased-namespace
|
|
506
|
+
// member callee (`m.floor(x)` → `Math.floor(x)` when `const m = Math`)
|
|
507
|
+
// — the target text is spliced in place of the leftmost segment and any
|
|
508
|
+
// remaining `.path` tail is carried over. Matcher dispatch below is
|
|
509
|
+
// narrower (bare identifiers only); the string-keyed registry path uses
|
|
510
|
+
// the full spliced text.
|
|
511
|
+
let resolvedPath = originalPath
|
|
512
|
+
let resolvedLeftmost = leftmost
|
|
407
513
|
if (leftmost !== null) {
|
|
408
|
-
const
|
|
514
|
+
const aliasTarget = env.aliasTargets?.get(leftmost)
|
|
515
|
+
if (aliasTarget !== undefined) {
|
|
516
|
+
resolvedPath =
|
|
517
|
+
originalPath === leftmost
|
|
518
|
+
? aliasTarget
|
|
519
|
+
: `${aliasTarget}${originalPath.slice(leftmost.length)}`
|
|
520
|
+
resolvedLeftmost = aliasTarget.includes('.') ? aliasTarget.split('.')[0] : aliasTarget
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
// Shadow guard, applied to the RESOLVED leftmost identifier. `undefined`
|
|
525
|
+
// (not in bindings) means truly global — safe; we let it through. A
|
|
526
|
+
// tracked binding must be in the safe set.
|
|
527
|
+
if (resolvedLeftmost !== null) {
|
|
528
|
+
const kind = env.bindings.get(resolvedLeftmost)
|
|
409
529
|
if (kind !== undefined && !REGISTRY_SAFE_BINDING_KINDS.has(kind)) {
|
|
410
530
|
return false
|
|
411
531
|
}
|
|
412
532
|
}
|
|
413
533
|
|
|
414
|
-
if (env.templatePrimitives && env.templatePrimitives[
|
|
415
|
-
if (env.acceptsTemplateCall && env.acceptsTemplateCall(
|
|
534
|
+
if (env.templatePrimitives && env.templatePrimitives[resolvedPath]) return true
|
|
535
|
+
if (env.acceptsTemplateCall && env.acceptsTemplateCall(resolvedPath)) return true
|
|
536
|
+
|
|
537
|
+
if (env.loweringMatchers && env.loweringMatchers.length > 0) {
|
|
538
|
+
const parsed = tsNodeToParsedExpr(call)
|
|
539
|
+
if (parsed.kind === 'call') {
|
|
540
|
+
// Only substitute the callee for matcher dispatch when the alias
|
|
541
|
+
// target is itself a bare identifier (no dots) — matchers expect a
|
|
542
|
+
// real parsed callee shape (`kind: 'identifier'`), not a synthesised
|
|
543
|
+
// dotted path.
|
|
544
|
+
const calleeForMatch =
|
|
545
|
+
resolvedPath !== originalPath &&
|
|
546
|
+
!resolvedPath.includes('.') &&
|
|
547
|
+
parsed.callee.kind === 'identifier'
|
|
548
|
+
? { kind: 'identifier' as const, name: resolvedPath }
|
|
549
|
+
: parsed.callee
|
|
550
|
+
for (const matcher of env.loweringMatchers) {
|
|
551
|
+
if (matcher(calleeForMatch, parsed.args)) return true
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
|
|
416
556
|
return false
|
|
417
557
|
}
|
|
418
558
|
|
|
@@ -626,6 +766,15 @@ export function buildRelocateEnvFromIR(
|
|
|
626
766
|
const env = buildRelocateEnvFromFields(metadata)
|
|
627
767
|
if (options?.templatePrimitives) env.templatePrimitives = options.templatePrimitives
|
|
628
768
|
if (options?.acceptsTemplateCall) env.acceptsTemplateCall = options.acceptsTemplateCall
|
|
769
|
+
// Bind every registered LoweringPlugin (built-in + userland, #2057) to
|
|
770
|
+
// THIS component's metadata once here, so `isCallAcceptedByAdapter`'s
|
|
771
|
+
// per-call check is just an array iteration + matcher call — no
|
|
772
|
+
// per-call import resolution (#2069). `metadata.imports` must be the
|
|
773
|
+
// REAL import list for plugin `prepare()` to resolve local names
|
|
774
|
+
// correctly; callers that reconstruct a synthetic `IRMetadata` (e.g.
|
|
775
|
+
// `compute-inlinability.ts`'s `buildEnvFromCtx`) must populate `imports`
|
|
776
|
+
// from the real component, not `[]`.
|
|
777
|
+
env.loweringMatchers = prepareLoweringMatchers(metadata)
|
|
629
778
|
return env
|
|
630
779
|
}
|
|
631
780
|
|
|
@@ -711,11 +860,49 @@ function buildRelocateEnvFromFields(src: EnvFields): RelocateEnv {
|
|
|
711
860
|
if (kind === 'prop') propsForLift.add(name)
|
|
712
861
|
}
|
|
713
862
|
|
|
863
|
+
// aliasTargets (#2069 R2): one-hop alias resolution table for
|
|
864
|
+
// `isCallAcceptedByAdapter`. A const whose FINAL resolved binding kind
|
|
865
|
+
// is `init-local` or `module-local` (i.e. not a signal/memo/prop-alias
|
|
866
|
+
// override) AND whose initializer is nothing but a bare identifier or
|
|
867
|
+
// dotted identifier path (`customSerialize`, `Math.floor` — no calls,
|
|
868
|
+
// no operators, no literals) is eligible. Eligibility is decided on the
|
|
869
|
+
// analyzer's structured `ConstantInfo.parsed` tree (never on the raw
|
|
870
|
+
// source text — see CLAUDE.md's no-regex-parsing rule); a const whose
|
|
871
|
+
// initializer didn't parse is simply not an alias. Built AFTER
|
|
872
|
+
// `bindings` is fully resolved (signals/memos have already overridden
|
|
873
|
+
// same-named consts) so eligibility reflects the FINAL kind, not a
|
|
874
|
+
// shadowed one.
|
|
875
|
+
const aliasTargets = new Map<string, string>()
|
|
876
|
+
for (const c of src.localConstants) {
|
|
877
|
+
const kind = bindings.get(c.name)
|
|
878
|
+
if (kind !== 'init-local' && kind !== 'module-local') continue
|
|
879
|
+
const target = identifierPathFromParsed(c.parsed)
|
|
880
|
+
if (target !== null) aliasTargets.set(c.name, target)
|
|
881
|
+
}
|
|
882
|
+
|
|
714
883
|
return {
|
|
715
884
|
bindings,
|
|
716
885
|
inlinable: new Map(), // populated by compute-inlinability after analyzer runs
|
|
717
886
|
propsForLift,
|
|
718
887
|
propsObjectName,
|
|
719
888
|
allowFallback: true,
|
|
889
|
+
aliasTargets,
|
|
720
890
|
}
|
|
721
891
|
}
|
|
892
|
+
|
|
893
|
+
/**
|
|
894
|
+
* The dotted identifier-path text of a parsed expression that is nothing
|
|
895
|
+
* but a bare identifier or a chain of non-computed member accesses on one
|
|
896
|
+
* (`foo`, `foo.bar`, `Math.floor`) — or null for every other shape
|
|
897
|
+
* (calls, literals, computed access, operators…). The structured
|
|
898
|
+
* counterpart of `getCalleeIdentifierPath` for `ParsedExpr` trees.
|
|
899
|
+
*/
|
|
900
|
+
function identifierPathFromParsed(expr: ParsedExpr | undefined): string | null {
|
|
901
|
+
if (!expr) return null
|
|
902
|
+
if (expr.kind === 'identifier') return expr.name
|
|
903
|
+
if (expr.kind === 'member' && !expr.computed) {
|
|
904
|
+
const object = identifierPathFromParsed(expr.object)
|
|
905
|
+
return object === null ? null : `${object}.${expr.property}`
|
|
906
|
+
}
|
|
907
|
+
return null
|
|
908
|
+
}
|
package/src/ssr-defaults.ts
CHANGED
|
@@ -93,27 +93,29 @@ export function extractSsrDefaults(metadata: IRMetadata): Record<string, SsrDefa
|
|
|
93
93
|
if (metadata.propsObjectName) propsLike.add(metadata.propsObjectName)
|
|
94
94
|
for (const p of metadata.propsParams) propsLike.add(p.name)
|
|
95
95
|
|
|
96
|
-
// Prop
|
|
97
|
-
//
|
|
98
|
-
//
|
|
99
|
-
//
|
|
100
|
-
//
|
|
101
|
-
//
|
|
102
|
-
//
|
|
103
|
-
//
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
96
|
+
// Prop entries. Both parameter forms need one entry per prop:
|
|
97
|
+
// - Destructured form (`function Foo({ variant = 'default' })`):
|
|
98
|
+
// each `propsParam` is a template-stash variable; a literal
|
|
99
|
+
// destructure default becomes the static fallback value.
|
|
100
|
+
// - Bare-props form (`function Foo(props: Props)`): template-stash
|
|
101
|
+
// adapters flatten `props.X` to the same bare scalar (`$X` — see
|
|
102
|
+
// the Mojo emitter's `member()`), NOT a `$props->{X}` hash read,
|
|
103
|
+
// so an unseeded prop the caller forgets to pass is a strict-mode
|
|
104
|
+
// compile error, not a soft `undef` (#2126). Seed every declared
|
|
105
|
+
// prop with a `null` fallback (→ undef; the template-side `// …`
|
|
106
|
+
// recompute supplies the real default, and a caller-passed prop
|
|
107
|
+
// wins via `propName`).
|
|
108
|
+
for (const p of metadata.propsParams) {
|
|
109
|
+
if (p.isRest) continue
|
|
110
|
+
if (metadata.propsObjectName === null && p.defaultValue !== undefined) {
|
|
111
|
+
const value = tryStaticEval(p.defaultValue, { bindings: {}, propsLike })
|
|
112
|
+
out[p.name] = { propName: p.name, value: resultToJsonable(value) }
|
|
113
|
+
} else {
|
|
114
|
+
// No destructure default — the template reads the prop as-is;
|
|
115
|
+
// Perl's `//` operator decides what `undef` becomes. The entry
|
|
116
|
+
// still matters so the template variable exists and consumers
|
|
117
|
+
// can supply the propName even with no static fallback.
|
|
118
|
+
out[p.name] = { propName: p.name, value: null }
|
|
117
119
|
}
|
|
118
120
|
}
|
|
119
121
|
// Rest-props bag (`...props`) — tracked separately from `propsParams`
|
|
@@ -159,17 +161,17 @@ export function extractSsrDefaults(metadata: IRMetadata): Record<string, SsrDefa
|
|
|
159
161
|
bindings[memo.name] = value
|
|
160
162
|
}
|
|
161
163
|
|
|
162
|
-
// Bare-props-arg
|
|
163
|
-
//
|
|
164
|
-
//
|
|
165
|
-
//
|
|
166
|
-
//
|
|
167
|
-
//
|
|
168
|
-
//
|
|
169
|
-
//
|
|
170
|
-
//
|
|
171
|
-
//
|
|
172
|
-
//
|
|
164
|
+
// Bare-props-arg safety net: the prop block above covers every prop
|
|
165
|
+
// *declared* on the props type, but `propsParams` can miss props read
|
|
166
|
+
// through an untyped / inline-typed `props` object. A signal / memo
|
|
167
|
+
// initializer's `props.X` read is lowered by template-stash adapters
|
|
168
|
+
// to a *bare scalar* recompute (`my $count = ($initial // 0)`, the
|
|
169
|
+
// #1297 prop-derived seeding), and that bare `$initial` has to exist
|
|
170
|
+
// in the stash or Perl's strict mode aborts the render with `Global
|
|
171
|
+
// symbol "$initial" requires explicit package name` — so also seed
|
|
172
|
+
// every prop a signal / memo initializer references. Value is `null`
|
|
173
|
+
// (→ undef): the recompute's own `?? <literal>` supplies the real
|
|
174
|
+
// fallback, and a caller-passed prop still wins via `propName`.
|
|
173
175
|
if (metadata.propsObjectName !== null) {
|
|
174
176
|
const referenced = new Set<string>()
|
|
175
177
|
for (const sig of metadata.signals) {
|