@pyreon/compiler 0.18.0 → 0.20.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/lib/analysis/index.js.html +1 -1
- package/lib/index.js +2081 -1262
- package/lib/types/index.d.ts +310 -125
- package/package.json +14 -12
- package/src/defer-inline.ts +397 -157
- package/src/index.ts +14 -2
- package/src/jsx.ts +784 -19
- package/src/load-native.ts +1 -0
- package/src/manifest.ts +280 -0
- package/src/pyreon-intercept.ts +164 -0
- package/src/react-intercept.ts +59 -0
- package/src/reactivity-lens.ts +190 -0
- package/src/tests/backend-parity-r7-r9.test.ts +91 -0
- package/src/tests/backend-prop-derived-callback-divergence.test.ts +74 -0
- package/src/tests/collapse-bail-census.test.ts +245 -0
- package/src/tests/collapse-key-source-hygiene.test.ts +88 -0
- package/src/tests/defer-inline.test.ts +209 -21
- package/src/tests/detector-tag-consistency.test.ts +2 -0
- package/src/tests/element-valued-const-child.test.ts +61 -0
- package/src/tests/falsy-child-characterization.test.ts +48 -0
- package/src/tests/malformed-input-resilience.test.ts +50 -0
- package/src/tests/manifest-snapshot.test.ts +55 -0
- package/src/tests/native-equivalence.test.ts +104 -3
- package/src/tests/partial-collapse-detector.test.ts +121 -0
- package/src/tests/partial-collapse-emit.test.ts +104 -0
- package/src/tests/partial-collapse-robustness.test.ts +53 -0
- package/src/tests/prop-derived-shadow.test.ts +96 -0
- package/src/tests/pure-call-reactive-args.test.ts +50 -0
- package/src/tests/pyreon-intercept.test.ts +189 -0
- package/src/tests/r13-callback-stmt-equivalence.test.ts +58 -0
- package/src/tests/r14-ssr-mode-parity.test.ts +51 -0
- package/src/tests/r15-elemconst-propderived.test.ts +47 -0
- package/src/tests/r19-defer-inline-robust.test.ts +54 -0
- package/src/tests/r20-backend-equivalence-sweep.test.ts +50 -0
- package/src/tests/react-intercept.test.ts +50 -2
- package/src/tests/reactivity-lens.test.ts +170 -0
- package/src/tests/rocketstyle-collapse.test.ts +208 -0
- package/src/tests/signal-autocall-shadow.test.ts +86 -0
- package/src/tests/sourcemap-fidelity.test.ts +77 -0
- package/src/tests/static-text-baking.test.ts +64 -0
- package/src/tests/transform-state-isolation.test.ts +49 -0
package/src/jsx.ts
CHANGED
|
@@ -28,10 +28,28 @@
|
|
|
28
28
|
* Implementation: Rust native binary (napi-rs) when available, JS fallback via oxc-parser.
|
|
29
29
|
*/
|
|
30
30
|
|
|
31
|
+
import MagicString from 'magic-string'
|
|
31
32
|
import { parseSync } from 'oxc-parser'
|
|
32
33
|
import { REACT_EVENT_REMAP } from './event-names'
|
|
33
34
|
import { loadNativeBinding } from './load-native'
|
|
34
35
|
|
|
36
|
+
/**
|
|
37
|
+
* V3 source map shape returned by the JS backend. Structurally exactly
|
|
38
|
+
* magic-string's `SourceMap` (a valid V3 map plus `.toString()`/`.toUrl()`),
|
|
39
|
+
* declared locally so `TransformResult` carries no hard type dependency on
|
|
40
|
+
* magic-string's exported types.
|
|
41
|
+
*/
|
|
42
|
+
export interface GeneratedSourceMap {
|
|
43
|
+
version: number
|
|
44
|
+
file?: string
|
|
45
|
+
sources: string[]
|
|
46
|
+
sourcesContent?: (string | null)[]
|
|
47
|
+
names: string[]
|
|
48
|
+
mappings: string
|
|
49
|
+
toString(): string
|
|
50
|
+
toUrl(): string
|
|
51
|
+
}
|
|
52
|
+
|
|
35
53
|
// ─── Native binary auto-detection ────────────────────────────────────────────
|
|
36
54
|
// Two-path resolution: in-tree binary first (dev mode), then per-platform
|
|
37
55
|
// npm package (production install via optionalDependencies). Falls through
|
|
@@ -39,7 +57,13 @@ import { loadNativeBinding } from './load-native'
|
|
|
39
57
|
// environment, WASM runtime like StackBlitz, missing per-platform package).
|
|
40
58
|
//
|
|
41
59
|
// See `load-native.ts` for the resolution logic.
|
|
42
|
-
type NativeTransformFn = (
|
|
60
|
+
type NativeTransformFn = (
|
|
61
|
+
code: string,
|
|
62
|
+
filename: string,
|
|
63
|
+
ssr: boolean,
|
|
64
|
+
knownSignals: string[] | null,
|
|
65
|
+
reactivityLens: boolean,
|
|
66
|
+
) => TransformResult
|
|
43
67
|
const nativeBinding = loadNativeBinding(import.meta.url)
|
|
44
68
|
const nativeTransformJsx: NativeTransformFn | null = nativeBinding
|
|
45
69
|
? (nativeBinding.transformJsx as NativeTransformFn)
|
|
@@ -60,6 +84,41 @@ export interface CompilerWarning {
|
|
|
60
84
|
| 'circular-prop-derived'
|
|
61
85
|
}
|
|
62
86
|
|
|
87
|
+
/**
|
|
88
|
+
* Reactivity-lens kinds. Each is a RECORD of a codegen decision the compiler
|
|
89
|
+
* already made — never an approximation. Positive claims (`reactive*`) are
|
|
90
|
+
* emitted ONLY where the compiler provably wrapped/tracked the span; absence
|
|
91
|
+
* of a span is "not asserted", never an implicit static claim. `static-text`
|
|
92
|
+
* is the high-precision negative: the literal `else` branch of the
|
|
93
|
+
* reactive-vs-static text decision (the "this `{x}` is baked once / dead"
|
|
94
|
+
* footgun signal when the author expected reactivity).
|
|
95
|
+
*/
|
|
96
|
+
export type ReactivityKind =
|
|
97
|
+
| 'reactive' // expression re-evaluates on signal change (_bind/_bindText/`() =>` wrap)
|
|
98
|
+
| 'reactive-prop' // component prop tracked into the child (_rp(() => …))
|
|
99
|
+
| 'reactive-attr' // DOM attribute re-applied on signal change
|
|
100
|
+
| 'static-text' // text expression baked once into the DOM, never re-renders
|
|
101
|
+
| 'hoisted-static' // JSX hoisted to module scope, never re-evaluated
|
|
102
|
+
|
|
103
|
+
export interface ReactivitySpan {
|
|
104
|
+
/** Source byte offset (start) of the spanned expression in the INPUT. */
|
|
105
|
+
start: number
|
|
106
|
+
/** Source byte offset (end). */
|
|
107
|
+
end: number
|
|
108
|
+
/** 1-based start line. */
|
|
109
|
+
line: number
|
|
110
|
+
/** 0-based start column. */
|
|
111
|
+
column: number
|
|
112
|
+
/** 1-based end line. */
|
|
113
|
+
endLine: number
|
|
114
|
+
/** 0-based end column. */
|
|
115
|
+
endColumn: number
|
|
116
|
+
/** Which codegen decision this span records. */
|
|
117
|
+
kind: ReactivityKind
|
|
118
|
+
/** Human-readable, editor-facing one-liner explaining the decision. */
|
|
119
|
+
detail: string
|
|
120
|
+
}
|
|
121
|
+
|
|
63
122
|
export interface TransformResult {
|
|
64
123
|
/** Transformed source code (JSX preserved, only expression containers modified) */
|
|
65
124
|
code: string
|
|
@@ -67,6 +126,22 @@ export interface TransformResult {
|
|
|
67
126
|
usesTemplates?: boolean
|
|
68
127
|
/** Compiler warnings for common mistakes */
|
|
69
128
|
warnings: CompilerWarning[]
|
|
129
|
+
/**
|
|
130
|
+
* Source map (V3) for the transform — present on the JS backend whenever a
|
|
131
|
+
* transformation actually occurred. `undefined` when nothing changed (the
|
|
132
|
+
* emitted code is byte-identical to the input, so no remapping is needed)
|
|
133
|
+
* and on the native backend (a Rust-side map is a scoped follow-up). The
|
|
134
|
+
* object is magic-string's `SourceMap`: it is a valid V3 map AND has
|
|
135
|
+
* `.toString()` / `.toUrl()`, so Vite/Rollup consume it directly.
|
|
136
|
+
*/
|
|
137
|
+
map?: GeneratedSourceMap
|
|
138
|
+
/**
|
|
139
|
+
* Reactivity-lens spans — populated ONLY when `TransformOptions.reactivityLens`
|
|
140
|
+
* is `true`. Additive: codegen output is byte-identical whether or not this is
|
|
141
|
+
* collected. Each span is a faithful record of a reactivity decision the
|
|
142
|
+
* compiler made for that source range. See {@link ReactivitySpan}.
|
|
143
|
+
*/
|
|
144
|
+
reactivityLens?: ReactivitySpan[]
|
|
70
145
|
}
|
|
71
146
|
|
|
72
147
|
// Props that should never be wrapped in a reactive getter
|
|
@@ -103,6 +178,73 @@ export interface TransformOptions {
|
|
|
103
178
|
* // {count} in JSX → {() => count()}
|
|
104
179
|
*/
|
|
105
180
|
knownSignals?: string[]
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Collect the {@link ReactivitySpan} sidecar (`TransformResult.reactivityLens`).
|
|
184
|
+
* Default `false`. Purely additive — the emitted `code` is byte-identical
|
|
185
|
+
* whether this is on or off (asserted by the compiler equivalence tests).
|
|
186
|
+
* The lens records reactivity decisions the compiler ALREADY makes for
|
|
187
|
+
* codegen; it never runs a second analysis pass.
|
|
188
|
+
*/
|
|
189
|
+
reactivityLens?: boolean
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* P0 — compile-time rocketstyle wrapper collapse. OFF unless the Vite
|
|
193
|
+
* plugin supplies this (opt-in `pyreon({ collapse: true })`). The plugin
|
|
194
|
+
* scans the module's imports for collapsible component candidates,
|
|
195
|
+
* SSR-resolves each literal-prop call site once (real component, light
|
|
196
|
+
* + dark), and passes the resolved `sites` map keyed by
|
|
197
|
+
* {@link rocketstyleCollapseKey}. The compiler only DETECTS the
|
|
198
|
+
* collapsible shape (bail catalogue — every dimension prop a string
|
|
199
|
+
* literal, no spread, static-text children) and EMITS the collapsed
|
|
200
|
+
* `_rsCollapse` call + the once-per-module rule injection; it never
|
|
201
|
+
* runs the rocketstyle chain itself (RFC decision 2).
|
|
202
|
+
*/
|
|
203
|
+
collapseRocketstyle?: {
|
|
204
|
+
/** Component names imported into this module that MAY collapse. */
|
|
205
|
+
candidates: Set<string>
|
|
206
|
+
/** key → resolved emission data (absent ⇒ bail, keep normal mount). */
|
|
207
|
+
sites: Map<
|
|
208
|
+
string,
|
|
209
|
+
{
|
|
210
|
+
templateHtml: string
|
|
211
|
+
lightClass: string
|
|
212
|
+
darkClass: string
|
|
213
|
+
rules: string[]
|
|
214
|
+
ruleKey: string
|
|
215
|
+
}
|
|
216
|
+
>
|
|
217
|
+
/** Live mode accessor to thread for dual-emit (RFC decision 1). */
|
|
218
|
+
mode: { name: string; source: string }
|
|
219
|
+
/** Module specifier for `_rsCollapse`. Default `@pyreon/runtime-dom`. */
|
|
220
|
+
runtimeDomSource?: string
|
|
221
|
+
/** Module specifier for the styler `sheet`. Default `@pyreon/styler`. */
|
|
222
|
+
stylerSource?: string
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Canonical key for a collapsible rocketstyle call site. The Vite plugin
|
|
228
|
+
* computes this when it resolves a site; the compiler recomputes the
|
|
229
|
+
* IDENTICAL key from the JSX node to look the resolution up. Stable
|
|
230
|
+
* ordering of props so attribute order in source doesn't change the key.
|
|
231
|
+
*/
|
|
232
|
+
export function rocketstyleCollapseKey(
|
|
233
|
+
componentName: string,
|
|
234
|
+
props: Record<string, string>,
|
|
235
|
+
childrenText: string,
|
|
236
|
+
): string {
|
|
237
|
+
const propStr = Object.keys(props)
|
|
238
|
+
.sort()
|
|
239
|
+
.map((k) => `${k}=${props[k]}`)
|
|
240
|
+
.join('\u0001')
|
|
241
|
+
const src = `${componentName}\u0000${propStr}\u0000${childrenText}`
|
|
242
|
+
let h = 2166136261
|
|
243
|
+
for (let i = 0; i < src.length; i++) {
|
|
244
|
+
h ^= src.charCodeAt(i)
|
|
245
|
+
h = Math.imul(h, 16777619)
|
|
246
|
+
}
|
|
247
|
+
return (h >>> 0).toString(36)
|
|
106
248
|
}
|
|
107
249
|
|
|
108
250
|
// ─── oxc ESTree helpers ───────────────────────────────────────────────────────
|
|
@@ -176,6 +318,215 @@ function jsxChildren(node: N): N[] {
|
|
|
176
318
|
return node.children ?? []
|
|
177
319
|
}
|
|
178
320
|
|
|
321
|
+
/**
|
|
322
|
+
* A collapsible call site found by {@link scanCollapsibleSites}.
|
|
323
|
+
* `componentName` is the LOCAL JSX tag (post-import-alias) — it MUST be
|
|
324
|
+
* what `rocketstyleCollapseKey` is computed from on BOTH sides so the
|
|
325
|
+
* plugin's resolved `sites` map keys match the compiler's lookups.
|
|
326
|
+
*/
|
|
327
|
+
export interface CollapsibleSite {
|
|
328
|
+
/** Local JSX tag name (the key + the compiler's detection use this). */
|
|
329
|
+
componentName: string
|
|
330
|
+
/** Module specifier the component was imported from (for the resolver). */
|
|
331
|
+
source: string
|
|
332
|
+
/** Imported binding name at `source` (may differ from local if aliased). */
|
|
333
|
+
importedName: string
|
|
334
|
+
/** Literal string-valued props (the only shape the slice collapses). */
|
|
335
|
+
props: Record<string, string>
|
|
336
|
+
/** Static text children (trimmed; empty ⇒ none). */
|
|
337
|
+
childrenText: string
|
|
338
|
+
/** `rocketstyleCollapseKey(componentName, props, childrenText)`. */
|
|
339
|
+
key: string
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* Build a `localName → { imported, source }` table from a module's
|
|
344
|
+
* import declarations. Only named imports (`import { X as Y }`) are
|
|
345
|
+
* relevant — the collapsible components are always named exports.
|
|
346
|
+
*/
|
|
347
|
+
function collectImportTable(program: N): Map<string, { imported: string; source: string }> {
|
|
348
|
+
const table = new Map<string, { imported: string; source: string }>()
|
|
349
|
+
for (const stmt of program.body ?? []) {
|
|
350
|
+
if (stmt.type !== 'ImportDeclaration') continue
|
|
351
|
+
const source = stmt.source?.value
|
|
352
|
+
if (typeof source !== 'string') continue
|
|
353
|
+
for (const spec of stmt.specifiers ?? []) {
|
|
354
|
+
if (spec.type !== 'ImportSpecifier') continue
|
|
355
|
+
const local = spec.local?.name
|
|
356
|
+
const imported = spec.imported?.name ?? local
|
|
357
|
+
if (typeof local === 'string') table.set(local, { imported, source })
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
return table
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* Pure detector — finds every collapsible rocketstyle call site in a
|
|
365
|
+
* module. Used by `@pyreon/vite-plugin` to know which (component, props,
|
|
366
|
+
* text) tuples to SSR-resolve. The bail catalogue here MUST stay
|
|
367
|
+
* byte-identical to `tryRocketstyleCollapse`'s (RFC decision 3): a
|
|
368
|
+
* candidate PascalCase tag whose import source is in `collapsibleSources`,
|
|
369
|
+
* every attr a plain string literal (no spread, no `{expr}`, no boolean
|
|
370
|
+
* attr), children empty or static text only. A consistency test asserts
|
|
371
|
+
* the keys this produces equal the keys the compiler looks up.
|
|
372
|
+
*/
|
|
373
|
+
export function scanCollapsibleSites(
|
|
374
|
+
code: string,
|
|
375
|
+
filename: string,
|
|
376
|
+
collapsibleSources: Set<string>,
|
|
377
|
+
): CollapsibleSite[] {
|
|
378
|
+
let program: N
|
|
379
|
+
try {
|
|
380
|
+
program = parseSync(filename, code, { sourceType: 'module', lang: getLang(filename) }).program
|
|
381
|
+
} catch {
|
|
382
|
+
return []
|
|
383
|
+
}
|
|
384
|
+
const imports = collectImportTable(program)
|
|
385
|
+
const out: CollapsibleSite[] = []
|
|
386
|
+
const visit = (node: N): void => {
|
|
387
|
+
if (!node || typeof node !== 'object') return
|
|
388
|
+
if (node.type === 'JSXElement') {
|
|
389
|
+
const tag = jsxTagName(node)
|
|
390
|
+
const imp = tag ? imports.get(tag) : undefined
|
|
391
|
+
if (
|
|
392
|
+
tag &&
|
|
393
|
+
tag.charAt(0) !== tag.charAt(0).toLowerCase() &&
|
|
394
|
+
imp &&
|
|
395
|
+
collapsibleSources.has(imp.source)
|
|
396
|
+
) {
|
|
397
|
+
const site = detectCollapsibleShape(node, tag)
|
|
398
|
+
if (site) {
|
|
399
|
+
out.push({
|
|
400
|
+
componentName: tag,
|
|
401
|
+
source: imp.source,
|
|
402
|
+
importedName: imp.imported,
|
|
403
|
+
props: site.props,
|
|
404
|
+
childrenText: site.childrenText,
|
|
405
|
+
key: rocketstyleCollapseKey(tag, site.props, site.childrenText),
|
|
406
|
+
})
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
for (const k in node) {
|
|
411
|
+
const v = node[k]
|
|
412
|
+
if (Array.isArray(v)) for (const c of v) visit(c)
|
|
413
|
+
else if (v && typeof v === 'object' && typeof v.type === 'string') visit(v)
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
visit(program)
|
|
417
|
+
return out
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* The shared bail catalogue — every attr a string literal (no spread, no
|
|
422
|
+
* `{expr}`, no boolean attr), children empty or static text. Returns the
|
|
423
|
+
* extracted {props, childrenText} or null (bail). `tryRocketstyleCollapse`
|
|
424
|
+
* inlines the identical checks; a consistency test locks them together.
|
|
425
|
+
*/
|
|
426
|
+
function detectCollapsibleShape(
|
|
427
|
+
node: N,
|
|
428
|
+
_tag: string,
|
|
429
|
+
): { props: Record<string, string>; childrenText: string } | null {
|
|
430
|
+
const props: Record<string, string> = {}
|
|
431
|
+
for (const attr of jsxAttrs(node)) {
|
|
432
|
+
if (attr.type !== 'JSXAttribute') return null // spread → bail
|
|
433
|
+
const nm = attr.name?.type === 'JSXIdentifier' ? attr.name.name : null
|
|
434
|
+
if (!nm) return null
|
|
435
|
+
const v = attr.value
|
|
436
|
+
if (!v) return null // boolean attr → bail
|
|
437
|
+
const isStr =
|
|
438
|
+
v.type === 'StringLiteral' || (v.type === 'Literal' && typeof v.value === 'string')
|
|
439
|
+
if (!isStr) return null // `{expr}` / dynamic → bail
|
|
440
|
+
props[nm] = String(v.value)
|
|
441
|
+
}
|
|
442
|
+
let childrenText = ''
|
|
443
|
+
for (const c of jsxChildren(node)) {
|
|
444
|
+
if (c.type === 'JSXText') childrenText += (c.value ?? '') as string
|
|
445
|
+
else return null // element / expression child → bail
|
|
446
|
+
}
|
|
447
|
+
return { props, childrenText: childrenText.trim() }
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/** A residual event handler peeled off a partially-collapsible site. */
|
|
451
|
+
export interface CollapsibleHandler {
|
|
452
|
+
/** JSX attribute name, e.g. `onClick`. */
|
|
453
|
+
name: string
|
|
454
|
+
/** Source span of the handler expression (the `{...}` contents). */
|
|
455
|
+
exprStart: number
|
|
456
|
+
exprEnd: number
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/**
|
|
460
|
+
* Partial-collapse detector — PR 1 of the partial-collapse spec
|
|
461
|
+
* (`.claude/plans/open-work-2026-q3.md` → #1). The `on*`-handler-only
|
|
462
|
+
* subset the bail-reason census measured at 7.8% of all
|
|
463
|
+
* `@pyreon/ui-components` call sites (`collapse-bail-census.test.ts`).
|
|
464
|
+
*
|
|
465
|
+
* It is the EXACT `detectCollapsibleShape` bail catalogue with ONE
|
|
466
|
+
* relaxation: a `{expr}`-valued attribute whose name matches `on[A-Z]…`
|
|
467
|
+
* (an event handler) does NOT bail — it is peeled into `handlers[]`
|
|
468
|
+
* instead. Handlers are orthogonal to the SSR-resolved styler class (an
|
|
469
|
+
* event binding never changes rendered CSS), so the literal-prop subset
|
|
470
|
+
* still feeds the UNCHANGED `rocketstyleCollapseKey` and the resolver's
|
|
471
|
+
* pre-resolved `templateHtml` / `lightClass` / `darkClass` are
|
|
472
|
+
* byte-identical to a full-collapse site's. The collapsed runtime node
|
|
473
|
+
* just re-attaches the residual handlers (PR 2 — `_rsCollapseH`).
|
|
474
|
+
*
|
|
475
|
+
* Every OTHER non-literal shape still bails (spread, non-handler
|
|
476
|
+
* `{expr}` prop, boolean attr, element/expression child) — conservative
|
|
477
|
+
* by construction, exactly like the full detector. Returns `null` when
|
|
478
|
+
* there are ZERO handlers so the full-collapse path stays byte-unchanged
|
|
479
|
+
* and the two detectors never both claim the same site (full-collapse
|
|
480
|
+
* sites have no handlers; partial sites have ≥1). A consistency test
|
|
481
|
+
* will lock this catalogue against the plugin scan in PR 3, mirroring
|
|
482
|
+
* the existing `detectCollapsibleShape` ↔ `scanCollapsibleSites`
|
|
483
|
+
* invariant — keys cannot drift.
|
|
484
|
+
*/
|
|
485
|
+
export function detectPartialCollapsibleShape(
|
|
486
|
+
node: N,
|
|
487
|
+
_tag: string,
|
|
488
|
+
): { props: Record<string, string>; childrenText: string; handlers: CollapsibleHandler[] } | null {
|
|
489
|
+
const props: Record<string, string> = {}
|
|
490
|
+
const handlers: CollapsibleHandler[] = []
|
|
491
|
+
for (const attr of jsxAttrs(node)) {
|
|
492
|
+
if (attr.type !== 'JSXAttribute') return null // spread → bail
|
|
493
|
+
const nm = attr.name?.type === 'JSXIdentifier' ? attr.name.name : null
|
|
494
|
+
if (!nm) return null
|
|
495
|
+
const v = attr.value
|
|
496
|
+
if (!v) return null // boolean attr → bail
|
|
497
|
+
const isStr =
|
|
498
|
+
v.type === 'StringLiteral' || (v.type === 'Literal' && typeof v.value === 'string')
|
|
499
|
+
if (isStr) {
|
|
500
|
+
props[nm] = String(v.value)
|
|
501
|
+
continue
|
|
502
|
+
}
|
|
503
|
+
// Non-literal: ONLY an `on[A-Z]…` handler in a `{expr}` container is
|
|
504
|
+
// peelable. Everything else (non-handler dynamic prop, shorthand
|
|
505
|
+
// `onClick` without a container, etc.) is a hard bail — same
|
|
506
|
+
// conservatism as the full detector.
|
|
507
|
+
if (
|
|
508
|
+
/^on[A-Z]/.test(nm) &&
|
|
509
|
+
v.type === 'JSXExpressionContainer' &&
|
|
510
|
+
v.expression &&
|
|
511
|
+
typeof v.expression.start === 'number' &&
|
|
512
|
+
typeof v.expression.end === 'number'
|
|
513
|
+
) {
|
|
514
|
+
handlers.push({ name: nm, exprStart: v.expression.start, exprEnd: v.expression.end })
|
|
515
|
+
continue
|
|
516
|
+
}
|
|
517
|
+
return null // `{expr}` non-handler / dynamic → bail
|
|
518
|
+
}
|
|
519
|
+
let childrenText = ''
|
|
520
|
+
for (const c of jsxChildren(node)) {
|
|
521
|
+
if (c.type === 'JSXText') childrenText += (c.value ?? '') as string
|
|
522
|
+
else return null // element / expression child → bail
|
|
523
|
+
}
|
|
524
|
+
// Zero handlers ⇒ this is the FULL-collapse shape; defer to
|
|
525
|
+
// `detectCollapsibleShape` so the existing path stays byte-unchanged.
|
|
526
|
+
if (handlers.length === 0) return null
|
|
527
|
+
return { props, childrenText: childrenText.trim(), handlers }
|
|
528
|
+
}
|
|
529
|
+
|
|
179
530
|
// ─── Main transform ─────────────────────────────────────────────────────────
|
|
180
531
|
|
|
181
532
|
export function transformJSX(
|
|
@@ -183,13 +534,25 @@ export function transformJSX(
|
|
|
183
534
|
filename = 'input.tsx',
|
|
184
535
|
options: TransformOptions = {},
|
|
185
536
|
): TransformResult {
|
|
537
|
+
// `collapseRocketstyle` emission lives only in the JS path (the Rust
|
|
538
|
+
// binary doesn't implement it and isn't passed the option). Force the
|
|
539
|
+
// JS path when collapse is requested so it isn't silently skipped —
|
|
540
|
+
// same pattern as `analyzeReactivity` forcing `transformJSX_JS`.
|
|
541
|
+
if (options.collapseRocketstyle) return transformJSX_JS(code, filename, options)
|
|
542
|
+
|
|
186
543
|
// Try Rust native binary first (3.7-8.2x faster).
|
|
187
544
|
// Per-call try/catch: if the native binary panics on an edge case
|
|
188
545
|
// (bad UTF-8, unexpected AST shape), fall back gracefully instead
|
|
189
546
|
// of crashing the Vite dev server.
|
|
190
547
|
if (nativeTransformJsx) {
|
|
191
548
|
try {
|
|
192
|
-
return nativeTransformJsx(
|
|
549
|
+
return nativeTransformJsx(
|
|
550
|
+
code,
|
|
551
|
+
filename,
|
|
552
|
+
options.ssr === true,
|
|
553
|
+
options.knownSignals ?? null,
|
|
554
|
+
options.reactivityLens === true,
|
|
555
|
+
)
|
|
193
556
|
} catch {
|
|
194
557
|
// Native transform failed — fall through to JS implementation
|
|
195
558
|
}
|
|
@@ -227,6 +590,25 @@ export function transformJSX_JS(
|
|
|
227
590
|
warnings.push({ message, line, column, code: warnCode })
|
|
228
591
|
}
|
|
229
592
|
|
|
593
|
+
// ── Reactivity lens (opt-in, additive — never affects `result`) ───────────
|
|
594
|
+
const collectLens = options.reactivityLens === true
|
|
595
|
+
const reactivityLens: ReactivitySpan[] = []
|
|
596
|
+
function lens(start: number, end: number, kind: ReactivityKind, detail: string): void {
|
|
597
|
+
if (!collectLens) return
|
|
598
|
+
const a = locate(start)
|
|
599
|
+
const b = locate(end)
|
|
600
|
+
reactivityLens.push({
|
|
601
|
+
start,
|
|
602
|
+
end,
|
|
603
|
+
line: a.line,
|
|
604
|
+
column: a.column,
|
|
605
|
+
endLine: b.line,
|
|
606
|
+
endColumn: b.column,
|
|
607
|
+
kind,
|
|
608
|
+
detail,
|
|
609
|
+
})
|
|
610
|
+
}
|
|
611
|
+
|
|
230
612
|
// ── Parent + children maps (built once, eliminates repeated Object.keys) ──
|
|
231
613
|
const parentMap = new WeakMap<object, N>()
|
|
232
614
|
const childrenMap = new WeakMap<object, N[]>()
|
|
@@ -280,6 +662,109 @@ export function transformJSX_JS(
|
|
|
280
662
|
let needsApplyPropsImportGlobal = false
|
|
281
663
|
let needsMountSlotImportGlobal = false
|
|
282
664
|
|
|
665
|
+
// ── P0 rocketstyle-collapse state ─────────────────────────────────────────
|
|
666
|
+
let needsCollapse = false
|
|
667
|
+
let needsCollapseH = false
|
|
668
|
+
const collapseRuleKeys = new Set<string>()
|
|
669
|
+
const collapseRules: Array<{ ruleKey: string; rules: string[] }> = []
|
|
670
|
+
|
|
671
|
+
/**
|
|
672
|
+
* Detect + collapse a literal-prop rocketstyle call site. Conservative
|
|
673
|
+
* bail catalogue (RFC decision 3): PascalCase candidate, every attr a
|
|
674
|
+
* StringLiteral (no spread, no `{expr}`, no boolean attr), children
|
|
675
|
+
* empty or a single static JSXText. The plugin must already have
|
|
676
|
+
* SSR-resolved this exact (component, props, text) tuple — an absent
|
|
677
|
+
* `sites` entry is a hard bail (covers resolver-bailed shapes,
|
|
678
|
+
* cross-package-without-data, anything uncertain). Emits ONE
|
|
679
|
+
* `_rsCollapse(tpl, light, dark, () => mode()==='dark')` (dual-emit)
|
|
680
|
+
* plus a once-per-module idempotent `injectRules`. A false negative is
|
|
681
|
+
* correct-but-slow; a false positive is wrong output — so every
|
|
682
|
+
* uncertain signal returns false.
|
|
683
|
+
*/
|
|
684
|
+
function tryRocketstyleCollapse(node: N): boolean {
|
|
685
|
+
const cfg = options.collapseRocketstyle
|
|
686
|
+
if (!cfg) return false
|
|
687
|
+
const tag = jsxTagName(node)
|
|
688
|
+
if (!tag || tag.charAt(0) === tag.charAt(0).toLowerCase()) return false
|
|
689
|
+
if (!cfg.candidates.has(tag)) return false
|
|
690
|
+
// Shared bail catalogue — IDENTICAL to scanCollapsibleSites (the
|
|
691
|
+
// plugin scans with the same predicate, so its resolved `sites`
|
|
692
|
+
// keys match these lookups exactly; no drift possible).
|
|
693
|
+
const shape = detectCollapsibleShape(node, tag)
|
|
694
|
+
if (!shape) return tryPartialCollapse(node, tag) // PR 3: on*-handler-only fallback
|
|
695
|
+
const { props, childrenText } = shape
|
|
696
|
+
const key = rocketstyleCollapseKey(tag, props, childrenText)
|
|
697
|
+
const site = cfg.sites.get(key)
|
|
698
|
+
if (!site) return false // not resolved → keep normal rocketstyle mount
|
|
699
|
+
const call =
|
|
700
|
+
`__rsCollapse(${JSON.stringify(site.templateHtml)}, ` +
|
|
701
|
+
`${JSON.stringify(site.lightClass)}, ${JSON.stringify(site.darkClass)}, ` +
|
|
702
|
+
`() => __pyrMode() === "dark")`
|
|
703
|
+
const start = node.start as number
|
|
704
|
+
const end = node.end as number
|
|
705
|
+
const parent = findParent(node)
|
|
706
|
+
const needsBraces =
|
|
707
|
+
parent && (parent.type === 'JSXElement' || parent.type === 'JSXFragment')
|
|
708
|
+
replacements.push({ start, end, text: needsBraces ? `{${call}}` : call })
|
|
709
|
+
needsCollapse = true
|
|
710
|
+
if (!collapseRuleKeys.has(site.ruleKey)) {
|
|
711
|
+
collapseRuleKeys.add(site.ruleKey)
|
|
712
|
+
collapseRules.push({ ruleKey: site.ruleKey, rules: site.rules })
|
|
713
|
+
}
|
|
714
|
+
return true
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
/**
|
|
718
|
+
* PR 3 of the partial-collapse build (open-work #1). The `on*`-handler-
|
|
719
|
+
* only fallback `tryRocketstyleCollapse` defers to when the FULL
|
|
720
|
+
* `detectCollapsibleShape` bails. Identical site-resolution contract as
|
|
721
|
+
* the full path — handlers are orthogonal to the SSR-resolved styler
|
|
722
|
+
* class, so the literal-prop subset feeds the UNCHANGED
|
|
723
|
+
* `rocketstyleCollapseKey` and the resolver's pre-resolved
|
|
724
|
+
* `templateHtml`/`lightClass`/`darkClass` are byte-identical to a
|
|
725
|
+
* full-collapse site's. The ONLY difference vs the full emit is
|
|
726
|
+
* `__rsCollapseH(...)` with a handlers object literal built from the
|
|
727
|
+
* sliced source spans `detectPartialCollapsibleShape` (PR 1) returned;
|
|
728
|
+
* the runtime helper (`_rsCollapseH`, PR 2 / #681) re-attaches them
|
|
729
|
+
* through the canonical event path. Same conservative discipline: an
|
|
730
|
+
* unresolved key, the option absent, or any non-handler non-literal
|
|
731
|
+
* shape ⇒ keep the normal mount (return false).
|
|
732
|
+
*/
|
|
733
|
+
function tryPartialCollapse(node: N, tag: string): boolean {
|
|
734
|
+
const cfg = options.collapseRocketstyle
|
|
735
|
+
if (!cfg) return false
|
|
736
|
+
const partial = detectPartialCollapsibleShape(node, tag)
|
|
737
|
+
if (!partial) return false
|
|
738
|
+
const { props, childrenText, handlers } = partial
|
|
739
|
+
const key = rocketstyleCollapseKey(tag, props, childrenText)
|
|
740
|
+
const site = cfg.sites.get(key)
|
|
741
|
+
if (!site) return false // not resolved → keep normal rocketstyle mount
|
|
742
|
+
// `{ "onClick": (<sliced expr>), … }` — each handler expression is
|
|
743
|
+
// re-emitted verbatim from its source span (paren-wrapped so an
|
|
744
|
+
// arrow / sequence expr stays a single argument).
|
|
745
|
+
const handlerObj =
|
|
746
|
+
`{ ${handlers
|
|
747
|
+
.map((h) => `${JSON.stringify(h.name)}: (${code.slice(h.exprStart, h.exprEnd)})`)
|
|
748
|
+
.join(', ')} }`
|
|
749
|
+
const call =
|
|
750
|
+
`__rsCollapseH(${JSON.stringify(site.templateHtml)}, ` +
|
|
751
|
+
`${JSON.stringify(site.lightClass)}, ${JSON.stringify(site.darkClass)}, ` +
|
|
752
|
+
`() => __pyrMode() === "dark", ${handlerObj})`
|
|
753
|
+
const start = node.start as number
|
|
754
|
+
const end = node.end as number
|
|
755
|
+
const parent = findParent(node)
|
|
756
|
+
const needsBraces =
|
|
757
|
+
parent && (parent.type === 'JSXElement' || parent.type === 'JSXFragment')
|
|
758
|
+
replacements.push({ start, end, text: needsBraces ? `{${call}}` : call })
|
|
759
|
+
needsCollapse = true
|
|
760
|
+
needsCollapseH = true
|
|
761
|
+
if (!collapseRuleKeys.has(site.ruleKey)) {
|
|
762
|
+
collapseRuleKeys.add(site.ruleKey)
|
|
763
|
+
collapseRules.push({ ruleKey: site.ruleKey, rules: site.rules })
|
|
764
|
+
}
|
|
765
|
+
return true
|
|
766
|
+
}
|
|
767
|
+
|
|
283
768
|
function maybeHoist(node: N): string | null {
|
|
284
769
|
if (
|
|
285
770
|
(node.type === 'JSXElement' || node.type === 'JSXFragment') &&
|
|
@@ -288,6 +773,12 @@ export function transformJSX_JS(
|
|
|
288
773
|
const name = `_$h${hoistIdx++}`
|
|
289
774
|
const text = code.slice(node.start as number, node.end as number)
|
|
290
775
|
hoists.push({ name, text })
|
|
776
|
+
lens(
|
|
777
|
+
node.start as number,
|
|
778
|
+
node.end as number,
|
|
779
|
+
'hoisted-static',
|
|
780
|
+
'static — hoisted once to module scope, never re-evaluated',
|
|
781
|
+
)
|
|
291
782
|
return name
|
|
292
783
|
}
|
|
293
784
|
return null
|
|
@@ -301,6 +792,7 @@ export function transformJSX_JS(
|
|
|
301
792
|
? `() => (${sliced})`
|
|
302
793
|
: `() => ${sliced}`
|
|
303
794
|
replacements.push({ start, end, text })
|
|
795
|
+
lens(start, end, 'reactive', 'live — re-evaluates whenever its signals change')
|
|
304
796
|
}
|
|
305
797
|
|
|
306
798
|
function hoistOrWrap(expr: N): void {
|
|
@@ -411,6 +903,7 @@ export function transformJSX_JS(
|
|
|
411
903
|
const inner = expr.type === 'ObjectExpression' ? `(${sliced})` : sliced
|
|
412
904
|
replacements.push({ start, end, text: `_rp(() => ${inner})` })
|
|
413
905
|
needsRpImport = true
|
|
906
|
+
lens(start, end, 'reactive-prop', 'live prop — signal reads here are tracked into the component')
|
|
414
907
|
}
|
|
415
908
|
} else {
|
|
416
909
|
hoistOrWrap(expr)
|
|
@@ -435,6 +928,15 @@ export function transformJSX_JS(
|
|
|
435
928
|
// ── Prop-derived variable tracking (collected during the single walk) ─────
|
|
436
929
|
const propsNames = new Set<string>()
|
|
437
930
|
const propDerivedVars = new Map<string, { start: number; end: number }>()
|
|
931
|
+
// Round 9 fix: names of const/let bindings whose initializer is a JSX
|
|
932
|
+
// element (`const x = <El/>`). A bare `{x}` child of such a binding must be
|
|
933
|
+
// MOUNTED, not text-coerced — pre-fix it emitted `createTextNode(x)` which
|
|
934
|
+
// stringifies the NativeItem to "[object Object]". Routing through
|
|
935
|
+
// `_mountSlot` (the general child-insert `props.children` already uses) is
|
|
936
|
+
// safe even if a same-named binding is later shadowed by a string/number:
|
|
937
|
+
// `_mountSlot` renders those correctly too — the only cost of imprecision
|
|
938
|
+
// is skipping the createTextNode fast path, never a correctness regression.
|
|
939
|
+
const elementVars = new Set<string>()
|
|
438
940
|
|
|
439
941
|
// ── Signal variable tracking (for auto-call in JSX) ──────────────────────
|
|
440
942
|
// Tracks `const x = signal(...)` declarations. In JSX expressions, bare
|
|
@@ -541,6 +1043,18 @@ export function transformJSX_JS(
|
|
|
541
1043
|
}
|
|
542
1044
|
}
|
|
543
1045
|
}
|
|
1046
|
+
// Round 9: track element-valued bindings (`const`/`let`, any depth) so
|
|
1047
|
+
// a bare `{x}` child routes to _mountSlot instead of createTextNode.
|
|
1048
|
+
// Tight: only a DIRECT JSX element/fragment initializer (optionally
|
|
1049
|
+
// parenthesized) — conditionals/calls go the existing reactive/text
|
|
1050
|
+
// paths and must not be reclassified here.
|
|
1051
|
+
if ((node.kind === 'const' || node.kind === 'let') && decl.id?.type === 'Identifier' && decl.init) {
|
|
1052
|
+
let initNode = decl.init
|
|
1053
|
+
while (initNode?.type === 'ParenthesizedExpression') initNode = initNode.expression
|
|
1054
|
+
if (initNode?.type === 'JSXElement' || initNode?.type === 'JSXFragment') {
|
|
1055
|
+
elementVars.add(decl.id.name)
|
|
1056
|
+
}
|
|
1057
|
+
}
|
|
544
1058
|
if (node.kind !== 'const') continue
|
|
545
1059
|
if (callbackDepth > 0) continue
|
|
546
1060
|
if (decl.id?.type === 'Identifier' && decl.init) {
|
|
@@ -617,13 +1131,99 @@ export function transformJSX_JS(
|
|
|
617
1131
|
const endOffset = baseOffset + text.length
|
|
618
1132
|
const idents: { start: number; end: number; name: string }[] = []
|
|
619
1133
|
|
|
1134
|
+
// ── Scope-aware shadow tracking ──────────────────────────────────────────
|
|
1135
|
+
// Prop-derived consts are only ever COLLECTED at component top level
|
|
1136
|
+
// (callbackDepth === 0), so ANY same-named binding in a deeper lexical
|
|
1137
|
+
// scope necessarily shadows it. Substituting a shadowed reference (or a
|
|
1138
|
+
// binding occurrence) miscompiles idiomatic code — e.g.
|
|
1139
|
+
// `const a = props.x; items.map(a => <li>{a}</li>)` would rewrite the
|
|
1140
|
+
// arrow PARAMETER `a` into `(props.x)` (invalid `(props.x) =>`) and the
|
|
1141
|
+
// body `{a}` (the map item) into `props.x`. The signal-auto-call pass is
|
|
1142
|
+
// already scope-aware via `shadowedSignals`; this mirrors that discipline
|
|
1143
|
+
// for the prop-derived inlining pass.
|
|
1144
|
+
const shadowed = new Set<string>()
|
|
1145
|
+
|
|
1146
|
+
/** Collect identifier names bound by a pattern (params / declarators). */
|
|
1147
|
+
function patternBindingNames(p: N, out: string[]): void {
|
|
1148
|
+
if (!p) return
|
|
1149
|
+
switch (p.type) {
|
|
1150
|
+
case 'Identifier':
|
|
1151
|
+
out.push(p.name)
|
|
1152
|
+
break
|
|
1153
|
+
case 'ObjectPattern':
|
|
1154
|
+
for (const pr of p.properties ?? []) {
|
|
1155
|
+
if (pr.type === 'RestElement') patternBindingNames(pr.argument, out)
|
|
1156
|
+
else patternBindingNames(pr.value ?? pr.key, out)
|
|
1157
|
+
}
|
|
1158
|
+
break
|
|
1159
|
+
case 'ArrayPattern':
|
|
1160
|
+
for (const el of p.elements ?? []) patternBindingNames(el, out)
|
|
1161
|
+
break
|
|
1162
|
+
case 'AssignmentPattern':
|
|
1163
|
+
patternBindingNames(p.left, out)
|
|
1164
|
+
break
|
|
1165
|
+
case 'RestElement':
|
|
1166
|
+
patternBindingNames(p.argument, out)
|
|
1167
|
+
break
|
|
1168
|
+
}
|
|
1169
|
+
}
|
|
1170
|
+
|
|
1171
|
+
/**
|
|
1172
|
+
* Prop-derived names bound by `node` FOR ITS OWN SUBTREE (block-accurate
|
|
1173
|
+
* lexical scoping). Excludes the prop-derived const's own defining
|
|
1174
|
+
* declaration (matched by init span) so the binding we inline FROM is
|
|
1175
|
+
* never mistaken for a shadow of itself.
|
|
1176
|
+
*/
|
|
1177
|
+
function scopeBoundPropDerived(node: N): string[] {
|
|
1178
|
+
const out: string[] = []
|
|
1179
|
+
const t = node.type
|
|
1180
|
+
const declNames = (declNode: N): void => {
|
|
1181
|
+
for (const d of declNode.declarations ?? []) {
|
|
1182
|
+
// The prop-derived defining declaration is NOT a shadow.
|
|
1183
|
+
if (d.id?.type === 'Identifier' && propDerivedVars.has(d.id.name)) {
|
|
1184
|
+
const span = propDerivedVars.get(d.id.name)!
|
|
1185
|
+
if (d.init && (d.init.start as number) === span.start) continue
|
|
1186
|
+
}
|
|
1187
|
+
patternBindingNames(d.id, out)
|
|
1188
|
+
}
|
|
1189
|
+
}
|
|
1190
|
+
if (
|
|
1191
|
+
t === 'ArrowFunctionExpression' ||
|
|
1192
|
+
t === 'FunctionExpression' ||
|
|
1193
|
+
t === 'FunctionDeclaration'
|
|
1194
|
+
) {
|
|
1195
|
+
for (const p of node.params ?? []) patternBindingNames(p, out)
|
|
1196
|
+
} else if (t === 'CatchClause') {
|
|
1197
|
+
patternBindingNames(node.param, out)
|
|
1198
|
+
} else if (t === 'ForStatement') {
|
|
1199
|
+
if (node.init?.type === 'VariableDeclaration') declNames(node.init)
|
|
1200
|
+
} else if (t === 'ForInStatement' || t === 'ForOfStatement') {
|
|
1201
|
+
if (node.left?.type === 'VariableDeclaration') declNames(node.left)
|
|
1202
|
+
} else if (t === 'BlockStatement' || t === 'Program' || t === 'StaticBlock') {
|
|
1203
|
+
const stmts = node.body ?? node.statements
|
|
1204
|
+
if (Array.isArray(stmts)) {
|
|
1205
|
+
for (const s of stmts) {
|
|
1206
|
+
if (s.type === 'VariableDeclaration') declNames(s)
|
|
1207
|
+
else if (s.type === 'FunctionDeclaration' && s.id?.type === 'Identifier') out.push(s.id.name)
|
|
1208
|
+
else if (s.type === 'ClassDeclaration' && s.id?.type === 'Identifier') out.push(s.id.name)
|
|
1209
|
+
}
|
|
1210
|
+
}
|
|
1211
|
+
}
|
|
1212
|
+
return out.filter((n) => propDerivedVars.has(n))
|
|
1213
|
+
}
|
|
1214
|
+
|
|
620
1215
|
// Walk the AST to find identifiers in the span, passing parent context
|
|
621
1216
|
// to skip non-reference positions (property names, declarations, etc.)
|
|
1217
|
+
// and a lexical shadow set so a same-named inner binding is never inlined.
|
|
622
1218
|
function findIdents(node: N, parent: N | null): void {
|
|
623
1219
|
const nodeStart = node.start as number
|
|
624
1220
|
const nodeEnd = node.end as number
|
|
625
1221
|
if (nodeStart >= endOffset || nodeEnd <= baseOffset) return
|
|
626
|
-
if (
|
|
1222
|
+
if (
|
|
1223
|
+
node.type === 'Identifier' &&
|
|
1224
|
+
propDerivedVars.has(node.name) &&
|
|
1225
|
+
!shadowed.has(node.name)
|
|
1226
|
+
) {
|
|
627
1227
|
if (parent) {
|
|
628
1228
|
if (parent.type === 'MemberExpression' && parent.property === node && !parent.computed) { /* skip */ }
|
|
629
1229
|
else if (parent.type === 'VariableDeclarator' && parent.id === node) { /* skip */ }
|
|
@@ -636,7 +1236,12 @@ export function transformJSX_JS(
|
|
|
636
1236
|
idents.push({ start: nodeStart, end: nodeEnd, name: node.name })
|
|
637
1237
|
}
|
|
638
1238
|
}
|
|
1239
|
+
// Names this node binds for its subtree shadow the top-level prop-derived
|
|
1240
|
+
// const within that subtree (and the binding occurrence itself).
|
|
1241
|
+
const introduced = scopeBoundPropDerived(node).filter((n) => !shadowed.has(n))
|
|
1242
|
+
for (const n of introduced) shadowed.add(n)
|
|
639
1243
|
forEachChildFast(node, (child) => findIdents(child, node))
|
|
1244
|
+
for (const n of introduced) shadowed.delete(n)
|
|
640
1245
|
}
|
|
641
1246
|
findIdents(program, null)
|
|
642
1247
|
|
|
@@ -767,6 +1372,11 @@ export function transformJSX_JS(
|
|
|
767
1372
|
|
|
768
1373
|
// ── JSX processing (was pass 3) ──
|
|
769
1374
|
if (node.type === 'JSXElement') {
|
|
1375
|
+
if (tryRocketstyleCollapse(node)) {
|
|
1376
|
+
// Collapsed to _rsCollapse — children are baked into the SSR-
|
|
1377
|
+
// resolved template; do not recurse into the subtree.
|
|
1378
|
+
return
|
|
1379
|
+
}
|
|
770
1380
|
if (!isSelfClosing(node) && tryTemplateEmit(node)) {
|
|
771
1381
|
// Template emitted — don't recurse into this subtree (JSXElement is never a function)
|
|
772
1382
|
return
|
|
@@ -803,22 +1413,35 @@ export function transformJSX_JS(
|
|
|
803
1413
|
|
|
804
1414
|
walkNode(program)
|
|
805
1415
|
|
|
806
|
-
if (replacements.length === 0 && hoists.length === 0)
|
|
1416
|
+
if (replacements.length === 0 && hoists.length === 0) {
|
|
1417
|
+
return collectLens ? { code, warnings, reactivityLens } : { code, warnings }
|
|
1418
|
+
}
|
|
807
1419
|
|
|
808
1420
|
replacements.sort((a, b) => a.start - b.start)
|
|
809
|
-
|
|
810
|
-
|
|
1421
|
+
// R12 fix: apply the disjoint, sorted {start,end,text} edits through
|
|
1422
|
+
// MagicString instead of manual slice/join. `toString()` is byte-identical
|
|
1423
|
+
// to the old concatenation (the full 1200-test suite + native-equivalence
|
|
1424
|
+
// assert exact emitted strings), but `generateMap()` now yields a correct
|
|
1425
|
+
// V3 source map — the previous transform emitted none AND shifted line
|
|
1426
|
+
// counts (template emission expands one-line JSX into a multi-line _tpl
|
|
1427
|
+
// factory), so every stack frame / breakpoint in a Pyreon component
|
|
1428
|
+
// mislocated app-wide.
|
|
1429
|
+
const s = new MagicString(code)
|
|
811
1430
|
for (const r of replacements) {
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
outPos = r.end
|
|
1431
|
+
if (r.start === r.end) s.appendLeft(r.start, r.text)
|
|
1432
|
+
else s.update(r.start, r.end, r.text)
|
|
815
1433
|
}
|
|
816
|
-
|
|
817
|
-
|
|
1434
|
+
|
|
1435
|
+
// Build the generated preamble (hoists + auto-imports + collapse prologue)
|
|
1436
|
+
// in the SAME final top-to-bottom order the previous chained `X + output`
|
|
1437
|
+
// produced, then `prepend` it ONCE. magic-string's prepend shifts every
|
|
1438
|
+
// source mapping down by the preamble's line count, so original positions
|
|
1439
|
+
// resolve to the correct OUTPUT lines despite the inserted preamble — the
|
|
1440
|
+
// exact line-shift R12 measured. Innermost (closest to code) first.
|
|
1441
|
+
let preamble = ''
|
|
818
1442
|
|
|
819
1443
|
if (hoists.length > 0) {
|
|
820
|
-
|
|
821
|
-
output = preamble + output
|
|
1444
|
+
preamble = hoists.map((h) => `const ${h.name} = /*@__PURE__*/ ${h.text}\n`).join('') + preamble
|
|
822
1445
|
}
|
|
823
1446
|
|
|
824
1447
|
if (needsTplImport) {
|
|
@@ -830,19 +1453,53 @@ export function transformJSX_JS(
|
|
|
830
1453
|
const reactivityImports = needsBindImportGlobal
|
|
831
1454
|
? `\nimport { _bind } from "@pyreon/reactivity";`
|
|
832
1455
|
: ''
|
|
833
|
-
|
|
1456
|
+
preamble =
|
|
834
1457
|
`import { ${runtimeDomImports.join(', ')} } from "@pyreon/runtime-dom";${reactivityImports}\n` +
|
|
835
|
-
|
|
1458
|
+
preamble
|
|
836
1459
|
}
|
|
837
1460
|
|
|
838
1461
|
if (needsRpImport || needsWrapSpreadImport) {
|
|
839
1462
|
const coreImports: string[] = []
|
|
840
1463
|
if (needsRpImport) coreImports.push('_rp')
|
|
841
1464
|
if (needsWrapSpreadImport) coreImports.push('_wrapSpread')
|
|
842
|
-
|
|
1465
|
+
preamble = `import { ${coreImports.join(', ')} } from "@pyreon/core";\n` + preamble
|
|
843
1466
|
}
|
|
844
1467
|
|
|
845
|
-
|
|
1468
|
+
if (needsCollapse) {
|
|
1469
|
+
const cfg = options.collapseRocketstyle!
|
|
1470
|
+
const rd = cfg.runtimeDomSource ?? '@pyreon/runtime-dom'
|
|
1471
|
+
const st = cfg.stylerSource ?? '@pyreon/styler'
|
|
1472
|
+
// One idempotent injectRules per distinct rule bundle — keyed by the
|
|
1473
|
+
// resolver's FNV so a re-eval (HMR) or another module's identical
|
|
1474
|
+
// bundle is a no-op (styler dedupes by key). Runs at module-eval,
|
|
1475
|
+
// before any collapsed site mounts, so the sheet is populated
|
|
1476
|
+
// without a prior runtime mount of the real component.
|
|
1477
|
+
const inj = collapseRules
|
|
1478
|
+
.map(
|
|
1479
|
+
(r) =>
|
|
1480
|
+
`__rsSheet.injectRules(${JSON.stringify(r.rules)},${JSON.stringify(r.ruleKey)});`,
|
|
1481
|
+
)
|
|
1482
|
+
.join('')
|
|
1483
|
+
preamble =
|
|
1484
|
+
`import { _rsCollapse as __rsCollapse${needsCollapseH ? ', _rsCollapseH as __rsCollapseH' : ''} } from "${rd}";\n` +
|
|
1485
|
+
`import { sheet as __rsSheet } from "${st}";\n` +
|
|
1486
|
+
`import { ${cfg.mode.name} as __pyrMode } from "${cfg.mode.source}";\n` +
|
|
1487
|
+
`${inj}\n` +
|
|
1488
|
+
preamble
|
|
1489
|
+
}
|
|
1490
|
+
|
|
1491
|
+
if (preamble) s.prepend(preamble)
|
|
1492
|
+
|
|
1493
|
+
const output = s.toString()
|
|
1494
|
+
const map = s.generateMap({
|
|
1495
|
+
source: filename,
|
|
1496
|
+
includeContent: true,
|
|
1497
|
+
hires: true,
|
|
1498
|
+
}) as unknown as GeneratedSourceMap
|
|
1499
|
+
|
|
1500
|
+
return collectLens
|
|
1501
|
+
? { code: output, usesTemplates: needsTplImport, warnings, map, reactivityLens }
|
|
1502
|
+
: { code: output, usesTemplates: needsTplImport, warnings, map }
|
|
846
1503
|
|
|
847
1504
|
// ── Template emission helpers ─────────────────────────────────────────────
|
|
848
1505
|
|
|
@@ -1017,6 +1674,12 @@ export function transformJSX_JS(
|
|
|
1017
1674
|
bindLines.push(attrSetter(htmlAttrName, varName, expr))
|
|
1018
1675
|
return
|
|
1019
1676
|
}
|
|
1677
|
+
lens(
|
|
1678
|
+
exprNode.start as number,
|
|
1679
|
+
exprNode.end as number,
|
|
1680
|
+
'reactive-attr',
|
|
1681
|
+
`live attribute — \`${htmlAttrName}\` re-applies whenever its signals change`,
|
|
1682
|
+
)
|
|
1020
1683
|
const directRef = tryDirectSignalRef(exprNode)
|
|
1021
1684
|
if (directRef) {
|
|
1022
1685
|
needsBindDirectImport = true
|
|
@@ -1214,16 +1877,36 @@ export function transformJSX_JS(
|
|
|
1214
1877
|
}
|
|
1215
1878
|
const needsPlaceholder = useMixed || useMultiExpr
|
|
1216
1879
|
const { expr, isReactive } = unwrapAccessor(child.expression)
|
|
1217
|
-
|
|
1880
|
+
// Round 9 fix: a bare `{el}` where `el` is an element-valued binding
|
|
1881
|
+
// (`const el = <X/>`) must be MOUNTED via _mountSlot, not text-coerced
|
|
1882
|
+
// via createTextNode (which stringifies the NativeItem). Same emission
|
|
1883
|
+
// as the children-slot path; _mountSlot handles every child type.
|
|
1884
|
+
const isElementValuedIdent =
|
|
1885
|
+
(child.expression?.type === 'Identifier' && elementVars.has(child.expression.name)) ||
|
|
1886
|
+
(!isReactive && /^[A-Za-z_$][\w$]*$/.test(expr) && elementVars.has(expr))
|
|
1887
|
+
if (isChildrenExpression(child.expression, expr) || isElementValuedIdent) {
|
|
1218
1888
|
needsMountSlotImport = true
|
|
1219
1889
|
const placeholder = `${parentRef}.childNodes[${childNodeIdx}]`
|
|
1220
1890
|
const d = nextDisp()
|
|
1221
1891
|
bindLines.push(`const ${d} = _mountSlot(${expr}, ${parentRef}, ${placeholder})`)
|
|
1222
1892
|
return '<!>'
|
|
1223
1893
|
}
|
|
1894
|
+
const cx = child.expression
|
|
1224
1895
|
if (isReactive) {
|
|
1896
|
+
lens(
|
|
1897
|
+
cx.start as number,
|
|
1898
|
+
cx.end as number,
|
|
1899
|
+
'reactive',
|
|
1900
|
+
'live — this text re-renders whenever its signals change',
|
|
1901
|
+
)
|
|
1225
1902
|
return emitReactiveTextChild(expr, child.expression, varName, parentRef, childNodeIdx, needsPlaceholder)
|
|
1226
1903
|
}
|
|
1904
|
+
lens(
|
|
1905
|
+
cx.start as number,
|
|
1906
|
+
cx.end as number,
|
|
1907
|
+
'static-text',
|
|
1908
|
+
'baked once into the DOM — never re-renders (no signal read here)',
|
|
1909
|
+
)
|
|
1227
1910
|
return emitStaticTextChild(expr, varName, parentRef, childNodeIdx, needsPlaceholder)
|
|
1228
1911
|
}
|
|
1229
1912
|
|
|
@@ -1360,14 +2043,95 @@ export function transformJSX_JS(
|
|
|
1360
2043
|
|
|
1361
2044
|
/** Auto-insert () after signal variable references in the expression source.
|
|
1362
2045
|
* Uses the AST to find exact Identifier positions — never scans raw text. */
|
|
2046
|
+
// Recursively collect identifier names bound by a pattern (params /
|
|
2047
|
+
// declarators). Self-contained twin of resolveIdentifiersInText's
|
|
2048
|
+
// `patternBindingNames` (different closure scope; kept local to avoid a
|
|
2049
|
+
// risky shared-helper hoist).
|
|
2050
|
+
function sigPatternNames(p: N, out: string[]): void {
|
|
2051
|
+
if (!p) return
|
|
2052
|
+
switch (p.type) {
|
|
2053
|
+
case 'Identifier':
|
|
2054
|
+
out.push(p.name)
|
|
2055
|
+
break
|
|
2056
|
+
case 'ObjectPattern':
|
|
2057
|
+
for (const pr of p.properties ?? []) {
|
|
2058
|
+
if (pr.type === 'RestElement') sigPatternNames(pr.argument, out)
|
|
2059
|
+
else sigPatternNames(pr.value ?? pr.key, out)
|
|
2060
|
+
}
|
|
2061
|
+
break
|
|
2062
|
+
case 'ArrayPattern':
|
|
2063
|
+
for (const el of p.elements ?? []) sigPatternNames(el, out)
|
|
2064
|
+
break
|
|
2065
|
+
case 'AssignmentPattern':
|
|
2066
|
+
sigPatternNames(p.left, out)
|
|
2067
|
+
break
|
|
2068
|
+
case 'RestElement':
|
|
2069
|
+
sigPatternNames(p.argument, out)
|
|
2070
|
+
break
|
|
2071
|
+
}
|
|
2072
|
+
}
|
|
2073
|
+
|
|
2074
|
+
// Signal names a scope-introducing node binds FOR ITS OWN SUBTREE
|
|
2075
|
+
// (block-accurate lexical scoping). Mirrors scopeBoundPropDerived but
|
|
2076
|
+
// against `signalVars` — a same-named inner binding (callback param,
|
|
2077
|
+
// nested const, catch/loop var) shadows the signal and must NOT be
|
|
2078
|
+
// auto-called (doing so emits `paramValue()` → runtime TypeError).
|
|
2079
|
+
function scopeBoundSignals(node: N): string[] {
|
|
2080
|
+
const out: string[] = []
|
|
2081
|
+
const t = node.type
|
|
2082
|
+
const declNames = (declNode: N): void => {
|
|
2083
|
+
for (const d of declNode.declarations ?? []) {
|
|
2084
|
+
// A `const x = signal(...)` re-declaration is itself a signal, not a
|
|
2085
|
+
// shadow — leave it for the normal signalVars path.
|
|
2086
|
+
if (d.id?.type === 'Identifier' && d.init && isSignalCall(d.init)) continue
|
|
2087
|
+
sigPatternNames(d.id, out)
|
|
2088
|
+
}
|
|
2089
|
+
}
|
|
2090
|
+
if (
|
|
2091
|
+
t === 'ArrowFunctionExpression' ||
|
|
2092
|
+
t === 'FunctionExpression' ||
|
|
2093
|
+
t === 'FunctionDeclaration'
|
|
2094
|
+
) {
|
|
2095
|
+
for (const p of node.params ?? []) sigPatternNames(p, out)
|
|
2096
|
+
} else if (t === 'CatchClause') {
|
|
2097
|
+
sigPatternNames(node.param, out)
|
|
2098
|
+
} else if (t === 'ForStatement') {
|
|
2099
|
+
if (node.init?.type === 'VariableDeclaration') declNames(node.init)
|
|
2100
|
+
} else if (t === 'ForInStatement' || t === 'ForOfStatement') {
|
|
2101
|
+
if (node.left?.type === 'VariableDeclaration') declNames(node.left)
|
|
2102
|
+
} else if (t === 'BlockStatement' || t === 'StaticBlock') {
|
|
2103
|
+
const stmts = node.body ?? node.statements
|
|
2104
|
+
if (Array.isArray(stmts)) {
|
|
2105
|
+
for (const s of stmts) {
|
|
2106
|
+
if (s.type === 'VariableDeclaration') declNames(s)
|
|
2107
|
+
else if (s.type === 'FunctionDeclaration' && s.id?.type === 'Identifier') out.push(s.id.name)
|
|
2108
|
+
else if (s.type === 'ClassDeclaration' && s.id?.type === 'Identifier') out.push(s.id.name)
|
|
2109
|
+
}
|
|
2110
|
+
}
|
|
2111
|
+
}
|
|
2112
|
+
return out.filter((n) => signalVars.has(n))
|
|
2113
|
+
}
|
|
2114
|
+
|
|
1363
2115
|
function autoCallSignals(text: string, expr: N): string {
|
|
1364
2116
|
const start = expr.start as number
|
|
1365
2117
|
// Collect signal identifier positions that need auto-calling
|
|
1366
2118
|
const idents: { start: number; end: number }[] = []
|
|
2119
|
+
// Local lexical shadow set — a signal-named binding introduced INSIDE
|
|
2120
|
+
// the rewritten expression (callback param, nested const, …) is NOT the
|
|
2121
|
+
// signal and must not get `()` (R11: scope-blind rewrite emitted
|
|
2122
|
+
// `({x}) => <li>{x()}</li>` → `1()` runtime crash).
|
|
2123
|
+
const shadowed = new Set<string>()
|
|
1367
2124
|
|
|
1368
2125
|
function findSignalIdents(node: N): void {
|
|
1369
2126
|
if ((node.start as number) >= start + text.length || (node.end as number) <= start) return
|
|
1370
|
-
|
|
2127
|
+
const introduced: string[] = []
|
|
2128
|
+
for (const n of scopeBoundSignals(node)) {
|
|
2129
|
+
if (!shadowed.has(n)) {
|
|
2130
|
+
shadowed.add(n)
|
|
2131
|
+
introduced.push(n)
|
|
2132
|
+
}
|
|
2133
|
+
}
|
|
2134
|
+
if (node.type === 'Identifier' && isActiveSignal(node.name) && !shadowed.has(node.name)) {
|
|
1371
2135
|
const parent = findParent(node)
|
|
1372
2136
|
// Skip property name positions (obj.name)
|
|
1373
2137
|
if (parent && parent.type === 'MemberExpression' && parent.property === node && !parent.computed) return
|
|
@@ -1405,6 +2169,7 @@ export function transformJSX_JS(
|
|
|
1405
2169
|
idents.push({ start: node.start as number, end: node.end as number })
|
|
1406
2170
|
}
|
|
1407
2171
|
forEachChildFast(node, findSignalIdents)
|
|
2172
|
+
for (const n of introduced) shadowed.delete(n)
|
|
1408
2173
|
}
|
|
1409
2174
|
findSignalIdents(expr)
|
|
1410
2175
|
|