Skip to content

Commit 6c3e64d

Browse files
committed
Fix webpack cache script recovery
1 parent 1cae551 commit 6c3e64d

6 files changed

Lines changed: 280 additions & 30 deletions

File tree

src/Plugin/AssetCompiler.js

Lines changed: 20 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -312,33 +312,29 @@ class AssetCompiler {
312312
// TODO: init by PluginIndex, for each instance create own PluginService instance for pluginOption
313313
PluginService.init(compiler, this.pluginContext, AssetCompiler);
314314

315+
this.cacheDataComplete = false;
316+
315317
if (this.pluginOption.isCacheable()) {
316318
const collectionCache = createPersistentCache(this.collection);
317319
const cache = compiler.getCache(pluginName).getItemCache('PersistentCache', null);
318-
let isCached = false;
319320

320-
compiler.hooks.beforeCompile.tap(pluginName, () => {
321-
cache.get((error, data) => {
322-
if (error) {
323-
throw new Error(error);
324-
}
325-
isCached = !!data;
321+
compiler.hooks.beforeCompile.tapPromise(pluginName, () => {
322+
return cache.getPromise().catch(() => {
323+
this.collection.clear();
326324
});
327325
});
328326

329-
// note: if used `tapAsync` then no webpack statistics or errors will be displayed
330-
// then use in the `done` hook the output of `stats.compilation.options.stats` in Promise.finally
331-
//compiler.cache.hooks.shutdown.tapAsync({ name: pluginName, stage: Cache.STAGE_DISK }, () => {
332-
compiler.cache.hooks.shutdown.tap({ name: pluginName, stage: Cache.STAGE_DISK }, () => {
333-
if (!isCached) {
334-
const cacheData = collectionCache.getData();
327+
// Store before Webpack's disk cache shutdown task collects pending cache writes.
328+
compiler.cache.hooks.shutdown.tap({ name: pluginName, stage: Cache.STAGE_DISK - 1 }, () => {
329+
if (!this.cacheDataComplete) return;
335330

336-
cache.store(cacheData, (error) => {
337-
if (error) {
338-
throw new Error(error);
339-
}
340-
});
341-
}
331+
const cacheData = collectionCache.getData();
332+
333+
cache.store(cacheData, (error) => {
334+
if (error) {
335+
throw new Error(error);
336+
}
337+
});
342338
});
343339
}
344340

@@ -405,6 +401,7 @@ class AssetCompiler {
405401
const normalModuleHooks = NormalModule.getCompilationHooks(compilation);
406402
const renderStage = this.pluginOption.getRenderStage();
407403

404+
this.cacheDataComplete = false;
408405
this.IS_WEBPACK_VERSION_LOWER_5_96_0 = compareVersions(compilation.compiler.webpack.version, '<', '5.96.0');
409406

410407
this.compilation = compilation;
@@ -1776,6 +1773,8 @@ class AssetCompiler {
17761773
// }
17771774

17781775
if (this.exceptions.size > 0) {
1776+
this.cacheDataComplete = false;
1777+
17791778
const messages = Array.from(this.exceptions)
17801779
.map((error) => (error.stack ? error.stack : error.toString()))
17811780
.reduce((previousValue, currentValue) => previousValue + currentValue, '');
@@ -1786,6 +1785,8 @@ class AssetCompiler {
17861785

17871786
if (this.pluginOption.isVerbose()) verbose(pluginCompiler);
17881787

1788+
this.cacheDataComplete = !hasError;
1789+
17891790
this.asset.reset();
17901791
this.assetEntry.reset();
17911792
this.assetTrash.reset();

src/Plugin/Collection.js

Lines changed: 142 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -484,6 +484,99 @@ class Collection {
484484
return this.assets.get(resource)?.type === Collection.type.script;
485485
}
486486

487+
/**
488+
* Recover a script entry from Webpack's cached chunk graph.
489+
*
490+
* This is a fallback for the case where Webpack restores a cached template
491+
* module, but the plugin's persistent collection cache is missing the script
492+
* resource that the restored module still requires.
493+
*
494+
* @param {{resource: string, issuer: FileInfo, entry: AssetEntryOptions}} options
495+
* @return {boolean}
496+
*/
497+
recoverMissingScript({ resource, issuer, entry }) {
498+
const issuerResource = issuer?.resource;
499+
const entryFilename = entry?.filename;
500+
501+
if (!issuerResource || !entryFilename) return false;
502+
503+
let item = this.assets.get(resource);
504+
if (item && item.type !== Collection.type.script) return false;
505+
506+
const name = item?.name || this.#findScriptEntryName(resource);
507+
if (!name) return false;
508+
509+
if (!item) {
510+
item = {
511+
type: Collection.type.script,
512+
inline: undefined,
513+
name,
514+
entries: new Map(),
515+
assets: [],
516+
};
517+
this.assets.set(resource, item);
518+
} else if (!item.name) {
519+
item.name = name;
520+
}
521+
522+
let entryFilenames = item.entries.get(issuerResource);
523+
if (!entryFilenames) {
524+
entryFilenames = new Set();
525+
item.entries.set(issuerResource, entryFilenames);
526+
}
527+
528+
entryFilenames.add(entryFilename);
529+
530+
if (entry.id != null) {
531+
let orderedResources = this.orderedResources.get(entry.id);
532+
if (!orderedResources) {
533+
orderedResources = new Set();
534+
this.orderedResources.set(entry.id, orderedResources);
535+
}
536+
orderedResources.add(resource);
537+
}
538+
539+
return true;
540+
}
541+
542+
/**
543+
* Find an entrypoint containing the script resource.
544+
*
545+
* @param {string} resource The script resource.
546+
* @return {string|null}
547+
*/
548+
#findScriptEntryName(resource) {
549+
const compilation = this.compilation;
550+
const chunkGraph = compilation?.chunkGraph;
551+
const namedChunkGroups =
552+
compilation?.entrypoints?.size > 0 ? compilation.entrypoints : compilation?.namedChunkGroups;
553+
const [sourceFile] = resource.split('?', 1);
554+
555+
if (!chunkGraph || !namedChunkGroups) return null;
556+
557+
for (const [name, entrypoint] of namedChunkGroups) {
558+
for (const chunk of entrypoint.chunks) {
559+
const modules = chunkGraph.getChunkModulesIterable(chunk);
560+
561+
if (!modules) continue;
562+
563+
for (const module of modules) {
564+
const moduleResource = module.resource;
565+
if (!moduleResource) continue;
566+
567+
const [moduleFile] = moduleResource.split('?', 1);
568+
if (moduleResource === resource || moduleFile === sourceFile) {
569+
const entry = this.assetEntry?.entriesByName.get(name);
570+
571+
if (!entry?.isTemplate) return name;
572+
}
573+
}
574+
}
575+
}
576+
577+
return null;
578+
}
579+
487580
/**
488581
* Whether the collection contains the style file.
489582
*
@@ -1308,6 +1401,7 @@ class Collection {
13081401
this.importStyleRootIssuers.clear();
13091402
this.importStyleSources.clear();
13101403
this.importStyleIdx = 1000;
1404+
this.deserialized = false;
13111405
}
13121406

13131407
/**
@@ -1341,6 +1435,7 @@ class Collection {
13411435
// the original functions will be recovered by deserialization from the cached object `AssetEntry`
13421436
entry.filenameFn = null;
13431437
entry.filenameTemplate = null;
1438+
entry.options = null;
13441439
}
13451440

13461441
write(this.assets);
@@ -1352,20 +1447,64 @@ class Collection {
13521447
* @param {Function} read The deserialize function.
13531448
*/
13541449
deserialize({ read }) {
1355-
this.assets = read();
1356-
this.data = read();
1450+
const assets = read();
1451+
const data = read();
1452+
1453+
if (!this.#isDeserializedDataValid(assets, data)) {
1454+
this.clear();
1455+
return;
1456+
}
1457+
1458+
this.assets = assets;
1459+
this.data = data;
1460+
1461+
const assetEntry = this.assetEntry || this.pluginContext.assetEntry;
1462+
1463+
if (!assetEntry) {
1464+
this.clear();
1465+
return;
1466+
}
13571467

13581468
for (let [, { entry }] of this.data) {
1359-
const cachedEntry = this.assetEntry.entriesById.get(entry.id);
1469+
if (!entry.id) continue;
1470+
1471+
const cachedEntry = assetEntry.entriesById.get(entry.id);
1472+
1473+
if (!cachedEntry) {
1474+
this.clear();
1475+
return;
1476+
}
13601477

13611478
// recovery original not serializable functions from the object cached in the memory
13621479
entry.filenameFn = cachedEntry.filenameFn;
13631480
entry.filenameTemplate = cachedEntry.filenameTemplate;
1481+
entry.options = cachedEntry.options;
13641482
}
13651483

13661484
this.deserialized = true;
13671485
}
13681486

1487+
/**
1488+
* @param {Map} assets
1489+
* @param {Map} data
1490+
* @return {boolean}
1491+
*/
1492+
#isDeserializedDataValid(assets, data) {
1493+
if (!(assets instanceof Map) || !(data instanceof Map)) return false;
1494+
1495+
for (const [, item] of assets) {
1496+
if (item == null || typeof item !== 'object') return false;
1497+
if (!item.type || !(item.entries instanceof Map)) return false;
1498+
}
1499+
1500+
for (const [, item] of data) {
1501+
if (item == null || typeof item !== 'object') return false;
1502+
if (item.entry == null || !Array.isArray(item.assets)) return false;
1503+
}
1504+
1505+
return true;
1506+
}
1507+
13691508
isDeserialized() {
13701509
return this.deserialized;
13711510
}

src/Plugin/Resolver.js

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -277,6 +277,15 @@ class Resolver {
277277
const file = resource || rawRequest;
278278

279279
if (this.pluginOption.js.test.test(file) && this.assetEntry.isEntryResource(issuer.resource)) {
280+
const [sourceFile] = file.split('?', 1);
281+
282+
if (
283+
this.fs.existsSync(sourceFile) &&
284+
this.collection.recoverMissingScript({ resource: file, issuer, entry: this.entryPoint })
285+
) {
286+
return file;
287+
}
288+
280289
// occur after rename/delete of a js file when the entry module was already rebuilt
281290
Snapshot.addMissingFile(issuer.resource, file);
282291
resolveException(file, issuer.resource, this.rootContext, this.pluginOption);

test/integration.test.js

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -30,10 +30,7 @@ describe('cache tests', () => {
3030

3131
test('filesystem, display stats', () => stdoutContain('cache-filesystem-display-stats', 'compiled successfully'));
3232
test('filesystem, multiple config', () => compareFiles('cache-filesystem-multi-config'));
33-
test('filesystem-js-runs_n1', () => compareFilesRuns('cache-filesystem-js', false, 1));
34-
35-
// TODO: fix DEP_WEBPACK_COMPILATION_ASSETS warning
36-
//test('filesystem-js-runs_n2', () => compareFilesRuns('cache-filesystem-js', false, 2));
33+
test('filesystem-js-runs_n2', () => compareFilesRuns('cache-filesystem-js', false, 2));
3734
});
3835

3936
describe('resolve files', () => {

test/unit.test.js

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import AssetEntry from '../src/Plugin/AssetEntry';
1616
import Snapshot from '../src/Plugin/Snapshot';
1717
import Option from '../src/Plugin/Option';
1818
import Collection from '../src/Plugin/Collection';
19+
import PluginResolver from '../src/Plugin/Resolver';
1920

2021
const asset = new Asset();
2122
const assetEntry = new AssetEntry({});
@@ -1943,4 +1944,102 @@ describe('misc tests', () => {
19431944
const expected = -1;
19441945
return expect(received).toEqual(expected);
19451946
});
1947+
1948+
test('Collection.recoverMissingScript restores a script from the chunk graph', () => {
1949+
const resource = '/project/src/main.js';
1950+
const issuer = { resource: '/project/src/index.html' };
1951+
const entry = { id: 1, filename: 'index.html' };
1952+
const chunk = {};
1953+
const templateChunk = {};
1954+
const recoveredCollection = new Collection({});
1955+
1956+
recoveredCollection.assetEntry = {
1957+
entriesByName: new Map([['__bundler-plugin-entry__index', { isTemplate: true }]]),
1958+
};
1959+
recoveredCollection.compilation = {
1960+
namedChunkGroups: new Map([
1961+
[
1962+
'__bundler-plugin-entry__index',
1963+
{
1964+
chunks: [templateChunk],
1965+
},
1966+
],
1967+
[
1968+
'main',
1969+
{
1970+
chunks: [chunk],
1971+
},
1972+
],
1973+
]),
1974+
chunkGraph: {
1975+
getChunkModulesIterable: (receivedChunk) =>
1976+
receivedChunk === chunk || receivedChunk === templateChunk ? [{ resource }] : [],
1977+
},
1978+
};
1979+
1980+
const received = recoveredCollection.recoverMissingScript({ resource, issuer, entry });
1981+
const item = recoveredCollection.assets.get(resource);
1982+
1983+
expect(received).toBe(true);
1984+
expect(recoveredCollection.hasScript(resource)).toBe(true);
1985+
expect(item.name).toBe('main');
1986+
expect(Array.from(item.entries.get(issuer.resource))).toEqual([entry.filename]);
1987+
expect(Array.from(recoveredCollection.orderedResources.get(entry.id))).toEqual([resource]);
1988+
});
1989+
1990+
test('Collection.deserialize clears structurally invalid cache data', () => {
1991+
const recoveredCollection = new Collection({});
1992+
const values = [{}, new Map()];
1993+
1994+
recoveredCollection.assets.set('/project/src/main.js', {
1995+
type: Collection.type.script,
1996+
entries: new Map(),
1997+
});
1998+
recoveredCollection.deserialized = true;
1999+
recoveredCollection.deserialize({ read: () => values.shift() });
2000+
2001+
expect(recoveredCollection.isDeserialized()).toBe(false);
2002+
expect(recoveredCollection.assets.size).toBe(0);
2003+
});
2004+
2005+
test('Plugin Resolver recovers an existing script missing from collection cache', () => {
2006+
const script = '/project/src/main.js';
2007+
const issuer = '/project/src/index.html';
2008+
const recoverMissingScript = jest.fn(() => true);
2009+
const resolver = new PluginResolver({
2010+
pluginOption: {
2011+
context: '/project',
2012+
js: { test: /\.js$/ },
2013+
isEntry: () => false,
2014+
},
2015+
assetEntry: {
2016+
isEntryResource: () => true,
2017+
},
2018+
assetInline: {
2019+
getDataUrl: () => null,
2020+
isDataUrl: () => false,
2021+
isSvgFile: () => false,
2022+
},
2023+
collection: {
2024+
hasScript: () => false,
2025+
hasStyle: () => false,
2026+
isInlineStyle: () => false,
2027+
recoverMissingScript,
2028+
},
2029+
});
2030+
2031+
resolver.init({
2032+
fs: {
2033+
existsSync: (file) => file === script,
2034+
},
2035+
});
2036+
resolver.setContext({ filename: 'index.html' }, { resource: issuer, filename: 'index.html' });
2037+
2038+
expect(resolver.require(script)).toBe(script);
2039+
expect(recoverMissingScript).toHaveBeenCalledWith({
2040+
resource: script,
2041+
issuer: { resource: issuer, filename: 'index.html' },
2042+
entry: { filename: 'index.html' },
2043+
});
2044+
});
19462045
});

0 commit comments

Comments
 (0)