@barefootjs/hono 0.6.1 → 0.8.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/adapter/hono-adapter.d.ts.map +1 -1
- package/dist/adapter/index.js +6 -3
- package/dist/async.d.ts +10 -1
- package/dist/async.d.ts.map +1 -1
- package/dist/async.js +7 -2
- package/dist/build.js +6 -3
- package/dist/index.js +6 -3
- package/package.json +1 -1
- package/src/__tests__/async-error-boundary.test.ts +111 -0
- package/src/__tests__/async.test.tsx +128 -9
- package/src/adapter/hono-adapter.ts +23 -4
- package/src/async.tsx +26 -4
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"hono-adapter.d.ts","sourceRoot":"","sources":["../../src/adapter/hono-adapter.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,EACL,KAAK,WAAW,EAChB,KAAK,MAAM,EACX,KAAK,SAAS,EACd,KAAK,MAAM,EACX,KAAK,YAAY,EACjB,KAAK,aAAa,EAClB,KAAK,MAAM,EACX,KAAK,WAAW,EAChB,KAAK,UAAU,EACf,KAAK,aAAa,EAClB,KAAK,UAAU,EACf,KAAK,OAAO,EACZ,KAAK,MAAM,EAIX,KAAK,sBAAsB,EAC3B,KAAK,aAAa,EAElB,KAAK,gBAAgB,EACrB,KAAK,aAAa,EAClB,KAAK,UAAU,EAEf,UAAU,EAMX,MAAM,iBAAiB,CAAA;AAExB;;;;;GAKG;AACH,KAAK,aAAa,GAAG;IACnB,uBAAuB,CAAC,EAAE,OAAO,CAAA;IACjC,YAAY,CAAC,EAAE,OAAO,CAAA;IACtB,cAAc,CAAC,EAAE,OAAO,CAAA;CACzB,CAAA;AAGD,MAAM,WAAW,kBAAkB;IACjC;;;OAGG;IACH,gBAAgB,CAAC,EAAE,MAAM,CAAA;IAEzB;;OAEG;IACH,cAAc,CAAC,EAAE,MAAM,CAAA;IAEvB;;;;OAIG;IACH,gBAAgB,CAAC,EAAE,MAAM,CAAA;IAEzB;;;;;;OAMG;IACH,IAAI,CAAC,EAAE,MAAM,CAAA;CACd;AAuBD,qBAAa,WAAY,SAAQ,UAAW,YAAW,aAAa,CAAC,aAAa,CAAC;IACjF,IAAI,SAAS;IACb,SAAS,SAAS;IAClB,gBAAgB,SAAiC;IAGjD,kBAAkB,EAAG,WAAW,CAAS;IAmBzC,mBAAmB,QAAO,OAAO,CAAQ;IAEzC,SAAS,CAAC,SAAS,EAAE,gBAAgB,CAA0B;IAE/D,OAAO,CAAC,OAAO,CAAoB;IACnC,OAAO,CAAC,iBAAiB,CAAiB;IAC1C,OAAO,CAAC,sBAAsB,CAAiB;IAC/C,OAAO,CAAC,wBAAwB,CAAiB;IACjD;;;;;;;OAOG;IACH,OAAO,CAAC,qBAAqB,CAAC,CAAgC;IAC9D,uFAAuF;IACvF,OAAO,CAAC,YAAY,CAAmD;IAEvE,YAAY,OAAO,GAAE,kBAAuB,EAQ3C;IAED,QAAQ,CAAC,EAAE,EAAE,WAAW,EAAE,OAAO,CAAC,EAAE,sBAAsB,GAAG,aAAa,CAyCzE;IAED,OAAO,CAAC,kCAAkC;IAkB1C,OAAO,CAAC,eAAe;
|
|
1
|
+
{"version":3,"file":"hono-adapter.d.ts","sourceRoot":"","sources":["../../src/adapter/hono-adapter.ts"],"names":[],"mappings":"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"}
|
package/dist/adapter/index.js
CHANGED
|
@@ -100,8 +100,11 @@ export default ${this.componentName}` : "";
|
|
|
100
100
|
if (utilImports.length > 0) {
|
|
101
101
|
lines.push(`import { ${utilImports.join(", ")} } from '@barefootjs/hono/utils'`);
|
|
102
102
|
}
|
|
103
|
-
if (componentCode.includes("<
|
|
104
|
-
lines.push(`import { Suspense } from 'hono/jsx/streaming'`);
|
|
103
|
+
if (componentCode.includes("<__BfSuspense")) {
|
|
104
|
+
lines.push(`import { Suspense as __BfSuspense } from 'hono/jsx/streaming'`);
|
|
105
|
+
}
|
|
106
|
+
if (componentCode.includes("<__BfErrorBoundary")) {
|
|
107
|
+
lines.push(`import { ErrorBoundary as __BfErrorBoundary } from 'hono/jsx'`);
|
|
105
108
|
}
|
|
106
109
|
const templateImports = rewriteImportsForTemplate(ir.metadata.templateImports, this.clientShimSource, this.rewriteRelativeImport);
|
|
107
110
|
for (const imp of templateImports) {
|
|
@@ -518,7 +521,7 @@ export default ${this.componentName}` : "";
|
|
|
518
521
|
renderAsync(node) {
|
|
519
522
|
const fallback = this.renderNode(node.fallback);
|
|
520
523
|
const children = this.renderChildren(node.children);
|
|
521
|
-
return `<
|
|
524
|
+
return `<__BfErrorBoundary fallback={<>${fallback}</>}>` + `<__BfSuspense fallback={<>${fallback}</>}>${children}</__BfSuspense>` + `</__BfErrorBoundary>`;
|
|
522
525
|
}
|
|
523
526
|
renderComponent(comp, ctx) {
|
|
524
527
|
const props = this.renderComponentProps(comp);
|
package/dist/async.d.ts
CHANGED
|
@@ -4,12 +4,21 @@ export interface BfAsyncProps {
|
|
|
4
4
|
fallback: Child;
|
|
5
5
|
/** Async children that will be streamed when resolved. */
|
|
6
6
|
children: Child;
|
|
7
|
+
/**
|
|
8
|
+
* Optional observer invoked when the body throws synchronously or its
|
|
9
|
+
* async resolution rejects. The `fallback` is rendered either way; this
|
|
10
|
+
* hook only exists so the error isn't swallowed silently (logging,
|
|
11
|
+
* metrics). It does not change what is rendered.
|
|
12
|
+
*/
|
|
13
|
+
onError?: (error: Error) => void;
|
|
7
14
|
}
|
|
8
15
|
/**
|
|
9
16
|
* Async streaming boundary component.
|
|
10
17
|
*
|
|
11
18
|
* Renders fallback content immediately (sent in the initial HTTP response
|
|
12
|
-
* for fast TTFB), then streams the resolved children when ready.
|
|
19
|
+
* for fast TTFB), then streams the resolved children when ready. If the
|
|
20
|
+
* body throws (sync) or rejects (async), the same fallback is rendered via
|
|
21
|
+
* the surrounding `ErrorBoundary`.
|
|
13
22
|
*/
|
|
14
23
|
export declare function BfAsync(props: BfAsyncProps): import("hono/jsx/jsx-dev-runtime").JSX.Element;
|
|
15
24
|
//# sourceMappingURL=async.d.ts.map
|
package/dist/async.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"async.d.ts","sourceRoot":"","sources":["../src/async.tsx"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"async.d.ts","sourceRoot":"","sources":["../src/async.tsx"],"names":[],"mappings":"AA4CA,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,UAAU,CAAA;AAErC,MAAM,WAAW,YAAY;IAC3B,+DAA+D;IAC/D,QAAQ,EAAE,KAAK,CAAA;IACf,0DAA0D;IAC1D,QAAQ,EAAE,KAAK,CAAA;IACf;;;;;OAKG;IACH,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,KAAK,KAAK,IAAI,CAAA;CACjC;AAED;;;;;;;GAOG;AACH,wBAAgB,OAAO,CAAC,KAAK,EAAE,YAAY,kDAQ1C"}
|
package/dist/async.js
CHANGED
|
@@ -1,10 +1,15 @@
|
|
|
1
1
|
// src/async.tsx
|
|
2
2
|
import { Suspense } from "hono/jsx/streaming";
|
|
3
|
+
import { ErrorBoundary } from "hono/jsx";
|
|
3
4
|
import { jsxDEV } from "hono/jsx/jsx-dev-runtime";
|
|
4
5
|
function BfAsync(props) {
|
|
5
|
-
return /* @__PURE__ */ jsxDEV(
|
|
6
|
+
return /* @__PURE__ */ jsxDEV(ErrorBoundary, {
|
|
6
7
|
fallback: props.fallback,
|
|
7
|
-
|
|
8
|
+
onError: props.onError,
|
|
9
|
+
children: /* @__PURE__ */ jsxDEV(Suspense, {
|
|
10
|
+
fallback: props.fallback,
|
|
11
|
+
children: props.children
|
|
12
|
+
}, undefined, false, undefined, this)
|
|
8
13
|
}, undefined, false, undefined, this);
|
|
9
14
|
}
|
|
10
15
|
export {
|
package/dist/build.js
CHANGED
|
@@ -100,8 +100,11 @@ export default ${this.componentName}` : "";
|
|
|
100
100
|
if (utilImports.length > 0) {
|
|
101
101
|
lines.push(`import { ${utilImports.join(", ")} } from '@barefootjs/hono/utils'`);
|
|
102
102
|
}
|
|
103
|
-
if (componentCode.includes("<
|
|
104
|
-
lines.push(`import { Suspense } from 'hono/jsx/streaming'`);
|
|
103
|
+
if (componentCode.includes("<__BfSuspense")) {
|
|
104
|
+
lines.push(`import { Suspense as __BfSuspense } from 'hono/jsx/streaming'`);
|
|
105
|
+
}
|
|
106
|
+
if (componentCode.includes("<__BfErrorBoundary")) {
|
|
107
|
+
lines.push(`import { ErrorBoundary as __BfErrorBoundary } from 'hono/jsx'`);
|
|
105
108
|
}
|
|
106
109
|
const templateImports = rewriteImportsForTemplate(ir.metadata.templateImports, this.clientShimSource, this.rewriteRelativeImport);
|
|
107
110
|
for (const imp of templateImports) {
|
|
@@ -518,7 +521,7 @@ export default ${this.componentName}` : "";
|
|
|
518
521
|
renderAsync(node) {
|
|
519
522
|
const fallback = this.renderNode(node.fallback);
|
|
520
523
|
const children = this.renderChildren(node.children);
|
|
521
|
-
return `<
|
|
524
|
+
return `<__BfErrorBoundary fallback={<>${fallback}</>}>` + `<__BfSuspense fallback={<>${fallback}</>}>${children}</__BfSuspense>` + `</__BfErrorBoundary>`;
|
|
522
525
|
}
|
|
523
526
|
renderComponent(comp, ctx) {
|
|
524
527
|
const props = this.renderComponentProps(comp);
|
package/dist/index.js
CHANGED
|
@@ -100,8 +100,11 @@ export default ${this.componentName}` : "";
|
|
|
100
100
|
if (utilImports.length > 0) {
|
|
101
101
|
lines.push(`import { ${utilImports.join(", ")} } from '@barefootjs/hono/utils'`);
|
|
102
102
|
}
|
|
103
|
-
if (componentCode.includes("<
|
|
104
|
-
lines.push(`import { Suspense } from 'hono/jsx/streaming'`);
|
|
103
|
+
if (componentCode.includes("<__BfSuspense")) {
|
|
104
|
+
lines.push(`import { Suspense as __BfSuspense } from 'hono/jsx/streaming'`);
|
|
105
|
+
}
|
|
106
|
+
if (componentCode.includes("<__BfErrorBoundary")) {
|
|
107
|
+
lines.push(`import { ErrorBoundary as __BfErrorBoundary } from 'hono/jsx'`);
|
|
105
108
|
}
|
|
106
109
|
const templateImports = rewriteImportsForTemplate(ir.metadata.templateImports, this.clientShimSource, this.rewriteRelativeImport);
|
|
107
110
|
for (const imp of templateImports) {
|
|
@@ -518,7 +521,7 @@ export default ${this.componentName}` : "";
|
|
|
518
521
|
renderAsync(node) {
|
|
519
522
|
const fallback = this.renderNode(node.fallback);
|
|
520
523
|
const children = this.renderChildren(node.children);
|
|
521
|
-
return `<
|
|
524
|
+
return `<__BfErrorBoundary fallback={<>${fallback}</>}>` + `<__BfSuspense fallback={<>${fallback}</>}>${children}</__BfSuspense>` + `</__BfErrorBoundary>`;
|
|
522
525
|
}
|
|
523
526
|
renderComponent(comp, ctx) {
|
|
524
527
|
const props = this.renderComponentProps(comp);
|
package/package.json
CHANGED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hono adapter: compiled `<Async>` boundary wraps its body in ErrorBoundary
|
|
3
|
+
* for the body error path (#1375).
|
|
4
|
+
*
|
|
5
|
+
* The compiler lowers `<Async fallback={…}>…</Async>` to an `IRAsync` node;
|
|
6
|
+
* the Hono adapter's `renderAsync` emits it into the generated SSR template.
|
|
7
|
+
* A bare `<Suspense>` mishandles a body that fails at render time: a
|
|
8
|
+
* synchronous throw aborts the stream with empty output, and a Promise
|
|
9
|
+
* rejection during async resolution escapes as an unhandled rejection while
|
|
10
|
+
* the loading fallback is left stranded.
|
|
11
|
+
*
|
|
12
|
+
* The fix wraps the streaming body in Hono's `ErrorBoundary` with the same
|
|
13
|
+
* `fallback`, mirroring the hand-written `BfAsync` runtime component
|
|
14
|
+
* (`../async.tsx`). The runtime behaviour itself is asserted in
|
|
15
|
+
* `async.test.tsx`; this test pins the *emit shape* of the compiled path.
|
|
16
|
+
*/
|
|
17
|
+
import { describe, test, expect } from 'bun:test'
|
|
18
|
+
import { compileJSX } from '@barefootjs/jsx'
|
|
19
|
+
import { HonoAdapter } from '../adapter'
|
|
20
|
+
|
|
21
|
+
const adapter = new HonoAdapter()
|
|
22
|
+
|
|
23
|
+
function templateOf(source: string): string {
|
|
24
|
+
const result = compileJSX(source, 'Page.tsx', { adapter })
|
|
25
|
+
expect(result.errors.filter((e) => e.severity === 'error')).toEqual([])
|
|
26
|
+
return result.files.find((f) => f.type === 'markedTemplate')?.content ?? ''
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
describe('compiled <Async> boundary — ErrorBoundary wrapping (#1375)', () => {
|
|
30
|
+
test('emits ErrorBoundary enclosing Suspense, sharing the fallback', () => {
|
|
31
|
+
const template = templateOf(`
|
|
32
|
+
export function Page() {
|
|
33
|
+
return (
|
|
34
|
+
<div>
|
|
35
|
+
<Async fallback={<p>Loading...</p>}>
|
|
36
|
+
<span>Resolved</span>
|
|
37
|
+
</Async>
|
|
38
|
+
</div>
|
|
39
|
+
)
|
|
40
|
+
}
|
|
41
|
+
`)
|
|
42
|
+
|
|
43
|
+
// Tags are emitted under `__Bf`-prefixed aliases so they can't collide
|
|
44
|
+
// with a user component named `ErrorBoundary` / `Suspense` (#1375).
|
|
45
|
+
expect(template).toContain('<__BfErrorBoundary fallback={<>')
|
|
46
|
+
expect(template).toContain('<__BfSuspense fallback={<>')
|
|
47
|
+
// ErrorBoundary must enclose Suspense (catch errors raised while the
|
|
48
|
+
// Suspense body resolves), not the reverse.
|
|
49
|
+
expect(template.indexOf('<__BfErrorBoundary')).toBeLessThan(template.indexOf('<__BfSuspense'))
|
|
50
|
+
expect(template).toContain('</__BfSuspense></__BfErrorBoundary>')
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
test('injects the aliased ErrorBoundary import alongside Suspense', () => {
|
|
54
|
+
const template = templateOf(`
|
|
55
|
+
export function Page() {
|
|
56
|
+
return (
|
|
57
|
+
<Async fallback={<p>Loading...</p>}>
|
|
58
|
+
<span>Resolved</span>
|
|
59
|
+
</Async>
|
|
60
|
+
)
|
|
61
|
+
}
|
|
62
|
+
`)
|
|
63
|
+
|
|
64
|
+
expect(template).toContain(`import { Suspense as __BfSuspense } from 'hono/jsx/streaming'`)
|
|
65
|
+
expect(template).toContain(`import { ErrorBoundary as __BfErrorBoundary } from 'hono/jsx'`)
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
test('aliasing avoids a duplicate import when the body uses a user component named ErrorBoundary', () => {
|
|
69
|
+
// Regression for the bare-name collision: a user component literally
|
|
70
|
+
// named `ErrorBoundary` used inside the boundary must not cause the
|
|
71
|
+
// injected Hono import to duplicate the user's own `ErrorBoundary`
|
|
72
|
+
// binding (which would be a "Duplicate identifier" build error).
|
|
73
|
+
const template = templateOf(`
|
|
74
|
+
import { ErrorBoundary } from './my-error-boundary'
|
|
75
|
+
export function Page() {
|
|
76
|
+
return (
|
|
77
|
+
<Async fallback={<ErrorBoundary><p>fail</p></ErrorBoundary>}>
|
|
78
|
+
<span>Resolved</span>
|
|
79
|
+
</Async>
|
|
80
|
+
)
|
|
81
|
+
}
|
|
82
|
+
`)
|
|
83
|
+
|
|
84
|
+
// Exactly one `ErrorBoundary` binding survives — the user's. The Hono
|
|
85
|
+
// wrapper imports under the `__BfErrorBoundary` alias, so no collision.
|
|
86
|
+
const bareImports = template.match(/import \{ ErrorBoundary \} from/g) ?? []
|
|
87
|
+
expect(bareImports.length).toBe(1)
|
|
88
|
+
expect(template).toContain(`import { ErrorBoundary } from './my-error-boundary'`)
|
|
89
|
+
expect(template).toContain(`import { ErrorBoundary as __BfErrorBoundary } from 'hono/jsx'`)
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
test('each of multiple boundaries gets its own ErrorBoundary wrapper', () => {
|
|
93
|
+
const template = templateOf(`
|
|
94
|
+
export function Dashboard() {
|
|
95
|
+
return (
|
|
96
|
+
<div>
|
|
97
|
+
<Async fallback={<p>A...</p>}>
|
|
98
|
+
<span>A</span>
|
|
99
|
+
</Async>
|
|
100
|
+
<Async fallback={<p>B...</p>}>
|
|
101
|
+
<span>B</span>
|
|
102
|
+
</Async>
|
|
103
|
+
</div>
|
|
104
|
+
)
|
|
105
|
+
}
|
|
106
|
+
`)
|
|
107
|
+
|
|
108
|
+
const wrappers = template.match(/<__BfErrorBoundary fallback=\{<>/g) ?? []
|
|
109
|
+
expect(wrappers.length).toBe(2)
|
|
110
|
+
})
|
|
111
|
+
})
|
|
@@ -33,13 +33,15 @@ describe('BfAsync', () => {
|
|
|
33
33
|
</BfAsync>
|
|
34
34
|
)
|
|
35
35
|
|
|
36
|
-
const
|
|
36
|
+
const fullOutput = (await collectStream(stream)).join('')
|
|
37
37
|
|
|
38
|
-
//
|
|
39
|
-
|
|
40
|
-
//
|
|
41
|
-
|
|
38
|
+
// Fallback streams first, resolved content swaps in afterwards. The
|
|
39
|
+
// body is wrapped in an ErrorBoundary (#1375) whose template/swap
|
|
40
|
+
// markers now lead the stream, so assert ordering across the full
|
|
41
|
+
// output rather than pinning the fallback to a literal `chunks[0]`.
|
|
42
|
+
expect(fullOutput).toContain('Loading...')
|
|
42
43
|
expect(fullOutput).toContain('Loaded!')
|
|
44
|
+
expect(fullOutput.indexOf('Loading...')).toBeLessThan(fullOutput.indexOf('Loaded!'))
|
|
43
45
|
})
|
|
44
46
|
|
|
45
47
|
it('renders multiple async boundaries independently', async () => {
|
|
@@ -89,7 +91,7 @@ describe('BfAsync', () => {
|
|
|
89
91
|
expect(fullOutput).toContain('bf-p=')
|
|
90
92
|
})
|
|
91
93
|
|
|
92
|
-
it('renders synchronous children without
|
|
94
|
+
it('renders synchronous children without a fallback flash', async () => {
|
|
93
95
|
const SyncContent = () => <div>Already here</div>
|
|
94
96
|
|
|
95
97
|
const stream = renderToReadableStream(
|
|
@@ -98,9 +100,126 @@ describe('BfAsync', () => {
|
|
|
98
100
|
</BfAsync>
|
|
99
101
|
)
|
|
100
102
|
|
|
101
|
-
const
|
|
103
|
+
const fullOutput = (await collectStream(stream)).join('')
|
|
104
|
+
|
|
105
|
+
// Synchronous content needs no fallback round-trip: it resolves in the
|
|
106
|
+
// initial flush and the loading fallback never appears. The
|
|
107
|
+
// ErrorBoundary wrapper (#1375) routes the content through a
|
|
108
|
+
// template/swap chunk, so it is no longer literally `chunks[0]`, but
|
|
109
|
+
// there is still no `Loading...` flash.
|
|
110
|
+
expect(fullOutput).toContain('Already here')
|
|
111
|
+
expect(fullOutput).not.toContain('Loading...')
|
|
112
|
+
})
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
// ---------------------------------------------------------------------------
|
|
116
|
+
// Error paths (#1375): a body that throws — synchronously or asynchronously.
|
|
117
|
+
//
|
|
118
|
+
// A bare `<Suspense>` mishandles both: a synchronous throw aborts the stream
|
|
119
|
+
// with empty output (no fallback), and a rejection during async resolution
|
|
120
|
+
// escapes as an unhandled rejection while the loading fallback is stranded.
|
|
121
|
+
// `BfAsync` wraps the body in Hono's `ErrorBoundary` so the same `fallback`
|
|
122
|
+
// is rendered on either failure and the error never leaks.
|
|
123
|
+
//
|
|
124
|
+
// These are the two cases the Layer 1 / streaming layer can observe directly
|
|
125
|
+
// (a single SSR render). The remaining catalog cases — reset-signal re-mount
|
|
126
|
+
// and throw-during-cleanup — are client-lifecycle behaviours covered by the
|
|
127
|
+
// Layer 6 fixme stubs in `site/ui/e2e/stress-1244.spec.ts`.
|
|
128
|
+
// ---------------------------------------------------------------------------
|
|
129
|
+
|
|
130
|
+
describe('BfAsync — error paths (#1375)', () => {
|
|
131
|
+
// A render that errors must not surface a stray unhandled rejection on the
|
|
132
|
+
// process: install a guard for the duration of each test and assert it
|
|
133
|
+
// stayed quiet. `BfAsync`'s ErrorBoundary is what keeps it quiet.
|
|
134
|
+
function trackUnhandledRejections(): { count: () => number; restore: () => void } {
|
|
135
|
+
let n = 0
|
|
136
|
+
const onRejection = () => {
|
|
137
|
+
n++
|
|
138
|
+
}
|
|
139
|
+
process.on('unhandledRejection', onRejection)
|
|
140
|
+
return {
|
|
141
|
+
count: () => n,
|
|
142
|
+
restore: () => process.off('unhandledRejection', onRejection),
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
it('synchronous throw in the body renders the fallback (not empty output)', async () => {
|
|
147
|
+
const Boom = (): HtmlEscapedString => {
|
|
148
|
+
throw new Error('sync-boom')
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const guard = trackUnhandledRejections()
|
|
152
|
+
let onErrorMessage: string | undefined
|
|
153
|
+
try {
|
|
154
|
+
const stream = renderToReadableStream(
|
|
155
|
+
<BfAsync
|
|
156
|
+
fallback={<p>Fallback shown</p>}
|
|
157
|
+
onError={(e) => {
|
|
158
|
+
onErrorMessage = e.message
|
|
159
|
+
}}
|
|
160
|
+
>
|
|
161
|
+
<Boom />
|
|
162
|
+
</BfAsync>
|
|
163
|
+
)
|
|
164
|
+
const fullOutput = (await collectStream(stream)).join('')
|
|
165
|
+
|
|
166
|
+
// The fallback is rendered (a bare <Suspense> would yield empty output).
|
|
167
|
+
expect(fullOutput).toContain('Fallback shown')
|
|
168
|
+
// onError observed the failure so it isn't swallowed silently.
|
|
169
|
+
expect(onErrorMessage).toBe('sync-boom')
|
|
170
|
+
} finally {
|
|
171
|
+
guard.restore()
|
|
172
|
+
}
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
it('async rejection during resolution renders the fallback and leaks no rejection', async () => {
|
|
176
|
+
const Rejects = (): Promise<HtmlEscapedString> =>
|
|
177
|
+
new Promise((_resolve, reject) =>
|
|
178
|
+
setTimeout(() => reject(new Error('async-boom')), 10)
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
const guard = trackUnhandledRejections()
|
|
182
|
+
let onErrorMessage: string | undefined
|
|
183
|
+
try {
|
|
184
|
+
const stream = renderToReadableStream(
|
|
185
|
+
<BfAsync
|
|
186
|
+
fallback={<p>Fallback shown</p>}
|
|
187
|
+
onError={(e) => {
|
|
188
|
+
onErrorMessage = e.message
|
|
189
|
+
}}
|
|
190
|
+
>
|
|
191
|
+
<Rejects />
|
|
192
|
+
</BfAsync>
|
|
193
|
+
)
|
|
194
|
+
const fullOutput = (await collectStream(stream)).join('')
|
|
195
|
+
|
|
196
|
+
// Fallback is the final rendered state; the rejected content never
|
|
197
|
+
// mutates into the DOM late.
|
|
198
|
+
expect(fullOutput).toContain('Fallback shown')
|
|
199
|
+
expect(onErrorMessage).toBe('async-boom')
|
|
200
|
+
|
|
201
|
+
// Give any stray rejection a tick to surface, then assert none did.
|
|
202
|
+
await new Promise((r) => setTimeout(r, 0))
|
|
203
|
+
expect(guard.count()).toBe(0)
|
|
204
|
+
} finally {
|
|
205
|
+
guard.restore()
|
|
206
|
+
}
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
it('the happy path still streams resolved content past the error boundary', async () => {
|
|
210
|
+
// Wrapping the body in ErrorBoundary must not regress normal streaming.
|
|
211
|
+
const AsyncContent = () =>
|
|
212
|
+
new Promise<HtmlEscapedString>((resolve) =>
|
|
213
|
+
setTimeout(() => resolve(<div>Loaded past boundary</div>), 10)
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
const stream = renderToReadableStream(
|
|
217
|
+
<BfAsync fallback={<p>Loading...</p>}>
|
|
218
|
+
<AsyncContent />
|
|
219
|
+
</BfAsync>
|
|
220
|
+
)
|
|
102
221
|
|
|
103
|
-
|
|
104
|
-
expect(
|
|
222
|
+
const fullOutput = (await collectStream(stream)).join('')
|
|
223
|
+
expect(fullOutput).toContain('Loaded past boundary')
|
|
105
224
|
})
|
|
106
225
|
})
|
|
@@ -229,9 +229,16 @@ export class HonoAdapter extends JsxAdapter implements IRNodeEmitter<HonoRenderC
|
|
|
229
229
|
lines.push(`import { ${utilImports.join(', ')} } from '@barefootjs/hono/utils'`)
|
|
230
230
|
}
|
|
231
231
|
|
|
232
|
-
// Import Suspense when async boundaries are used
|
|
233
|
-
|
|
234
|
-
|
|
232
|
+
// Import Suspense / ErrorBoundary when async boundaries are used. Both
|
|
233
|
+
// are imported under `__Bf`-prefixed aliases (and emitted as such by
|
|
234
|
+
// `renderAsync`) so the generated tags can never collide with a user
|
|
235
|
+
// component literally named `Suspense` or `ErrorBoundary` — a bare-name
|
|
236
|
+
// import would otherwise duplicate the user's own import binding (#1375).
|
|
237
|
+
if (componentCode.includes('<__BfSuspense')) {
|
|
238
|
+
lines.push(`import { Suspense as __BfSuspense } from 'hono/jsx/streaming'`)
|
|
239
|
+
}
|
|
240
|
+
if (componentCode.includes('<__BfErrorBoundary')) {
|
|
241
|
+
lines.push(`import { ErrorBoundary as __BfErrorBoundary } from 'hono/jsx'`)
|
|
235
242
|
}
|
|
236
243
|
|
|
237
244
|
// Re-emit template imports, rewriting `@barefootjs/client` to this
|
|
@@ -962,7 +969,19 @@ export class HonoAdapter extends JsxAdapter implements IRNodeEmitter<HonoRenderC
|
|
|
962
969
|
renderAsync(node: IRAsync): string {
|
|
963
970
|
const fallback = this.renderNode(node.fallback)
|
|
964
971
|
const children = this.renderChildren(node.children)
|
|
965
|
-
|
|
972
|
+
// Wrap the streaming body in an ErrorBoundary so a synchronous throw or
|
|
973
|
+
// a rejected Promise during async resolution falls back to the same
|
|
974
|
+
// `fallback` instead of aborting the stream / leaking an unhandled
|
|
975
|
+
// rejection. Mirrors the runtime `BfAsync` component (#1375).
|
|
976
|
+
//
|
|
977
|
+
// Both tags use `__Bf`-prefixed aliases (see the import injection above)
|
|
978
|
+
// so they can't collide with a user component named `Suspense` /
|
|
979
|
+
// `ErrorBoundary` in the same module.
|
|
980
|
+
return (
|
|
981
|
+
`<__BfErrorBoundary fallback={<>${fallback}</>}>` +
|
|
982
|
+
`<__BfSuspense fallback={<>${fallback}</>}>${children}</__BfSuspense>` +
|
|
983
|
+
`</__BfErrorBoundary>`
|
|
984
|
+
)
|
|
966
985
|
}
|
|
967
986
|
|
|
968
987
|
renderComponent(comp: IRComponent, ctx?: { isRootOfClientComponent?: boolean; isInsideLoop?: boolean; isLoopItemRoot?: boolean }): string {
|
package/src/async.tsx
CHANGED
|
@@ -14,6 +14,16 @@
|
|
|
14
14
|
* Suspense/streaming. The BarefootJS hydration runtime automatically picks
|
|
15
15
|
* up components rendered inside async boundaries via requestAnimationFrame.
|
|
16
16
|
*
|
|
17
|
+
* The body is additionally wrapped in Hono's `ErrorBoundary` so that a
|
|
18
|
+
* failure during render — a synchronous `throw` in a child, or a rejected
|
|
19
|
+
* Promise during async resolution — surfaces the same `fallback` instead
|
|
20
|
+
* of producing empty output (sync case) or leaking an unhandled rejection
|
|
21
|
+
* (async case). Without the boundary a bare `<Suspense>` does neither:
|
|
22
|
+
* a synchronous throw aborts the stream with no fallback, and a rejection
|
|
23
|
+
* during streaming escapes as an unhandled rejection while the loading
|
|
24
|
+
* fallback is left stranded. See `__tests__/async.test.tsx` error-path
|
|
25
|
+
* cases (#1375).
|
|
26
|
+
*
|
|
17
27
|
* Usage:
|
|
18
28
|
* ```tsx
|
|
19
29
|
* import { BfAsync } from '@barefootjs/hono/async'
|
|
@@ -31,6 +41,7 @@
|
|
|
31
41
|
*/
|
|
32
42
|
|
|
33
43
|
import { Suspense } from 'hono/jsx/streaming'
|
|
44
|
+
import { ErrorBoundary } from 'hono/jsx'
|
|
34
45
|
import type { Child } from 'hono/jsx'
|
|
35
46
|
|
|
36
47
|
export interface BfAsyncProps {
|
|
@@ -38,18 +49,29 @@ export interface BfAsyncProps {
|
|
|
38
49
|
fallback: Child
|
|
39
50
|
/** Async children that will be streamed when resolved. */
|
|
40
51
|
children: Child
|
|
52
|
+
/**
|
|
53
|
+
* Optional observer invoked when the body throws synchronously or its
|
|
54
|
+
* async resolution rejects. The `fallback` is rendered either way; this
|
|
55
|
+
* hook only exists so the error isn't swallowed silently (logging,
|
|
56
|
+
* metrics). It does not change what is rendered.
|
|
57
|
+
*/
|
|
58
|
+
onError?: (error: Error) => void
|
|
41
59
|
}
|
|
42
60
|
|
|
43
61
|
/**
|
|
44
62
|
* Async streaming boundary component.
|
|
45
63
|
*
|
|
46
64
|
* Renders fallback content immediately (sent in the initial HTTP response
|
|
47
|
-
* for fast TTFB), then streams the resolved children when ready.
|
|
65
|
+
* for fast TTFB), then streams the resolved children when ready. If the
|
|
66
|
+
* body throws (sync) or rejects (async), the same fallback is rendered via
|
|
67
|
+
* the surrounding `ErrorBoundary`.
|
|
48
68
|
*/
|
|
49
69
|
export function BfAsync(props: BfAsyncProps) {
|
|
50
70
|
return (
|
|
51
|
-
<
|
|
52
|
-
{props.
|
|
53
|
-
|
|
71
|
+
<ErrorBoundary fallback={props.fallback} onError={props.onError}>
|
|
72
|
+
<Suspense fallback={props.fallback}>
|
|
73
|
+
{props.children}
|
|
74
|
+
</Suspense>
|
|
75
|
+
</ErrorBoundary>
|
|
54
76
|
)
|
|
55
77
|
}
|