fix: resolve cache invalidation issues in asset prefetch implementation

This commit is contained in:
Ryuya 2025-07-27 04:10:59 -07:00
parent 43e8a85399
commit 4aaaf13071
12 changed files with 63 additions and 182 deletions

View File

@ -28,8 +28,6 @@ const AssetPrefetchStartupRuntimeModule = require("./AssetPrefetchStartupRuntime
* @property {AssetInfo[]} preload
*/
/** @typedef {import("../Chunk") & { _assetPrefetchInfo?: AssetPrefetchInfo }} ChunkWithAssetInfo */
const PLUGIN_NAME = "AssetPrefetchStartupPlugin";
class AssetPrefetchStartupPlugin {
@ -39,9 +37,8 @@ class AssetPrefetchStartupPlugin {
*/
apply(compiler) {
compiler.hooks.compilation.tap(PLUGIN_NAME, (compilation) => {
// Store asset prefetch/preload info per chunk
// Using WeakMap to allow garbage collection
const assetPrefetchMap = new WeakMap();
const chunkAssetInfoMap = new WeakMap();
// Hook into finishModules to collect all URLDependencies
compilation.hooks.finishModules.tap(PLUGIN_NAME, (modules) => {
@ -96,9 +93,17 @@ class AssetPrefetchStartupPlugin {
).request;
if (!request) continue;
// Get the relative asset path (webpack will handle as relative to runtime)
// We just need the filename, not the full path
const assetUrl = request.split("/").pop() || request;
// Get the actual asset filename from module buildInfo
let assetUrl;
if (
resolvedModule.buildInfo &&
resolvedModule.buildInfo.filename
) {
assetUrl = resolvedModule.buildInfo.filename;
} else {
// Fallback to extracting from request
assetUrl = request.split(/[\\/]/).pop() || request;
}
const assetType =
AssetPrefetchStartupPlugin._getAssetType(request);
@ -117,33 +122,26 @@ class AssetPrefetchStartupPlugin {
}
}
// Store collected asset info on the chunk
if (assetInfo.prefetch.length > 0 || assetInfo.preload.length > 0) {
const chunkWithInfo = /** @type {ChunkWithAssetInfo} */ (chunk);
if (!chunkWithInfo._assetPrefetchInfo) {
chunkWithInfo._assetPrefetchInfo = assetInfo;
const existing = chunkAssetInfoMap.get(chunk);
if (!existing) {
chunkAssetInfoMap.set(chunk, assetInfo);
} else {
// Merge with existing info
chunkWithInfo._assetPrefetchInfo.prefetch.push(
...assetInfo.prefetch
);
chunkWithInfo._assetPrefetchInfo.preload.push(
...assetInfo.preload
);
existing.prefetch.push(...assetInfo.prefetch);
existing.preload.push(...assetInfo.preload);
}
}
}
}
);
// Add runtime requirements and modules
compilation.hooks.additionalChunkRuntimeRequirements.tap(
PLUGIN_NAME,
(chunk, set) => {
const chunkWithInfo = /** @type {ChunkWithAssetInfo} */ (chunk);
if (!chunkWithInfo._assetPrefetchInfo) return;
const assetInfo = chunkAssetInfoMap.get(chunk);
if (!assetInfo) return;
const { prefetch, preload } = chunkWithInfo._assetPrefetchInfo;
const { prefetch, preload } = assetInfo;
if (prefetch.length > 0) {
set.add(RuntimeGlobals.prefetchAsset);
@ -153,13 +151,10 @@ class AssetPrefetchStartupPlugin {
set.add(RuntimeGlobals.preloadAsset);
}
// Add startup runtime module for assets
if (prefetch.length > 0 || preload.length > 0) {
compilation.addRuntimeModule(
chunk,
new AssetPrefetchStartupRuntimeModule(
chunkWithInfo._assetPrefetchInfo
)
new AssetPrefetchStartupRuntimeModule(assetInfo)
);
}
}

View File

@ -35,6 +35,15 @@ class AssetPrefetchStartupRuntimeModule extends RuntimeModule {
this.assetInfo = assetInfo;
}
/**
* @returns {string} a unique identifier of the module
*/
identifier() {
return `webpack/runtime/asset-prefetch-startup|${JSON.stringify(
this.assetInfo
)}`;
}
/**
* @returns {string | null} runtime code
*/
@ -45,14 +54,13 @@ class AssetPrefetchStartupRuntimeModule extends RuntimeModule {
const lines = [];
// Helper to serialize asset info
/**
* @param {AssetInfo} asset The asset information to serialize
* @returns {string} Serialized arguments for prefetch/preload function
*/
const serializeAsset = (asset) => {
const args = [
`${RuntimeGlobals.publicPath} + "${asset.url}"`,
`${RuntimeGlobals.publicPath} + ${JSON.stringify(asset.url)}`,
`"${asset.as}"`
];
@ -69,21 +77,20 @@ class AssetPrefetchStartupRuntimeModule extends RuntimeModule {
return args.join(", ");
};
// Generate prefetch code
if (assetInfo.prefetch.length > 0) {
const prefetchCode =
assetInfo.prefetch.length <= 2
? // For few assets, generate direct calls
assetInfo.prefetch.map(
? assetInfo.prefetch.map(
(asset) =>
`${RuntimeGlobals.prefetchAsset}(${serializeAsset(asset)});`
)
: // For many assets, use array iteration
Template.asString([
: Template.asString([
`[${assetInfo.prefetch
.map(
(asset) =>
`{ url: ${RuntimeGlobals.publicPath} + "${asset.url}", as: "${asset.as}"${
`{ url: ${RuntimeGlobals.publicPath} + ${JSON.stringify(
asset.url
)}, as: "${asset.as}"${
asset.fetchPriority
? `, fetchPriority: "${asset.fetchPriority}"`
: ""
@ -101,21 +108,20 @@ class AssetPrefetchStartupRuntimeModule extends RuntimeModule {
}
}
// Generate preload code with higher priority
if (assetInfo.preload.length > 0) {
const preloadCode =
assetInfo.preload.length <= 2
? // For few assets, generate direct calls
assetInfo.preload.map(
? assetInfo.preload.map(
(asset) =>
`${RuntimeGlobals.preloadAsset}(${serializeAsset(asset)});`
)
: // For many assets, use array iteration
Template.asString([
: Template.asString([
`[${assetInfo.preload
.map(
(asset) =>
`{ url: ${RuntimeGlobals.publicPath} + "${asset.url}", as: "${asset.as}"${
`{ url: ${RuntimeGlobals.publicPath} + ${JSON.stringify(
asset.url
)}, as: "${asset.as}"${
asset.fetchPriority
? `, fetchPriority: "${asset.fetchPriority}"`
: ""
@ -135,6 +141,13 @@ class AssetPrefetchStartupRuntimeModule extends RuntimeModule {
return Template.asString(lines);
}
/**
* @returns {boolean} true, if the runtime module should get it's own scope
*/
shouldIsolate() {
return false;
}
}
module.exports = AssetPrefetchStartupRuntimeModule;

View File

@ -58,6 +58,11 @@ it("should generate all prefetch and preload links", () => {
/* webpackPrefetch: true */
"./assets/images/test.png",
import.meta.url
),
preloadFont: new URL(
/* webpackPreload: true */
"./assets/fonts/test.woff2",
import.meta.url
)
};
@ -121,5 +126,15 @@ it("should generate all prefetch and preload links", () => {
as: "image",
fetchPriority: undefined
});
const fontPreloadLink = document.head._children.find(
link => link.href.includes("test.woff2") && link.rel === "preload"
);
expect(fontPreloadLink).toBeTruthy();
verifyLink(fontPreloadLink, {
rel: "preload",
as: "font",
href: /test\.woff2$/
});
});

View File

@ -1,142 +0,0 @@
const AssetPrefetchPreloadRuntimeModule = require("../../lib/runtime/AssetPrefetchPreloadRuntimeModule");
const Template = require("../../lib/Template");
describe("AssetPrefetchPreloadRuntimeModule", () => {
const mockCompilation = {
outputOptions: {
crossOriginLoading: false
}
};
const mockRuntimeTemplate = {
basicFunction: (args, body) => {
if (typeof body === "string") {
return `function(${args}){${body}}`;
}
return `function(${args}){${Template.asString(body)}}`;
}
};
beforeEach(() => {
// Mock for runtime environment
global.__webpack_require__ = {
p: "/",
u: id => `${id}.js`
};
global.RuntimeGlobals = {
prefetchAsset: "__webpack_require__.PA",
preloadAsset: "__webpack_require__.LA"
};
global.document = {
head: {
appendChild: jest.fn()
},
createElement: jest.fn(tag => ({
tag,
setAttribute: jest.fn()
}))
};
});
afterEach(() => {
delete global.__webpack_require__;
delete global.RuntimeGlobals;
delete global.document;
});
describe("prefetch module", () => {
it("should generate runtime code for prefetch", () => {
const module = new AssetPrefetchPreloadRuntimeModule("prefetch");
module.compilation = mockCompilation;
module.runtimeTemplate = mockRuntimeTemplate;
const code = module.generate();
expect(code).toContain("__webpack_require__.PAQueue = [];");
expect(code).toContain("__webpack_require__.PAQueueProcessing = false;");
expect(code).toContain("processPrefetchQueue");
expect(code).toContain("link.rel = 'prefetch';");
expect(code).toContain("PAQueue.sort");
expect(code).toContain("order: order || 0");
});
it("should support fetchPriority attribute", () => {
const module = new AssetPrefetchPreloadRuntimeModule("prefetch");
module.compilation = mockCompilation;
module.runtimeTemplate = mockRuntimeTemplate;
const code = module.generate();
expect(code).toContain("if(item.fetchPriority)");
expect(code).toContain("link.fetchPriority = item.fetchPriority;");
expect(code).toContain("link.setAttribute('fetchpriority', item.fetchPriority);");
});
it("should handle numeric order for queue sorting", () => {
const module = new AssetPrefetchPreloadRuntimeModule("prefetch");
module.compilation = mockCompilation;
module.runtimeTemplate = mockRuntimeTemplate;
const code = module.generate();
expect(code).toContain("// Sort queue by order (lower numbers first)");
expect(code).toContain("return a.order - b.order;");
});
});
describe("preload module", () => {
it("should generate runtime code for preload", () => {
const module = new AssetPrefetchPreloadRuntimeModule("preload");
module.compilation = mockCompilation;
module.runtimeTemplate = mockRuntimeTemplate;
const code = module.generate();
expect(code).toContain("__webpack_require__.LAQueue = [];");
expect(code).toContain("__webpack_require__.LAQueueProcessing = false;");
expect(code).toContain("processPreloadQueue");
expect(code).toContain("link.rel = 'preload';");
expect(code).toContain("LAQueue.sort");
expect(code).toContain("order: order || 0");
});
it("should add nonce when crossOriginLoading is enabled", () => {
const module = new AssetPrefetchPreloadRuntimeModule("preload");
module.compilation = {
outputOptions: {
crossOriginLoading: true
}
};
module.runtimeTemplate = mockRuntimeTemplate;
const code = module.generate();
expect(code).toContain("if(__webpack_require__.nc)");
expect(code).toContain("link.setAttribute('nonce', __webpack_require__.nc);");
});
});
describe("queue processing", () => {
it("should process items sequentially with setTimeout", () => {
const module = new AssetPrefetchPreloadRuntimeModule("prefetch");
module.compilation = mockCompilation;
module.runtimeTemplate = mockRuntimeTemplate;
const code = module.generate();
expect(code).toContain("// Process next item after a small delay to avoid blocking");
expect(code).toContain("setTimeout(processNext, 0);");
});
it("should handle empty queue", () => {
const module = new AssetPrefetchPreloadRuntimeModule("prefetch");
module.compilation = mockCompilation;
module.runtimeTemplate = mockRuntimeTemplate;
const code = module.generate();
expect(code).toContain("if (__webpack_require__.PAQueue.length === 0)");
expect(code).toContain("__webpack_require__.PAQueueProcessing = false;");
expect(code).toContain("return;");
});
});
});