From 3fd33a41706e9c14e588f2f720f10c99c94733bf Mon Sep 17 00:00:00 2001 From: Soon Date: Sun, 5 Jan 2025 17:07:17 +0800 Subject: [PATCH] fix(plugin/assets-retry): addQuery and switchDomain should work in async css chunk (#4315) --- e2e/cases/assets/assets-retry/index.test.ts | 70 ++++++++++++ .../src/AsyncChunkRetryPlugin.ts | 70 +++++++++--- .../src/runtime/asyncChunkRetry.ts | 104 +++++++++++++----- scripts/dictionary.txt | 1 + .../en/plugins/list/plugin-assets-retry.mdx | 2 +- .../zh/plugins/list/plugin-assets-retry.mdx | 2 +- 6 files changed, 204 insertions(+), 45 deletions(-) diff --git a/e2e/cases/assets/assets-retry/index.test.ts b/e2e/cases/assets/assets-retry/index.test.ts index 14bfb5a55f..0998b12d94 100644 --- a/e2e/cases/assets/assets-retry/index.test.ts +++ b/e2e/cases/assets/assets-retry/index.test.ts @@ -77,6 +77,7 @@ async function createRsbuildWithMiddleware( options: PluginAssetsRetryOptions, entry?: string, port?: number, + assetPrefix?: string ) { const rsbuild = await dev({ cwd: __dirname, @@ -93,6 +94,9 @@ async function createRsbuildWithMiddleware( middlewares.unshift(...addMiddleWares); }, ], + ...(assetPrefix ? { + assetPrefix + }: {}) }, ...(port ? { @@ -655,6 +659,42 @@ test('should work with addQuery boolean option', async ({ page }) => { logger.level = 'log'; }); +test('should work with addQuery boolean option when retrying async css chunk', async ({ page }) => { + logger.level = 'verbose'; + const { logs, restore } = proxyConsole(); + + const asyncChunkBlockedMiddleware = createBlockMiddleware({ + blockNum: 3, + urlPrefix: '/static/css/async/src_AsyncCompTest_tsx.css', + }); + const rsbuild = await createRsbuildWithMiddleware( + asyncChunkBlockedMiddleware, + { + minify: true, + addQuery: true, + }, + ); + + await gotoPage(page, rsbuild); + const asyncCompTestElement = page.locator('#async-comp-test'); + await expect(asyncCompTestElement).toHaveText('Hello AsyncCompTest'); + await expect(asyncCompTestElement).toHaveCSS('background-color', 'rgb(0, 0, 139)'); + + const blockedAsyncChunkResponseCount = count404ResponseByUrl( + logs, + '/static/css/async/src_AsyncCompTest_tsx.css', + ); + expect(blockedAsyncChunkResponseCount).toMatchObject({ + '/static/css/async/src_AsyncCompTest_tsx.css': 1, + '/static/css/async/src_AsyncCompTest_tsx.css?retry=1': 1, + '/static/css/async/src_AsyncCompTest_tsx.css?retry=2': 1, + }); + + await rsbuild.close(); + restore(); + logger.level = 'log'; +}); + test('should work with addQuery function type option', async ({ page }) => { logger.level = 'verbose'; const { logs, restore } = proxyConsole(); @@ -860,3 +900,33 @@ test('onRetry and onFail options should work when multiple parallel retrying asy }); await rsbuild.close(); }); + +test('should work when the first, second cdn are all failed and the third is success', async ({ page }) => { + // this is a real world case for assets-retry + const port = await getRandomPort(); + const rsbuild = await createRsbuildWithMiddleware( + [], + { + minify: true, + domain: ['http://a.com/foo-path', 'http://b.com', `http://localhost:${port}`], + addQuery: true, + onRetry(context) { + console.info('onRetry', context); + }, + onSuccess(context) { + console.info('onSuccess', context); + }, + onFail(context) { + console.info('onFail', context); + }, + }, + undefined, + port, + 'http://a.com/foo-path' + ); + + await gotoPage(page, rsbuild); + const compTestElement = page.locator('#async-comp-test'); + await expect(compTestElement).toHaveText('Hello AsyncCompTest'); + await expect(compTestElement).toHaveCSS('background-color', 'rgb(0, 0, 139)'); +}) diff --git a/packages/plugin-assets-retry/src/AsyncChunkRetryPlugin.ts b/packages/plugin-assets-retry/src/AsyncChunkRetryPlugin.ts index 0a389c6a79..51c6417098 100644 --- a/packages/plugin-assets-retry/src/AsyncChunkRetryPlugin.ts +++ b/packages/plugin-assets-retry/src/AsyncChunkRetryPlugin.ts @@ -7,30 +7,45 @@ import type { PluginAssetsRetryOptions, RuntimeRetryOptions } from './types.js'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); -// https://github.com/web-infra-dev/rspack/pull/5370 -function appendWebpackScript(module: any, appendSource: string) { +function modifyWebpackRuntimeModule( + module: any, + modifier: (originSource: string) => string, +) { try { const originSource = module.getGeneratedCode(); - module.getGeneratedCode = () => `${originSource}\n${appendSource}`; + module.getGeneratedCode = () => modifier(originSource); } catch (err) { console.error('Failed to modify Webpack RuntimeModule'); throw err; } } -function appendRspackScript( +function modifyRspackRuntimeModule( module: any, // JsRuntimeModule type is not exported by Rspack temporarily */ - appendSource: string, + modifier: (originSource: string) => string, ) { try { - const source = module.source.source.toString(); - module.source.source = Buffer.from(`${source}\n${appendSource}`, 'utf-8'); + const originSource = module.source.source.toString(); + module.source.source = Buffer.from(modifier(originSource), 'utf-8'); } catch (err) { console.error('Failed to modify Rspack RuntimeModule'); throw err; } } +// https://github.com/web-infra-dev/rspack/pull/5370 +function modifyRuntimeModule( + module: any, + modifier: (originSource: string) => string, + isRspack: boolean, +) { + if (isRspack) { + modifyRspackRuntimeModule(module, modifier); + } else { + modifyWebpackRuntimeModule(module, modifier); + } +} + function pick(obj: T, keys: ReadonlyArray) { return keys.reduce( (ret, key) => { @@ -89,6 +104,10 @@ class AsyncChunkRetryPlugin implements Rspack.RspackPluginInstance { '__RUNTIME_GLOBALS_GET_MINI_CSS_EXTRACT_FILENAME__', '__webpack_require__.miniCssF', ) + .replaceAll( + '__RUNTIME_GLOBALS_RSBUILD_LOAD_STYLESHEET__', + '__webpack_require__.rsbuildLoadStyleSheet', + ) .replaceAll('__RUNTIME_GLOBALS_PUBLIC_PATH__', RuntimeGlobals.publicPath) .replaceAll('__RUNTIME_GLOBALS_LOAD_SCRIPT__', RuntimeGlobals.loadScript) .replaceAll('__RETRY_OPTIONS__', serialize(this.runtimeOptions)); @@ -102,23 +121,38 @@ class AsyncChunkRetryPlugin implements Rspack.RspackPluginInstance { ? module.constructorName : module.constructor?.name; + const isCssLoadingRuntimeModule = + constructorName === 'CssLoadingRuntimeModule'; + + // https://github.com/web-infra-dev/rspack/blob/734ba4cfbec00ab68ff55bac95e7740fe8228229/crates/rspack_plugin_extract_css/src/runtime/css_load.js#L54 + if (isCssLoadingRuntimeModule) { + modifyRuntimeModule( + module, + (originSource) => + originSource.replace( + 'var fullhref = __webpack_require__.p + href;', + 'var fullhref = __webpack_require__.rsbuildLoadStyleSheet ? __webpack_require__.rsbuildLoadStyleSheet(href, chunkId) : (__webpack_require__.p + href);', + ), + isRspack, + ); + return; + } + const isPublicPathModule = module.name === 'publicPath' || constructorName === 'PublicPathRuntimeModule' || constructorName === 'AutoPublicPathRuntimeModule'; - if (!isPublicPathModule) { - return; - } - - const runtimeCode = this.getRawRuntimeRetryCode(); + if (isPublicPathModule) { + const runtimeCode = this.getRawRuntimeRetryCode(); - // Rspack currently does not have module.addRuntimeModule on the js side, - // so we insert our runtime code after PublicPathRuntimeModule or AutoPublicPathRuntimeModule. - if (isRspack) { - appendRspackScript(module, runtimeCode); - } else { - appendWebpackScript(module, runtimeCode); + // Rspack currently does not have module.addRuntimeModule on the js side, + // so we insert our runtime code after PublicPathRuntimeModule or AutoPublicPathRuntimeModule. + modifyRuntimeModule( + module, + (originSource) => `${originSource}\n${runtimeCode}`, + isRspack, + ); } }); }); diff --git a/packages/plugin-assets-retry/src/runtime/asyncChunkRetry.ts b/packages/plugin-assets-retry/src/runtime/asyncChunkRetry.ts index 2b5ded1c70..07aa4d4197 100644 --- a/packages/plugin-assets-retry/src/runtime/asyncChunkRetry.ts +++ b/packages/plugin-assets-retry/src/runtime/asyncChunkRetry.ts @@ -6,6 +6,7 @@ type ChunkSrcUrl = string; // publicPath + ChunkFilename e.g: http://localhost:3 type Retry = { nextDomain: string; nextRetryUrl: ChunkSrcUrl; + originalScriptFilename: ChunkFilename; originalSrcUrl: ChunkSrcUrl; originalQuery: string; @@ -20,6 +21,7 @@ type LoadScript = ( chunkId: ChunkId, ...args: unknown[] ) => void; +type LoadStyleSheet = (href: string, chunkId: ChunkId) => string; declare global { // RuntimeGlobals.require @@ -41,6 +43,8 @@ declare global { | undefined; // RuntimeGlobals.loadScript var __RUNTIME_GLOBALS_LOAD_SCRIPT__: LoadScript; + // __webpack_require__.rsbuildLoadStyleSheet + var __RUNTIME_GLOBALS_RSBUILD_LOAD_STYLESHEET__: LoadStyleSheet; // RuntimeGlobals.publicPath var __RUNTIME_GLOBALS_PUBLIC_PATH__: string; // user options @@ -53,6 +57,7 @@ declare global { const config = __RETRY_OPTIONS__; const maxRetries = config.max || 3; const retryCollector: RetryCollector = {}; +const retryCssCollector: RetryCollector = {}; function findCurrentDomain(url: string) { const domainList = config.domain ?? []; @@ -111,16 +116,28 @@ function getNextRetryUrl( } // shared between ensureChunk and loadScript -const globalCurrRetrying: Record = {}; +const globalCurrRetrying: Record = {}; +// shared between ensureChunk and loadStyleSheet +const globalCurrRetryingCss: Record = {}; + function getCurrentRetry( chunkId: string, existRetryTimes: number, + isCssAsyncChunk: boolean, ): Retry | undefined { - return retryCollector[chunkId]?.[existRetryTimes]; + return isCssAsyncChunk + ? retryCssCollector[chunkId]?.[existRetryTimes] + : retryCollector[chunkId]?.[existRetryTimes]; } -function initRetry(chunkId: string): Retry { - const originalScriptFilename = originalGetChunkScriptFilename(chunkId); +function initRetry(chunkId: string, isCssAsyncChunk: boolean): Retry { + const originalScriptFilename = isCssAsyncChunk + ? originalGetCssFilename(chunkId) + : originalGetChunkScriptFilename(chunkId); + + if (!originalScriptFilename) { + throw new Error('only support cssExtract'); + } const originalPublicPath = __RUNTIME_GLOBALS_PUBLIC_PATH__; const originalSrcUrl = originalPublicPath.startsWith('/') @@ -140,22 +157,29 @@ function initRetry(chunkId: string): Retry { existRetryTimes, originalQuery, ), - originalScriptFilename, originalSrcUrl, originalQuery, }; } -function nextRetry(chunkId: string, existRetryTimes: number): Retry { - const currRetry = getCurrentRetry(chunkId, existRetryTimes); +function nextRetry( + chunkId: string, + existRetryTimes: number, + isCssAsyncChunk: boolean, +): Retry { + const currRetry = getCurrentRetry(chunkId, existRetryTimes, isCssAsyncChunk); let nextRetry: Retry; const nextExistRetryTimes = existRetryTimes + 1; if (existRetryTimes === 0 || currRetry === undefined) { - nextRetry = initRetry(chunkId); - retryCollector[chunkId] = []; + nextRetry = initRetry(chunkId, isCssAsyncChunk); + if (isCssAsyncChunk) { + retryCssCollector[chunkId] = []; + } else { + retryCollector[chunkId] = []; + } } else { const { originalScriptFilename, originalSrcUrl, originalQuery } = currRetry; const nextDomain = findNextDomain(currRetry.nextDomain); @@ -176,8 +200,13 @@ function nextRetry(chunkId: string, existRetryTimes: number): Retry { }; } - retryCollector[chunkId][nextExistRetryTimes] = nextRetry; - globalCurrRetrying[chunkId] = nextRetry; + if (isCssAsyncChunk) { + retryCssCollector[chunkId][nextExistRetryTimes] = nextRetry; + globalCurrRetryingCss[chunkId] = nextRetry; + } else { + retryCollector[chunkId][nextExistRetryTimes] = nextRetry; + globalCurrRetrying[chunkId] = nextRetry; + } return nextRetry; } @@ -201,9 +230,9 @@ function ensureChunk(chunkId: string): Promise { // Other webpack runtimes would add arguments for `__webpack_require__.e`, // So we use `arguments[10]` to avoid conflicts with other runtimes if (!args[10]) { - args[10] = { count: 0 }; + args[10] = { count: 0, cssFailedCount: 0 }; } - const callingCounter: { count: number } = args[10]; + const callingCounter: { count: number; cssFailedCount: number } = args[10]; const result = originalEnsureChunk.apply( null, @@ -228,7 +257,11 @@ function ensureChunk(chunkId: string): Promise { } // if __webpack_require__.e is polluted by other runtime codes, fallback to originalEnsureChunk - if (typeof callingCounter?.count !== 'number') { + if ( + !callingCounter || + typeof callingCounter.count !== 'number' || + typeof callingCounter.cssFailedCount !== 'number' + ) { return result; } @@ -237,13 +270,30 @@ function ensureChunk(chunkId: string): Promise { return result.catch((error: Error) => { // the first calling is not retry // if the failed request is 4 in network panel, callingCounter.count === 4, the first one is the normal request, and existRetryTimes is 3, retried 3 times - const existRetryTimes = callingCounter.count - 1; + const existRetryTimesAll = callingCounter.count - 1; + const cssExistRetryTimes = callingCounter.cssFailedCount; + const jsExistRetryTimes = existRetryTimesAll - cssExistRetryTimes; let originalScriptFilename: string; let nextRetryUrl: string; let nextDomain: string; + const isCssAsyncChunkLoadFailed = Boolean( + error?.message?.includes('CSS chunk'), + ); + if (isCssAsyncChunkLoadFailed) { + callingCounter.cssFailedCount += 1; + } + + const existRetryTimes = isCssAsyncChunkLoadFailed + ? cssExistRetryTimes + : jsExistRetryTimes; + try { - const retryResult = nextRetry(chunkId, existRetryTimes); + const retryResult = nextRetry( + chunkId, + existRetryTimes, + isCssAsyncChunkLoadFailed, + ); originalScriptFilename = retryResult.originalScriptFilename; nextRetryUrl = retryResult.nextRetryUrl; nextDomain = retryResult.nextDomain; @@ -252,13 +302,6 @@ function ensureChunk(chunkId: string): Promise { throw error; } - // At present, we don't consider the switching domain and addQuery of async CSS chunk - // 1. Async js chunk will be requested first. It is rare for async CSS chunk to fail alone. - // 2. the code of loading CSS in webpack runtime is complex and it may be modified by cssExtractPlugin, increase the complexity of this plugin. - const isCssAsyncChunkLoadFailed = Boolean( - error?.message?.includes('CSS chunk'), - ); - const createContext = (times: number): AssetsRetryHookContext => ({ times, domain: nextDomain, @@ -309,8 +352,9 @@ function ensureChunk(chunkId: string): Promise { return nextPromise.then((result) => { // when after retrying the third time // ensureChunk(chunkId, { count: 3 }), at that time, existRetryTimes === 2 - // after all, callingCounter.count is 4 - const isLastSuccessRetry = callingCounter?.count === existRetryTimes + 2; + // at the end, callingCounter.count is 4 + const isLastSuccessRetry = + callingCounter?.count === existRetryTimesAll + 2; if (typeof config.onSuccess === 'function' && isLastSuccessRetry) { const context = createContext(existRetryTimes + 1); config.onSuccess(context); @@ -330,6 +374,15 @@ function loadScript() { return originalLoadScript.apply(null, args); } +function loadStyleSheet(href: string, chunkId: ChunkId): string { + const retry = globalCurrRetryingCss[chunkId]; + if (retry?.nextRetryUrl) { + return retry.nextRetryUrl; + } + + return __RUNTIME_GLOBALS_PUBLIC_PATH__ + href; +} + function registerAsyncChunkRetry() { // init global variables shared between initial-chunk-retry and async-chunk-retry if (typeof window !== 'undefined' && !window.__RB_ASYNC_CHUNKS__) { @@ -343,6 +396,7 @@ function registerAsyncChunkRetry() { ...args: unknown[] ) => Promise; __RUNTIME_GLOBALS_LOAD_SCRIPT__ = loadScript; + __RUNTIME_GLOBALS_RSBUILD_LOAD_STYLESHEET__ = loadStyleSheet; } catch (e) { console.error( ERROR_PREFIX, diff --git a/scripts/dictionary.txt b/scripts/dictionary.txt index 96d9dc3a81..803c85271b 100644 --- a/scripts/dictionary.txt +++ b/scripts/dictionary.txt @@ -38,6 +38,7 @@ flexbugs fnames frontends fullhash +fullhref gzipped icss idents diff --git a/website/docs/en/plugins/list/plugin-assets-retry.mdx b/website/docs/en/plugins/list/plugin-assets-retry.mdx index 13de431d78..749ecf77bb 100644 --- a/website/docs/en/plugins/list/plugin-assets-retry.mdx +++ b/website/docs/en/plugins/list/plugin-assets-retry.mdx @@ -276,7 +276,7 @@ pluginAssetsRetry({ ## Notes -When you use Assets Retry plugin, the Rsbuild injects some runtime code into the HTML and serializes the Assets Retry plugin config, inserting it into the runtime code. Therefore, you need to be aware of the following: +When you use Assets Retry plugin, the Rsbuild injects some runtime code into the HTML and [Rspack Runtime](https://rspack.dev/misc/glossary#runtime), then serializes the Assets Retry plugin config, inserting it into the runtime code. Therefore, you need to be aware of the following: - Avoid configuring sensitive information in Assets Retry plugin, such as internal tokens. - Avoid referencing variables or methods outside of `onRetry`, `onSuccess`, and `onFail`. diff --git a/website/docs/zh/plugins/list/plugin-assets-retry.mdx b/website/docs/zh/plugins/list/plugin-assets-retry.mdx index ed7ac8f27f..36d7df9990 100644 --- a/website/docs/zh/plugins/list/plugin-assets-retry.mdx +++ b/website/docs/zh/plugins/list/plugin-assets-retry.mdx @@ -276,7 +276,7 @@ pluginAssetsRetry({ ## 注意事项 -当你使用 Assets Retry 插件时,Rsbuild 会向 HTML 中注入一段运行时代码,并将 Assets Retry 插件配置的内容序列化,插入到这段代码中,因此你需要注意: +当你使用 Assets Retry 插件时,Rsbuild 会分别向 HTML 和 [Rspack Runtime](https://rspack.dev/zh/misc/glossary#runtime) 中注入运行时代码,并将 Assets Retry 插件配置的内容序列化后插入到这些代码中,因此你需要注意: - 避免在 Assets Retry 插件中配置敏感信息,比如内部使用的 token。 - 避免在 `onRetry`,`onSuccess`,`onFail` 中引用函数外部的变量或方法。