From 3c6d3066662b4aef8606cb4a683b186df7a2d74e Mon Sep 17 00:00:00 2001 From: fi3ework Date: Thu, 2 Jan 2025 18:27:01 +0800 Subject: [PATCH] fix: handle add and unlink file in bundleless mode --- packages/core/src/config.ts | 129 ++++++++++-------- packages/core/src/plugins/EntryChunkPlugin.ts | 63 ++++++--- .../tests/__snapshots__/config.test.ts.snap | 8 ++ .../integration/cli/build-watch/build.test.ts | 72 +++++++++- .../cli/build-watch/rslib.config.ts | 12 +- .../integration/cli/build-watch/src/index.ts | 2 +- tests/integration/directive/index.test.ts | 8 +- .../react/bundleless/rslib.config.ts | 8 +- tests/scripts/helper.ts | 22 +++ 9 files changed, 230 insertions(+), 94 deletions(-) diff --git a/packages/core/src/config.ts b/packages/core/src/config.ts index a4025bab7..ffa401701 100644 --- a/packages/core/src/config.ts +++ b/packages/core/src/config.ts @@ -918,76 +918,90 @@ const composeEntryConfig = async ( }; } - // In bundleless mode, resolve glob patterns and convert them to entry object. - const resolvedEntries: Record = {}; - 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 globScanEntries = async (calcLcp: boolean) => { + // In bundleless mode, resolve glob patterns and convert them to entry object. + const resolvedEntries: Record = {}; + 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 globScanEntries(true); const entryConfig: EnvironmentConfig = { - source: { - entry: appendEntryQuery(resolvedEntries), + tools: { + rspack: { + entry: async () => { + const { resolvedEntries } = await globScanEntries(false); + return appendEntryQuery(resolvedEntries); + }, + }, }, }; @@ -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( diff --git a/packages/core/src/plugins/EntryChunkPlugin.ts b/packages/core/src/plugins/EntryChunkPlugin.ts index c9377f74e..170acc6e3 100644 --- a/packages/core/src/plugins/EntryChunkPlugin.ts +++ b/packages/core/src/plugins/EntryChunkPlugin.ts @@ -37,40 +37,47 @@ class EntryChunkPlugin { private shebangInjectedAssets: Set = 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 = {}; + 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); @@ -78,7 +85,6 @@ class EntryChunkPlugin { this.shebangEntries[name] = shebangMatch; } } - // React directive const reactDirective = matchFirstLine(content, REACT_DIRECTIVE_REGEX); if (reactDirective) { @@ -87,7 +93,25 @@ 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; + + const name = chunk.name; + if (!name) return; + + const shebangEntry = this.shebangEntries[name]; + if (shebangEntry) { + this.shebangEntries[filename] = shebangEntry; + } + + const reactDirective = this.reactDirectives[name]; + if (reactDirective) { + this.reactDirectives[filename] = reactDirective; + } + }); + compilation.hooks.chunkAsset.tap(PLUGIN_NAME, (chunk, filename) => { const isJs = JS_EXTENSIONS_PATTERN.test(filename); if (!isJs) return; @@ -192,8 +216,10 @@ const entryModuleLoaderRsbuildPlugin = (): RsbuildPlugin => ({ export const composeEntryChunkConfig = ({ enabledImportMetaUrlShim, + contextToWatch = null, }: { enabledImportMetaUrlShim: boolean; + contextToWatch: string | null; }): EnvironmentConfig => { return { plugins: [entryModuleLoaderRsbuildPlugin()], @@ -202,6 +228,7 @@ export const composeEntryChunkConfig = ({ plugins: [ new EntryChunkPlugin({ enabledImportMetaUrlShim, + contextToWatch, }), ], }, diff --git a/packages/core/tests/__snapshots__/config.test.ts.snap b/packages/core/tests/__snapshots__/config.test.ts.snap index 65289ae66..19dae61ba 100644 --- a/packages/core/tests/__snapshots__/config.test.ts.snap +++ b/packages/core/tests/__snapshots__/config.test.ts.snap @@ -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, @@ -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, @@ -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, @@ -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, diff --git a/tests/integration/cli/build-watch/build.test.ts b/tests/integration/cli/build-watch/build.test.ts index 1b375ad2d..638013c05 100644 --- a/tests/integration/cli/build-watch/build.test.ts +++ b/tests/integration/cli/build-watch/build.test.ts @@ -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'; @@ -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(); + }); +}); diff --git a/tests/integration/cli/build-watch/rslib.config.ts b/tests/integration/cli/build-watch/rslib.config.ts index e5affdca1..642e4bd91 100644 --- a/tests/integration/cli/build-watch/rslib.config.ts +++ b/tests/integration/cli/build-watch/rslib.config.ts @@ -1,13 +1,15 @@ import { defineConfig } from '@rslib/core'; -import { generateBundleCjsConfig, generateBundleEsmConfig } from 'test-helper'; +import { generateBundleEsmConfig } from 'test-helper'; export default defineConfig({ lib: [ generateBundleEsmConfig({ - dts: true, - }), - generateBundleCjsConfig({ - dts: true, + source: { + entry: { + index: 'src', + }, + }, + bundle: false, }), ], }); diff --git a/tests/integration/cli/build-watch/src/index.ts b/tests/integration/cli/build-watch/src/index.ts index 3329a7d97..f7ef6a6c8 100644 --- a/tests/integration/cli/build-watch/src/index.ts +++ b/tests/integration/cli/build-watch/src/index.ts @@ -1 +1 @@ -export const foo = 'foo'; +export const index = 'index'; diff --git a/tests/integration/directive/index.test.ts b/tests/integration/directive/index.test.ts index 510a7b56b..66bdb1818 100644 --- a/tests/integration/directive/index.test.ts +++ b/tests/integration/directive/index.test.ts @@ -93,24 +93,24 @@ describe('react', async () => { describe('bundle-false', async () => { test('React directive at the beginning', async () => { - const { content: foo } = queryContent(contents.esm0!, 'foo.js', { + const { content: foo } = queryContent(contents.esm!, 'foo.js', { basename: true, }); expect(foo!.startsWith(`'use client';`)).toBe(true); - const { content: bar } = queryContent(contents.esm0!, 'bar.js', { + const { content: bar } = queryContent(contents.esm!, 'bar.js', { basename: true, }); expect(bar!.startsWith(`'use server';`)).toBe(true); }); test('React directive at the beginning even if minified', async () => { - const { content: foo } = queryContent(contents.esm1!, 'foo.js', { + const { content: foo } = queryContent(contents.cjs!, 'foo.cjs', { basename: true, }); expect(foo!.startsWith(`'use client';`)).toBe(true); - const { content: bar } = queryContent(contents.esm1!, 'bar.js', { + const { content: bar } = queryContent(contents.cjs!, 'bar.cjs', { basename: true, }); expect(bar!.startsWith(`'use server';`)).toBe(true); diff --git a/tests/integration/directive/react/bundleless/rslib.config.ts b/tests/integration/directive/react/bundleless/rslib.config.ts index fdafc85cf..dbaed546e 100644 --- a/tests/integration/directive/react/bundleless/rslib.config.ts +++ b/tests/integration/directive/react/bundleless/rslib.config.ts @@ -1,5 +1,5 @@ import { defineConfig } from '@rslib/core'; -import { generateBundleEsmConfig } from 'test-helper'; +import { generateBundleCjsConfig, generateBundleEsmConfig } from 'test-helper'; export default defineConfig({ lib: [ @@ -7,16 +7,16 @@ export default defineConfig({ bundle: false, output: { distPath: { - root: './dist/bundle/esm0', + root: './dist/esm', }, }, }), - generateBundleEsmConfig({ + generateBundleCjsConfig({ bundle: false, output: { minify: true, distPath: { - root: './dist/bundle/esm1', + root: './dist/cjs', }, }, }), diff --git a/tests/scripts/helper.ts b/tests/scripts/helper.ts index bc693803f..40fb933ca 100644 --- a/tests/scripts/helper.ts +++ b/tests/scripts/helper.ts @@ -91,3 +91,25 @@ export const awaitFileExists = async (dir: string) => { throw new Error(`awaitFileExists failed: ${dir}`); } }; + +export const awaitFileChanges = async (file: string) => { + const oldContent = await fse.readFile(file, 'utf-8'); + return async () => { + const result = await waitFor( + () => { + try { + return fse.readFileSync(file, 'utf-8') !== oldContent; + } catch (e) { + return false; + } + }, + { interval: 50 }, + ); + + if (!result) { + throw new Error(`awaitFileExists failed: ${file}`); + } + + return result; + }; +};