@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.
Files changed (38) hide show
  1. package/dist/adapter/hono-adapter.d.ts +67 -2
  2. package/dist/adapter/hono-adapter.d.ts.map +1 -1
  3. package/dist/adapter/index.js +46 -187398
  4. package/dist/app.js +0 -71
  5. package/dist/async.js +0 -71
  6. package/dist/client-shim.js +0 -71
  7. package/dist/dev-worker.js +0 -71
  8. package/dist/dialog-context.js +0 -71
  9. package/dist/index.js +46 -187398
  10. package/dist/jsx/jsx-dev-runtime/index.d.ts +3 -1
  11. package/dist/jsx/jsx-dev-runtime/index.d.ts.map +1 -1
  12. package/dist/jsx/jsx-dev-runtime/index.js +14 -69
  13. package/dist/jsx/jsx-runtime/index.d.ts +4 -1
  14. package/dist/jsx/jsx-runtime/index.d.ts.map +1 -1
  15. package/dist/jsx/jsx-runtime/index.js +24 -69
  16. package/dist/jsx/resolve-dangerously-set-inner-html.d.ts +2 -0
  17. package/dist/jsx/resolve-dangerously-set-inner-html.d.ts.map +1 -0
  18. package/dist/portal-ssr.js +0 -71
  19. package/dist/portals.js +0 -71
  20. package/dist/preload.js +0 -71
  21. package/dist/render.js +0 -71
  22. package/dist/request-env.js +0 -71
  23. package/dist/scripts.d.ts +3 -2
  24. package/dist/scripts.d.ts.map +1 -1
  25. package/dist/scripts.js +0 -71
  26. package/dist/utils.js +0 -71
  27. package/dist/vite.js +313 -142
  28. package/package.json +2 -2
  29. package/src/__tests__/aliased-destructured-prop.test.ts +8 -7
  30. package/src/__tests__/consumer-typecheck.test.ts +403 -0
  31. package/src/__tests__/corpus-typecheck.test.ts +130 -0
  32. package/src/__tests__/dangerously-set-inner-html.test.ts +70 -0
  33. package/src/__tests__/nested-ternary-bare-branch.test.ts +70 -0
  34. package/src/adapter/hono-adapter.ts +123 -166
  35. package/src/jsx/jsx-dev-runtime/index.ts +13 -1
  36. package/src/jsx/jsx-runtime/index.ts +24 -1
  37. package/src/jsx/resolve-dangerously-set-inner-html.ts +34 -0
  38. 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.0",
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.0",
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.<localName>` (props-extraction
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 LOCAL key (`count`) — the rename only affects the
105
- // caller-facing key, not the local binding the hydration bridge uses.
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 LOCAL
124
- // binding name, which is what the generated client JS's
125
- // `const count = _p.count` extraction reads.
126
- expect(html).toMatch(/bf-p="[^"]*count[^"]*7/)
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
+ })