-
Notifications
You must be signed in to change notification settings - Fork 26.2k
/
ppr-navigations.test.ts
329 lines (286 loc) · 10.2 KB
/
ppr-navigations.test.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
import { createNext } from 'e2e-utils'
import { findPort } from 'next-test-utils'
import http from 'http'
describe('ppr-navigations', () => {
if ((global as any).isNextDev) {
test('prefetching is disabled in dev', () => {})
return
}
let server
let next
afterEach(async () => {
await next?.destroy()
server?.close()
})
test('when PPR is enabled, loading.tsx boundaries do not cause a partial prefetch', async () => {
const TestLog = createTestLog()
let pendingRequests = new Map()
server = createTestDataServer(async (key, res) => {
TestLog.log('REQUEST: ' + key)
if (pendingRequests.has(key)) {
throw new Error('Request already pending for ' + key)
}
pendingRequests.set(key, res)
})
const port = await findPort()
server.listen(port)
next = await createNext({
files: __dirname,
env: { TEST_DATA_SERVICE_URL: `http://localhost:${port}` },
})
// There should have been no data requests during build
TestLog.assert([])
const browser = await next.browser(
'/loading-tsx-no-partial-rendering/start'
)
// Use a text input to set the target URL.
const input = await browser.elementByCss('input')
await input.fill('/loading-tsx-no-partial-rendering/yay')
// This causes a <Link> to appear. (We create the Link after initial render
// so we can control when the prefetch happens.)
const link = await browser.elementByCss('a')
expect(await link.getAttribute('href')).toBe(
'/loading-tsx-no-partial-rendering/yay'
)
// The <Link> triggers a prefetch. Even though this route has a loading.tsx
// boundary, we're still able to prefetch the static data in the page.
// Without PPR, we would have stopped prefetching at the loading.tsx
// boundary. (The dynamic data is not fetched until navigation.)
await TestLog.waitFor(['REQUEST: yay [static]'])
// Navigate. This will trigger the dynamic fetch.
await link.click()
// TODO: Even though the prefetch request hasn't resolved yet, we should
// have already started fetching the dynamic data. Currently, the dynamic
// is fetched lazily during rendering, creating a waterfall. The plan is to
// remove this waterfall by initiating the fetch directly inside the
// router navigation handler, not during render.
TestLog.assert([])
// Finish loading the static data
pendingRequests.get('yay [static]').resolve()
// The static UI appears
await browser.elementById('static')
const container = await browser.elementById('container')
expect(await container.innerHTML()).toEqual(
'Loading dynamic...<div id="static">yay [static]</div>'
)
// The dynamic data is fetched
TestLog.assert(['REQUEST: yay [dynamic]'])
// Finish loading and render the full UI
pendingRequests.get('yay [dynamic]').resolve()
await browser.elementById('dynamic')
expect(await container.innerHTML()).toEqual(
'<div id="dynamic">yay [dynamic]</div><div id="static">yay [static]</div>'
)
// Now we'll demonstrate that even though loading.tsx wasn't activated
// during initial render, it still acts as a regular Suspense boundary.
// Trigger a "bad" Suspense fallback by intentionally suspending without
// startTransition.
await browser.elementById('trigger-bad-suspense-fallback').click()
const loading = await browser.elementById('loading-tsx')
expect(await loading.innerHTML()).toEqual('Loading [inner loading.tsx]...')
})
})
// NOTE: I've intentionally not yet moved these helpers into a separate
// module, to avoid early abstraction. I will if/when we start using them for
// other tests. They are based on the testing patterns we use all over the React
// codebase, so I'm reasonably confident in them.
type TestDataResponse = {
_res: http.ServerResponse
resolve: (value: string) => any
reject: (value: any) => any
}
type TestDataServer = {
_server: http.Server
listen: (port: number) => void
close: () => void
}
// Creates a lightweight HTTP server for use in e2e testing. This simulates the
// data service that would be used in a real Next.js application, whether it's
// direct database access, an ORM, or a higher-level data access layer. The e2e
// test can observe when individual requests are received, and control the
// timing of when the data is fulfilled, without needing to mock any lower
// level I/O.
//
// Receives requests of the form: /?key=foo
//
// Responds in plain text. By default, the response is the key itself, but the
// e2e test can respond with any text it wants.
//
// Examples:
// response.resolve() // Responds with the key itself
// response.resolve('custom') // Responds with custom text
// response.reject(new Error('oops!')) // Responds with a 500 error
//
// Based on the AsyncText pattern used in the React repo.
function createTestDataServer(
onRequest: (key: string, response: TestDataResponse) => any
): TestDataServer {
const httpServer = http.createServer(async (req, res) => {
const searchParams = new URL(req.url, 'http://n').searchParams
const key = searchParams.get('key')
if (typeof key !== 'string') {
res.statusCode = 400
const msg = 'Missing key parameter'
res.end(msg)
return
}
const response: TestDataResponse = {
_res: res,
resolve(value: string | void) {
res.end(value === undefined ? key : value)
},
reject(error: Error, status?: number) {
res.statusCode = status ?? 500
res.end(error.message ?? `Failed to fetch data for "${key}"`)
},
}
try {
const result = await onRequest(key, response)
if (typeof result === 'string') {
response.resolve(result)
}
} catch (error) {
response.reject(error)
}
})
return {
_server: httpServer,
listen(port: number) {
httpServer.listen(port)
},
close() {
httpServer.close()
},
}
}
// Creates an event log. You can write to this during testing and then assert
// on the result.
//
// The main use case is for asynchronous e2e tests. It provides a `waitFor`
// method that resolves when the log matches some expected asynchronous sequence
// of events. This is an alternative to setting up a timer loop. It helps catch
// subtle mistakes where the order of events is not expected, or the same
// event happens more than it should.
//
// Based on the Scheduler.log pattern used in the React repo.
function createTestLog() {
let events = []
// Represents a pending waitFor call.
let pendingExpectation: null | {
resolve: () => void
reject: (error: Error) => void
expectedEvents: Array<any>
error: Error
} = null
function log(value: any) {
// Add to the event log.
events.push(value)
// Check if we've reached the end of the expected log. If there's a
// pending waitFor, and we've reached the last of the expected events, this
// will resolve the promise.
pingExpectation()
}
function assert(expectedEvents: any[]) {
const actualEvents = events
if (pendingExpectation !== null) {
const error = new Error('Cannot assert while a waitFor() is pending.')
Error.captureStackTrace(error, assert)
throw error
}
if (!areLogsEqual(expectedEvents, actualEvents)) {
// Capture the stack trace of `assert` so that Jest will report the
// error as originating from the `assert` call instead of here.
const error = new Error(
'Expected sequence of events did not occur.\n\n' +
createDiff(expectedEvents, actualEvents)
)
Error.captureStackTrace(error, assert)
throw error
}
}
function waitFor(expectedEvents: any[], timeout: number = 5000) {
// Returns a promise that resolves when the event log matches the
// expected sequence.
// Capture the stack trace of `waitFor` so that if an inner assertion fails,
// Jest will report the error as originating from the `waitFor` call instead
// of inside this module's implementation.
const error = new Error()
Error.captureStackTrace(error, waitFor)
if (pendingExpectation !== null) {
error.message = 'A previous waitFor() is still pending.'
throw error
}
let resolve
let reject
const promise = new Promise<void>((res, rej) => {
resolve = res
reject = rej
})
const thisExpectation = {
resolve,
reject,
expectedEvents,
error,
}
pendingExpectation = thisExpectation
setTimeout(() => {
if (pendingExpectation === thisExpectation) {
error.message = `waitFor timed out after ${timeout}ms`
reject(error)
}
}, timeout)
return promise
}
function pingExpectation() {
if (pendingExpectation !== null) {
const expectedEvents = pendingExpectation.expectedEvents
if (events.length < expectedEvents.length) {
return
}
if (areLogsEqual(expectedEvents, events)) {
// We've reached the end of the expected log. Resolve the promise and
// reset the log.
events = []
pendingExpectation.resolve()
pendingExpectation = null
} else {
// The log does not match what was expected by the test. Reject the
// promise and reset the log.
// Use the error object that we captured at the start of the `waitFor`
// call. Jest will show that the error originated from `waitFor` call
// instead of inside this internal function.
const error = pendingExpectation.error
error.message =
'Expected sequence of events did not occur.\n\n' +
createDiff(expectedEvents, events)
events = []
pendingExpectation.reject(error)
pendingExpectation = null
}
}
}
function createDiff(expected, actual) {
// TODO: Jest exposes the diffing utility that it uses for `expect`.
// We could use that here for nicer output.
return `
Expected: ${JSON.stringify(expected)}
Actual: ${JSON.stringify(actual)}
`
}
function areLogsEqual(a, b) {
if (a.length !== b.length) {
return false
}
for (let i = 0; i < a.length; i++) {
if (a[i] !== b[i]) {
return false
}
}
return true
}
return {
log,
waitFor,
assert,
}
}