@pyreon/compiler 0.13.1 → 0.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +14 -4
- package/lib/analysis/index.js.html +1 -1
- package/lib/index.js +1330 -409
- package/lib/types/index.d.ts +152 -14
- package/package.json +12 -1
- package/src/event-names.ts +65 -0
- package/src/index.ts +10 -1
- package/src/jsx.ts +974 -784
- package/src/pyreon-intercept.ts +728 -0
- package/src/test-audit.ts +435 -0
- package/src/tests/depth-stress.test.ts +16 -0
- package/src/tests/detector-tag-consistency.test.ts +86 -0
- package/src/tests/jsx.test.ts +1170 -4
- package/src/tests/native-equivalence.test.ts +731 -0
- package/src/tests/project-scanner.test.ts +30 -0
- package/src/tests/pyreon-intercept.test.ts +486 -0
- package/src/tests/react-intercept.test.ts +354 -0
- package/src/tests/runtime/control-flow.test.ts +159 -0
- package/src/tests/runtime/dom-properties.test.ts +138 -0
- package/src/tests/runtime/events.test.ts +301 -0
- package/src/tests/runtime/harness.ts +94 -0
- package/src/tests/runtime/pr-352-shapes.test.ts +121 -0
- package/src/tests/runtime/reactive-props.test.ts +81 -0
- package/src/tests/runtime/signals.test.ts +129 -0
- package/src/tests/runtime/whitespace.test.ts +106 -0
- package/src/tests/test-audit.test.ts +549 -0
- package/lib/index.js.map +0 -1
- package/lib/types/index.d.ts.map +0 -1
package/src/jsx.ts
CHANGED
|
@@ -17,23 +17,43 @@
|
|
|
17
17
|
* values, and all children are text nodes or other static JSX nodes.
|
|
18
18
|
*
|
|
19
19
|
* Template emission:
|
|
20
|
-
* - JSX element trees with ≥
|
|
21
|
-
* are compiled to `_tpl(html, bindFn)` calls instead of nested
|
|
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.
|
|
22
23
|
* - The HTML string is parsed once via <template>.innerHTML, then cloneNode(true)
|
|
23
24
|
* for each instance (~5-10x faster than sequential createElement calls).
|
|
24
25
|
* - Static attributes are baked into the HTML string; dynamic attributes and
|
|
25
26
|
* text content use renderEffect in the bind function.
|
|
26
27
|
*
|
|
27
|
-
* Implementation:
|
|
28
|
-
* No extra runtime dependencies — `typescript` is already in devDependencies.
|
|
29
|
-
*
|
|
30
|
-
* Known limitation (v0): expressions inside *nested* JSX within a child
|
|
31
|
-
* expression container are not individually wrapped. They are still reactive
|
|
32
|
-
* because the outer wrapper re-evaluates the whole subtree, just at a coarser
|
|
33
|
-
* granularity. Fine-grained nested wrapping is planned for a future pass.
|
|
28
|
+
* Implementation: Rust native binary (napi-rs) when available, JS fallback via oxc-parser.
|
|
34
29
|
*/
|
|
35
30
|
|
|
36
|
-
import
|
|
31
|
+
import { parseSync } from 'oxc-parser'
|
|
32
|
+
import { createRequire } from 'node:module'
|
|
33
|
+
import { fileURLToPath } from 'node:url'
|
|
34
|
+
import { dirname, join } from 'node:path'
|
|
35
|
+
import { REACT_EVENT_REMAP } from './event-names'
|
|
36
|
+
|
|
37
|
+
// ─── Native binary auto-detection ────────────────────────────────────────────
|
|
38
|
+
// Try to load the Rust napi-rs binary for 3.7-8.2x faster transforms.
|
|
39
|
+
// Falls back to the JS implementation below if the binary isn't available
|
|
40
|
+
// (wrong platform, CI environment, WASM runtime like StackBlitz, etc.)
|
|
41
|
+
//
|
|
42
|
+
// Uses createRequire for ESM compatibility — __dirname and require() don't
|
|
43
|
+
// exist in ESM modules.
|
|
44
|
+
type NativeTransformFn = (code: string, filename: string, ssr: boolean, knownSignals: string[] | null) => TransformResult
|
|
45
|
+
let nativeTransformJsx: NativeTransformFn | null = null
|
|
46
|
+
|
|
47
|
+
try {
|
|
48
|
+
const __filename = fileURLToPath(import.meta.url)
|
|
49
|
+
const __dirname = dirname(__filename)
|
|
50
|
+
const nativeRequire = createRequire(import.meta.url)
|
|
51
|
+
const nativePath = join(__dirname, '..', 'native', 'pyreon-compiler.node')
|
|
52
|
+
const native = nativeRequire(nativePath) as { transformJsx: NativeTransformFn }
|
|
53
|
+
nativeTransformJsx = native.transformJsx
|
|
54
|
+
} catch {
|
|
55
|
+
// Native binary not available — JS fallback will be used
|
|
56
|
+
}
|
|
37
57
|
|
|
38
58
|
export interface CompilerWarning {
|
|
39
59
|
/** Warning message */
|
|
@@ -65,66 +85,196 @@ const SKIP_PROPS = new Set(['key', 'ref'])
|
|
|
65
85
|
const EVENT_RE = /^on[A-Z]/
|
|
66
86
|
// Events delegated to the container — must match runtime DELEGATED_EVENTS set
|
|
67
87
|
const DELEGATED_EVENTS = new Set([
|
|
68
|
-
'click',
|
|
69
|
-
'
|
|
70
|
-
'
|
|
71
|
-
'
|
|
72
|
-
'focusout',
|
|
73
|
-
'input',
|
|
74
|
-
'change',
|
|
75
|
-
'keydown',
|
|
76
|
-
'keyup',
|
|
77
|
-
'mousedown',
|
|
78
|
-
'mouseup',
|
|
79
|
-
'mousemove',
|
|
80
|
-
'mouseover',
|
|
81
|
-
'mouseout',
|
|
82
|
-
'pointerdown',
|
|
83
|
-
'pointerup',
|
|
84
|
-
'pointermove',
|
|
85
|
-
'pointerover',
|
|
86
|
-
'pointerout',
|
|
87
|
-
'touchstart',
|
|
88
|
-
'touchend',
|
|
89
|
-
'touchmove',
|
|
88
|
+
'click', 'dblclick', 'contextmenu', 'focusin', 'focusout', 'input',
|
|
89
|
+
'change', 'keydown', 'keyup', 'mousedown', 'mouseup', 'mousemove',
|
|
90
|
+
'mouseover', 'mouseout', 'pointerdown', 'pointerup', 'pointermove',
|
|
91
|
+
'pointerover', 'pointerout', 'touchstart', 'touchend', 'touchmove',
|
|
90
92
|
'submit',
|
|
91
93
|
])
|
|
92
94
|
|
|
93
95
|
export interface TransformOptions {
|
|
94
96
|
/**
|
|
95
97
|
* Compile for server-side rendering. When true, the compiler skips the
|
|
96
|
-
* `_tpl()` template optimization
|
|
97
|
-
* `
|
|
98
|
-
* `@pyreon/runtime-server` can walk the VNode tree. Client builds keep
|
|
99
|
-
* the `_tpl()` fast path. Default: false.
|
|
98
|
+
* `_tpl()` template optimization and falls back to plain `h()` calls so
|
|
99
|
+
* `@pyreon/runtime-server` can walk the VNode tree. Default: false.
|
|
100
100
|
*/
|
|
101
101
|
ssr?: boolean
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Known signal variable names from resolved imports.
|
|
105
|
+
* The Vite plugin maintains a cross-module signal export registry and
|
|
106
|
+
* passes imported signal names here so the compiler can auto-call them
|
|
107
|
+
* in JSX even though the `signal()` declaration is in another file.
|
|
108
|
+
*
|
|
109
|
+
* @example
|
|
110
|
+
* // store.ts: export const count = signal(0)
|
|
111
|
+
* // component.tsx: import { count } from './store'
|
|
112
|
+
* transformJSX(code, 'component.tsx', { knownSignals: ['count'] })
|
|
113
|
+
* // {count} in JSX → {() => count()}
|
|
114
|
+
*/
|
|
115
|
+
knownSignals?: string[]
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// ─── oxc ESTree helpers ───────────────────────────────────────────────────────
|
|
119
|
+
|
|
120
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
121
|
+
type N = any // ESTree node — untyped for speed, matches the lint package approach
|
|
122
|
+
|
|
123
|
+
function getLang(filename: string): 'tsx' | 'jsx' {
|
|
124
|
+
if (filename.endsWith('.jsx')) return 'jsx'
|
|
125
|
+
// Default to tsx so JSX is always parsed — matches the original TypeScript
|
|
126
|
+
// parser behavior which forced ScriptKind.TSX for all files.
|
|
127
|
+
return 'tsx'
|
|
102
128
|
}
|
|
103
129
|
|
|
130
|
+
/** Binary search for line/column from byte offset. */
|
|
131
|
+
function makeLineIndex(code: string): (offset: number) => { line: number; column: number } {
|
|
132
|
+
const lineStarts = [0]
|
|
133
|
+
for (let i = 0; i < code.length; i++) {
|
|
134
|
+
if (code[i] === '\n') lineStarts.push(i + 1)
|
|
135
|
+
}
|
|
136
|
+
return (offset: number) => {
|
|
137
|
+
let lo = 0
|
|
138
|
+
let hi = lineStarts.length - 1
|
|
139
|
+
while (lo <= hi) {
|
|
140
|
+
const mid = (lo + hi) >>> 1
|
|
141
|
+
if (lineStarts[mid]! <= offset) lo = mid + 1
|
|
142
|
+
else hi = mid - 1
|
|
143
|
+
}
|
|
144
|
+
return { line: lo, column: offset - lineStarts[lo - 1]! }
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Iterate all direct children of an ESTree node via known property keys. */
|
|
149
|
+
function forEachChild(node: N, cb: (child: N) => void): void {
|
|
150
|
+
if (!node || typeof node !== 'object') return
|
|
151
|
+
const keys = Object.keys(node)
|
|
152
|
+
for (let i = 0; i < keys.length; i++) {
|
|
153
|
+
const key = keys[i]!
|
|
154
|
+
// Skip metadata fields for speed
|
|
155
|
+
if (key === 'type' || key === 'start' || key === 'end' || key === 'loc' || key === 'range') continue
|
|
156
|
+
const val = node[key]
|
|
157
|
+
if (Array.isArray(val)) {
|
|
158
|
+
for (let j = 0; j < val.length; j++) {
|
|
159
|
+
const item = val[j]
|
|
160
|
+
if (item && typeof item === 'object' && item.type) cb(item)
|
|
161
|
+
}
|
|
162
|
+
} else if (val && typeof val === 'object' && val.type) {
|
|
163
|
+
cb(val)
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// ─── JSX element helpers ────────────────────────────────────────────────────
|
|
169
|
+
|
|
170
|
+
function jsxTagName(node: N): string {
|
|
171
|
+
const opening = node.openingElement
|
|
172
|
+
if (!opening) return ''
|
|
173
|
+
const name = opening.name
|
|
174
|
+
return name?.type === 'JSXIdentifier' ? name.name : ''
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function isSelfClosing(node: N): boolean {
|
|
178
|
+
return node.type === 'JSXElement' && node.openingElement?.selfClosing === true
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function jsxAttrs(node: N): N[] {
|
|
182
|
+
return node.openingElement?.attributes ?? []
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function jsxChildren(node: N): N[] {
|
|
186
|
+
return node.children ?? []
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// ─── Main transform ─────────────────────────────────────────────────────────
|
|
190
|
+
|
|
104
191
|
export function transformJSX(
|
|
105
192
|
code: string,
|
|
106
193
|
filename = 'input.tsx',
|
|
107
194
|
options: TransformOptions = {},
|
|
195
|
+
): TransformResult {
|
|
196
|
+
// Try Rust native binary first (3.7-8.2x faster).
|
|
197
|
+
// Per-call try/catch: if the native binary panics on an edge case
|
|
198
|
+
// (bad UTF-8, unexpected AST shape), fall back gracefully instead
|
|
199
|
+
// of crashing the Vite dev server.
|
|
200
|
+
if (nativeTransformJsx) {
|
|
201
|
+
try {
|
|
202
|
+
return nativeTransformJsx(code, filename, options.ssr === true, options.knownSignals ?? null)
|
|
203
|
+
} catch {
|
|
204
|
+
// Native transform failed — fall through to JS implementation
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
return transformJSX_JS(code, filename, options)
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** JS fallback implementation — used when the native binary isn't available. */
|
|
211
|
+
export function transformJSX_JS(
|
|
212
|
+
code: string,
|
|
213
|
+
filename = 'input.tsx',
|
|
214
|
+
options: TransformOptions = {},
|
|
108
215
|
): TransformResult {
|
|
109
216
|
const ssr = options.ssr === true
|
|
110
|
-
const scriptKind =
|
|
111
|
-
filename.endsWith('.tsx') || filename.endsWith('.jsx') ? ts.ScriptKind.TSX : ts.ScriptKind.TSX // default to TSX so JSX is always parsed
|
|
112
217
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
code,
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
218
|
+
let program: N
|
|
219
|
+
try {
|
|
220
|
+
const result = parseSync(filename, code, {
|
|
221
|
+
sourceType: 'module',
|
|
222
|
+
lang: getLang(filename),
|
|
223
|
+
})
|
|
224
|
+
program = result.program
|
|
225
|
+
} catch {
|
|
226
|
+
return { code, warnings: [] }
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const locate = makeLineIndex(code)
|
|
120
230
|
|
|
121
231
|
type Replacement = { start: number; end: number; text: string }
|
|
122
232
|
const replacements: Replacement[] = []
|
|
123
233
|
const warnings: CompilerWarning[] = []
|
|
124
234
|
|
|
125
|
-
function warn(node:
|
|
126
|
-
const { line,
|
|
127
|
-
warnings.push({ message, line
|
|
235
|
+
function warn(node: N, message: string, warnCode: CompilerWarning['code']): void {
|
|
236
|
+
const { line, column } = locate(node.start as number)
|
|
237
|
+
warnings.push({ message, line, column, code: warnCode })
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// ── Parent + children maps (built once, eliminates repeated Object.keys) ──
|
|
241
|
+
const parentMap = new WeakMap<object, N>()
|
|
242
|
+
const childrenMap = new WeakMap<object, N[]>()
|
|
243
|
+
|
|
244
|
+
/** Build parent pointers + cached children arrays for the entire AST. */
|
|
245
|
+
function buildMaps(node: N): void {
|
|
246
|
+
const kids: N[] = []
|
|
247
|
+
const keys = Object.keys(node)
|
|
248
|
+
for (let i = 0; i < keys.length; i++) {
|
|
249
|
+
const key = keys[i]!
|
|
250
|
+
if (key === 'type' || key === 'start' || key === 'end' || key === 'loc' || key === 'range') continue
|
|
251
|
+
const val = node[key]
|
|
252
|
+
if (Array.isArray(val)) {
|
|
253
|
+
for (let j = 0; j < val.length; j++) {
|
|
254
|
+
const item = val[j]
|
|
255
|
+
if (item && typeof item === 'object' && item.type) kids.push(item)
|
|
256
|
+
}
|
|
257
|
+
} else if (val && typeof val === 'object' && val.type) {
|
|
258
|
+
kids.push(val)
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
childrenMap.set(node, kids)
|
|
262
|
+
for (let i = 0; i < kids.length; i++) {
|
|
263
|
+
parentMap.set(kids[i]!, node)
|
|
264
|
+
buildMaps(kids[i]!)
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
buildMaps(program)
|
|
268
|
+
|
|
269
|
+
function findParent(node: N): N | undefined {
|
|
270
|
+
return parentMap.get(node)
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/** Fast child iteration using pre-computed children array. */
|
|
274
|
+
function forEachChildFast(node: N, cb: (child: N) => void): void {
|
|
275
|
+
const kids = childrenMap.get(node)
|
|
276
|
+
if (!kids) return
|
|
277
|
+
for (let i = 0; i < kids.length; i++) cb(kids[i]!)
|
|
128
278
|
}
|
|
129
279
|
|
|
130
280
|
// ── Static hoisting state ─────────────────────────────────────────────────
|
|
@@ -139,444 +289,491 @@ export function transformJSX(
|
|
|
139
289
|
let needsApplyPropsImportGlobal = false
|
|
140
290
|
let needsMountSlotImportGlobal = false
|
|
141
291
|
|
|
142
|
-
|
|
143
|
-
* If `node` is a fully-static JSX element/fragment, register a module-scope
|
|
144
|
-
* hoist for it and return the generated variable name. Otherwise return null.
|
|
145
|
-
*/
|
|
146
|
-
function maybeHoist(node: ts.Node): string | null {
|
|
292
|
+
function maybeHoist(node: N): string | null {
|
|
147
293
|
if (
|
|
148
|
-
(
|
|
149
|
-
isStaticJSXNode(node
|
|
294
|
+
(node.type === 'JSXElement' || node.type === 'JSXFragment') &&
|
|
295
|
+
isStaticJSXNode(node)
|
|
150
296
|
) {
|
|
151
297
|
const name = `_$h${hoistIdx++}`
|
|
152
|
-
const text = code.slice(node.
|
|
298
|
+
const text = code.slice(node.start as number, node.end as number)
|
|
153
299
|
hoists.push({ name, text })
|
|
154
300
|
return name
|
|
155
301
|
}
|
|
156
302
|
return null
|
|
157
303
|
}
|
|
158
304
|
|
|
159
|
-
function wrap(expr:
|
|
160
|
-
const start = expr.
|
|
161
|
-
const end = expr.
|
|
162
|
-
// Object literals need parens: `() => { ... }` is a function body with
|
|
163
|
-
// labeled statements, not an object expression. Use `() => ({ ... })`.
|
|
305
|
+
function wrap(expr: N): void {
|
|
306
|
+
const start = expr.start as number
|
|
307
|
+
const end = expr.end as number
|
|
164
308
|
const sliced = sliceExpr(expr)
|
|
165
|
-
const text =
|
|
309
|
+
const text = expr.type === 'ObjectExpression'
|
|
166
310
|
? `() => (${sliced})`
|
|
167
311
|
: `() => ${sliced}`
|
|
168
312
|
replacements.push({ start, end, text })
|
|
169
313
|
}
|
|
170
314
|
|
|
171
|
-
|
|
172
|
-
function hoistOrWrap(expr: ts.Expression): void {
|
|
315
|
+
function hoistOrWrap(expr: N): void {
|
|
173
316
|
const hoistName = maybeHoist(expr)
|
|
174
317
|
if (hoistName) {
|
|
175
|
-
replacements.push({ start: expr.
|
|
318
|
+
replacements.push({ start: expr.start as number, end: expr.end as number, text: hoistName })
|
|
176
319
|
} else if (shouldWrap(expr)) {
|
|
177
320
|
wrap(expr)
|
|
178
321
|
}
|
|
179
322
|
}
|
|
180
323
|
|
|
181
|
-
// ──
|
|
324
|
+
// ── Template emit ─────────────────────────────────────────────────────────
|
|
182
325
|
|
|
183
|
-
|
|
184
|
-
function tryTemplateEmit(node: ts.JsxElement): boolean {
|
|
185
|
-
// SSR builds skip the `_tpl()` fast path entirely. `_tpl` clones a real
|
|
186
|
-
// DOM element via `document.createElement('template')` and the emitted
|
|
187
|
-
// bind callback calls `appendChild`, `createTextNode`, etc. — none of
|
|
188
|
-
// that exists in Node. Falling back to standard JSX→`h()` lets
|
|
189
|
-
// `@pyreon/runtime-server` walk the VNode tree to a string. Client
|
|
190
|
-
// builds keep the template optimization.
|
|
326
|
+
function tryTemplateEmit(node: N): boolean {
|
|
191
327
|
if (ssr) return false
|
|
192
|
-
|
|
328
|
+
if (isSelfClosing(node)) return false
|
|
329
|
+
const elemCount = templateElementCount(node, true)
|
|
193
330
|
if (elemCount < 1) return false
|
|
194
331
|
const tplCall = buildTemplateCall(node)
|
|
195
332
|
if (!tplCall) return false
|
|
196
|
-
const start = node.
|
|
197
|
-
const end = node.
|
|
198
|
-
const parent = node
|
|
199
|
-
const needsBraces = parent && (
|
|
333
|
+
const start = node.start as number
|
|
334
|
+
const end = node.end as number
|
|
335
|
+
const parent = findParent(node)
|
|
336
|
+
const needsBraces = parent && (parent.type === 'JSXElement' || parent.type === 'JSXFragment')
|
|
200
337
|
replacements.push({ start, end, text: needsBraces ? `{${tplCall}}` : tplCall })
|
|
201
338
|
needsTplImport = true
|
|
202
339
|
return true
|
|
203
340
|
}
|
|
204
341
|
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
const opening = ts.isJsxElement(node) ? node.openingElement : node
|
|
208
|
-
const tagName = ts.isIdentifier(opening.tagName) ? opening.tagName.text : ''
|
|
342
|
+
function checkForWarnings(node: N): void {
|
|
343
|
+
const tagName = jsxTagName(node)
|
|
209
344
|
if (tagName !== 'For') return
|
|
210
|
-
const hasBy =
|
|
211
|
-
(p) =>
|
|
345
|
+
const hasBy = jsxAttrs(node).some(
|
|
346
|
+
(p: N) => p.type === 'JSXAttribute' && p.name?.type === 'JSXIdentifier' && p.name.name === 'by',
|
|
212
347
|
)
|
|
213
348
|
if (!hasBy) {
|
|
214
349
|
warn(
|
|
215
|
-
|
|
350
|
+
node.openingElement?.name ?? node,
|
|
216
351
|
`<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.`,
|
|
217
352
|
'missing-key-on-for',
|
|
218
353
|
)
|
|
219
354
|
}
|
|
220
355
|
}
|
|
221
356
|
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
* Both DOM and component props are processed:
|
|
225
|
-
* - DOM props: () => expr — applyProp creates renderEffect
|
|
226
|
-
* - Component props: _rp(() => expr) — makeReactiveProps converts to getters
|
|
227
|
-
*
|
|
228
|
-
* The _rp() brand distinguishes compiler wrappers from user-written accessor
|
|
229
|
-
* props (like Show's when, For's each) so makeReactiveProps only converts
|
|
230
|
-
* compiler-emitted wrappers.
|
|
231
|
-
*/
|
|
232
|
-
function handleJsxAttribute(node: ts.JsxAttribute): void {
|
|
233
|
-
const name = ts.isIdentifier(node.name) ? node.name.text : ''
|
|
357
|
+
function handleJsxAttribute(node: N, parentElement: N): void {
|
|
358
|
+
const name = node.name?.type === 'JSXIdentifier' ? node.name.name : ''
|
|
234
359
|
if (SKIP_PROPS.has(name) || EVENT_RE.test(name)) return
|
|
235
|
-
if (!node.
|
|
236
|
-
const expr = node.
|
|
237
|
-
if (!expr) return
|
|
360
|
+
if (!node.value || node.value.type !== 'JSXExpressionContainer') return
|
|
361
|
+
const expr = node.value.expression
|
|
362
|
+
if (!expr || expr.type === 'JSXEmptyExpression') return
|
|
238
363
|
|
|
239
|
-
const
|
|
240
|
-
const tagName = ts.isIdentifier(openingEl.tagName) ? openingEl.tagName.text : ''
|
|
364
|
+
const tagName = jsxTagName(parentElement)
|
|
241
365
|
const isComponent = tagName.length > 0 && tagName.charAt(0) !== tagName.charAt(0).toLowerCase()
|
|
242
366
|
|
|
243
367
|
if (isComponent) {
|
|
244
|
-
|
|
245
|
-
//
|
|
246
|
-
// EXCEPTION: If the expression is a single JSX element (not a conditional),
|
|
247
|
-
// do NOT wrap the outer expression. The JSX element is created once (stable VNode).
|
|
248
|
-
// Its own inner props will be wrapped individually via recursive walk().
|
|
249
|
-
// This prevents remounting: <Icon name={x()} /> stays one Icon instance,
|
|
250
|
-
// only its name prop updates reactively.
|
|
251
|
-
const isSingleJsx = ts.isJsxElement(expr) || ts.isJsxSelfClosingElement(expr)
|
|
368
|
+
const isSingleJsx = expr.type === 'JSXElement' || expr.type === 'JSXFragment'
|
|
252
369
|
if (isSingleJsx) {
|
|
253
|
-
|
|
254
|
-
ts.forEachChild(expr, walk)
|
|
370
|
+
walkNode(expr)
|
|
255
371
|
return
|
|
256
372
|
}
|
|
257
|
-
|
|
258
373
|
const hoistName = maybeHoist(expr)
|
|
259
374
|
if (hoistName) {
|
|
260
|
-
replacements.push({ start: expr.
|
|
375
|
+
replacements.push({ start: expr.start as number, end: expr.end as number, text: hoistName })
|
|
261
376
|
} else if (shouldWrap(expr)) {
|
|
262
|
-
const start = expr.
|
|
263
|
-
const end = expr.
|
|
264
|
-
// Object literals need parens to disambiguate from arrow function body
|
|
377
|
+
const start = expr.start as number
|
|
378
|
+
const end = expr.end as number
|
|
265
379
|
const sliced = sliceExpr(expr)
|
|
266
|
-
const inner =
|
|
380
|
+
const inner = expr.type === 'ObjectExpression' ? `(${sliced})` : sliced
|
|
267
381
|
replacements.push({ start, end, text: `_rp(() => ${inner})` })
|
|
268
382
|
needsRpImport = true
|
|
269
383
|
}
|
|
270
384
|
} else {
|
|
271
|
-
// DOM prop: standard () => expr wrapping
|
|
272
385
|
hoistOrWrap(expr)
|
|
273
386
|
}
|
|
274
387
|
}
|
|
275
388
|
|
|
276
|
-
|
|
277
|
-
function handleJsxExpression(node: ts.JsxExpression): void {
|
|
389
|
+
function handleJsxExpression(node: N): void {
|
|
278
390
|
const expr = node.expression
|
|
279
|
-
if (!expr) return
|
|
391
|
+
if (!expr || expr.type === 'JSXEmptyExpression') return
|
|
280
392
|
const hoistName = maybeHoist(expr)
|
|
281
393
|
if (hoistName) {
|
|
282
|
-
replacements.push({ start: expr.
|
|
394
|
+
replacements.push({ start: expr.start as number, end: expr.end as number, text: hoistName })
|
|
283
395
|
return
|
|
284
396
|
}
|
|
285
397
|
if (shouldWrap(expr)) {
|
|
286
398
|
wrap(expr)
|
|
287
399
|
return
|
|
288
400
|
}
|
|
289
|
-
|
|
290
|
-
// Recurse into the expression body to find nested JSX elements
|
|
291
|
-
// that should be compiled to _tpl() calls.
|
|
292
|
-
ts.forEachChild(expr, walk)
|
|
401
|
+
walkNode(expr)
|
|
293
402
|
}
|
|
294
403
|
|
|
295
|
-
// ── Prop-derived variable tracking
|
|
296
|
-
// Pre-pass: find variables derived from props/splitProps results inside
|
|
297
|
-
// component functions. These are inlined at JSX use sites so the compiler's
|
|
298
|
-
// existing wrapping makes them reactive.
|
|
299
|
-
//
|
|
300
|
-
// Example:
|
|
301
|
-
// const align = props.alignX ?? 'left'
|
|
302
|
-
// return <div class={align}> ← inlined to: class={props.alignX ?? 'left'}
|
|
303
|
-
// ← compiler wraps: class={() => props.alignX ?? 'left'}
|
|
304
|
-
|
|
305
|
-
/** Names that refer to the props object or splitProps results. */
|
|
404
|
+
// ── Prop-derived variable tracking (collected during the single walk) ─────
|
|
306
405
|
const propsNames = new Set<string>()
|
|
406
|
+
const propDerivedVars = new Map<string, { start: number; end: number }>()
|
|
407
|
+
|
|
408
|
+
// ── Signal variable tracking (for auto-call in JSX) ──────────────────────
|
|
409
|
+
// Tracks `const x = signal(...)` declarations. In JSX expressions, bare
|
|
410
|
+
// references to these identifiers are auto-called: `{x}` → `{x()}`.
|
|
411
|
+
// This makes signals look like plain JS variables in templates while
|
|
412
|
+
// maintaining fine-grained reactivity.
|
|
413
|
+
const signalVars = new Set<string>(options.knownSignals)
|
|
414
|
+
|
|
415
|
+
// ── Scope-aware signal shadowing ──────────────────────────────────────────
|
|
416
|
+
// When a function/block declares a variable with the same name as a signal
|
|
417
|
+
// (e.g. `const show = 'text'` shadowing module-scope `const show = signal(false)`),
|
|
418
|
+
// that name is NOT a signal within that scope. The shadowedSignals set tracks
|
|
419
|
+
// names that are currently shadowed by a closer non-signal declaration.
|
|
420
|
+
const shadowedSignals = new Set<string>()
|
|
421
|
+
|
|
422
|
+
/** Check if an identifier name is an active (non-shadowed) signal variable. */
|
|
423
|
+
function isActiveSignal(name: string): boolean {
|
|
424
|
+
return signalVars.has(name) && !shadowedSignals.has(name)
|
|
425
|
+
}
|
|
307
426
|
|
|
308
|
-
/**
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
427
|
+
/** Find variable declarations and parameters in a function that shadow signal names. */
|
|
428
|
+
function findShadowingNames(node: N): string[] {
|
|
429
|
+
const shadows: string[] = []
|
|
430
|
+
// Check function parameters
|
|
431
|
+
for (const param of node.params ?? []) {
|
|
432
|
+
if (param.type === 'Identifier' && signalVars.has(param.name)) {
|
|
433
|
+
shadows.push(param.name)
|
|
434
|
+
}
|
|
435
|
+
// Handle destructured parameters: ({ name }) => ...
|
|
436
|
+
if (param.type === 'ObjectPattern') {
|
|
437
|
+
for (const prop of param.properties ?? []) {
|
|
438
|
+
const val = prop.value ?? prop.key
|
|
439
|
+
if (val?.type === 'Identifier' && signalVars.has(val.name)) {
|
|
440
|
+
shadows.push(val.name)
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
// Handle array destructured parameters: ([a, b]) => ...
|
|
445
|
+
if (param.type === 'ArrayPattern') {
|
|
446
|
+
for (const el of param.elements ?? []) {
|
|
447
|
+
if (el?.type === 'Identifier' && signalVars.has(el.name)) {
|
|
448
|
+
shadows.push(el.name)
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
}
|
|
316
452
|
}
|
|
317
|
-
|
|
318
|
-
|
|
453
|
+
// Check top-level variable declarations in the function body
|
|
454
|
+
const body = node.body
|
|
455
|
+
const stmts = body?.body ?? body?.statements
|
|
456
|
+
if (!Array.isArray(stmts)) return shadows
|
|
457
|
+
for (const stmt of stmts) {
|
|
458
|
+
if (stmt.type === 'VariableDeclaration') {
|
|
459
|
+
for (const decl of stmt.declarations ?? []) {
|
|
460
|
+
if (decl.id?.type === 'Identifier' && signalVars.has(decl.id.name)) {
|
|
461
|
+
// Only shadow if it's NOT a signal() call
|
|
462
|
+
if (!decl.init || !isSignalCall(decl.init)) {
|
|
463
|
+
shadows.push(decl.id.name)
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
return shadows
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
function readsFromProps(node: N): boolean {
|
|
473
|
+
if (node.type === 'MemberExpression' && node.object?.type === 'Identifier') {
|
|
474
|
+
if (propsNames.has(node.object.name)) return true
|
|
319
475
|
}
|
|
320
|
-
// Check children recursively — e.g. props.x ?? 'default'
|
|
321
476
|
let found = false
|
|
322
|
-
|
|
477
|
+
forEachChildFast(node, (child) => {
|
|
323
478
|
if (found) return
|
|
324
479
|
if (readsFromProps(child)) found = true
|
|
325
480
|
})
|
|
326
481
|
return found
|
|
327
482
|
}
|
|
328
483
|
|
|
329
|
-
/**
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
if ((ts.isArrowFunction(node) || ts.isFunctionExpression(node))) {
|
|
336
|
-
const parent = node.parent
|
|
337
|
-
if (parent && ts.isCallExpression(parent) && parent.arguments.includes(node as any)) {
|
|
338
|
-
_callbackDepth++
|
|
339
|
-
ts.forEachChild(node, scanForPropDerivedVars)
|
|
340
|
-
_callbackDepth--
|
|
341
|
-
return
|
|
342
|
-
}
|
|
484
|
+
/** Check if an expression references any prop-derived variable. */
|
|
485
|
+
function referencesPropDerived(node: N): boolean {
|
|
486
|
+
if (node.type === 'Identifier' && propDerivedVars.has(node.name)) {
|
|
487
|
+
const p = findParent(node)
|
|
488
|
+
if (p && p.type === 'MemberExpression' && p.property === node && !p.computed) return false
|
|
489
|
+
return true
|
|
343
490
|
}
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
// Skip functions that are arguments to a call (map/filter callbacks)
|
|
352
|
-
const parent = node.parent
|
|
353
|
-
if (parent && ts.isCallExpression(parent) && parent.arguments.includes(node as any)) {
|
|
354
|
-
ts.forEachChild(node, scanForPropDerivedVars)
|
|
355
|
-
return
|
|
356
|
-
}
|
|
491
|
+
let found = false
|
|
492
|
+
forEachChildFast(node, (child) => {
|
|
493
|
+
if (found) return
|
|
494
|
+
if (referencesPropDerived(child)) found = true
|
|
495
|
+
})
|
|
496
|
+
return found
|
|
497
|
+
}
|
|
357
498
|
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
499
|
+
/** Collect prop-derived variable info from a VariableDeclaration node.
|
|
500
|
+
* Called inline during the single-pass walk when we encounter a declaration. */
|
|
501
|
+
function collectPropDerivedFromDecl(node: N, callbackDepth: number): void {
|
|
502
|
+
if (node.type !== 'VariableDeclaration') return
|
|
503
|
+
for (const decl of node.declarations ?? []) {
|
|
504
|
+
// splitProps: const [own, rest] = splitProps(props, [...])
|
|
505
|
+
if (decl.id?.type === 'ArrayPattern' && decl.init?.type === 'CallExpression') {
|
|
506
|
+
const callee = decl.init.callee
|
|
507
|
+
if (callee?.type === 'Identifier' && callee.name === 'splitProps') {
|
|
508
|
+
for (const el of decl.id.elements ?? []) {
|
|
509
|
+
if (el?.type === 'Identifier') propsNames.add(el.name)
|
|
367
510
|
}
|
|
368
|
-
|
|
369
|
-
})
|
|
370
|
-
if (hasJSX) propsNames.add(firstParam.name.text)
|
|
511
|
+
}
|
|
371
512
|
}
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
const callee = decl.initializer.expression
|
|
380
|
-
if (ts.isIdentifier(callee) && callee.text === 'splitProps') {
|
|
381
|
-
for (const el of decl.name.elements) {
|
|
382
|
-
if (ts.isBindingElement(el) && ts.isIdentifier(el.name)) {
|
|
383
|
-
propsNames.add(el.name.text)
|
|
384
|
-
}
|
|
385
|
-
}
|
|
386
|
-
}
|
|
513
|
+
if (node.kind !== 'const') continue
|
|
514
|
+
if (callbackDepth > 0) continue
|
|
515
|
+
if (decl.id?.type === 'Identifier' && decl.init) {
|
|
516
|
+
if (isStatefulCall(decl.init)) {
|
|
517
|
+
// Track signal() declarations for auto-call in JSX
|
|
518
|
+
if (isSignalCall(decl.init)) signalVars.add(decl.id.name)
|
|
519
|
+
continue
|
|
387
520
|
}
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
// Skip declarations inside callbacks (map, filter, etc.)
|
|
392
|
-
// Skip stateful calls (signal, computed, effect) — inlining creates new instances
|
|
393
|
-
if (!(node.declarationList.flags & ts.NodeFlags.Const)) continue
|
|
394
|
-
if (_callbackDepth > 0) continue
|
|
395
|
-
if (ts.isIdentifier(decl.name) && decl.initializer) {
|
|
396
|
-
if (isStatefulCall(decl.initializer)) continue
|
|
397
|
-
if (readsFromProps(decl.initializer)) {
|
|
398
|
-
propDerivedVars.set(decl.name.text, decl.initializer)
|
|
399
|
-
}
|
|
521
|
+
// Direct prop read OR transitive (references another prop-derived var)
|
|
522
|
+
if (readsFromProps(decl.init) || referencesPropDerived(decl.init)) {
|
|
523
|
+
propDerivedVars.set(decl.id.name, { start: decl.init.start as number, end: decl.init.end as number })
|
|
400
524
|
}
|
|
401
525
|
}
|
|
402
526
|
}
|
|
403
|
-
|
|
404
|
-
ts.forEachChild(node, scanForPropDerivedVars)
|
|
405
527
|
}
|
|
406
528
|
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
ts.forEachChild(decl.initializer, function check(n) {
|
|
425
|
-
if (usesPropVar) return
|
|
426
|
-
if (ts.isIdentifier(n) && propDerivedVars.has(n.text)) {
|
|
427
|
-
const parent = n.parent
|
|
428
|
-
if (parent && ts.isPropertyAccessExpression(parent) && parent.name === n) return
|
|
429
|
-
usesPropVar = true
|
|
430
|
-
}
|
|
431
|
-
ts.forEachChild(n, check)
|
|
432
|
-
})
|
|
433
|
-
if (usesPropVar) {
|
|
434
|
-
propDerivedVars.set(varName, decl.initializer)
|
|
435
|
-
changed = true
|
|
529
|
+
/** Detect component functions and register their first param as a props name.
|
|
530
|
+
* Called inline during the walk when entering a function. */
|
|
531
|
+
function maybeRegisterComponentProps(node: N): void {
|
|
532
|
+
if (
|
|
533
|
+
(node.type === 'FunctionDeclaration' || node.type === 'ArrowFunctionExpression' || node.type === 'FunctionExpression') &&
|
|
534
|
+
(node.params?.length ?? 0) > 0
|
|
535
|
+
) {
|
|
536
|
+
const parent = findParent(node)
|
|
537
|
+
// Skip callback functions (arguments to calls like .map, .filter)
|
|
538
|
+
if (parent && parent.type === 'CallExpression' && (parent.arguments ?? []).includes(node)) return
|
|
539
|
+
const firstParam = node.params[0]
|
|
540
|
+
if (firstParam?.type === 'Identifier') {
|
|
541
|
+
let hasJSX = false
|
|
542
|
+
function checkJSX(n: N): void {
|
|
543
|
+
if (hasJSX) return
|
|
544
|
+
if (n.type === 'JSXElement' || n.type === 'JSXFragment') { hasJSX = true; return }
|
|
545
|
+
forEachChildFast(n, checkJSX)
|
|
436
546
|
}
|
|
547
|
+
forEachChildFast(node, checkJSX)
|
|
548
|
+
if (hasJSX) propsNames.add(firstParam.name)
|
|
437
549
|
}
|
|
438
|
-
}
|
|
550
|
+
}
|
|
439
551
|
}
|
|
440
552
|
|
|
441
|
-
//
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
//
|
|
445
|
-
// The `visited` set prevents infinite recursion on circular references:
|
|
446
|
-
// const a = b + props.x; const b = a + 1;
|
|
447
|
-
// Without it, resolving `a` reaches `b`, which reaches `a` again, and
|
|
448
|
-
// the compiler stack-overflows. The fix: when a variable is already in
|
|
449
|
-
// the visited set, leave the identifier as-is (it falls back to the
|
|
450
|
-
// captured const value, which is the correct runtime behavior for a
|
|
451
|
-
// circular dependency — the variable reads its value at definition time).
|
|
452
|
-
// Track which cycles have been warned about so we don't emit
|
|
453
|
-
// duplicate warnings for the same cycle seen from different vars.
|
|
553
|
+
// ── String-based transitive resolution ─────────────────────────────────────
|
|
554
|
+
const resolvedCache = new Map<string, string>()
|
|
555
|
+
const resolving = new Set<string>()
|
|
454
556
|
const warnedCycles = new Set<string>()
|
|
455
557
|
|
|
456
|
-
function
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
if (ts.isShorthandPropertyAssignment(parent)) return n
|
|
483
|
-
}
|
|
558
|
+
function resolveVarToString(varName: string, sourceNode?: N): string {
|
|
559
|
+
if (resolvedCache.has(varName)) return resolvedCache.get(varName)!
|
|
560
|
+
if (resolving.has(varName)) {
|
|
561
|
+
const cycleKey = [...resolving, varName].sort().join(',')
|
|
562
|
+
if (!warnedCycles.has(cycleKey)) {
|
|
563
|
+
warnedCycles.add(cycleKey)
|
|
564
|
+
const chain = [...resolving, varName].join(' → ')
|
|
565
|
+
warn(
|
|
566
|
+
sourceNode ?? program,
|
|
567
|
+
`[Pyreon] Circular prop-derived const reference: ${chain}. ` +
|
|
568
|
+
`The cyclic identifier \`${varName}\` will use its captured value ` +
|
|
569
|
+
`instead of being reactively inlined. Break the cycle by reading ` +
|
|
570
|
+
`from \`props.*\` directly or restructuring the derivation chain.`,
|
|
571
|
+
'circular-prop-derived',
|
|
572
|
+
)
|
|
573
|
+
}
|
|
574
|
+
return varName
|
|
575
|
+
}
|
|
576
|
+
resolving.add(varName)
|
|
577
|
+
const span = propDerivedVars.get(varName)!
|
|
578
|
+
const rawText = code.slice(span.start, span.end)
|
|
579
|
+
const resolved = resolveIdentifiersInText(rawText, span.start, sourceNode)
|
|
580
|
+
resolving.delete(varName)
|
|
581
|
+
resolvedCache.set(varName, resolved)
|
|
582
|
+
return resolved
|
|
583
|
+
}
|
|
484
584
|
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
)
|
|
585
|
+
function resolveIdentifiersInText(text: string, baseOffset: number, sourceNode?: N): string {
|
|
586
|
+
const endOffset = baseOffset + text.length
|
|
587
|
+
const idents: { start: number; end: number; name: string }[] = []
|
|
588
|
+
|
|
589
|
+
// Walk the AST to find identifiers in the span, passing parent context
|
|
590
|
+
// to skip non-reference positions (property names, declarations, etc.)
|
|
591
|
+
function findIdents(node: N, parent: N | null): void {
|
|
592
|
+
const nodeStart = node.start as number
|
|
593
|
+
const nodeEnd = node.end as number
|
|
594
|
+
if (nodeStart >= endOffset || nodeEnd <= baseOffset) return
|
|
595
|
+
if (node.type === 'Identifier' && propDerivedVars.has(node.name)) {
|
|
596
|
+
if (parent) {
|
|
597
|
+
if (parent.type === 'MemberExpression' && parent.property === node && !parent.computed) { /* skip */ }
|
|
598
|
+
else if (parent.type === 'VariableDeclarator' && parent.id === node) { /* skip */ }
|
|
599
|
+
else if (parent.type === 'Property' && parent.key === node && !parent.computed) { /* skip */ }
|
|
600
|
+
else if (parent.type === 'Property' && parent.shorthand) { /* skip */ }
|
|
601
|
+
else if (nodeStart >= baseOffset && nodeEnd <= endOffset) {
|
|
602
|
+
idents.push({ start: nodeStart, end: nodeEnd, name: node.name })
|
|
503
603
|
}
|
|
504
|
-
|
|
604
|
+
} else if (nodeStart >= baseOffset && nodeEnd <= endOffset) {
|
|
605
|
+
idents.push({ start: nodeStart, end: nodeEnd, name: node.name })
|
|
505
606
|
}
|
|
506
|
-
|
|
507
|
-
const resolved = propDerivedVars.get(n.text)!
|
|
508
|
-
// Mark this variable as visited BEFORE recursing so cycles are
|
|
509
|
-
// detected on the next encounter rather than re-entering.
|
|
510
|
-
const nextVisited = new Set(visited)
|
|
511
|
-
nextVisited.add(n.text)
|
|
512
|
-
return ts.factory.createParenthesizedExpression(
|
|
513
|
-
resolveExprTransitive(resolved, nextVisited, sourceNode),
|
|
514
|
-
)
|
|
515
607
|
}
|
|
516
|
-
|
|
517
|
-
}
|
|
608
|
+
forEachChildFast(node, (child) => findIdents(child, node))
|
|
609
|
+
}
|
|
610
|
+
findIdents(program, null)
|
|
611
|
+
|
|
612
|
+
if (idents.length === 0) return text
|
|
613
|
+
|
|
614
|
+
idents.sort((a, b) => a.start - b.start)
|
|
615
|
+
const parts: string[] = []
|
|
616
|
+
let lastPos = baseOffset
|
|
617
|
+
for (const id of idents) {
|
|
618
|
+
parts.push(code.slice(lastPos, id.start))
|
|
619
|
+
parts.push(`(${resolveVarToString(id.name, sourceNode)})`)
|
|
620
|
+
lastPos = id.end
|
|
621
|
+
}
|
|
622
|
+
parts.push(code.slice(lastPos, endOffset))
|
|
623
|
+
return parts.join('')
|
|
518
624
|
}
|
|
519
625
|
|
|
520
|
-
|
|
521
|
-
|
|
626
|
+
// ── Analysis helpers with memoization (Phase 3) ────────────────────────────
|
|
627
|
+
// Cache results keyed by node.start (unique per node in a file).
|
|
628
|
+
// Eliminates redundant subtree traversals for containsCall + accessesProps.
|
|
629
|
+
const _isDynamicCache = new Map<number, boolean>()
|
|
630
|
+
|
|
631
|
+
/** Fused isDynamic: checks both containsCall and accessesProps in one traversal. */
|
|
632
|
+
function isDynamic(node: N): boolean {
|
|
633
|
+
const key = node.start as number
|
|
634
|
+
const cached = _isDynamicCache.get(key)
|
|
635
|
+
if (cached !== undefined) return cached
|
|
636
|
+
const result = _isDynamicImpl(node)
|
|
637
|
+
_isDynamicCache.set(key, result)
|
|
638
|
+
return result
|
|
639
|
+
}
|
|
522
640
|
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
641
|
+
function _isDynamicImpl(node: N): boolean {
|
|
642
|
+
// Call expression (non-pure)
|
|
643
|
+
if (node.type === 'CallExpression') {
|
|
644
|
+
if (!isPureStaticCall(node)) return true
|
|
645
|
+
}
|
|
646
|
+
if (node.type === 'TaggedTemplateExpression') return true
|
|
647
|
+
// Props access
|
|
648
|
+
if (node.type === 'MemberExpression' && !node.computed && node.object?.type === 'Identifier') {
|
|
649
|
+
if (propsNames.has(node.object.name)) return true
|
|
650
|
+
}
|
|
651
|
+
// Prop-derived variable reference
|
|
652
|
+
if (node.type === 'Identifier' && propDerivedVars.has(node.name)) {
|
|
653
|
+
const parent = findParent(node)
|
|
654
|
+
if (parent && parent.type === 'MemberExpression' && parent.property === node && !parent.computed) {
|
|
655
|
+
// This is a property name position, not a reference — fall through
|
|
656
|
+
} else {
|
|
657
|
+
return true
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
// Signal variable reference — treated as dynamic (will be auto-called)
|
|
661
|
+
if (node.type === 'Identifier' && isActiveSignal(node.name)) {
|
|
662
|
+
const parent = findParent(node)
|
|
663
|
+
if (parent && parent.type === 'MemberExpression' && parent.property === node && !parent.computed) {
|
|
664
|
+
// Property name position — not a reference
|
|
665
|
+
} else if (parent && parent.type === 'CallExpression' && parent.callee === node) {
|
|
666
|
+
// Already being called: signal() — don't double-flag
|
|
667
|
+
} else {
|
|
668
|
+
return true
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
// Don't recurse into nested functions
|
|
672
|
+
if (node.type === 'ArrowFunctionExpression' || node.type === 'FunctionExpression') return false
|
|
673
|
+
// Recurse into children
|
|
674
|
+
let found = false
|
|
675
|
+
forEachChildFast(node, (child) => {
|
|
676
|
+
if (found) return
|
|
677
|
+
if (isDynamic(child)) found = true
|
|
678
|
+
})
|
|
679
|
+
return found
|
|
531
680
|
}
|
|
532
681
|
|
|
533
|
-
/**
|
|
534
|
-
function accessesProps(node:
|
|
535
|
-
if (
|
|
536
|
-
if (propsNames.has(node.
|
|
682
|
+
/** accessesProps — kept for sliceExpr's quick check (does this need resolution?) */
|
|
683
|
+
function accessesProps(node: N): boolean {
|
|
684
|
+
if (node.type === 'MemberExpression' && !node.computed && node.object?.type === 'Identifier') {
|
|
685
|
+
if (propsNames.has(node.object.name)) return true
|
|
537
686
|
}
|
|
538
|
-
if (
|
|
539
|
-
const parent = node
|
|
540
|
-
if (parent &&
|
|
687
|
+
if (node.type === 'Identifier' && propDerivedVars.has(node.name)) {
|
|
688
|
+
const parent = findParent(node)
|
|
689
|
+
if (parent && parent.type === 'MemberExpression' && parent.property === node && !parent.computed) return false
|
|
541
690
|
return true
|
|
542
691
|
}
|
|
543
692
|
let found = false
|
|
544
|
-
|
|
693
|
+
forEachChildFast(node, (child) => {
|
|
545
694
|
if (found) return
|
|
546
|
-
if (
|
|
695
|
+
if (child.type === 'ArrowFunctionExpression' || child.type === 'FunctionExpression') return
|
|
547
696
|
if (accessesProps(child)) found = true
|
|
548
697
|
})
|
|
549
698
|
return found
|
|
550
699
|
}
|
|
551
700
|
|
|
552
|
-
function shouldWrap(node:
|
|
553
|
-
if (
|
|
701
|
+
function shouldWrap(node: N): boolean {
|
|
702
|
+
if (node.type === 'ArrowFunctionExpression' || node.type === 'FunctionExpression') return false
|
|
554
703
|
if (isStatic(node)) return false
|
|
555
|
-
if (
|
|
704
|
+
if (node.type === 'CallExpression' && isPureStaticCall(node)) return false
|
|
556
705
|
return isDynamic(node)
|
|
557
706
|
}
|
|
558
707
|
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
708
|
+
// ── Single unified walk (Phase 2) ─────────────────────────────────────────
|
|
709
|
+
// Merges the old 3-pass architecture (scanForPropDerivedVars + transitive
|
|
710
|
+
// resolution + JSX walk) into one top-down traversal. Works because `const`
|
|
711
|
+
// declarations have a temporal dead zone — they're always before their use.
|
|
712
|
+
let _callbackDepth = 0
|
|
713
|
+
|
|
714
|
+
function walkNode(node: N): void {
|
|
715
|
+
// ── Component function detection (was pass 1) ──
|
|
716
|
+
const isFunction = node.type === 'FunctionDeclaration' || node.type === 'ArrowFunctionExpression' || node.type === 'FunctionExpression'
|
|
717
|
+
let scopeShadows: string[] | null = null
|
|
718
|
+
if (isFunction) {
|
|
719
|
+
// Track callback nesting for prop-derived var exclusion
|
|
720
|
+
const parent = findParent(node)
|
|
721
|
+
const isCallbackArg = parent && parent.type === 'CallExpression' && (parent.arguments ?? []).includes(node)
|
|
722
|
+
if (isCallbackArg) _callbackDepth++
|
|
723
|
+
// Register component props (only for non-callback functions with JSX)
|
|
724
|
+
maybeRegisterComponentProps(node)
|
|
725
|
+
// Track signal name shadowing for scope awareness
|
|
726
|
+
if (signalVars.size > 0) {
|
|
727
|
+
scopeShadows = findShadowingNames(node)
|
|
728
|
+
for (const name of scopeShadows) shadowedSignals.add(name)
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
// ── Variable declaration collection (was pass 1 + 2) ──
|
|
733
|
+
if (node.type === 'VariableDeclaration') {
|
|
734
|
+
collectPropDerivedFromDecl(node, _callbackDepth)
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
// ── JSX processing (was pass 3) ──
|
|
738
|
+
if (node.type === 'JSXElement') {
|
|
739
|
+
if (!isSelfClosing(node) && tryTemplateEmit(node)) {
|
|
740
|
+
// Template emitted — don't recurse into this subtree (JSXElement is never a function)
|
|
741
|
+
return
|
|
742
|
+
}
|
|
743
|
+
checkForWarnings(node)
|
|
744
|
+
for (const attr of jsxAttrs(node)) {
|
|
745
|
+
if (attr.type === 'JSXAttribute') handleJsxAttribute(attr, node)
|
|
746
|
+
}
|
|
747
|
+
for (const child of jsxChildren(node)) {
|
|
748
|
+
if (child.type === 'JSXExpressionContainer') handleJsxExpression(child)
|
|
749
|
+
else walkNode(child)
|
|
750
|
+
}
|
|
751
|
+
// Note: JSXElement is never a function, so no callback depth or scope cleanup needed here
|
|
564
752
|
return
|
|
565
753
|
}
|
|
566
|
-
if (
|
|
754
|
+
if (node.type === 'JSXExpressionContainer') {
|
|
567
755
|
handleJsxExpression(node)
|
|
756
|
+
// Note: JSXExpressionContainer is never a function, no scope cleanup needed
|
|
568
757
|
return
|
|
569
758
|
}
|
|
570
|
-
|
|
759
|
+
|
|
760
|
+
// Generic descent
|
|
761
|
+
forEachChildFast(node, walkNode)
|
|
762
|
+
|
|
763
|
+
// Restore callback depth after leaving function
|
|
764
|
+
if (isFunction) {
|
|
765
|
+
const parent = findParent(node)
|
|
766
|
+
if (parent && parent.type === 'CallExpression' && (parent.arguments ?? []).includes(node)) _callbackDepth--
|
|
767
|
+
}
|
|
768
|
+
// Restore signal shadowing
|
|
769
|
+
if (scopeShadows) for (const name of scopeShadows) shadowedSignals.delete(name)
|
|
571
770
|
}
|
|
572
771
|
|
|
573
|
-
|
|
772
|
+
walkNode(program)
|
|
574
773
|
|
|
575
774
|
if (replacements.length === 0 && hoists.length === 0) return { code, warnings }
|
|
576
775
|
|
|
577
|
-
// Apply replacements left-to-right via string builder — O(n) single join
|
|
578
776
|
replacements.sort((a, b) => a.start - b.start)
|
|
579
|
-
|
|
580
777
|
const parts: string[] = []
|
|
581
778
|
let lastPos = 0
|
|
582
779
|
for (const r of replacements) {
|
|
@@ -587,13 +784,11 @@ export function transformJSX(
|
|
|
587
784
|
parts.push(code.slice(lastPos))
|
|
588
785
|
let result = parts.join('')
|
|
589
786
|
|
|
590
|
-
// Prepend module-scope hoisted static VNode declarations
|
|
591
787
|
if (hoists.length > 0) {
|
|
592
788
|
const preamble = hoists.map((h) => `const ${h.name} = /*@__PURE__*/ ${h.text}\n`).join('')
|
|
593
789
|
result = preamble + result
|
|
594
790
|
}
|
|
595
791
|
|
|
596
|
-
// Prepend template imports if _tpl() was emitted
|
|
597
792
|
if (needsTplImport) {
|
|
598
793
|
const runtimeDomImports = ['_tpl']
|
|
599
794
|
if (needsBindDirectImportGlobal) runtimeDomImports.push('_bindDirect')
|
|
@@ -608,65 +803,45 @@ export function transformJSX(
|
|
|
608
803
|
result
|
|
609
804
|
}
|
|
610
805
|
|
|
611
|
-
// Prepend _rp import if reactive component props were emitted
|
|
612
806
|
if (needsRpImport) {
|
|
613
807
|
result = `import { _rp } from "@pyreon/core";\n` + result
|
|
614
808
|
}
|
|
615
809
|
|
|
616
810
|
return { code: result, usesTemplates: needsTplImport, warnings }
|
|
617
811
|
|
|
618
|
-
// ── Template emission helpers
|
|
812
|
+
// ── Template emission helpers ─────────────────────────────────────────────
|
|
619
813
|
|
|
620
|
-
|
|
621
|
-
* Check if attributes prevent template emission.
|
|
622
|
-
* - `key` always bails (VNode reconciliation prop)
|
|
623
|
-
* - Spread on inner elements bails (too complex to merge in _bind)
|
|
624
|
-
* - Spread on root element is allowed — applied via applyProps in _bind
|
|
625
|
-
*/
|
|
626
|
-
function hasBailAttr(node: ts.JsxElement | ts.JsxSelfClosingElement, isRoot = false): boolean {
|
|
814
|
+
function hasBailAttr(node: N, isRoot = false): boolean {
|
|
627
815
|
for (const attr of jsxAttrs(node)) {
|
|
628
|
-
if (
|
|
629
|
-
// Allow spread on root element — handled in buildTemplateCall
|
|
816
|
+
if (attr.type === 'JSXSpreadAttribute') {
|
|
630
817
|
if (isRoot) continue
|
|
631
818
|
return true
|
|
632
819
|
}
|
|
633
|
-
if (
|
|
820
|
+
if (attr.type === 'JSXAttribute' && attr.name?.type === 'JSXIdentifier' && attr.name.name === 'key')
|
|
634
821
|
return true
|
|
635
822
|
}
|
|
636
823
|
return false
|
|
637
824
|
}
|
|
638
825
|
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
if (
|
|
648
|
-
if (!child.expression) return 0
|
|
649
|
-
return containsJSXInExpr(child.expression) ? -1 : 0
|
|
650
|
-
}
|
|
651
|
-
if (ts.isJsxFragment(child)) return templateFragmentCount(child)
|
|
826
|
+
function countChildForTemplate(child: N): number {
|
|
827
|
+
if (child.type === 'JSXText') return 0
|
|
828
|
+
if (child.type === 'JSXElement') return templateElementCount(child)
|
|
829
|
+
if (child.type === 'JSXExpressionContainer') {
|
|
830
|
+
const expr = child.expression
|
|
831
|
+
if (!expr || expr.type === 'JSXEmptyExpression') return 0
|
|
832
|
+
return containsJSXInExpr(expr) ? -1 : 0
|
|
833
|
+
}
|
|
834
|
+
if (child.type === 'JSXFragment') return templateFragmentCount(child)
|
|
652
835
|
return -1
|
|
653
836
|
}
|
|
654
837
|
|
|
655
|
-
|
|
656
|
-
* Count DOM elements in a JSX subtree. Returns -1 if the tree is not
|
|
657
|
-
* eligible for template emission.
|
|
658
|
-
*/
|
|
659
|
-
function templateElementCount(
|
|
660
|
-
node: ts.JsxElement | ts.JsxSelfClosingElement,
|
|
661
|
-
isRoot = false,
|
|
662
|
-
): number {
|
|
838
|
+
function templateElementCount(node: N, isRoot = false): number {
|
|
663
839
|
const tag = jsxTagName(node)
|
|
664
840
|
if (!tag || !isLowerCase(tag)) return -1
|
|
665
841
|
if (hasBailAttr(node, isRoot)) return -1
|
|
666
|
-
if (
|
|
667
|
-
|
|
842
|
+
if (isSelfClosing(node)) return 1
|
|
668
843
|
let count = 1
|
|
669
|
-
for (const child of node
|
|
844
|
+
for (const child of jsxChildren(node)) {
|
|
670
845
|
const c = countChildForTemplate(child)
|
|
671
846
|
if (c === -1) return -1
|
|
672
847
|
count += c
|
|
@@ -674,10 +849,9 @@ export function transformJSX(
|
|
|
674
849
|
return count
|
|
675
850
|
}
|
|
676
851
|
|
|
677
|
-
|
|
678
|
-
function templateFragmentCount(frag: ts.JsxFragment): number {
|
|
852
|
+
function templateFragmentCount(frag: N): number {
|
|
679
853
|
let count = 0
|
|
680
|
-
for (const child of frag
|
|
854
|
+
for (const child of jsxChildren(frag)) {
|
|
681
855
|
const c = countChildForTemplate(child)
|
|
682
856
|
if (c === -1) return -1
|
|
683
857
|
count += c
|
|
@@ -685,35 +859,25 @@ export function transformJSX(
|
|
|
685
859
|
return count
|
|
686
860
|
}
|
|
687
861
|
|
|
688
|
-
|
|
689
|
-
* Build the complete `_tpl("html", (__root) => { ... })` call string
|
|
690
|
-
* for a template-eligible JSX element tree. Returns null if codegen fails.
|
|
691
|
-
*/
|
|
692
|
-
function buildTemplateCall(node: ts.JsxElement | ts.JsxSelfClosingElement): string | null {
|
|
862
|
+
function buildTemplateCall(node: N): string | null {
|
|
693
863
|
const bindLines: string[] = []
|
|
694
864
|
const disposerNames: string[] = []
|
|
695
865
|
let varIdx = 0
|
|
696
866
|
let dispIdx = 0
|
|
697
|
-
// Reactive expressions that will be combined into a single _bind call
|
|
698
867
|
const reactiveBindExprs: string[] = []
|
|
699
868
|
let needsBindTextImport = false
|
|
700
869
|
let needsBindDirectImport = false
|
|
701
870
|
let needsApplyPropsImport = false
|
|
702
871
|
let needsMountSlotImport = false
|
|
703
872
|
|
|
704
|
-
function nextVar(): string {
|
|
705
|
-
return `__e${varIdx++}`
|
|
706
|
-
}
|
|
873
|
+
function nextVar(): string { return `__e${varIdx++}` }
|
|
707
874
|
function nextDisp(): string {
|
|
708
875
|
const name = `__d${dispIdx++}`
|
|
709
876
|
disposerNames.push(name)
|
|
710
877
|
return name
|
|
711
878
|
}
|
|
712
|
-
function nextTextVar(): string {
|
|
713
|
-
return `__t${varIdx++}`
|
|
714
|
-
}
|
|
879
|
+
function nextTextVar(): string { return `__t${varIdx++}` }
|
|
715
880
|
|
|
716
|
-
/** Resolve the variable name for an element given its accessor path. */
|
|
717
881
|
function resolveElementVar(accessor: string, hasDynamic: boolean): string {
|
|
718
882
|
if (accessor === '__root') return '__root'
|
|
719
883
|
if (hasDynamic) {
|
|
@@ -724,14 +888,11 @@ export function transformJSX(
|
|
|
724
888
|
return accessor
|
|
725
889
|
}
|
|
726
890
|
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
if (
|
|
732
|
-
// Function ref: ref={(el) => { ... }} or ref={fn} → call with element
|
|
733
|
-
// Object ref: ref={myRef} → assign element to .current
|
|
734
|
-
if (ts.isArrowFunction(expr) || ts.isFunctionExpression(expr)) {
|
|
891
|
+
function emitRef(attr: N, varName: string): void {
|
|
892
|
+
if (!attr.value || attr.value.type !== 'JSXExpressionContainer') return
|
|
893
|
+
const expr = attr.value.expression
|
|
894
|
+
if (!expr || expr.type === 'JSXEmptyExpression') return
|
|
895
|
+
if (expr.type === 'ArrowFunctionExpression' || expr.type === 'FunctionExpression') {
|
|
735
896
|
bindLines.push(`(${sliceExpr(expr)})(${varName})`)
|
|
736
897
|
} else {
|
|
737
898
|
bindLines.push(
|
|
@@ -740,87 +901,87 @@ export function transformJSX(
|
|
|
740
901
|
}
|
|
741
902
|
}
|
|
742
903
|
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
904
|
+
function emitEventListener(attr: N, attrName: string, varName: string): void {
|
|
905
|
+
// Translate the JSX-style React attribute name (e.g. `onKeyDown`,
|
|
906
|
+
// `onDoubleClick`) to the canonical DOM event name (`keydown`,
|
|
907
|
+
// `dblclick`).
|
|
908
|
+
//
|
|
909
|
+
// The default rule is "drop the `on` prefix and lowercase" —
|
|
910
|
+
// covers `onKeyDown` → `keydown`, `onMouseEnter` → `mouseenter`,
|
|
911
|
+
// `onPointerLeave` → `pointerleave`, `onAnimationStart` →
|
|
912
|
+
// `animationstart`, etc. Most React event names follow this rule
|
|
913
|
+
// because the underlying DOM event name is also the lowercased
|
|
914
|
+
// multi-word form.
|
|
915
|
+
//
|
|
916
|
+
// The exception list lives in `REACT_EVENT_REMAP` (event-names.ts).
|
|
917
|
+
// Every React event-prop in the official component-prop list was
|
|
918
|
+
// audited against canonical DOM event names — see the JSDoc on
|
|
919
|
+
// REACT_EVENT_REMAP for the audit. Today exactly one entry:
|
|
920
|
+
// `onDoubleClick` → `dblclick`
|
|
921
|
+
// The Rust native backend (`native/src/lib.rs:emit_event_listener`)
|
|
922
|
+
// mirrors the same table — keep them in sync if a new entry is added.
|
|
923
|
+
const lowered = attrName.slice(2).toLowerCase()
|
|
924
|
+
const eventName = REACT_EVENT_REMAP[lowered] ?? lowered
|
|
925
|
+
if (!attr.value || attr.value.type !== 'JSXExpressionContainer') return
|
|
926
|
+
const expr = attr.value.expression
|
|
927
|
+
if (!expr || expr.type === 'JSXEmptyExpression') return
|
|
928
|
+
const handler = sliceExpr(expr)
|
|
749
929
|
if (DELEGATED_EVENTS.has(eventName)) {
|
|
750
|
-
// Delegated: store handler as expando property — container listener picks it up
|
|
751
930
|
bindLines.push(`${varName}.__ev_${eventName} = ${handler}`)
|
|
752
931
|
} else {
|
|
753
932
|
bindLines.push(`${varName}.addEventListener("${eventName}", ${handler})`)
|
|
754
933
|
}
|
|
755
934
|
}
|
|
756
935
|
|
|
757
|
-
|
|
758
|
-
function staticAttrToHtml(exprNode: ts.Expression, htmlAttrName: string): string | null {
|
|
936
|
+
function staticAttrToHtml(exprNode: N, htmlAttrName: string): string | null {
|
|
759
937
|
if (!isStatic(exprNode)) return null
|
|
760
|
-
|
|
761
|
-
if (
|
|
762
|
-
|
|
938
|
+
// String literal
|
|
939
|
+
if ((exprNode.type === 'Literal' || exprNode.type === 'StringLiteral') && typeof exprNode.value === 'string')
|
|
940
|
+
return ` ${htmlAttrName}="${escapeHtmlAttr(exprNode.value)}"`
|
|
941
|
+
// Numeric literal
|
|
942
|
+
if ((exprNode.type === 'Literal' || exprNode.type === 'NumericLiteral') && typeof exprNode.value === 'number')
|
|
943
|
+
return ` ${htmlAttrName}="${exprNode.value}"`
|
|
944
|
+
// Boolean true
|
|
945
|
+
if ((exprNode.type === 'Literal' || exprNode.type === 'BooleanLiteral') && exprNode.value === true)
|
|
946
|
+
return ` ${htmlAttrName}`
|
|
763
947
|
return '' // false/null/undefined → omit
|
|
764
948
|
}
|
|
765
949
|
|
|
766
|
-
|
|
767
|
-
* Try to extract a direct signal reference from an expression.
|
|
768
|
-
* Returns the callee text (e.g. "count" or "row.label") if the expression
|
|
769
|
-
* is a single call with no arguments, otherwise null.
|
|
770
|
-
*/
|
|
771
|
-
function tryDirectSignalRef(exprNode: ts.Expression): string | null {
|
|
950
|
+
function tryDirectSignalRef(exprNode: N): string | null {
|
|
772
951
|
let inner = exprNode
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
inner = inner.body as ts.Expression
|
|
776
|
-
}
|
|
777
|
-
if (!ts.isCallExpression(inner)) return null
|
|
778
|
-
if (inner.arguments.length > 0) return null
|
|
779
|
-
const callee = inner.expression
|
|
780
|
-
// Only match simple identifiers: count() → _bindText(count, node)
|
|
781
|
-
// Property access like obj.method() is NOT safe — detaching the method
|
|
782
|
-
// loses `this` context (e.g. value.toLocaleString becomes unbound).
|
|
783
|
-
if (ts.isIdentifier(callee)) {
|
|
784
|
-
return sliceExpr(callee)
|
|
952
|
+
if (inner.type === 'ArrowFunctionExpression' && inner.body?.type !== 'BlockStatement') {
|
|
953
|
+
inner = inner.body
|
|
785
954
|
}
|
|
955
|
+
if (inner.type !== 'CallExpression') return null
|
|
956
|
+
if ((inner.arguments?.length ?? 0) > 0) return null
|
|
957
|
+
const callee = inner.callee
|
|
958
|
+
if (callee?.type === 'Identifier') return sliceExpr(callee)
|
|
786
959
|
return null
|
|
787
960
|
}
|
|
788
961
|
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
if (ts.isArrowFunction(exprNode) && !ts.isBlock(exprNode.body)) {
|
|
793
|
-
return { expr: sliceExpr(exprNode.body as ts.Expression), isReactive: true }
|
|
962
|
+
function unwrapAccessor(exprNode: N): { expr: string; isReactive: boolean } {
|
|
963
|
+
if (exprNode.type === 'ArrowFunctionExpression' && exprNode.body?.type !== 'BlockStatement') {
|
|
964
|
+
return { expr: sliceExpr(exprNode.body), isReactive: true }
|
|
794
965
|
}
|
|
795
|
-
|
|
796
|
-
if (ts.isArrowFunction(exprNode) || ts.isFunctionExpression(exprNode)) {
|
|
966
|
+
if (exprNode.type === 'ArrowFunctionExpression' || exprNode.type === 'FunctionExpression') {
|
|
797
967
|
return { expr: `(${sliceExpr(exprNode)})()`, isReactive: true }
|
|
798
968
|
}
|
|
799
969
|
return { expr: sliceExpr(exprNode), isReactive: isDynamic(exprNode) }
|
|
800
970
|
}
|
|
801
971
|
|
|
802
|
-
/** Build a setter expression for an attribute. */
|
|
803
972
|
function attrSetter(htmlAttrName: string, varName: string, expr: string): string {
|
|
804
973
|
if (htmlAttrName === 'class') return `${varName}.className = ${expr}`
|
|
805
974
|
if (htmlAttrName === 'style') return `${varName}.style.cssText = ${expr}`
|
|
975
|
+
if (DOM_PROPS.has(htmlAttrName)) return `${varName}.${htmlAttrName} = ${expr}`
|
|
806
976
|
return `${varName}.setAttribute("${htmlAttrName}", ${expr})`
|
|
807
977
|
}
|
|
808
978
|
|
|
809
|
-
|
|
810
|
-
function emitDynamicAttr(
|
|
811
|
-
_expr: string,
|
|
812
|
-
exprNode: ts.Expression,
|
|
813
|
-
htmlAttrName: string,
|
|
814
|
-
varName: string,
|
|
815
|
-
): void {
|
|
979
|
+
function emitDynamicAttr(_expr: string, exprNode: N, htmlAttrName: string, varName: string): void {
|
|
816
980
|
const { expr, isReactive } = unwrapAccessor(exprNode)
|
|
817
|
-
|
|
818
981
|
if (!isReactive) {
|
|
819
982
|
bindLines.push(attrSetter(htmlAttrName, varName, expr))
|
|
820
983
|
return
|
|
821
984
|
}
|
|
822
|
-
|
|
823
|
-
// Direct signal binding for bare signal calls (e.g. class={() => active()})
|
|
824
985
|
const directRef = tryDirectSignalRef(exprNode)
|
|
825
986
|
if (directRef) {
|
|
826
987
|
needsBindDirectImport = true
|
|
@@ -830,115 +991,85 @@ export function transformJSX(
|
|
|
830
991
|
? `(v) => { ${varName}.className = v == null ? "" : String(v) }`
|
|
831
992
|
: htmlAttrName === 'style'
|
|
832
993
|
? `(v) => { if (typeof v === "string") ${varName}.style.cssText = v; else if (v) Object.assign(${varName}.style, v) }`
|
|
833
|
-
:
|
|
994
|
+
: DOM_PROPS.has(htmlAttrName)
|
|
995
|
+
? `(v) => { ${varName}.${htmlAttrName} = v }`
|
|
996
|
+
: `(v) => { ${varName}.setAttribute("${htmlAttrName}", v == null ? "" : String(v)) }`
|
|
834
997
|
bindLines.push(`const ${d} = _bindDirect(${directRef}, ${updater})`)
|
|
835
998
|
return
|
|
836
999
|
}
|
|
837
|
-
|
|
838
1000
|
reactiveBindExprs.push(attrSetter(htmlAttrName, varName, expr))
|
|
839
1001
|
}
|
|
840
1002
|
|
|
841
|
-
|
|
842
|
-
function emitAttrExpression(
|
|
843
|
-
exprNode: ts.Expression,
|
|
844
|
-
htmlAttrName: string,
|
|
845
|
-
varName: string,
|
|
846
|
-
): string {
|
|
1003
|
+
function emitAttrExpression(exprNode: N, htmlAttrName: string, varName: string): string {
|
|
847
1004
|
const staticHtml = staticAttrToHtml(exprNode, htmlAttrName)
|
|
848
1005
|
if (staticHtml !== null) return staticHtml
|
|
849
|
-
|
|
850
|
-
// style={{...}} → Object.assign(el.style, {...}) for object expressions
|
|
851
|
-
if (htmlAttrName === 'style' && ts.isObjectLiteralExpression(exprNode)) {
|
|
1006
|
+
if (htmlAttrName === 'style' && exprNode.type === 'ObjectExpression') {
|
|
852
1007
|
bindLines.push(`Object.assign(${varName}.style, ${sliceExpr(exprNode)})`)
|
|
853
1008
|
return ''
|
|
854
1009
|
}
|
|
855
|
-
|
|
856
1010
|
emitDynamicAttr(sliceExpr(exprNode), exprNode, htmlAttrName, varName)
|
|
857
1011
|
return ''
|
|
858
1012
|
}
|
|
859
1013
|
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
if (attrName
|
|
863
|
-
emitRef(attr, varName)
|
|
864
|
-
return true
|
|
865
|
-
}
|
|
866
|
-
if (EVENT_RE.test(attrName)) {
|
|
867
|
-
emitEventListener(attr, attrName, varName)
|
|
868
|
-
return true
|
|
869
|
-
}
|
|
1014
|
+
function tryEmitSpecialAttr(attr: N, attrName: string, varName: string): boolean {
|
|
1015
|
+
if (attrName === 'ref') { emitRef(attr, varName); return true }
|
|
1016
|
+
if (EVENT_RE.test(attrName)) { emitEventListener(attr, attrName, varName); return true }
|
|
870
1017
|
return false
|
|
871
1018
|
}
|
|
872
1019
|
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
if (ts.isJsxExpression(attr.initializer) && attr.initializer.expression)
|
|
883
|
-
return emitAttrExpression(attr.initializer.expression, htmlAttrName, varName)
|
|
1020
|
+
function attrInitializerToHtml(attr: N, htmlAttrName: string, varName: string): string {
|
|
1021
|
+
if (!attr.value) return ` ${htmlAttrName}`
|
|
1022
|
+
// JSX string attribute: class="foo"
|
|
1023
|
+
if (attr.value.type === 'StringLiteral' || (attr.value.type === 'Literal' && typeof attr.value.value === 'string'))
|
|
1024
|
+
return ` ${htmlAttrName}="${escapeHtmlAttr(attr.value.value)}"`
|
|
1025
|
+
if (attr.value.type === 'JSXExpressionContainer') {
|
|
1026
|
+
const expr = attr.value.expression
|
|
1027
|
+
if (expr && expr.type !== 'JSXEmptyExpression') return emitAttrExpression(expr, htmlAttrName, varName)
|
|
1028
|
+
}
|
|
884
1029
|
return ''
|
|
885
1030
|
}
|
|
886
1031
|
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
if (ts.isJsxSpreadAttribute(attr)) {
|
|
891
|
-
const expr = sliceExpr(attr.expression)
|
|
892
|
-
// Use runtime-dom's applyProps which handles class, style, events, etc.
|
|
1032
|
+
function processOneAttr(attr: N, varName: string): string {
|
|
1033
|
+
if (attr.type === 'JSXSpreadAttribute') {
|
|
1034
|
+
const expr = sliceExpr(attr.argument)
|
|
893
1035
|
needsApplyPropsImport = true
|
|
894
|
-
if (isDynamic(attr.
|
|
1036
|
+
if (isDynamic(attr.argument)) {
|
|
895
1037
|
reactiveBindExprs.push(`_applyProps(${varName}, ${expr})`)
|
|
896
1038
|
} else {
|
|
897
1039
|
bindLines.push(`_applyProps(${varName}, ${expr})`)
|
|
898
1040
|
}
|
|
899
1041
|
return ''
|
|
900
1042
|
}
|
|
901
|
-
if (
|
|
902
|
-
const attrName =
|
|
1043
|
+
if (attr.type !== 'JSXAttribute') return ''
|
|
1044
|
+
const attrName = attr.name?.type === 'JSXIdentifier' ? attr.name.name : ''
|
|
903
1045
|
if (attrName === 'key') return ''
|
|
904
1046
|
if (tryEmitSpecialAttr(attr, attrName, varName)) return ''
|
|
905
1047
|
return attrInitializerToHtml(attr, JSX_TO_HTML_ATTR[attrName] ?? attrName, varName)
|
|
906
1048
|
}
|
|
907
1049
|
|
|
908
|
-
|
|
909
|
-
function processAttrs(el: ts.JsxElement | ts.JsxSelfClosingElement, varName: string): string {
|
|
1050
|
+
function processAttrs(el: N, varName: string): string {
|
|
910
1051
|
let htmlAttrs = ''
|
|
911
1052
|
for (const attr of jsxAttrs(el)) htmlAttrs += processOneAttr(attr, varName)
|
|
912
1053
|
return htmlAttrs
|
|
913
1054
|
}
|
|
914
1055
|
|
|
915
|
-
/** Emit bind lines for a reactive text expression child. */
|
|
916
1056
|
function emitReactiveTextChild(
|
|
917
|
-
expr: string,
|
|
918
|
-
|
|
919
|
-
varName: string,
|
|
920
|
-
parentRef: string,
|
|
921
|
-
childNodeIdx: number,
|
|
922
|
-
needsPlaceholder: boolean,
|
|
1057
|
+
expr: string, exprNode: N, varName: string,
|
|
1058
|
+
parentRef: string, childNodeIdx: number, needsPlaceholder: boolean,
|
|
923
1059
|
): string {
|
|
924
1060
|
const tVar = nextTextVar()
|
|
925
1061
|
bindLines.push(`const ${tVar} = document.createTextNode("")`)
|
|
926
1062
|
if (needsPlaceholder) {
|
|
927
|
-
bindLines.push(
|
|
928
|
-
`${parentRef}.replaceChild(${tVar}, ${parentRef}.childNodes[${childNodeIdx}])`,
|
|
929
|
-
)
|
|
1063
|
+
bindLines.push(`${parentRef}.replaceChild(${tVar}, ${parentRef}.childNodes[${childNodeIdx}])`)
|
|
930
1064
|
} else {
|
|
931
1065
|
bindLines.push(`${varName}.appendChild(${tVar})`)
|
|
932
1066
|
}
|
|
933
|
-
// Direct signal binding: bypass effect system entirely
|
|
934
1067
|
const directRef = tryDirectSignalRef(exprNode)
|
|
935
1068
|
if (directRef) {
|
|
936
1069
|
needsBindTextImport = true
|
|
937
1070
|
const d = nextDisp()
|
|
938
1071
|
bindLines.push(`const ${d} = _bindText(${directRef}, ${tVar})`)
|
|
939
1072
|
} else {
|
|
940
|
-
// Each reactive text child gets its own _bind — independent tracking.
|
|
941
|
-
// When r.name() changes, r.email()'s _bind doesn't re-run.
|
|
942
1073
|
needsBindImportGlobal = true
|
|
943
1074
|
const d = nextDisp()
|
|
944
1075
|
bindLines.push(`const ${d} = _bind(() => { ${tVar}.data = ${expr} })`)
|
|
@@ -946,34 +1077,98 @@ export function transformJSX(
|
|
|
946
1077
|
return needsPlaceholder ? '<!>' : ''
|
|
947
1078
|
}
|
|
948
1079
|
|
|
949
|
-
/** Emit bind lines for a static text expression child. */
|
|
950
1080
|
function emitStaticTextChild(
|
|
951
|
-
expr: string,
|
|
952
|
-
|
|
953
|
-
parentRef: string,
|
|
954
|
-
childNodeIdx: number,
|
|
955
|
-
needsPlaceholder: boolean,
|
|
1081
|
+
expr: string, varName: string,
|
|
1082
|
+
parentRef: string, childNodeIdx: number, needsPlaceholder: boolean,
|
|
956
1083
|
): string {
|
|
957
1084
|
if (needsPlaceholder) {
|
|
958
1085
|
const tVar = nextTextVar()
|
|
959
1086
|
bindLines.push(`const ${tVar} = document.createTextNode(${expr})`)
|
|
960
|
-
bindLines.push(
|
|
961
|
-
`${parentRef}.replaceChild(${tVar}, ${parentRef}.childNodes[${childNodeIdx}])`,
|
|
962
|
-
)
|
|
1087
|
+
bindLines.push(`${parentRef}.replaceChild(${tVar}, ${parentRef}.childNodes[${childNodeIdx}])`)
|
|
963
1088
|
return '<!>'
|
|
964
1089
|
}
|
|
965
1090
|
bindLines.push(`${varName}.textContent = ${expr}`)
|
|
966
1091
|
return ''
|
|
967
1092
|
}
|
|
968
1093
|
|
|
969
|
-
|
|
1094
|
+
type FlatChild =
|
|
1095
|
+
| { kind: 'text'; text: string }
|
|
1096
|
+
| { kind: 'element'; node: N; elemIdx: number }
|
|
1097
|
+
| { kind: 'expression'; expression: N }
|
|
1098
|
+
|
|
1099
|
+
function classifyJsxChild(
|
|
1100
|
+
child: N, out: FlatChild[],
|
|
1101
|
+
elemIdxRef: { value: number },
|
|
1102
|
+
recurse: (kids: N[]) => void,
|
|
1103
|
+
): void {
|
|
1104
|
+
if (child.type === 'JSXText') {
|
|
1105
|
+
const raw = child.value ?? child.raw ?? ''
|
|
1106
|
+
const cleaned = cleanJsxText(raw)
|
|
1107
|
+
if (cleaned) out.push({ kind: 'text', text: cleaned })
|
|
1108
|
+
return
|
|
1109
|
+
}
|
|
1110
|
+
if (child.type === 'JSXElement') {
|
|
1111
|
+
out.push({ kind: 'element', node: child, elemIdx: elemIdxRef.value++ })
|
|
1112
|
+
return
|
|
1113
|
+
}
|
|
1114
|
+
if (child.type === 'JSXExpressionContainer') {
|
|
1115
|
+
const expr = child.expression
|
|
1116
|
+
if (expr && expr.type !== 'JSXEmptyExpression') out.push({ kind: 'expression', expression: expr })
|
|
1117
|
+
return
|
|
1118
|
+
}
|
|
1119
|
+
if (child.type === 'JSXFragment') recurse(jsxChildren(child))
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
function flattenChildren(children: N[]): FlatChild[] {
|
|
1123
|
+
const flatList: FlatChild[] = []
|
|
1124
|
+
const elemIdxRef = { value: 0 }
|
|
1125
|
+
function addChildren(kids: N[]): void {
|
|
1126
|
+
for (const child of kids) classifyJsxChild(child, flatList, elemIdxRef, addChildren)
|
|
1127
|
+
}
|
|
1128
|
+
addChildren(children)
|
|
1129
|
+
return flatList
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
function analyzeChildren(flatChildren: FlatChild[]): { useMixed: boolean; useMultiExpr: boolean } {
|
|
1133
|
+
const hasElem = flatChildren.some((c) => c.kind === 'element')
|
|
1134
|
+
const hasText = flatChildren.some((c) => c.kind === 'text')
|
|
1135
|
+
const exprCount = flatChildren.filter((c) => c.kind === 'expression').length
|
|
1136
|
+
// `useMixed` triggers placeholder-based positional mounting (each
|
|
1137
|
+
// dynamic child gets a `<!>` comment slot in the template that
|
|
1138
|
+
// `replaceChild`-replaces at mount). It must fire whenever ≥2 of
|
|
1139
|
+
// {element, text, expression} are interleaved — otherwise dynamic
|
|
1140
|
+
// text nodes added via `appendChild` land after all static
|
|
1141
|
+
// template content, breaking source-order rendering for shapes
|
|
1142
|
+
// like `<p>foo {x()} bar</p>` (rendered "foo barX" instead of
|
|
1143
|
+
// "foo X bar"). Discovered by Phase B2's whitespace tests.
|
|
1144
|
+
const present =
|
|
1145
|
+
(hasElem ? 1 : 0) + (hasText ? 1 : 0) + (exprCount > 0 ? 1 : 0)
|
|
1146
|
+
return { useMixed: present > 1, useMultiExpr: exprCount > 1 }
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1149
|
+
function attrIsDynamic(attr: N): boolean {
|
|
1150
|
+
if (attr.type !== 'JSXAttribute') return false
|
|
1151
|
+
const name = attr.name?.type === 'JSXIdentifier' ? attr.name.name : ''
|
|
1152
|
+
if (name === 'ref') return true
|
|
1153
|
+
if (EVENT_RE.test(name)) return true
|
|
1154
|
+
if (!attr.value || attr.value.type !== 'JSXExpressionContainer') return false
|
|
1155
|
+
const expr = attr.value.expression
|
|
1156
|
+
return expr && expr.type !== 'JSXEmptyExpression' ? !isStatic(expr) : false
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
function elementHasDynamic(node: N): boolean {
|
|
1160
|
+
if (jsxAttrs(node).some(attrIsDynamic)) return true
|
|
1161
|
+
if (!isSelfClosing(node)) {
|
|
1162
|
+
return jsxChildren(node).some((c: N) =>
|
|
1163
|
+
c.type === 'JSXExpressionContainer' && c.expression && c.expression.type !== 'JSXEmptyExpression',
|
|
1164
|
+
)
|
|
1165
|
+
}
|
|
1166
|
+
return false
|
|
1167
|
+
}
|
|
1168
|
+
|
|
970
1169
|
function processOneChild(
|
|
971
|
-
child: FlatChild,
|
|
972
|
-
|
|
973
|
-
parentRef: string,
|
|
974
|
-
useMixed: boolean,
|
|
975
|
-
useMultiExpr: boolean,
|
|
976
|
-
childNodeIdx: number,
|
|
1170
|
+
child: FlatChild, varName: string, parentRef: string,
|
|
1171
|
+
useMixed: boolean, useMultiExpr: boolean, childNodeIdx: number,
|
|
977
1172
|
): string | null {
|
|
978
1173
|
if (child.kind === 'text') return escapeHtmlText(child.text)
|
|
979
1174
|
if (child.kind === 'element') {
|
|
@@ -982,12 +1177,8 @@ export function transformJSX(
|
|
|
982
1177
|
: `${parentRef}.children[${child.elemIdx}]`
|
|
983
1178
|
return processElement(child.node, childAccessor)
|
|
984
1179
|
}
|
|
985
|
-
// expression
|
|
986
1180
|
const needsPlaceholder = useMixed || useMultiExpr
|
|
987
1181
|
const { expr, isReactive } = unwrapAccessor(child.expression)
|
|
988
|
-
|
|
989
|
-
// Children slot: expression accesses .children (e.g. props.children, own.children)
|
|
990
|
-
// These can contain VNodes — use _mountSlot instead of text node binding.
|
|
991
1182
|
if (isChildrenExpression(child.expression, expr)) {
|
|
992
1183
|
needsMountSlotImport = true
|
|
993
1184
|
const placeholder = `${parentRef}.childNodes[${childNodeIdx}]`
|
|
@@ -995,64 +1186,38 @@ export function transformJSX(
|
|
|
995
1186
|
bindLines.push(`const ${d} = _mountSlot(${expr}, ${parentRef}, ${placeholder})`)
|
|
996
1187
|
return '<!>'
|
|
997
1188
|
}
|
|
998
|
-
|
|
999
1189
|
if (isReactive) {
|
|
1000
|
-
return emitReactiveTextChild(
|
|
1001
|
-
expr,
|
|
1002
|
-
child.expression,
|
|
1003
|
-
varName,
|
|
1004
|
-
parentRef,
|
|
1005
|
-
childNodeIdx,
|
|
1006
|
-
needsPlaceholder,
|
|
1007
|
-
)
|
|
1190
|
+
return emitReactiveTextChild(expr, child.expression, varName, parentRef, childNodeIdx, needsPlaceholder)
|
|
1008
1191
|
}
|
|
1009
1192
|
return emitStaticTextChild(expr, varName, parentRef, childNodeIdx, needsPlaceholder)
|
|
1010
1193
|
}
|
|
1011
1194
|
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
const flatChildren = flattenChildren(el.children)
|
|
1195
|
+
function processChildren(el: N, varName: string, accessor: string): string | null {
|
|
1196
|
+
const flatChildren = flattenChildren(jsxChildren(el))
|
|
1015
1197
|
const { useMixed, useMultiExpr } = analyzeChildren(flatChildren)
|
|
1016
1198
|
const parentRef = accessor === '__root' ? '__root' : varName
|
|
1017
|
-
|
|
1018
1199
|
let html = ''
|
|
1019
1200
|
let childNodeIdx = 0
|
|
1020
|
-
|
|
1021
1201
|
for (const child of flatChildren) {
|
|
1022
|
-
const childHtml = processOneChild(
|
|
1023
|
-
child,
|
|
1024
|
-
varName,
|
|
1025
|
-
parentRef,
|
|
1026
|
-
useMixed,
|
|
1027
|
-
useMultiExpr,
|
|
1028
|
-
childNodeIdx,
|
|
1029
|
-
)
|
|
1202
|
+
const childHtml = processOneChild(child, varName, parentRef, useMixed, useMultiExpr, childNodeIdx)
|
|
1030
1203
|
if (childHtml === null) return null
|
|
1031
1204
|
html += childHtml
|
|
1032
1205
|
childNodeIdx++
|
|
1033
1206
|
}
|
|
1034
|
-
|
|
1035
1207
|
return html
|
|
1036
1208
|
}
|
|
1037
1209
|
|
|
1038
|
-
|
|
1039
|
-
function processElement(
|
|
1040
|
-
el: ts.JsxElement | ts.JsxSelfClosingElement,
|
|
1041
|
-
accessor: string,
|
|
1042
|
-
): string | null {
|
|
1210
|
+
function processElement(el: N, accessor: string): string | null {
|
|
1043
1211
|
const tag = jsxTagName(el)
|
|
1044
1212
|
if (!tag) return null
|
|
1045
|
-
|
|
1046
1213
|
const varName = resolveElementVar(accessor, elementHasDynamic(el))
|
|
1047
1214
|
const htmlAttrs = processAttrs(el, varName)
|
|
1048
1215
|
let html = `<${tag}${htmlAttrs}>`
|
|
1049
|
-
|
|
1050
|
-
if (ts.isJsxElement(el)) {
|
|
1216
|
+
if (!isSelfClosing(el)) {
|
|
1051
1217
|
const childHtml = processChildren(el, varName, accessor)
|
|
1052
1218
|
if (childHtml === null) return null
|
|
1053
1219
|
html += childHtml
|
|
1054
1220
|
}
|
|
1055
|
-
|
|
1056
1221
|
if (!VOID_ELEMENTS.has(tag)) html += `</${tag}>`
|
|
1057
1222
|
return html
|
|
1058
1223
|
}
|
|
@@ -1065,15 +1230,8 @@ export function transformJSX(
|
|
|
1065
1230
|
if (needsApplyPropsImport) needsApplyPropsImportGlobal = true
|
|
1066
1231
|
if (needsMountSlotImport) needsMountSlotImportGlobal = true
|
|
1067
1232
|
|
|
1068
|
-
// Build bind function body
|
|
1069
1233
|
const escaped = html.replace(/\\/g, '\\\\').replace(/"/g, '\\"')
|
|
1070
1234
|
|
|
1071
|
-
// Emit combined _bind for reactive attribute/text expressions that
|
|
1072
|
-
// weren't handled by _bindText. This merges N separate _bind calls into
|
|
1073
|
-
// one — saving N-1 closures + deps arrays per template instance.
|
|
1074
|
-
// Emit a single combined _bind for all reactive attribute/text expressions
|
|
1075
|
-
// that weren't handled by _bindText. Merges N separate _bind calls into one —
|
|
1076
|
-
// saving N-1 closures + deps arrays per template instance.
|
|
1077
1235
|
if (reactiveBindExprs.length > 0) {
|
|
1078
1236
|
needsBindImportGlobal = true
|
|
1079
1237
|
const combinedName = nextDisp()
|
|
@@ -1085,7 +1243,31 @@ export function transformJSX(
|
|
|
1085
1243
|
return `_tpl("${escaped}", () => null)`
|
|
1086
1244
|
}
|
|
1087
1245
|
|
|
1088
|
-
|
|
1246
|
+
// Append `;` to every bind line so ASI can't merge consecutive
|
|
1247
|
+
// statements when the next line starts with `(`, `[`, etc.
|
|
1248
|
+
// Concrete bug shape (pre-fix): a child element with `hasDynamic=true`
|
|
1249
|
+
// emits `const __e0 = __root.children[N]` followed by a ref-callback
|
|
1250
|
+
// line `((el) => { x = el })(__e0)`. JS does NOT insert ASI here
|
|
1251
|
+
// because `__root.children[N]((el) => ...)` is a valid expression,
|
|
1252
|
+
// so the parser merges them into a single function call:
|
|
1253
|
+
// `const __e0 = __root.children[N]((el) => ...)(__e0)`
|
|
1254
|
+
// — calling `children[N]` as a function with the arrow as argument,
|
|
1255
|
+
// and self-referencing `__e0` before assignment. Adding the `;`
|
|
1256
|
+
// terminates each statement deterministically. Trailing `;` after
|
|
1257
|
+
// a `{...}` block is a harmless empty statement.
|
|
1258
|
+
// Append `;` to every bind line so ASI can't merge consecutive
|
|
1259
|
+
// statements when the next line starts with `(`, `[`, etc.
|
|
1260
|
+
// Concrete bug shape (pre-fix): a child element with `hasDynamic=true`
|
|
1261
|
+
// emits `const __e0 = __root.children[N]` followed by a ref-callback
|
|
1262
|
+
// line `((el) => { x = el })(__e0)`. JS does NOT insert ASI here
|
|
1263
|
+
// because `__root.children[N]((el) => ...)` is a valid expression,
|
|
1264
|
+
// so the parser merges them into a single function call:
|
|
1265
|
+
// `const __e0 = __root.children[N]((el) => ...)(__e0)`
|
|
1266
|
+
// — calling `children[N]` as a function with the arrow as argument,
|
|
1267
|
+
// and self-referencing `__e0` before assignment. Adding the `;`
|
|
1268
|
+
// terminates each statement deterministically. Trailing `;` after
|
|
1269
|
+
// a `{...}` block is a harmless empty statement.
|
|
1270
|
+
let body = bindLines.map((l) => ` ${l};`).join('\n')
|
|
1089
1271
|
if (disposerNames.length > 0) {
|
|
1090
1272
|
body += `\n return () => { ${disposerNames.map((d) => `${d}()`).join('; ')} }`
|
|
1091
1273
|
} else {
|
|
@@ -1095,127 +1277,123 @@ export function transformJSX(
|
|
|
1095
1277
|
return `_tpl("${escaped}", (__root) => {\n${body}\n})`
|
|
1096
1278
|
}
|
|
1097
1279
|
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
child: ts.JsxChild,
|
|
1107
|
-
out: FlatChild[],
|
|
1108
|
-
elemIdxRef: { value: number },
|
|
1109
|
-
recurse: (kids: ts.NodeArray<ts.JsxChild>) => void,
|
|
1110
|
-
): void {
|
|
1111
|
-
if (ts.isJsxText(child)) {
|
|
1112
|
-
const trimmed = child.text.replace(/\n\s*/g, '').trim()
|
|
1113
|
-
if (trimmed) out.push({ kind: 'text', text: trimmed })
|
|
1114
|
-
return
|
|
1115
|
-
}
|
|
1116
|
-
if (ts.isJsxElement(child) || ts.isJsxSelfClosingElement(child)) {
|
|
1117
|
-
out.push({ kind: 'element', node: child, elemIdx: elemIdxRef.value++ })
|
|
1118
|
-
return
|
|
1119
|
-
}
|
|
1120
|
-
if (ts.isJsxExpression(child)) {
|
|
1121
|
-
if (child.expression) out.push({ kind: 'expression', expression: child.expression })
|
|
1122
|
-
return
|
|
1280
|
+
function sliceExpr(expr: N): string {
|
|
1281
|
+
let result: string
|
|
1282
|
+
if (propDerivedVars.size > 0 && accessesProps(expr)) {
|
|
1283
|
+
const start = expr.start as number
|
|
1284
|
+
const end = expr.end as number
|
|
1285
|
+
result = resolveIdentifiersInText(code.slice(start, end), start, expr)
|
|
1286
|
+
} else {
|
|
1287
|
+
result = code.slice(expr.start as number, expr.end as number)
|
|
1123
1288
|
}
|
|
1124
|
-
if (ts.isJsxFragment(child)) recurse(child.children)
|
|
1125
|
-
}
|
|
1126
|
-
|
|
1127
|
-
/**
|
|
1128
|
-
* Flatten JSX children, inlining fragment children and stripping whitespace-only text.
|
|
1129
|
-
* Returns a flat array of child descriptors with element indices pre-computed.
|
|
1130
|
-
*/
|
|
1131
|
-
function flattenChildren(children: ts.NodeArray<ts.JsxChild>): FlatChild[] {
|
|
1132
|
-
const flatList: FlatChild[] = []
|
|
1133
|
-
const elemIdxRef = { value: 0 }
|
|
1134
1289
|
|
|
1135
|
-
|
|
1136
|
-
|
|
1290
|
+
// Auto-call signal variables: replace bare `x` with `x()` in the expression.
|
|
1291
|
+
// Only applies to identifiers that are NOT already being called (not `x()`).
|
|
1292
|
+
if (signalVars.size > 0 && signalVars.size > shadowedSignals.size && referencesSignalVar(expr)) {
|
|
1293
|
+
result = autoCallSignals(result, expr)
|
|
1137
1294
|
}
|
|
1138
1295
|
|
|
1139
|
-
|
|
1140
|
-
return flatList
|
|
1141
|
-
}
|
|
1142
|
-
|
|
1143
|
-
/** Analyze flat children to determine indexing strategy. */
|
|
1144
|
-
function analyzeChildren(flatChildren: FlatChild[]): {
|
|
1145
|
-
useMixed: boolean
|
|
1146
|
-
useMultiExpr: boolean
|
|
1147
|
-
} {
|
|
1148
|
-
const hasElem = flatChildren.some((c) => c.kind === 'element')
|
|
1149
|
-
const hasNonElem = flatChildren.some((c) => c.kind !== 'element')
|
|
1150
|
-
const exprCount = flatChildren.filter((c) => c.kind === 'expression').length
|
|
1151
|
-
return { useMixed: hasElem && hasNonElem, useMultiExpr: exprCount > 1 }
|
|
1152
|
-
}
|
|
1153
|
-
|
|
1154
|
-
/** Check if a single attribute is dynamic (has ref, event, or non-static expression). */
|
|
1155
|
-
function attrIsDynamic(attr: ts.JsxAttributeLike): boolean {
|
|
1156
|
-
if (!ts.isJsxAttribute(attr)) return false
|
|
1157
|
-
const name = ts.isIdentifier(attr.name) ? attr.name.text : ''
|
|
1158
|
-
if (name === 'ref') return true
|
|
1159
|
-
if (EVENT_RE.test(name)) return true
|
|
1160
|
-
if (!attr.initializer || !ts.isJsxExpression(attr.initializer)) return false
|
|
1161
|
-
const expr = attr.initializer.expression
|
|
1162
|
-
return expr ? !isStatic(expr) : false
|
|
1296
|
+
return result
|
|
1163
1297
|
}
|
|
1164
1298
|
|
|
1165
|
-
/** Check if an
|
|
1166
|
-
function
|
|
1167
|
-
if (
|
|
1168
|
-
|
|
1169
|
-
|
|
1299
|
+
/** Check if an expression references any tracked signal variable. */
|
|
1300
|
+
function referencesSignalVar(node: N): boolean {
|
|
1301
|
+
if (node.type === 'Identifier' && isActiveSignal(node.name)) {
|
|
1302
|
+
const parent = findParent(node)
|
|
1303
|
+
if (parent && parent.type === 'MemberExpression' && parent.property === node && !parent.computed) return false
|
|
1304
|
+
// signal.X(...) — operating on the signal object (calling a method).
|
|
1305
|
+
// Mirrors the same narrow skip in findSignalIdents below.
|
|
1306
|
+
if (
|
|
1307
|
+
parent &&
|
|
1308
|
+
parent.type === 'MemberExpression' &&
|
|
1309
|
+
parent.object === node
|
|
1310
|
+
) {
|
|
1311
|
+
const grand = findParent(parent)
|
|
1312
|
+
if (grand && grand.type === 'CallExpression' && grand.callee === parent) return false
|
|
1313
|
+
}
|
|
1314
|
+
if (parent && parent.type === 'CallExpression' && parent.callee === node) return false // already called
|
|
1315
|
+
return true
|
|
1170
1316
|
}
|
|
1171
|
-
|
|
1317
|
+
let found = false
|
|
1318
|
+
forEachChildFast(node, (child) => {
|
|
1319
|
+
if (found) return
|
|
1320
|
+
if (child.type === 'ArrowFunctionExpression' || child.type === 'FunctionExpression') return
|
|
1321
|
+
if (referencesSignalVar(child)) found = true
|
|
1322
|
+
})
|
|
1323
|
+
return found
|
|
1172
1324
|
}
|
|
1173
1325
|
|
|
1174
|
-
/**
|
|
1175
|
-
*
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
//
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1326
|
+
/** Auto-insert () after signal variable references in the expression source.
|
|
1327
|
+
* Uses the AST to find exact Identifier positions — never scans raw text. */
|
|
1328
|
+
function autoCallSignals(text: string, expr: N): string {
|
|
1329
|
+
const start = expr.start as number
|
|
1330
|
+
// Collect signal identifier positions that need auto-calling
|
|
1331
|
+
const idents: { start: number; end: number }[] = []
|
|
1332
|
+
|
|
1333
|
+
function findSignalIdents(node: N): void {
|
|
1334
|
+
if ((node.start as number) >= start + text.length || (node.end as number) <= start) return
|
|
1335
|
+
if (node.type === 'Identifier' && isActiveSignal(node.name)) {
|
|
1336
|
+
const parent = findParent(node)
|
|
1337
|
+
// Skip property name positions (obj.name)
|
|
1338
|
+
if (parent && parent.type === 'MemberExpression' && parent.property === node && !parent.computed) return
|
|
1339
|
+
// Skip when the identifier is the OBJECT of a member access AND
|
|
1340
|
+
// the result is being CALLED (signal.set(...), signal.peek(),
|
|
1341
|
+
// signal.update(...)). The user is invoking a method on the
|
|
1342
|
+
// signal OBJECT — auto-calling would produce `signal().set(...)`
|
|
1343
|
+
// which calls the signal, gets its value (string/number/etc),
|
|
1344
|
+
// then `.set` on the value is undefined → TypeError. Every event
|
|
1345
|
+
// handler that did `signal.set(x)` was silently broken.
|
|
1346
|
+
//
|
|
1347
|
+
// Note: bare `signal.value` (member access NOT followed by call)
|
|
1348
|
+
// STILL auto-calls — keeps the existing convention where
|
|
1349
|
+
// `signal({a:1})` followed by `signal.a` reads the signal's
|
|
1350
|
+
// value's property (see "signal as member expression object IS
|
|
1351
|
+
// auto-called" test).
|
|
1352
|
+
if (
|
|
1353
|
+
parent &&
|
|
1354
|
+
parent.type === 'MemberExpression' &&
|
|
1355
|
+
parent.object === node
|
|
1356
|
+
) {
|
|
1357
|
+
const grand = findParent(parent)
|
|
1358
|
+
if (grand && grand.type === 'CallExpression' && grand.callee === parent) return
|
|
1359
|
+
}
|
|
1360
|
+
// Skip if already being called: signal()
|
|
1361
|
+
if (parent && parent.type === 'CallExpression' && parent.callee === node) return
|
|
1362
|
+
// Skip declaration positions
|
|
1363
|
+
if (parent && parent.type === 'VariableDeclarator' && parent.id === node) return
|
|
1364
|
+
// Skip object property keys and shorthand properties ({ name } or { name: val })
|
|
1365
|
+
// Inserting () after a shorthand key produces name() which is a method shorthand — invalid
|
|
1366
|
+
if (parent && (parent.type === 'Property' || parent.type === 'ObjectProperty')) {
|
|
1367
|
+
if (parent.shorthand) return // { name } — can't auto-call without breaking syntax
|
|
1368
|
+
if (parent.key === node && !parent.computed) return // { name: val } — key position
|
|
1369
|
+
}
|
|
1370
|
+
idents.push({ start: node.start as number, end: node.end as number })
|
|
1371
|
+
}
|
|
1372
|
+
forEachChildFast(node, findSignalIdents)
|
|
1182
1373
|
}
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
: node.attributes.properties
|
|
1374
|
+
findSignalIdents(expr)
|
|
1375
|
+
|
|
1376
|
+
if (idents.length === 0) return text
|
|
1377
|
+
|
|
1378
|
+
// Sort by position and insert () after each identifier
|
|
1379
|
+
idents.sort((a, b) => a.start - b.start)
|
|
1380
|
+
const parts: string[] = []
|
|
1381
|
+
let lastPos = start
|
|
1382
|
+
for (const id of idents) {
|
|
1383
|
+
parts.push(code.slice(lastPos, id.end))
|
|
1384
|
+
parts.push('()') // auto-call
|
|
1385
|
+
lastPos = id.end
|
|
1386
|
+
}
|
|
1387
|
+
parts.push(code.slice(lastPos, start + text.length))
|
|
1388
|
+
return parts.join('')
|
|
1199
1389
|
}
|
|
1200
1390
|
}
|
|
1201
1391
|
|
|
1202
|
-
// ───
|
|
1392
|
+
// ─── Module-scope constants and helpers ─────────────────────────────────────
|
|
1203
1393
|
|
|
1204
1394
|
const VOID_ELEMENTS = new Set([
|
|
1205
|
-
'area',
|
|
1206
|
-
'
|
|
1207
|
-
'br',
|
|
1208
|
-
'col',
|
|
1209
|
-
'embed',
|
|
1210
|
-
'hr',
|
|
1211
|
-
'img',
|
|
1212
|
-
'input',
|
|
1213
|
-
'link',
|
|
1214
|
-
'meta',
|
|
1215
|
-
'param',
|
|
1216
|
-
'source',
|
|
1217
|
-
'track',
|
|
1218
|
-
'wbr',
|
|
1395
|
+
'area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input',
|
|
1396
|
+
'link', 'meta', 'param', 'source', 'track', 'wbr',
|
|
1219
1397
|
])
|
|
1220
1398
|
|
|
1221
1399
|
const JSX_TO_HTML_ATTR: Record<string, string> = {
|
|
@@ -1223,11 +1401,24 @@ const JSX_TO_HTML_ATTR: Record<string, string> = {
|
|
|
1223
1401
|
htmlFor: 'for',
|
|
1224
1402
|
}
|
|
1225
1403
|
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1404
|
+
// DOM properties whose live value diverges from the content attribute.
|
|
1405
|
+
// For these, emit property assignment (`el.value = v`) instead of
|
|
1406
|
+
// `setAttribute("value", v)`. Otherwise the property and attribute drift
|
|
1407
|
+
// apart in user-driven flows: typing in a controlled <input> updates the
|
|
1408
|
+
// .value property, but `input.set('')` clearing the signal only resets
|
|
1409
|
+
// the attribute — the stale typed text stays visible. Same for `checked`
|
|
1410
|
+
// on checkboxes (presence of the attribute means checked regardless of
|
|
1411
|
+
// value: `setAttribute("checked", "false")` still checks the box).
|
|
1412
|
+
const DOM_PROPS = new Set([
|
|
1413
|
+
'value',
|
|
1414
|
+
'checked',
|
|
1415
|
+
'selected',
|
|
1416
|
+
'disabled',
|
|
1417
|
+
'multiple',
|
|
1418
|
+
'readOnly',
|
|
1419
|
+
'indeterminate',
|
|
1420
|
+
])
|
|
1421
|
+
|
|
1231
1422
|
const STATEFUL_CALLS = new Set([
|
|
1232
1423
|
'signal', 'computed', 'effect', 'batch',
|
|
1233
1424
|
'createContext', 'createReactiveContext',
|
|
@@ -1236,24 +1427,23 @@ const STATEFUL_CALLS = new Set([
|
|
|
1236
1427
|
'defineStore', 'useStore',
|
|
1237
1428
|
])
|
|
1238
1429
|
|
|
1239
|
-
function isStatefulCall(node:
|
|
1240
|
-
if (
|
|
1241
|
-
const callee = node.
|
|
1242
|
-
if (
|
|
1430
|
+
function isStatefulCall(node: N): boolean {
|
|
1431
|
+
if (node.type !== 'CallExpression') return false
|
|
1432
|
+
const callee = node.callee
|
|
1433
|
+
if (callee?.type === 'Identifier') return STATEFUL_CALLS.has(callee.name)
|
|
1243
1434
|
return false
|
|
1244
1435
|
}
|
|
1245
1436
|
|
|
1246
|
-
/**
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
if (
|
|
1256
|
-
// String fallback for inlined expressions
|
|
1437
|
+
/** Check if a call expression creates a callable reactive value (`signal(...)` or `computed(...)`). */
|
|
1438
|
+
function isSignalCall(node: N): boolean {
|
|
1439
|
+
if (node.type !== 'CallExpression') return false
|
|
1440
|
+
const callee = node.callee
|
|
1441
|
+
return callee?.type === 'Identifier' && (callee.name === 'signal' || callee.name === 'computed')
|
|
1442
|
+
}
|
|
1443
|
+
|
|
1444
|
+
function isChildrenExpression(node: N, expr: string): boolean {
|
|
1445
|
+
if (node.type === 'MemberExpression' && !node.computed && node.property?.type === 'Identifier' && node.property.name === 'children') return true
|
|
1446
|
+
if (node.type === 'Identifier' && node.name === 'children') return true
|
|
1257
1447
|
if (expr.endsWith('.children') || expr === 'children') return true
|
|
1258
1448
|
return false
|
|
1259
1449
|
}
|
|
@@ -1262,11 +1452,14 @@ function isLowerCase(s: string): boolean {
|
|
|
1262
1452
|
return s.length > 0 && s[0] === s[0]?.toLowerCase()
|
|
1263
1453
|
}
|
|
1264
1454
|
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1455
|
+
function containsJSXInExpr(node: N): boolean {
|
|
1456
|
+
if (node.type === 'JSXElement' || node.type === 'JSXFragment') return true
|
|
1457
|
+
let found = false
|
|
1458
|
+
forEachChild(node, (child) => {
|
|
1459
|
+
if (found) return
|
|
1460
|
+
if (containsJSXInExpr(child)) found = true
|
|
1461
|
+
})
|
|
1462
|
+
return found
|
|
1270
1463
|
}
|
|
1271
1464
|
|
|
1272
1465
|
function escapeHtmlAttr(s: string): string {
|
|
@@ -1274,71 +1467,84 @@ function escapeHtmlAttr(s: string): string {
|
|
|
1274
1467
|
}
|
|
1275
1468
|
|
|
1276
1469
|
function escapeHtmlText(s: string): string {
|
|
1277
|
-
// TypeScript's JsxText preserves HTML entities as-is (e.g. "<" stays "<",
|
|
1278
|
-
// not decoded to "<"). Since the template is parsed via innerHTML, entities are
|
|
1279
|
-
// already valid HTML — pass them through. Only escape raw `<` and raw `&` that
|
|
1280
|
-
// are NOT part of existing entities.
|
|
1281
1470
|
return s.replace(/&(?!(?:#\d+|#x[\da-fA-F]+|[a-zA-Z]\w*);)/g, '&').replace(/</g, '<')
|
|
1282
1471
|
}
|
|
1283
1472
|
|
|
1284
|
-
//
|
|
1285
|
-
|
|
1286
|
-
|
|
1473
|
+
// React/Babel JSX whitespace algorithm (cleanJSXElementLiteralChild).
|
|
1474
|
+
// Same-line text is preserved verbatim so adjacent expressions keep their
|
|
1475
|
+
// spacing (`<p>doubled: {x}</p>` keeps the trailing space). Multi-line text
|
|
1476
|
+
// strips leading whitespace from non-first lines and trailing whitespace
|
|
1477
|
+
// from non-last lines, drops fully-empty lines, and joins the survivors
|
|
1478
|
+
// with a single space — collapsing JSX indentation without losing
|
|
1479
|
+
// intentional inline spacing.
|
|
1480
|
+
function cleanJsxText(raw: string): string {
|
|
1481
|
+
if (!raw.includes('\n') && !raw.includes('\r')) return raw
|
|
1482
|
+
const lines = raw.split(/\r\n|\n|\r/)
|
|
1483
|
+
let lastNonEmpty = -1
|
|
1484
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1485
|
+
if (/[^ \t]/.test(lines[i] ?? '')) lastNonEmpty = i
|
|
1486
|
+
}
|
|
1487
|
+
let str = ''
|
|
1488
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1489
|
+
let line = (lines[i] ?? '').replace(/\t/g, ' ')
|
|
1490
|
+
if (i !== 0) line = line.replace(/^ +/, '')
|
|
1491
|
+
if (i !== lines.length - 1) line = line.replace(/ +$/, '')
|
|
1492
|
+
if (line) {
|
|
1493
|
+
if (i !== lastNonEmpty) line += ' '
|
|
1494
|
+
str += line
|
|
1495
|
+
}
|
|
1496
|
+
}
|
|
1497
|
+
return str
|
|
1498
|
+
}
|
|
1287
1499
|
|
|
1288
|
-
function isStaticJSXNode(node:
|
|
1289
|
-
if (
|
|
1290
|
-
return isStaticAttrs(node.attributes)
|
|
1500
|
+
function isStaticJSXNode(node: N): boolean {
|
|
1501
|
+
if (node.type === 'JSXElement' && node.openingElement?.selfClosing) {
|
|
1502
|
+
return isStaticAttrs(node.openingElement.attributes ?? [])
|
|
1291
1503
|
}
|
|
1292
|
-
if (
|
|
1293
|
-
return node.children.every(isStaticChild)
|
|
1504
|
+
if (node.type === 'JSXFragment') {
|
|
1505
|
+
return (node.children ?? []).every(isStaticChild)
|
|
1294
1506
|
}
|
|
1295
|
-
|
|
1296
|
-
|
|
1507
|
+
if (node.type === 'JSXElement') {
|
|
1508
|
+
return isStaticAttrs(node.openingElement?.attributes ?? []) && (node.children ?? []).every(isStaticChild)
|
|
1509
|
+
}
|
|
1510
|
+
return false
|
|
1297
1511
|
}
|
|
1298
1512
|
|
|
1299
|
-
function isStaticAttrs(attrs:
|
|
1300
|
-
return attrs.
|
|
1301
|
-
|
|
1302
|
-
if (!
|
|
1303
|
-
|
|
1304
|
-
if (
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
return
|
|
1513
|
+
function isStaticAttrs(attrs: N[]): boolean {
|
|
1514
|
+
return attrs.every((prop: N) => {
|
|
1515
|
+
if (prop.type !== 'JSXAttribute') return false
|
|
1516
|
+
if (!prop.value) return true
|
|
1517
|
+
if (prop.value.type === 'StringLiteral' || (prop.value.type === 'Literal' && typeof prop.value.value === 'string')) return true
|
|
1518
|
+
if (prop.value.type === 'JSXExpressionContainer') {
|
|
1519
|
+
const expr = prop.value.expression
|
|
1520
|
+
if (!expr || expr.type === 'JSXEmptyExpression') return true
|
|
1521
|
+
return isStatic(expr)
|
|
1522
|
+
}
|
|
1523
|
+
return false
|
|
1310
1524
|
})
|
|
1311
1525
|
}
|
|
1312
1526
|
|
|
1313
|
-
function isStaticChild(child:
|
|
1314
|
-
|
|
1315
|
-
if (
|
|
1316
|
-
|
|
1317
|
-
if (
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
return
|
|
1527
|
+
function isStaticChild(child: N): boolean {
|
|
1528
|
+
if (child.type === 'JSXText') return true
|
|
1529
|
+
if (child.type === 'JSXElement') return isStaticJSXNode(child)
|
|
1530
|
+
if (child.type === 'JSXFragment') return isStaticJSXNode(child)
|
|
1531
|
+
if (child.type === 'JSXExpressionContainer') {
|
|
1532
|
+
const expr = child.expression
|
|
1533
|
+
if (!expr || expr.type === 'JSXEmptyExpression') return true
|
|
1534
|
+
return isStatic(expr)
|
|
1535
|
+
}
|
|
1536
|
+
return false
|
|
1323
1537
|
}
|
|
1324
1538
|
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
return
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
node.kind === ts.SyntaxKind.TrueKeyword ||
|
|
1333
|
-
node.kind === ts.SyntaxKind.FalseKeyword ||
|
|
1334
|
-
node.kind === ts.SyntaxKind.NullKeyword ||
|
|
1335
|
-
node.kind === ts.SyntaxKind.UndefinedKeyword
|
|
1336
|
-
)
|
|
1337
|
-
// Note: object/array literals are NOT static — they need runtime application
|
|
1338
|
-
// (e.g., style={{ color: "red" }} requires Object.assign at runtime).
|
|
1539
|
+
function isStatic(node: N): boolean {
|
|
1540
|
+
if (node.type === 'Literal') return true
|
|
1541
|
+
if (node.type === 'StringLiteral' || node.type === 'NumericLiteral' || node.type === 'BooleanLiteral' || node.type === 'NullLiteral') return true
|
|
1542
|
+
if (node.type === 'TemplateLiteral' && (node.expressions?.length ?? 0) === 0) return true
|
|
1543
|
+
// Note: `undefined` is an Identifier in ESTree, not a keyword literal.
|
|
1544
|
+
// It is NOT treated as static — it goes through the dynamic attr path.
|
|
1545
|
+
return false
|
|
1339
1546
|
}
|
|
1340
1547
|
|
|
1341
|
-
/** Known pure global functions that don't read signals. */
|
|
1342
1548
|
const PURE_CALLS = new Set([
|
|
1343
1549
|
'Math.max', 'Math.min', 'Math.abs', 'Math.floor', 'Math.ceil', 'Math.round',
|
|
1344
1550
|
'Math.pow', 'Math.sqrt', 'Math.random', 'Math.trunc', 'Math.sign',
|
|
@@ -1353,30 +1559,14 @@ const PURE_CALLS = new Set([
|
|
|
1353
1559
|
'Date.now',
|
|
1354
1560
|
])
|
|
1355
1561
|
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
const callee = node.expression
|
|
1562
|
+
function isPureStaticCall(node: N): boolean {
|
|
1563
|
+
const callee = node.callee
|
|
1359
1564
|
let name = ''
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
name = `${callee.expression.text}.${callee.name.text}`
|
|
1565
|
+
if (callee?.type === 'Identifier') {
|
|
1566
|
+
name = callee.name
|
|
1567
|
+
} else if (callee?.type === 'MemberExpression' && !callee.computed && callee.object?.type === 'Identifier' && callee.property?.type === 'Identifier') {
|
|
1568
|
+
name = `${callee.object.name}.${callee.property.name}`
|
|
1365
1569
|
}
|
|
1366
|
-
|
|
1367
1570
|
if (!PURE_CALLS.has(name)) return false
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
}
|
|
1371
|
-
|
|
1372
|
-
function containsCall(node: ts.Node): boolean {
|
|
1373
|
-
if (ts.isCallExpression(node)) {
|
|
1374
|
-
// Skip pure calls with static args
|
|
1375
|
-
if (isPureStaticCall(node as ts.CallExpression)) return false
|
|
1376
|
-
return true
|
|
1377
|
-
}
|
|
1378
|
-
if (ts.isTaggedTemplateExpression(node)) return true
|
|
1379
|
-
// Don't recurse into nested functions — they're self-contained
|
|
1380
|
-
if (ts.isArrowFunction(node) || ts.isFunctionExpression(node)) return false
|
|
1381
|
-
return ts.forEachChild(node, containsCall) ?? false
|
|
1382
|
-
}
|
|
1571
|
+
return (node.arguments ?? []).every((arg: N) => arg.type !== 'SpreadElement' && isStatic(arg))
|
|
1572
|
+
}
|