@barefootjs/jsx 0.19.1 → 0.20.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/analyzer.d.ts.map +1 -1
- package/dist/compiler.d.ts.map +1 -1
- package/dist/index.js +293 -4
- package/dist/ir-to-client-js/generate-init.d.ts.map +1 -1
- package/dist/ir-to-client-js/reactivity.d.ts.map +1 -1
- package/dist/rich-type-evidence.d.ts +56 -0
- package/dist/rich-type-evidence.d.ts.map +1 -0
- package/dist/rich-type-refusal.d.ts +35 -0
- package/dist/rich-type-refusal.d.ts.map +1 -0
- package/dist/types.d.ts +9 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/client-js-generation.test.ts +59 -0
- package/src/__tests__/nested-loop-reactive-attrs.test.ts +56 -0
- package/src/__tests__/rich-type-method-refusal.test.ts +303 -0
- package/src/analyzer.ts +2 -0
- package/src/compiler.ts +3 -0
- package/src/ir-to-client-js/generate-init.ts +11 -4
- package/src/ir-to-client-js/reactivity.ts +13 -2
- package/src/rich-type-evidence.ts +159 -0
- package/src/rich-type-refusal.ts +311 -0
- package/src/types.ts +9 -0
|
@@ -1162,6 +1162,65 @@ describe('Client JS generation', () => {
|
|
|
1162
1162
|
expect(basicErrorCount).toBe(1) // only inside computeError body
|
|
1163
1163
|
expect(isDuplicateCount).toBe(1) // only inside computeError body
|
|
1164
1164
|
})
|
|
1165
|
+
|
|
1166
|
+
test('import used only inside a module-level helper is traced into the client bundle (#2283)', () => {
|
|
1167
|
+
const source = `
|
|
1168
|
+
'use client'
|
|
1169
|
+
import { createMemo } from '@barefootjs/client'
|
|
1170
|
+
import { computeSheetGeometry } from '../src/lib/sheetGeometry'
|
|
1171
|
+
|
|
1172
|
+
function buildSheetVMs(count: number) {
|
|
1173
|
+
return computeSheetGeometry(count)
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
export function PrintSheets(props: { count: number }) {
|
|
1177
|
+
const sheets = createMemo(() => buildSheetVMs(props.count))
|
|
1178
|
+
return <div>{sheets()}</div>
|
|
1179
|
+
}
|
|
1180
|
+
`
|
|
1181
|
+
|
|
1182
|
+
const result = compileJSX(source, 'PrintSheets.tsx', { adapter })
|
|
1183
|
+
expect(result.errors).toHaveLength(0)
|
|
1184
|
+
|
|
1185
|
+
const clientJs = result.files.find(f => f.type === 'clientJs')
|
|
1186
|
+
expect(clientJs).toBeDefined()
|
|
1187
|
+
const content = clientJs!.content
|
|
1188
|
+
|
|
1189
|
+
// The helper's body (which references the import) must be emitted...
|
|
1190
|
+
expect(content).toContain('computeSheetGeometry(count)')
|
|
1191
|
+
// ...and the import that body depends on must be traced and emitted too,
|
|
1192
|
+
// otherwise the browser throws `ReferenceError: computeSheetGeometry is not defined`.
|
|
1193
|
+
expect(content).toContain("import { computeSheetGeometry } from '../src/lib/sheetGeometry'")
|
|
1194
|
+
})
|
|
1195
|
+
|
|
1196
|
+
test('module-level helper body containing a literal "$&" is spliced in verbatim', () => {
|
|
1197
|
+
// A plain-string second argument to String.replace() specially
|
|
1198
|
+
// interprets `$&`/`$1`/`$$` — the placeholder-substitution step must
|
|
1199
|
+
// use the replacer-function form so a helper body containing one of
|
|
1200
|
+
// these sequences isn't corrupted (piconic-ai/barefootjs#2286 review).
|
|
1201
|
+
const source = `
|
|
1202
|
+
'use client'
|
|
1203
|
+
import { createMemo } from '@barefootjs/client'
|
|
1204
|
+
|
|
1205
|
+
function formatMoney(amount: number) {
|
|
1206
|
+
return '$&' + amount
|
|
1207
|
+
}
|
|
1208
|
+
|
|
1209
|
+
export function Price(props: { amount: number }) {
|
|
1210
|
+
const label = createMemo(() => formatMoney(props.amount))
|
|
1211
|
+
return <div>{label()}</div>
|
|
1212
|
+
}
|
|
1213
|
+
`
|
|
1214
|
+
|
|
1215
|
+
const result = compileJSX(source, 'Price.tsx', { adapter })
|
|
1216
|
+
expect(result.errors).toHaveLength(0)
|
|
1217
|
+
|
|
1218
|
+
const clientJs = result.files.find(f => f.type === 'clientJs')
|
|
1219
|
+
expect(clientJs).toBeDefined()
|
|
1220
|
+
const content = clientJs!.content
|
|
1221
|
+
|
|
1222
|
+
expect(content).toContain("'$&' + amount")
|
|
1223
|
+
})
|
|
1165
1224
|
})
|
|
1166
1225
|
|
|
1167
1226
|
describe('child component value/boolean prop binding', () => {
|
|
@@ -342,4 +342,60 @@ describe('reactive attributes inside a nested .map() body (#135)', () => {
|
|
|
342
342
|
expect(content).toMatch(/createEffect\(\(\) => \{[\s\S]*?\.textContent = String\(panel\(\)\.text\)/)
|
|
343
343
|
expect(content).toContain("setAttribute('class'")
|
|
344
344
|
})
|
|
345
|
+
|
|
346
|
+
test('reactive text child of a triple-nested inner loop read through an opaque helper gets an update effect (#2282)', () => {
|
|
347
|
+
// #2264 fixed the case where `classifyReactivity` proves the text
|
|
348
|
+
// reactive via the loop-param path (bare `panel.text`). It left a
|
|
349
|
+
// sibling gap: `collectLoopChildReactiveTexts` had no Solid-style
|
|
350
|
+
// AST-flag fallback, so a text read through an opaque helper the
|
|
351
|
+
// classifier can't see through (`labelAt(pi)` where `const labelAt =
|
|
352
|
+
// (i) => labels()[i]`) still silently dropped its update effect — while
|
|
353
|
+
// `collectLoopChildReactiveAttrs` already had that fallback (#1673,
|
|
354
|
+
// see `reactive-attrs-in-map.test.ts`), so the sibling `className`
|
|
355
|
+
// effect on the SAME element kept working. Reported as #2282 ("child
|
|
356
|
+
// inlined into a parent island drops the innermost reactive text
|
|
357
|
+
// effect"); the issue's own literal `{panel.text}` repro snippet
|
|
358
|
+
// doesn't reproduce it (that shape is exactly what #2264 already
|
|
359
|
+
// fixed) — this test pins the actual asymmetry root-caused during
|
|
360
|
+
// investigation, using the opaque-helper shape that does reproduce.
|
|
361
|
+
const source = `
|
|
362
|
+
'use client'
|
|
363
|
+
import { createSignal } from '@barefootjs/client'
|
|
364
|
+
|
|
365
|
+
type Panel = { id: number; cls: string }
|
|
366
|
+
type Band = { id: string; panels: Panel[] }
|
|
367
|
+
type Page = { id: string; bands: Band[] }
|
|
368
|
+
|
|
369
|
+
export function Doc2() {
|
|
370
|
+
const [pages] = createSignal<Page[]>([])
|
|
371
|
+
const [labels] = createSignal<string[]>([])
|
|
372
|
+
const labelAt = (i: number) => labels()[i]
|
|
373
|
+
return (
|
|
374
|
+
<div>
|
|
375
|
+
{pages().map(page => (
|
|
376
|
+
<div key={page.id}>
|
|
377
|
+
{page.bands.map(band => (
|
|
378
|
+
<div key={band.id}>
|
|
379
|
+
{band.panels.map((panel, pi) => (
|
|
380
|
+
<div key={panel.id} className={panel.cls}>{labelAt(pi)}</div>
|
|
381
|
+
))}
|
|
382
|
+
</div>
|
|
383
|
+
))}
|
|
384
|
+
</div>
|
|
385
|
+
))}
|
|
386
|
+
</div>
|
|
387
|
+
)
|
|
388
|
+
}
|
|
389
|
+
`
|
|
390
|
+
const result = compileJSX(source, 'Doc2.tsx', { adapter })
|
|
391
|
+
expect(result.errors).toHaveLength(0)
|
|
392
|
+
const content = result.files.find((f) => f.type === 'clientJs')!.content
|
|
393
|
+
|
|
394
|
+
// The helper call must appear inside a createEffect alongside the
|
|
395
|
+
// textContent write — `labelAt(` also appears in the static template
|
|
396
|
+
// clone, so asserting it independently would pass even with the
|
|
397
|
+
// effect missing (the exact regression here).
|
|
398
|
+
expect(content).toMatch(/createEffect\(\(\) => \{[\s\S]*?\.textContent = String\(labelAt\(pi\)\)/)
|
|
399
|
+
expect(content).toContain("setAttribute('class'")
|
|
400
|
+
})
|
|
345
401
|
})
|
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rich-type method-call refusal (BF021, #2273).
|
|
3
|
+
*
|
|
4
|
+
* A method call on a prop typed as a built-in host rich type (`Date`,
|
|
5
|
+
* `Map`, …) has no catalogued lowering in any adapter — left unchecked it
|
|
6
|
+
* transliterates into the target template's own syntax and dies at request
|
|
7
|
+
* time. `checkRichTypeMethodCalls` (rich-type-refusal.ts) is wired into
|
|
8
|
+
* `compileJSX` (not the bare analyzer/jsxToIR pipeline other BF021 tests in
|
|
9
|
+
* this directory use), so these tests go through `compileJSX` directly.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { describe, test, expect } from 'bun:test'
|
|
13
|
+
import { compileJSX } from '../compiler'
|
|
14
|
+
import { ErrorCodes } from '../errors'
|
|
15
|
+
import { TestAdapter } from '../adapters/test-adapter'
|
|
16
|
+
import { registerLoweringPlugin, __resetLoweringPluginsForTest, getLoweringPlugins, type LoweringPlugin } from '../lowering-registry'
|
|
17
|
+
|
|
18
|
+
const adapter = new TestAdapter()
|
|
19
|
+
|
|
20
|
+
function bf021(source: string, filePath = 'Test.tsx') {
|
|
21
|
+
const result = compileJSX(source, filePath, { adapter })
|
|
22
|
+
return result.errors.filter((e) => e.code === ErrorCodes.UNSUPPORTED_JSX_PATTERN)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
describe('rich-type method-call refusal — fires (BF021)', () => {
|
|
26
|
+
test('inline-destructured Date in text position', () => {
|
|
27
|
+
const errors = bf021(`
|
|
28
|
+
export function Foo({ createdAt }: { createdAt: Date }) {
|
|
29
|
+
return <div>{createdAt.toISOString()}</div>
|
|
30
|
+
}
|
|
31
|
+
`)
|
|
32
|
+
expect(errors).toHaveLength(1)
|
|
33
|
+
expect(errors[0].message).toContain("'.toISOString()'")
|
|
34
|
+
expect(errors[0].message).toContain("'createdAt'")
|
|
35
|
+
expect(errors[0].message).toContain("'Date'")
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
test('props-object member chain in attribute position', () => {
|
|
39
|
+
const errors = bf021(`
|
|
40
|
+
export function Foo(props: { d: Date }) {
|
|
41
|
+
return <div data-year={props.d.getUTCFullYear()} />
|
|
42
|
+
}
|
|
43
|
+
`)
|
|
44
|
+
expect(errors).toHaveLength(1)
|
|
45
|
+
expect(errors[0].message).toContain("'.getUTCFullYear()'")
|
|
46
|
+
expect(errors[0].message).toContain("'props.d'")
|
|
47
|
+
expect(errors[0].message).toContain("'Date'")
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
test('named interface Props Date field (typeDefinitions deref)', () => {
|
|
51
|
+
const errors = bf021(`
|
|
52
|
+
interface Props { createdAt: Date }
|
|
53
|
+
export function Foo({ createdAt }: Props) {
|
|
54
|
+
return <div>{createdAt.getFullYear()}</div>
|
|
55
|
+
}
|
|
56
|
+
`)
|
|
57
|
+
expect(errors).toHaveLength(1)
|
|
58
|
+
expect(errors[0].message).toContain("'.getFullYear()'")
|
|
59
|
+
expect(errors[0].message).toContain("'createdAt'")
|
|
60
|
+
expect(errors[0].message).toContain("'Date'")
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
test('optional-chained call', () => {
|
|
64
|
+
const errors = bf021(`
|
|
65
|
+
export function Foo({ d }: { d: Date | undefined }) {
|
|
66
|
+
return <div>{d?.toISOString()}</div>
|
|
67
|
+
}
|
|
68
|
+
`)
|
|
69
|
+
expect(errors).toHaveLength(1)
|
|
70
|
+
expect(errors[0].message).toContain("'.toISOString()'")
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
test('Date | null union resolves to Date', () => {
|
|
74
|
+
const errors = bf021(`
|
|
75
|
+
export function Foo({ d }: { d: Date | null }) {
|
|
76
|
+
return <div>{d.toISOString()}</div>
|
|
77
|
+
}
|
|
78
|
+
`)
|
|
79
|
+
expect(errors).toHaveLength(1)
|
|
80
|
+
expect(errors[0].message).toContain("'Date'")
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
test('loop-item member (items.map(i => i.at.getTime()))', () => {
|
|
84
|
+
const errors = bf021(`
|
|
85
|
+
export function Foo({ items }: { items: { at: Date }[] }) {
|
|
86
|
+
return <ul>{items.map(i => <li>{i.at.getTime()}</li>)}</ul>
|
|
87
|
+
}
|
|
88
|
+
`)
|
|
89
|
+
expect(errors).toHaveLength(1)
|
|
90
|
+
expect(errors[0].message).toContain("'.getTime()'")
|
|
91
|
+
// A loop item is prop-DERIVED but not itself a prop — the message must
|
|
92
|
+
// not call it one (only bare / props-object receivers earn "prop").
|
|
93
|
+
expect(errors[0].message).toContain("on 'i.at'")
|
|
94
|
+
expect(errors[0].message).not.toContain("prop 'i.at'")
|
|
95
|
+
expect(errors[0].message).toContain("'Date'")
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
test('renamed destructured prop ({ createdAt: c })', () => {
|
|
99
|
+
const errors = bf021(`
|
|
100
|
+
export function Foo({ createdAt: c }: { createdAt: Date }) {
|
|
101
|
+
return <div>{c.toISOString()}</div>
|
|
102
|
+
}
|
|
103
|
+
`)
|
|
104
|
+
expect(errors).toHaveLength(1)
|
|
105
|
+
expect(errors[0].message).toContain("'.toISOString()'")
|
|
106
|
+
expect(errors[0].message).toContain("prop 'c'")
|
|
107
|
+
expect(errors[0].message).toContain("'Date'")
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
test('conditional-branch call without @client', () => {
|
|
111
|
+
const errors = bf021(`
|
|
112
|
+
export function Foo({ d }: { d: Date | null }) {
|
|
113
|
+
return <div>{d && <span>{d.toISOString()}</span>}</div>
|
|
114
|
+
}
|
|
115
|
+
`)
|
|
116
|
+
expect(errors).toHaveLength(1)
|
|
117
|
+
expect(errors[0].message).toContain("'.toISOString()'")
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
test('two distinct receivers at the same expression report separately', () => {
|
|
121
|
+
const errors = bf021(`
|
|
122
|
+
export function Foo({ a, b }: { a: Date; b: Date }) {
|
|
123
|
+
return <div>{a.getTime() + b.getTime()}</div>
|
|
124
|
+
}
|
|
125
|
+
`)
|
|
126
|
+
expect(errors).toHaveLength(2)
|
|
127
|
+
expect(errors[0].message).toContain("prop 'a'")
|
|
128
|
+
expect(errors[1].message).toContain("prop 'b'")
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
test('Date in component-prop position', () => {
|
|
132
|
+
const errors = bf021(`
|
|
133
|
+
function Bar(props: { value: string }) {
|
|
134
|
+
return <div>{props.value}</div>
|
|
135
|
+
}
|
|
136
|
+
export function Foo({ createdAt }: { createdAt: Date }) {
|
|
137
|
+
return <Bar value={createdAt.toISOString()} />
|
|
138
|
+
}
|
|
139
|
+
`)
|
|
140
|
+
expect(errors).toHaveLength(1)
|
|
141
|
+
expect(errors[0].message).toContain("'.toISOString()'")
|
|
142
|
+
expect(errors[0].message).toContain("'createdAt'")
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
test('Map.get() (broad host-type list)', () => {
|
|
146
|
+
const errors = bf021(`
|
|
147
|
+
export function Foo({ m }: { m: Map<string, string> }) {
|
|
148
|
+
return <div>{m.get('x')}</div>
|
|
149
|
+
}
|
|
150
|
+
`)
|
|
151
|
+
expect(errors).toHaveLength(1)
|
|
152
|
+
expect(errors[0].message).toContain("'.get()'")
|
|
153
|
+
expect(errors[0].message).toContain("'Map'")
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
test('diagnostic carries the @client suggestion', () => {
|
|
157
|
+
const errors = bf021(`
|
|
158
|
+
export function Foo({ createdAt }: { createdAt: Date }) {
|
|
159
|
+
return <div>{createdAt.toISOString()}</div>
|
|
160
|
+
}
|
|
161
|
+
`)
|
|
162
|
+
expect(errors[0].severity).toBe('error')
|
|
163
|
+
expect(errors[0].suggestion?.message).toContain('@client')
|
|
164
|
+
})
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
describe('rich-type method-call refusal — silent (no BF021)', () => {
|
|
168
|
+
test('/* @client */-prefixed Date call', () => {
|
|
169
|
+
const errors = bf021(`
|
|
170
|
+
export function Foo({ createdAt }: { createdAt: Date }) {
|
|
171
|
+
return <div>{/* @client */ createdAt.toISOString()}</div>
|
|
172
|
+
}
|
|
173
|
+
`)
|
|
174
|
+
expect(errors).toHaveLength(0)
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
test('/* @client */-wrapped conditional branch', () => {
|
|
178
|
+
const errors = bf021(`
|
|
179
|
+
export function Foo({ d }: { d: Date | null }) {
|
|
180
|
+
return <div>{/* @client */ d && <span>{d.toISOString()}</span>}</div>
|
|
181
|
+
}
|
|
182
|
+
`)
|
|
183
|
+
expect(errors).toHaveLength(0)
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
test('module const sharing a propsType field name (object-props mode)', () => {
|
|
187
|
+
const errors = bf021(`
|
|
188
|
+
const version = 'v1'
|
|
189
|
+
export function Foo(props: { version: Map<string, string> }) {
|
|
190
|
+
return <div>{version.toUpperCase()}</div>
|
|
191
|
+
}
|
|
192
|
+
`)
|
|
193
|
+
expect(errors).toHaveLength(0)
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
test('string method on string prop', () => {
|
|
197
|
+
const errors = bf021(`
|
|
198
|
+
export function Foo({ s }: { s: string }) {
|
|
199
|
+
return <div>{s.toUpperCase()}</div>
|
|
200
|
+
}
|
|
201
|
+
`)
|
|
202
|
+
expect(errors).toHaveLength(0)
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
test('array method on array prop', () => {
|
|
206
|
+
const errors = bf021(`
|
|
207
|
+
export function Foo({ items }: { items: string[] }) {
|
|
208
|
+
return <div>{items.join(',')}</div>
|
|
209
|
+
}
|
|
210
|
+
`)
|
|
211
|
+
expect(errors).toHaveLength(0)
|
|
212
|
+
})
|
|
213
|
+
|
|
214
|
+
test('untyped receiver (no type annotation)', () => {
|
|
215
|
+
const errors = bf021(`
|
|
216
|
+
export function Foo({ d }) {
|
|
217
|
+
return <div>{d.toISOString()}</div>
|
|
218
|
+
}
|
|
219
|
+
`)
|
|
220
|
+
expect(errors).toHaveLength(0)
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
test('generic type-parameter receiver', () => {
|
|
224
|
+
const errors = bf021(`
|
|
225
|
+
export function Foo<T>({ d }: { d: T }) {
|
|
226
|
+
return <div>{d.toISOString()}</div>
|
|
227
|
+
}
|
|
228
|
+
`)
|
|
229
|
+
expect(errors).toHaveLength(0)
|
|
230
|
+
})
|
|
231
|
+
|
|
232
|
+
test('imported named type receiver', () => {
|
|
233
|
+
const errors = bf021(`
|
|
234
|
+
import type { Widget } from './widget'
|
|
235
|
+
export function Foo({ w }: { w: Widget }) {
|
|
236
|
+
return <div>{w.render()}</div>
|
|
237
|
+
}
|
|
238
|
+
`)
|
|
239
|
+
expect(errors).toHaveLength(0)
|
|
240
|
+
})
|
|
241
|
+
|
|
242
|
+
test('signal getter call result (d().toISOString())', () => {
|
|
243
|
+
const errors = bf021(`
|
|
244
|
+
'use client'
|
|
245
|
+
import { createSignal } from '@barefootjs/client'
|
|
246
|
+
export function Foo() {
|
|
247
|
+
const [d, setD] = createSignal(new Date())
|
|
248
|
+
return <div>{d().toISOString()}</div>
|
|
249
|
+
}
|
|
250
|
+
`)
|
|
251
|
+
expect(errors).toHaveLength(0)
|
|
252
|
+
})
|
|
253
|
+
|
|
254
|
+
test('local-function call (not a member call on the receiver)', () => {
|
|
255
|
+
const errors = bf021(`
|
|
256
|
+
function formatDate(x: Date): string { return x.toString() }
|
|
257
|
+
export function Foo({ createdAt }: { createdAt: Date }) {
|
|
258
|
+
return <div>{formatDate(createdAt)}</div>
|
|
259
|
+
}
|
|
260
|
+
`)
|
|
261
|
+
expect(errors).toHaveLength(0)
|
|
262
|
+
})
|
|
263
|
+
|
|
264
|
+
test('.length non-call access', () => {
|
|
265
|
+
const errors = bf021(`
|
|
266
|
+
export function Foo({ items }: { items: string[] }) {
|
|
267
|
+
return <div>{items.length}</div>
|
|
268
|
+
}
|
|
269
|
+
`)
|
|
270
|
+
expect(errors).toHaveLength(0)
|
|
271
|
+
})
|
|
272
|
+
|
|
273
|
+
test('in-file interface Date shadow', () => {
|
|
274
|
+
const errors = bf021(`
|
|
275
|
+
interface Date { iso: string }
|
|
276
|
+
export function Foo({ d }: { d: Date }) {
|
|
277
|
+
return <div>{d.toISOString()}</div>
|
|
278
|
+
}
|
|
279
|
+
`)
|
|
280
|
+
expect(errors).toHaveLength(0)
|
|
281
|
+
})
|
|
282
|
+
|
|
283
|
+
test('registry-claimed call is exempt (#2274 seam)', () => {
|
|
284
|
+
const samplePlugin: LoweringPlugin = {
|
|
285
|
+
name: 'sample-date-lowering',
|
|
286
|
+
prepare: () => (callee, _args) =>
|
|
287
|
+
callee.kind === 'member' && !callee.computed && callee.property === 'toISOString'
|
|
288
|
+
? { kind: 'helper-call', helper: 'isoDate', args: [] }
|
|
289
|
+
: null,
|
|
290
|
+
}
|
|
291
|
+
registerLoweringPlugin(samplePlugin)
|
|
292
|
+
try {
|
|
293
|
+
const errors = bf021(`
|
|
294
|
+
export function Foo({ createdAt }: { createdAt: Date }) {
|
|
295
|
+
return <div>{createdAt.toISOString()}</div>
|
|
296
|
+
}
|
|
297
|
+
`)
|
|
298
|
+
expect(errors).toHaveLength(0)
|
|
299
|
+
} finally {
|
|
300
|
+
__resetLoweringPluginsForTest(getLoweringPlugins().filter((p) => p.name !== 'sample-date-lowering'))
|
|
301
|
+
}
|
|
302
|
+
})
|
|
303
|
+
})
|
package/src/analyzer.ts
CHANGED
|
@@ -3068,6 +3068,8 @@ function extractProps(param: ts.ParameterDeclaration, ctx: AnalyzerContext): voi
|
|
|
3068
3068
|
optional: !!member?.optional || !!element.initializer,
|
|
3069
3069
|
defaultValue,
|
|
3070
3070
|
defaultContainsArrow: defaultContainsArrow || undefined,
|
|
3071
|
+
// Only aliased bindings carry the source key — see ParamInfo.sourceName.
|
|
3072
|
+
...(sourcePropName !== localName && { sourceName: sourcePropName }),
|
|
3071
3073
|
})
|
|
3072
3074
|
}
|
|
3073
3075
|
}
|
package/src/compiler.ts
CHANGED
|
@@ -24,6 +24,7 @@ import { applyCssLayerPrefix } from './css-layer-prefixer.ts'
|
|
|
24
24
|
import { preprocessInlineJsxCallbacks } from './preprocess-inline-jsx-callbacks.ts'
|
|
25
25
|
import { extractSsrDefaults } from './ssr-defaults.ts'
|
|
26
26
|
import { computeSsrSeedPlan } from './ssr-seed-plan.ts'
|
|
27
|
+
import { checkRichTypeMethodCalls } from './rich-type-refusal.ts'
|
|
27
28
|
|
|
28
29
|
/**
|
|
29
30
|
* Extended compile options with required adapter
|
|
@@ -137,6 +138,7 @@ function compileMultipleComponents(
|
|
|
137
138
|
}
|
|
138
139
|
|
|
139
140
|
componentIR.metadata.clientAnalysis = analyzeClientNeeds(componentIR)
|
|
141
|
+
checkRichTypeMethodCalls(componentIR.root, componentIR.metadata, errors)
|
|
140
142
|
|
|
141
143
|
if (options.cssLayerPrefix) {
|
|
142
144
|
applyCssLayerPrefix(componentIR, options.cssLayerPrefix)
|
|
@@ -615,6 +617,7 @@ export function compileJSX(
|
|
|
615
617
|
|
|
616
618
|
// Pre-compute client JS analysis for adapter optimization
|
|
617
619
|
componentIR.metadata.clientAnalysis = analyzeClientNeeds(componentIR)
|
|
620
|
+
checkRichTypeMethodCalls(componentIR.root, componentIR.metadata, errors)
|
|
618
621
|
|
|
619
622
|
// Cross-file @client signal sources: identify which import sources
|
|
620
623
|
// need `.client.js` path rewriting in the client bundle.
|
|
@@ -99,16 +99,23 @@ export function generateInitFunction(
|
|
|
99
99
|
let generatedCode = rewritePropsObjectRef(lines.join('\n'), ctx.propsObjectName)
|
|
100
100
|
generatedCode += '\n' + hydrateLine
|
|
101
101
|
|
|
102
|
-
|
|
102
|
+
// Substitute module-level declarations BEFORE import detection: a
|
|
103
|
+
// module-level helper's body (e.g. `buildSheetVMs` calling
|
|
104
|
+
// `computeSheetGeometry`) only exists in `moduleConstantsCode`, so
|
|
105
|
+
// scanning `generatedCode` first would miss any import referenced
|
|
106
|
+
// only from that body and silently drop it (#2283).
|
|
103
107
|
const moduleConstantsCode = emitModuleLevelDeclarations(
|
|
104
108
|
classification.moduleLevelConstants,
|
|
105
109
|
classification.moduleLevelFunctions,
|
|
106
110
|
classification.moduleLevelSignals,
|
|
107
111
|
classification.moduleLevelMemos,
|
|
108
112
|
)
|
|
113
|
+
// Replacer-function form: a plain replacement string would let literal
|
|
114
|
+
// `$&`/`$1`/`$$` sequences in user helper bodies or import paths be
|
|
115
|
+
// reinterpreted by `String.replace`'s special-pattern handling.
|
|
116
|
+
const codeWithModuleConstants = generatedCode.replace(MODULE_CONSTANTS_PLACEHOLDER, () => moduleConstantsCode)
|
|
117
|
+
const allImportLines = resolveFinalImports(codeWithModuleConstants, ir, localImportPrefixes)
|
|
109
118
|
|
|
110
|
-
return
|
|
111
|
-
.replace(IMPORT_PLACEHOLDER, allImportLines)
|
|
112
|
-
.replace(MODULE_CONSTANTS_PLACEHOLDER, moduleConstantsCode)
|
|
119
|
+
return codeWithModuleConstants.replace(IMPORT_PLACEHOLDER, () => allImportLines)
|
|
113
120
|
}
|
|
114
121
|
|
|
@@ -529,8 +529,19 @@ export function collectLoopChildReactiveTexts(
|
|
|
529
529
|
const originFreeIds = freeIdsFromRefs(n.origin?.freeRefs)
|
|
530
530
|
const expanded = expandConstantForReactivity(n.expr, ctx, originFreeIds)
|
|
531
531
|
// Include if expression reads signals OR references the loop parameter
|
|
532
|
-
// (loop param becomes a signal accessor via per-item signals).
|
|
533
|
-
|
|
532
|
+
// (loop param becomes a signal accessor via per-item signals). Falls
|
|
533
|
+
// back to the Solid-style AST-flag wrap decision — mirroring
|
|
534
|
+
// `collectLoopChildReactiveAttrs`'s `callsReactiveGetters` /
|
|
535
|
+
// `hasFunctionCalls` fallback (#1673) and the top-level text path's
|
|
536
|
+
// `decideWrapFromAstFlags` gate (`collectElements`'s `expression`
|
|
537
|
+
// handler) — so a loop-item text read through an opaque helper
|
|
538
|
+
// (`textAt(i)` where `const textAt = (i) => rows()[i]`, which
|
|
539
|
+
// `classifyReactivity` can't see through) still gets an update
|
|
540
|
+
// effect instead of silently freezing at its SSR value (#2282).
|
|
541
|
+
const reactive =
|
|
542
|
+
classifyReactivity(expanded.expr, ctx, loopParam, loopParamBindings, expanded.freeIds).kind !== 'none'
|
|
543
|
+
|| decideWrapFromAstFlags(n).wrap
|
|
544
|
+
if (!reactive) return
|
|
534
545
|
texts.push({
|
|
535
546
|
slotId: n.slotId,
|
|
536
547
|
expression: expanded.expr,
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Type-evidence resolution for the rich-type method-call refusal (#2273).
|
|
3
|
+
*
|
|
4
|
+
* `resolveReceiverType` answers one question: "what TypeScript type, if any,
|
|
5
|
+
* does this `ParsedExpr` evaluate to?" — using only the structured metadata
|
|
6
|
+
* already collected at IR-build time (`propsType` / `typeDefinitions`), never
|
|
7
|
+
* a fresh type-checker pass. It is deliberately conservative: any receiver
|
|
8
|
+
* shape it doesn't recognize (a call result, computed access, a local not in
|
|
9
|
+
* `bindings`, …) resolves to `null` ("no evidence"), which the caller must
|
|
10
|
+
* treat as "don't flag" rather than "flag as unknown". A false negative here
|
|
11
|
+
* only misses a refusal; a false positive would incorrectly block valid code.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type { IRMetadata, PropertyInfo, TypeInfo } from './types.ts'
|
|
15
|
+
import type { ParsedExpr } from './expression-parser.ts'
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Built-in JS/TS types whose instance methods have no catalogued lowering
|
|
19
|
+
* (spec/subset-conformance.md). A prop typed as one of these is opaque past
|
|
20
|
+
* this point — the adapters have no structural representation for `Date`,
|
|
21
|
+
* `Map`, etc., only for the primitives/arrays/plain-objects the IR already
|
|
22
|
+
* lowers. Names only (no generic args) — compare against `baseTypeName`.
|
|
23
|
+
*
|
|
24
|
+
* Two shapes deliberately escape this catalogue (conservative misses, not
|
|
25
|
+
* bugs — a miss only skips a refusal, never misdiagnoses):
|
|
26
|
+
* - keyword-typed `bigint` / `symbol` annotations lower to
|
|
27
|
+
* `{ kind: 'unknown' }` in `typeNodeToTypeInfo` (only the object-form
|
|
28
|
+
* `BigInt` / `Symbol` type references reach `kind: 'interface'` and
|
|
29
|
+
* match here);
|
|
30
|
+
* - a local alias of a host type (`type Timestamp = Date`) resolves to
|
|
31
|
+
* the alias NAME — `derefNamedType` only fills in `properties` from a
|
|
32
|
+
* declaration, it never rewrites `raw` to the alias target — so the
|
|
33
|
+
* catalogue lookup sees `Timestamp`, not `Date`.
|
|
34
|
+
*/
|
|
35
|
+
export const HOST_RICH_TYPE_NAMES: ReadonlySet<string> = new Set([
|
|
36
|
+
'Date',
|
|
37
|
+
'Map',
|
|
38
|
+
'Set',
|
|
39
|
+
'WeakMap',
|
|
40
|
+
'WeakSet',
|
|
41
|
+
'URL',
|
|
42
|
+
'URLSearchParams',
|
|
43
|
+
'RegExp',
|
|
44
|
+
'Promise',
|
|
45
|
+
'Error',
|
|
46
|
+
'Symbol',
|
|
47
|
+
'BigInt',
|
|
48
|
+
'Function',
|
|
49
|
+
])
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Strip generic type arguments from a `TypeInfo.raw` string (`Map<string,
|
|
53
|
+
* string>` → `Map`) so a parametrized host type still matches the bare-name
|
|
54
|
+
* catalogue above. `raw` is source-verbatim (`typeNodeToTypeInfo`), so this
|
|
55
|
+
* is a plain substring split on the first `<` — not a type-syntax parse.
|
|
56
|
+
*/
|
|
57
|
+
export function baseTypeName(raw: string): string {
|
|
58
|
+
const idx = raw.indexOf('<')
|
|
59
|
+
return (idx === -1 ? raw : raw.slice(0, idx)).trim()
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
type EvidenceMetadata = Pick<IRMetadata, 'propsType' | 'propsObjectName' | 'propsParams' | 'typeDefinitions'>
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Collapse a union to its single non-nullish arm (`Date | null` → `Date`),
|
|
66
|
+
* recursively, so an optional rich-typed prop still carries evidence. A
|
|
67
|
+
* union with more than one non-nullish arm has no single answer and is left
|
|
68
|
+
* as-is (its `kind` is `'union'`, which never matches the `'interface'`
|
|
69
|
+
* check callers gate on).
|
|
70
|
+
*/
|
|
71
|
+
function isNullishArm(t: TypeInfo): boolean {
|
|
72
|
+
if (t.kind === 'primitive' && (t.primitive === 'null' || t.primitive === 'undefined')) return true
|
|
73
|
+
// `null` as a type annotation is a `ts.LiteralTypeNode` (not the
|
|
74
|
+
// `NullKeyword` `typeNodeToTypeInfo`'s primitive switch checks for), so it
|
|
75
|
+
// falls through to `{ kind: 'unknown', raw: 'null' }` there — a pre-existing
|
|
76
|
+
// gap in that shared helper, out of scope to fix here. Match on `raw` too
|
|
77
|
+
// so `Date | null` still strips down to `Date`.
|
|
78
|
+
return t.kind === 'unknown' && (t.raw === 'null' || t.raw === 'undefined')
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function stripUnion(type: TypeInfo | null): TypeInfo | null {
|
|
82
|
+
if (!type || type.kind !== 'union' || !type.unionTypes) return type
|
|
83
|
+
const nonNullish = type.unionTypes.filter((t) => !isNullishArm(t))
|
|
84
|
+
return nonNullish.length === 1 ? stripUnion(nonNullish[0]) : type
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Resolve a named type (`{ kind: 'interface', raw: 'Props' }`) that carries
|
|
89
|
+
* no inline `properties` to its declaration's field list via
|
|
90
|
+
* `metadata.typeDefinitions`. A type already carrying properties (an inline
|
|
91
|
+
* object literal type, or a type resolved from `tsTypeToTypeInfo`) is
|
|
92
|
+
* returned unchanged — this only fills in the gap left by a *named*
|
|
93
|
+
* reference, which `typeNodeToTypeInfo` intentionally resolves to
|
|
94
|
+
* `{ kind: 'interface', raw }` with no member walk of its own.
|
|
95
|
+
*/
|
|
96
|
+
function derefNamedType(type: TypeInfo, meta: EvidenceMetadata): TypeInfo {
|
|
97
|
+
if (type.kind !== 'interface') return type
|
|
98
|
+
if (type.properties && type.properties.length > 0) return type
|
|
99
|
+
const name = baseTypeName(type.raw)
|
|
100
|
+
const def = meta.typeDefinitions.find((d) => d.name === name)
|
|
101
|
+
if (!def?.properties) return type
|
|
102
|
+
return { ...type, properties: def.properties }
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Resolve one property's type off an object-shaped receiver type, deref'ing
|
|
107
|
+
* a named type first (`Props.createdAt`) and stripping a nullable union off
|
|
108
|
+
* the result (`Date | null` field). Returns `null` when the receiver has no
|
|
109
|
+
* evidence, or the property isn't found on it.
|
|
110
|
+
*/
|
|
111
|
+
function lookupProperty(objType: TypeInfo | null, propName: string, meta: EvidenceMetadata): TypeInfo | null {
|
|
112
|
+
const stripped = stripUnion(objType)
|
|
113
|
+
if (!stripped) return null
|
|
114
|
+
const deref = derefNamedType(stripped, meta)
|
|
115
|
+
const prop = deref.properties?.find((p: PropertyInfo) => p.name === propName)
|
|
116
|
+
return prop ? stripUnion(prop.type) : null
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Resolve the TypeInfo of a receiver expression, using only propsType /
|
|
121
|
+
* propsParams / typeDefinitions and the caller-supplied local bindings.
|
|
122
|
+
* `bindings` maps a name to its known type — or explicitly to `null` for a
|
|
123
|
+
* shadow the caller has proven carries no evidence (e.g. an arrow param, a
|
|
124
|
+
* loop item whose array type isn't known). A `bindings` hit always wins over
|
|
125
|
+
* the props fallback, matching JS lexical shadowing.
|
|
126
|
+
*
|
|
127
|
+
* Only two `ParsedExpr` shapes carry evidence: a bare identifier and a
|
|
128
|
+
* non-computed member access. Everything else (calls, computed/index
|
|
129
|
+
* access, literals, …) resolves to `null` — see the module doc.
|
|
130
|
+
*/
|
|
131
|
+
export function resolveReceiverType(
|
|
132
|
+
expr: ParsedExpr,
|
|
133
|
+
meta: EvidenceMetadata,
|
|
134
|
+
bindings: ReadonlyMap<string, TypeInfo | null>,
|
|
135
|
+
): TypeInfo | null {
|
|
136
|
+
if (expr.kind === 'identifier') {
|
|
137
|
+
if (bindings.has(expr.name)) return stripUnion(bindings.get(expr.name) ?? null)
|
|
138
|
+
if (meta.propsObjectName !== null) {
|
|
139
|
+
// Object-props mode: props are only reachable through the props object,
|
|
140
|
+
// so a bare identifier is never a prop — treating every name that
|
|
141
|
+
// happens to match a propsType field as one would misattribute module
|
|
142
|
+
// consts / imports that share a field's name.
|
|
143
|
+
return expr.name === meta.propsObjectName ? stripUnion(meta.propsType) : null
|
|
144
|
+
}
|
|
145
|
+
// Destructured mode: only a declared param binding is a prop (membership
|
|
146
|
+
// via propsParams, which carries LOCAL names — including rename targets).
|
|
147
|
+
// The TYPE must come from propsType.properties keyed by the SOURCE prop
|
|
148
|
+
// name: propsParams' own `type` degrades to `unknown` for non-primitive
|
|
149
|
+
// props (`collectMemberTypes`' primitives-only gate).
|
|
150
|
+
const param = meta.propsParams.find((p) => p.name === expr.name && !p.isRest)
|
|
151
|
+
if (!param) return null
|
|
152
|
+
return lookupProperty(meta.propsType, param.sourceName ?? param.name, meta)
|
|
153
|
+
}
|
|
154
|
+
if (expr.kind === 'member' && !expr.computed) {
|
|
155
|
+
const objType = resolveReceiverType(expr.object, meta, bindings)
|
|
156
|
+
return lookupProperty(objType, expr.property, meta)
|
|
157
|
+
}
|
|
158
|
+
return null
|
|
159
|
+
}
|