acrylblends 0.6.1 → 0.6.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +11 -6
- package/docs/framework/README.md +3 -2
- package/docs/framework/UPSTREAM.md +1 -1
- package/docs/framework/api/presets.md +0 -3
- package/docs/framework/cordis/acryl-in-a-cordis-app.md +9 -0
- package/docs/framework/getting-started/basic.md +5 -4
- package/docs/framework/getting-started/desktop.md +41 -0
- package/docs/framework/guides/jsx-dom.md +0 -3
- package/docs/framework/guides/jsx.md +1 -4
- package/docs/framework/guides/middleware.md +1 -1
- package/docs/framework/helpers/cookie.md +1 -1
- package/docs/framework/helpers/css.md +0 -3
- package/docs/framework/helpers/dev.md +0 -3
- package/docs/framework/helpers/jwt.md +1 -1
- package/docs/framework/helpers/proxy.md +0 -3
- package/docs/framework/middleware/builtin/basic-auth.md +6 -6
- package/docs/framework/middleware/builtin/cache.md +0 -3
- package/docs/framework/middleware/builtin/combine.md +1 -1
- package/docs/framework/middleware/builtin/ip-restriction.md +1 -1
- package/docs/framework/middleware/builtin/logger.md +1 -1
- package/docs/framework/middleware/builtin/mount.md +0 -3
- package/docs/framework/upstream.json +3 -3
- package/lib/accepts.d.ts +1 -1
- package/lib/acryl-base.d.ts +1 -1
- package/lib/adapter.d.ts +1 -1
- package/lib/{app-CmAYW1Al.d.ts → app-wFwpg-bt.d.ts} +1 -1
- package/lib/base-BpQy2Rk7.d.ts +783 -0
- package/lib/base-ddiVmiE7.js +721 -0
- package/lib/basic-auth.d.ts +1 -1
- package/lib/bearer-auth.d.ts +1 -1
- package/lib/body-limit.d.ts +1 -1
- package/lib/cache.d.ts +43 -0
- package/lib/cache.js +169 -0
- package/lib/client.d.ts +2 -2
- package/lib/combine.d.ts +1 -1
- package/lib/common-D8Z4JRCw.d.ts +46 -0
- package/lib/components-Ce5FRyHU.js +268 -0
- package/lib/components-DYZdxJIJ.js +24 -0
- package/lib/compress.d.ts +1 -1
- package/lib/conninfo.d.ts +2 -2
- package/lib/constants-BR9jM9BC.js +9 -0
- package/lib/{context-Bm7rI-oK.d.ts → context-DVQBjuns.d.ts} +1 -1
- package/lib/context-DxFdY_un.d.ts +250 -0
- package/lib/context-storage.d.ts +1 -1
- package/lib/{cookie-ClMaKDQf.d.ts → cookie-CCelEqky.d.ts} +1 -1
- package/lib/cookie.d.ts +3 -3
- package/lib/cors.d.ts +1 -1
- package/lib/csrf.d.ts +1 -1
- package/lib/css-D2jb7X8b.js +320 -0
- package/lib/css.d.ts +82 -0
- package/lib/css.js +134 -0
- package/lib/dev.d.ts +19 -0
- package/lib/dev.js +50 -0
- package/lib/etag.d.ts +1 -1
- package/lib/factory.d.ts +2 -2
- package/lib/hooks-Bq5q9IMl.js +762 -0
- package/lib/html-SllAe0YT.d.ts +46 -0
- package/lib/html.d.ts +1 -1
- package/lib/index-C850MeBA.d.ts +94 -0
- package/lib/{index-oOClm_Xk.d.ts → index-CUTfU69R.d.ts} +3 -3
- package/lib/{index-B-3F4wt2.d.ts → index-Cqcdluyn.d.ts} +2 -2
- package/lib/{index-UTBmM3GX.d.ts → index-CsvB-F9j.d.ts} +2 -2
- package/lib/index.d.ts +4 -4
- package/lib/ip-restriction.d.ts +2 -2
- package/lib/jsx/dom/client.d.ts +30 -0
- package/lib/jsx/dom/client.js +49 -0
- package/lib/jsx/dom/css.d.ts +84 -0
- package/lib/jsx/dom/css.js +2 -0
- package/lib/jsx/dom/jsx-dev-runtime.d.ts +3 -0
- package/lib/jsx/dom/jsx-dev-runtime.js +2 -0
- package/lib/jsx/dom/jsx-runtime.d.ts +3 -0
- package/lib/jsx/dom/jsx-runtime.js +2 -0
- package/lib/jsx/dom/server.d.ts +105 -0
- package/lib/jsx/dom/server.js +37 -0
- package/lib/jsx/dom.d.ts +5 -0
- package/lib/jsx/dom.js +74 -0
- package/lib/jsx/jsx-dev-runtime.d.ts +6 -0
- package/lib/jsx/jsx-dev-runtime.js +14 -0
- package/lib/jsx/jsx-runtime.d.ts +10 -0
- package/lib/jsx/jsx-runtime.js +30 -0
- package/lib/jsx/streaming.d.ts +2 -0
- package/lib/jsx/streaming.js +2 -0
- package/lib/jsx-dev-runtime-B22O-Npk.d.ts +7 -0
- package/lib/jsx-dev-runtime-kAhR_4C7.js +354 -0
- package/lib/jsx-runtime-BVVgDSdq.d.ts +1 -0
- package/lib/jsx.d.ts +3 -0
- package/lib/jsx.js +47 -0
- package/lib/jwk.d.ts +4 -4
- package/lib/{jwt-Dg493Tai.d.ts → jwt-BARhotDz.d.ts} +1 -1
- package/lib/jwt.d.ts +4 -4
- package/lib/language.d.ts +1 -1
- package/lib/linear-router-C3FPib6h.js +96 -0
- package/lib/logger.d.ts +1 -1
- package/lib/loopback-only.d.ts +1 -1
- package/lib/method-not-allowed.d.ts +2 -2
- package/lib/method-override.d.ts +2 -2
- package/lib/mount.d.ts +44 -0
- package/lib/mount.js +67 -0
- package/lib/node-server/conninfo.d.ts +1 -1
- package/lib/node-server/serve-static.d.ts +1 -1
- package/lib/node-server/serve-static.js +1 -1
- package/lib/node-server.d.ts +1 -1
- package/lib/node-server.js +1 -1
- package/lib/pattern-router-HZxjDGEQ.js +41 -0
- package/lib/powered-by.d.ts +1 -1
- package/lib/pretty-json.d.ts +1 -1
- package/lib/proxy.d.ts +72 -0
- package/lib/proxy.js +121 -0
- package/lib/quick.d.ts +8 -0
- package/lib/quick.js +17 -0
- package/lib/request-id.d.ts +1 -1
- package/lib/request.d.ts +1 -1
- package/lib/route.d.ts +1 -1
- package/lib/router/linear-router.js +1 -95
- package/lib/router/pattern-router.js +1 -40
- package/lib/schema-validator.d.ts +1 -1
- package/lib/secure-headers.d.ts +1 -1
- package/lib/{serve-static-Dw_m6KxR.js → serve-static-Cw23shXv.js} +1 -0
- package/lib/serve-static.d.ts +1 -1
- package/lib/streaming.d.ts +1 -1
- package/lib/testing.d.ts +4 -4
- package/lib/timeout.d.ts +1 -1
- package/lib/timing.d.ts +1 -1
- package/lib/tiny.d.ts +8 -0
- package/lib/tiny.js +15 -0
- package/lib/trailing-slash.d.ts +1 -1
- package/lib/{types-CHesD1KG.d.ts → types-CQiGmsWW.d.ts} +3 -3
- package/lib/{types-CwrQjlaM.d.ts → types-Lqx5BxQF.d.ts} +10 -1
- package/lib/{types-CDO6I3B0.d.ts → types-nEL8VPDC.d.ts} +1 -1
- package/lib/types.d.ts +1 -1
- package/lib/utils/body.d.ts +1 -1
- package/lib/utils/cookie.d.ts +1 -1
- package/lib/utils/crypto.d.ts +1 -1
- package/lib/utils/html.d.ts +1 -45
- package/lib/utils/ipaddr.d.ts +1 -1
- package/lib/utils/jwt.d.ts +1 -1
- package/lib/utils/types.d.ts +2 -2
- package/lib/validator.d.ts +2 -2
- package/lib/ws.d.ts +1 -1
- package/package.json +69 -1
- package/plugins/acryl-inspect/upstream.json +1 -1
- package/plugins/acryl-node-server/upstream.json +1 -1
- package/plugins/acryl-router/src/core/helper/css/common.case.test.tsx +506 -0
- package/plugins/acryl-router/src/core/helper/css/common.ts +337 -0
- package/plugins/acryl-router/src/core/helper/css/index.test.tsx +678 -0
- package/plugins/acryl-router/src/core/helper/css/index.ts +262 -0
- package/plugins/acryl-router/src/core/helper/dev/index.test.ts +181 -0
- package/plugins/acryl-router/src/core/helper/dev/index.ts +79 -0
- package/plugins/acryl-router/src/core/helper/proxy/index.test.ts +356 -0
- package/plugins/acryl-router/src/core/helper/proxy/index.ts +203 -0
- package/plugins/acryl-router/src/core/helper/streaming/sse.test.tsx +557 -0
- package/plugins/acryl-router/src/core/jsx/base.test.tsx +97 -0
- package/plugins/acryl-router/src/core/jsx/base.ts +490 -0
- package/plugins/acryl-router/src/core/jsx/children.test.ts +52 -0
- package/plugins/acryl-router/src/core/jsx/children.ts +20 -0
- package/plugins/acryl-router/src/core/jsx/components.test.tsx +936 -0
- package/plugins/acryl-router/src/core/jsx/components.ts +257 -0
- package/plugins/acryl-router/src/core/jsx/constants.ts +6 -0
- package/plugins/acryl-router/src/core/jsx/context-isolation.test.ts +655 -0
- package/plugins/acryl-router/src/core/jsx/context.ts +266 -0
- package/plugins/acryl-router/src/core/jsx/dom/client.test.tsx +128 -0
- package/plugins/acryl-router/src/core/jsx/dom/client.ts +89 -0
- package/plugins/acryl-router/src/core/jsx/dom/components.test.tsx +330 -0
- package/plugins/acryl-router/src/core/jsx/dom/components.ts +39 -0
- package/plugins/acryl-router/src/core/jsx/dom/context.test.tsx +184 -0
- package/plugins/acryl-router/src/core/jsx/dom/context.ts +52 -0
- package/plugins/acryl-router/src/core/jsx/dom/css.test.tsx +149 -0
- package/plugins/acryl-router/src/core/jsx/dom/css.ts +263 -0
- package/plugins/acryl-router/src/core/jsx/dom/hooks/index.test.tsx +172 -0
- package/plugins/acryl-router/src/core/jsx/dom/hooks/index.ts +91 -0
- package/plugins/acryl-router/src/core/jsx/dom/index.test.tsx +3681 -0
- package/plugins/acryl-router/src/core/jsx/dom/index.ts +170 -0
- package/plugins/acryl-router/src/core/jsx/dom/intrinsic-element/components.test.tsx +1344 -0
- package/plugins/acryl-router/src/core/jsx/dom/intrinsic-element/components.ts +438 -0
- package/plugins/acryl-router/src/core/jsx/dom/jsx-dev-runtime.ts +23 -0
- package/plugins/acryl-router/src/core/jsx/dom/jsx-runtime.ts +8 -0
- package/plugins/acryl-router/src/core/jsx/dom/render.ts +879 -0
- package/plugins/acryl-router/src/core/jsx/dom/server.test.tsx +199 -0
- package/plugins/acryl-router/src/core/jsx/dom/server.ts +76 -0
- package/plugins/acryl-router/src/core/jsx/dom/utils.ts +7 -0
- package/plugins/acryl-router/src/core/jsx/hooks/dom.test.tsx +859 -0
- package/plugins/acryl-router/src/core/jsx/hooks/index.ts +437 -0
- package/plugins/acryl-router/src/core/jsx/hooks/string.test.tsx +40 -0
- package/plugins/acryl-router/src/core/jsx/index.test.tsx +1744 -0
- package/plugins/acryl-router/src/core/jsx/index.ts +115 -0
- package/plugins/acryl-router/src/core/jsx/intrinsic-element/common.ts +23 -0
- package/plugins/acryl-router/src/core/jsx/intrinsic-element/components.test.tsx +666 -0
- package/plugins/acryl-router/src/core/jsx/intrinsic-element/components.ts +210 -0
- package/plugins/acryl-router/src/core/jsx/intrinsic-elements.ts +938 -0
- package/plugins/acryl-router/src/core/jsx/jsx-dev-runtime.ts +26 -0
- package/plugins/acryl-router/src/core/jsx/jsx-runtime.test.tsx +85 -0
- package/plugins/acryl-router/src/core/jsx/jsx-runtime.ts +51 -0
- package/plugins/acryl-router/src/core/jsx/streaming.test.tsx +1281 -0
- package/plugins/acryl-router/src/core/jsx/streaming.ts +231 -0
- package/plugins/acryl-router/src/core/jsx/types.ts +41 -0
- package/plugins/acryl-router/src/core/jsx/utils.test.ts +366 -0
- package/plugins/acryl-router/src/core/jsx/utils.ts +202 -0
- package/plugins/acryl-router/src/core/middleware/cache/index.test.ts +1211 -0
- package/plugins/acryl-router/src/core/middleware/cache/index.ts +323 -0
- package/plugins/acryl-router/src/core/middleware/mount/index.test.ts +334 -0
- package/plugins/acryl-router/src/core/middleware/mount/index.ts +108 -0
- package/plugins/acryl-router/src/core/preset/quick.test.ts +42 -0
- package/plugins/acryl-router/src/core/preset/quick.ts +24 -0
- package/plugins/acryl-router/src/core/preset/tiny.test.ts +42 -0
- package/plugins/acryl-router/src/core/preset/tiny.ts +20 -0
- package/plugins/acryl-router/src/core/utils/types.ts +9 -0
- package/plugins/acryl-router/src/helpers/index.ts +3 -0
- package/plugins/acryl-router/src/middleware/index.ts +2 -0
- package/plugins/acryl-router/src/service.ts +22 -5
- package/plugins/acryl-router/tests/jsx.spec.tsx +63 -0
- package/plugins/acryl-router/upstream.json +3 -3
- package/plugins/acryl-router-http/upstream.json +1 -1
- package/plugins/acryl-state/upstream.json +1 -1
- package/plugins/desktop/upstream.json +1 -1
- package/plugins/desktop-web/upstream.json +1 -1
- package/plugins/save/plugin.json +1 -1
- package/templates/todo/upstream.json +1 -1
|
@@ -0,0 +1,1281 @@
|
|
|
1
|
+
/** @jsxImportSource ./ */
|
|
2
|
+
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
3
|
+
import { JSDOM } from 'jsdom'
|
|
4
|
+
import { raw } from '../helper/html/index.ts'
|
|
5
|
+
import { HtmlEscapedCallbackPhase, resolveCallback } from '../utils/html.ts'
|
|
6
|
+
import type { HtmlEscapedString } from '../utils/html.ts'
|
|
7
|
+
import { createContext, useContext } from './context.ts'
|
|
8
|
+
import { buildDataStack } from './dom/render.ts'
|
|
9
|
+
import { use } from './hooks/index.ts'
|
|
10
|
+
import { Suspense, renderToReadableStream, StreamingContext } from './streaming.ts'
|
|
11
|
+
|
|
12
|
+
const unsafeHtml = '<img src=x onerror=alert(1)>'
|
|
13
|
+
const escapedUnsafeHtml = '<img src=x onerror=alert(1)>'
|
|
14
|
+
|
|
15
|
+
function replacementResult(html: string) {
|
|
16
|
+
const document = new JSDOM(html, { runScripts: 'dangerously' }).window.document
|
|
17
|
+
document.querySelectorAll('template, script').forEach((e) => e.remove())
|
|
18
|
+
return document.body.innerHTML
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async function drainStream(stream: unknown): Promise<string> {
|
|
22
|
+
const textDecoder = new TextDecoder()
|
|
23
|
+
let html = ''
|
|
24
|
+
for await (const chunk of stream as any) {
|
|
25
|
+
html += textDecoder.decode(chunk)
|
|
26
|
+
}
|
|
27
|
+
return html
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function stringify(node: { toString(): string | Promise<string> }): Promise<string> {
|
|
31
|
+
return String(
|
|
32
|
+
await resolveCallback(await node.toString(), HtmlEscapedCallbackPhase.Stringify, false, {})
|
|
33
|
+
)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async function readInitialSuspenseChunk(fallback: unknown): Promise<string> {
|
|
37
|
+
let resolve!: () => void
|
|
38
|
+
const promise = new Promise<void>((done) => (resolve = done))
|
|
39
|
+
const Async = async () => {
|
|
40
|
+
await promise
|
|
41
|
+
return <span>done</span>
|
|
42
|
+
}
|
|
43
|
+
const reader = renderToReadableStream(
|
|
44
|
+
<Suspense fallback={fallback}>
|
|
45
|
+
<Async />
|
|
46
|
+
</Suspense>
|
|
47
|
+
).getReader()
|
|
48
|
+
|
|
49
|
+
const first = await reader.read()
|
|
50
|
+
resolve()
|
|
51
|
+
while (!(await reader.read()).done) {
|
|
52
|
+
// Drain the retry so it cannot outlive the test.
|
|
53
|
+
}
|
|
54
|
+
return new TextDecoder().decode(first.value)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
describe('Streaming', () => {
|
|
58
|
+
let suspenseCounter = 0
|
|
59
|
+
afterEach(() => {
|
|
60
|
+
suspenseCounter++
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('Suspense / async component returning an array', async () => {
|
|
64
|
+
const Content = async () => {
|
|
65
|
+
await new Promise((resolve) => setTimeout(resolve, 10))
|
|
66
|
+
return [<h1>Hello</h1>, <h2>World</h2>]
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const stream = renderToReadableStream(
|
|
70
|
+
<Suspense fallback={<p>Loading...</p>}>
|
|
71
|
+
<Content />
|
|
72
|
+
</Suspense>
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
const chunks = []
|
|
76
|
+
const textDecoder = new TextDecoder()
|
|
77
|
+
for await (const chunk of stream as any) {
|
|
78
|
+
chunks.push(textDecoder.decode(chunk))
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
expect(chunks[0]).toBe(
|
|
82
|
+
`<template id="H:${suspenseCounter}"></template><p>Loading...</p><!--/$-->`
|
|
83
|
+
)
|
|
84
|
+
expect(chunks[1]).toContain(
|
|
85
|
+
`<template data-acryl-target="H:${suspenseCounter}"><h1>Hello</h1><h2>World</h2></template>`
|
|
86
|
+
)
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it('preserves callbacks in a streamed async component array', async () => {
|
|
90
|
+
const phases: number[] = []
|
|
91
|
+
const Content = async () => [
|
|
92
|
+
raw('Hello', [({ phase }) => void phases.push(phase)]),
|
|
93
|
+
<span>World</span>,
|
|
94
|
+
]
|
|
95
|
+
|
|
96
|
+
const html = await drainStream(renderToReadableStream(<Content />))
|
|
97
|
+
|
|
98
|
+
expect(html).toBe('Hello<span>World</span>')
|
|
99
|
+
expect(phases).toEqual([HtmlEscapedCallbackPhase.BeforeStream, HtmlEscapedCallbackPhase.Stream])
|
|
100
|
+
suspenseCounter--
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
it('Suspense / renderToReadableStream', async () => {
|
|
104
|
+
let contentEvaluatedCount = 0
|
|
105
|
+
const Content = () => {
|
|
106
|
+
contentEvaluatedCount++
|
|
107
|
+
const content = new Promise<HtmlEscapedString>((resolve) =>
|
|
108
|
+
setTimeout(() => resolve(<h1>Hello</h1>), 10)
|
|
109
|
+
)
|
|
110
|
+
return content
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const stream = renderToReadableStream(
|
|
114
|
+
<Suspense fallback={<p>Loading...</p>}>
|
|
115
|
+
<Content />
|
|
116
|
+
</Suspense>
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
const chunks = []
|
|
120
|
+
const textDecoder = new TextDecoder()
|
|
121
|
+
for await (const chunk of stream as any) {
|
|
122
|
+
chunks.push(textDecoder.decode(chunk))
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
expect(chunks).toEqual([
|
|
126
|
+
`<template id="H:${suspenseCounter}"></template><p>Loading...</p><!--/$-->`,
|
|
127
|
+
`<template data-acryl-target="H:${suspenseCounter}"><h1>Hello</h1></template><script>
|
|
128
|
+
((d,c,n) => {
|
|
129
|
+
c=d.currentScript.previousSibling
|
|
130
|
+
d=d.getElementById('H:${suspenseCounter}')
|
|
131
|
+
if(!d)return
|
|
132
|
+
do{n=d.nextSibling;n.remove()}while(n.nodeType!=8||n.nodeValue!='/$')
|
|
133
|
+
d.replaceWith(c.content)
|
|
134
|
+
})(document)
|
|
135
|
+
</script>`,
|
|
136
|
+
])
|
|
137
|
+
|
|
138
|
+
expect(replacementResult(`<html><body>${chunks.join('')}</body></html>`)).toEqual(
|
|
139
|
+
'<h1>Hello</h1>'
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
expect(contentEvaluatedCount).toEqual(1)
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
it('Suspense with StreamingContext', async () => {
|
|
146
|
+
let contentEvaluatedCount = 0
|
|
147
|
+
const Content = () => {
|
|
148
|
+
contentEvaluatedCount++
|
|
149
|
+
const content = new Promise<HtmlEscapedString>((resolve) =>
|
|
150
|
+
setTimeout(() => resolve(<h1>Hello</h1>), 10)
|
|
151
|
+
)
|
|
152
|
+
return content
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const stream = renderToReadableStream(
|
|
156
|
+
<StreamingContext value={{ scriptNonce: 'test-nonce' }}>
|
|
157
|
+
<Suspense fallback={<p>Loading...</p>}>
|
|
158
|
+
<Content />
|
|
159
|
+
</Suspense>
|
|
160
|
+
</StreamingContext>
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
const chunks = []
|
|
164
|
+
const textDecoder = new TextDecoder()
|
|
165
|
+
for await (const chunk of stream as any) {
|
|
166
|
+
chunks.push(textDecoder.decode(chunk))
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
expect(chunks).toEqual([
|
|
170
|
+
`<template id="H:${suspenseCounter}"></template><p>Loading...</p><!--/$-->`,
|
|
171
|
+
`<template data-acryl-target="H:${suspenseCounter}"><h1>Hello</h1></template><script nonce="test-nonce">
|
|
172
|
+
((d,c,n) => {
|
|
173
|
+
c=d.currentScript.previousSibling
|
|
174
|
+
d=d.getElementById('H:${suspenseCounter}')
|
|
175
|
+
if(!d)return
|
|
176
|
+
do{n=d.nextSibling;n.remove()}while(n.nodeType!=8||n.nodeValue!='/$')
|
|
177
|
+
d.replaceWith(c.content)
|
|
178
|
+
})(document)
|
|
179
|
+
</script>`,
|
|
180
|
+
])
|
|
181
|
+
|
|
182
|
+
expect(replacementResult(`<html><body>${chunks.join('')}</body></html>`)).toEqual(
|
|
183
|
+
'<h1>Hello</h1>'
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
expect(contentEvaluatedCount).toEqual(1)
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
it('`throw promise` inside Suspense', async () => {
|
|
190
|
+
let contentEvaluatedCount = 0
|
|
191
|
+
let resolvedContent: HtmlEscapedString | undefined = undefined
|
|
192
|
+
const Content = () => {
|
|
193
|
+
contentEvaluatedCount++
|
|
194
|
+
if (!resolvedContent) {
|
|
195
|
+
throw new Promise<void>((resolve) =>
|
|
196
|
+
setTimeout(() => {
|
|
197
|
+
resolvedContent = (<p>thrown a promise then resolved</p>) as HtmlEscapedString
|
|
198
|
+
resolve()
|
|
199
|
+
}, 10)
|
|
200
|
+
)
|
|
201
|
+
}
|
|
202
|
+
return resolvedContent
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const stream = renderToReadableStream(
|
|
206
|
+
<Suspense fallback={<p>Loading...</p>}>
|
|
207
|
+
<Content />
|
|
208
|
+
</Suspense>
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
const chunks = []
|
|
212
|
+
const textDecoder = new TextDecoder()
|
|
213
|
+
for await (const chunk of stream as any) {
|
|
214
|
+
chunks.push(textDecoder.decode(chunk))
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
expect(chunks).toEqual([
|
|
218
|
+
`<template id="H:${suspenseCounter}"></template><p>Loading...</p><!--/$-->`,
|
|
219
|
+
`<template data-acryl-target="H:${suspenseCounter}"><p>thrown a promise then resolved</p></template><script>
|
|
220
|
+
((d,c,n) => {
|
|
221
|
+
c=d.currentScript.previousSibling
|
|
222
|
+
d=d.getElementById('H:${suspenseCounter}')
|
|
223
|
+
if(!d)return
|
|
224
|
+
do{n=d.nextSibling;n.remove()}while(n.nodeType!=8||n.nodeValue!='/$')
|
|
225
|
+
d.replaceWith(c.content)
|
|
226
|
+
})(document)
|
|
227
|
+
</script>`,
|
|
228
|
+
])
|
|
229
|
+
|
|
230
|
+
expect(replacementResult(`<html><body>${chunks.join('')}</body></html>`)).toEqual(
|
|
231
|
+
'<p>thrown a promise then resolved</p>'
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
expect(contentEvaluatedCount).toEqual(2)
|
|
235
|
+
})
|
|
236
|
+
|
|
237
|
+
it('simple content inside Suspense', async () => {
|
|
238
|
+
const Content = () => {
|
|
239
|
+
return <h1>Hello</h1>
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const stream = renderToReadableStream(
|
|
243
|
+
<Suspense fallback={<p>Loading...</p>}>
|
|
244
|
+
<Content />
|
|
245
|
+
</Suspense>
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
const chunks = []
|
|
249
|
+
const textDecoder = new TextDecoder()
|
|
250
|
+
for await (const chunk of stream as any) {
|
|
251
|
+
chunks.push(textDecoder.decode(chunk))
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
expect(chunks).toEqual(['<h1>Hello</h1>'])
|
|
255
|
+
|
|
256
|
+
suspenseCounter -= 1 // fallback is not rendered
|
|
257
|
+
})
|
|
258
|
+
|
|
259
|
+
it('nullish children', async () => {
|
|
260
|
+
const stream = renderToReadableStream(
|
|
261
|
+
<div>
|
|
262
|
+
<Suspense fallback={<p>Loading...</p>}>{[null, undefined]}</Suspense>
|
|
263
|
+
</div>
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
const chunks = []
|
|
267
|
+
const textDecoder = new TextDecoder()
|
|
268
|
+
for await (const chunk of stream as any) {
|
|
269
|
+
chunks.push(textDecoder.decode(chunk))
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
expect(chunks).toEqual(['<div></div>'])
|
|
273
|
+
|
|
274
|
+
suspenseCounter -= 1 // fallback is not rendered
|
|
275
|
+
})
|
|
276
|
+
|
|
277
|
+
it('without children', async () => {
|
|
278
|
+
const stream = renderToReadableStream(
|
|
279
|
+
<div>
|
|
280
|
+
<Suspense fallback={<p>Loading...</p>}></Suspense>
|
|
281
|
+
</div>
|
|
282
|
+
)
|
|
283
|
+
|
|
284
|
+
const chunks = []
|
|
285
|
+
const textDecoder = new TextDecoder()
|
|
286
|
+
for await (const chunk of stream as any) {
|
|
287
|
+
chunks.push(textDecoder.decode(chunk))
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
expect(chunks).toEqual(['<div></div>'])
|
|
291
|
+
|
|
292
|
+
suspenseCounter -= 1 // fallback is not rendered
|
|
293
|
+
})
|
|
294
|
+
|
|
295
|
+
it('only a "false" child', async () => {
|
|
296
|
+
const stream = renderToReadableStream(
|
|
297
|
+
<div>
|
|
298
|
+
<Suspense fallback={<p>Loading...</p>}>{false}</Suspense>
|
|
299
|
+
</div>
|
|
300
|
+
)
|
|
301
|
+
|
|
302
|
+
const chunks = []
|
|
303
|
+
const textDecoder = new TextDecoder()
|
|
304
|
+
for await (const chunk of stream as any) {
|
|
305
|
+
chunks.push(textDecoder.decode(chunk))
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
expect(chunks).toEqual(['<div></div>'])
|
|
309
|
+
|
|
310
|
+
suspenseCounter -= 1 // fallback is not rendered
|
|
311
|
+
})
|
|
312
|
+
|
|
313
|
+
it('async nullish children', async () => {
|
|
314
|
+
let resolved = false
|
|
315
|
+
const Content = () => {
|
|
316
|
+
if (!resolved) {
|
|
317
|
+
resolved = true
|
|
318
|
+
throw new Promise<void>((r) =>
|
|
319
|
+
setTimeout(() => {
|
|
320
|
+
resolved = true
|
|
321
|
+
r()
|
|
322
|
+
}, 10)
|
|
323
|
+
)
|
|
324
|
+
}
|
|
325
|
+
return <h1>Hello</h1>
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
const stream = renderToReadableStream(
|
|
329
|
+
<Suspense fallback={<p>Loading...</p>}>
|
|
330
|
+
<Content />
|
|
331
|
+
{[null, undefined]}
|
|
332
|
+
</Suspense>
|
|
333
|
+
)
|
|
334
|
+
|
|
335
|
+
const chunks = []
|
|
336
|
+
const textDecoder = new TextDecoder()
|
|
337
|
+
for await (const chunk of stream as any) {
|
|
338
|
+
chunks.push(textDecoder.decode(chunk))
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
expect(chunks).toEqual([
|
|
342
|
+
`<template id="H:${suspenseCounter}"></template><p>Loading...</p><!--/$-->`,
|
|
343
|
+
`<template data-acryl-target="H:${suspenseCounter}"><h1>Hello</h1></template><script>
|
|
344
|
+
((d,c,n) => {
|
|
345
|
+
c=d.currentScript.previousSibling
|
|
346
|
+
d=d.getElementById('H:${suspenseCounter}')
|
|
347
|
+
if(!d)return
|
|
348
|
+
do{n=d.nextSibling;n.remove()}while(n.nodeType!=8||n.nodeValue!='/$')
|
|
349
|
+
d.replaceWith(c.content)
|
|
350
|
+
})(document)
|
|
351
|
+
</script>`,
|
|
352
|
+
])
|
|
353
|
+
|
|
354
|
+
expect(replacementResult(`<html><body>${chunks.join('')}</body></html>`)).toEqual(
|
|
355
|
+
'<h1>Hello</h1>'
|
|
356
|
+
)
|
|
357
|
+
})
|
|
358
|
+
|
|
359
|
+
it('boolean children', async () => {
|
|
360
|
+
const stream = renderToReadableStream(
|
|
361
|
+
<div>
|
|
362
|
+
<Suspense fallback={<p>Loading...</p>}>{[true, false]}</Suspense>
|
|
363
|
+
</div>
|
|
364
|
+
)
|
|
365
|
+
|
|
366
|
+
const chunks = []
|
|
367
|
+
const textDecoder = new TextDecoder()
|
|
368
|
+
for await (const chunk of stream as any) {
|
|
369
|
+
chunks.push(textDecoder.decode(chunk))
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
expect(chunks).toEqual(['<div></div>'])
|
|
373
|
+
|
|
374
|
+
suspenseCounter -= 1 // fallback is not rendered
|
|
375
|
+
})
|
|
376
|
+
|
|
377
|
+
it('async boolean children', async () => {
|
|
378
|
+
let resolved = false
|
|
379
|
+
const Content = () => {
|
|
380
|
+
if (!resolved) {
|
|
381
|
+
resolved = true
|
|
382
|
+
throw new Promise<void>((r) =>
|
|
383
|
+
setTimeout(() => {
|
|
384
|
+
resolved = true
|
|
385
|
+
r()
|
|
386
|
+
}, 10)
|
|
387
|
+
)
|
|
388
|
+
}
|
|
389
|
+
return <h1>Hello</h1>
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
const stream = renderToReadableStream(
|
|
393
|
+
<Suspense fallback={<p>Loading...</p>}>
|
|
394
|
+
<Content />
|
|
395
|
+
{[true, false]}
|
|
396
|
+
</Suspense>
|
|
397
|
+
)
|
|
398
|
+
|
|
399
|
+
const chunks = []
|
|
400
|
+
const textDecoder = new TextDecoder()
|
|
401
|
+
for await (const chunk of stream as any) {
|
|
402
|
+
chunks.push(textDecoder.decode(chunk))
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
expect(chunks).toEqual([
|
|
406
|
+
`<template id="H:${suspenseCounter}"></template><p>Loading...</p><!--/$-->`,
|
|
407
|
+
`<template data-acryl-target="H:${suspenseCounter}"><h1>Hello</h1></template><script>
|
|
408
|
+
((d,c,n) => {
|
|
409
|
+
c=d.currentScript.previousSibling
|
|
410
|
+
d=d.getElementById('H:${suspenseCounter}')
|
|
411
|
+
if(!d)return
|
|
412
|
+
do{n=d.nextSibling;n.remove()}while(n.nodeType!=8||n.nodeValue!='/$')
|
|
413
|
+
d.replaceWith(c.content)
|
|
414
|
+
})(document)
|
|
415
|
+
</script>`,
|
|
416
|
+
])
|
|
417
|
+
|
|
418
|
+
expect(replacementResult(`<html><body>${chunks.join('')}</body></html>`)).toEqual(
|
|
419
|
+
'<h1>Hello</h1>'
|
|
420
|
+
)
|
|
421
|
+
})
|
|
422
|
+
|
|
423
|
+
it('children Suspense', async () => {
|
|
424
|
+
const Content1 = () =>
|
|
425
|
+
new Promise<HtmlEscapedString>((resolve) => setTimeout(() => resolve(<h1>Hello</h1>), 10))
|
|
426
|
+
const Content2 = () =>
|
|
427
|
+
new Promise<HtmlEscapedString>((resolve) => setTimeout(() => resolve(<h2>Acryl</h2>), 10))
|
|
428
|
+
|
|
429
|
+
const stream = renderToReadableStream(
|
|
430
|
+
<Suspense fallback={<p>Loading...</p>}>
|
|
431
|
+
<Suspense fallback={<p>Loading sub content1...</p>}>
|
|
432
|
+
<Content1 />
|
|
433
|
+
</Suspense>
|
|
434
|
+
<Suspense fallback={<p>Loading sub content2...</p>}>
|
|
435
|
+
<Content2 />
|
|
436
|
+
</Suspense>
|
|
437
|
+
</Suspense>
|
|
438
|
+
)
|
|
439
|
+
|
|
440
|
+
const chunks = []
|
|
441
|
+
const textDecoder = new TextDecoder()
|
|
442
|
+
for await (const chunk of stream as any) {
|
|
443
|
+
chunks.push(textDecoder.decode(chunk))
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
expect(replacementResult(`<html><body>${chunks.join('')}</body></html>`)).toEqual(
|
|
447
|
+
'<h1>Hello</h1><h2>Acryl</h2>'
|
|
448
|
+
)
|
|
449
|
+
|
|
450
|
+
suspenseCounter += 2
|
|
451
|
+
})
|
|
452
|
+
|
|
453
|
+
it('children Suspense: Suspense and string', async () => {
|
|
454
|
+
const Content1 = () =>
|
|
455
|
+
new Promise<HtmlEscapedString>((resolve) => setTimeout(() => resolve(<h1>Hello</h1>), 10))
|
|
456
|
+
|
|
457
|
+
const stream = renderToReadableStream(
|
|
458
|
+
<Suspense fallback={<p>Loading...</p>}>
|
|
459
|
+
<Suspense fallback={<p>Loading sub content1...</p>}>
|
|
460
|
+
<Content1 />
|
|
461
|
+
</Suspense>
|
|
462
|
+
Acryl
|
|
463
|
+
</Suspense>
|
|
464
|
+
)
|
|
465
|
+
|
|
466
|
+
const chunks = []
|
|
467
|
+
const textDecoder = new TextDecoder()
|
|
468
|
+
for await (const chunk of stream as any) {
|
|
469
|
+
chunks.push(textDecoder.decode(chunk))
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
expect(replacementResult(`<html><body>${chunks.join('')}</body></html>`)).toEqual(
|
|
473
|
+
'<h1>Hello</h1>Acryl'
|
|
474
|
+
)
|
|
475
|
+
|
|
476
|
+
suspenseCounter += 1
|
|
477
|
+
})
|
|
478
|
+
|
|
479
|
+
it('resolve(undefined)', async () => {
|
|
480
|
+
const Content = async () => {
|
|
481
|
+
const content = await Promise.resolve(undefined)
|
|
482
|
+
return <p>{content}</p>
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
const stream = renderToReadableStream(
|
|
486
|
+
<Suspense fallback={<p>Loading...</p>}>
|
|
487
|
+
<Content />
|
|
488
|
+
</Suspense>
|
|
489
|
+
)
|
|
490
|
+
|
|
491
|
+
const chunks = []
|
|
492
|
+
const textDecoder = new TextDecoder()
|
|
493
|
+
for await (const chunk of stream as any) {
|
|
494
|
+
chunks.push(textDecoder.decode(chunk))
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
expect(chunks).toEqual([
|
|
498
|
+
`<template id="H:${suspenseCounter}"></template><p>Loading...</p><!--/$-->`,
|
|
499
|
+
`<template data-acryl-target="H:${suspenseCounter}"><p></p></template><script>
|
|
500
|
+
((d,c,n) => {
|
|
501
|
+
c=d.currentScript.previousSibling
|
|
502
|
+
d=d.getElementById('H:${suspenseCounter}')
|
|
503
|
+
if(!d)return
|
|
504
|
+
do{n=d.nextSibling;n.remove()}while(n.nodeType!=8||n.nodeValue!='/$')
|
|
505
|
+
d.replaceWith(c.content)
|
|
506
|
+
})(document)
|
|
507
|
+
</script>`,
|
|
508
|
+
])
|
|
509
|
+
|
|
510
|
+
expect(replacementResult(`<html><body>${chunks.join('')}</body></html>`)).toEqual('<p></p>')
|
|
511
|
+
})
|
|
512
|
+
|
|
513
|
+
it('resolve(null)', async () => {
|
|
514
|
+
const Content = async () => {
|
|
515
|
+
const content = await Promise.resolve(null)
|
|
516
|
+
return <p>{content}</p>
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
const stream = renderToReadableStream(
|
|
520
|
+
<Suspense fallback={<p>Loading...</p>}>
|
|
521
|
+
<Content />
|
|
522
|
+
</Suspense>
|
|
523
|
+
)
|
|
524
|
+
|
|
525
|
+
const chunks = []
|
|
526
|
+
const textDecoder = new TextDecoder()
|
|
527
|
+
for await (const chunk of stream as any) {
|
|
528
|
+
chunks.push(textDecoder.decode(chunk))
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
expect(chunks).toEqual([
|
|
532
|
+
`<template id="H:${suspenseCounter}"></template><p>Loading...</p><!--/$-->`,
|
|
533
|
+
`<template data-acryl-target="H:${suspenseCounter}"><p></p></template><script>
|
|
534
|
+
((d,c,n) => {
|
|
535
|
+
c=d.currentScript.previousSibling
|
|
536
|
+
d=d.getElementById('H:${suspenseCounter}')
|
|
537
|
+
if(!d)return
|
|
538
|
+
do{n=d.nextSibling;n.remove()}while(n.nodeType!=8||n.nodeValue!='/$')
|
|
539
|
+
d.replaceWith(c.content)
|
|
540
|
+
})(document)
|
|
541
|
+
</script>`,
|
|
542
|
+
])
|
|
543
|
+
|
|
544
|
+
expect(replacementResult(`<html><body>${chunks.join('')}</body></html>`)).toEqual('<p></p>')
|
|
545
|
+
})
|
|
546
|
+
|
|
547
|
+
it('reject()', async () => {
|
|
548
|
+
const Content = async () => {
|
|
549
|
+
const content = await Promise.reject()
|
|
550
|
+
return <p>{content}</p>
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
const onError = vi.fn()
|
|
554
|
+
const stream = renderToReadableStream(
|
|
555
|
+
<Suspense fallback={<p>Loading...</p>}>
|
|
556
|
+
<Content />
|
|
557
|
+
</Suspense>,
|
|
558
|
+
onError
|
|
559
|
+
)
|
|
560
|
+
|
|
561
|
+
const chunks = []
|
|
562
|
+
const textDecoder = new TextDecoder()
|
|
563
|
+
for await (const chunk of stream as any) {
|
|
564
|
+
chunks.push(textDecoder.decode(chunk))
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
expect(onError).toBeCalledTimes(1)
|
|
568
|
+
|
|
569
|
+
expect(chunks).toEqual([
|
|
570
|
+
`<template id="H:${suspenseCounter}"></template><p>Loading...</p><!--/$-->`,
|
|
571
|
+
'',
|
|
572
|
+
])
|
|
573
|
+
|
|
574
|
+
expect(replacementResult(`<html><body>${chunks.join('')}</body></html>`)).toEqual(
|
|
575
|
+
'<p>Loading...</p><!--/$-->'
|
|
576
|
+
)
|
|
577
|
+
})
|
|
578
|
+
|
|
579
|
+
it('closed()', async () => {
|
|
580
|
+
const Content = async () => {
|
|
581
|
+
await new Promise<void>((resolve) =>
|
|
582
|
+
setTimeout(() => {
|
|
583
|
+
vi.spyOn(ReadableStreamDefaultController.prototype, 'enqueue').mockImplementation(() => {
|
|
584
|
+
throw new Error('closed')
|
|
585
|
+
})
|
|
586
|
+
resolve()
|
|
587
|
+
}, 10)
|
|
588
|
+
)
|
|
589
|
+
return <p>content</p>
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
const onError = vi.fn()
|
|
593
|
+
const stream = renderToReadableStream(
|
|
594
|
+
<>
|
|
595
|
+
<Suspense fallback={<p>Loading...</p>}>
|
|
596
|
+
<Content />
|
|
597
|
+
</Suspense>
|
|
598
|
+
<Suspense fallback={<p>Loading...</p>}>
|
|
599
|
+
<Content />
|
|
600
|
+
</Suspense>
|
|
601
|
+
</>,
|
|
602
|
+
onError
|
|
603
|
+
)
|
|
604
|
+
|
|
605
|
+
const chunks = []
|
|
606
|
+
const textDecoder = new TextDecoder()
|
|
607
|
+
for await (const chunk of stream as any) {
|
|
608
|
+
chunks.push(textDecoder.decode(chunk))
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
expect(onError).toBeCalledTimes(1)
|
|
612
|
+
|
|
613
|
+
expect(chunks).toEqual([
|
|
614
|
+
`<template id="H:${suspenseCounter}"></template><p>Loading...</p><!--/$--><template id="H:${
|
|
615
|
+
suspenseCounter + 1
|
|
616
|
+
}"></template><p>Loading...</p><!--/$-->`,
|
|
617
|
+
])
|
|
618
|
+
|
|
619
|
+
expect(replacementResult(`<html><body>${chunks.join('')}</body></html>`)).toEqual(
|
|
620
|
+
'<p>Loading...</p><!--/$--><p>Loading...</p><!--/$-->'
|
|
621
|
+
)
|
|
622
|
+
|
|
623
|
+
suspenseCounter++
|
|
624
|
+
await new Promise((resolve) => setTimeout(resolve, 10))
|
|
625
|
+
vi.restoreAllMocks()
|
|
626
|
+
})
|
|
627
|
+
|
|
628
|
+
it('Multiple "await" call', async () => {
|
|
629
|
+
const delayedContent = new Promise<HtmlEscapedString>((resolve) =>
|
|
630
|
+
setTimeout(() => resolve(<h1>Hello</h1>), 10)
|
|
631
|
+
)
|
|
632
|
+
const delayedContent2 = new Promise<HtmlEscapedString>((resolve) =>
|
|
633
|
+
setTimeout(() => resolve(<h2>World</h2>), 10)
|
|
634
|
+
)
|
|
635
|
+
const Content = async () => {
|
|
636
|
+
const content = await delayedContent
|
|
637
|
+
const content2 = await delayedContent2
|
|
638
|
+
return (
|
|
639
|
+
<>
|
|
640
|
+
{content}
|
|
641
|
+
{content2}
|
|
642
|
+
</>
|
|
643
|
+
)
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
const stream = renderToReadableStream(
|
|
647
|
+
<Suspense fallback={<p>Loading...</p>}>
|
|
648
|
+
<Content />
|
|
649
|
+
</Suspense>
|
|
650
|
+
)
|
|
651
|
+
|
|
652
|
+
const chunks = []
|
|
653
|
+
const textDecoder = new TextDecoder()
|
|
654
|
+
for await (const chunk of stream as any) {
|
|
655
|
+
chunks.push(textDecoder.decode(chunk))
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
expect(chunks).toEqual([
|
|
659
|
+
`<template id="H:${suspenseCounter}"></template><p>Loading...</p><!--/$-->`,
|
|
660
|
+
`<template data-acryl-target="H:${suspenseCounter}"><h1>Hello</h1><h2>World</h2></template><script>
|
|
661
|
+
((d,c,n) => {
|
|
662
|
+
c=d.currentScript.previousSibling
|
|
663
|
+
d=d.getElementById('H:${suspenseCounter}')
|
|
664
|
+
if(!d)return
|
|
665
|
+
do{n=d.nextSibling;n.remove()}while(n.nodeType!=8||n.nodeValue!='/$')
|
|
666
|
+
d.replaceWith(c.content)
|
|
667
|
+
})(document)
|
|
668
|
+
</script>`,
|
|
669
|
+
])
|
|
670
|
+
|
|
671
|
+
expect(replacementResult(`<html><body>${chunks.join('')}</body></html>`)).toEqual(
|
|
672
|
+
'<h1>Hello</h1><h2>World</h2>'
|
|
673
|
+
)
|
|
674
|
+
})
|
|
675
|
+
|
|
676
|
+
it('Complex fallback content', async () => {
|
|
677
|
+
const delayedContent = new Promise<HtmlEscapedString>((resolve) =>
|
|
678
|
+
setTimeout(() => resolve(<h1>Hello</h1>), 10)
|
|
679
|
+
)
|
|
680
|
+
|
|
681
|
+
const Content = async () => {
|
|
682
|
+
const content = await delayedContent
|
|
683
|
+
return content
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
const stream = renderToReadableStream(
|
|
687
|
+
<Suspense
|
|
688
|
+
fallback={
|
|
689
|
+
<>
|
|
690
|
+
Loading<span>...</span>
|
|
691
|
+
</>
|
|
692
|
+
}
|
|
693
|
+
>
|
|
694
|
+
<Content />
|
|
695
|
+
</Suspense>
|
|
696
|
+
)
|
|
697
|
+
|
|
698
|
+
const chunks = []
|
|
699
|
+
const textDecoder = new TextDecoder()
|
|
700
|
+
for await (const chunk of stream as any) {
|
|
701
|
+
chunks.push(textDecoder.decode(chunk))
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
expect(chunks).toEqual([
|
|
705
|
+
`<template id="H:${suspenseCounter}"></template>Loading<span>...</span><!--/$-->`,
|
|
706
|
+
`<template data-acryl-target="H:${suspenseCounter}"><h1>Hello</h1></template><script>
|
|
707
|
+
((d,c,n) => {
|
|
708
|
+
c=d.currentScript.previousSibling
|
|
709
|
+
d=d.getElementById('H:${suspenseCounter}')
|
|
710
|
+
if(!d)return
|
|
711
|
+
do{n=d.nextSibling;n.remove()}while(n.nodeType!=8||n.nodeValue!='/$')
|
|
712
|
+
d.replaceWith(c.content)
|
|
713
|
+
})(document)
|
|
714
|
+
</script>`,
|
|
715
|
+
])
|
|
716
|
+
|
|
717
|
+
expect(replacementResult(`<html><body>${chunks.join('')}</body></html>`)).toEqual(
|
|
718
|
+
'<h1>Hello</h1>'
|
|
719
|
+
)
|
|
720
|
+
})
|
|
721
|
+
|
|
722
|
+
it('renders Suspense fallback with outer context values', async () => {
|
|
723
|
+
const ThemeContext = createContext('default')
|
|
724
|
+
const Fallback = () => <p>Loading {useContext(ThemeContext)}</p>
|
|
725
|
+
const Content = () =>
|
|
726
|
+
new Promise<HtmlEscapedString>((resolve) => setTimeout(() => resolve(<h1>Done</h1>), 10))
|
|
727
|
+
|
|
728
|
+
const stream = renderToReadableStream(
|
|
729
|
+
<ThemeContext.Provider value='outer'>
|
|
730
|
+
<Suspense fallback={<Fallback />}>
|
|
731
|
+
<Content />
|
|
732
|
+
</Suspense>
|
|
733
|
+
</ThemeContext.Provider>
|
|
734
|
+
)
|
|
735
|
+
|
|
736
|
+
const chunks = []
|
|
737
|
+
const textDecoder = new TextDecoder()
|
|
738
|
+
for await (const chunk of stream as any) {
|
|
739
|
+
chunks.push(textDecoder.decode(chunk))
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
expect(chunks[0]).toBe(
|
|
743
|
+
`<template id="H:${suspenseCounter}"></template><p>Loading outer</p><!--/$-->`
|
|
744
|
+
)
|
|
745
|
+
expect(chunks.join('')).toContain('<h1>Done</h1>')
|
|
746
|
+
expect(chunks.join('')).not.toContain('default')
|
|
747
|
+
})
|
|
748
|
+
|
|
749
|
+
it('nested Suspense', async () => {
|
|
750
|
+
const SubContent = () => {
|
|
751
|
+
const content = new Promise<HtmlEscapedString>((resolve) =>
|
|
752
|
+
setTimeout(() => resolve(<h2>World</h2>), 10)
|
|
753
|
+
)
|
|
754
|
+
return content
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
const Content = () => {
|
|
758
|
+
const content = new Promise<HtmlEscapedString>((resolve) =>
|
|
759
|
+
setTimeout(
|
|
760
|
+
() =>
|
|
761
|
+
resolve(
|
|
762
|
+
<>
|
|
763
|
+
<h1>Hello</h1>
|
|
764
|
+
<Suspense fallback={<p>Loading sub content...</p>}>
|
|
765
|
+
<SubContent />
|
|
766
|
+
</Suspense>
|
|
767
|
+
</>
|
|
768
|
+
),
|
|
769
|
+
10
|
|
770
|
+
)
|
|
771
|
+
)
|
|
772
|
+
return content
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
const stream = renderToReadableStream(
|
|
776
|
+
<Suspense fallback={<p>Loading...</p>}>
|
|
777
|
+
<Content />
|
|
778
|
+
</Suspense>
|
|
779
|
+
)
|
|
780
|
+
|
|
781
|
+
const chunks = []
|
|
782
|
+
const textDecoder = new TextDecoder()
|
|
783
|
+
for await (const chunk of stream as any) {
|
|
784
|
+
chunks.push(textDecoder.decode(chunk))
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
expect(chunks).toEqual([
|
|
788
|
+
`<template id="H:${suspenseCounter}"></template><p>Loading...</p><!--/$-->`,
|
|
789
|
+
`<template data-acryl-target="H:${suspenseCounter}"><h1>Hello</h1><template id=\"H:${
|
|
790
|
+
suspenseCounter + 1
|
|
791
|
+
}\"></template><p>Loading sub content...</p><!--/$--></template><script>
|
|
792
|
+
((d,c,n) => {
|
|
793
|
+
c=d.currentScript.previousSibling
|
|
794
|
+
d=d.getElementById('H:${suspenseCounter}')
|
|
795
|
+
if(!d)return
|
|
796
|
+
do{n=d.nextSibling;n.remove()}while(n.nodeType!=8||n.nodeValue!='/$')
|
|
797
|
+
d.replaceWith(c.content)
|
|
798
|
+
})(document)
|
|
799
|
+
</script>`,
|
|
800
|
+
`<template data-acryl-target="H:${suspenseCounter + 1}"><h2>World</h2></template><script>
|
|
801
|
+
((d,c,n) => {
|
|
802
|
+
c=d.currentScript.previousSibling
|
|
803
|
+
d=d.getElementById('H:${suspenseCounter + 1}')
|
|
804
|
+
if(!d)return
|
|
805
|
+
do{n=d.nextSibling;n.remove()}while(n.nodeType!=8||n.nodeValue!='/$')
|
|
806
|
+
d.replaceWith(c.content)
|
|
807
|
+
})(document)
|
|
808
|
+
</script>`,
|
|
809
|
+
])
|
|
810
|
+
|
|
811
|
+
expect(replacementResult(`<html><body>${chunks.join('')}</body></html>`)).toEqual(
|
|
812
|
+
'<h1>Hello</h1><h2>World</h2>'
|
|
813
|
+
)
|
|
814
|
+
suspenseCounter++
|
|
815
|
+
})
|
|
816
|
+
|
|
817
|
+
it('In multiple Suspense, go ahead in the order of resolved', async () => {
|
|
818
|
+
const SubContent2 = () => {
|
|
819
|
+
const content = new Promise<HtmlEscapedString>((resolve) =>
|
|
820
|
+
setTimeout(() => resolve(<p>first</p>), 20)
|
|
821
|
+
)
|
|
822
|
+
return content
|
|
823
|
+
}
|
|
824
|
+
const SubContent1 = () => {
|
|
825
|
+
const content = new Promise<HtmlEscapedString>((resolve) =>
|
|
826
|
+
setTimeout(
|
|
827
|
+
() =>
|
|
828
|
+
resolve(
|
|
829
|
+
<Suspense fallback={<p>Loading content2...</p>}>
|
|
830
|
+
<SubContent2 />
|
|
831
|
+
</Suspense>
|
|
832
|
+
),
|
|
833
|
+
10
|
|
834
|
+
)
|
|
835
|
+
)
|
|
836
|
+
return content
|
|
837
|
+
}
|
|
838
|
+
const SubContent3 = () => {
|
|
839
|
+
const content = new Promise<HtmlEscapedString>((resolve) =>
|
|
840
|
+
setTimeout(() => resolve(<p>last</p>), 40)
|
|
841
|
+
)
|
|
842
|
+
return content
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
const Content = () => {
|
|
846
|
+
const content = new Promise<HtmlEscapedString>((resolve) =>
|
|
847
|
+
setTimeout(
|
|
848
|
+
() =>
|
|
849
|
+
resolve(
|
|
850
|
+
<>
|
|
851
|
+
<Suspense fallback={<p>Loading content1...</p>}>
|
|
852
|
+
<SubContent1 />
|
|
853
|
+
</Suspense>
|
|
854
|
+
<Suspense fallback={<p>Loading content3...</p>}>
|
|
855
|
+
<SubContent3 />
|
|
856
|
+
</Suspense>
|
|
857
|
+
</>
|
|
858
|
+
),
|
|
859
|
+
10
|
|
860
|
+
)
|
|
861
|
+
)
|
|
862
|
+
return content
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
const stream = renderToReadableStream(
|
|
866
|
+
<Suspense fallback={<p>Loading...</p>}>
|
|
867
|
+
<Content />
|
|
868
|
+
</Suspense>
|
|
869
|
+
)
|
|
870
|
+
|
|
871
|
+
const chunks = []
|
|
872
|
+
const textDecoder = new TextDecoder()
|
|
873
|
+
for await (const chunk of stream as any) {
|
|
874
|
+
chunks.push(textDecoder.decode(chunk))
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
expect(replacementResult(`<html><body>${chunks.join('')}</body></html>`)).toEqual(
|
|
878
|
+
'<p>first</p><p>last</p>'
|
|
879
|
+
)
|
|
880
|
+
})
|
|
881
|
+
|
|
882
|
+
it('Suspense with resolveStream', async () => {
|
|
883
|
+
let contentEvaluatedCount = 0
|
|
884
|
+
const Content = () => {
|
|
885
|
+
contentEvaluatedCount++
|
|
886
|
+
const content = new Promise<HtmlEscapedString>((resolve) =>
|
|
887
|
+
setTimeout(() => resolve(<h1>Hello</h1>), 10)
|
|
888
|
+
)
|
|
889
|
+
return content
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
const str = await resolveCallback(
|
|
893
|
+
await (
|
|
894
|
+
<Suspense fallback={<p>Loading...</p>}>
|
|
895
|
+
<Content />
|
|
896
|
+
</Suspense>
|
|
897
|
+
).toString(),
|
|
898
|
+
HtmlEscapedCallbackPhase.Stream,
|
|
899
|
+
false,
|
|
900
|
+
{}
|
|
901
|
+
)
|
|
902
|
+
|
|
903
|
+
expect(str).toEqual('<h1>Hello</h1>')
|
|
904
|
+
expect(contentEvaluatedCount).toEqual(1)
|
|
905
|
+
})
|
|
906
|
+
|
|
907
|
+
it('renderToReadableStream(str: string)', async () => {
|
|
908
|
+
const str = '<h1>Hello</h1>'
|
|
909
|
+
const stream = renderToReadableStream(raw(str))
|
|
910
|
+
|
|
911
|
+
const chunks = []
|
|
912
|
+
const textDecoder = new TextDecoder()
|
|
913
|
+
for await (const chunk of stream as any) {
|
|
914
|
+
chunks.push(textDecoder.decode(chunk))
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
expect(chunks).toEqual([str])
|
|
918
|
+
})
|
|
919
|
+
|
|
920
|
+
it('renderToReadableStream(promise: Promise<HtmlEscapedString>)', async () => {
|
|
921
|
+
const stream = renderToReadableStream(Promise.resolve(raw('<h1>Hello</h1>')))
|
|
922
|
+
|
|
923
|
+
const chunks = []
|
|
924
|
+
const textDecoder = new TextDecoder()
|
|
925
|
+
for await (const chunk of stream as any) {
|
|
926
|
+
chunks.push(textDecoder.decode(chunk))
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
expect(chunks).toEqual(['<h1>Hello</h1>'])
|
|
930
|
+
|
|
931
|
+
suspenseCounter++
|
|
932
|
+
})
|
|
933
|
+
|
|
934
|
+
describe('use()', async () => {
|
|
935
|
+
it('render to string', async () => {
|
|
936
|
+
const promise = new Promise((resolve) => setTimeout(() => resolve('Hello from use()'), 0))
|
|
937
|
+
const Content = () => {
|
|
938
|
+
const message = use(promise)
|
|
939
|
+
return <h1>{message}</h1>
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
const str = await resolveCallback(
|
|
943
|
+
await (
|
|
944
|
+
<Suspense fallback={<p>Loading...</p>}>
|
|
945
|
+
<Content />
|
|
946
|
+
</Suspense>
|
|
947
|
+
).toString(),
|
|
948
|
+
HtmlEscapedCallbackPhase.Stream,
|
|
949
|
+
false,
|
|
950
|
+
{}
|
|
951
|
+
)
|
|
952
|
+
expect(str).toEqual('<h1>Hello from use()</h1>')
|
|
953
|
+
})
|
|
954
|
+
|
|
955
|
+
it('render to stream', async () => {
|
|
956
|
+
const promise = new Promise((resolve) => setTimeout(() => resolve('Hello from use()'), 0))
|
|
957
|
+
const Content = () => {
|
|
958
|
+
const message = use(promise)
|
|
959
|
+
return <h1>{message}</h1>
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
const stream = renderToReadableStream(
|
|
963
|
+
<Suspense fallback={<p>Loading...</p>}>
|
|
964
|
+
<Content />
|
|
965
|
+
</Suspense>
|
|
966
|
+
)
|
|
967
|
+
|
|
968
|
+
const chunks = []
|
|
969
|
+
const textDecoder = new TextDecoder()
|
|
970
|
+
for await (const chunk of stream as any) {
|
|
971
|
+
chunks.push(textDecoder.decode(chunk))
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
expect(chunks).toEqual([
|
|
975
|
+
`<template id="H:${suspenseCounter}"></template><p>Loading...</p><!--/$-->`,
|
|
976
|
+
`<template data-acryl-target="H:${suspenseCounter}"><h1>Hello from use()</h1></template><script>
|
|
977
|
+
((d,c,n) => {
|
|
978
|
+
c=d.currentScript.previousSibling
|
|
979
|
+
d=d.getElementById('H:${suspenseCounter}')
|
|
980
|
+
if(!d)return
|
|
981
|
+
do{n=d.nextSibling;n.remove()}while(n.nodeType!=8||n.nodeValue!='/$')
|
|
982
|
+
d.replaceWith(c.content)
|
|
983
|
+
})(document)
|
|
984
|
+
</script>`,
|
|
985
|
+
])
|
|
986
|
+
|
|
987
|
+
expect(replacementResult(`<html><body>${chunks.join('')}</body></html>`)).toEqual(
|
|
988
|
+
'<h1>Hello from use()</h1>'
|
|
989
|
+
)
|
|
990
|
+
})
|
|
991
|
+
})
|
|
992
|
+
|
|
993
|
+
it('should not throw ERR_INVALID_STATE when reader is cancelled during nested Suspense streaming', async () => {
|
|
994
|
+
const unhandled: unknown[] = []
|
|
995
|
+
const onRejection = (e: unknown) => unhandled.push(e)
|
|
996
|
+
process.on('unhandledRejection', onRejection)
|
|
997
|
+
|
|
998
|
+
const SubContent = async () => <h2>World</h2>
|
|
999
|
+
const Content = async () => (
|
|
1000
|
+
<>
|
|
1001
|
+
<h1>Hello</h1>
|
|
1002
|
+
<Suspense fallback={<p>Loading sub...</p>}>
|
|
1003
|
+
<SubContent />
|
|
1004
|
+
</Suspense>
|
|
1005
|
+
</>
|
|
1006
|
+
)
|
|
1007
|
+
|
|
1008
|
+
const onError = vi.fn()
|
|
1009
|
+
const stream = renderToReadableStream(
|
|
1010
|
+
<Suspense fallback={<p>Loading...</p>}>
|
|
1011
|
+
<Content />
|
|
1012
|
+
</Suspense>,
|
|
1013
|
+
onError
|
|
1014
|
+
)
|
|
1015
|
+
|
|
1016
|
+
const reader = stream.getReader()
|
|
1017
|
+
const firstChunk = await reader.read()
|
|
1018
|
+
expect(firstChunk.done).toBe(false)
|
|
1019
|
+
|
|
1020
|
+
// Simulate client disconnect
|
|
1021
|
+
await reader.cancel()
|
|
1022
|
+
|
|
1023
|
+
// Wait for nested Suspense callbacks to fire against the closed controller
|
|
1024
|
+
await new Promise((resolve) => setTimeout(resolve))
|
|
1025
|
+
|
|
1026
|
+
expect(unhandled).toHaveLength(0)
|
|
1027
|
+
expect(onError).not.toHaveBeenCalled()
|
|
1028
|
+
|
|
1029
|
+
process.off('unhandledRejection', onRejection)
|
|
1030
|
+
})
|
|
1031
|
+
|
|
1032
|
+
it('should not call onError when reader is cancelled during a slow callback resolution', async () => {
|
|
1033
|
+
const unhandled: unknown[] = []
|
|
1034
|
+
const onRejection = (e: unknown) => unhandled.push(e)
|
|
1035
|
+
process.on('unhandledRejection', onRejection)
|
|
1036
|
+
|
|
1037
|
+
let signalCallbackStarted!: () => void
|
|
1038
|
+
const callbackStarted = new Promise<void>((r) => {
|
|
1039
|
+
signalCallbackStarted = r
|
|
1040
|
+
})
|
|
1041
|
+
|
|
1042
|
+
const Content = async () =>
|
|
1043
|
+
raw('<p>content</p>', [
|
|
1044
|
+
((opts: any) => {
|
|
1045
|
+
if (opts.phase === HtmlEscapedCallbackPhase.BeforeStream) {
|
|
1046
|
+
signalCallbackStarted()
|
|
1047
|
+
return new Promise<string>((r) => setTimeout(() => r('')))
|
|
1048
|
+
}
|
|
1049
|
+
return undefined
|
|
1050
|
+
}) as any,
|
|
1051
|
+
])
|
|
1052
|
+
|
|
1053
|
+
const onError = vi.fn()
|
|
1054
|
+
const stream = renderToReadableStream(
|
|
1055
|
+
<Suspense fallback={<p>Loading...</p>}>
|
|
1056
|
+
<Content />
|
|
1057
|
+
</Suspense>,
|
|
1058
|
+
onError
|
|
1059
|
+
)
|
|
1060
|
+
|
|
1061
|
+
const reader = stream.getReader()
|
|
1062
|
+
await reader.read()
|
|
1063
|
+
|
|
1064
|
+
await callbackStarted
|
|
1065
|
+
await reader.cancel()
|
|
1066
|
+
|
|
1067
|
+
await new Promise((resolve) => setTimeout(resolve))
|
|
1068
|
+
|
|
1069
|
+
expect(unhandled).toHaveLength(0)
|
|
1070
|
+
expect(onError).not.toHaveBeenCalled()
|
|
1071
|
+
|
|
1072
|
+
process.off('unhandledRejection', onRejection)
|
|
1073
|
+
})
|
|
1074
|
+
|
|
1075
|
+
it('should not throw when cancelled before initial content resolves', async () => {
|
|
1076
|
+
const unhandled: unknown[] = []
|
|
1077
|
+
const onRejection = (e: unknown) => unhandled.push(e)
|
|
1078
|
+
process.on('unhandledRejection', onRejection)
|
|
1079
|
+
|
|
1080
|
+
const onError = vi.fn()
|
|
1081
|
+
const stream = renderToReadableStream(
|
|
1082
|
+
Promise.resolve(raw('<p>slow content</p>') as HtmlEscapedString),
|
|
1083
|
+
onError
|
|
1084
|
+
)
|
|
1085
|
+
|
|
1086
|
+
const reader = stream.getReader()
|
|
1087
|
+
await reader.cancel()
|
|
1088
|
+
|
|
1089
|
+
await new Promise((resolve) => setTimeout(resolve))
|
|
1090
|
+
|
|
1091
|
+
expect(unhandled).toHaveLength(0)
|
|
1092
|
+
expect(onError).not.toHaveBeenCalled()
|
|
1093
|
+
|
|
1094
|
+
process.off('unhandledRejection', onRejection)
|
|
1095
|
+
})
|
|
1096
|
+
|
|
1097
|
+
it('pops buildDataStack when a deferred re-render rejects', async () => {
|
|
1098
|
+
const baseLength = buildDataStack.length
|
|
1099
|
+
|
|
1100
|
+
const deferred = new Promise<string>((resolve) => setTimeout(() => resolve('x'), 10))
|
|
1101
|
+
let first = true
|
|
1102
|
+
const Content = () => {
|
|
1103
|
+
if (first) {
|
|
1104
|
+
// Suspend like use() does so Suspense defers the re-render.
|
|
1105
|
+
first = false
|
|
1106
|
+
throw deferred
|
|
1107
|
+
}
|
|
1108
|
+
throw new Error('boom')
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
const onError = vi.fn(() => '')
|
|
1112
|
+
const stream = renderToReadableStream(
|
|
1113
|
+
<Suspense fallback={<p>Loading</p>}>
|
|
1114
|
+
<Content />
|
|
1115
|
+
</Suspense>,
|
|
1116
|
+
onError
|
|
1117
|
+
)
|
|
1118
|
+
for await (const _ of stream as any) {
|
|
1119
|
+
// drain
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
expect(onError).toHaveBeenCalledTimes(1)
|
|
1123
|
+
// The deferred re-render pushed a stack frame; rejecting must still pop it.
|
|
1124
|
+
expect(buildDataStack.length).toBe(baseLength)
|
|
1125
|
+
})
|
|
1126
|
+
|
|
1127
|
+
it('isolates context between concurrent streaming renders', async () => {
|
|
1128
|
+
// Default value is distinct from both requests so a cross-request leak
|
|
1129
|
+
// (reading the other request's value) would be detectable.
|
|
1130
|
+
const SessionContext = createContext('nobody')
|
|
1131
|
+
const waits = new Map<string, Promise<void>>()
|
|
1132
|
+
const entered = new Map<string, () => void>()
|
|
1133
|
+
|
|
1134
|
+
const Dashboard = async ({ name }: { name: string }) => {
|
|
1135
|
+
// Read context only after suspending, the pattern that previously leaked.
|
|
1136
|
+
entered.get(name)?.()
|
|
1137
|
+
await waits.get(name)
|
|
1138
|
+
return <span>role:{useContext(SessionContext)}</span>
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
const drain = async (value: string) => {
|
|
1142
|
+
return drainStream(
|
|
1143
|
+
renderToReadableStream(
|
|
1144
|
+
<SessionContext.Provider value={value}>
|
|
1145
|
+
<Dashboard name={value} />
|
|
1146
|
+
</SessionContext.Provider>
|
|
1147
|
+
)
|
|
1148
|
+
)
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
let resolveAdmin!: () => void
|
|
1152
|
+
let resolveGuest!: () => void
|
|
1153
|
+
waits.set(
|
|
1154
|
+
'admin',
|
|
1155
|
+
new Promise<void>((resolve) => {
|
|
1156
|
+
resolveAdmin = resolve
|
|
1157
|
+
})
|
|
1158
|
+
)
|
|
1159
|
+
waits.set(
|
|
1160
|
+
'guest',
|
|
1161
|
+
new Promise<void>((resolve) => {
|
|
1162
|
+
resolveGuest = resolve
|
|
1163
|
+
})
|
|
1164
|
+
)
|
|
1165
|
+
const adminEntered = new Promise<void>((resolve) => {
|
|
1166
|
+
entered.set('admin', resolve)
|
|
1167
|
+
})
|
|
1168
|
+
const guestEntered = new Promise<void>((resolve) => {
|
|
1169
|
+
entered.set('guest', resolve)
|
|
1170
|
+
})
|
|
1171
|
+
|
|
1172
|
+
const adminDrain = drain('admin')
|
|
1173
|
+
const guestDrain = drain('guest')
|
|
1174
|
+
|
|
1175
|
+
await Promise.all([adminEntered, guestEntered])
|
|
1176
|
+
resolveGuest()
|
|
1177
|
+
await Promise.resolve()
|
|
1178
|
+
resolveAdmin()
|
|
1179
|
+
|
|
1180
|
+
const [adminHtml, guestHtml] = await Promise.all([adminDrain, guestDrain])
|
|
1181
|
+
|
|
1182
|
+
expect(adminHtml).toContain('role:admin')
|
|
1183
|
+
expect(adminHtml).not.toContain('role:guest')
|
|
1184
|
+
expect(guestHtml).toContain('role:guest')
|
|
1185
|
+
expect(guestHtml).not.toContain('role:admin')
|
|
1186
|
+
})
|
|
1187
|
+
|
|
1188
|
+
describe('escaping', () => {
|
|
1189
|
+
it('escapes string children with an asynchronous sibling', async () => {
|
|
1190
|
+
const Async = async () => <span>done</span>
|
|
1191
|
+
const node = (
|
|
1192
|
+
<Suspense fallback='loading'>
|
|
1193
|
+
{unsafeHtml}
|
|
1194
|
+
<Async />
|
|
1195
|
+
</Suspense>
|
|
1196
|
+
)
|
|
1197
|
+
|
|
1198
|
+
expect(await stringify(node)).toBe(`${escapedUnsafeHtml}<span>done</span>`)
|
|
1199
|
+
const streamed = await drainStream(renderToReadableStream(node))
|
|
1200
|
+
expect(streamed).toContain(escapedUnsafeHtml)
|
|
1201
|
+
expect(streamed).not.toContain(unsafeHtml)
|
|
1202
|
+
})
|
|
1203
|
+
|
|
1204
|
+
it('escapes nested array strings', async () => {
|
|
1205
|
+
expect(await stringify(<Suspense fallback='loading'>{[[[unsafeHtml]]]}</Suspense>)).toBe(
|
|
1206
|
+
escapedUnsafeHtml
|
|
1207
|
+
)
|
|
1208
|
+
})
|
|
1209
|
+
|
|
1210
|
+
it('escapes a primitive fallback in the initial stream chunk', async () => {
|
|
1211
|
+
const initialChunk = await readInitialSuspenseChunk(unsafeHtml)
|
|
1212
|
+
|
|
1213
|
+
expect(initialChunk).toContain(escapedUnsafeHtml)
|
|
1214
|
+
expect(initialChunk).not.toContain(unsafeHtml)
|
|
1215
|
+
})
|
|
1216
|
+
|
|
1217
|
+
it('escapes untrusted object fallbacks', async () => {
|
|
1218
|
+
const object = { toString: () => unsafeHtml }
|
|
1219
|
+
const asyncObject = { toString: async () => unsafeHtml }
|
|
1220
|
+
const rawObject = { toString: () => raw(unsafeHtml) }
|
|
1221
|
+
|
|
1222
|
+
for (const fallback of [object, asyncObject, rawObject]) {
|
|
1223
|
+
const initialChunk = await readInitialSuspenseChunk(fallback)
|
|
1224
|
+
expect(initialChunk).toContain(escapedUnsafeHtml)
|
|
1225
|
+
expect(initialChunk).not.toContain(unsafeHtml)
|
|
1226
|
+
}
|
|
1227
|
+
})
|
|
1228
|
+
|
|
1229
|
+
it('preserves an explicitly trusted fallback', async () => {
|
|
1230
|
+
expect(await readInitialSuspenseChunk(raw('<strong>trusted</strong>'))).toContain(
|
|
1231
|
+
'<strong>trusted</strong>'
|
|
1232
|
+
)
|
|
1233
|
+
})
|
|
1234
|
+
|
|
1235
|
+
it('does not interpret replacement patterns in content', async () => {
|
|
1236
|
+
const content = "literal $& $` $'"
|
|
1237
|
+
const Async = async () => <span>done</span>
|
|
1238
|
+
|
|
1239
|
+
expect(
|
|
1240
|
+
await stringify(
|
|
1241
|
+
<Suspense fallback='loading'>
|
|
1242
|
+
{content}
|
|
1243
|
+
<Async />
|
|
1244
|
+
</Suspense>
|
|
1245
|
+
)
|
|
1246
|
+
).toBe('literal $& $` $'<span>done</span>')
|
|
1247
|
+
})
|
|
1248
|
+
|
|
1249
|
+
it('escapes untrusted object children', async () => {
|
|
1250
|
+
const object = { toString: () => unsafeHtml }
|
|
1251
|
+
const asyncObject = { toString: async () => unsafeHtml }
|
|
1252
|
+
const rawObject = { toString: () => raw(unsafeHtml) }
|
|
1253
|
+
|
|
1254
|
+
for (const child of [object, asyncObject, rawObject]) {
|
|
1255
|
+
const node = <Suspense fallback='loading'>{child as never}</Suspense>
|
|
1256
|
+
expect(await stringify(node)).toBe(escapedUnsafeHtml)
|
|
1257
|
+
const streamed = await drainStream(renderToReadableStream(node))
|
|
1258
|
+
expect(streamed).toContain(escapedUnsafeHtml)
|
|
1259
|
+
expect(streamed).not.toContain(unsafeHtml)
|
|
1260
|
+
}
|
|
1261
|
+
})
|
|
1262
|
+
|
|
1263
|
+
it('preserves explicitly trusted children', async () => {
|
|
1264
|
+
expect(
|
|
1265
|
+
await stringify(<Suspense fallback='loading'>{raw('<strong>trusted</strong>')}</Suspense>)
|
|
1266
|
+
).toBe('<strong>trusted</strong>')
|
|
1267
|
+
})
|
|
1268
|
+
|
|
1269
|
+
it('keeps benign object stringification visible', async () => {
|
|
1270
|
+
expect(await stringify(<Suspense fallback='loading'>{{} as never}</Suspense>)).toBe(
|
|
1271
|
+
'[object Object]'
|
|
1272
|
+
)
|
|
1273
|
+
})
|
|
1274
|
+
|
|
1275
|
+
it('keeps a direct Promise child unchanged', async () => {
|
|
1276
|
+
expect(
|
|
1277
|
+
await stringify(<Suspense fallback='loading'>{Promise.resolve('resolved')}</Suspense>)
|
|
1278
|
+
).toBe('[object Promise]')
|
|
1279
|
+
})
|
|
1280
|
+
})
|
|
1281
|
+
})
|