Skip to content

feat: make module loading all async #437

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 2 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,11 @@
"pnpm": ">=10.12.4"
},
"pnpm": {
"overrides": {
"@rspack/core": "link:../rspack/packages/rspack",
"@rslib/core>@rsbuild/core": "1.4.8",
"@rsbuild/[email protected]>@rspack/core": "1.4.8"
},
"ignoredBuiltDependencies": [
"@biomejs/biome",
"nx",
Expand Down
2 changes: 2 additions & 0 deletions packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,8 @@
"@vitest/snapshot": "^3.2.4",
"birpc": "2.5.0",
"chai": "^5.2.1",
"es-module-lexer": "^1.7.0",
"magic-string": "^0.30.17",
"pathe": "^2.0.3",
"std-env": "^3.9.0",
"tinypool": "^1.1.1"
Expand Down
3 changes: 3 additions & 0 deletions packages/core/rslib.config.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { RslibConfig } from '@rslib/core';
declare const config: RslibConfig;
export default config;
18 changes: 15 additions & 3 deletions packages/core/rslib.config.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import { defineConfig } from '@rslib/core';
import { defineConfig, RslibConfig, rspack } from '@rslib/core';
import { LicenseWebpackPlugin } from 'license-webpack-plugin';
import type { LicenseIdentifiedModule } from 'license-webpack-plugin/dist/LicenseIdentifiedModule';

const isBuildWatch = process.argv.includes('--watch');

export default defineConfig({
const config: RslibConfig = defineConfig({
lib: [
{
id: 'rstest',
Expand Down Expand Up @@ -62,7 +62,17 @@ export default defineConfig({
tools: {
rspack: {
// fix licensePlugin watch error: ResourceData has been dropped by Rust.
plugins: isBuildWatch ? [] : [licensePlugin()],
plugins: [
new rspack.CopyRspackPlugin({
patterns: [
{
from: 'src/core/plugins/mockRuntimeCode.js',
to: 'mockRuntimeCode.js',
},
],
}),
isBuildWatch ? null : licensePlugin(),
].filter(Boolean),
},
},
},
Expand Down Expand Up @@ -91,6 +101,8 @@ export default defineConfig({
},
});

export default config;

function licensePlugin() {
const formatLicenseTitle = (module: LicenseIdentifiedModule) => {
// @ts-ignore
Expand Down
4 changes: 2 additions & 2 deletions packages/core/src/core/plugins/external.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ const autoExternalNodeModules: (
callback(
undefined,
externalPath,
dependencyType === 'commonjs' ? 'commonjs' : 'import',
dependencyType === 'commonjs' ? 'commonjs' : 'module',
);
};

Expand Down Expand Up @@ -71,7 +71,7 @@ function autoExternalNodeBuiltin(
callback(
undefined,
request,
dependencyType === 'commonjs' ? 'commonjs' : 'module-import',
dependencyType === 'commonjs' ? 'commonjs' : 'module',
);
} else {
callback();
Expand Down
142 changes: 142 additions & 0 deletions packages/core/src/core/plugins/mockLoader.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
import { init, parse } from 'es-module-lexer';
import MagicString from 'magic-string';

/**
* A webpack/rspack loader that transforms static imports to top-level await dynamic imports
* Example: import x from 'b' -> const x = await import('b')
*/
export default async function mockLoader(source, map) {
const callback = this.async();

try {
// Initialize es-module-lexer
await init;

// Parse the source to find static imports
const [imports] = parse(source);

const magicString = new MagicString(source);

// Transform imports in reverse order to maintain correct string positions
for (let i = imports.length - 1; i >= 0; i--) {
const importInfo = imports[i];
const { ss: start, se: end, d: dynamicStart } = importInfo;

// Skip dynamic imports (they already have d >= 0)
if (dynamicStart >= 0) continue;

// Extract the import statement
const importStatement = source.slice(start, end);

// Parse different import patterns
let transformedImport = '';

// Match: import defaultImport from 'module'
const defaultImportMatch = importStatement.match(
/import\s+(\w+)\s+from\s+['"`]([^'"`]+)['"`]/,
);
if (defaultImportMatch) {
const [, defaultName, moduleName] = defaultImportMatch;
transformedImport = `const ${defaultName} = (await import('${moduleName}')).default`;
}

// Match: import * as namespace from 'module'
const namespaceImportMatch = importStatement.match(
/import\s+\*\s+as\s+(\w+)\s+from\s+['"`]([^'"`]+)['"`]/,
);
if (namespaceImportMatch) {
const [, namespaceName, moduleName] = namespaceImportMatch;
transformedImport = `const ${namespaceName} = await import('${moduleName}')`;
}

// Match: import { named1, named2 } from 'module'
const namedImportMatch = importStatement.match(
/import\s+\{([^}]+)\}\s+from\s+['"`]([^'"`]+)['"`]/,
);
if (namedImportMatch) {
const [, namedImports, moduleName] = namedImportMatch;
const imports = namedImports
.split(',')
.map((imp) => {
const trimmed = imp.trim();
// Handle 'as' aliases: import { foo as bar } from 'module'
const aliasMatch = trimmed.match(/(\w+)\s+as\s+(\w+)/);
if (aliasMatch) {
return `${aliasMatch[1]}: ${aliasMatch[2]}`;
}
return trimmed;
})
// .filter((v) => {
// return !(v === 'rs' && moduleName === '@rstest/core');
// })
.join(', ');

transformedImport = `const { ${imports} } = await import('${moduleName}')`;
}

// Match: import 'module' (side-effect import)
const sideEffectImportMatch = importStatement.match(
/import\s+['"`]([^'"`]+)['"`]/,
);
if (
sideEffectImportMatch &&
!defaultImportMatch &&
!namespaceImportMatch &&
!namedImportMatch
) {
const [, moduleName] = sideEffectImportMatch;
transformedImport = `await import('${moduleName}')`;
}

// Match: import defaultImport, { named1, named2 } from 'module'
const mixedImportMatch = importStatement.match(
/import\s+(\w+)\s*,\s*\{([^}]+)\}\s+from\s+['"`]([^'"`]+)['"`]/,
);
if (mixedImportMatch) {
const [, defaultName, namedImports, moduleName] = mixedImportMatch;
const imports = namedImports
.split(',')
.map((imp) => {
const trimmed = imp.trim();
const aliasMatch = trimmed.match(/(\w+)\s+as\s+(\w+)/);
if (aliasMatch) {
return `${aliasMatch[2]}: ${aliasMatch[1]}`;
}
return trimmed;
})
.join(', ');
transformedImport = `const _importResult = await import('${moduleName}');\nconst ${defaultName} = _importResult.default;\nconst { ${imports} } = _importResult`;
}

// Match: import defaultImport, * as namespace from 'module'
const mixedNamespaceImportMatch = importStatement.match(
/import\s+(\w+)\s*,\s*\*\s+as\s+(\w+)\s+from\s+['"`]([^'"`]+)['"`]/,
);
if (mixedNamespaceImportMatch) {
const [, defaultName, namespaceName, moduleName] =
mixedNamespaceImportMatch;
transformedImport = `const ${namespaceName} = await import('${moduleName}');\nconst ${defaultName} = ${namespaceName}.default`;
}

const isPureCjs = source.includes('module.exports = ');
const flag = isPureCjs ? '' : ';export {}';

// Apply the transformation
if (transformedImport) {
magicString.overwrite(start, end, transformedImport + flag);
}
}

const result = magicString.toString();
const newMap = magicString.generateMap({
source: this.resourcePath,
includeContent: true,
hires: true,
});
newMap.names = map?.names ?? newMap.names;

callback(null, result, map ?? newMap);
} catch (error) {
callback(error);
}
}
109 changes: 10 additions & 99 deletions packages/core/src/core/plugins/mockRuntime.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import type { RsbuildPlugin, Rspack } from '@rsbuild/core';

const __dirname = path.dirname(fileURLToPath(import.meta.url));

class MockRuntimeRspackPlugin {
apply(compiler: Rspack.Compiler) {
const { RuntimeModule } = compiler.webpack;
Expand All @@ -9,106 +14,12 @@ class MockRuntimeRspackPlugin {
}

override generate() {
// Rstest runtime code should be prefixed with `rstest_` to avoid conflicts with other runtimes.
return `
if (typeof __webpack_require__ === 'undefined') {
return;
}

const originalRequire = __webpack_require__;
__webpack_require__ = function(...args) {
try {
return originalRequire(...args);
} catch (e) {
const errMsg = e.message ?? e.toString();
if (errMsg.includes('__webpack_modules__[moduleId] is not a function')) {
throw new Error(\`Cannot find module '\${args[0]}'\`)
}
throw e;
}
};

Object.keys(originalRequire).forEach(key => {
__webpack_require__[key] = originalRequire[key];
});

__webpack_require__.rstest_original_modules = {};

__webpack_require__.rstest_reset_modules = () => {
const mockedIds = Object.keys(__webpack_require__.rstest_original_modules)
Object.keys(__webpack_module_cache__).forEach(id => {
// Do not reset mocks registry.
if (!mockedIds.includes(id)) {
delete __webpack_module_cache__[id];
}
});
}

__webpack_require__.rstest_unmock = (id) => {
delete __webpack_module_cache__[id]
}

__webpack_require__.rstest_require_actual = __webpack_require__.rstest_import_actual = (id) => {
const originalModule = __webpack_require__.rstest_original_modules[id];
// Use fallback module if the module is not mocked.
const fallbackMod = __webpack_require__(id);
return originalModule ? originalModule : fallbackMod;
}

__webpack_require__.rstest_exec = async (id, modFactory) => {
if (__webpack_module_cache__) {
let asyncFactory = __webpack_module_cache__[id];
if (asyncFactory && asyncFactory.constructor.name === 'AsyncFunction') {
await asyncFactory();
}
}
};

__webpack_require__.rstest_mock = (id, modFactory) => {
let requiredModule = undefined
try {
requiredModule = __webpack_require__(id);
} catch {
// TODO: non-resolved module
} finally {
__webpack_require__.rstest_original_modules[id] = requiredModule;
}
if (typeof modFactory === 'string' || typeof modFactory === 'number') {
__webpack_module_cache__[id] = { exports: __webpack_require__(modFactory) };
} else if (typeof modFactory === 'function') {
if (modFactory.constructor.name === 'AsyncFunction') {
__webpack_module_cache__[id] = async () => {
const exports = await modFactory();
__webpack_require__.r(exports);
__webpack_module_cache__[id] = { exports, id, loaded: true };
}
} else {
const exports = modFactory();
__webpack_require__.r(exports);
__webpack_module_cache__[id] = { exports, id, loaded: true };
}
}
};

__webpack_require__.rstest_do_mock = (id, modFactory) => {
let requiredModule = undefined
try {
requiredModule = __webpack_require__(id);
} catch {
// TODO: non-resolved module
} finally {
__webpack_require__.rstest_original_modules[id] = requiredModule;
}
if (typeof modFactory === 'string' || typeof modFactory === 'number') {
__webpack_module_cache__[id] = { exports: __webpack_require__(modFactory) };
} else if (typeof modFactory === 'function') {
const exports = modFactory();
__webpack_require__.r(exports);
__webpack_module_cache__[id] = { exports, id, loaded: true };
}
};
const code = fs.readFileSync(
path.join(__dirname, './mockRuntimeCode.js'),
'utf8',
);

`;
return code;
}
}

Expand Down
Loading