@pyreon/compiler 0.19.0 → 0.21.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 +418 -18
- package/lib/types/index.d.ts +92 -1
- package/package.json +13 -12
- package/src/index.ts +2 -1
- package/src/jsx.ts +669 -17
- 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/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/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/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/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
|
|
@@ -108,6 +126,15 @@ export interface TransformResult {
|
|
|
108
126
|
usesTemplates?: boolean
|
|
109
127
|
/** Compiler warnings for common mistakes */
|
|
110
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
|
|
111
138
|
/**
|
|
112
139
|
* Reactivity-lens spans — populated ONLY when `TransformOptions.reactivityLens`
|
|
113
140
|
* is `true`. Additive: codegen output is byte-identical whether or not this is
|
|
@@ -160,6 +187,64 @@ export interface TransformOptions {
|
|
|
160
187
|
* codegen; it never runs a second analysis pass.
|
|
161
188
|
*/
|
|
162
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)
|
|
163
248
|
}
|
|
164
249
|
|
|
165
250
|
// ─── oxc ESTree helpers ───────────────────────────────────────────────────────
|
|
@@ -233,6 +318,215 @@ function jsxChildren(node: N): N[] {
|
|
|
233
318
|
return node.children ?? []
|
|
234
319
|
}
|
|
235
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
|
+
|
|
236
530
|
// ─── Main transform ─────────────────────────────────────────────────────────
|
|
237
531
|
|
|
238
532
|
export function transformJSX(
|
|
@@ -240,6 +534,12 @@ export function transformJSX(
|
|
|
240
534
|
filename = 'input.tsx',
|
|
241
535
|
options: TransformOptions = {},
|
|
242
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
|
+
|
|
243
543
|
// Try Rust native binary first (3.7-8.2x faster).
|
|
244
544
|
// Per-call try/catch: if the native binary panics on an edge case
|
|
245
545
|
// (bad UTF-8, unexpected AST shape), fall back gracefully instead
|
|
@@ -362,6 +662,109 @@ export function transformJSX_JS(
|
|
|
362
662
|
let needsApplyPropsImportGlobal = false
|
|
363
663
|
let needsMountSlotImportGlobal = false
|
|
364
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
|
+
|
|
365
768
|
function maybeHoist(node: N): string | null {
|
|
366
769
|
if (
|
|
367
770
|
(node.type === 'JSXElement' || node.type === 'JSXFragment') &&
|
|
@@ -525,6 +928,15 @@ export function transformJSX_JS(
|
|
|
525
928
|
// ── Prop-derived variable tracking (collected during the single walk) ─────
|
|
526
929
|
const propsNames = new Set<string>()
|
|
527
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>()
|
|
528
940
|
|
|
529
941
|
// ── Signal variable tracking (for auto-call in JSX) ──────────────────────
|
|
530
942
|
// Tracks `const x = signal(...)` declarations. In JSX expressions, bare
|
|
@@ -631,6 +1043,18 @@ export function transformJSX_JS(
|
|
|
631
1043
|
}
|
|
632
1044
|
}
|
|
633
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
|
+
}
|
|
634
1058
|
if (node.kind !== 'const') continue
|
|
635
1059
|
if (callbackDepth > 0) continue
|
|
636
1060
|
if (decl.id?.type === 'Identifier' && decl.init) {
|
|
@@ -707,13 +1131,99 @@ export function transformJSX_JS(
|
|
|
707
1131
|
const endOffset = baseOffset + text.length
|
|
708
1132
|
const idents: { start: number; end: number; name: string }[] = []
|
|
709
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
|
+
|
|
710
1215
|
// Walk the AST to find identifiers in the span, passing parent context
|
|
711
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.
|
|
712
1218
|
function findIdents(node: N, parent: N | null): void {
|
|
713
1219
|
const nodeStart = node.start as number
|
|
714
1220
|
const nodeEnd = node.end as number
|
|
715
1221
|
if (nodeStart >= endOffset || nodeEnd <= baseOffset) return
|
|
716
|
-
if (
|
|
1222
|
+
if (
|
|
1223
|
+
node.type === 'Identifier' &&
|
|
1224
|
+
propDerivedVars.has(node.name) &&
|
|
1225
|
+
!shadowed.has(node.name)
|
|
1226
|
+
) {
|
|
717
1227
|
if (parent) {
|
|
718
1228
|
if (parent.type === 'MemberExpression' && parent.property === node && !parent.computed) { /* skip */ }
|
|
719
1229
|
else if (parent.type === 'VariableDeclarator' && parent.id === node) { /* skip */ }
|
|
@@ -726,7 +1236,12 @@ export function transformJSX_JS(
|
|
|
726
1236
|
idents.push({ start: nodeStart, end: nodeEnd, name: node.name })
|
|
727
1237
|
}
|
|
728
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)
|
|
729
1243
|
forEachChildFast(node, (child) => findIdents(child, node))
|
|
1244
|
+
for (const n of introduced) shadowed.delete(n)
|
|
730
1245
|
}
|
|
731
1246
|
findIdents(program, null)
|
|
732
1247
|
|
|
@@ -857,6 +1372,11 @@ export function transformJSX_JS(
|
|
|
857
1372
|
|
|
858
1373
|
// ── JSX processing (was pass 3) ──
|
|
859
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
|
+
}
|
|
860
1380
|
if (!isSelfClosing(node) && tryTemplateEmit(node)) {
|
|
861
1381
|
// Template emitted — don't recurse into this subtree (JSXElement is never a function)
|
|
862
1382
|
return
|
|
@@ -898,19 +1418,30 @@ export function transformJSX_JS(
|
|
|
898
1418
|
}
|
|
899
1419
|
|
|
900
1420
|
replacements.sort((a, b) => a.start - b.start)
|
|
901
|
-
|
|
902
|
-
|
|
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)
|
|
903
1430
|
for (const r of replacements) {
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
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)
|
|
907
1433
|
}
|
|
908
|
-
|
|
909
|
-
|
|
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 = ''
|
|
910
1442
|
|
|
911
1443
|
if (hoists.length > 0) {
|
|
912
|
-
|
|
913
|
-
output = preamble + output
|
|
1444
|
+
preamble = hoists.map((h) => `const ${h.name} = /*@__PURE__*/ ${h.text}\n`).join('') + preamble
|
|
914
1445
|
}
|
|
915
1446
|
|
|
916
1447
|
if (needsTplImport) {
|
|
@@ -922,21 +1453,53 @@ export function transformJSX_JS(
|
|
|
922
1453
|
const reactivityImports = needsBindImportGlobal
|
|
923
1454
|
? `\nimport { _bind } from "@pyreon/reactivity";`
|
|
924
1455
|
: ''
|
|
925
|
-
|
|
1456
|
+
preamble =
|
|
926
1457
|
`import { ${runtimeDomImports.join(', ')} } from "@pyreon/runtime-dom";${reactivityImports}\n` +
|
|
927
|
-
|
|
1458
|
+
preamble
|
|
928
1459
|
}
|
|
929
1460
|
|
|
930
1461
|
if (needsRpImport || needsWrapSpreadImport) {
|
|
931
1462
|
const coreImports: string[] = []
|
|
932
1463
|
if (needsRpImport) coreImports.push('_rp')
|
|
933
1464
|
if (needsWrapSpreadImport) coreImports.push('_wrapSpread')
|
|
934
|
-
|
|
1465
|
+
preamble = `import { ${coreImports.join(', ')} } from "@pyreon/core";\n` + preamble
|
|
1466
|
+
}
|
|
1467
|
+
|
|
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
|
|
935
1489
|
}
|
|
936
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
|
+
|
|
937
1500
|
return collectLens
|
|
938
|
-
? { code: output, usesTemplates: needsTplImport, warnings, reactivityLens }
|
|
939
|
-
: { code: output, usesTemplates: needsTplImport, warnings }
|
|
1501
|
+
? { code: output, usesTemplates: needsTplImport, warnings, map, reactivityLens }
|
|
1502
|
+
: { code: output, usesTemplates: needsTplImport, warnings, map }
|
|
940
1503
|
|
|
941
1504
|
// ── Template emission helpers ─────────────────────────────────────────────
|
|
942
1505
|
|
|
@@ -1314,7 +1877,14 @@ export function transformJSX_JS(
|
|
|
1314
1877
|
}
|
|
1315
1878
|
const needsPlaceholder = useMixed || useMultiExpr
|
|
1316
1879
|
const { expr, isReactive } = unwrapAccessor(child.expression)
|
|
1317
|
-
|
|
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) {
|
|
1318
1888
|
needsMountSlotImport = true
|
|
1319
1889
|
const placeholder = `${parentRef}.childNodes[${childNodeIdx}]`
|
|
1320
1890
|
const d = nextDisp()
|
|
@@ -1473,14 +2043,95 @@ export function transformJSX_JS(
|
|
|
1473
2043
|
|
|
1474
2044
|
/** Auto-insert () after signal variable references in the expression source.
|
|
1475
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
|
+
|
|
1476
2115
|
function autoCallSignals(text: string, expr: N): string {
|
|
1477
2116
|
const start = expr.start as number
|
|
1478
2117
|
// Collect signal identifier positions that need auto-calling
|
|
1479
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>()
|
|
1480
2124
|
|
|
1481
2125
|
function findSignalIdents(node: N): void {
|
|
1482
2126
|
if ((node.start as number) >= start + text.length || (node.end as number) <= start) return
|
|
1483
|
-
|
|
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)) {
|
|
1484
2135
|
const parent = findParent(node)
|
|
1485
2136
|
// Skip property name positions (obj.name)
|
|
1486
2137
|
if (parent && parent.type === 'MemberExpression' && parent.property === node && !parent.computed) return
|
|
@@ -1518,6 +2169,7 @@ export function transformJSX_JS(
|
|
|
1518
2169
|
idents.push({ start: node.start as number, end: node.end as number })
|
|
1519
2170
|
}
|
|
1520
2171
|
forEachChildFast(node, findSignalIdents)
|
|
2172
|
+
for (const n of introduced) shadowed.delete(n)
|
|
1521
2173
|
}
|
|
1522
2174
|
findSignalIdents(expr)
|
|
1523
2175
|
|