Skip to content

Commit c568a10

Browse files
authored
Add "resolveDependencies" option to "this.load" (#4358)
1 parent e88edfd commit c568a10

8 files changed

Lines changed: 214 additions & 35 deletions

File tree

docs/05-plugin-development.md

Lines changed: 70 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -726,13 +726,13 @@ Get ids of the files which has been watched previously. Include both files added
726726
727727
#### `this.load`
728728
729-
**Type:** `({id: string, moduleSideEffects?: boolean | 'no-treeshake' | null, syntheticNamedExports?: boolean | string | null, meta?: {[plugin: string]: any} | null}) => Promise<ModuleInfo>`
729+
**Type:** `({id: string, moduleSideEffects?: boolean | 'no-treeshake' | null, syntheticNamedExports?: boolean | string | null, meta?: {[plugin: string]: any} | null, resolveDependencies?: boolean}) => Promise<ModuleInfo>`
730730
731731
Loads and parses the module corresponding to the given id, attaching additional meta information to the module if provided. This will trigger the same [`load`](guide/en/#load), [`transform`](guide/en/#transform) and [`moduleParsed`](guide/en/#moduleparsed) hooks that would be triggered if the module were imported by another module.
732732
733733
This allows you to inspect the final content of modules before deciding how to resolve them in the [`resolveId`](guide/en/#resolveid) hook and e.g. resolve to a proxy module instead. If the module becomes part of the graph later, there is no additional overhead from using this context function as the module will not be parsed again. The signature allows you to directly pass the return value of [`this.resolve`](guide/en/#thisresolve) to this function as long as it is neither `null` nor external.
734734
735-
The returned promise will resolve once the module has been fully transformed and parsed but before any imports have been resolved. That means that the resulting `ModuleInfo` will have empty `importedIds`, `dynamicallyImportedIds`, `importedIdResolutions` and `dynamicallyImportedIdResolutions`. This helps to avoid deadlock situations when awaiting `this.load` in a `resolveId` hook. If you are interested in `importedIds` and `dynamicallyImportedIds`, you should implement a `moduleParsed` hook.
735+
The returned promise will resolve once the module has been fully transformed and parsed but before any imports have been resolved. That means that the resulting `ModuleInfo` will have empty `importedIds`, `dynamicallyImportedIds`, `importedIdResolutions` and `dynamicallyImportedIdResolutions`. This helps to avoid deadlock situations when awaiting `this.load` in a `resolveId` hook. If you are interested in `importedIds` and `dynamicallyImportedIds`, you can either implement a `moduleParsed` hook or pass the `resolveDependencies` flag, which will make the promise returned by `this.load` wait until all dependency ids have been resolved.
736736
737737
Note that with regard to the `moduleSideEffects`, `syntheticNamedExports` and `meta` options, the same restrictions apply as for the `resolveId` hook: Their values only have an effect if the module has not been loaded yet. Thus, it is very important to use `this.resolve` first to find out if any plugins want to set special values for these options in their `resolveId` hook, and pass these options on to `this.load` if appropriate. The example below showcases how this can be handled to add a proxy module for modules containing a special code comment. Note the special handling for re-exporting the default export:
738738
@@ -777,10 +777,77 @@ export default function addProxyPlugin() {
777777
}
778778
```
779779
780-
If the module was already loaded, this will just wait for the parsing to complete and then return its module information. If the module was not yet imported by another module, this will not automatically trigger loading other modules imported by this module. Instead, static and dynamic dependencies will only be loaded once this module has actually been imported at least once.
780+
If the module was already loaded, `this.load` will just wait for the parsing to complete and then return its module information. If the module was not yet imported by another module, it will not automatically trigger loading other modules imported by this module. Instead, static and dynamic dependencies will only be loaded once this module has actually been imported at least once.
781781
782782
While it is safe to use `this.load` in a `resolveId` hook, you should be very careful when awaiting it in a `load` or `transform` hook. If there are cyclic dependencies in the module graph, this can easily lead to a deadlock, so any plugin needs to manually take care to avoid waiting for `this.load` inside the `load` or `transform` of the any module that is in a cycle with the loaded module.
783783
784+
Here is another, more elaborate example where we scan entire dependency sub-graphs via the `resolveDependencies` option and repeated calls to `this.load`. We use a `Set` of handled module ids to handle cyclic dependencies. The goal of the plugin is to add a log to each dynamically imported chunk that just lists all modules in the chunk. While this is just a toy example, the technique could be used to e.g. create a single style tag for all CSS imported in the sub-graph.
785+
786+
```js
787+
// The leading \0 instructs other plugins not to try to resolve, load or
788+
// transform our proxy modules
789+
const DYNAMIC_IMPORT_PROXY_PREFIX = '\0dynamic-import:';
790+
791+
export default function dynamicChunkLogsPlugin() {
792+
return {
793+
name: 'dynamic-chunk-logs',
794+
async resolveDynamicImport(specifier, importer) {
795+
// Ignore non-static targets
796+
if (!(typeof specifier === 'string')) return;
797+
// Get the id and initial meta information of the import target
798+
const resolved = await this.resolve(specifier, importer);
799+
// Ignore external targets. Explicit externals have the "external"
800+
// property while unresolved imports are "null".
801+
if (resolved && !resolved.external) {
802+
// We trigger loading the module without waiting for it here
803+
// because meta information attached by resolveId hooks, that may
804+
// be contained in "resolved" and that plugins like "commonjs" may
805+
// depend upon, is only attached to a module the first time it is
806+
// loaded.
807+
// This ensures that this meta information is not lost when we later
808+
// use "this.load" again in the load hook with just the module id.
809+
this.load(resolved);
810+
return `${DYNAMIC_IMPORT_PROXY_PREFIX}${resolved.id}`;
811+
}
812+
},
813+
async load(id) {
814+
// Ignore all files except our dynamic import proxies
815+
if (!id.startsWith('\0dynamic-import:')) return null;
816+
const actualId = id.slice(DYNAMIC_IMPORT_PROXY_PREFIX.length);
817+
// To allow loading modules in parallel while keeping complexity low,
818+
// we do not directly await each "this.load" call but put their
819+
// promises into an array where we await them via an async for loop.
820+
const moduleInfoPromises = [this.load({ id: actualId, resolveDependencies: true })];
821+
// We track each loaded dependency here so that we do not load a file
822+
// twice and also do not get stuck when there are circular
823+
// dependencies.
824+
const dependencies = new Set([actualId]);
825+
// "importedIdResolutions" tracks the objects created by resolveId
826+
// hooks. We are using those instead of "importedIds" so that again,
827+
// important meta information is not lost.
828+
for await (const { importedIdResolutions } of moduleInfoPromises) {
829+
for (const resolved of importedIdResolutions) {
830+
if (!dependencies.has(resolved.id)) {
831+
dependencies.add(resolved.id);
832+
moduleInfoPromises.push(this.load({ ...resolved, resolveDependencies: true }));
833+
}
834+
}
835+
}
836+
// We log all modules in a dynamic chunk when it is loaded.
837+
let code = `console.log([${[...dependencies]
838+
.map(JSON.stringify)
839+
.join(', ')}]); export * from ${JSON.stringify(actualId)};`;
840+
// Namespace reexports do not reexport default exports, which is why
841+
// we reexport it manually if it exists
842+
if (this.getModuleInfo(actualId).hasDefaultExport) {
843+
code += `export { default } from ${JSON.stringify(actualId)};`;
844+
}
845+
return code;
846+
}
847+
};
848+
}
849+
```
850+
784851
#### `this.meta`
785852
786853
**Type:** `{rollupVersion: string, watchMode: boolean}`

src/ModuleLoader.ts

Lines changed: 42 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,8 @@ type LoadModulePromise = Promise<
6060
loadAndResolveDependencies: Promise<void>
6161
]
6262
>;
63+
type PreloadType = boolean | 'resolveDependencies';
64+
const RESOLVE_DEPENDENCIES: PreloadType = 'resolveDependencies';
6365

6466
export class ModuleLoader {
6567
private readonly hasModuleSideEffects: HasModuleSideEffects;
@@ -161,12 +163,14 @@ export class ModuleLoader {
161163
return module;
162164
}
163165

164-
public async preloadModule(resolvedId: NormalizedResolveIdWithoutDefaults): Promise<ModuleInfo> {
166+
public async preloadModule(
167+
resolvedId: { id: string; resolveDependencies?: boolean } & Partial<PartialNull<ModuleOptions>>
168+
): Promise<ModuleInfo> {
165169
const module = await this.fetchModule(
166-
this.addDefaultsToResolvedId(resolvedId)!,
170+
this.getResolvedIdWithDefaults(resolvedId)!,
167171
undefined,
168172
false,
169-
true
173+
resolvedId.resolveDependencies ? RESOLVE_DEPENDENCIES : true
170174
);
171175
return module.info;
172176
}
@@ -178,7 +182,7 @@ export class ModuleLoader {
178182
isEntry: boolean | undefined,
179183
skip: readonly { importer: string | undefined; plugin: Plugin; source: string }[] | null = null
180184
): Promise<ResolvedId | null> => {
181-
return this.addDefaultsToResolvedId(
185+
return this.getResolvedIdWithDefaults(
182186
this.getNormalizedResolvedIdWithoutDefaults(
183187
this.options.external(source, importer, false)
184188
? false
@@ -199,23 +203,6 @@ export class ModuleLoader {
199203
);
200204
};
201205

202-
private addDefaultsToResolvedId(
203-
resolvedId: NormalizedResolveIdWithoutDefaults | null
204-
): ResolvedId | null {
205-
if (!resolvedId) {
206-
return null;
207-
}
208-
const external = resolvedId.external || false;
209-
return {
210-
external,
211-
id: resolvedId.id,
212-
meta: resolvedId.meta || {},
213-
moduleSideEffects:
214-
resolvedId.moduleSideEffects ?? this.hasModuleSideEffects(resolvedId.id, !!external),
215-
syntheticNamedExports: resolvedId.syntheticNamedExports ?? false
216-
};
217-
}
218-
219206
private addEntryWithImplicitDependants(
220207
unresolvedModule: UnresolvedModule,
221208
implicitlyLoadedAfter: readonly string[]
@@ -353,7 +340,7 @@ export class ModuleLoader {
353340
{ id, meta, moduleSideEffects, syntheticNamedExports }: ResolvedId,
354341
importer: string | undefined,
355342
isEntry: boolean,
356-
isPreload: boolean
343+
isPreload: PreloadType
357344
): Promise<Module> {
358345
const existingModule = this.modulesById.get(id);
359346
if (existingModule instanceof Module) {
@@ -377,18 +364,18 @@ export class ModuleLoader {
377364
this.getResolveDynamicImportPromises(module),
378365
loadAndResolveDependenciesPromise
379366
]);
380-
const loadAndResolveDependenciesPromise = loadPromise
381-
.then(([resolveStaticDependencyPromises, resolveDynamicImportPromises]) =>
382-
Promise.all([...resolveStaticDependencyPromises, ...resolveDynamicImportPromises])
383-
)
384-
.then(() => this.pluginDriver.hookParallel('moduleParsed', [module.info]));
367+
const loadAndResolveDependenciesPromise = waitForDependencyResolution(loadPromise).then(() =>
368+
this.pluginDriver.hookParallel('moduleParsed', [module.info])
369+
);
385370
loadAndResolveDependenciesPromise.catch(() => {
386371
/* avoid unhandled promise rejections */
387372
});
388373
this.moduleLoadPromises.set(module, loadPromise);
389374
const resolveDependencyPromises = await loadPromise;
390375
if (!isPreload) {
391376
await this.fetchModuleDependencies(module, ...resolveDependencyPromises);
377+
} else if (isPreload === RESOLVE_DEPENDENCIES) {
378+
await loadAndResolveDependenciesPromise;
392379
}
393380
return module;
394381
}
@@ -545,10 +532,29 @@ export class ModuleLoader {
545532
);
546533
}
547534

548-
private async handleExistingModule(module: Module, isEntry: boolean, isPreload: boolean) {
535+
private getResolvedIdWithDefaults(
536+
resolvedId: NormalizedResolveIdWithoutDefaults | null
537+
): ResolvedId | null {
538+
if (!resolvedId) {
539+
return null;
540+
}
541+
const external = resolvedId.external || false;
542+
return {
543+
external,
544+
id: resolvedId.id,
545+
meta: resolvedId.meta || {},
546+
moduleSideEffects:
547+
resolvedId.moduleSideEffects ?? this.hasModuleSideEffects(resolvedId.id, !!external),
548+
syntheticNamedExports: resolvedId.syntheticNamedExports ?? false
549+
};
550+
}
551+
552+
private async handleExistingModule(module: Module, isEntry: boolean, isPreload: PreloadType) {
549553
const loadPromise = this.moduleLoadPromises.get(module)!;
550554
if (isPreload) {
551-
return loadPromise;
555+
return isPreload === RESOLVE_DEPENDENCIES
556+
? waitForDependencyResolution(loadPromise)
557+
: loadPromise;
552558
}
553559
if (isEntry) {
554560
module.info.isEntry = true;
@@ -620,7 +626,7 @@ export class ModuleLoader {
620626
);
621627
}
622628
return this.fetchModule(
623-
this.addDefaultsToResolvedId(
629+
this.getResolvedIdWithDefaults(
624630
typeof resolveIdResult === 'object'
625631
? (resolveIdResult as NormalizedResolveIdWithoutDefaults)
626632
: { id: resolveIdResult }
@@ -663,7 +669,7 @@ export class ModuleLoader {
663669
));
664670
}
665671
return this.handleResolveId(
666-
this.addDefaultsToResolvedId(
672+
this.getResolvedIdWithDefaults(
667673
this.getNormalizedResolvedIdWithoutDefaults(resolution, importer, specifier)
668674
),
669675
specifier,
@@ -708,3 +714,8 @@ function isNotAbsoluteExternal(
708714
!isAbsolute(id)
709715
);
710716
}
717+
718+
async function waitForDependencyResolution(loadPromise: LoadModulePromise) {
719+
const [resolveStaticDependencyPromises, resolveDynamicImportPromises] = await loadPromise;
720+
return Promise.all([...resolveStaticDependencyPromises, ...resolveDynamicImportPromises]);
721+
}

src/rollup/types.d.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -202,7 +202,9 @@ export interface PluginContext extends MinimalPluginContext {
202202
getWatchFiles: () => string[];
203203
/** @deprecated Use `this.resolve` instead */
204204
isExternal: IsExternal;
205-
load: (options: { id: string } & Partial<PartialNull<ModuleOptions>>) => Promise<ModuleInfo>;
205+
load: (
206+
options: { id: string; resolveDependencies?: boolean } & Partial<PartialNull<ModuleOptions>>
207+
) => Promise<ModuleInfo>;
206208
/** @deprecated Use `this.getModuleIds` instead */
207209
moduleIds: IterableIterator<string>;
208210
parse: (input: string, options?: any) => AcornNode;
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
const assert = require('assert');
2+
const path = require('path');
3+
const DYNAMIC_IMPORT_PROXY_PREFIX = '\0dynamic-import:';
4+
const chunks = [];
5+
6+
module.exports = {
7+
description: 'allows to wait for dependency resolution in this.load to scan dependency trees',
8+
context: { chunks },
9+
async exports(exports) {
10+
assert.deepStrictEqual(chunks, []);
11+
const { importSecond } = await exports.importFirst();
12+
const expectedFirstChunk = ['first.js', 'second.js', 'third.js'].map(name =>
13+
path.join(__dirname, name)
14+
);
15+
assert.deepStrictEqual(chunks, [expectedFirstChunk]);
16+
await importSecond();
17+
const expectedSecondChunk = ['second.js', 'third.js'].map(name => path.join(__dirname, name));
18+
assert.deepStrictEqual(chunks, [expectedFirstChunk, expectedSecondChunk]);
19+
},
20+
options: {
21+
plugins: [
22+
{
23+
name: 'add-chunk-log',
24+
async resolveDynamicImport(specifier, importer) {
25+
// Ignore non-static targets
26+
if (!(typeof specifier === 'string')) return;
27+
// Get the id and initial meta information of the import target
28+
const resolved = await this.resolve(specifier, importer);
29+
// Ignore external targets. Explicit externals have the "external"
30+
// property while unresolved imports are "null".
31+
if (resolved && !resolved.external) {
32+
// We trigger loading the module without waiting for it here
33+
// because meta information attached by resolveId hooks (that may
34+
// be contained in "resolved") is only attached to a module the
35+
// first time it is loaded.
36+
// That guarantees this meta information, that plugins like
37+
// commonjs may depend upon, is not lost even if we use "this.load"
38+
// with just the id in the load hook.
39+
this.load(resolved);
40+
return `${DYNAMIC_IMPORT_PROXY_PREFIX}${resolved.id}`;
41+
}
42+
},
43+
async load(id) {
44+
// Ignore all files but our dynamic import proxies
45+
if (!id.startsWith('\0dynamic-import:')) return null;
46+
const actualId = id.slice(DYNAMIC_IMPORT_PROXY_PREFIX.length);
47+
// To allow loading modules in parallel while keeping complexity low,
48+
// we do not directly await each "this.load" call but put their
49+
// promises into an array where we await each entry via an async for
50+
// loop.
51+
const moduleInfoPromises = [this.load({ id: actualId, resolveDependencies: true })];
52+
// We track each loaded dependency here so that we do not load a file
53+
// twice and also do not get stuck when there are circular
54+
// dependencies.
55+
const dependencies = new Set([actualId]);
56+
// "importedResolution" tracks the objects created via "resolveId".
57+
// Again we are using those instead of "importedIds" so that
58+
// important meta information is not lost.
59+
for await (const { importedIdResolutions } of moduleInfoPromises) {
60+
for (const resolved of importedIdResolutions) {
61+
if (!dependencies.has(resolved.id)) {
62+
dependencies.add(resolved.id);
63+
moduleInfoPromises.push(this.load({ ...resolved, resolveDependencies: true }));
64+
}
65+
}
66+
}
67+
let code = `chunks.push([${[...dependencies]
68+
.map(JSON.stringify)
69+
.join(', ')}]); export * from ${JSON.stringify(actualId)};`;
70+
// Namespace reexports do not reexport default exports, which is why
71+
// we reexport it manually if it exists
72+
if (this.getModuleInfo(actualId).hasDefaultExport) {
73+
code += `export { default } from ${JSON.stringify(actualId)};`;
74+
}
75+
return code;
76+
},
77+
async resolveId() {
78+
// We delay resolution just slightly so that we can see the effect of
79+
// resolveDependencies
80+
return new Promise(resolve => setTimeout(() => resolve(null), 10));
81+
}
82+
}
83+
]
84+
},
85+
warnings: [
86+
{
87+
code: 'CIRCULAR_DEPENDENCY',
88+
cycle: ['second.js', 'third.js', 'second.js'],
89+
importer: 'second.js',
90+
message: 'Circular dependency: second.js -> third.js -> second.js'
91+
}
92+
]
93+
};
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
import './second.js';
2+
import './third.js';
3+
export const importSecond = () => import('./second.js');
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export const importFirst = () => import('./first.js')
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
import './third.js';
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
import './second.js';

0 commit comments

Comments
 (0)