Skip to content

Commit efa58ef

Browse files
authored
Ensure stalled CSS triggers fallback navigation (#24488)
This ensures when CSS requests stall that they are included in the route load timeout so that stalled CSS requests don't block us from falling back to a hard navigation. This handles a rare case noticed by @pacocoursey where a transition did not complete while attempting to load CSS assets. ## Bug - [ ] Related issues linked using `fixes #number` - [x] Integration tests added
1 parent fff183c commit efa58ef

File tree

3 files changed

+101
-32
lines changed

3 files changed

+101
-32
lines changed

packages/next/client/route-loader.ts

Lines changed: 37 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ function withFuture<T>(
6868
export interface RouteLoader {
6969
whenEntrypoint(route: string): Promise<RouteEntrypoint>
7070
onEntrypoint(route: string, execute: () => unknown): void
71-
loadRoute(route: string): Promise<RouteLoaderEntry>
71+
loadRoute(route: string, prefetch?: boolean): Promise<RouteLoaderEntry>
7272
prefetch(route: string): Promise<void>
7373
}
7474

@@ -305,33 +305,41 @@ function createRouteLoader(assetPrefix: string): RouteLoader {
305305
if (old && 'resolve' in old) old.resolve(input)
306306
})
307307
},
308-
loadRoute(route: string) {
309-
return withFuture<RouteLoaderEntry>(route, routes, async () => {
310-
try {
311-
const { scripts, css } = await getFilesForRoute(assetPrefix, route)
312-
const [, styles] = await Promise.all([
313-
entrypoints.has(route)
314-
? []
315-
: Promise.all(scripts.map(maybeExecuteScript)),
316-
Promise.all(css.map(fetchStyleSheet)),
317-
] as const)
318-
319-
const entrypoint: RouteEntrypoint = await resolvePromiseWithTimeout(
320-
this.whenEntrypoint(route),
321-
MS_MAX_IDLE_DELAY,
322-
markAssetError(
323-
new Error(`Route did not complete loading: ${route}`)
324-
)
325-
)
326-
327-
const res: RouteLoaderEntry = Object.assign<
328-
{ styles: RouteStyleSheet[] },
329-
RouteEntrypoint
330-
>({ styles }, entrypoint)
331-
return 'error' in entrypoint ? entrypoint : res
332-
} catch (err) {
333-
return { error: err }
334-
}
308+
loadRoute(route: string, prefetch?: boolean) {
309+
return withFuture<RouteLoaderEntry>(route, routes, () => {
310+
return resolvePromiseWithTimeout(
311+
getFilesForRoute(assetPrefix, route)
312+
.then(({ scripts, css }) => {
313+
return Promise.all([
314+
entrypoints.has(route)
315+
? []
316+
: Promise.all(scripts.map(maybeExecuteScript)),
317+
Promise.all(css.map(fetchStyleSheet)),
318+
] as const)
319+
})
320+
.then((res) => {
321+
return this.whenEntrypoint(route).then((entrypoint) => ({
322+
entrypoint,
323+
styles: res[1],
324+
}))
325+
}),
326+
MS_MAX_IDLE_DELAY,
327+
markAssetError(new Error(`Route did not complete loading: ${route}`))
328+
)
329+
.then(({ entrypoint, styles }) => {
330+
const res: RouteLoaderEntry = Object.assign<
331+
{ styles: RouteStyleSheet[] },
332+
RouteEntrypoint
333+
>({ styles: styles! }, entrypoint)
334+
return 'error' in entrypoint ? entrypoint : res
335+
})
336+
.catch((err) => {
337+
if (prefetch) {
338+
// we don't want to cache errors during prefetch
339+
throw err
340+
}
341+
return { error: err }
342+
})
335343
})
336344
},
337345
prefetch(route: string): Promise<void> {
@@ -351,7 +359,7 @@ function createRouteLoader(assetPrefix: string): RouteLoader {
351359
)
352360
)
353361
.then(() => {
354-
requestIdleCallback(() => this.loadRoute(route))
362+
requestIdleCallback(() => this.loadRoute(route, true).catch(() => {}))
355363
})
356364
.catch(
357365
// swallow prefetch errors

test/integration/build-output/test/index.test.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,7 @@ describe('Build Output', () => {
9999
expect(indexSize.endsWith('B')).toBe(true)
100100

101101
// should be no bigger than 64.8 kb
102-
expect(parseFloat(indexFirstLoad)).toBeCloseTo(65.4, 1)
102+
expect(parseFloat(indexFirstLoad)).toBeCloseTo(65.3, 1)
103103
expect(indexFirstLoad.endsWith('kB')).toBe(true)
104104

105105
expect(parseFloat(err404Size)).toBeCloseTo(3.69, 1)
@@ -108,7 +108,7 @@ describe('Build Output', () => {
108108
expect(parseFloat(err404FirstLoad)).toBeCloseTo(68.8, 0)
109109
expect(err404FirstLoad.endsWith('kB')).toBe(true)
110110

111-
expect(parseFloat(sharedByAll)).toBeCloseTo(65.1, 1)
111+
expect(parseFloat(sharedByAll)).toBeCloseTo(65, 1)
112112
expect(sharedByAll.endsWith('kB')).toBe(true)
113113

114114
if (_appSize.endsWith('kB')) {

test/integration/css-client-nav/test/index.test.js

Lines changed: 62 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
/* eslint-env jest */
22

3+
import http from 'http'
4+
import httpProxy from 'http-proxy'
35
import cheerio from 'cheerio'
46
import { remove } from 'fs-extra'
57
import {
@@ -18,6 +20,8 @@ jest.setTimeout(1000 * 60 * 1)
1820
const fixturesDir = join(__dirname, '../../css-fixtures')
1921
const appDir = join(fixturesDir, 'multi-module')
2022

23+
let proxyServer
24+
let stallCss
2125
let appPort
2226
let app
2327

@@ -152,12 +156,69 @@ describe('CSS Module client-side navigation', () => {
152156
beforeAll(async () => {
153157
await remove(join(appDir, '.next'))
154158
await nextBuild(appDir)
159+
const port = await findPort()
160+
app = await nextStart(appDir, port)
155161
appPort = await findPort()
156-
app = await nextStart(appDir, appPort)
162+
163+
const proxy = httpProxy.createProxyServer({
164+
target: `http://localhost:${port}`,
165+
})
166+
167+
proxyServer = http.createServer(async (req, res) => {
168+
if (stallCss && req.url.endsWith('.css')) {
169+
console.log('stalling request for', req.url)
170+
await new Promise((resolve) => setTimeout(resolve, 5 * 1000))
171+
}
172+
proxy.web(req, res)
173+
})
174+
175+
proxy.on('error', (err) => {
176+
console.warn('Failed to proxy', err)
177+
})
178+
179+
await new Promise((resolve) => {
180+
proxyServer.listen(appPort, () => resolve())
181+
})
157182
})
158183
afterAll(async () => {
184+
proxyServer.close()
159185
await killApp(app)
160186
})
187+
188+
it('should time out and hard navigate for stalled CSS request', async () => {
189+
let browser
190+
stallCss = true
191+
192+
try {
193+
browser = await webdriver(appPort, '/red')
194+
browser.eval('window.beforeNav = "hello"')
195+
196+
const redColor = await browser.eval(
197+
`window.getComputedStyle(document.querySelector('#verify-red')).color`
198+
)
199+
expect(redColor).toMatchInlineSnapshot(`"rgb(255, 0, 0)"`)
200+
expect(await browser.eval('window.beforeNav')).toBe('hello')
201+
202+
await browser.elementByCss('#link-blue').click()
203+
204+
await browser.waitForElementByCss('#verify-blue')
205+
206+
const blueColor = await browser.eval(
207+
`window.getComputedStyle(document.querySelector('#verify-blue')).color`
208+
)
209+
expect(blueColor).toMatchInlineSnapshot(`"rgb(0, 0, 255)"`)
210+
211+
// the timeout should have been reached and we did a hard
212+
// navigation
213+
expect(await browser.eval('window.beforeNav')).toBe(null)
214+
} finally {
215+
stallCss = false
216+
if (browser) {
217+
await browser.close()
218+
}
219+
}
220+
})
221+
161222
runTests()
162223
})
163224

0 commit comments

Comments
 (0)