Skip to content

fix: handle add and unlink file in bundleless mode #642

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

Merged
merged 1 commit into from
Jan 6, 2025
Merged
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
129 changes: 72 additions & 57 deletions packages/core/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -918,76 +918,90 @@ const composeEntryConfig = async (
};
}

// In bundleless mode, resolve glob patterns and convert them to entry object.
const resolvedEntries: Record<string, string> = {};
for (const key of Object.keys(entries)) {
const entry = entries[key];

// Entries in bundleless mode could be:
// 1. A string of glob pattern: { entry: { index: 'src/*.ts' } }
// 2. An array of glob patterns: { entry: { index: ['src/*.ts', 'src/*.tsx'] } }
// Not supported for now: entry description object
const entryFiles = Array.isArray(entry)
? entry
: typeof entry === 'string'
? [entry]
: null;

if (!entryFiles) {
throw new Error(
'Entry can only be a string or an array of strings for now',
);
}
const scanGlobEntries = async (calcLcp: boolean) => {
// In bundleless mode, resolve glob patterns and convert them to entry object.
const resolvedEntries: Record<string, string> = {};
for (const key of Object.keys(entries)) {
const entry = entries[key];

// Entries in bundleless mode could be:
// 1. A string of glob pattern: { entry: { index: 'src/*.ts' } }
// 2. An array of glob patterns: { entry: { index: ['src/*.ts', 'src/*.tsx'] } }
// Not supported for now: entry description object
const entryFiles = Array.isArray(entry)
? entry
: typeof entry === 'string'
? [entry]
: null;

// Turn entries in array into each separate entry.
const globEntryFiles = await glob(entryFiles, {
cwd: root,
absolute: true,
});
if (!entryFiles) {
throw new Error(
'Entry can only be a string or an array of strings for now',
);
}

// Filter the glob resolved entry files based on the allowed extensions
const resolvedEntryFiles = globEntryFiles.filter((file) =>
ENTRY_EXTENSIONS_PATTERN.test(file),
);
// Turn entries in array into each separate entry.
const globEntryFiles = await glob(entryFiles, {
cwd: root,
absolute: true,
});

if (resolvedEntryFiles.length === 0) {
throw new Error(`Cannot find ${resolvedEntryFiles}`);
}
// Filter the glob resolved entry files based on the allowed extensions
const resolvedEntryFiles = globEntryFiles.filter((file) =>
ENTRY_EXTENSIONS_PATTERN.test(file),
);

if (resolvedEntryFiles.length === 0) {
throw new Error(`Cannot find ${resolvedEntryFiles}`);
}

// Similar to `rootDir` in tsconfig and `outbase` in esbuild.
const lcp = await calcLongestCommonPath(resolvedEntryFiles);
// Using the longest common path of all non-declaration input files by default.
const outBase = lcp === null ? root : lcp;

// Similar to `rootDir` in tsconfig and `outbase` in esbuild.
const lcp = await calcLongestCommonPath(resolvedEntryFiles);
// Using the longest common path of all non-declaration input files by default.
const outBase = lcp === null ? root : lcp;
function getEntryName(file: string) {
const { dir, name } = path.parse(path.relative(outBase, file));
// Entry filename contains nested path to preserve source directory structure.
const entryFileName = path.join(dir, name);

function getEntryName(file: string) {
const { dir, name } = path.parse(path.relative(outBase, file));
// Entry filename contains nested path to preserve source directory structure.
const entryFileName = path.join(dir, name);
// 1. we mark the global css files (which will generate empty js chunk in cssExtract), and deleteAsset in RemoveCssExtractAssetPlugin
// 2. avoid the same name e.g: `index.ts` and `index.css`
if (isCssGlobalFile(file, cssModulesAuto)) {
return `${RSLIB_CSS_ENTRY_FLAG}/${entryFileName}`;
}

// 1. we mark the global css files (which will generate empty js chunk in cssExtract), and deleteAsset in RemoveCssExtractAssetPlugin
// 2. avoid the same name e.g: `index.ts` and `index.css`
if (isCssGlobalFile(file, cssModulesAuto)) {
return `${RSLIB_CSS_ENTRY_FLAG}/${entryFileName}`;
return entryFileName;
}

return entryFileName;
for (const file of resolvedEntryFiles) {
const entryName = getEntryName(file);
if (resolvedEntries[entryName]) {
logger.warn(
`duplicate entry: ${entryName}, this may lead to the incorrect output, please rename the file`,
);
}
resolvedEntries[entryName] = file;
}
}

for (const file of resolvedEntryFiles) {
const entryName = getEntryName(file);
if (resolvedEntries[entryName]) {
logger.warn(
`duplicate entry: ${entryName}, this may lead to the incorrect output, please rename the file`,
);
}
resolvedEntries[entryName] = file;
if (calcLcp) {
const lcp = await calcLongestCommonPath(Object.values(resolvedEntries));
return { resolvedEntries, lcp };
}
}
return { resolvedEntries, lcp: null };
};

const lcp = await calcLongestCommonPath(Object.values(resolvedEntries));
// LCP could only be determined at the first time of glob scan.
const { lcp } = await scanGlobEntries(true);
const entryConfig: EnvironmentConfig = {
source: {
entry: appendEntryQuery(resolvedEntries),
tools: {
rspack: {
entry: async () => {
const { resolvedEntries } = await scanGlobEntries(false);
return appendEntryQuery(resolvedEntries);
},
},
},
};

Expand Down Expand Up @@ -1342,6 +1356,7 @@ async function composeLibRsbuildConfig(

const entryChunkConfig = composeEntryChunkConfig({
enabledImportMetaUrlShim: enabledShims.cjs['import.meta.url'],
contextToWatch: lcp,
});
const dtsConfig = await composeDtsConfig(config, dtsExtension);
const externalsWarnConfig = composeExternalsWarnConfig(
Expand Down
45 changes: 27 additions & 18 deletions packages/core/src/plugins/EntryChunkPlugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,48 +37,54 @@ class EntryChunkPlugin {
private shebangInjectedAssets: Set<string> = new Set();

private enabledImportMetaUrlShim: boolean;
private contextToWatch: string | null = null;
private contextWatched = false;

constructor({
enabledImportMetaUrlShim = true,
contextToWatch,
}: {
enabledImportMetaUrlShim: boolean;
contextToWatch: string | null;
}) {
this.enabledImportMetaUrlShim = enabledImportMetaUrlShim;
this.contextToWatch = contextToWatch;
}

apply(compiler: Rspack.Compiler) {
compiler.hooks.entryOption.tap(PLUGIN_NAME, (_context, entries) => {
for (const name in entries) {
const entry = (entries as Rspack.EntryStaticNormalized)[name];
if (!entry) continue;

let first: string | undefined;
if (Array.isArray(entry)) {
first = entry[0];
} else if (Array.isArray(entry.import)) {
first = entry.import[0];
} else if (typeof entry === 'string') {
first = entry;
}
compiler.hooks.afterCompile.tap(PLUGIN_NAME, (compilation) => {
if (this.contextWatched || this.contextToWatch === null) return;

const contextDep = compilation.contextDependencies;
contextDep.add(this.contextToWatch);
this.contextWatched = true;
});

if (typeof first !== 'string') continue;
compiler.hooks.make.tap(PLUGIN_NAME, (compilation) => {
const entries: Record<string, string> = {};
for (const [key, value] of compilation.entries) {
const firstDep = value.dependencies[0];
if (firstDep?.request) {
entries[key] = firstDep.request;
}
}

for (const name in entries) {
const first = entries[name];
if (!first) continue;
const filename = first.split('?')[0]!;
const isJs = JS_EXTENSIONS_PATTERN.test(filename);
if (!isJs) continue;

const content = compiler.inputFileSystem!.readFileSync!(filename, {
encoding: 'utf-8',
});

// Shebang
if (content.startsWith(SHEBANG_PREFIX)) {
const shebangMatch = matchFirstLine(content, SHEBANG_REGEX);
if (shebangMatch) {
this.shebangEntries[name] = shebangMatch;
}
}

// React directive
const reactDirective = matchFirstLine(content, REACT_DIRECTIVE_REGEX);
if (reactDirective) {
Expand All @@ -87,7 +93,7 @@ class EntryChunkPlugin {
}
});

compiler.hooks.thisCompilation.tap(PLUGIN_NAME, (compilation) => {
compiler.hooks.make.tap(PLUGIN_NAME, (compilation) => {
compilation.hooks.chunkAsset.tap(PLUGIN_NAME, (chunk, filename) => {
const isJs = JS_EXTENSIONS_PATTERN.test(filename);
if (!isJs) return;
Expand Down Expand Up @@ -192,8 +198,10 @@ const entryModuleLoaderRsbuildPlugin = (): RsbuildPlugin => ({

export const composeEntryChunkConfig = ({
enabledImportMetaUrlShim,
contextToWatch = null,
}: {
enabledImportMetaUrlShim: boolean;
contextToWatch: string | null;
}): EnvironmentConfig => {
return {
plugins: [entryModuleLoaderRsbuildPlugin()],
Expand All @@ -202,6 +210,7 @@ export const composeEntryChunkConfig = ({
plugins: [
new EntryChunkPlugin({
enabledImportMetaUrlShim,
contextToWatch,
}),
],
},
Expand Down
8 changes: 8 additions & 0 deletions packages/core/tests/__snapshots__/config.test.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,8 @@ exports[`Should compose create Rsbuild config correctly > Merge Rsbuild config i
{
"plugins": [
EntryChunkPlugin {
"contextToWatch": null,
"contextWatched": false,
"enabledImportMetaUrlShim": false,
"reactDirectives": {},
"shebangChmod": 493,
Expand Down Expand Up @@ -449,6 +451,8 @@ exports[`Should compose create Rsbuild config correctly > Merge Rsbuild config i
{
"plugins": [
EntryChunkPlugin {
"contextToWatch": null,
"contextWatched": false,
"enabledImportMetaUrlShim": true,
"reactDirectives": {},
"shebangChmod": 493,
Expand Down Expand Up @@ -668,6 +672,8 @@ exports[`Should compose create Rsbuild config correctly > Merge Rsbuild config i
{
"plugins": [
EntryChunkPlugin {
"contextToWatch": null,
"contextWatched": false,
"enabledImportMetaUrlShim": false,
"reactDirectives": {},
"shebangChmod": 493,
Expand Down Expand Up @@ -822,6 +828,8 @@ exports[`Should compose create Rsbuild config correctly > Merge Rsbuild config i
{
"plugins": [
EntryChunkPlugin {
"contextToWatch": null,
"contextWatched": false,
"enabledImportMetaUrlShim": false,
"reactDirectives": {},
"shebangChmod": 493,
Expand Down
72 changes: 67 additions & 5 deletions tests/integration/cli/build-watch/build.test.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,19 @@
import { exec } from 'node:child_process';
import path from 'node:path';
import fse from 'fs-extra';
import { awaitFileExists } from 'test-helper';
import { describe, test } from 'vitest';
import { awaitFileChanges, awaitFileExists } from 'test-helper';
import { describe, expect, test } from 'vitest';

describe('build --watch command', async () => {
test('basic', async () => {
const distPath = path.join(__dirname, 'dist');
const dist1Path = path.join(__dirname, 'dist-1');
fse.removeSync(distPath);

fse.removeSync(dist1Path);

const distEsmIndexFile = path.join(__dirname, 'dist/esm/index.js');
const dist1EsmIndexFile = path.join(__dirname, 'dist-1/esm/index.js');

const tempConfigFile = path.join(__dirname, 'test-temp-rslib.config.mjs');

fse.outputFileSync(
tempConfigFile,
`import { defineConfig } from '@rslib/core';
Expand Down Expand Up @@ -56,3 +53,68 @@ export default defineConfig({
process.kill();
});
});

describe('build --watch should handle add / change / unlink', async () => {
test('basic', async () => {
const tempSrcPath = path.join(__dirname, 'test-temp-src');
await fse.remove(tempSrcPath);
await fse.remove(path.join(__dirname, 'dist'));
await fse.copy(path.join(__dirname, 'src'), './test-temp-src');
const tempConfigFile = path.join(__dirname, 'test-temp-rslib.config.mjs');
await fse.remove(tempConfigFile);
fse.outputFileSync(
tempConfigFile,
`import { defineConfig } from '@rslib/core';
import { generateBundleEsmConfig } from 'test-helper';

export default defineConfig({
lib: [
generateBundleEsmConfig({
source: {
entry: {
index: 'test-temp-src',
},
},
bundle: false,
}),
],
});
`,
);

const srcIndexFile = path.join(tempSrcPath, 'index.js');
const srcFooFile = path.join(tempSrcPath, 'foo.js');
const distFooFile = path.join(__dirname, 'dist/esm/foo.js');

const process = exec(`npx rslib build --watch -c ${tempConfigFile}`, {
cwd: __dirname,
});

// add
fse.outputFileSync(srcFooFile, `export const foo = 'foo';`);
await awaitFileExists(distFooFile);
const content1 = await fse.readFile(distFooFile, 'utf-8');
expect(content1!).toMatchInlineSnapshot(`
"const foo = 'foo';
export { foo };
"
`);

// unlink
// Following "change" cases won't succeed if error is thrown in this step.
fse.removeSync(srcIndexFile);

// change
const wait = await awaitFileChanges(distFooFile);
fse.outputFileSync(srcFooFile, `export const foo = 'foo1';`);
await wait();
const content2 = await fse.readFile(distFooFile, 'utf-8');
expect(content2!).toMatchInlineSnapshot(`
"const foo = 'foo1';
export { foo };
"
`);

process.kill();
});
});
Loading
Loading