Skip to content

Commit 4b519be

Browse files
sampoderfeedthejim
andauthored
[turbopack] Retry chunk loading on failure (vercel#94918)
This is a trimmed down PR from vercel#88107, it achieves the same thing (with much of the same code). However, it is Turbopack-only. This is because the Webpack portions of that PR required patching React - we can return to this in a different PR or I guess people could use a plugin? (https://github.com/mattlewis92/webpack-retry-chunk-load-plugin) --------- Co-authored-by: Jimmy Lai <laijimmy0@gmail.com>
1 parent 45d26df commit 4b519be

22 files changed

Lines changed: 825 additions & 200 deletions

File tree

crates/next-core/src/next_client/context.rs

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,8 @@ use turbopack_browser::{
1414
};
1515
use turbopack_core::{
1616
chunk::{
17-
AssetSuffix, ChunkingConfig, ChunkingContext, ContentHashing, CrossOrigin, MangleType,
18-
MinifyType, SourceMapSourceType, SourceMapsType, UnusedReferences, UrlBehavior,
17+
AssetSuffix, ChunkLoadRetry, ChunkingConfig, ChunkingContext, ContentHashing, CrossOrigin,
18+
MangleType, MinifyType, SourceMapSourceType, SourceMapsType, UnusedReferences, UrlBehavior,
1919
chunk_id_strategy::ModuleIdStrategy,
2020
},
2121
compile_time_info::{CompileTimeDefines, CompileTimeInfo, FreeVarReference, FreeVarReferences},
@@ -483,6 +483,14 @@ pub struct ClientChunkingContextOptions {
483483
pub style_groups_algorithm: StyleGroupsAlgorithm,
484484
}
485485

486+
/// Next.js' chunk-load retry policy for the Turbopack browser runtime.
487+
/// Webpack does not currently support chunk-load retrying.
488+
const NEXT_CHUNK_LOAD_RETRY: ChunkLoadRetry = ChunkLoadRetry {
489+
max_retry_attempts: 1,
490+
base_delay_ms: 200,
491+
max_jitter_ms: 400,
492+
};
493+
486494
#[turbo_tasks::function]
487495
pub async fn get_client_chunking_context(
488496
options: ClientChunkingContextOptions,
@@ -543,6 +551,7 @@ pub async fn get_client_chunking_context(
543551
.asset_base_path(Some(asset_prefix))
544552
.current_chunk_method(CurrentChunkMethod::DocumentCurrentScript)
545553
.cross_origin(cross_origin_loading)
554+
.chunk_load_retry(NEXT_CHUNK_LOAD_RETRY)
546555
.export_usage(*export_usage.await?)
547556
.unused_references(unused_references.to_resolved().await?)
548557
.module_id_strategy(module_id_strategy.to_resolved().await?)

test/production/chunk-load-failure/chunk-load-failure.test.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,10 +27,12 @@ describe('chunk-load-failure', () => {
2727
it('should report async chunk load failures', async () => {
2828
let nextDynamicChunk = await getNextDynamicChunk()
2929

30+
let chunkRequestCount = 0
3031
let pageError: Error | undefined
3132
const browser = await next.browser('/dynamic', {
3233
beforePageLoad(page) {
3334
page.route(`**/${nextDynamicChunk}*`, async (route) => {
35+
chunkRequestCount++
3436
await route.abort('connectionreset')
3537
})
3638
page.on('pageerror', (error: Error) => {
@@ -48,15 +50,61 @@ describe('chunk-load-failure', () => {
4850
expect(pageError).toBeDefined()
4951
expect(pageError.name).toBe('ChunkLoadError')
5052
if (process.env.IS_TURBOPACK_TEST) {
53+
// Turbopack retries a failed chunk load once before giving up: one initial
54+
// request plus one retry.
55+
expect(chunkRequestCount).toBe(2)
5156
expect(pageError.message).toStartWith(
5257
'Failed to load chunk /_next/' + nextDynamicChunk
5358
)
5459
} else {
60+
// Webpack does not retry yet, so the failure surfaces on the first request.
61+
expect(chunkRequestCount).toBe(1)
5562
expect(pageError.message).toMatch(/^Loading chunk \S+ failed./)
5663
expect(pageError.message).toContain('/_next/' + nextDynamicChunk)
5764
}
5865
})
5966

67+
// Turbopack-only: webpack does not retry chunk loads yet, so a transient
68+
// failure there surfaces immediately as a ChunkLoadError.
69+
/* eslint-disable jest/no-standalone-expect */
70+
;(process.env.IS_TURBOPACK_TEST ? it : it.skip)(
71+
'should recover after a transient async chunk load failure',
72+
async () => {
73+
let nextDynamicChunk = await getNextDynamicChunk()
74+
75+
let chunkRequestCount = 0
76+
let pageError: Error | undefined
77+
const browser = await next.browser('/dynamic', {
78+
beforePageLoad(page) {
79+
page.route(`**/${nextDynamicChunk}*`, async (route) => {
80+
chunkRequestCount++
81+
// Fail only the first attempt; let the retry through.
82+
if (chunkRequestCount === 1) {
83+
await route.abort('connectionreset')
84+
return
85+
}
86+
await route.continue()
87+
})
88+
page.on('pageerror', (error: Error) => {
89+
pageError = error
90+
})
91+
},
92+
})
93+
94+
await retry(async () => {
95+
const body = await browser.elementByCss('body')
96+
expect(await body.text()).toContain(
97+
'this is a lazy loaded async component'
98+
)
99+
})
100+
101+
// One initial request that failed + one retry that succeeded.
102+
expect(chunkRequestCount).toBe(2)
103+
expect(pageError).toBeUndefined()
104+
}
105+
)
106+
/* eslint-enable jest/no-standalone-expect */
107+
60108
it('should report aborted chunks when navigating away', async () => {
61109
let nextDynamicChunk = await getNextDynamicChunk()
62110

turbopack/crates/turbopack-browser/src/chunking_context.rs

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,9 @@ use turbo_tasks_hash::HashAlgorithm;
77
use turbopack_core::{
88
asset::{Asset, AssetContent},
99
chunk::{
10-
AssetSuffix, Chunk, ChunkGroupResult, ChunkItem, ChunkType, ChunkableModule,
11-
ChunkingConfig, ChunkingConfigs, ChunkingContext, ContentHashing, CrossOrigin,
12-
EntryChunkGroupResult, EvaluatableAsset, EvaluatableAssets, MinifyType,
10+
AssetSuffix, Chunk, ChunkGroupResult, ChunkItem, ChunkLoadRetry, ChunkType,
11+
ChunkableModule, ChunkingConfig, ChunkingConfigs, ChunkingContext, ContentHashing,
12+
CrossOrigin, EntryChunkGroupResult, EvaluatableAsset, EvaluatableAssets, MinifyType,
1313
SourceMapSourceType, SourceMapsType, UnusedReferences, UrlBehavior,
1414
WorkerConfigurationOptions,
1515
availability_info::AvailabilityInfo,
@@ -230,6 +230,11 @@ impl BrowserChunkingContextBuilder {
230230
self
231231
}
232232

233+
pub fn chunk_load_retry(mut self, chunk_load_retry: ChunkLoadRetry) -> Self {
234+
self.chunking_context.chunk_load_retry = chunk_load_retry;
235+
self
236+
}
237+
233238
pub fn build(self) -> Vc<BrowserChunkingContext> {
234239
BrowserChunkingContext::cell(self.chunking_context)
235240
}
@@ -336,6 +341,8 @@ pub struct BrowserChunkingContext {
336341
hash_salt: ResolvedVc<RcStr>,
337342
/// The crossorigin mode for dynamically loaded chunks.
338343
cross_origin: CrossOrigin,
344+
/// The retry policy for transient chunk load failures in the browser runtime.
345+
chunk_load_retry: ChunkLoadRetry,
339346
}
340347

341348
impl BrowserChunkingContext {
@@ -390,6 +397,7 @@ impl BrowserChunkingContext {
390397
chunk_loading_global: Default::default(),
391398
hash_salt: ResolvedVc::cell(RcStr::default()),
392399
cross_origin: Default::default(),
400+
chunk_load_retry: Default::default(),
393401
},
394402
}
395403
}
@@ -524,6 +532,11 @@ impl BrowserChunkingContext {
524532
pub fn cross_origin(&self) -> Vc<CrossOrigin> {
525533
self.cross_origin.cell()
526534
}
535+
536+
#[turbo_tasks::function]
537+
pub fn chunk_load_retry(&self) -> Vc<ChunkLoadRetry> {
538+
self.chunk_load_retry.cell()
539+
}
527540
}
528541

529542
#[turbo_tasks::value_impl]

turbopack/crates/turbopack-browser/src/ecmascript/evaluate/chunk.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,7 @@ impl EcmascriptBrowserEvaluateChunk {
192192
source_maps,
193193
this.chunking_context.chunk_loading_global(),
194194
this.chunking_context.cross_origin(),
195+
this.chunking_context.chunk_load_retry(),
195196
has_async_modules,
196197
);
197198
code.push_code(&*runtime_code.await?);

turbopack/crates/turbopack-core/src/chunk/mod.rs

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,30 @@ impl TryFrom<Option<&str>> for CrossOrigin {
9898
}
9999
}
100100

101+
#[turbo_tasks::value(shared)]
102+
#[derive(Debug, Clone, Copy, Hash, Serialize, Deserialize)]
103+
pub struct ChunkLoadRetry {
104+
/// Number of retry attempts after the initial load fails. `0` disables retries.
105+
pub max_retry_attempts: u32,
106+
/// Base delay before a retry, in milliseconds.
107+
pub base_delay_ms: u32,
108+
/// Maximum random jitter added to the base delay, in milliseconds.
109+
pub max_jitter_ms: u32,
110+
}
111+
112+
impl Default for ChunkLoadRetry {
113+
fn default() -> Self {
114+
// Retry a transient failure once after a short jittered delay. Network
115+
// blips (a brief connection reset, a short CDN hiccup) often succeed on
116+
// a second try.
117+
Self {
118+
max_retry_attempts: 1,
119+
base_delay_ms: 200,
120+
max_jitter_ms: 400,
121+
}
122+
}
123+
}
124+
101125
/// A module id, which can be a number or string
102126
#[turbo_tasks::value(shared, operation)]
103127
#[derive(Debug, Clone, Hash, Ord, PartialOrd, DeterministicHash, Serialize, ValueToString)]

turbopack/crates/turbopack-ecmascript-runtime/js/src/browser/runtime/base/runtime-base.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,9 @@ declare var TURBOPACK_NEXT_CHUNK_URLS: ChunkUrl[] | undefined
2222
declare var CHUNK_BASE_PATH: string
2323
declare var ASSET_SUFFIX: string
2424
declare var CROSS_ORIGIN: 'anonymous' | 'use-credentials' | null
25+
declare var CHUNK_LOAD_RETRY_MAX_ATTEMPTS: number
26+
declare var CHUNK_LOAD_RETRY_BASE_DELAY_MS: number
27+
declare var CHUNK_LOAD_RETRY_MAX_JITTER_MS: number
2528

2629
interface TurbopackBrowserBaseContext<M> extends TurbopackBaseContext<M> {
2730
R: ResolvePathFromModule

turbopack/crates/turbopack-ecmascript-runtime/js/src/browser/runtime/dom/runtime-backend-dom.ts

Lines changed: 106 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ function getAssetSuffixFromScriptSrc() {
2020
type ChunkResolver = {
2121
resolved: boolean
2222
loadingStarted: boolean
23+
retryAttempts: number
2324
resolve: () => void
2425
reject: (error?: Error) => void
2526
promise: Promise<any>
@@ -88,6 +89,7 @@ const chunkResolvers: Map<ChunkUrl, ChunkResolver> = new Map()
8889
resolver = {
8990
resolved: false,
9091
loadingStarted: false,
92+
retryAttempts: 0,
9193
promise,
9294
resolve: () => {
9395
resolver!.resolved = true
@@ -100,6 +102,72 @@ const chunkResolvers: Map<ChunkUrl, ChunkResolver> = new Map()
100102
return resolver
101103
}
102104

105+
/**
106+
* Rejects a chunk resolver and drops it from the cache.
107+
* We don't want to cache failed chunk loads: a later
108+
* request for the same chunk should try again.
109+
*/
110+
function rejectChunkResolver(
111+
chunkUrl: ChunkUrl,
112+
resolver: ChunkResolver,
113+
error?: Error
114+
) {
115+
if (chunkResolvers.get(chunkUrl) === resolver) {
116+
chunkResolvers.delete(chunkUrl)
117+
}
118+
resolver.reject(error)
119+
}
120+
121+
function getChunkLoadRetryDelayMs() {
122+
const jitter = Math.floor(
123+
Math.random() * (CHUNK_LOAD_RETRY_MAX_JITTER_MS + 1)
124+
)
125+
return CHUNK_LOAD_RETRY_BASE_DELAY_MS + jitter
126+
}
127+
128+
function isRetryableChunkLoadError(error?: Error): boolean {
129+
return (
130+
error == null ||
131+
(error instanceof DOMException && error.name === 'NetworkError')
132+
)
133+
}
134+
135+
/**
136+
* Handles a failed chunk load: retries the load once after a short delay.
137+
*/
138+
function onChunkLoadError(
139+
sourceType: SourceType,
140+
chunkUrl: ChunkUrl,
141+
resolver: ChunkResolver,
142+
error?: Error,
143+
reload?: () => void
144+
) {
145+
if (
146+
!isRetryableChunkLoadError(error) ||
147+
resolver.retryAttempts >= CHUNK_LOAD_RETRY_MAX_ATTEMPTS ||
148+
chunkResolvers.get(chunkUrl) !== resolver
149+
) {
150+
rejectChunkResolver(chunkUrl, resolver, error)
151+
return
152+
}
153+
154+
resolver.retryAttempts++
155+
setTimeout(() => {
156+
// if this chunk is being fetched multiple times, and one of those
157+
// attempts succeeds. or, if this chunk has another resolver
158+
// mapped to it - it's safe to skip retrying.
159+
if (resolver.resolved || chunkResolvers.get(chunkUrl) !== resolver) {
160+
return
161+
}
162+
if (reload) {
163+
reload()
164+
} else {
165+
resolver.loadingStarted = false
166+
doLoadChunk(sourceType, chunkUrl)
167+
}
168+
}, getChunkLoadRetryDelayMs())
169+
}
170+
103171
/**
104172
* Loads the given chunk, and returns a promise that resolves once the chunk
105173
* has been loaded.
@@ -134,7 +202,11 @@ const chunkResolvers: Map<ChunkUrl, ChunkResolver> = new Map()
134202
// ignore
135203
} else if (isJs(chunkUrl)) {
136204
self.TURBOPACK_NEXT_CHUNK_URLS!.push(chunkUrl)
137-
importScripts(chunkUrl)
205+
try {
206+
importScripts(chunkUrl)
207+
} catch (error) {
208+
onChunkLoadError(sourceType, chunkUrl, resolver, error as Error)
209+
}
138210
} else {
139211
throw new Error(
140212
`can't infer type of chunk from URL ${chunkUrl} in worker`
@@ -153,32 +225,45 @@ const chunkResolvers: Map<ChunkUrl, ChunkResolver> = new Map()
153225
// loaded instantly.
154226
resolver.resolve()
155227
} else {
156-
const link = document.createElement('link')
157-
link.rel = 'stylesheet'
158-
link.crossOrigin = CROSS_ORIGIN
159-
link.href = chunkUrl
160-
link.onerror = () => {
161-
resolver.reject()
162-
}
163-
link.onload = () => {
164-
// CSS chunks do not register themselves, and as such must be marked as
165-
// loaded instantly.
166-
resolver.resolve()
228+
const createLink = () => {
229+
const link = document.createElement('link')
230+
link.rel = 'stylesheet'
231+
link.crossOrigin = CROSS_ORIGIN
232+
link.href = chunkUrl
233+
link.onerror = () => {
234+
// Re-insert a fresh tag at the same position on retry to preserve
235+
// cascade order.
236+
const anchor = document.createComment('')
237+
link.replaceWith(anchor)
238+
onChunkLoadError(sourceType, chunkUrl, resolver, undefined, () =>
239+
anchor.replaceWith(createLink())
240+
)
241+
}
242+
link.onload = () => {
243+
// CSS chunks do not register themselves, and as such must be marked as
244+
// loaded instantly.
245+
resolver.resolve()
246+
}
247+
return link
167248
}
168249
// Append to the `head` for webpack compatibility.
169-
document.head.appendChild(link)
250+
document.head.appendChild(createLink())
170251
}
171252
} else if (isJs(chunkUrl)) {
172253
const previousScripts = document.querySelectorAll(
173254
`script[src="${chunkUrl}"],script[src^="${chunkUrl}?"],script[src="${decodedChunkUrl}"],script[src^="${decodedChunkUrl}?"]`
174255
)
175256
if (previousScripts.length > 0) {
176-
// There is this edge where the script already failed loading, but we
177-
// can't detect that. The Promise will never resolve in this case.
178257
for (const script of Array.from(previousScripts)) {
179-
script.addEventListener('error', () => {
180-
resolver.reject()
181-
})
258+
script.addEventListener(
259+
'error',
260+
() => {
261+
// Drop the failed tag so a retry can re-add it cleanly.
262+
script.remove()
263+
onChunkLoadError(sourceType, chunkUrl, resolver)
264+
},
265+
{ once: true }
266+
)
182267
}
183268
} else {
184269
const script = document.createElement('script')
@@ -188,7 +273,9 @@ const chunkResolvers: Map<ChunkUrl, ChunkResolver> = new Map()
188273
// which happens in `registerChunk`. Hence the absence of `resolve()` in
189274
// this branch.
190275
script.onerror = () => {
191-
resolver.reject()
276+
// Drop the failed tag so a retry can re-add it cleanly.
277+
script.remove()
278+
onChunkLoadError(sourceType, chunkUrl, resolver)
192279
}
193280
// Append to the `head` for webpack compatibility.
194281
document.head.appendChild(script)

0 commit comments

Comments
 (0)