@barefootjs/hono 0.31.0 → 0.31.2
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/adapter/hono-adapter.d.ts +67 -2
- package/dist/adapter/hono-adapter.d.ts.map +1 -1
- package/dist/adapter/index.js +46 -187398
- package/dist/app.js +0 -71
- package/dist/async.js +0 -71
- package/dist/client-shim.js +0 -71
- package/dist/dev-worker.js +0 -71
- package/dist/dialog-context.js +0 -71
- package/dist/index.js +46 -187398
- package/dist/jsx/jsx-dev-runtime/index.d.ts +3 -1
- package/dist/jsx/jsx-dev-runtime/index.d.ts.map +1 -1
- package/dist/jsx/jsx-dev-runtime/index.js +14 -69
- package/dist/jsx/jsx-runtime/index.d.ts +4 -1
- package/dist/jsx/jsx-runtime/index.d.ts.map +1 -1
- package/dist/jsx/jsx-runtime/index.js +24 -69
- package/dist/jsx/resolve-dangerously-set-inner-html.d.ts +2 -0
- package/dist/jsx/resolve-dangerously-set-inner-html.d.ts.map +1 -0
- package/dist/portal-ssr.js +0 -71
- package/dist/portals.js +0 -71
- package/dist/preload.js +0 -71
- package/dist/render.js +0 -71
- package/dist/request-env.js +0 -71
- package/dist/scripts.d.ts +3 -2
- package/dist/scripts.d.ts.map +1 -1
- package/dist/scripts.js +0 -71
- package/dist/utils.js +0 -71
- package/dist/vite.js +313 -142
- package/package.json +2 -2
- package/src/__tests__/aliased-destructured-prop.test.ts +8 -7
- package/src/__tests__/consumer-typecheck.test.ts +403 -0
- package/src/__tests__/corpus-typecheck.test.ts +130 -0
- package/src/__tests__/dangerously-set-inner-html.test.ts +70 -0
- package/src/__tests__/nested-ternary-bare-branch.test.ts +70 -0
- package/src/adapter/hono-adapter.ts +123 -166
- package/src/jsx/jsx-dev-runtime/index.ts +13 -1
- package/src/jsx/jsx-runtime/index.ts +24 -1
- package/src/jsx/resolve-dangerously-set-inner-html.ts +34 -0
- package/src/scripts.tsx +3 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@barefootjs/hono",
|
|
3
|
-
"version": "0.31.
|
|
3
|
+
"version": "0.31.2",
|
|
4
4
|
"description": "Hono integration for BarefootJS",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -122,7 +122,7 @@
|
|
|
122
122
|
},
|
|
123
123
|
"devDependencies": {
|
|
124
124
|
"@barefootjs/adapter-tests": "0.1.0",
|
|
125
|
-
"@barefootjs/vite": "0.31.
|
|
125
|
+
"@barefootjs/vite": "0.31.2",
|
|
126
126
|
"@types/jsdom": "^27.0.0",
|
|
127
127
|
"hono": "^4.6.0",
|
|
128
128
|
"jsdom": "^27.3.0",
|
|
@@ -99,10 +99,11 @@ describe('aliased destructured props (#2460)', () => {
|
|
|
99
99
|
})
|
|
100
100
|
|
|
101
101
|
test('aliased prop reaches client-side hydration serialization with the correct value', async () => {
|
|
102
|
-
// The client init function reads `_p.<
|
|
102
|
+
// The client init function reads `_p.<callerKey>` (props-extraction
|
|
103
103
|
// phase), so the SSR-serialized `bf-p` blob must carry the correct
|
|
104
|
-
// value under the
|
|
105
|
-
//
|
|
104
|
+
// value under the CALLER-facing key (`n`) — `_p` is uniformly keyed
|
|
105
|
+
// by `sourceName ?? name` across every producer/consumer (#2524 CSR
|
|
106
|
+
// half), not the local binding the destructure renames it to.
|
|
106
107
|
const html = await renderHonoComponent({
|
|
107
108
|
adapter: new HonoAdapter(),
|
|
108
109
|
source: `
|
|
@@ -120,9 +121,9 @@ describe('aliased destructured props (#2460)', () => {
|
|
|
120
121
|
|
|
121
122
|
// The rendered value is correct...
|
|
122
123
|
expect(html).toContain(':<!--bf:s1-->7<!--/-->')
|
|
123
|
-
// ...and the serialized hydration payload carries it under the
|
|
124
|
-
//
|
|
125
|
-
// `const count = _p.
|
|
126
|
-
expect(html).toMatch(/bf-p="[^"]*
|
|
124
|
+
// ...and the serialized hydration payload carries it under the
|
|
125
|
+
// CALLER-facing key, which is what the generated client JS's
|
|
126
|
+
// `const count = _p.n` extraction reads.
|
|
127
|
+
expect(html).toMatch(/bf-p="[^"]*n[^"]*7/)
|
|
127
128
|
})
|
|
128
129
|
})
|
|
@@ -0,0 +1,403 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Type-check a CONSUMER program that imports a compiled template — the
|
|
3
|
+
* coverage gap behind both #2559 and #2565: nothing in-repo ever ran tsc
|
|
4
|
+
* over a program shaped like a consumer app, so type-level defects in the
|
|
5
|
+
* emitted `.tsx` shipped invisibly (both were found by downstream apps
|
|
6
|
+
* migrating their BarefootJS version).
|
|
7
|
+
*
|
|
8
|
+
* Each case compiles a real `'use client'` component, writes the emitted
|
|
9
|
+
* template plus a scaffold-shaped `server.tsx` that renders the island,
|
|
10
|
+
* and type-checks the pair with the scaffold's own options (`strict`,
|
|
11
|
+
* `jsxImportSource: '@barefootjs/hono/jsx'`).
|
|
12
|
+
*/
|
|
13
|
+
import { describe, expect, test } from 'bun:test'
|
|
14
|
+
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'
|
|
15
|
+
import { join, resolve } from 'node:path'
|
|
16
|
+
import ts from 'typescript'
|
|
17
|
+
import { compileJSX } from '@barefootjs/jsx'
|
|
18
|
+
import { HonoAdapter } from '../adapter/index.ts'
|
|
19
|
+
|
|
20
|
+
const HERE = resolve(import.meta.dir)
|
|
21
|
+
|
|
22
|
+
interface Diagnostic {
|
|
23
|
+
code: number
|
|
24
|
+
file: string
|
|
25
|
+
message: string
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Write `template` as `components/<name>.tsx` alongside `server.tsx` and
|
|
30
|
+
* run tsc over the pair, returning the flattened diagnostics.
|
|
31
|
+
*
|
|
32
|
+
* The temp dir lives INSIDE the package so module resolution reaches the
|
|
33
|
+
* workspace's node_modules (hono, @barefootjs/*) exactly like a
|
|
34
|
+
* scaffolded app's.
|
|
35
|
+
*/
|
|
36
|
+
function typeCheckConsumer(name: string, template: string, server: string): Diagnostic[] {
|
|
37
|
+
const tmp = mkdtempSync(join(HERE, '.consumer-typecheck-'))
|
|
38
|
+
try {
|
|
39
|
+
mkdirSync(join(tmp, 'components'), { recursive: true })
|
|
40
|
+
writeFileSync(join(tmp, 'components', `${name}.tsx`), template)
|
|
41
|
+
writeFileSync(join(tmp, 'server.tsx'), server)
|
|
42
|
+
|
|
43
|
+
const program = ts.createProgram(
|
|
44
|
+
[join(tmp, 'server.tsx')],
|
|
45
|
+
{
|
|
46
|
+
strict: true,
|
|
47
|
+
noEmit: true,
|
|
48
|
+
target: ts.ScriptTarget.ESNext,
|
|
49
|
+
module: ts.ModuleKind.ESNext,
|
|
50
|
+
moduleResolution: ts.ModuleResolutionKind.Bundler,
|
|
51
|
+
jsx: ts.JsxEmit.ReactJSX,
|
|
52
|
+
jsxImportSource: '@barefootjs/hono/jsx',
|
|
53
|
+
lib: ['lib.esnext.d.ts', 'lib.dom.d.ts'],
|
|
54
|
+
allowImportingTsExtensions: true,
|
|
55
|
+
// Consumer apps skipLibCheck too; the errors these tests pin fire
|
|
56
|
+
// in OUR files regardless.
|
|
57
|
+
skipLibCheck: true,
|
|
58
|
+
},
|
|
59
|
+
)
|
|
60
|
+
return ts.getPreEmitDiagnostics(program).map(d => ({
|
|
61
|
+
code: d.code,
|
|
62
|
+
file: d.file?.fileName.replace(tmp, '') ?? '',
|
|
63
|
+
message: ts.flattenDiagnosticMessageText(d.messageText, ' '),
|
|
64
|
+
}))
|
|
65
|
+
} finally {
|
|
66
|
+
rmSync(tmp, { recursive: true, force: true })
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const COMPONENT_SOURCE = `"use client"
|
|
71
|
+
|
|
72
|
+
import { createSignal } from '@barefootjs/client'
|
|
73
|
+
|
|
74
|
+
export function Counter() {
|
|
75
|
+
const [count, setCount] = createSignal(0)
|
|
76
|
+
return <button onClick={() => setCount(count() + 1)}>{count()}</button>
|
|
77
|
+
}
|
|
78
|
+
`
|
|
79
|
+
|
|
80
|
+
const SERVER_SOURCE = `import { Counter } from './components/Counter.tsx'
|
|
81
|
+
|
|
82
|
+
export function Page() {
|
|
83
|
+
return (
|
|
84
|
+
<div>
|
|
85
|
+
<Counter />
|
|
86
|
+
</div>
|
|
87
|
+
)
|
|
88
|
+
}
|
|
89
|
+
`
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* #2565's shape: a module-level `as const` record indexed with a
|
|
93
|
+
* narrowing assertion (`strokePaths[name as keyof typeof strokePaths]`),
|
|
94
|
+
* where the prop's union is DELIBERATELY wider than the record's keys —
|
|
95
|
+
* `'github' | 'search'` are handled by earlier early-returns and have no
|
|
96
|
+
* entry in `strokePaths`. That width is the whole point: the compiler
|
|
97
|
+
* folds the record's cases into the JSX binding, and the assertion that
|
|
98
|
+
* made the source type-check is type-stripped out of the IR's index
|
|
99
|
+
* expression, so the inlined literal ends up indexed by the full union.
|
|
100
|
+
*
|
|
101
|
+
* `IconName` is spelled as an explicit literal union rather than the
|
|
102
|
+
* source component's `keyof typeof strokePaths | …` so this case pins the
|
|
103
|
+
* lookup annotation ALONE. The alias form is pinned separately below —
|
|
104
|
+
* before that fix it failed TS2304 and widened `keyof typeof` to
|
|
105
|
+
* `string | number | symbol`, which would have masked the TS7053 here.
|
|
106
|
+
*/
|
|
107
|
+
const ICON_SOURCE = `"use client"
|
|
108
|
+
|
|
109
|
+
const strokePaths = {
|
|
110
|
+
'check': 'M20 6 9 17l-5-5',
|
|
111
|
+
'chevron-down': 'm6 9 6 6 6-6',
|
|
112
|
+
} as const
|
|
113
|
+
|
|
114
|
+
export type IconName = 'check' | 'chevron-down' | 'github' | 'search'
|
|
115
|
+
|
|
116
|
+
export function Icon({ name }: { name: IconName }) {
|
|
117
|
+
if (name === 'github') {
|
|
118
|
+
return <span>gh</span>
|
|
119
|
+
}
|
|
120
|
+
if (name === 'search') {
|
|
121
|
+
return <span>search</span>
|
|
122
|
+
}
|
|
123
|
+
const path = strokePaths[name as keyof typeof strokePaths]
|
|
124
|
+
if (!path) {
|
|
125
|
+
return null
|
|
126
|
+
}
|
|
127
|
+
return <svg viewBox="0 0 24 24"><path d={path} /></svg>
|
|
128
|
+
}
|
|
129
|
+
`
|
|
130
|
+
|
|
131
|
+
const ICON_SERVER_SOURCE = `import { Icon } from './components/Icon.tsx'
|
|
132
|
+
|
|
133
|
+
export function Page() {
|
|
134
|
+
return (
|
|
135
|
+
<div>
|
|
136
|
+
<Icon name="check" />
|
|
137
|
+
</div>
|
|
138
|
+
)
|
|
139
|
+
}
|
|
140
|
+
`
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* The same defect through the OTHER emit path: a component-prop `template`
|
|
144
|
+
* is collapsed to a neutral JS expression at IR construction time, so it
|
|
145
|
+
* bypasses the adapter's template-parts renderer. Both positions —
|
|
146
|
+
* intrinsic attr and component prop — carry the record lookup here, and
|
|
147
|
+
* `size` is never narrowed, so every case of `Size` reaches the index.
|
|
148
|
+
*/
|
|
149
|
+
const BOX_SOURCE = `"use client"
|
|
150
|
+
|
|
151
|
+
const sizeClasses = { sm: 'h-4', md: 'h-6' } as const
|
|
152
|
+
|
|
153
|
+
export type Size = 'sm' | 'md' | 'lg'
|
|
154
|
+
|
|
155
|
+
function Inner({ className }: { className: string }) {
|
|
156
|
+
return <span className={className} />
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export function Box({ size }: { size: Size }) {
|
|
160
|
+
const cls = sizeClasses[size as keyof typeof sizeClasses]
|
|
161
|
+
return <div className={cls}><Inner className={cls} /></div>
|
|
162
|
+
}
|
|
163
|
+
`
|
|
164
|
+
|
|
165
|
+
const BOX_SERVER_SOURCE = `import { Box } from './components/Box.tsx'
|
|
166
|
+
|
|
167
|
+
export function Page() {
|
|
168
|
+
return (
|
|
169
|
+
<div>
|
|
170
|
+
<Box size="sm" />
|
|
171
|
+
</div>
|
|
172
|
+
)
|
|
173
|
+
}
|
|
174
|
+
`
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* #2570: a module-level type alias that queries a const with `typeof`.
|
|
178
|
+
* Type declarations are re-emitted verbatim at MODULE scope while source
|
|
179
|
+
* module-level consts are localised into each component body, so
|
|
180
|
+
* `keyof typeof strokePaths` lost its referent — TS2304, and the alias
|
|
181
|
+
* then degraded to `keyof any`.
|
|
182
|
+
*
|
|
183
|
+
* This is the icon component's real shape (`ui/components/ui/icon`): the
|
|
184
|
+
* union mixes the record's own keys with names handled by early returns.
|
|
185
|
+
*/
|
|
186
|
+
const TYPEOF_ALIAS_SOURCE = `"use client"
|
|
187
|
+
|
|
188
|
+
const strokePaths = {
|
|
189
|
+
'check': 'M20 6 9 17l-5-5',
|
|
190
|
+
'chevron-down': 'm6 9 6 6 6-6',
|
|
191
|
+
} as const
|
|
192
|
+
|
|
193
|
+
export type IconName = keyof typeof strokePaths | 'github'
|
|
194
|
+
|
|
195
|
+
export function Icon({ name }: { name: IconName }) {
|
|
196
|
+
if (name === 'github') {
|
|
197
|
+
return <span>gh</span>
|
|
198
|
+
}
|
|
199
|
+
const path = strokePaths[name as keyof typeof strokePaths]
|
|
200
|
+
return <svg viewBox="0 0 24 24"><path d={path} /></svg>
|
|
201
|
+
}
|
|
202
|
+
`
|
|
203
|
+
|
|
204
|
+
const TYPEOF_ALIAS_SERVER_SOURCE = `import { Icon } from './components/Icon.tsx'
|
|
205
|
+
|
|
206
|
+
export function Page() {
|
|
207
|
+
return (
|
|
208
|
+
<div>
|
|
209
|
+
<Icon name="chevron-down" />
|
|
210
|
+
</div>
|
|
211
|
+
)
|
|
212
|
+
}
|
|
213
|
+
`
|
|
214
|
+
|
|
215
|
+
/** Same consumer, but with a name that is NOT in the union. */
|
|
216
|
+
const TYPEOF_ALIAS_BAD_SERVER_SOURCE = `import { Icon } from './components/Icon.tsx'
|
|
217
|
+
|
|
218
|
+
export function Page() {
|
|
219
|
+
return (
|
|
220
|
+
<div>
|
|
221
|
+
<Icon name="totally-not-an-icon" />
|
|
222
|
+
</div>
|
|
223
|
+
)
|
|
224
|
+
}
|
|
225
|
+
`
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* #2570 through the PROPS-TYPE channel: the `typeof` query lives in the
|
|
229
|
+
* props annotation (which the emitter folds into the synthesized
|
|
230
|
+
* `<Name>PropsWithHydration` alias at module scope), not in a named type
|
|
231
|
+
* alias — so a fix that only scanned `typeDefinitions` missed it.
|
|
232
|
+
*/
|
|
233
|
+
const PROPS_TYPEOF_SOURCE = `"use client"
|
|
234
|
+
|
|
235
|
+
const modes = { a: 1, b: 2 } as const
|
|
236
|
+
|
|
237
|
+
export function Box({ mode }: { mode: keyof typeof modes }) {
|
|
238
|
+
return <div>{modes[mode]}</div>
|
|
239
|
+
}
|
|
240
|
+
`
|
|
241
|
+
|
|
242
|
+
const PROPS_TYPEOF_SERVER_SOURCE = `import { Box } from './components/Box.tsx'
|
|
243
|
+
|
|
244
|
+
export function Page() {
|
|
245
|
+
return <Box mode="a" />
|
|
246
|
+
}
|
|
247
|
+
`
|
|
248
|
+
|
|
249
|
+
/** Same consumer, but with a key that is NOT in the record. */
|
|
250
|
+
const PROPS_TYPEOF_BAD_SERVER_SOURCE = `import { Box } from './components/Box.tsx'
|
|
251
|
+
|
|
252
|
+
export function Page() {
|
|
253
|
+
return <Box mode="zzz" />
|
|
254
|
+
}
|
|
255
|
+
`
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* #2570's third face: an inline-exported type alias no component body
|
|
259
|
+
* references. Per-component reachability pruning dropped it from the
|
|
260
|
+
* emitted template entirely, so a consumer's `import type { Sizer }`
|
|
261
|
+
* failed TS2305 — and had it survived, its `typeof sizeOf` query needed
|
|
262
|
+
* the (non-exported, module-scope) function beside it.
|
|
263
|
+
*/
|
|
264
|
+
const EXPORTED_TYPE_SOURCE = `"use client"
|
|
265
|
+
|
|
266
|
+
function sizeOf(s: 'sm' | 'md') { return s === 'sm' ? 16 : 20 }
|
|
267
|
+
|
|
268
|
+
export type Sizer = typeof sizeOf
|
|
269
|
+
|
|
270
|
+
export function Box({ label }: { label: string }) {
|
|
271
|
+
const n = sizeOf('sm')
|
|
272
|
+
return <div>{label}{n}</div>
|
|
273
|
+
}
|
|
274
|
+
`
|
|
275
|
+
|
|
276
|
+
const EXPORTED_TYPE_SERVER_SOURCE = `import { Box } from './components/Box.tsx'
|
|
277
|
+
import type { Sizer } from './components/Box.tsx'
|
|
278
|
+
|
|
279
|
+
const f: Sizer = (s) => (s === 'sm' ? 16 : 20)
|
|
280
|
+
|
|
281
|
+
export function Page() {
|
|
282
|
+
return <Box label={String(f('md'))} />
|
|
283
|
+
}
|
|
284
|
+
`
|
|
285
|
+
|
|
286
|
+
describe('consumer program type-check', () => {
|
|
287
|
+
test('a compiled template used as a JSX component type-checks clean (#2559)', () => {
|
|
288
|
+
const result = compileJSX(COMPONENT_SOURCE, '/virtual/Counter.tsx', {
|
|
289
|
+
adapter: new HonoAdapter(),
|
|
290
|
+
// Non-empty so the emitted template's component body returns
|
|
291
|
+
// wrapWithInlineScripts(...) — the #2559 shape.
|
|
292
|
+
scriptAssets: ['/static/components/assets/Counter.js'],
|
|
293
|
+
})
|
|
294
|
+
expect(result.errors.filter(e => e.severity === 'error')).toEqual([])
|
|
295
|
+
const template = result.files.find(f => f.type === 'markedTemplate')?.content
|
|
296
|
+
expect(template).toContain('wrapWithInlineScripts(')
|
|
297
|
+
|
|
298
|
+
const diagnostics = typeCheckConsumer('Counter', template!, SERVER_SOURCE)
|
|
299
|
+
|
|
300
|
+
// TS2786 = "'X' cannot be used as a JSX component." — the #2559
|
|
301
|
+
// failure. Assert none anywhere in the consumer program.
|
|
302
|
+
expect(diagnostics.filter(d => d.code === 2786)).toEqual([])
|
|
303
|
+
})
|
|
304
|
+
|
|
305
|
+
test('an inlined-const index does not re-expose an unnarrowed key (#2565)', () => {
|
|
306
|
+
const result = compileJSX(ICON_SOURCE, '/virtual/Icon.tsx', {
|
|
307
|
+
adapter: new HonoAdapter(),
|
|
308
|
+
})
|
|
309
|
+
expect(result.errors.filter(e => e.severity === 'error')).toEqual([])
|
|
310
|
+
const template = result.files.find(f => f.type === 'markedTemplate')?.content
|
|
311
|
+
|
|
312
|
+
// The record's cases really are folded into the binding (if this ever
|
|
313
|
+
// stops holding, the type-check below would pass vacuously).
|
|
314
|
+
expect(template).toContain('"chevron-down": "m6 9 6 6 6-6"')
|
|
315
|
+
expect(template).toContain('as Record<string, string>)[name]')
|
|
316
|
+
|
|
317
|
+
const diagnostics = typeCheckConsumer('Icon', template!, ICON_SERVER_SOURCE)
|
|
318
|
+
|
|
319
|
+
// TS7053 = "Element implicitly has an 'any' type because expression of
|
|
320
|
+
// type 'IconName' can't be used to index type '{ check: string; … }'".
|
|
321
|
+
expect(diagnostics.filter(d => d.code === 7053)).toEqual([])
|
|
322
|
+
// The whole emitted template is clean for this shape — no error was
|
|
323
|
+
// merely traded for a different one (e.g. TS2538 on the index type).
|
|
324
|
+
expect(diagnostics).toEqual([])
|
|
325
|
+
})
|
|
326
|
+
|
|
327
|
+
test('a collapsed component-prop lookup is annotated too (#2565)', () => {
|
|
328
|
+
const result = compileJSX(BOX_SOURCE, '/virtual/Box.tsx', {
|
|
329
|
+
adapter: new HonoAdapter(),
|
|
330
|
+
})
|
|
331
|
+
expect(result.errors.filter(e => e.severity === 'error')).toEqual([])
|
|
332
|
+
const template = result.files.find(f => f.type === 'markedTemplate')?.content
|
|
333
|
+
|
|
334
|
+
// Both emit paths reach the annotation: the intrinsic `<div className>`
|
|
335
|
+
// via the template-parts renderer, and the `<Inner className>` prop via
|
|
336
|
+
// the IR-time collapse that `expressionValueToJs` re-renders.
|
|
337
|
+
expect(template!.match(/as Record<string, string>/g)).toHaveLength(2)
|
|
338
|
+
|
|
339
|
+
const diagnostics = typeCheckConsumer('Box', template!, BOX_SERVER_SOURCE)
|
|
340
|
+
|
|
341
|
+
// Without the annotation TS reports TS2339 here (it distributes the
|
|
342
|
+
// `Size` union over the literal and finds no `lg`); the attribute-only
|
|
343
|
+
// fix left this one behind.
|
|
344
|
+
expect(diagnostics).toEqual([])
|
|
345
|
+
})
|
|
346
|
+
|
|
347
|
+
test('a type alias keeps resolving the const it queries with typeof (#2570)', () => {
|
|
348
|
+
const result = compileJSX(TYPEOF_ALIAS_SOURCE, '/virtual/Icon.tsx', {
|
|
349
|
+
adapter: new HonoAdapter(),
|
|
350
|
+
})
|
|
351
|
+
expect(result.errors.filter(e => e.severity === 'error')).toEqual([])
|
|
352
|
+
const template = result.files.find(f => f.type === 'markedTemplate')?.content
|
|
353
|
+
|
|
354
|
+
// The alias really is re-emitted at module scope with the type query
|
|
355
|
+
// intact (otherwise the assertions below would pass vacuously), and
|
|
356
|
+
// the const it queries is hoisted to module scope beside it — exactly
|
|
357
|
+
// once, since `moduleConstants` dedups file-wide by exact string match.
|
|
358
|
+
expect(template).toContain('export type IconName = keyof typeof strokePaths')
|
|
359
|
+
expect(template!.match(/^const strokePaths = \{/gm)).toHaveLength(1)
|
|
360
|
+
|
|
361
|
+
expect(typeCheckConsumer('Icon', template!, TYPEOF_ALIAS_SERVER_SOURCE)).toEqual([])
|
|
362
|
+
|
|
363
|
+
// The alias must keep its LITERAL key union, not merely resolve. An
|
|
364
|
+
// unresolved `keyof typeof` degrades to `keyof any`
|
|
365
|
+
// (`string | number | symbol`), which still type-checks the valid
|
|
366
|
+
// consumer above while silently accepting anything — so the guard that
|
|
367
|
+
// actually holds the line is a consumer passing a bogus name.
|
|
368
|
+
const rejected = typeCheckConsumer('Icon', template!, TYPEOF_ALIAS_BAD_SERVER_SOURCE)
|
|
369
|
+
expect(rejected.map(d => d.code)).toEqual([2322])
|
|
370
|
+
expect(rejected[0]!.message).toContain('is not assignable to type \'IconName\'')
|
|
371
|
+
})
|
|
372
|
+
|
|
373
|
+
test('a props-annotation typeof query keeps its referent too (#2570)', () => {
|
|
374
|
+
const result = compileJSX(PROPS_TYPEOF_SOURCE, '/virtual/Box.tsx', {
|
|
375
|
+
adapter: new HonoAdapter(),
|
|
376
|
+
})
|
|
377
|
+
expect(result.errors.filter(e => e.severity === 'error')).toEqual([])
|
|
378
|
+
const template = result.files.find(f => f.type === 'markedTemplate')?.content
|
|
379
|
+
|
|
380
|
+
expect(typeCheckConsumer('Box', template!, PROPS_TYPEOF_SERVER_SOURCE)).toEqual([])
|
|
381
|
+
|
|
382
|
+
// The union must stay literal — an unresolved `typeof modes` widens
|
|
383
|
+
// `keyof` to `string | number | symbol`, which accepts anything.
|
|
384
|
+
const rejected = typeCheckConsumer('Box', template!, PROPS_TYPEOF_BAD_SERVER_SOURCE)
|
|
385
|
+
expect(rejected.map(d => d.code)).toEqual([2322])
|
|
386
|
+
})
|
|
387
|
+
|
|
388
|
+
test('an inline-exported type alias survives emission (#2570)', () => {
|
|
389
|
+
const result = compileJSX(EXPORTED_TYPE_SOURCE, '/virtual/Box.tsx', {
|
|
390
|
+
adapter: new HonoAdapter(),
|
|
391
|
+
})
|
|
392
|
+
expect(result.errors.filter(e => e.severity === 'error')).toEqual([])
|
|
393
|
+
const template = result.files.find(f => f.type === 'markedTemplate')?.content
|
|
394
|
+
|
|
395
|
+
// The alias is emitted (previously reachability-pruned → TS2305 for
|
|
396
|
+
// the consumer) AND its `typeof sizeOf` referent is emitted at module
|
|
397
|
+
// scope beside it.
|
|
398
|
+
expect(template).toContain('export type Sizer = typeof sizeOf')
|
|
399
|
+
expect(template).toMatch(/^function sizeOf/m)
|
|
400
|
+
|
|
401
|
+
expect(typeCheckConsumer('Box', template!, EXPORTED_TYPE_SERVER_SOURCE)).toEqual([])
|
|
402
|
+
})
|
|
403
|
+
})
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Corpus type-check gate: compile EVERY `ui/components/ui/*` component with
|
|
3
|
+
* the Hono adapter and run one tsc program over all emitted templates.
|
|
4
|
+
*
|
|
5
|
+
* #2559, #2565, and #2570 were each found by a downstream app migrating its
|
|
6
|
+
* BarefootJS version, not by CI — the example-based consumer-typecheck cases
|
|
7
|
+
* pin each *known* shape, but can't anticipate the next one. The real ui/
|
|
8
|
+
* corpus can: it exercises every emitter path the library itself uses, so a
|
|
9
|
+
* new type-level emission defect surfaces here as a new diagnostic.
|
|
10
|
+
*
|
|
11
|
+
* The gate holds the line at the CURRENT profile via `KNOWN_DIAGNOSTICS`
|
|
12
|
+
* (issue #2573): a (component, TS code) pair outside the allowlist, or a
|
|
13
|
+
* count above its allowlisted ceiling, fails. Fixing a family should ratchet
|
|
14
|
+
* its entry down (or out) in the same PR — entries may only shrink.
|
|
15
|
+
*
|
|
16
|
+
* Heaviest test in this package (~30s compile + ~10s tsc), deliberately one
|
|
17
|
+
* program for all templates so cross-template imports (`../button`,
|
|
18
|
+
* `../../types`) resolve like a consumer app's.
|
|
19
|
+
*/
|
|
20
|
+
import { describe, expect, test } from 'bun:test'
|
|
21
|
+
import { cpSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs'
|
|
22
|
+
import { join, resolve } from 'node:path'
|
|
23
|
+
import ts from 'typescript'
|
|
24
|
+
import { compileJSX } from '@barefootjs/jsx'
|
|
25
|
+
import { HonoAdapter } from '../adapter/index.ts'
|
|
26
|
+
|
|
27
|
+
const HERE = resolve(import.meta.dir)
|
|
28
|
+
const REPO = resolve(HERE, '../../../..')
|
|
29
|
+
const UI = join(REPO, 'ui/components/ui')
|
|
30
|
+
const UI_TYPES = join(REPO, 'ui/types/index.tsx')
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Pre-existing type-level debt in emitted templates, tracked in
|
|
34
|
+
* https://github.com/piconic-ai/barefootjs/issues/2573 — see the issue for
|
|
35
|
+
* the per-family mechanisms. Counts are ceilings: shrink them (or delete
|
|
36
|
+
* the entry) when a family is fixed; never raise one to make a new defect
|
|
37
|
+
* pass.
|
|
38
|
+
*/
|
|
39
|
+
const KNOWN_DIAGNOSTICS: Record<string, number> = {
|
|
40
|
+
'chart TS17001': 6,
|
|
41
|
+
'chart TS18046': 69,
|
|
42
|
+
'chart TS2307': 2,
|
|
43
|
+
'chart TS2322': 2,
|
|
44
|
+
'chart TS7006': 34,
|
|
45
|
+
'xyflow TS2304': 7,
|
|
46
|
+
'xyflow TS2307': 2,
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
describe('ui corpus type-check gate (#2570 / #2573)', () => {
|
|
50
|
+
test('emitted templates introduce no type diagnostics beyond the known debt', () => {
|
|
51
|
+
const tmp = mkdtempSync(join(HERE, '.corpus-typecheck-'))
|
|
52
|
+
try {
|
|
53
|
+
mkdirSync(join(tmp, 'components', 'ui'), { recursive: true })
|
|
54
|
+
// `../../types` (from a component) and `../../../types` (both shapes
|
|
55
|
+
// appear in ui sources) — mirror the source tree's resolution targets.
|
|
56
|
+
// Parent dirs created explicitly: `cpSync`'s parent-creation behaviour
|
|
57
|
+
// for file→file copies is an implementation detail not worth relying on.
|
|
58
|
+
mkdirSync(join(tmp, 'types'), { recursive: true })
|
|
59
|
+
mkdirSync(join(tmp, 'components', 'types'), { recursive: true })
|
|
60
|
+
cpSync(UI_TYPES, join(tmp, 'types', 'index.tsx'))
|
|
61
|
+
cpSync(UI_TYPES, join(tmp, 'components', 'types', 'index.tsx'))
|
|
62
|
+
|
|
63
|
+
const roots: string[] = []
|
|
64
|
+
for (const name of readdirSync(UI).sort()) {
|
|
65
|
+
let source: string
|
|
66
|
+
try {
|
|
67
|
+
source = readFileSync(join(UI, name, 'index.tsx'), 'utf8')
|
|
68
|
+
} catch {
|
|
69
|
+
continue
|
|
70
|
+
}
|
|
71
|
+
const result = compileJSX(source, join(UI, name, 'index.tsx'), {
|
|
72
|
+
adapter: new HonoAdapter(),
|
|
73
|
+
})
|
|
74
|
+
// A component the compiler refuses outright is a different failure
|
|
75
|
+
// class (covered by conformance) — this gate is about the emitted
|
|
76
|
+
// templates, so refusals must not silently shrink its coverage.
|
|
77
|
+
expect(result.errors.filter(e => e.severity === 'error')).toEqual([])
|
|
78
|
+
const template = result.files.find(f => f.type === 'markedTemplate')?.content
|
|
79
|
+
if (!template) continue
|
|
80
|
+
const dir = join(tmp, 'components', 'ui', name)
|
|
81
|
+
mkdirSync(dir, { recursive: true })
|
|
82
|
+
const out = join(dir, 'index.tsx')
|
|
83
|
+
writeFileSync(out, template)
|
|
84
|
+
roots.push(out)
|
|
85
|
+
}
|
|
86
|
+
// The corpus is the coverage — a collapse in compiled-template count
|
|
87
|
+
// would make the diagnostic assertions below pass vacuously.
|
|
88
|
+
expect(roots.length).toBeGreaterThanOrEqual(60)
|
|
89
|
+
|
|
90
|
+
const program = ts.createProgram(roots, {
|
|
91
|
+
strict: true,
|
|
92
|
+
noEmit: true,
|
|
93
|
+
target: ts.ScriptTarget.ESNext,
|
|
94
|
+
module: ts.ModuleKind.ESNext,
|
|
95
|
+
moduleResolution: ts.ModuleResolutionKind.Bundler,
|
|
96
|
+
jsx: ts.JsxEmit.ReactJSX,
|
|
97
|
+
jsxImportSource: '@barefootjs/hono/jsx',
|
|
98
|
+
lib: ['lib.esnext.d.ts', 'lib.dom.d.ts'],
|
|
99
|
+
allowImportingTsExtensions: true,
|
|
100
|
+
skipLibCheck: true,
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
const counts = new Map<string, number>()
|
|
104
|
+
const samples = new Map<string, string>()
|
|
105
|
+
for (const d of ts.getPreEmitDiagnostics(program)) {
|
|
106
|
+
const component = d.file
|
|
107
|
+
? d.file.fileName.replace(`${tmp}/components/ui/`, '').replace('/index.tsx', '')
|
|
108
|
+
: '(no file)'
|
|
109
|
+
const key = `${component} TS${d.code}`
|
|
110
|
+
counts.set(key, (counts.get(key) ?? 0) + 1)
|
|
111
|
+
if (!samples.has(key)) {
|
|
112
|
+
samples.set(key, ts.flattenDiagnosticMessageText(d.messageText, ' ').slice(0, 200))
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const violations: string[] = []
|
|
117
|
+
for (const [key, count] of [...counts].sort()) {
|
|
118
|
+
const allowed = KNOWN_DIAGNOSTICS[key]
|
|
119
|
+
if (allowed === undefined) {
|
|
120
|
+
violations.push(`NEW ${key} x${count} — ${samples.get(key)}`)
|
|
121
|
+
} else if (count > allowed) {
|
|
122
|
+
violations.push(`GREW ${key}: ${allowed} -> ${count} — ${samples.get(key)}`)
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
expect(violations).toEqual([])
|
|
126
|
+
} finally {
|
|
127
|
+
rmSync(tmp, { recursive: true, force: true })
|
|
128
|
+
}
|
|
129
|
+
}, 180_000)
|
|
130
|
+
})
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hono adapter jsx-runtime: `dangerouslySetInnerHTML` with no children (#2557).
|
|
3
|
+
*
|
|
4
|
+
* hono's own `jsxFn` (see `hono/dist/jsx/base.js`) always wraps `<svg>` /
|
|
5
|
+
* `<head>` children in an internal namespace-context node, even when the
|
|
6
|
+
* caller passed no real children. That phantom wrapper makes hono's own
|
|
7
|
+
* `children.length > 0` guard true, so any *childless* `<svg>`/`<head>`
|
|
8
|
+
* element using `dangerouslySetInnerHTML` tripped hono's
|
|
9
|
+
* "Can only set one of `children` or `props.dangerouslySetInnerHTML`"
|
|
10
|
+
* error — even though there were no real children to conflict with.
|
|
11
|
+
*
|
|
12
|
+
* `../jsx/jsx-runtime/index.ts` and `../jsx/jsx-dev-runtime/index.ts` work
|
|
13
|
+
* around this by resolving `dangerouslySetInnerHTML` into real `children`
|
|
14
|
+
* themselves before delegating to hono, whenever no explicit `children`
|
|
15
|
+
* prop is present. This pins that fix at the runtime-function level (the
|
|
16
|
+
* layer BarefootJS's compiled SSR output actually calls into) and confirms
|
|
17
|
+
* genuine children+dangerouslySetInnerHTML conflicts are still rejected.
|
|
18
|
+
*/
|
|
19
|
+
import { describe, test, expect } from 'bun:test'
|
|
20
|
+
import { jsx, jsxs } from '../jsx/jsx-runtime/index.ts'
|
|
21
|
+
import { jsxDEV } from '../jsx/jsx-dev-runtime/index.ts'
|
|
22
|
+
|
|
23
|
+
describe('dangerouslySetInnerHTML with no children (#2557)', () => {
|
|
24
|
+
test('jsx: <svg dangerouslySetInnerHTML> with no children does not throw', () => {
|
|
25
|
+
const html = String(jsx('svg', { dangerouslySetInnerHTML: { __html: '<path d="M1"/>' } }))
|
|
26
|
+
expect(html).toBe('<svg><path d="M1"/></svg>')
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
test('jsx: <head dangerouslySetInnerHTML> with no children does not throw', () => {
|
|
30
|
+
const html = String(jsx('head', { dangerouslySetInnerHTML: { __html: '<meta charset="utf-8">' } }))
|
|
31
|
+
expect(html).toBe('<head><meta charset="utf-8"></head>')
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
test('jsx: ordinary tags with dangerouslySetInnerHTML still work', () => {
|
|
35
|
+
const html = String(jsx('div', { dangerouslySetInnerHTML: { __html: '<b>hi</b>' } }))
|
|
36
|
+
expect(html).toBe('<div><b>hi</b></div>')
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
test('jsxs: <svg dangerouslySetInnerHTML> with no children does not throw', () => {
|
|
40
|
+
const html = String(jsxs('svg', { dangerouslySetInnerHTML: { __html: '<circle r="1"/>' } }))
|
|
41
|
+
expect(html).toBe('<svg><circle r="1"/></svg>')
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
test('jsxDEV: <svg dangerouslySetInnerHTML> with no children does not throw', () => {
|
|
45
|
+
const html = String(jsxDEV('svg', { dangerouslySetInnerHTML: { __html: '<rect/>' } }))
|
|
46
|
+
expect(html).toBe('<svg><rect/></svg>')
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
test('genuine conflict — both children and dangerouslySetInnerHTML — still throws', () => {
|
|
50
|
+
expect(() =>
|
|
51
|
+
String(jsx('span', { dangerouslySetInnerHTML: { __html: 'x' }, children: 'real' }))
|
|
52
|
+
).toThrow('Can only set one of `children` or `props.dangerouslySetInnerHTML`.')
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
test('function components receive the caller props untouched', () => {
|
|
56
|
+
// The workaround is scoped to intrinsic string tags: a user component
|
|
57
|
+
// must see exactly what the caller passed (it may forward
|
|
58
|
+
// `dangerouslySetInnerHTML` to an intrinsic element itself).
|
|
59
|
+
const seen: Record<string, unknown>[] = []
|
|
60
|
+
const Widget = (props: Record<string, unknown>) => {
|
|
61
|
+
seen.push(props)
|
|
62
|
+
return jsx('div', { dangerouslySetInnerHTML: props.dangerouslySetInnerHTML })
|
|
63
|
+
}
|
|
64
|
+
const html = String(jsx(Widget, { dangerouslySetInnerHTML: { __html: '<b>fwd</b>' } }))
|
|
65
|
+
expect(html).toBe('<div><b>fwd</b></div>')
|
|
66
|
+
expect(seen).toHaveLength(1)
|
|
67
|
+
expect(seen[0].dangerouslySetInnerHTML).toEqual({ __html: '<b>fwd</b>' })
|
|
68
|
+
expect('children' in seen[0]).toBe(false)
|
|
69
|
+
})
|
|
70
|
+
})
|