@pyreon/compiler 0.24.5 → 0.24.6

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.
Files changed (64) hide show
  1. package/package.json +11 -13
  2. package/src/defer-inline.ts +0 -686
  3. package/src/event-names.ts +0 -65
  4. package/src/index.ts +0 -61
  5. package/src/island-audit.ts +0 -675
  6. package/src/jsx.ts +0 -2792
  7. package/src/load-native.ts +0 -156
  8. package/src/lpih.ts +0 -270
  9. package/src/manifest.ts +0 -280
  10. package/src/project-scanner.ts +0 -214
  11. package/src/pyreon-intercept.ts +0 -1029
  12. package/src/react-intercept.ts +0 -1217
  13. package/src/reactivity-lens.ts +0 -190
  14. package/src/ssg-audit.ts +0 -513
  15. package/src/test-audit.ts +0 -435
  16. package/src/tests/backend-parity-r7-r9.test.ts +0 -91
  17. package/src/tests/backend-prop-derived-callback-divergence.test.ts +0 -74
  18. package/src/tests/collapse-bail-census.test.ts +0 -330
  19. package/src/tests/collapse-key-source-hygiene.test.ts +0 -88
  20. package/src/tests/component-child-no-wrap.test.ts +0 -204
  21. package/src/tests/defer-inline.test.ts +0 -387
  22. package/src/tests/depth-stress.test.ts +0 -16
  23. package/src/tests/detector-tag-consistency.test.ts +0 -101
  24. package/src/tests/dynamic-collapse-detector.test.ts +0 -164
  25. package/src/tests/dynamic-collapse-emit.test.ts +0 -192
  26. package/src/tests/dynamic-collapse-scan.test.ts +0 -111
  27. package/src/tests/element-valued-const-child.test.ts +0 -61
  28. package/src/tests/falsy-child-characterization.test.ts +0 -48
  29. package/src/tests/island-audit.test.ts +0 -524
  30. package/src/tests/jsx.test.ts +0 -2908
  31. package/src/tests/load-native.test.ts +0 -53
  32. package/src/tests/lpih.test.ts +0 -404
  33. package/src/tests/malformed-input-resilience.test.ts +0 -50
  34. package/src/tests/manifest-snapshot.test.ts +0 -55
  35. package/src/tests/native-equivalence.test.ts +0 -924
  36. package/src/tests/partial-collapse-detector.test.ts +0 -121
  37. package/src/tests/partial-collapse-emit.test.ts +0 -104
  38. package/src/tests/partial-collapse-robustness.test.ts +0 -53
  39. package/src/tests/project-scanner.test.ts +0 -269
  40. package/src/tests/prop-derived-shadow.test.ts +0 -96
  41. package/src/tests/pure-call-reactive-args.test.ts +0 -50
  42. package/src/tests/pyreon-intercept.test.ts +0 -816
  43. package/src/tests/r13-callback-stmt-equivalence.test.ts +0 -58
  44. package/src/tests/r14-ssr-mode-parity.test.ts +0 -51
  45. package/src/tests/r15-elemconst-propderived.test.ts +0 -47
  46. package/src/tests/r19-defer-inline-robust.test.ts +0 -54
  47. package/src/tests/r20-backend-equivalence-sweep.test.ts +0 -50
  48. package/src/tests/react-intercept.test.ts +0 -1104
  49. package/src/tests/reactivity-lens.test.ts +0 -170
  50. package/src/tests/rocketstyle-collapse.test.ts +0 -208
  51. package/src/tests/runtime/control-flow.test.ts +0 -159
  52. package/src/tests/runtime/dom-properties.test.ts +0 -138
  53. package/src/tests/runtime/events.test.ts +0 -301
  54. package/src/tests/runtime/harness.ts +0 -94
  55. package/src/tests/runtime/pr-352-shapes.test.ts +0 -121
  56. package/src/tests/runtime/reactive-props.test.ts +0 -81
  57. package/src/tests/runtime/signals.test.ts +0 -129
  58. package/src/tests/runtime/whitespace.test.ts +0 -106
  59. package/src/tests/signal-autocall-shadow.test.ts +0 -86
  60. package/src/tests/sourcemap-fidelity.test.ts +0 -77
  61. package/src/tests/ssg-audit.test.ts +0 -402
  62. package/src/tests/static-text-baking.test.ts +0 -64
  63. package/src/tests/test-audit.test.ts +0 -549
  64. package/src/tests/transform-state-isolation.test.ts +0 -49
package/src/jsx.ts DELETED
@@ -1,2792 +0,0 @@
1
- /**
2
- * JSX transform — wraps dynamic JSX expressions in `() =>` so the Pyreon runtime
3
- * receives reactive getters instead of eagerly-evaluated snapshot values.
4
- *
5
- * Rules:
6
- * - `<div>{expr}</div>` → `<div>{() => expr}</div>` (child)
7
- * - `<div class={expr}>` → `<div class={() => expr}>` (prop)
8
- * - `<button onClick={fn}>` → unchanged (event handler)
9
- * - `<div>{() => expr}</div>` → unchanged (already wrapped)
10
- * - `<div>{"literal"}</div>` → unchanged (static)
11
- *
12
- * Static VNode hoisting:
13
- * - Fully static JSX in expression containers is hoisted to module scope:
14
- * `{<span>Hello</span>}` → `const _$h0 = <span>Hello</span>` + `{_$h0}`
15
- * - Hoisted nodes are created ONCE at module initialisation, not per-instance.
16
- * - A JSX node is static if: all props are string literals / booleans / static
17
- * values, and all children are text nodes or other static JSX nodes.
18
- *
19
- * Template emission:
20
- * - JSX element trees with ≥ 1 DOM elements (no components, no spread attrs on
21
- * inner elements) are compiled to `_tpl(html, bindFn)` calls instead of nested
22
- * `h()` calls.
23
- * - The HTML string is parsed once via <template>.innerHTML, then cloneNode(true)
24
- * for each instance (~5-10x faster than sequential createElement calls).
25
- * - Static attributes are baked into the HTML string; dynamic attributes and
26
- * text content use renderEffect in the bind function.
27
- *
28
- * Implementation: Rust native binary (napi-rs) when available, JS fallback via oxc-parser.
29
- */
30
-
31
- import MagicString from 'magic-string'
32
- import { parseSync } from 'oxc-parser'
33
- import { REACT_EVENT_REMAP } from './event-names'
34
- import { loadNativeBinding } from './load-native'
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
-
53
- // ─── Native binary auto-detection ────────────────────────────────────────────
54
- // Two-path resolution: in-tree binary first (dev mode), then per-platform
55
- // npm package (production install via optionalDependencies). Falls through
56
- // to the JS implementation below when both paths fail (wrong platform, CI
57
- // environment, WASM runtime like StackBlitz, missing per-platform package).
58
- //
59
- // See `load-native.ts` for the resolution logic.
60
- type NativeTransformFn = (
61
- code: string,
62
- filename: string,
63
- ssr: boolean,
64
- knownSignals: string[] | null,
65
- reactivityLens: boolean,
66
- ) => TransformResult
67
- const nativeBinding = loadNativeBinding(import.meta.url)
68
- const nativeTransformJsx: NativeTransformFn | null = nativeBinding
69
- ? (nativeBinding.transformJsx as NativeTransformFn)
70
- : null
71
-
72
- export interface CompilerWarning {
73
- /** Warning message */
74
- message: string
75
- /** Source file line number (1-based) */
76
- line: number
77
- /** Source file column number (0-based) */
78
- column: number
79
- /** Warning code for filtering */
80
- code:
81
- | 'signal-call-in-jsx'
82
- | 'missing-key-on-for'
83
- | 'signal-in-static-prop'
84
- | 'circular-prop-derived'
85
- }
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
-
122
- export interface TransformResult {
123
- /** Transformed source code (JSX preserved, only expression containers modified) */
124
- code: string
125
- /** Whether the output uses _tpl/_re template helpers (needs auto-import) */
126
- usesTemplates?: boolean
127
- /** Compiler warnings for common mistakes */
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[]
145
- }
146
-
147
- // Props that should never be wrapped in a reactive getter
148
- const SKIP_PROPS = new Set(['key', 'ref'])
149
- // Event handler pattern: onClick, onInput, onMouseEnter, …
150
- const EVENT_RE = /^on[A-Z]/
151
- // Events delegated to the container — must match runtime DELEGATED_EVENTS set
152
- const DELEGATED_EVENTS = new Set([
153
- 'click', 'dblclick', 'contextmenu', 'focusin', 'focusout', 'input',
154
- 'change', 'keydown', 'keyup', 'mousedown', 'mouseup', 'mousemove',
155
- 'mouseover', 'mouseout', 'pointerdown', 'pointerup', 'pointermove',
156
- 'pointerover', 'pointerout', 'touchstart', 'touchend', 'touchmove',
157
- 'submit',
158
- ])
159
-
160
- export interface TransformOptions {
161
- /**
162
- * Compile for server-side rendering. When true, the compiler skips the
163
- * `_tpl()` template optimization and falls back to plain `h()` calls so
164
- * `@pyreon/runtime-server` can walk the VNode tree. Default: false.
165
- */
166
- ssr?: boolean
167
-
168
- /**
169
- * Known signal variable names from resolved imports.
170
- * The Vite plugin maintains a cross-module signal export registry and
171
- * passes imported signal names here so the compiler can auto-call them
172
- * in JSX even though the `signal()` declaration is in another file.
173
- *
174
- * @example
175
- * // store.ts: export const count = signal(0)
176
- * // component.tsx: import { count } from './store'
177
- * transformJSX(code, 'component.tsx', { knownSignals: ['count'] })
178
- * // {count} in JSX → {() => count()}
179
- */
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)
248
- }
249
-
250
- // ─── oxc ESTree helpers ───────────────────────────────────────────────────────
251
-
252
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
253
- type N = any // ESTree node — untyped for speed, matches the lint package approach
254
-
255
- function getLang(filename: string): 'tsx' | 'jsx' {
256
- if (filename.endsWith('.jsx')) return 'jsx'
257
- // Default to tsx so JSX is always parsed — matches the original TypeScript
258
- // parser behavior which forced ScriptKind.TSX for all files.
259
- return 'tsx'
260
- }
261
-
262
- /** Binary search for line/column from byte offset. */
263
- function makeLineIndex(code: string): (offset: number) => { line: number; column: number } {
264
- const lineStarts = [0]
265
- for (let i = 0; i < code.length; i++) {
266
- if (code[i] === '\n') lineStarts.push(i + 1)
267
- }
268
- return (offset: number) => {
269
- let lo = 0
270
- let hi = lineStarts.length - 1
271
- while (lo <= hi) {
272
- const mid = (lo + hi) >>> 1
273
- if (lineStarts[mid]! <= offset) lo = mid + 1
274
- else hi = mid - 1
275
- }
276
- return { line: lo, column: offset - lineStarts[lo - 1]! }
277
- }
278
- }
279
-
280
- /** Iterate all direct children of an ESTree node via known property keys. */
281
- function forEachChild(node: N, cb: (child: N) => void): void {
282
- if (!node || typeof node !== 'object') return
283
- const keys = Object.keys(node)
284
- for (let i = 0; i < keys.length; i++) {
285
- const key = keys[i]!
286
- // Skip metadata fields for speed
287
- if (key === 'type' || key === 'start' || key === 'end' || key === 'loc' || key === 'range') continue
288
- const val = node[key]
289
- if (Array.isArray(val)) {
290
- for (let j = 0; j < val.length; j++) {
291
- const item = val[j]
292
- if (item && typeof item === 'object' && item.type) cb(item)
293
- }
294
- } else if (val && typeof val === 'object' && val.type) {
295
- cb(val)
296
- }
297
- }
298
- }
299
-
300
- // ─── JSX element helpers ────────────────────────────────────────────────────
301
-
302
- function jsxTagName(node: N): string {
303
- const opening = node.openingElement
304
- if (!opening) return ''
305
- const name = opening.name
306
- return name?.type === 'JSXIdentifier' ? name.name : ''
307
- }
308
-
309
- function isSelfClosing(node: N): boolean {
310
- return node.type === 'JSXElement' && node.openingElement?.selfClosing === true
311
- }
312
-
313
- function jsxAttrs(node: N): N[] {
314
- return node.openingElement?.attributes ?? []
315
- }
316
-
317
- function jsxChildren(node: N): N[] {
318
- return node.children ?? []
319
- }
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
- } else {
408
- // Dynamic-prop fallthrough: if the full detector bailed but
409
- // the site matches the ternary-of-two-literals shape, expand
410
- // into TWO CollapsibleSite entries — one per literal value.
411
- // Each expanded site is byte-identical to a static-collapse
412
- // site for that value, so the resolver pre-renders both via
413
- // the existing SSR pipeline and the compiler emit looks up
414
- // both by their respective keys to build the dispatcher.
415
- //
416
- // No-handler sites route to `__rsCollapseDyn`; handler-bearing
417
- // sites route to `__rsCollapseDynH` (handlers are orthogonal
418
- // to the SSR-resolved styler class — see `tryDynamicCollapse`
419
- // in this file). The scan does NOT distinguish here because
420
- // the resolver only cares about (componentName, props, text);
421
- // handlers don't affect the resolution.
422
- const dyn = detectDynamicCollapsibleShape(node, tag)
423
- if (dyn) {
424
- for (const value of [dyn.dynamicProp.valueTruthy, dyn.dynamicProp.valueFalsy]) {
425
- const expandedProps = { ...dyn.props, [dyn.dynamicProp.name]: value }
426
- out.push({
427
- componentName: tag,
428
- source: imp.source,
429
- importedName: imp.imported,
430
- props: expandedProps,
431
- childrenText: dyn.childrenText,
432
- key: rocketstyleCollapseKey(tag, expandedProps, dyn.childrenText),
433
- })
434
- }
435
- }
436
- }
437
- }
438
- }
439
- for (const k in node) {
440
- const v = node[k]
441
- if (Array.isArray(v)) for (const c of v) visit(c)
442
- else if (v && typeof v === 'object' && typeof v.type === 'string') visit(v)
443
- }
444
- }
445
- visit(program)
446
- return out
447
- }
448
-
449
- /**
450
- * The shared bail catalogue — every attr a string literal (no spread, no
451
- * `{expr}`, no boolean attr), children empty or static text. Returns the
452
- * extracted {props, childrenText} or null (bail). `tryRocketstyleCollapse`
453
- * inlines the identical checks; a consistency test locks them together.
454
- */
455
- function detectCollapsibleShape(
456
- node: N,
457
- _tag: string,
458
- ): { props: Record<string, string>; childrenText: string } | null {
459
- const props: Record<string, string> = {}
460
- for (const attr of jsxAttrs(node)) {
461
- if (attr.type !== 'JSXAttribute') return null // spread → bail
462
- const nm = attr.name?.type === 'JSXIdentifier' ? attr.name.name : null
463
- if (!nm) return null
464
- const v = attr.value
465
- if (!v) return null // boolean attr → bail
466
- const isStr =
467
- v.type === 'StringLiteral' || (v.type === 'Literal' && typeof v.value === 'string')
468
- if (!isStr) return null // `{expr}` / dynamic → bail
469
- props[nm] = String(v.value)
470
- }
471
- let childrenText = ''
472
- for (const c of jsxChildren(node)) {
473
- if (c.type === 'JSXText') childrenText += (c.value ?? '') as string
474
- else return null // element / expression child → bail
475
- }
476
- return { props, childrenText: childrenText.trim() }
477
- }
478
-
479
- /** A residual event handler peeled off a partially-collapsible site. */
480
- export interface CollapsibleHandler {
481
- /** JSX attribute name, e.g. `onClick`. */
482
- name: string
483
- /** Source span of the handler expression (the `{...}` contents). */
484
- exprStart: number
485
- exprEnd: number
486
- }
487
-
488
- /**
489
- * Partial-collapse detector — PR 1 of the partial-collapse spec
490
- * (`.claude/plans/open-work-2026-q3.md` → #1). The `on*`-handler-only
491
- * subset the bail-reason census measured at 7.8% of all
492
- * `@pyreon/ui-components` call sites (`collapse-bail-census.test.ts`).
493
- *
494
- * It is the EXACT `detectCollapsibleShape` bail catalogue with ONE
495
- * relaxation: a `{expr}`-valued attribute whose name matches `on[A-Z]…`
496
- * (an event handler) does NOT bail — it is peeled into `handlers[]`
497
- * instead. Handlers are orthogonal to the SSR-resolved styler class (an
498
- * event binding never changes rendered CSS), so the literal-prop subset
499
- * still feeds the UNCHANGED `rocketstyleCollapseKey` and the resolver's
500
- * pre-resolved `templateHtml` / `lightClass` / `darkClass` are
501
- * byte-identical to a full-collapse site's. The collapsed runtime node
502
- * just re-attaches the residual handlers (PR 2 — `_rsCollapseH`).
503
- *
504
- * Every OTHER non-literal shape still bails (spread, non-handler
505
- * `{expr}` prop, boolean attr, element/expression child) — conservative
506
- * by construction, exactly like the full detector. Returns `null` when
507
- * there are ZERO handlers so the full-collapse path stays byte-unchanged
508
- * and the two detectors never both claim the same site (full-collapse
509
- * sites have no handlers; partial sites have ≥1). A consistency test
510
- * will lock this catalogue against the plugin scan in PR 3, mirroring
511
- * the existing `detectCollapsibleShape` ↔ `scanCollapsibleSites`
512
- * invariant — keys cannot drift.
513
- */
514
- export function detectPartialCollapsibleShape(
515
- node: N,
516
- _tag: string,
517
- ): { props: Record<string, string>; childrenText: string; handlers: CollapsibleHandler[] } | null {
518
- const props: Record<string, string> = {}
519
- const handlers: CollapsibleHandler[] = []
520
- for (const attr of jsxAttrs(node)) {
521
- if (attr.type !== 'JSXAttribute') return null // spread → bail
522
- const nm = attr.name?.type === 'JSXIdentifier' ? attr.name.name : null
523
- if (!nm) return null
524
- const v = attr.value
525
- if (!v) return null // boolean attr → bail
526
- const isStr =
527
- v.type === 'StringLiteral' || (v.type === 'Literal' && typeof v.value === 'string')
528
- if (isStr) {
529
- props[nm] = String(v.value)
530
- continue
531
- }
532
- // Non-literal: ONLY an `on[A-Z]…` handler in a `{expr}` container is
533
- // peelable. Everything else (non-handler dynamic prop, shorthand
534
- // `onClick` without a container, etc.) is a hard bail — same
535
- // conservatism as the full detector.
536
- if (
537
- /^on[A-Z]/.test(nm) &&
538
- v.type === 'JSXExpressionContainer' &&
539
- v.expression &&
540
- typeof v.expression.start === 'number' &&
541
- typeof v.expression.end === 'number'
542
- ) {
543
- handlers.push({ name: nm, exprStart: v.expression.start, exprEnd: v.expression.end })
544
- continue
545
- }
546
- return null // `{expr}` non-handler / dynamic → bail
547
- }
548
- let childrenText = ''
549
- for (const c of jsxChildren(node)) {
550
- if (c.type === 'JSXText') childrenText += (c.value ?? '') as string
551
- else return null // element / expression child → bail
552
- }
553
- // Zero handlers ⇒ this is the FULL-collapse shape; defer to
554
- // `detectCollapsibleShape` so the existing path stays byte-unchanged.
555
- if (handlers.length === 0) return null
556
- return { props, childrenText: childrenText.trim(), handlers }
557
- }
558
-
559
- /**
560
- * A dynamic dimension prop on a collapsible call site. A ConditionalExpression
561
- * (ternary) where both branches are string literals — `state={cond ? 'a' : 'b'}`
562
- * is the canonical shape. Pre-resolution: the prop's value belongs to the
563
- * enumerable set `[valueA, valueB]`. The compiler emits one collapsed
564
- * variant per literal value + a dispatcher on the original `cond`.
565
- */
566
- export interface DynamicCollapsibleProp {
567
- /** JSX attribute name, e.g. `state`. */
568
- name: string
569
- /** Source span of the ternary condition (the `cond` part), re-emitted into the runtime dispatcher. */
570
- condStart: number
571
- condEnd: number
572
- /** Literal value for the `cond === truthy` branch (consequent). */
573
- valueTruthy: string
574
- /** Literal value for the `cond === falsy` branch (alternate). */
575
- valueFalsy: string
576
- }
577
-
578
- /**
579
- * Dynamic-prop partial-collapse detector — PR 2 of the dynamic-prop
580
- * partial-collapse build (`.claude/plans/open-work-2026-q3.md` → #1
581
- * dynamic-prop bucket = 15.3% of all real-corpus sites; the next-bigger
582
- * bite after the `on*`-handler partial-collapse).
583
- *
584
- * Mirrors `detectPartialCollapsibleShape`'s "extend the bail catalogue
585
- * with ONE relaxation" pattern (see that detector's docstring + `PR 1`
586
- * `_rsCollapseDyn` runtime helper, PR #765). The single relaxation: a
587
- * `JSXExpressionContainer` wrapping a `ConditionalExpression` whose
588
- * `consequent` AND `alternate` are BOTH `StringLiteral` is acceptable as
589
- * a "ternary-of-two-literals" dynamic prop — captured as a {@link DynamicCollapsibleProp}
590
- * with the cond source span + the two literal values.
591
- *
592
- * Constraint: **AT MOST ONE** such dynamic prop per site. Multiple
593
- * ternaries would compound into a 2^N value-set per site at build time
594
- * and an N-axis dispatcher at runtime — that's a separable scope
595
- * (potential PR 5+), NOT this PR. Sites with 2+ ternaries bail (return
596
- * null), keeping the normal mount; same conservative shape as the rest
597
- * of the detector family.
598
- *
599
- * Constraint: the FULL `on*`-handler relaxation is also folded in — a
600
- * site can have ONE ternary AND `on*` handlers in the same call. This
601
- * matches the real-corpus shape (a Button with `state={cond ? 'a' : 'b'}`
602
- * almost always also has an `onClick`). The two relaxations compose
603
- * cleanly because they're orthogonal at the resolver layer (handlers
604
- * don't change rendered CSS; the ternary picks among pre-resolved
605
- * classes). PR 3's emit will use `_rsCollapseDyn` when handlers are
606
- * absent and a future combined helper when both are present — for THIS
607
- * PR (detector-only) the structure carries both so PR 3 can dispatch.
608
- *
609
- * Every OTHER non-literal shape still bails (spread, non-handler
610
- * non-ternary `{expr}` prop, multi-literal ternary anywhere, computed-
611
- * expression ternary, element/expression child, boolean attr) —
612
- * conservative by construction, exactly like the rest of the family.
613
- * Returns `null` when there are ZERO ternaries so the on*-only path
614
- * (`detectPartialCollapsibleShape`) and the full-collapse path
615
- * (`detectCollapsibleShape`) stay byte-unchanged and no detector both
616
- * claims the same site.
617
- *
618
- * A consistency test (PR 3) will lock this catalogue against the
619
- * plugin scan, mirroring the `detectCollapsibleShape` ↔ `scanCollapsibleSites`
620
- * + `detectPartialCollapsibleShape` ↔ scan invariants — keys cannot drift.
621
- */
622
- export function detectDynamicCollapsibleShape(
623
- node: N,
624
- _tag: string,
625
- ): {
626
- props: Record<string, string>
627
- childrenText: string
628
- handlers: CollapsibleHandler[]
629
- dynamicProp: DynamicCollapsibleProp
630
- } | null {
631
- const props: Record<string, string> = {}
632
- const handlers: CollapsibleHandler[] = []
633
- const dynamicProps: DynamicCollapsibleProp[] = []
634
- for (const attr of jsxAttrs(node)) {
635
- if (attr.type !== 'JSXAttribute') return null // spread → bail
636
- const nm = attr.name?.type === 'JSXIdentifier' ? attr.name.name : null
637
- if (!nm) return null
638
- const v = attr.value
639
- if (!v) return null // boolean attr → bail
640
- const isStr =
641
- v.type === 'StringLiteral' || (v.type === 'Literal' && typeof v.value === 'string')
642
- if (isStr) {
643
- props[nm] = String(v.value)
644
- continue
645
- }
646
- // Non-literal in a `{expr}` container — three possible relaxations:
647
- // (a) `on[A-Z]…` handler with any expression → peeled
648
- // (b) any other prop whose expression is a ternary of two string
649
- // literals → peeled as a DynamicCollapsibleProp
650
- // (c) anything else → bail
651
- if (
652
- v.type === 'JSXExpressionContainer' &&
653
- v.expression &&
654
- typeof v.expression.start === 'number' &&
655
- typeof v.expression.end === 'number'
656
- ) {
657
- if (/^on[A-Z]/.test(nm)) {
658
- handlers.push({ name: nm, exprStart: v.expression.start, exprEnd: v.expression.end })
659
- continue
660
- }
661
- const expr = v.expression
662
- if (
663
- expr.type === 'ConditionalExpression' &&
664
- expr.test &&
665
- typeof expr.test.start === 'number' &&
666
- typeof expr.test.end === 'number' &&
667
- expr.consequent &&
668
- expr.alternate
669
- ) {
670
- // Both branches must be StringLiteral. We deliberately do NOT
671
- // accept TemplateLiteral / `as`-casted literals / any other
672
- // shape — keep the static-resolvable set narrow + provable.
673
- const isLitStr = (n: unknown): n is { type: 'StringLiteral'; value: string } => {
674
- const x = n as { type?: string; value?: unknown }
675
- return (
676
- x?.type === 'StringLiteral' ||
677
- (x?.type === 'Literal' && typeof x.value === 'string')
678
- )
679
- }
680
- if (isLitStr(expr.consequent) && isLitStr(expr.alternate)) {
681
- dynamicProps.push({
682
- name: nm,
683
- condStart: expr.test.start,
684
- condEnd: expr.test.end,
685
- valueTruthy: String((expr.consequent as { value: string }).value),
686
- valueFalsy: String((expr.alternate as { value: string }).value),
687
- })
688
- continue
689
- }
690
- }
691
- }
692
- return null // `{expr}` non-handler non-ternary, or non-literal ternary branch → bail
693
- }
694
- let childrenText = ''
695
- for (const c of jsxChildren(node)) {
696
- if (c.type === 'JSXText') childrenText += (c.value ?? '') as string
697
- else return null // element / expression child → bail
698
- }
699
- // Exactly ONE dynamic prop is the scope of this PR. Zero ⇒ defer to
700
- // the existing detectors (full / on*-handler partial); 2+ ⇒ bail
701
- // (multi-axis combinatorics is a separable scope).
702
- if (dynamicProps.length !== 1) return null
703
- return {
704
- props,
705
- childrenText: childrenText.trim(),
706
- handlers,
707
- dynamicProp: dynamicProps[0]!,
708
- }
709
- }
710
-
711
- // ─── Main transform ─────────────────────────────────────────────────────────
712
-
713
- export function transformJSX(
714
- code: string,
715
- filename = 'input.tsx',
716
- options: TransformOptions = {},
717
- ): TransformResult {
718
- // `collapseRocketstyle` emission lives only in the JS path (the Rust
719
- // binary doesn't implement it and isn't passed the option). Force the
720
- // JS path when collapse is requested so it isn't silently skipped —
721
- // same pattern as `analyzeReactivity` forcing `transformJSX_JS`.
722
- if (options.collapseRocketstyle) return transformJSX_JS(code, filename, options)
723
-
724
- // Try Rust native binary first (3.7-8.2x faster).
725
- // Per-call try/catch: if the native binary panics on an edge case
726
- // (bad UTF-8, unexpected AST shape), fall back gracefully instead
727
- // of crashing the Vite dev server.
728
- if (nativeTransformJsx) {
729
- try {
730
- return nativeTransformJsx(
731
- code,
732
- filename,
733
- options.ssr === true,
734
- options.knownSignals ?? null,
735
- options.reactivityLens === true,
736
- )
737
- } catch {
738
- // Native transform failed — fall through to JS implementation
739
- }
740
- }
741
- return transformJSX_JS(code, filename, options)
742
- }
743
-
744
- /** JS fallback implementation — used when the native binary isn't available. */
745
- export function transformJSX_JS(
746
- code: string,
747
- filename = 'input.tsx',
748
- options: TransformOptions = {},
749
- ): TransformResult {
750
- const ssr = options.ssr === true
751
-
752
- let program: N
753
- try {
754
- const result = parseSync(filename, code, {
755
- sourceType: 'module',
756
- lang: getLang(filename),
757
- })
758
- program = result.program
759
- } catch {
760
- return { code, warnings: [] }
761
- }
762
-
763
- const locate = makeLineIndex(code)
764
-
765
- type Replacement = { start: number; end: number; text: string }
766
- const replacements: Replacement[] = []
767
- const warnings: CompilerWarning[] = []
768
-
769
- function warn(node: N, message: string, warnCode: CompilerWarning['code']): void {
770
- const { line, column } = locate(node.start as number)
771
- warnings.push({ message, line, column, code: warnCode })
772
- }
773
-
774
- // ── Reactivity lens (opt-in, additive — never affects `result`) ───────────
775
- const collectLens = options.reactivityLens === true
776
- const reactivityLens: ReactivitySpan[] = []
777
- function lens(start: number, end: number, kind: ReactivityKind, detail: string): void {
778
- if (!collectLens) return
779
- const a = locate(start)
780
- const b = locate(end)
781
- reactivityLens.push({
782
- start,
783
- end,
784
- line: a.line,
785
- column: a.column,
786
- endLine: b.line,
787
- endColumn: b.column,
788
- kind,
789
- detail,
790
- })
791
- }
792
-
793
- // ── Parent + children maps (built once, eliminates repeated Object.keys) ──
794
- const parentMap = new WeakMap<object, N>()
795
- const childrenMap = new WeakMap<object, N[]>()
796
-
797
- /** Build parent pointers + cached children arrays for the entire AST. */
798
- function buildMaps(node: N): void {
799
- const kids: N[] = []
800
- const keys = Object.keys(node)
801
- for (let i = 0; i < keys.length; i++) {
802
- const key = keys[i]!
803
- if (key === 'type' || key === 'start' || key === 'end' || key === 'loc' || key === 'range') continue
804
- const val = node[key]
805
- if (Array.isArray(val)) {
806
- for (let j = 0; j < val.length; j++) {
807
- const item = val[j]
808
- if (item && typeof item === 'object' && item.type) kids.push(item)
809
- }
810
- } else if (val && typeof val === 'object' && val.type) {
811
- kids.push(val)
812
- }
813
- }
814
- childrenMap.set(node, kids)
815
- for (let i = 0; i < kids.length; i++) {
816
- parentMap.set(kids[i]!, node)
817
- buildMaps(kids[i]!)
818
- }
819
- }
820
- buildMaps(program)
821
-
822
- function findParent(node: N): N | undefined {
823
- return parentMap.get(node)
824
- }
825
-
826
- /** Fast child iteration using pre-computed children array. */
827
- function forEachChildFast(node: N, cb: (child: N) => void): void {
828
- const kids = childrenMap.get(node)
829
- if (!kids) return
830
- for (let i = 0; i < kids.length; i++) cb(kids[i]!)
831
- }
832
-
833
- // ── Static hoisting state ─────────────────────────────────────────────────
834
- type Hoist = { name: string; text: string }
835
- const hoists: Hoist[] = []
836
- let hoistIdx = 0
837
- let needsTplImport = false
838
- let needsRpImport = false
839
- let needsWrapSpreadImport = false
840
- let needsBindTextImportGlobal = false
841
- let needsBindDirectImportGlobal = false
842
- let needsBindImportGlobal = false
843
- let needsApplyPropsImportGlobal = false
844
- let needsMountSlotImportGlobal = false
845
-
846
- // ── P0 rocketstyle-collapse state ─────────────────────────────────────────
847
- let needsCollapse = false
848
- let needsCollapseH = false
849
- let needsCollapseDyn = false
850
- let needsCollapseDynH = false
851
- const collapseRuleKeys = new Set<string>()
852
- const collapseRules: Array<{ ruleKey: string; rules: string[] }> = []
853
-
854
- /**
855
- * Detect + collapse a literal-prop rocketstyle call site. Conservative
856
- * bail catalogue (RFC decision 3): PascalCase candidate, every attr a
857
- * StringLiteral (no spread, no `{expr}`, no boolean attr), children
858
- * empty or a single static JSXText. The plugin must already have
859
- * SSR-resolved this exact (component, props, text) tuple — an absent
860
- * `sites` entry is a hard bail (covers resolver-bailed shapes,
861
- * cross-package-without-data, anything uncertain). Emits ONE
862
- * `_rsCollapse(tpl, light, dark, () => mode()==='dark')` (dual-emit)
863
- * plus a once-per-module idempotent `injectRules`. A false negative is
864
- * correct-but-slow; a false positive is wrong output — so every
865
- * uncertain signal returns false.
866
- */
867
- function tryRocketstyleCollapse(node: N): boolean {
868
- const cfg = options.collapseRocketstyle
869
- if (!cfg) return false
870
- const tag = jsxTagName(node)
871
- if (!tag || tag.charAt(0) === tag.charAt(0).toLowerCase()) return false
872
- if (!cfg.candidates.has(tag)) return false
873
- // Shared bail catalogue — IDENTICAL to scanCollapsibleSites (the
874
- // plugin scans with the same predicate, so its resolved `sites`
875
- // keys match these lookups exactly; no drift possible).
876
- const shape = detectCollapsibleShape(node, tag)
877
- // Fallthrough chain — same conservative discipline at each layer:
878
- // 1. on*-handler-only partial (literal dim props + handlers)
879
- // 2. dynamic-prop partial (ternary-of-two-literals on ≤1 dim prop,
880
- // no handlers — handler-combined dynamic is a future PR's scope)
881
- if (!shape) return tryPartialCollapse(node, tag) || tryDynamicCollapse(node, tag)
882
- const { props, childrenText } = shape
883
- const key = rocketstyleCollapseKey(tag, props, childrenText)
884
- const site = cfg.sites.get(key)
885
- if (!site) return false // not resolved → keep normal rocketstyle mount
886
- const call =
887
- `__rsCollapse(${JSON.stringify(site.templateHtml)}, ` +
888
- `${JSON.stringify(site.lightClass)}, ${JSON.stringify(site.darkClass)}, ` +
889
- `() => __pyrMode() === "dark")`
890
- const start = node.start as number
891
- const end = node.end as number
892
- const parent = findParent(node)
893
- const needsBraces =
894
- parent && (parent.type === 'JSXElement' || parent.type === 'JSXFragment')
895
- replacements.push({ start, end, text: needsBraces ? `{${call}}` : call })
896
- needsCollapse = true
897
- if (!collapseRuleKeys.has(site.ruleKey)) {
898
- collapseRuleKeys.add(site.ruleKey)
899
- collapseRules.push({ ruleKey: site.ruleKey, rules: site.rules })
900
- }
901
- return true
902
- }
903
-
904
- /**
905
- * PR 3 of the partial-collapse build (open-work #1). The `on*`-handler-
906
- * only fallback `tryRocketstyleCollapse` defers to when the FULL
907
- * `detectCollapsibleShape` bails. Identical site-resolution contract as
908
- * the full path — handlers are orthogonal to the SSR-resolved styler
909
- * class, so the literal-prop subset feeds the UNCHANGED
910
- * `rocketstyleCollapseKey` and the resolver's pre-resolved
911
- * `templateHtml`/`lightClass`/`darkClass` are byte-identical to a
912
- * full-collapse site's. The ONLY difference vs the full emit is
913
- * `__rsCollapseH(...)` with a handlers object literal built from the
914
- * sliced source spans `detectPartialCollapsibleShape` (PR 1) returned;
915
- * the runtime helper (`_rsCollapseH`, PR 2 / #681) re-attaches them
916
- * through the canonical event path. Same conservative discipline: an
917
- * unresolved key, the option absent, or any non-handler non-literal
918
- * shape ⇒ keep the normal mount (return false).
919
- */
920
- function tryPartialCollapse(node: N, tag: string): boolean {
921
- const cfg = options.collapseRocketstyle
922
- if (!cfg) return false
923
- const partial = detectPartialCollapsibleShape(node, tag)
924
- if (!partial) return false
925
- const { props, childrenText, handlers } = partial
926
- const key = rocketstyleCollapseKey(tag, props, childrenText)
927
- const site = cfg.sites.get(key)
928
- if (!site) return false // not resolved → keep normal rocketstyle mount
929
- // `{ "onClick": (<sliced expr>), … }` — each handler expression is
930
- // re-emitted verbatim from its source span (paren-wrapped so an
931
- // arrow / sequence expr stays a single argument).
932
- const handlerObj =
933
- `{ ${handlers
934
- .map((h) => `${JSON.stringify(h.name)}: (${code.slice(h.exprStart, h.exprEnd)})`)
935
- .join(', ')} }`
936
- const call =
937
- `__rsCollapseH(${JSON.stringify(site.templateHtml)}, ` +
938
- `${JSON.stringify(site.lightClass)}, ${JSON.stringify(site.darkClass)}, ` +
939
- `() => __pyrMode() === "dark", ${handlerObj})`
940
- const start = node.start as number
941
- const end = node.end as number
942
- const parent = findParent(node)
943
- const needsBraces =
944
- parent && (parent.type === 'JSXElement' || parent.type === 'JSXFragment')
945
- replacements.push({ start, end, text: needsBraces ? `{${call}}` : call })
946
- needsCollapse = true
947
- needsCollapseH = true
948
- if (!collapseRuleKeys.has(site.ruleKey)) {
949
- collapseRuleKeys.add(site.ruleKey)
950
- collapseRules.push({ ruleKey: site.ruleKey, rules: site.rules })
951
- }
952
- return true
953
- }
954
-
955
- /**
956
- * PR 3 of the dynamic-prop partial-collapse build (open-work #1
957
- * dynamic-prop bucket = 15.3% of all real-corpus sites; the
958
- * next-bigger bite after the just-shipped `on*`-handler partial).
959
- * The dynamic-prop fallback `tryRocketstyleCollapse` defers to when
960
- * BOTH the full and the on*-handler-partial paths bail.
961
- *
962
- * Same site-resolution contract as the full path — the dynamic prop
963
- * is replaced with EACH literal value to compute TWO keys; the
964
- * resolver pre-renders both via the existing SSR pipeline; if both
965
- * lookups succeed AND the structural template is byte-identical
966
- * across values, emit `__rsCollapseDyn(html, [classes...], () =>
967
- * cond ? 0 : 1, () => __pyrMode() === "dark")` — the PR 1 runtime
968
- * helper (#765) dispatches across `(value × mode)` with a stride-2
969
- * value-major class layout.
970
- *
971
- * Conservative discipline:
972
- * - Either expanded key missing from sites map ⇒ bail (an
973
- * intermittent resolver failure on one value mustn't half-collapse)
974
- * - Divergent template HTML across values ⇒ bail (the dispatcher
975
- * assumes a shared template; deriveCollapseDyn cannot be done
976
- * across values that produce structurally different markup —
977
- * this is the cross-value parallel of `deriveCollapse`'s
978
- * light↔dark template-divergence bail)
979
- *
980
- * Handler-combined sites: when the detected dynamic site has `on*`
981
- * handlers (the most common real-corpus shape — bail-census measured
982
- * the no-handler subset at 0.2% of all sites; handler-combined is
983
- * the bulk of the 15.4% dynamic-prop bucket), emit
984
- * `__rsCollapseDynH(...)` (PR A: runtime helper) instead of
985
- * `__rsCollapseDyn(...)`. Handlers are orthogonal to the SSR-
986
- * resolved styler class (the resolver pre-renders both values
987
- * identically regardless of handlers); the union helper just
988
- * re-attaches them through the same canonical `_bindEvent` path
989
- * `tryPartialCollapse` uses.
990
- *
991
- * Rule injection unions the rule sets across both values (each value
992
- * may inject distinct CSS rules — e.g. `state="primary"` and
993
- * `state="secondary"` produce different background-color rules); the
994
- * union is the byte-set the dispatcher will need at runtime regardless
995
- * of which value the cond resolves to. Idempotent by per-value
996
- * `ruleKey` so a re-resolve / HMR is a no-op.
997
- */
998
- function tryDynamicCollapse(node: N, tag: string): boolean {
999
- const cfg = options.collapseRocketstyle
1000
- if (!cfg) return false
1001
- const dyn = detectDynamicCollapsibleShape(node, tag)
1002
- if (!dyn) return false
1003
- const { props, childrenText, dynamicProp, handlers } = dyn
1004
- // Look up BOTH expanded sites (one per literal value). The scan's
1005
- // dynamic-prop fallthrough (above in this file) emits a CollapsibleSite
1006
- // for each value with identical key construction, so these lookups
1007
- // must succeed iff both resolved.
1008
- const truthyProps = { ...props, [dynamicProp.name]: dynamicProp.valueTruthy }
1009
- const falsyProps = { ...props, [dynamicProp.name]: dynamicProp.valueFalsy }
1010
- const truthyKey = rocketstyleCollapseKey(tag, truthyProps, childrenText)
1011
- const falsyKey = rocketstyleCollapseKey(tag, falsyProps, childrenText)
1012
- const truthySite = cfg.sites.get(truthyKey)
1013
- const falsySite = cfg.sites.get(falsyKey)
1014
- if (!truthySite || !falsySite) return false // half-resolved ⇒ keep normal mount
1015
- // Cross-value template parity — the dispatcher reuses ONE `_tpl`
1016
- // across both values; divergent markup means we'd silently pick
1017
- // the truthy variant's HTML for falsy too. Bail conservatively.
1018
- if (truthySite.templateHtml !== falsySite.templateHtml) return false
1019
-
1020
- // Build the stride-2 value-major class array (consumed by
1021
- // `_rsCollapseDyn`): `[v0_light, v0_dark, v1_light, v1_dark]` where
1022
- // v0 = truthy (cond → 0), v1 = falsy (cond → 1).
1023
- const classes = [
1024
- truthySite.lightClass,
1025
- truthySite.darkClass,
1026
- falsySite.lightClass,
1027
- falsySite.darkClass,
1028
- ]
1029
- const condSrc = code.slice(dynamicProp.condStart, dynamicProp.condEnd)
1030
-
1031
- // Handler-combined sites route to `__rsCollapseDynH(...)` (PR A
1032
- // runtime helper) — handlers re-attached after the class dispatcher
1033
- // via the canonical `_bindEvent` path, byte-identical to how
1034
- // `tryPartialCollapse` re-emits handlers via `__rsCollapseH`.
1035
- // No-handler sites stay on `__rsCollapseDyn(...)` (lighter — no
1036
- // handlers parameter, no loop allocation).
1037
- let call: string
1038
- if (handlers.length > 0) {
1039
- const handlerObj =
1040
- `{ ${handlers
1041
- .map((h) => `${JSON.stringify(h.name)}: (${code.slice(h.exprStart, h.exprEnd)})`)
1042
- .join(', ')} }`
1043
- call =
1044
- `__rsCollapseDynH(${JSON.stringify(truthySite.templateHtml)}, ` +
1045
- `${JSON.stringify(classes)}, ` +
1046
- `() => (${condSrc}) ? 0 : 1, ` +
1047
- `() => __pyrMode() === "dark", ` +
1048
- `${handlerObj})`
1049
- needsCollapseDynH = true
1050
- } else {
1051
- call =
1052
- `__rsCollapseDyn(${JSON.stringify(truthySite.templateHtml)}, ` +
1053
- `${JSON.stringify(classes)}, ` +
1054
- `() => (${condSrc}) ? 0 : 1, ` +
1055
- `() => __pyrMode() === "dark")`
1056
- needsCollapseDyn = true
1057
- }
1058
- const start = node.start as number
1059
- const end = node.end as number
1060
- const parent = findParent(node)
1061
- const needsBraces =
1062
- parent && (parent.type === 'JSXElement' || parent.type === 'JSXFragment')
1063
- replacements.push({ start, end, text: needsBraces ? `{${call}}` : call })
1064
- // Union BOTH value's rule bundles into the per-module injection.
1065
- // De-dupe by ruleKey (the FNV-1a hash from the resolver) so two
1066
- // dynamic sites sharing a value pay one injection.
1067
- for (const site of [truthySite, falsySite]) {
1068
- if (!collapseRuleKeys.has(site.ruleKey)) {
1069
- collapseRuleKeys.add(site.ruleKey)
1070
- collapseRules.push({ ruleKey: site.ruleKey, rules: site.rules })
1071
- }
1072
- }
1073
- return true
1074
- }
1075
-
1076
- function maybeHoist(node: N): string | null {
1077
- if (
1078
- (node.type === 'JSXElement' || node.type === 'JSXFragment') &&
1079
- isStaticJSXNode(node)
1080
- ) {
1081
- const name = `_$h${hoistIdx++}`
1082
- const text = code.slice(node.start as number, node.end as number)
1083
- hoists.push({ name, text })
1084
- lens(
1085
- node.start as number,
1086
- node.end as number,
1087
- 'hoisted-static',
1088
- 'static — hoisted once to module scope, never re-evaluated',
1089
- )
1090
- return name
1091
- }
1092
- return null
1093
- }
1094
-
1095
- function wrap(expr: N): void {
1096
- const start = expr.start as number
1097
- const end = expr.end as number
1098
- const sliced = sliceExpr(expr)
1099
- const text = expr.type === 'ObjectExpression'
1100
- ? `() => (${sliced})`
1101
- : `() => ${sliced}`
1102
- replacements.push({ start, end, text })
1103
- lens(start, end, 'reactive', 'live — re-evaluates whenever its signals change')
1104
- }
1105
-
1106
- function hoistOrWrap(expr: N): void {
1107
- const hoistName = maybeHoist(expr)
1108
- if (hoistName) {
1109
- replacements.push({ start: expr.start as number, end: expr.end as number, text: hoistName })
1110
- } else if (shouldWrap(expr)) {
1111
- wrap(expr)
1112
- }
1113
- }
1114
-
1115
- // ── Template emit ─────────────────────────────────────────────────────────
1116
-
1117
- function tryTemplateEmit(node: N): boolean {
1118
- if (ssr) return false
1119
- if (isSelfClosing(node)) return false
1120
- const elemCount = templateElementCount(node, true)
1121
- if (elemCount < 1) return false
1122
- const tplCall = buildTemplateCall(node)
1123
- if (!tplCall) return false
1124
- const start = node.start as number
1125
- const end = node.end as number
1126
- const parent = findParent(node)
1127
- const needsBraces = parent && (parent.type === 'JSXElement' || parent.type === 'JSXFragment')
1128
- replacements.push({ start, end, text: needsBraces ? `{${tplCall}}` : tplCall })
1129
- needsTplImport = true
1130
- return true
1131
- }
1132
-
1133
- function checkForWarnings(node: N): void {
1134
- const tagName = jsxTagName(node)
1135
- if (tagName !== 'For') return
1136
- const hasBy = jsxAttrs(node).some(
1137
- (p: N) => p.type === 'JSXAttribute' && p.name?.type === 'JSXIdentifier' && p.name.name === 'by',
1138
- )
1139
- if (!hasBy) {
1140
- warn(
1141
- node.openingElement?.name ?? node,
1142
- `<For> without a "by" prop will use index-based diffing, which is slower and may cause bugs with stateful children. Add by={(item) => item.id} for efficient keyed reconciliation.`,
1143
- 'missing-key-on-for',
1144
- )
1145
- }
1146
- }
1147
-
1148
- /**
1149
- * Wrap component-JSX spread arguments with `_wrapSpread(...)` so
1150
- * getter-shaped reactive props survive esbuild's JS-level spread emit.
1151
- *
1152
- * esbuild compiles `<Comp {...source}>` to `jsx(Comp, { ...source })`.
1153
- * The JS spread fires every getter on `source` and stores the resolved
1154
- * values — collapsing compiler-emitted reactive props (`_rp` thunks
1155
- * later converted to getters by `makeReactiveProps`) to static values
1156
- * before the receiving component sees them.
1157
- *
1158
- * `_wrapSpread` replaces getter descriptors with `_rp`-branded thunks,
1159
- * so the JS-level spread carries function values instead. The runtime
1160
- * `makeReactiveProps` step converts them back to getters on the
1161
- * component's props object — preserving the live signal subscription.
1162
- *
1163
- * Lowercase tags (DOM elements) go through the template path's
1164
- * `_applyProps` which already handles spread reactively — no need to
1165
- * wrap there.
1166
- */
1167
- function handleJsxSpreadAttribute(attr: N, parentElement: N): void {
1168
- const tagName = jsxTagName(parentElement)
1169
- const isComponent =
1170
- tagName.length > 0 && tagName.charAt(0) !== tagName.charAt(0).toLowerCase()
1171
- if (!isComponent) return
1172
- const arg = attr.argument
1173
- if (!arg) return
1174
- // Skip already-wrapped sources (idempotent compilation guard).
1175
- if (
1176
- arg.type === 'CallExpression' &&
1177
- arg.callee?.type === 'Identifier' &&
1178
- arg.callee.name === '_wrapSpread'
1179
- )
1180
- return
1181
- const start = arg.start as number
1182
- const end = arg.end as number
1183
- const sliced = sliceExpr(arg)
1184
- replacements.push({ start, end, text: `_wrapSpread(${sliced})` })
1185
- needsWrapSpreadImport = true
1186
- }
1187
-
1188
- function handleJsxAttribute(node: N, parentElement: N): void {
1189
- const name = node.name?.type === 'JSXIdentifier' ? node.name.name : ''
1190
- if (SKIP_PROPS.has(name) || EVENT_RE.test(name)) return
1191
- if (!node.value || node.value.type !== 'JSXExpressionContainer') return
1192
- const expr = node.value.expression
1193
- if (!expr || expr.type === 'JSXEmptyExpression') return
1194
-
1195
- const tagName = jsxTagName(parentElement)
1196
- const isComponent = tagName.length > 0 && tagName.charAt(0) !== tagName.charAt(0).toLowerCase()
1197
-
1198
- if (isComponent) {
1199
- const isSingleJsx = expr.type === 'JSXElement' || expr.type === 'JSXFragment'
1200
- if (isSingleJsx) {
1201
- walkNode(expr)
1202
- return
1203
- }
1204
- const hoistName = maybeHoist(expr)
1205
- if (hoistName) {
1206
- replacements.push({ start: expr.start as number, end: expr.end as number, text: hoistName })
1207
- } else if (shouldWrap(expr)) {
1208
- const start = expr.start as number
1209
- const end = expr.end as number
1210
- const sliced = sliceExpr(expr)
1211
- const inner = expr.type === 'ObjectExpression' ? `(${sliced})` : sliced
1212
- replacements.push({ start, end, text: `_rp(() => ${inner})` })
1213
- needsRpImport = true
1214
- lens(start, end, 'reactive-prop', 'live prop — signal reads here are tracked into the component')
1215
- }
1216
- } else {
1217
- hoistOrWrap(expr)
1218
- }
1219
- }
1220
-
1221
- function handleJsxExpression(node: N, parentJsx?: N): void {
1222
- const expr = node.expression
1223
- if (!expr || expr.type === 'JSXEmptyExpression') return
1224
- const hoistName = maybeHoist(expr)
1225
- if (hoistName) {
1226
- replacements.push({ start: expr.start as number, end: expr.end as number, text: hoistName })
1227
- return
1228
- }
1229
- if (shouldWrap(expr)) {
1230
- // Skip the accessor wrap for stable references passed as JSX children
1231
- // of a COMPONENT parent (uppercase tag). The compiler's prop-inlining
1232
- // pass replaces `{children}` with `() => h.children` for component
1233
- // parents too (the kinetic Stagger + bokisch.com Intro reproducer);
1234
- // most consumer libraries (rocketstyle/styler/ui-core/elements) route
1235
- // children through `mountChild` which handles function children via
1236
- // `mountReactive`, but libraries that iterate children at the VNode
1237
- // level (kinetic's StaggerRenderer/TransitionItem) or `cloneVNode`
1238
- // them directly are silently broken — the function spread produces
1239
- // `{type: undefined}` and the DOM renders `<undefined>` tags.
1240
- //
1241
- // Narrow contract — only stable references are emitted bare:
1242
- // - Bare Identifier (`{children}` referencing a prop-derived const)
1243
- // - Simple MemberExpression chain (`{obj.x}`, `{obj.x.y}`)
1244
- // These shapes evaluate the same way whether called once at JSX-
1245
- // emit time or repeatedly in a `mountReactive` effect — no
1246
- // reactivity is lost because the underlying value is just a
1247
- // property read. Other dynamic shapes (CallExpression, BinaryExpression,
1248
- // LogicalExpression, etc.) keep the wrap so `<Comp>{count()}</Comp>`
1249
- // and similar patterns stay reactive end-to-end.
1250
- //
1251
- // Without this carve-out, library authors are forced to write
1252
- // defensive `typeof children === 'function' ? children() : children`
1253
- // unwraps everywhere they consume `props.children` structurally.
1254
- if (
1255
- parentJsx &&
1256
- isComponentTag(jsxTagName(parentJsx)) &&
1257
- isStableReference(expr) &&
1258
- !referencesSignalVar(expr)
1259
- ) {
1260
- // Skip the carve-out for signal references — `<Comp>{count}</Comp>`
1261
- // (bare signal identifier) is the user's deliberate "make this
1262
- // reactive at the call site" pattern. Auto-call + wrap converts to
1263
- // `() => count()` so the receiving component re-evaluates inside
1264
- // its mountReactive/mountChild scope. Prop-derived stable refs
1265
- // (the kinetic / bokisch fix shape) take the bare path.
1266
- //
1267
- // Slice the UNWRAPPED expression — TS type-only layers (`as T`,
1268
- // `satisfies T`, `!`) are stripped because the receiving component
1269
- // doesn't care about the static type and esbuild strips casts at
1270
- // the next stage anyway. Also keeps cross-backend equivalence
1271
- // with the Rust path (whose `accesses_props` doesn't recurse into
1272
- // TSAsExpression).
1273
- const start = expr.start as number
1274
- const end = expr.end as number
1275
- const unwrapped = unwrapTypeLayers(expr)
1276
- const sliced = sliceExpr(unwrapped)
1277
- replacements.push({ start, end, text: sliced })
1278
- return
1279
- }
1280
- wrap(expr)
1281
- return
1282
- }
1283
- walkNode(expr)
1284
- }
1285
-
1286
- /** Component tag — uppercase first letter. Lowercase = DOM element. */
1287
- function isComponentTag(tag: string): boolean {
1288
- return tag.length > 0 && tag.charAt(0) !== tag.charAt(0).toLowerCase()
1289
- }
1290
-
1291
- /**
1292
- * Stable reference — an expression whose value is a bare property read.
1293
- * Bare Identifier (`children`) or a non-computed MemberExpression chain
1294
- * (`obj.x.y`) terminating in an Identifier or `this`. These are the
1295
- * shapes that survive the no-wrap path without losing reactivity:
1296
- * reading them once captures the same value as reading them N times,
1297
- * because the underlying getter (if any) is the source of truth either
1298
- * way. Excludes CallExpression / TaggedTemplateExpression / BinaryExpression
1299
- * / LogicalExpression / ConditionalExpression / etc. — those keep the
1300
- * wrap so consumers can re-evaluate inside reactive scopes.
1301
- *
1302
- * TS type-only layers (`as T` / `satisfies T` / non-null `!`) and
1303
- * parentheses are transparent — they don't change runtime semantics
1304
- * so we unwrap to look at the underlying expression. Reproducer:
1305
- * `<Comp>{children as VNode[]}</Comp>` in `createKineticComponent.tsx`
1306
- * — the TS cast wraps the Identifier as a `TSAsExpression`; without
1307
- * unwrap the carve-out misses the very pattern it was written for.
1308
- */
1309
- function isStableReference(expr: N): boolean {
1310
- const u = unwrapTypeLayers(expr)
1311
- if (u.type === 'Identifier') return true
1312
- if (u.type === 'MemberExpression') {
1313
- let cur: N = u
1314
- while (cur.type === 'MemberExpression') {
1315
- if (cur.computed) return false
1316
- if (cur.property?.type !== 'Identifier') return false
1317
- cur = cur.object
1318
- }
1319
- return cur.type === 'Identifier' || cur.type === 'ThisExpression'
1320
- }
1321
- return false
1322
- }
1323
-
1324
- /** Strip TS type-only layers + parens that don't affect runtime value. */
1325
- function unwrapTypeLayers(expr: N): N {
1326
- let cur: N = expr
1327
- while (
1328
- cur.type === 'TSAsExpression' ||
1329
- cur.type === 'TSSatisfiesExpression' ||
1330
- cur.type === 'TSNonNullExpression' ||
1331
- cur.type === 'TSTypeAssertion' ||
1332
- cur.type === 'ParenthesizedExpression'
1333
- ) {
1334
- cur = cur.expression
1335
- }
1336
- return cur
1337
- }
1338
-
1339
- // ── Prop-derived variable tracking (collected during the single walk) ─────
1340
- const propsNames = new Set<string>()
1341
- const propDerivedVars = new Map<string, { start: number; end: number }>()
1342
- // Round 9 fix: names of const/let bindings whose initializer is a JSX
1343
- // element (`const x = <El/>`). A bare `{x}` child of such a binding must be
1344
- // MOUNTED, not text-coerced — pre-fix it emitted `createTextNode(x)` which
1345
- // stringifies the NativeItem to "[object Object]". Routing through
1346
- // `_mountSlot` (the general child-insert `props.children` already uses) is
1347
- // safe even if a same-named binding is later shadowed by a string/number:
1348
- // `_mountSlot` renders those correctly too — the only cost of imprecision
1349
- // is skipping the createTextNode fast path, never a correctness regression.
1350
- const elementVars = new Set<string>()
1351
-
1352
- // ── Signal variable tracking (for auto-call in JSX) ──────────────────────
1353
- // Tracks `const x = signal(...)` declarations. In JSX expressions, bare
1354
- // references to these identifiers are auto-called: `{x}` → `{x()}`.
1355
- // This makes signals look like plain JS variables in templates while
1356
- // maintaining fine-grained reactivity.
1357
- const signalVars = new Set<string>(options.knownSignals)
1358
-
1359
- // ── Scope-aware signal shadowing ──────────────────────────────────────────
1360
- // When a function/block declares a variable with the same name as a signal
1361
- // (e.g. `const show = 'text'` shadowing module-scope `const show = signal(false)`),
1362
- // that name is NOT a signal within that scope. The shadowedSignals set tracks
1363
- // names that are currently shadowed by a closer non-signal declaration.
1364
- const shadowedSignals = new Set<string>()
1365
-
1366
- /** Check if an identifier name is an active (non-shadowed) signal variable. */
1367
- function isActiveSignal(name: string): boolean {
1368
- return signalVars.has(name) && !shadowedSignals.has(name)
1369
- }
1370
-
1371
- /** Find variable declarations and parameters in a function that shadow signal names. */
1372
- function findShadowingNames(node: N): string[] {
1373
- const shadows: string[] = []
1374
- // Check function parameters
1375
- for (const param of node.params ?? []) {
1376
- if (param.type === 'Identifier' && signalVars.has(param.name)) {
1377
- shadows.push(param.name)
1378
- }
1379
- // Handle destructured parameters: ({ name }) => ...
1380
- if (param.type === 'ObjectPattern') {
1381
- for (const prop of param.properties ?? []) {
1382
- const val = prop.value ?? prop.key
1383
- if (val?.type === 'Identifier' && signalVars.has(val.name)) {
1384
- shadows.push(val.name)
1385
- }
1386
- }
1387
- }
1388
- // Handle array destructured parameters: ([a, b]) => ...
1389
- if (param.type === 'ArrayPattern') {
1390
- for (const el of param.elements ?? []) {
1391
- if (el?.type === 'Identifier' && signalVars.has(el.name)) {
1392
- shadows.push(el.name)
1393
- }
1394
- }
1395
- }
1396
- }
1397
- // Check top-level variable declarations in the function body
1398
- const body = node.body
1399
- const stmts = body?.body ?? body?.statements
1400
- if (!Array.isArray(stmts)) return shadows
1401
- for (const stmt of stmts) {
1402
- if (stmt.type === 'VariableDeclaration') {
1403
- for (const decl of stmt.declarations ?? []) {
1404
- if (decl.id?.type === 'Identifier' && signalVars.has(decl.id.name)) {
1405
- // Only shadow if it's NOT a signal() call
1406
- if (!decl.init || !isSignalCall(decl.init)) {
1407
- shadows.push(decl.id.name)
1408
- }
1409
- }
1410
- }
1411
- }
1412
- }
1413
- return shadows
1414
- }
1415
-
1416
- function readsFromProps(node: N): boolean {
1417
- if (node.type === 'MemberExpression' && node.object?.type === 'Identifier') {
1418
- if (propsNames.has(node.object.name)) return true
1419
- }
1420
- let found = false
1421
- forEachChildFast(node, (child) => {
1422
- if (found) return
1423
- if (readsFromProps(child)) found = true
1424
- })
1425
- return found
1426
- }
1427
-
1428
- /** Check if an expression references any prop-derived variable. */
1429
- function referencesPropDerived(node: N): boolean {
1430
- if (node.type === 'Identifier' && propDerivedVars.has(node.name)) {
1431
- const p = findParent(node)
1432
- if (p && p.type === 'MemberExpression' && p.property === node && !p.computed) return false
1433
- return true
1434
- }
1435
- let found = false
1436
- forEachChildFast(node, (child) => {
1437
- if (found) return
1438
- if (referencesPropDerived(child)) found = true
1439
- })
1440
- return found
1441
- }
1442
-
1443
- /** Collect prop-derived variable info from a VariableDeclaration node.
1444
- * Called inline during the single-pass walk when we encounter a declaration. */
1445
- function collectPropDerivedFromDecl(node: N, callbackDepth: number): void {
1446
- if (node.type !== 'VariableDeclaration') return
1447
- for (const decl of node.declarations ?? []) {
1448
- // splitProps: const [own, rest] = splitProps(props, [...])
1449
- if (decl.id?.type === 'ArrayPattern' && decl.init?.type === 'CallExpression') {
1450
- const callee = decl.init.callee
1451
- if (callee?.type === 'Identifier' && callee.name === 'splitProps') {
1452
- for (const el of decl.id.elements ?? []) {
1453
- if (el?.type === 'Identifier') propsNames.add(el.name)
1454
- }
1455
- }
1456
- }
1457
- // Round 9: track element-valued bindings (`const`/`let`, any depth) so
1458
- // a bare `{x}` child routes to _mountSlot instead of createTextNode.
1459
- // Tight: only a DIRECT JSX element/fragment initializer (optionally
1460
- // parenthesized) — conditionals/calls go the existing reactive/text
1461
- // paths and must not be reclassified here.
1462
- if ((node.kind === 'const' || node.kind === 'let') && decl.id?.type === 'Identifier' && decl.init) {
1463
- let initNode = decl.init
1464
- while (initNode?.type === 'ParenthesizedExpression') initNode = initNode.expression
1465
- if (initNode?.type === 'JSXElement' || initNode?.type === 'JSXFragment') {
1466
- elementVars.add(decl.id.name)
1467
- }
1468
- }
1469
- if (node.kind !== 'const') continue
1470
- if (callbackDepth > 0) continue
1471
- if (decl.id?.type === 'Identifier' && decl.init) {
1472
- if (isStatefulCall(decl.init)) {
1473
- // Track signal() declarations for auto-call in JSX
1474
- if (isSignalCall(decl.init)) signalVars.add(decl.id.name)
1475
- continue
1476
- }
1477
- // Direct prop read OR transitive (references another prop-derived var)
1478
- if (readsFromProps(decl.init) || referencesPropDerived(decl.init)) {
1479
- propDerivedVars.set(decl.id.name, { start: decl.init.start as number, end: decl.init.end as number })
1480
- }
1481
- }
1482
- }
1483
- }
1484
-
1485
- /** Detect component functions and register their first param as a props name.
1486
- * Called inline during the walk when entering a function. */
1487
- function maybeRegisterComponentProps(node: N): void {
1488
- if (
1489
- (node.type === 'FunctionDeclaration' || node.type === 'ArrowFunctionExpression' || node.type === 'FunctionExpression') &&
1490
- (node.params?.length ?? 0) > 0
1491
- ) {
1492
- const parent = findParent(node)
1493
- // Skip callback functions (arguments to calls like .map, .filter)
1494
- if (parent && parent.type === 'CallExpression' && (parent.arguments ?? []).includes(node)) return
1495
- const firstParam = node.params[0]
1496
- if (firstParam?.type === 'Identifier') {
1497
- let hasJSX = false
1498
- function checkJSX(n: N): void {
1499
- if (hasJSX) return
1500
- if (n.type === 'JSXElement' || n.type === 'JSXFragment') { hasJSX = true; return }
1501
- forEachChildFast(n, checkJSX)
1502
- }
1503
- forEachChildFast(node, checkJSX)
1504
- if (hasJSX) propsNames.add(firstParam.name)
1505
- }
1506
- }
1507
- }
1508
-
1509
- // ── String-based transitive resolution ─────────────────────────────────────
1510
- const resolvedCache = new Map<string, string>()
1511
- const resolving = new Set<string>()
1512
- const warnedCycles = new Set<string>()
1513
-
1514
- function resolveVarToString(varName: string, sourceNode?: N): string {
1515
- if (resolvedCache.has(varName)) return resolvedCache.get(varName)!
1516
- if (resolving.has(varName)) {
1517
- const cycleKey = [...resolving, varName].sort().join(',')
1518
- if (!warnedCycles.has(cycleKey)) {
1519
- warnedCycles.add(cycleKey)
1520
- const chain = [...resolving, varName].join(' → ')
1521
- warn(
1522
- sourceNode ?? program,
1523
- `[Pyreon] Circular prop-derived const reference: ${chain}. ` +
1524
- `The cyclic identifier \`${varName}\` will use its captured value ` +
1525
- `instead of being reactively inlined. Break the cycle by reading ` +
1526
- `from \`props.*\` directly or restructuring the derivation chain.`,
1527
- 'circular-prop-derived',
1528
- )
1529
- }
1530
- return varName
1531
- }
1532
- resolving.add(varName)
1533
- const span = propDerivedVars.get(varName)!
1534
- const rawText = code.slice(span.start, span.end)
1535
- const resolved = resolveIdentifiersInText(rawText, span.start, sourceNode)
1536
- resolving.delete(varName)
1537
- resolvedCache.set(varName, resolved)
1538
- return resolved
1539
- }
1540
-
1541
- function resolveIdentifiersInText(text: string, baseOffset: number, sourceNode?: N): string {
1542
- const endOffset = baseOffset + text.length
1543
- const idents: { start: number; end: number; name: string }[] = []
1544
-
1545
- // ── Scope-aware shadow tracking ──────────────────────────────────────────
1546
- // Prop-derived consts are only ever COLLECTED at component top level
1547
- // (callbackDepth === 0), so ANY same-named binding in a deeper lexical
1548
- // scope necessarily shadows it. Substituting a shadowed reference (or a
1549
- // binding occurrence) miscompiles idiomatic code — e.g.
1550
- // `const a = props.x; items.map(a => <li>{a}</li>)` would rewrite the
1551
- // arrow PARAMETER `a` into `(props.x)` (invalid `(props.x) =>`) and the
1552
- // body `{a}` (the map item) into `props.x`. The signal-auto-call pass is
1553
- // already scope-aware via `shadowedSignals`; this mirrors that discipline
1554
- // for the prop-derived inlining pass.
1555
- const shadowed = new Set<string>()
1556
-
1557
- /** Collect identifier names bound by a pattern (params / declarators). */
1558
- function patternBindingNames(p: N, out: string[]): void {
1559
- if (!p) return
1560
- switch (p.type) {
1561
- case 'Identifier':
1562
- out.push(p.name)
1563
- break
1564
- case 'ObjectPattern':
1565
- for (const pr of p.properties ?? []) {
1566
- if (pr.type === 'RestElement') patternBindingNames(pr.argument, out)
1567
- else patternBindingNames(pr.value ?? pr.key, out)
1568
- }
1569
- break
1570
- case 'ArrayPattern':
1571
- for (const el of p.elements ?? []) patternBindingNames(el, out)
1572
- break
1573
- case 'AssignmentPattern':
1574
- patternBindingNames(p.left, out)
1575
- break
1576
- case 'RestElement':
1577
- patternBindingNames(p.argument, out)
1578
- break
1579
- }
1580
- }
1581
-
1582
- /**
1583
- * Prop-derived names bound by `node` FOR ITS OWN SUBTREE (block-accurate
1584
- * lexical scoping). Excludes the prop-derived const's own defining
1585
- * declaration (matched by init span) so the binding we inline FROM is
1586
- * never mistaken for a shadow of itself.
1587
- */
1588
- function scopeBoundPropDerived(node: N): string[] {
1589
- const out: string[] = []
1590
- const t = node.type
1591
- const declNames = (declNode: N): void => {
1592
- for (const d of declNode.declarations ?? []) {
1593
- // The prop-derived defining declaration is NOT a shadow.
1594
- if (d.id?.type === 'Identifier' && propDerivedVars.has(d.id.name)) {
1595
- const span = propDerivedVars.get(d.id.name)!
1596
- if (d.init && (d.init.start as number) === span.start) continue
1597
- }
1598
- patternBindingNames(d.id, out)
1599
- }
1600
- }
1601
- if (
1602
- t === 'ArrowFunctionExpression' ||
1603
- t === 'FunctionExpression' ||
1604
- t === 'FunctionDeclaration'
1605
- ) {
1606
- for (const p of node.params ?? []) patternBindingNames(p, out)
1607
- } else if (t === 'CatchClause') {
1608
- patternBindingNames(node.param, out)
1609
- } else if (t === 'ForStatement') {
1610
- if (node.init?.type === 'VariableDeclaration') declNames(node.init)
1611
- } else if (t === 'ForInStatement' || t === 'ForOfStatement') {
1612
- if (node.left?.type === 'VariableDeclaration') declNames(node.left)
1613
- } else if (t === 'BlockStatement' || t === 'Program' || t === 'StaticBlock') {
1614
- const stmts = node.body ?? node.statements
1615
- if (Array.isArray(stmts)) {
1616
- for (const s of stmts) {
1617
- if (s.type === 'VariableDeclaration') declNames(s)
1618
- else if (s.type === 'FunctionDeclaration' && s.id?.type === 'Identifier') out.push(s.id.name)
1619
- else if (s.type === 'ClassDeclaration' && s.id?.type === 'Identifier') out.push(s.id.name)
1620
- }
1621
- }
1622
- }
1623
- return out.filter((n) => propDerivedVars.has(n))
1624
- }
1625
-
1626
- // Walk the AST to find identifiers in the span, passing parent context
1627
- // to skip non-reference positions (property names, declarations, etc.)
1628
- // and a lexical shadow set so a same-named inner binding is never inlined.
1629
- function findIdents(node: N, parent: N | null): void {
1630
- const nodeStart = node.start as number
1631
- const nodeEnd = node.end as number
1632
- if (nodeStart >= endOffset || nodeEnd <= baseOffset) return
1633
- if (
1634
- node.type === 'Identifier' &&
1635
- propDerivedVars.has(node.name) &&
1636
- !shadowed.has(node.name)
1637
- ) {
1638
- if (parent) {
1639
- if (parent.type === 'MemberExpression' && parent.property === node && !parent.computed) { /* skip */ }
1640
- else if (parent.type === 'VariableDeclarator' && parent.id === node) { /* skip */ }
1641
- else if (parent.type === 'Property' && parent.key === node && !parent.computed) { /* skip */ }
1642
- else if (parent.type === 'Property' && parent.shorthand) { /* skip */ }
1643
- else if (nodeStart >= baseOffset && nodeEnd <= endOffset) {
1644
- idents.push({ start: nodeStart, end: nodeEnd, name: node.name })
1645
- }
1646
- } else if (nodeStart >= baseOffset && nodeEnd <= endOffset) {
1647
- idents.push({ start: nodeStart, end: nodeEnd, name: node.name })
1648
- }
1649
- }
1650
- // Names this node binds for its subtree shadow the top-level prop-derived
1651
- // const within that subtree (and the binding occurrence itself).
1652
- const introduced = scopeBoundPropDerived(node).filter((n) => !shadowed.has(n))
1653
- for (const n of introduced) shadowed.add(n)
1654
- forEachChildFast(node, (child) => findIdents(child, node))
1655
- for (const n of introduced) shadowed.delete(n)
1656
- }
1657
- findIdents(program, null)
1658
-
1659
- if (idents.length === 0) return text
1660
-
1661
- idents.sort((a, b) => a.start - b.start)
1662
- const parts: string[] = []
1663
- let lastPos = baseOffset
1664
- for (const id of idents) {
1665
- parts.push(code.slice(lastPos, id.start))
1666
- parts.push(`(${resolveVarToString(id.name, sourceNode)})`)
1667
- lastPos = id.end
1668
- }
1669
- parts.push(code.slice(lastPos, endOffset))
1670
- return parts.join('')
1671
- }
1672
-
1673
- // ── Analysis helpers with memoization (Phase 3) ────────────────────────────
1674
- // Cache results keyed by node.start (unique per node in a file).
1675
- // Eliminates redundant subtree traversals for containsCall + accessesProps.
1676
- const _isDynamicCache = new Map<number, boolean>()
1677
-
1678
- /** Fused isDynamic: checks both containsCall and accessesProps in one traversal. */
1679
- function isDynamic(node: N): boolean {
1680
- const key = node.start as number
1681
- const cached = _isDynamicCache.get(key)
1682
- if (cached !== undefined) return cached
1683
- const result = _isDynamicImpl(node)
1684
- _isDynamicCache.set(key, result)
1685
- return result
1686
- }
1687
-
1688
- function _isDynamicImpl(node: N): boolean {
1689
- // Call expression (non-pure)
1690
- if (node.type === 'CallExpression') {
1691
- if (!isPureStaticCall(node)) return true
1692
- }
1693
- if (node.type === 'TaggedTemplateExpression') return true
1694
- // Props access
1695
- if (node.type === 'MemberExpression' && !node.computed && node.object?.type === 'Identifier') {
1696
- if (propsNames.has(node.object.name)) return true
1697
- }
1698
- // Prop-derived variable reference
1699
- if (node.type === 'Identifier' && propDerivedVars.has(node.name)) {
1700
- const parent = findParent(node)
1701
- if (parent && parent.type === 'MemberExpression' && parent.property === node && !parent.computed) {
1702
- // This is a property name position, not a reference — fall through
1703
- } else {
1704
- return true
1705
- }
1706
- }
1707
- // Signal variable reference — treated as dynamic (will be auto-called)
1708
- if (node.type === 'Identifier' && isActiveSignal(node.name)) {
1709
- const parent = findParent(node)
1710
- if (parent && parent.type === 'MemberExpression' && parent.property === node && !parent.computed) {
1711
- // Property name position — not a reference
1712
- } else if (parent && parent.type === 'CallExpression' && parent.callee === node) {
1713
- // Already being called: signal() — don't double-flag
1714
- } else {
1715
- return true
1716
- }
1717
- }
1718
- // Don't recurse into nested functions
1719
- if (node.type === 'ArrowFunctionExpression' || node.type === 'FunctionExpression') return false
1720
- // Recurse into children
1721
- let found = false
1722
- forEachChildFast(node, (child) => {
1723
- if (found) return
1724
- if (isDynamic(child)) found = true
1725
- })
1726
- return found
1727
- }
1728
-
1729
- /** accessesProps — kept for sliceExpr's quick check (does this need resolution?) */
1730
- function accessesProps(node: N): boolean {
1731
- if (node.type === 'MemberExpression' && !node.computed && node.object?.type === 'Identifier') {
1732
- if (propsNames.has(node.object.name)) return true
1733
- }
1734
- if (node.type === 'Identifier' && propDerivedVars.has(node.name)) {
1735
- const parent = findParent(node)
1736
- if (parent && parent.type === 'MemberExpression' && parent.property === node && !parent.computed) return false
1737
- return true
1738
- }
1739
- let found = false
1740
- forEachChildFast(node, (child) => {
1741
- if (found) return
1742
- if (child.type === 'ArrowFunctionExpression' || child.type === 'FunctionExpression') return
1743
- if (accessesProps(child)) found = true
1744
- })
1745
- return found
1746
- }
1747
-
1748
- function shouldWrap(node: N): boolean {
1749
- if (node.type === 'ArrowFunctionExpression' || node.type === 'FunctionExpression') return false
1750
- if (isStatic(node)) return false
1751
- if (node.type === 'CallExpression' && isPureStaticCall(node)) return false
1752
- return isDynamic(node)
1753
- }
1754
-
1755
- // ── Single unified walk (Phase 2) ─────────────────────────────────────────
1756
- // Merges the old 3-pass architecture (scanForPropDerivedVars + transitive
1757
- // resolution + JSX walk) into one top-down traversal. Works because `const`
1758
- // declarations have a temporal dead zone — they're always before their use.
1759
- let _callbackDepth = 0
1760
-
1761
- function walkNode(node: N): void {
1762
- // ── Component function detection (was pass 1) ──
1763
- const isFunction = node.type === 'FunctionDeclaration' || node.type === 'ArrowFunctionExpression' || node.type === 'FunctionExpression'
1764
- let scopeShadows: string[] | null = null
1765
- if (isFunction) {
1766
- // Track callback nesting for prop-derived var exclusion
1767
- const parent = findParent(node)
1768
- const isCallbackArg = parent && parent.type === 'CallExpression' && (parent.arguments ?? []).includes(node)
1769
- if (isCallbackArg) _callbackDepth++
1770
- // Register component props (only for non-callback functions with JSX)
1771
- maybeRegisterComponentProps(node)
1772
- // Track signal name shadowing for scope awareness
1773
- if (signalVars.size > 0) {
1774
- scopeShadows = findShadowingNames(node)
1775
- for (const name of scopeShadows) shadowedSignals.add(name)
1776
- }
1777
- }
1778
-
1779
- // ── Variable declaration collection (was pass 1 + 2) ──
1780
- if (node.type === 'VariableDeclaration') {
1781
- collectPropDerivedFromDecl(node, _callbackDepth)
1782
- }
1783
-
1784
- // ── JSX processing (was pass 3) ──
1785
- if (node.type === 'JSXElement') {
1786
- if (tryRocketstyleCollapse(node)) {
1787
- // Collapsed to _rsCollapse — children are baked into the SSR-
1788
- // resolved template; do not recurse into the subtree.
1789
- return
1790
- }
1791
- if (!isSelfClosing(node) && tryTemplateEmit(node)) {
1792
- // Template emitted — don't recurse into this subtree (JSXElement is never a function)
1793
- return
1794
- }
1795
- checkForWarnings(node)
1796
- for (const attr of jsxAttrs(node)) {
1797
- if (attr.type === 'JSXAttribute') handleJsxAttribute(attr, node)
1798
- else if (attr.type === 'JSXSpreadAttribute') handleJsxSpreadAttribute(attr, node)
1799
- }
1800
- for (const child of jsxChildren(node)) {
1801
- if (child.type === 'JSXExpressionContainer') handleJsxExpression(child, node)
1802
- else walkNode(child)
1803
- }
1804
- // Note: JSXElement is never a function, so no callback depth or scope cleanup needed here
1805
- return
1806
- }
1807
- if (node.type === 'JSXExpressionContainer') {
1808
- handleJsxExpression(node)
1809
- // Note: JSXExpressionContainer is never a function, no scope cleanup needed
1810
- return
1811
- }
1812
-
1813
- // Generic descent
1814
- forEachChildFast(node, walkNode)
1815
-
1816
- // Restore callback depth after leaving function
1817
- if (isFunction) {
1818
- const parent = findParent(node)
1819
- if (parent && parent.type === 'CallExpression' && (parent.arguments ?? []).includes(node)) _callbackDepth--
1820
- }
1821
- // Restore signal shadowing
1822
- if (scopeShadows) for (const name of scopeShadows) shadowedSignals.delete(name)
1823
- }
1824
-
1825
- walkNode(program)
1826
-
1827
- if (replacements.length === 0 && hoists.length === 0) {
1828
- return collectLens ? { code, warnings, reactivityLens } : { code, warnings }
1829
- }
1830
-
1831
- replacements.sort((a, b) => a.start - b.start)
1832
- // R12 fix: apply the disjoint, sorted {start,end,text} edits through
1833
- // MagicString instead of manual slice/join. `toString()` is byte-identical
1834
- // to the old concatenation (the full 1200-test suite + native-equivalence
1835
- // assert exact emitted strings), but `generateMap()` now yields a correct
1836
- // V3 source map — the previous transform emitted none AND shifted line
1837
- // counts (template emission expands one-line JSX into a multi-line _tpl
1838
- // factory), so every stack frame / breakpoint in a Pyreon component
1839
- // mislocated app-wide.
1840
- const s = new MagicString(code)
1841
- for (const r of replacements) {
1842
- if (r.start === r.end) s.appendLeft(r.start, r.text)
1843
- else s.update(r.start, r.end, r.text)
1844
- }
1845
-
1846
- // Build the generated preamble (hoists + auto-imports + collapse prologue)
1847
- // in the SAME final top-to-bottom order the previous chained `X + output`
1848
- // produced, then `prepend` it ONCE. magic-string's prepend shifts every
1849
- // source mapping down by the preamble's line count, so original positions
1850
- // resolve to the correct OUTPUT lines despite the inserted preamble — the
1851
- // exact line-shift R12 measured. Innermost (closest to code) first.
1852
- let preamble = ''
1853
-
1854
- if (hoists.length > 0) {
1855
- preamble = hoists.map((h) => `const ${h.name} = /*@__PURE__*/ ${h.text}\n`).join('') + preamble
1856
- }
1857
-
1858
- if (needsTplImport) {
1859
- const runtimeDomImports = ['_tpl']
1860
- if (needsBindDirectImportGlobal) runtimeDomImports.push('_bindDirect')
1861
- if (needsBindTextImportGlobal) runtimeDomImports.push('_bindText')
1862
- if (needsApplyPropsImportGlobal) runtimeDomImports.push('_applyProps')
1863
- if (needsMountSlotImportGlobal) runtimeDomImports.push('_mountSlot')
1864
- const reactivityImports = needsBindImportGlobal
1865
- ? `\nimport { _bind } from "@pyreon/reactivity";`
1866
- : ''
1867
- preamble =
1868
- `import { ${runtimeDomImports.join(', ')} } from "@pyreon/runtime-dom";${reactivityImports}\n` +
1869
- preamble
1870
- }
1871
-
1872
- if (needsRpImport || needsWrapSpreadImport) {
1873
- const coreImports: string[] = []
1874
- if (needsRpImport) coreImports.push('_rp')
1875
- if (needsWrapSpreadImport) coreImports.push('_wrapSpread')
1876
- preamble = `import { ${coreImports.join(', ')} } from "@pyreon/core";\n` + preamble
1877
- }
1878
-
1879
- if (needsCollapse || needsCollapseDyn || needsCollapseDynH) {
1880
- const cfg = options.collapseRocketstyle!
1881
- const rd = cfg.runtimeDomSource ?? '@pyreon/runtime-dom'
1882
- const st = cfg.stylerSource ?? '@pyreon/styler'
1883
- // One idempotent injectRules per distinct rule bundle — keyed by the
1884
- // resolver's FNV so a re-eval (HMR) or another module's identical
1885
- // bundle is a no-op (styler dedupes by key). Runs at module-eval,
1886
- // before any collapsed site mounts, so the sheet is populated
1887
- // without a prior runtime mount of the real component.
1888
- const inj = collapseRules
1889
- .map(
1890
- (r) =>
1891
- `__rsSheet.injectRules(${JSON.stringify(r.rules)},${JSON.stringify(r.ruleKey)});`,
1892
- )
1893
- .join('')
1894
- // Only import the helpers actually emitted into this module — keeps
1895
- // the bundle bytes per-feature and tree-shakable. needsCollapse
1896
- // (full) gates `_rsCollapse`; the partial / dynamic flags gate
1897
- // their respective helpers independently.
1898
- const rdImports: string[] = []
1899
- if (needsCollapse) rdImports.push('_rsCollapse as __rsCollapse')
1900
- if (needsCollapseH) rdImports.push('_rsCollapseH as __rsCollapseH')
1901
- if (needsCollapseDyn) rdImports.push('_rsCollapseDyn as __rsCollapseDyn')
1902
- if (needsCollapseDynH) rdImports.push('_rsCollapseDynH as __rsCollapseDynH')
1903
- preamble =
1904
- `import { ${rdImports.join(', ')} } from "${rd}";\n` +
1905
- `import { sheet as __rsSheet } from "${st}";\n` +
1906
- `import { ${cfg.mode.name} as __pyrMode } from "${cfg.mode.source}";\n` +
1907
- `${inj}\n` +
1908
- preamble
1909
- }
1910
-
1911
- if (preamble) s.prepend(preamble)
1912
-
1913
- const output = s.toString()
1914
- const map = s.generateMap({
1915
- source: filename,
1916
- includeContent: true,
1917
- hires: true,
1918
- }) as unknown as GeneratedSourceMap
1919
-
1920
- return collectLens
1921
- ? { code: output, usesTemplates: needsTplImport, warnings, map, reactivityLens }
1922
- : { code: output, usesTemplates: needsTplImport, warnings, map }
1923
-
1924
- // ── Template emission helpers ─────────────────────────────────────────────
1925
-
1926
- function hasBailAttr(node: N, isRoot = false): boolean {
1927
- for (const attr of jsxAttrs(node)) {
1928
- if (attr.type === 'JSXSpreadAttribute') {
1929
- if (isRoot) continue
1930
- return true
1931
- }
1932
- if (attr.type === 'JSXAttribute' && attr.name?.type === 'JSXIdentifier' && attr.name.name === 'key')
1933
- return true
1934
- }
1935
- return false
1936
- }
1937
-
1938
- function countChildForTemplate(child: N): number {
1939
- if (child.type === 'JSXText') return 0
1940
- if (child.type === 'JSXElement') return templateElementCount(child)
1941
- if (child.type === 'JSXExpressionContainer') {
1942
- const expr = child.expression
1943
- if (!expr || expr.type === 'JSXEmptyExpression') return 0
1944
- return containsJSXInExpr(expr) ? -1 : 0
1945
- }
1946
- if (child.type === 'JSXFragment') return templateFragmentCount(child)
1947
- return -1
1948
- }
1949
-
1950
- function templateElementCount(node: N, isRoot = false): number {
1951
- const tag = jsxTagName(node)
1952
- if (!tag || !isLowerCase(tag)) return -1
1953
- if (hasBailAttr(node, isRoot)) return -1
1954
- if (isSelfClosing(node)) return 1
1955
- let count = 1
1956
- for (const child of jsxChildren(node)) {
1957
- const c = countChildForTemplate(child)
1958
- if (c === -1) return -1
1959
- count += c
1960
- }
1961
- return count
1962
- }
1963
-
1964
- function templateFragmentCount(frag: N): number {
1965
- let count = 0
1966
- for (const child of jsxChildren(frag)) {
1967
- const c = countChildForTemplate(child)
1968
- if (c === -1) return -1
1969
- count += c
1970
- }
1971
- return count
1972
- }
1973
-
1974
- function buildTemplateCall(node: N): string | null {
1975
- const bindLines: string[] = []
1976
- const disposerNames: string[] = []
1977
- let varIdx = 0
1978
- let dispIdx = 0
1979
- const reactiveBindExprs: string[] = []
1980
- let needsBindTextImport = false
1981
- let needsBindDirectImport = false
1982
- let needsApplyPropsImport = false
1983
- let needsMountSlotImport = false
1984
-
1985
- function nextVar(): string { return `__e${varIdx++}` }
1986
- function nextDisp(): string {
1987
- const name = `__d${dispIdx++}`
1988
- disposerNames.push(name)
1989
- return name
1990
- }
1991
- function nextTextVar(): string { return `__t${varIdx++}` }
1992
-
1993
- function resolveElementVar(accessor: string, hasDynamic: boolean): string {
1994
- if (accessor === '__root') return '__root'
1995
- if (hasDynamic) {
1996
- const v = nextVar()
1997
- bindLines.push(`const ${v} = ${accessor}`)
1998
- return v
1999
- }
2000
- return accessor
2001
- }
2002
-
2003
- function emitRef(attr: N, varName: string): void {
2004
- if (!attr.value || attr.value.type !== 'JSXExpressionContainer') return
2005
- const expr = attr.value.expression
2006
- if (!expr || expr.type === 'JSXEmptyExpression') return
2007
- if (expr.type === 'ArrowFunctionExpression' || expr.type === 'FunctionExpression') {
2008
- bindLines.push(`(${sliceExpr(expr)})(${varName})`)
2009
- } else {
2010
- bindLines.push(
2011
- `{ const __r = ${sliceExpr(expr)}; if (typeof __r === "function") __r(${varName}); else if (__r) __r.current = ${varName} }`,
2012
- )
2013
- }
2014
- }
2015
-
2016
- function emitEventListener(attr: N, attrName: string, varName: string): void {
2017
- // Translate the JSX-style React attribute name (e.g. `onKeyDown`,
2018
- // `onDoubleClick`) to the canonical DOM event name (`keydown`,
2019
- // `dblclick`).
2020
- //
2021
- // The default rule is "drop the `on` prefix and lowercase" —
2022
- // covers `onKeyDown` → `keydown`, `onMouseEnter` → `mouseenter`,
2023
- // `onPointerLeave` → `pointerleave`, `onAnimationStart` →
2024
- // `animationstart`, etc. Most React event names follow this rule
2025
- // because the underlying DOM event name is also the lowercased
2026
- // multi-word form.
2027
- //
2028
- // The exception list lives in `REACT_EVENT_REMAP` (event-names.ts).
2029
- // Every React event-prop in the official component-prop list was
2030
- // audited against canonical DOM event names — see the JSDoc on
2031
- // REACT_EVENT_REMAP for the audit. Today exactly one entry:
2032
- // `onDoubleClick` → `dblclick`
2033
- // The Rust native backend (`native/src/lib.rs:emit_event_listener`)
2034
- // mirrors the same table — keep them in sync if a new entry is added.
2035
- const lowered = attrName.slice(2).toLowerCase()
2036
- const eventName = REACT_EVENT_REMAP[lowered] ?? lowered
2037
- if (!attr.value || attr.value.type !== 'JSXExpressionContainer') return
2038
- const expr = attr.value.expression
2039
- if (!expr || expr.type === 'JSXEmptyExpression') return
2040
- const handler = sliceExpr(expr)
2041
- if (DELEGATED_EVENTS.has(eventName)) {
2042
- bindLines.push(`${varName}.__ev_${eventName} = ${handler}`)
2043
- } else {
2044
- bindLines.push(`${varName}.addEventListener("${eventName}", ${handler})`)
2045
- }
2046
- }
2047
-
2048
- function staticAttrToHtml(exprNode: N, htmlAttrName: string): string | null {
2049
- if (!isStatic(exprNode)) return null
2050
- // String literal
2051
- if ((exprNode.type === 'Literal' || exprNode.type === 'StringLiteral') && typeof exprNode.value === 'string')
2052
- return ` ${htmlAttrName}="${escapeHtmlAttr(exprNode.value)}"`
2053
- // Numeric literal
2054
- if ((exprNode.type === 'Literal' || exprNode.type === 'NumericLiteral') && typeof exprNode.value === 'number')
2055
- return ` ${htmlAttrName}="${exprNode.value}"`
2056
- // Boolean true
2057
- if ((exprNode.type === 'Literal' || exprNode.type === 'BooleanLiteral') && exprNode.value === true)
2058
- return ` ${htmlAttrName}`
2059
- return '' // false/null/undefined → omit
2060
- }
2061
-
2062
- function tryDirectSignalRef(exprNode: N): string | null {
2063
- let inner = exprNode
2064
- if (inner.type === 'ArrowFunctionExpression' && inner.body?.type !== 'BlockStatement') {
2065
- inner = inner.body
2066
- }
2067
- if (inner.type !== 'CallExpression') return null
2068
- if ((inner.arguments?.length ?? 0) > 0) return null
2069
- const callee = inner.callee
2070
- if (callee?.type === 'Identifier') return sliceExpr(callee)
2071
- return null
2072
- }
2073
-
2074
- function unwrapAccessor(exprNode: N): { expr: string; isReactive: boolean } {
2075
- if (exprNode.type === 'ArrowFunctionExpression' && exprNode.body?.type !== 'BlockStatement') {
2076
- return { expr: sliceExpr(exprNode.body), isReactive: true }
2077
- }
2078
- if (exprNode.type === 'ArrowFunctionExpression' || exprNode.type === 'FunctionExpression') {
2079
- return { expr: `(${sliceExpr(exprNode)})()`, isReactive: true }
2080
- }
2081
- return { expr: sliceExpr(exprNode), isReactive: isDynamic(exprNode) }
2082
- }
2083
-
2084
- function attrSetter(htmlAttrName: string, varName: string, expr: string): string {
2085
- if (htmlAttrName === 'class') return `${varName}.className = ${expr}`
2086
- if (htmlAttrName === 'style') return `${varName}.style.cssText = ${expr}`
2087
- if (DOM_PROPS.has(htmlAttrName)) return `${varName}.${htmlAttrName} = ${expr}`
2088
- return `${varName}.setAttribute("${htmlAttrName}", ${expr})`
2089
- }
2090
-
2091
- function emitDynamicAttr(_expr: string, exprNode: N, htmlAttrName: string, varName: string): void {
2092
- const { expr, isReactive } = unwrapAccessor(exprNode)
2093
- if (!isReactive) {
2094
- bindLines.push(attrSetter(htmlAttrName, varName, expr))
2095
- return
2096
- }
2097
- lens(
2098
- exprNode.start as number,
2099
- exprNode.end as number,
2100
- 'reactive-attr',
2101
- `live attribute — \`${htmlAttrName}\` re-applies whenever its signals change`,
2102
- )
2103
- const directRef = tryDirectSignalRef(exprNode)
2104
- if (directRef) {
2105
- needsBindDirectImport = true
2106
- const d = nextDisp()
2107
- const updater =
2108
- htmlAttrName === 'class'
2109
- ? `(v) => { ${varName}.className = v == null ? "" : String(v) }`
2110
- : htmlAttrName === 'style'
2111
- ? `(v) => { if (typeof v === "string") ${varName}.style.cssText = v; else if (v) Object.assign(${varName}.style, v) }`
2112
- : DOM_PROPS.has(htmlAttrName)
2113
- ? `(v) => { ${varName}.${htmlAttrName} = v }`
2114
- : `(v) => { ${varName}.setAttribute("${htmlAttrName}", v == null ? "" : String(v)) }`
2115
- bindLines.push(`const ${d} = _bindDirect(${directRef}, ${updater})`)
2116
- return
2117
- }
2118
- reactiveBindExprs.push(attrSetter(htmlAttrName, varName, expr))
2119
- }
2120
-
2121
- function emitAttrExpression(exprNode: N, htmlAttrName: string, varName: string): string {
2122
- const staticHtml = staticAttrToHtml(exprNode, htmlAttrName)
2123
- if (staticHtml !== null) return staticHtml
2124
- if (htmlAttrName === 'style' && exprNode.type === 'ObjectExpression') {
2125
- bindLines.push(`Object.assign(${varName}.style, ${sliceExpr(exprNode)})`)
2126
- return ''
2127
- }
2128
- emitDynamicAttr(sliceExpr(exprNode), exprNode, htmlAttrName, varName)
2129
- return ''
2130
- }
2131
-
2132
- function tryEmitSpecialAttr(attr: N, attrName: string, varName: string): boolean {
2133
- if (attrName === 'ref') { emitRef(attr, varName); return true }
2134
- if (EVENT_RE.test(attrName)) { emitEventListener(attr, attrName, varName); return true }
2135
- return false
2136
- }
2137
-
2138
- function attrInitializerToHtml(attr: N, htmlAttrName: string, varName: string): string {
2139
- if (!attr.value) return ` ${htmlAttrName}`
2140
- // JSX string attribute: class="foo"
2141
- if (attr.value.type === 'StringLiteral' || (attr.value.type === 'Literal' && typeof attr.value.value === 'string'))
2142
- return ` ${htmlAttrName}="${escapeHtmlAttr(attr.value.value)}"`
2143
- if (attr.value.type === 'JSXExpressionContainer') {
2144
- const expr = attr.value.expression
2145
- if (expr && expr.type !== 'JSXEmptyExpression') return emitAttrExpression(expr, htmlAttrName, varName)
2146
- }
2147
- return ''
2148
- }
2149
-
2150
- function processOneAttr(attr: N, varName: string): string {
2151
- if (attr.type === 'JSXSpreadAttribute') {
2152
- const expr = sliceExpr(attr.argument)
2153
- needsApplyPropsImport = true
2154
- if (isDynamic(attr.argument)) {
2155
- reactiveBindExprs.push(`_applyProps(${varName}, ${expr})`)
2156
- } else {
2157
- bindLines.push(`_applyProps(${varName}, ${expr})`)
2158
- }
2159
- return ''
2160
- }
2161
- if (attr.type !== 'JSXAttribute') return ''
2162
- const attrName = attr.name?.type === 'JSXIdentifier' ? attr.name.name : ''
2163
- if (attrName === 'key') return ''
2164
- if (tryEmitSpecialAttr(attr, attrName, varName)) return ''
2165
- return attrInitializerToHtml(attr, JSX_TO_HTML_ATTR[attrName] ?? attrName, varName)
2166
- }
2167
-
2168
- function processAttrs(el: N, varName: string): string {
2169
- let htmlAttrs = ''
2170
- for (const attr of jsxAttrs(el)) htmlAttrs += processOneAttr(attr, varName)
2171
- return htmlAttrs
2172
- }
2173
-
2174
- function emitReactiveTextChild(
2175
- expr: string, exprNode: N, varName: string,
2176
- parentRef: string, childNodeIdx: number, needsPlaceholder: boolean,
2177
- ): string {
2178
- const tVar = nextTextVar()
2179
- bindLines.push(`const ${tVar} = document.createTextNode("")`)
2180
- if (needsPlaceholder) {
2181
- bindLines.push(`${parentRef}.replaceChild(${tVar}, ${parentRef}.childNodes[${childNodeIdx}])`)
2182
- } else {
2183
- bindLines.push(`${varName}.appendChild(${tVar})`)
2184
- }
2185
- const directRef = tryDirectSignalRef(exprNode)
2186
- if (directRef) {
2187
- needsBindTextImport = true
2188
- const d = nextDisp()
2189
- bindLines.push(`const ${d} = _bindText(${directRef}, ${tVar})`)
2190
- } else {
2191
- needsBindImportGlobal = true
2192
- const d = nextDisp()
2193
- bindLines.push(`const ${d} = _bind(() => { ${tVar}.data = ${expr} })`)
2194
- }
2195
- return needsPlaceholder ? '<!>' : ''
2196
- }
2197
-
2198
- function emitStaticTextChild(
2199
- expr: string, varName: string,
2200
- parentRef: string, childNodeIdx: number, needsPlaceholder: boolean,
2201
- ): string {
2202
- if (needsPlaceholder) {
2203
- const tVar = nextTextVar()
2204
- bindLines.push(`const ${tVar} = document.createTextNode(${expr})`)
2205
- bindLines.push(`${parentRef}.replaceChild(${tVar}, ${parentRef}.childNodes[${childNodeIdx}])`)
2206
- return '<!>'
2207
- }
2208
- bindLines.push(`${varName}.textContent = ${expr}`)
2209
- return ''
2210
- }
2211
-
2212
- type FlatChild =
2213
- | { kind: 'text'; text: string }
2214
- | { kind: 'element'; node: N; elemIdx: number }
2215
- | { kind: 'expression'; expression: N }
2216
-
2217
- function classifyJsxChild(
2218
- child: N, out: FlatChild[],
2219
- elemIdxRef: { value: number },
2220
- recurse: (kids: N[]) => void,
2221
- ): void {
2222
- if (child.type === 'JSXText') {
2223
- const raw = child.value ?? child.raw ?? ''
2224
- const cleaned = cleanJsxText(raw)
2225
- if (cleaned) out.push({ kind: 'text', text: cleaned })
2226
- return
2227
- }
2228
- if (child.type === 'JSXElement') {
2229
- out.push({ kind: 'element', node: child, elemIdx: elemIdxRef.value++ })
2230
- return
2231
- }
2232
- if (child.type === 'JSXExpressionContainer') {
2233
- const expr = child.expression
2234
- if (expr && expr.type !== 'JSXEmptyExpression') out.push({ kind: 'expression', expression: expr })
2235
- return
2236
- }
2237
- if (child.type === 'JSXFragment') recurse(jsxChildren(child))
2238
- }
2239
-
2240
- function flattenChildren(children: N[]): FlatChild[] {
2241
- const flatList: FlatChild[] = []
2242
- const elemIdxRef = { value: 0 }
2243
- function addChildren(kids: N[]): void {
2244
- for (const child of kids) classifyJsxChild(child, flatList, elemIdxRef, addChildren)
2245
- }
2246
- addChildren(children)
2247
- return flatList
2248
- }
2249
-
2250
- function analyzeChildren(flatChildren: FlatChild[]): { useMixed: boolean; useMultiExpr: boolean } {
2251
- const hasElem = flatChildren.some((c) => c.kind === 'element')
2252
- const hasText = flatChildren.some((c) => c.kind === 'text')
2253
- const exprCount = flatChildren.filter((c) => c.kind === 'expression').length
2254
- // `useMixed` triggers placeholder-based positional mounting (each
2255
- // dynamic child gets a `<!>` comment slot in the template that
2256
- // `replaceChild`-replaces at mount). It must fire whenever ≥2 of
2257
- // {element, text, expression} are interleaved — otherwise dynamic
2258
- // text nodes added via `appendChild` land after all static
2259
- // template content, breaking source-order rendering for shapes
2260
- // like `<p>foo {x()} bar</p>` (rendered "foo barX" instead of
2261
- // "foo X bar"). Discovered by Phase B2's whitespace tests.
2262
- const present =
2263
- (hasElem ? 1 : 0) + (hasText ? 1 : 0) + (exprCount > 0 ? 1 : 0)
2264
- return { useMixed: present > 1, useMultiExpr: exprCount > 1 }
2265
- }
2266
-
2267
- function attrIsDynamic(attr: N): boolean {
2268
- if (attr.type !== 'JSXAttribute') return false
2269
- const name = attr.name?.type === 'JSXIdentifier' ? attr.name.name : ''
2270
- if (name === 'ref') return true
2271
- if (EVENT_RE.test(name)) return true
2272
- if (!attr.value || attr.value.type !== 'JSXExpressionContainer') return false
2273
- const expr = attr.value.expression
2274
- return expr && expr.type !== 'JSXEmptyExpression' ? !isStatic(expr) : false
2275
- }
2276
-
2277
- function elementHasDynamic(node: N): boolean {
2278
- if (jsxAttrs(node).some(attrIsDynamic)) return true
2279
- if (!isSelfClosing(node)) {
2280
- return jsxChildren(node).some((c: N) =>
2281
- c.type === 'JSXExpressionContainer' && c.expression && c.expression.type !== 'JSXEmptyExpression',
2282
- )
2283
- }
2284
- return false
2285
- }
2286
-
2287
- function processOneChild(
2288
- child: FlatChild, varName: string, parentRef: string,
2289
- useMixed: boolean, useMultiExpr: boolean, childNodeIdx: number,
2290
- ): string | null {
2291
- if (child.kind === 'text') return escapeHtmlText(child.text)
2292
- if (child.kind === 'element') {
2293
- const childAccessor = useMixed
2294
- ? `${parentRef}.childNodes[${childNodeIdx}]`
2295
- : `${parentRef}.children[${child.elemIdx}]`
2296
- return processElement(child.node, childAccessor)
2297
- }
2298
- const needsPlaceholder = useMixed || useMultiExpr
2299
- const { expr, isReactive } = unwrapAccessor(child.expression)
2300
- // Round 9 fix: a bare `{el}` where `el` is an element-valued binding
2301
- // (`const el = <X/>`) must be MOUNTED via _mountSlot, not text-coerced
2302
- // via createTextNode (which stringifies the NativeItem). Same emission
2303
- // as the children-slot path; _mountSlot handles every child type.
2304
- const isElementValuedIdent =
2305
- (child.expression?.type === 'Identifier' && elementVars.has(child.expression.name)) ||
2306
- (!isReactive && /^[A-Za-z_$][\w$]*$/.test(expr) && elementVars.has(expr))
2307
- if (isChildrenExpression(child.expression, expr) || isElementValuedIdent) {
2308
- needsMountSlotImport = true
2309
- const placeholder = `${parentRef}.childNodes[${childNodeIdx}]`
2310
- const d = nextDisp()
2311
- bindLines.push(`const ${d} = _mountSlot(${expr}, ${parentRef}, ${placeholder})`)
2312
- return '<!>'
2313
- }
2314
- const cx = child.expression
2315
- if (isReactive) {
2316
- lens(
2317
- cx.start as number,
2318
- cx.end as number,
2319
- 'reactive',
2320
- 'live — this text re-renders whenever its signals change',
2321
- )
2322
- return emitReactiveTextChild(expr, child.expression, varName, parentRef, childNodeIdx, needsPlaceholder)
2323
- }
2324
- lens(
2325
- cx.start as number,
2326
- cx.end as number,
2327
- 'static-text',
2328
- 'baked once into the DOM — never re-renders (no signal read here)',
2329
- )
2330
- return emitStaticTextChild(expr, varName, parentRef, childNodeIdx, needsPlaceholder)
2331
- }
2332
-
2333
- function processChildren(el: N, varName: string, accessor: string): string | null {
2334
- const flatChildren = flattenChildren(jsxChildren(el))
2335
- const { useMixed, useMultiExpr } = analyzeChildren(flatChildren)
2336
- const parentRef = accessor === '__root' ? '__root' : varName
2337
- let html = ''
2338
- let childNodeIdx = 0
2339
- for (const child of flatChildren) {
2340
- const childHtml = processOneChild(child, varName, parentRef, useMixed, useMultiExpr, childNodeIdx)
2341
- if (childHtml === null) return null
2342
- html += childHtml
2343
- childNodeIdx++
2344
- }
2345
- return html
2346
- }
2347
-
2348
- function processElement(el: N, accessor: string): string | null {
2349
- const tag = jsxTagName(el)
2350
- if (!tag) return null
2351
- const varName = resolveElementVar(accessor, elementHasDynamic(el))
2352
- const htmlAttrs = processAttrs(el, varName)
2353
- let html = `<${tag}${htmlAttrs}>`
2354
- if (!isSelfClosing(el)) {
2355
- const childHtml = processChildren(el, varName, accessor)
2356
- if (childHtml === null) return null
2357
- html += childHtml
2358
- }
2359
- if (!VOID_ELEMENTS.has(tag)) html += `</${tag}>`
2360
- return html
2361
- }
2362
-
2363
- const html = processElement(node, '__root')
2364
- if (html === null) return null
2365
-
2366
- if (needsBindTextImport) needsBindTextImportGlobal = true
2367
- if (needsBindDirectImport) needsBindDirectImportGlobal = true
2368
- if (needsApplyPropsImport) needsApplyPropsImportGlobal = true
2369
- if (needsMountSlotImport) needsMountSlotImportGlobal = true
2370
-
2371
- const escaped = html.replace(/\\/g, '\\\\').replace(/"/g, '\\"')
2372
-
2373
- if (reactiveBindExprs.length > 0) {
2374
- needsBindImportGlobal = true
2375
- const combinedName = nextDisp()
2376
- const combinedBody = reactiveBindExprs.join('; ')
2377
- bindLines.push(`const ${combinedName} = _bind(() => { ${combinedBody} })`)
2378
- }
2379
-
2380
- if (bindLines.length === 0 && disposerNames.length === 0) {
2381
- return `_tpl("${escaped}", () => null)`
2382
- }
2383
-
2384
- // Append `;` to every bind line so ASI can't merge consecutive
2385
- // statements when the next line starts with `(`, `[`, etc.
2386
- // Concrete bug shape (pre-fix): a child element with `hasDynamic=true`
2387
- // emits `const __e0 = __root.children[N]` followed by a ref-callback
2388
- // line `((el) => { x = el })(__e0)`. JS does NOT insert ASI here
2389
- // because `__root.children[N]((el) => ...)` is a valid expression,
2390
- // so the parser merges them into a single function call:
2391
- // `const __e0 = __root.children[N]((el) => ...)(__e0)`
2392
- // — calling `children[N]` as a function with the arrow as argument,
2393
- // and self-referencing `__e0` before assignment. Adding the `;`
2394
- // terminates each statement deterministically. Trailing `;` after
2395
- // a `{...}` block is a harmless empty statement.
2396
- // Append `;` to every bind line so ASI can't merge consecutive
2397
- // statements when the next line starts with `(`, `[`, etc.
2398
- // Concrete bug shape (pre-fix): a child element with `hasDynamic=true`
2399
- // emits `const __e0 = __root.children[N]` followed by a ref-callback
2400
- // line `((el) => { x = el })(__e0)`. JS does NOT insert ASI here
2401
- // because `__root.children[N]((el) => ...)` is a valid expression,
2402
- // so the parser merges them into a single function call:
2403
- // `const __e0 = __root.children[N]((el) => ...)(__e0)`
2404
- // — calling `children[N]` as a function with the arrow as argument,
2405
- // and self-referencing `__e0` before assignment. Adding the `;`
2406
- // terminates each statement deterministically. Trailing `;` after
2407
- // a `{...}` block is a harmless empty statement.
2408
- let body = bindLines.map((l) => ` ${l};`).join('\n')
2409
- if (disposerNames.length > 0) {
2410
- body += `\n return () => { ${disposerNames.map((d) => `${d}()`).join('; ')} }`
2411
- } else {
2412
- body += '\n return null'
2413
- }
2414
-
2415
- return `_tpl("${escaped}", (__root) => {\n${body}\n})`
2416
- }
2417
-
2418
- function sliceExpr(expr: N): string {
2419
- let result: string
2420
- if (propDerivedVars.size > 0 && accessesProps(expr)) {
2421
- const start = expr.start as number
2422
- const end = expr.end as number
2423
- result = resolveIdentifiersInText(code.slice(start, end), start, expr)
2424
- } else {
2425
- result = code.slice(expr.start as number, expr.end as number)
2426
- }
2427
-
2428
- // Auto-call signal variables: replace bare `x` with `x()` in the expression.
2429
- // Only applies to identifiers that are NOT already being called (not `x()`).
2430
- if (signalVars.size > 0 && signalVars.size > shadowedSignals.size && referencesSignalVar(expr)) {
2431
- result = autoCallSignals(result, expr)
2432
- }
2433
-
2434
- return result
2435
- }
2436
-
2437
- /** Check if an expression references any tracked signal variable. */
2438
- function referencesSignalVar(node: N): boolean {
2439
- if (node.type === 'Identifier' && isActiveSignal(node.name)) {
2440
- const parent = findParent(node)
2441
- if (parent && parent.type === 'MemberExpression' && parent.property === node && !parent.computed) return false
2442
- // signal.X(...) — operating on the signal object (calling a method).
2443
- // Mirrors the same narrow skip in findSignalIdents below.
2444
- if (
2445
- parent &&
2446
- parent.type === 'MemberExpression' &&
2447
- parent.object === node
2448
- ) {
2449
- const grand = findParent(parent)
2450
- if (grand && grand.type === 'CallExpression' && grand.callee === parent) return false
2451
- }
2452
- if (parent && parent.type === 'CallExpression' && parent.callee === node) return false // already called
2453
- return true
2454
- }
2455
- let found = false
2456
- forEachChildFast(node, (child) => {
2457
- if (found) return
2458
- if (child.type === 'ArrowFunctionExpression' || child.type === 'FunctionExpression') return
2459
- if (referencesSignalVar(child)) found = true
2460
- })
2461
- return found
2462
- }
2463
-
2464
- /** Auto-insert () after signal variable references in the expression source.
2465
- * Uses the AST to find exact Identifier positions — never scans raw text. */
2466
- // Recursively collect identifier names bound by a pattern (params /
2467
- // declarators). Self-contained twin of resolveIdentifiersInText's
2468
- // `patternBindingNames` (different closure scope; kept local to avoid a
2469
- // risky shared-helper hoist).
2470
- function sigPatternNames(p: N, out: string[]): void {
2471
- if (!p) return
2472
- switch (p.type) {
2473
- case 'Identifier':
2474
- out.push(p.name)
2475
- break
2476
- case 'ObjectPattern':
2477
- for (const pr of p.properties ?? []) {
2478
- if (pr.type === 'RestElement') sigPatternNames(pr.argument, out)
2479
- else sigPatternNames(pr.value ?? pr.key, out)
2480
- }
2481
- break
2482
- case 'ArrayPattern':
2483
- for (const el of p.elements ?? []) sigPatternNames(el, out)
2484
- break
2485
- case 'AssignmentPattern':
2486
- sigPatternNames(p.left, out)
2487
- break
2488
- case 'RestElement':
2489
- sigPatternNames(p.argument, out)
2490
- break
2491
- }
2492
- }
2493
-
2494
- // Signal names a scope-introducing node binds FOR ITS OWN SUBTREE
2495
- // (block-accurate lexical scoping). Mirrors scopeBoundPropDerived but
2496
- // against `signalVars` — a same-named inner binding (callback param,
2497
- // nested const, catch/loop var) shadows the signal and must NOT be
2498
- // auto-called (doing so emits `paramValue()` → runtime TypeError).
2499
- function scopeBoundSignals(node: N): string[] {
2500
- const out: string[] = []
2501
- const t = node.type
2502
- const declNames = (declNode: N): void => {
2503
- for (const d of declNode.declarations ?? []) {
2504
- // A `const x = signal(...)` re-declaration is itself a signal, not a
2505
- // shadow — leave it for the normal signalVars path.
2506
- if (d.id?.type === 'Identifier' && d.init && isSignalCall(d.init)) continue
2507
- sigPatternNames(d.id, out)
2508
- }
2509
- }
2510
- if (
2511
- t === 'ArrowFunctionExpression' ||
2512
- t === 'FunctionExpression' ||
2513
- t === 'FunctionDeclaration'
2514
- ) {
2515
- for (const p of node.params ?? []) sigPatternNames(p, out)
2516
- } else if (t === 'CatchClause') {
2517
- sigPatternNames(node.param, out)
2518
- } else if (t === 'ForStatement') {
2519
- if (node.init?.type === 'VariableDeclaration') declNames(node.init)
2520
- } else if (t === 'ForInStatement' || t === 'ForOfStatement') {
2521
- if (node.left?.type === 'VariableDeclaration') declNames(node.left)
2522
- } else if (t === 'BlockStatement' || t === 'StaticBlock') {
2523
- const stmts = node.body ?? node.statements
2524
- if (Array.isArray(stmts)) {
2525
- for (const s of stmts) {
2526
- if (s.type === 'VariableDeclaration') declNames(s)
2527
- else if (s.type === 'FunctionDeclaration' && s.id?.type === 'Identifier') out.push(s.id.name)
2528
- else if (s.type === 'ClassDeclaration' && s.id?.type === 'Identifier') out.push(s.id.name)
2529
- }
2530
- }
2531
- }
2532
- return out.filter((n) => signalVars.has(n))
2533
- }
2534
-
2535
- function autoCallSignals(text: string, expr: N): string {
2536
- const start = expr.start as number
2537
- // Collect signal identifier positions that need auto-calling
2538
- const idents: { start: number; end: number }[] = []
2539
- // Local lexical shadow set — a signal-named binding introduced INSIDE
2540
- // the rewritten expression (callback param, nested const, …) is NOT the
2541
- // signal and must not get `()` (R11: scope-blind rewrite emitted
2542
- // `({x}) => <li>{x()}</li>` → `1()` runtime crash).
2543
- const shadowed = new Set<string>()
2544
-
2545
- function findSignalIdents(node: N): void {
2546
- if ((node.start as number) >= start + text.length || (node.end as number) <= start) return
2547
- const introduced: string[] = []
2548
- for (const n of scopeBoundSignals(node)) {
2549
- if (!shadowed.has(n)) {
2550
- shadowed.add(n)
2551
- introduced.push(n)
2552
- }
2553
- }
2554
- if (node.type === 'Identifier' && isActiveSignal(node.name) && !shadowed.has(node.name)) {
2555
- const parent = findParent(node)
2556
- // Skip property name positions (obj.name)
2557
- if (parent && parent.type === 'MemberExpression' && parent.property === node && !parent.computed) return
2558
- // Skip when the identifier is the OBJECT of a member access AND
2559
- // the result is being CALLED (signal.set(...), signal.peek(),
2560
- // signal.update(...)). The user is invoking a method on the
2561
- // signal OBJECT — auto-calling would produce `signal().set(...)`
2562
- // which calls the signal, gets its value (string/number/etc),
2563
- // then `.set` on the value is undefined → TypeError. Every event
2564
- // handler that did `signal.set(x)` was silently broken.
2565
- //
2566
- // Note: bare `signal.value` (member access NOT followed by call)
2567
- // STILL auto-calls — keeps the existing convention where
2568
- // `signal({a:1})` followed by `signal.a` reads the signal's
2569
- // value's property (see "signal as member expression object IS
2570
- // auto-called" test).
2571
- if (
2572
- parent &&
2573
- parent.type === 'MemberExpression' &&
2574
- parent.object === node
2575
- ) {
2576
- const grand = findParent(parent)
2577
- if (grand && grand.type === 'CallExpression' && grand.callee === parent) return
2578
- }
2579
- // Skip if already being called: signal()
2580
- if (parent && parent.type === 'CallExpression' && parent.callee === node) return
2581
- // Skip declaration positions
2582
- if (parent && parent.type === 'VariableDeclarator' && parent.id === node) return
2583
- // Skip object property keys and shorthand properties ({ name } or { name: val })
2584
- // Inserting () after a shorthand key produces name() which is a method shorthand — invalid
2585
- if (parent && (parent.type === 'Property' || parent.type === 'ObjectProperty')) {
2586
- if (parent.shorthand) return // { name } — can't auto-call without breaking syntax
2587
- if (parent.key === node && !parent.computed) return // { name: val } — key position
2588
- }
2589
- idents.push({ start: node.start as number, end: node.end as number })
2590
- }
2591
- forEachChildFast(node, findSignalIdents)
2592
- for (const n of introduced) shadowed.delete(n)
2593
- }
2594
- findSignalIdents(expr)
2595
-
2596
- if (idents.length === 0) return text
2597
-
2598
- // Sort by position and insert () after each identifier
2599
- idents.sort((a, b) => a.start - b.start)
2600
- const parts: string[] = []
2601
- let lastPos = start
2602
- for (const id of idents) {
2603
- parts.push(code.slice(lastPos, id.end))
2604
- parts.push('()') // auto-call
2605
- lastPos = id.end
2606
- }
2607
- parts.push(code.slice(lastPos, start + text.length))
2608
- return parts.join('')
2609
- }
2610
- }
2611
-
2612
- // ─── Module-scope constants and helpers ─────────────────────────────────────
2613
-
2614
- const VOID_ELEMENTS = new Set([
2615
- 'area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input',
2616
- 'link', 'meta', 'param', 'source', 'track', 'wbr',
2617
- ])
2618
-
2619
- const JSX_TO_HTML_ATTR: Record<string, string> = {
2620
- className: 'class',
2621
- htmlFor: 'for',
2622
- }
2623
-
2624
- // DOM properties whose live value diverges from the content attribute.
2625
- // For these, emit property assignment (`el.value = v`) instead of
2626
- // `setAttribute("value", v)`. Otherwise the property and attribute drift
2627
- // apart in user-driven flows: typing in a controlled <input> updates the
2628
- // .value property, but `input.set('')` clearing the signal only resets
2629
- // the attribute — the stale typed text stays visible. Same for `checked`
2630
- // on checkboxes (presence of the attribute means checked regardless of
2631
- // value: `setAttribute("checked", "false")` still checks the box).
2632
- const DOM_PROPS = new Set([
2633
- 'value',
2634
- 'checked',
2635
- 'selected',
2636
- 'disabled',
2637
- 'multiple',
2638
- 'readOnly',
2639
- 'indeterminate',
2640
- ])
2641
-
2642
- const STATEFUL_CALLS = new Set([
2643
- 'signal', 'computed', 'effect', 'batch',
2644
- 'createContext', 'createReactiveContext',
2645
- 'useContext', 'useRef', 'createRef',
2646
- 'useForm', 'useQuery', 'useMutation',
2647
- 'defineStore', 'useStore',
2648
- ])
2649
-
2650
- function isStatefulCall(node: N): boolean {
2651
- if (node.type !== 'CallExpression') return false
2652
- const callee = node.callee
2653
- if (callee?.type === 'Identifier') return STATEFUL_CALLS.has(callee.name)
2654
- return false
2655
- }
2656
-
2657
- /** Check if a call expression creates a callable reactive value (`signal(...)` or `computed(...)`). */
2658
- function isSignalCall(node: N): boolean {
2659
- if (node.type !== 'CallExpression') return false
2660
- const callee = node.callee
2661
- return callee?.type === 'Identifier' && (callee.name === 'signal' || callee.name === 'computed')
2662
- }
2663
-
2664
- function isChildrenExpression(node: N, expr: string): boolean {
2665
- if (node.type === 'MemberExpression' && !node.computed && node.property?.type === 'Identifier' && node.property.name === 'children') return true
2666
- if (node.type === 'Identifier' && node.name === 'children') return true
2667
- if (expr.endsWith('.children') || expr === 'children') return true
2668
- return false
2669
- }
2670
-
2671
- function isLowerCase(s: string): boolean {
2672
- return s.length > 0 && s[0] === s[0]?.toLowerCase()
2673
- }
2674
-
2675
- function containsJSXInExpr(node: N): boolean {
2676
- if (node.type === 'JSXElement' || node.type === 'JSXFragment') return true
2677
- let found = false
2678
- forEachChild(node, (child) => {
2679
- if (found) return
2680
- if (containsJSXInExpr(child)) found = true
2681
- })
2682
- return found
2683
- }
2684
-
2685
- function escapeHtmlAttr(s: string): string {
2686
- return s.replace(/&/g, '&amp;').replace(/"/g, '&quot;')
2687
- }
2688
-
2689
- function escapeHtmlText(s: string): string {
2690
- return s.replace(/&(?!(?:#\d+|#x[\da-fA-F]+|[a-zA-Z]\w*);)/g, '&amp;').replace(/</g, '&lt;')
2691
- }
2692
-
2693
- // React/Babel JSX whitespace algorithm (cleanJSXElementLiteralChild).
2694
- // Same-line text is preserved verbatim so adjacent expressions keep their
2695
- // spacing (`<p>doubled: {x}</p>` keeps the trailing space). Multi-line text
2696
- // strips leading whitespace from non-first lines and trailing whitespace
2697
- // from non-last lines, drops fully-empty lines, and joins the survivors
2698
- // with a single space — collapsing JSX indentation without losing
2699
- // intentional inline spacing.
2700
- function cleanJsxText(raw: string): string {
2701
- if (!raw.includes('\n') && !raw.includes('\r')) return raw
2702
- const lines = raw.split(/\r\n|\n|\r/)
2703
- let lastNonEmpty = -1
2704
- for (let i = 0; i < lines.length; i++) {
2705
- if (/[^ \t]/.test(lines[i] ?? '')) lastNonEmpty = i
2706
- }
2707
- let str = ''
2708
- for (let i = 0; i < lines.length; i++) {
2709
- let line = (lines[i] ?? '').replace(/\t/g, ' ')
2710
- if (i !== 0) line = line.replace(/^ +/, '')
2711
- if (i !== lines.length - 1) line = line.replace(/ +$/, '')
2712
- if (line) {
2713
- if (i !== lastNonEmpty) line += ' '
2714
- str += line
2715
- }
2716
- }
2717
- return str
2718
- }
2719
-
2720
- function isStaticJSXNode(node: N): boolean {
2721
- if (node.type === 'JSXElement' && node.openingElement?.selfClosing) {
2722
- return isStaticAttrs(node.openingElement.attributes ?? [])
2723
- }
2724
- if (node.type === 'JSXFragment') {
2725
- return (node.children ?? []).every(isStaticChild)
2726
- }
2727
- if (node.type === 'JSXElement') {
2728
- return isStaticAttrs(node.openingElement?.attributes ?? []) && (node.children ?? []).every(isStaticChild)
2729
- }
2730
- return false
2731
- }
2732
-
2733
- function isStaticAttrs(attrs: N[]): boolean {
2734
- return attrs.every((prop: N) => {
2735
- if (prop.type !== 'JSXAttribute') return false
2736
- if (!prop.value) return true
2737
- if (prop.value.type === 'StringLiteral' || (prop.value.type === 'Literal' && typeof prop.value.value === 'string')) return true
2738
- if (prop.value.type === 'JSXExpressionContainer') {
2739
- const expr = prop.value.expression
2740
- if (!expr || expr.type === 'JSXEmptyExpression') return true
2741
- return isStatic(expr)
2742
- }
2743
- return false
2744
- })
2745
- }
2746
-
2747
- function isStaticChild(child: N): boolean {
2748
- if (child.type === 'JSXText') return true
2749
- if (child.type === 'JSXElement') return isStaticJSXNode(child)
2750
- if (child.type === 'JSXFragment') return isStaticJSXNode(child)
2751
- if (child.type === 'JSXExpressionContainer') {
2752
- const expr = child.expression
2753
- if (!expr || expr.type === 'JSXEmptyExpression') return true
2754
- return isStatic(expr)
2755
- }
2756
- return false
2757
- }
2758
-
2759
- function isStatic(node: N): boolean {
2760
- if (node.type === 'Literal') return true
2761
- if (node.type === 'StringLiteral' || node.type === 'NumericLiteral' || node.type === 'BooleanLiteral' || node.type === 'NullLiteral') return true
2762
- if (node.type === 'TemplateLiteral' && (node.expressions?.length ?? 0) === 0) return true
2763
- // Note: `undefined` is an Identifier in ESTree, not a keyword literal.
2764
- // It is NOT treated as static — it goes through the dynamic attr path.
2765
- return false
2766
- }
2767
-
2768
- const PURE_CALLS = new Set([
2769
- 'Math.max', 'Math.min', 'Math.abs', 'Math.floor', 'Math.ceil', 'Math.round',
2770
- 'Math.pow', 'Math.sqrt', 'Math.random', 'Math.trunc', 'Math.sign',
2771
- 'Number.parseInt', 'Number.parseFloat', 'Number.isNaN', 'Number.isFinite',
2772
- 'parseInt', 'parseFloat', 'isNaN', 'isFinite',
2773
- 'String.fromCharCode', 'String.fromCodePoint',
2774
- 'Object.keys', 'Object.values', 'Object.entries', 'Object.assign',
2775
- 'Object.freeze', 'Object.create',
2776
- 'Array.from', 'Array.isArray', 'Array.of',
2777
- 'JSON.stringify', 'JSON.parse',
2778
- 'encodeURIComponent', 'decodeURIComponent', 'encodeURI', 'decodeURI',
2779
- 'Date.now',
2780
- ])
2781
-
2782
- function isPureStaticCall(node: N): boolean {
2783
- const callee = node.callee
2784
- let name = ''
2785
- if (callee?.type === 'Identifier') {
2786
- name = callee.name
2787
- } else if (callee?.type === 'MemberExpression' && !callee.computed && callee.object?.type === 'Identifier' && callee.property?.type === 'Identifier') {
2788
- name = `${callee.object.name}.${callee.property.name}`
2789
- }
2790
- if (!PURE_CALLS.has(name)) return false
2791
- return (node.arguments ?? []).every((arg: N) => arg.type !== 'SpreadElement' && isStatic(arg))
2792
- }