diff --git a/packages/bundlers/library/package.json b/packages/bundlers/library/package.json new file mode 100644 index 00000000000..9311adc545f --- /dev/null +++ b/packages/bundlers/library/package.json @@ -0,0 +1,26 @@ +{ + "name": "@parcel/bundler-library", + "version": "2.11.0", + "license": "MIT", + "publishConfig": { + "access": "public" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "repository": { + "type": "git", + "url": "https://github.com/parcel-bundler/parcel.git" + }, + "main": "lib/LibraryBundler.js", + "source": "src/LibraryBundler.js", + "engines": { + "node": ">= 12.0.0", + "parcel": "^2.12.0" + }, + "dependencies": { + "@parcel/plugin": "2.12.0", + "nullthrows": "^1.1.1" + } +} diff --git a/packages/bundlers/library/src/LibraryBundler.js b/packages/bundlers/library/src/LibraryBundler.js new file mode 100644 index 00000000000..9e7f38e4d13 --- /dev/null +++ b/packages/bundlers/library/src/LibraryBundler.js @@ -0,0 +1,80 @@ +// @flow strict-local +import {Bundler} from '@parcel/plugin'; +import nullthrows from 'nullthrows'; + +// This bundler plugin is designed specifically for library builds. It outputs a bundle for +// each input asset, which ensures that the library can be effectively tree shaken and code +// split by an application bundler. +export default (new Bundler({ + bundle({bundleGraph}) { + // Collect dependencies from the graph. + // We do not want to mutate the graph while traversing, so this must be done first. + let dependencies = []; + bundleGraph.traverse((node, context) => { + if (node.type === 'dependency') { + let dependency = node.value; + if (bundleGraph.isDependencySkipped(dependency)) { + return; + } + dependencies.push([ + dependency, + nullthrows(dependency.target ?? context), + ]); + if (dependency.target) { + return dependency.target; + } + } + }); + + // Create bundles for each asset. + let bundles = new Map(); + for (let [dependency, target] of dependencies) { + let assets = bundleGraph.getDependencyAssets(dependency); + if (assets.length === 0) { + continue; + } + + let parentAsset = bundleGraph.getAssetWithDependency(dependency); + let parentBundle; + if (parentAsset) { + let parentKey = getBundleKey(parentAsset, target); + parentBundle = bundles.get(parentKey); + } + let bundleGroup; + + // Create a separate bundle group/bundle for each asset. + for (let asset of assets) { + let key = getBundleKey(asset, target); + let bundle = bundles.get(key); + if (!bundle) { + bundleGroup ??= bundleGraph.createBundleGroup(dependency, target); + bundle = bundleGraph.createBundle({ + entryAsset: asset, + needsStableName: dependency.isEntry, + target, + bundleBehavior: dependency.bundleBehavior ?? asset.bundleBehavior, + }); + bundleGraph.addBundleToBundleGroup(bundle, bundleGroup); + bundles.set(key, bundle); + } + + if (!bundle.hasAsset(asset)) { + bundleGraph.addAssetToBundle(asset, bundle); + } + + // Reference the parent bundle so we create dependencies between them. + if (parentBundle) { + bundleGraph.createBundleReference(parentBundle, bundle); + bundleGraph.createAssetReference(dependency, asset, bundle); + } + } + } + }, + optimize() {}, +}): Bundler); + +function getBundleKey(asset, target) { + // Group by type and file path so CSS generated by macros is combined together by parent JS file. + // Also group by environment/target to ensure bundles cannot be shared between packages. + return `${asset.type}:${asset.filePath}:${asset.env.id}:${target.distDir}`; +} diff --git a/packages/core/core/src/BundleGraph.js b/packages/core/core/src/BundleGraph.js index 4632586cd42..a4a9122056c 100644 --- a/packages/core/core/src/BundleGraph.js +++ b/packages/core/core/src/BundleGraph.js @@ -816,6 +816,24 @@ export default class BundleGraph { getReferencedBundle(dependency: Dependency, fromBundle: Bundle): ?Bundle { let dependencyNodeId = this._graph.getNodeIdByContentKey(dependency.id); + // Find an attached bundle via a reference edge (e.g. from createAssetReference). + let bundleNodes = this._graph + .getNodeIdsConnectedFrom( + dependencyNodeId, + bundleGraphEdgeTypes.references, + ) + .map(id => nullthrows(this._graph.getNode(id))) + .filter(node => node.type === 'bundle'); + + if (bundleNodes.length) { + let bundleNode = + bundleNodes.find( + b => b.type === 'bundle' && b.value.type === fromBundle.type, + ) || bundleNodes[0]; + invariant(bundleNode.type === 'bundle'); + return bundleNode.value; + } + // If this dependency is async, there will be a bundle group attached to it. let node = this._graph .getNodeIdsConnectedFrom(dependencyNodeId) @@ -831,20 +849,6 @@ export default class BundleGraph { return mainEntryId != null && node.value.entryAssetId === mainEntryId; }); } - - // Otherwise, find an attached bundle via a reference edge (e.g. from createAssetReference). - let bundleNode = this._graph - .getNodeIdsConnectedFrom( - dependencyNodeId, - bundleGraphEdgeTypes.references, - ) - .map(id => nullthrows(this._graph.getNode(id))) - .find(node => node.type === 'bundle'); - - if (bundleNode) { - invariant(bundleNode.type === 'bundle'); - return bundleNode.value; - } } removeAssetGraphFromBundle(asset: Asset, bundle: Bundle) { @@ -1142,12 +1146,12 @@ export default class BundleGraph { // If a resolution still hasn't been found, return the first referenced asset. if (resolved == null) { + let potential = []; this._graph.traverse( (nodeId, _, traversal) => { let node = nullthrows(this._graph.getNode(nodeId)); if (node.type === 'asset') { - resolved = node.value; - traversal.stop(); + potential.push(node.value); } else if (node.id !== dep.id) { traversal.skipChildren(); } @@ -1155,6 +1159,11 @@ export default class BundleGraph { this._graph.getNodeIdByContentKey(dep.id), bundleGraphEdgeTypes.references, ); + + if (bundle) { + resolved = potential.find(a => a.type === bundle.type); + } + resolved ||= potential[0]; } return resolved; diff --git a/packages/core/integration-tests/test/integration/formats/esm-cjs/a.js b/packages/core/integration-tests/test/integration/formats/esm-cjs/a.js index d5f4c34aa1a..78ddc8d9d40 100644 --- a/packages/core/integration-tests/test/integration/formats/esm-cjs/a.js +++ b/packages/core/integration-tests/test/integration/formats/esm-cjs/a.js @@ -1 +1,2 @@ exports.test = true; +exports['foo-bar'] = true; diff --git a/packages/core/integration-tests/test/integration/sync-entry-shared/yarn.lock b/packages/core/integration-tests/test/integration/sync-entry-shared/yarn.lock new file mode 100644 index 00000000000..e69de29bb2d diff --git a/packages/core/integration-tests/test/library-bundler.js b/packages/core/integration-tests/test/library-bundler.js new file mode 100644 index 00000000000..d8c05fc7cf1 --- /dev/null +++ b/packages/core/integration-tests/test/library-bundler.js @@ -0,0 +1,370 @@ +// @flow +import assert from 'assert'; +import path from 'path'; +import { + bundle, + runBundle, + overlayFS, + outputFS, + fsFixture, + assertBundles, +} from '@parcel/test-utils'; +import nullthrows from 'nullthrows'; + +describe('library bundler', function () { + let count = 0; + let dir; + beforeEach(async () => { + dir = path.join(__dirname, 'libraries', '' + ++count); + await overlayFS.mkdirp(dir); + }); + + after(async () => { + await overlayFS.rimraf(path.join(__dirname, 'libraries')); + }); + + it('should support named imports', async function () { + await fsFixture(overlayFS, dir)` + yarn.lock: + + .parcelrc: + { + "extends": "@parcel/config-default", + "bundler": "@parcel/bundler-library" + } + + package.json: + { + "main": "dist/main.js", + "module": "dist/module.js", + "engines": { "node": "*" } + } + + index.js: + export * from './foo'; + export {bar} from './bar'; + + foo.js: + import {baz} from './baz'; + export function foo() { + return 'foo' + baz(); + } + + bar.js: + import {baz} from './baz'; + export function bar() { + return 'bar' + baz(); + } + + baz.js: + export function baz() { + return 'baz'; + } + `; + + let b = await bundle(path.join(dir, '/index.js'), { + inputFS: overlayFS, + mode: 'production', + }); + + let esm: any = await runBundle( + b, + nullthrows(b.getBundles().find(b => b.name === 'module.js')), + ); + assert.equal(esm.foo(), 'foobaz'); + assert.equal(esm.bar(), 'barbaz'); + + let cjs: any = await runBundle( + b, + nullthrows(b.getBundles().find(b => b.name === 'main.js')), + ); + assert.equal(cjs.foo(), 'foobaz'); + assert.equal(cjs.bar(), 'barbaz'); + + assertBundles(b, [ + { + assets: ['index.js'], + }, + { + assets: ['foo.js'], + }, + { + assets: ['bar.js'], + }, + { + assets: ['baz.js'], + }, + { + assets: ['index.js'], + }, + { + assets: ['foo.js'], + }, + { + assets: ['bar.js'], + }, + { + assets: ['baz.js'], + }, + ]); + + for (let bundle of b.getBundles()) { + let contents = await outputFS.readFile(bundle.filePath, 'utf8'); + assert(!contents.includes('parcelRequire')); + if (bundle.env.outputFormat === 'esmodule') { + assert(contents.includes('export {')); + } else if (bundle.env.outputFormat === 'commonjs') { + assert(contents.includes('module.exports')); + } + } + }); + + it('should merge multiple assets in the same file together', async function () { + await fsFixture(overlayFS, dir)` + yarn.lock: + + .parcelrc: + { + "extends": "@parcel/config-default", + "bundler": "@parcel/bundler-library" + } + + package.json: + { + "module": "dist/module.js" + } + + index.js: + export {foo, bar} from './foo'; + + foo.js: + import {css} from './macro' with {type: 'macro'}; + export function foo() { + return css('.a { color: red }'); + } + + export function bar() { + return css('.b { color: pink }'); + } + + macro.js: + export function css(content) { + this.addAsset({type: 'css', content}); + return 'hi'; + } + `; + + let b = await bundle(path.join(dir, '/index.js'), { + inputFS: overlayFS, + mode: 'production', + }); + + assertBundles(b, [ + { + assets: ['index.js'], + }, + { + type: 'js', + assets: ['foo.js'], + }, + { + type: 'css', + assets: ['foo.js', 'foo.js'], + }, + ]); + + for (let bundle of b.getBundles()) { + let contents = await outputFS.readFile(bundle.filePath, 'utf8'); + assert(!contents.includes('parcelRequire')); + if (bundle.type === 'css') { + assert(contents.includes('.a')); + assert(contents.includes('.b')); + } else { + assert(contents.includes('export {')); + if (bundle.name === 'module.js') { + // Should only include shallow bundle references. + assert(!contents.includes('.css')); + } + } + } + }); + + it('should work with CSS modules', async function () { + await fsFixture(overlayFS, dir)` + yarn.lock: + + .parcelrc: + { + "extends": "@parcel/config-default", + "bundler": "@parcel/bundler-library" + } + + package.json: + { + "module": "dist/module.js", + "main": "dist/main.js", + "engines": { "node": "*" } + } + + index.js: + import foo from './foo.module.css'; + export function test() { + return foo.bar; + } + + foo.module.css: + .bar { + color: red; + } + `; + + let b = await bundle(path.join(dir, '/index.js'), { + inputFS: overlayFS, + mode: 'production', + }); + + assertBundles(b, [ + { + assets: ['index.js'], + }, + { + type: 'js', + assets: ['foo.module.css'], + }, + { + type: 'css', + assets: ['foo.module.css'], + }, + { + assets: ['index.js'], + }, + { + type: 'js', + assets: ['foo.module.css'], + }, + ]); + + for (let bundle of b.getBundles()) { + let contents = await outputFS.readFile(bundle.filePath, 'utf8'); + assert(!contents.includes('parcelRequire')); + if (bundle.type === 'css') { + assert(contents.includes('.Qe6WCq_bar')); + } else if (bundle.env.outputFormat === 'esmodule') { + assert(contents.includes('export {')); + } else if (bundle.env.outputFormat === 'commonjs') { + assert(contents.includes('module.exports')); + } + } + + let esm: any = await runBundle( + b, + nullthrows(b.getBundles().find(b => b.name === 'module.js')), + ); + assert.equal(esm.test(), 'Qe6WCq_bar'); + + let cjs: any = await runBundle( + b, + nullthrows(b.getBundles().find(b => b.name === 'main.js')), + ); + assert.equal(cjs.test(), 'Qe6WCq_bar'); + }); + + it('should support re-exporting external modules', async function () { + await fsFixture(overlayFS, dir)` + yarn.lock: + + .parcelrc: + { + "extends": "@parcel/config-default", + "bundler": "@parcel/bundler-library" + } + + package.json: + { + "module": "dist/module.js", + "main": "dist/main.js", + "engines": { "node": "*" }, + "targets": { + "module": { + "includeNodeModules": false + }, + "main": { + "includeNodeModules": false + } + }, + "dependencies": { + "bar": "*" + } + } + + index.js: + export {foo} from './foo.js'; + export {bar} from 'bar'; + + foo.js: + export function foo() { + return 'foo'; + } + `; + + let b = await bundle(path.join(dir, '/index.js'), { + inputFS: overlayFS, + mode: 'production', + }); + + assertBundles(b, [ + { + assets: ['index.js'], + }, + { + type: 'js', + assets: ['foo.js'], + }, + { + assets: ['index.js'], + }, + { + type: 'js', + assets: ['foo.js'], + }, + ]); + + for (let bundle of b.getBundles()) { + let contents = await outputFS.readFile(bundle.filePath, 'utf8'); + assert(!contents.includes('parcelRequire')); + if (bundle.env.outputFormat === 'esmodule') { + assert(contents.includes('export {')); + } else if (bundle.env.outputFormat === 'commonjs') { + assert(contents.includes('module.exports')); + } + } + + let esm: any = await runBundle( + b, + nullthrows(b.getBundles().find(b => b.name === 'module.js')), + null, + undefined, + { + bar() { + return {bar: () => 2}; + }, + }, + ); + assert.equal(esm.foo(), 'foo'); + assert.equal(esm.bar(), 2); + + let cjs: any = await runBundle( + b, + nullthrows(b.getBundles().find(b => b.name === 'main.js')), + null, + undefined, + { + bar() { + return {bar: () => 2}; + }, + }, + ); + assert.equal(cjs.foo(), 'foo'); + assert.equal(cjs.bar(), 2); + }); +}); diff --git a/packages/core/integration-tests/test/output-formats.js b/packages/core/integration-tests/test/output-formats.js index 9b34280fccb..22832c2138b 100644 --- a/packages/core/integration-tests/test/output-formats.js +++ b/packages/core/integration-tests/test/output-formats.js @@ -1300,7 +1300,8 @@ describe('output formats', function () { let ns = await run(b); assert.deepEqual(ns.test, true); - assert.deepEqual(ns.default, {test: true}); + assert.deepEqual(ns.default, {test: true, 'foo-bar': true}); + assert.deepEqual(ns['foo-bar'], true); }); it('should support outputting .mjs files', async function () { @@ -1501,7 +1502,7 @@ describe('output formats', function () { assert.equal(res.default, 'foo bar'); let content = await outputFS.readFile(b.getBundles()[0].filePath, 'utf8'); - assert(/import [a-z0-9$]+ from "\.\//.test(content)); + assert(/import [^\s]+ from "\.\//.test(content)); }); describe('global', function () { diff --git a/packages/core/test-utils/src/utils.js b/packages/core/test-utils/src/utils.js index 00acbee0bb4..eeb69654738 100644 --- a/packages/core/test-utils/src/utils.js +++ b/packages/core/test-utils/src/utils.js @@ -309,6 +309,8 @@ export async function runBundles( ctx = prepareNodeContext( outputFormat === 'commonjs' && parent.filePath, globals, + undefined, + externalModules, ); break; case 'electron-renderer': { @@ -318,6 +320,7 @@ export async function runBundles( outputFormat === 'commonjs' && parent.filePath, globals, prepared.ctx, + externalModules, ); ctx = prepared.ctx; promises = prepared.promises; @@ -356,10 +359,6 @@ export async function runBundles( esmOutput = bundles.length === 1 ? res[0] : res; } else { - invariant( - externalModules == null, - 'externalModules are only supported with ESM', - ); for (let [code, b] of bundles) { // require, parcelRequire was set up in prepare*Context new vm.Script((opts.strict ? '"use strict";\n' : '') + code, { @@ -855,12 +854,21 @@ function prepareWorkerContext( const nodeCache = new Map(); // no filepath = ESM -// $FlowFixMe -function prepareNodeContext(filePath, globals, ctx: any = {}) { +function prepareNodeContext( + filePath, + globals, + // $FlowFixMe + ctx: any = {}, + externalModules?: ExternalModules, +) { let exports = {}; let req = filePath && (specifier => { + if (externalModules && specifier in externalModules) { + return externalModules[specifier](ctx); + } + // $FlowFixMe[prop-missing] let res = resolve.sync(specifier, { basedir: path.dirname(filePath), @@ -905,6 +913,10 @@ function prepareNodeContext(filePath, globals, ctx: any = {}) { return require(specifier); } + if (path.extname(res) === '.css') { + return {}; + } + let cached = nodeCache.get(res); if (cached) { return cached.module.exports; @@ -977,7 +989,12 @@ export async function runESM( if (path.isAbsolute(specifier) || specifier.startsWith('.')) { let extname = path.extname(specifier); - if (extname && extname !== '.js' && extname !== '.mjs') { + if ( + extname && + extname !== '.js' && + extname !== '.mjs' && + extname !== '.css' + ) { throw new Error( 'Unknown file extension in ' + specifier + @@ -996,7 +1013,10 @@ export async function runESM( return m; } - let source = code ?? fs.readFileSync(filename, 'utf8'); + let source = + code ?? + (extname === '.css' ? '' : null) ?? + fs.readFileSync(filename, 'utf8'); // $FlowFixMe Experimental m = new vm.SourceTextModule(source, { identifier: `${normalizeSeparators( diff --git a/packages/packagers/js/src/ESMOutputFormat.js b/packages/packagers/js/src/ESMOutputFormat.js index 85f0acaa495..16a7217e99c 100644 --- a/packages/packagers/js/src/ESMOutputFormat.js +++ b/packages/packagers/js/src/ESMOutputFormat.js @@ -3,6 +3,7 @@ import type { ScopeHoistingPackager, OutputFormat, } from './ScopeHoistingPackager'; +import {isValidIdentifier} from './utils'; export class ESMOutputFormat implements OutputFormat { packager: ScopeHoistingPackager; @@ -25,6 +26,9 @@ export class ESMOutputFormat implements OutputFormat { namespaceSpecifier = `* as ${symbol}`; } else { let specifier = imported; + if (!isValidIdentifier(specifier)) { + specifier = JSON.stringify(specifier); + } if (symbol !== imported) { specifier += ` as ${symbol}`; } @@ -93,7 +97,10 @@ export class ESMOutputFormat implements OutputFormat { for (let as of exportAs) { let specifier = local; - if (exportAs !== local) { + if (as !== local) { + if (!isValidIdentifier(as)) { + as = JSON.stringify(as); + } specifier += ` as ${as}`; } diff --git a/packages/packagers/js/src/ScopeHoistingPackager.js b/packages/packagers/js/src/ScopeHoistingPackager.js index d12a2bac987..136b2d96966 100644 --- a/packages/packagers/js/src/ScopeHoistingPackager.js +++ b/packages/packagers/js/src/ScopeHoistingPackager.js @@ -28,12 +28,12 @@ import {ESMOutputFormat} from './ESMOutputFormat'; import {CJSOutputFormat} from './CJSOutputFormat'; import {GlobalOutputFormat} from './GlobalOutputFormat'; import {prelude, helpers, bundleQueuePrelude, fnExpr} from './helpers'; -import {replaceScriptDependencies, getSpecifier} from './utils'; - -// https://262.ecma-international.org/6.0/#sec-names-and-keywords -const IDENTIFIER_RE = /^[$_\p{ID_Start}][$_\u200C\u200D\p{ID_Continue}]*$/u; -const ID_START_RE = /^[$_\p{ID_Start}]/u; -const NON_ID_CONTINUE_RE = /[^$_\u200C\u200D\p{ID_Continue}]/gu; +import { + replaceScriptDependencies, + getSpecifier, + isValidIdentifier, + makeValidIdentifier, +} from './utils'; // General regex used to replace imports with the resolved code, references with resolutions, // and count the number of newlines in the file for source maps. @@ -134,25 +134,10 @@ export class ScopeHoistingPackager { this.bundle.env.isLibrary || this.bundle.env.outputFormat === 'commonjs' ) { - for (let b of this.bundleGraph.getReferencedBundles(this.bundle)) { - let entry = b.getMainEntry(); - let symbols = new Map(); - if (entry && !this.isAsyncBundle && entry.type === 'js') { - this.externalAssets.add(entry); - - let usedSymbols = this.bundleGraph.getUsedSymbols(entry) || new Set(); - for (let s of usedSymbols) { - // If the referenced bundle is ESM, and we are importing '*', use 'default' instead. - // This matches the logic below in buildExportedSymbols. - let imported = s; - if (imported === '*' && b.env.outputFormat === 'esmodule') { - imported = 'default'; - } - symbols.set(imported, this.getSymbolResolution(entry, entry, s)); - } - } - - this.externals.set(relativeBundlePath(this.bundle, b), symbols); + for (let b of this.bundleGraph.getReferencedBundles(this.bundle, { + recursive: false, + })) { + this.externals.set(relativeBundlePath(this.bundle, b), new Map()); } } @@ -324,7 +309,6 @@ export class ScopeHoistingPackager { if ( asset.meta.shouldWrap || - this.isAsyncBundle || this.bundle.env.sourceType === 'script' || this.bundleGraph.isAssetReferenced(this.bundle, asset) || this.bundleGraph @@ -361,7 +345,6 @@ export class ScopeHoistingPackager { buildExportedSymbols() { if ( - this.isAsyncBundle || !this.bundle.env.isLibrary || this.bundle.env.outputFormat !== 'esmodule' ) { @@ -418,8 +401,8 @@ export class ScopeHoistingPackager { } getTopLevelName(name: string): string { - name = name.replace(NON_ID_CONTINUE_RE, ''); - if (!ID_START_RE.test(name) || this.globalNames.has(name)) { + name = makeValidIdentifier(name); + if (this.globalNames.has(name)) { name = '_' + name; } @@ -434,7 +417,7 @@ export class ScopeHoistingPackager { } getPropertyAccess(obj: string, property: string): string { - if (IDENTIFIER_RE.test(property)) { + if (isValidIdentifier(property)) { return `${obj}.${property}`; } @@ -511,7 +494,11 @@ export class ScopeHoistingPackager { } let [depMap, replacements] = this.buildReplacements(asset, deps); - let [prepend, prependLines, append] = this.buildAssetPrelude(asset, deps); + let [prepend, prependLines, append] = this.buildAssetPrelude( + asset, + deps, + replacements, + ); if (prependLines > 0) { sourceMap?.offsetLines(1, prependLines); code = prepend + code; @@ -703,6 +690,24 @@ ${code} continue; } + // Handle imports from other bundles in libraries. + if (this.bundle.env.isLibrary && !this.bundle.hasAsset(resolved)) { + let referencedBundle = this.bundleGraph.getReferencedBundle( + dep, + this.bundle, + ); + if ( + referencedBundle && + referencedBundle.getMainEntry() === resolved && + referencedBundle.type === 'js' && + !this.bundleGraph.isAssetReferenced(referencedBundle, resolved) + ) { + this.addExternal(dep, replacements, referencedBundle); + this.externalAssets.add(resolved); + continue; + } + } + for (let [imported, {local}] of dep.symbols) { if (local === '*') { continue; @@ -748,7 +753,11 @@ ${code} return [depMap, replacements]; } - addExternal(dep: Dependency, replacements?: Map) { + addExternal( + dep: Dependency, + replacements?: Map, + referencedBundle?: NamedBundle, + ) { if (this.bundle.env.outputFormat === 'global') { throw new ThrowableDiagnostic({ diagnostic: { @@ -766,11 +775,16 @@ ${code} }); } + let specifier = dep.specifier; + if (referencedBundle) { + specifier = relativeBundlePath(this.bundle, referencedBundle); + } + // Map of DependencySpecifier -> Map> - let external = this.externals.get(dep.specifier); + let external = this.externals.get(specifier); if (!external) { external = new Map(); - this.externals.set(dep.specifier, external); + this.externals.set(specifier, external); } for (let [imported, {local}] of dep.symbols) { @@ -786,9 +800,16 @@ ${code} if (this.bundle.env.outputFormat === 'commonjs') { renamed = external.get('*'); if (!renamed) { - renamed = this.getTopLevelName( - `$${this.bundle.publicId}$${dep.specifier}`, - ); + if (referencedBundle) { + let entry = nullthrows(referencedBundle.getMainEntry()); + renamed = + entry.symbols.get('*')?.local ?? + `$${String(entry.meta.id)}$exports`; + } else { + renamed = this.getTopLevelName( + `$${this.bundle.publicId}$${specifier}`, + ); + } external.set('*', renamed); } @@ -807,24 +828,67 @@ ${code} replacements.set(local, replacement); } } else { + let property; + if (referencedBundle) { + let entry = nullthrows(referencedBundle.getMainEntry()); + if (entry.symbols.hasExportSymbol('*')) { + // If importing * and the referenced module has a * export (e.g. CJS), use default instead. + // This mirrors the logic in buildExportedSymbols. + property = imported; + imported = + referencedBundle?.env.outputFormat === 'esmodule' + ? 'default' + : '*'; + } else { + if (imported === '*') { + let exportedSymbols = this.bundleGraph.getExportedSymbols(entry); + if (local === '*') { + // Re-export all symbols. + for (let exported of exportedSymbols) { + if (exported.symbol) { + external.set(exported.exportSymbol, exported.symbol); + } + } + continue; + } + } + renamed = this.bundleGraph.getSymbolResolution( + entry, + imported, + this.bundle, + ).symbol; + } + } + // Rename the specifier so that multiple local imports of the same imported specifier // are deduplicated. We have to prefix the imported name with the bundle id so that // local variables do not shadow it. - if (this.exportedSymbols.has(local)) { - renamed = local; - } else if (imported === 'default' || imported === '*') { - renamed = this.getTopLevelName( - `$${this.bundle.publicId}$${dep.specifier}`, - ); - } else { - renamed = this.getTopLevelName( - `$${this.bundle.publicId}$${imported}`, - ); + if (!renamed) { + if (this.exportedSymbols.has(local)) { + renamed = local; + } else if (imported === 'default' || imported === '*') { + renamed = this.getTopLevelName( + `$${this.bundle.publicId}$${specifier}`, + ); + } else { + renamed = this.getTopLevelName( + `$${this.bundle.publicId}$${imported}`, + ); + } } external.set(imported, renamed); if (local !== '*' && replacements) { - replacements.set(local, renamed); + let replacement = renamed; + if (property === '*') { + replacement = renamed; + } else if (property === 'default') { + replacement = `($parcel$interopDefault(${renamed}))`; + this.usedHelpers.add('$parcel$interopDefault'); + } else if (property) { + replacement = this.getPropertyAccess(renamed, property); + } + replacements.set(local, replacement); } } } @@ -849,6 +913,7 @@ ${code} resolved: Asset, imported: string, dep?: Dependency, + replacements?: Map, ): string { let { asset: resolvedAsset, @@ -922,13 +987,16 @@ ${code} // namespace export symbol. let assetId = resolvedAsset.meta.id; invariant(typeof assetId === 'string'); - let obj = - isWrapped && (!dep || dep?.meta.shouldWrap) - ? // Wrap in extra parenthesis to not change semantics, e.g.`new (parcelRequire("..."))()`. - `(parcelRequire(${JSON.stringify(publicId)}))` - : isWrapped && dep - ? `$${publicId}` - : resolvedAsset.symbols.get('*')?.local || `$${assetId}$exports`; + let obj; + if (isWrapped && (!dep || dep?.meta.shouldWrap)) { + // Wrap in extra parenthesis to not change semantics, e.g.`new (parcelRequire("..."))()`. + obj = `(parcelRequire(${JSON.stringify(publicId)}))`; + } else if (isWrapped && dep) { + obj = `$${publicId}`; + } else { + obj = resolvedAsset.symbols.get('*')?.local || `$${assetId}$exports`; + obj = replacements?.get(obj) || obj; + } if (imported === '*' || exportSymbol === '*' || isDefaultInterop) { // Resolve to the namespace object if requested or this is a CJS default interop reqiure. @@ -964,7 +1032,7 @@ ${code} } else if (!symbol) { invariant(false, 'Asset was skipped or not found.'); } else { - return symbol; + return replacements?.get(symbol) || symbol; } } @@ -1011,6 +1079,7 @@ ${code} buildAssetPrelude( asset: Asset, deps: Array, + replacements: Map, ): [string, number, string] { let prepend = ''; let prependLineCount = 0; @@ -1043,6 +1112,7 @@ ${code} .some( dep => !dep.isEntry && + this.bundle.hasDependency(dep) && nullthrows(this.bundleGraph.getUsedSymbols(dep)).has('*'), ))) || // If a symbol is imported (used) from a CJS asset but isn't listed in the symbols, @@ -1051,7 +1121,11 @@ ${code} [...usedSymbols].some(s => !asset.symbols.hasExportSymbol(s))) || // If the exports has this asset's namespace (e.g. ESM output from CJS input), // include the namespace object for the default export. - this.exportedSymbols.has(`$${assetId}$exports`); + this.exportedSymbols.has(`$${assetId}$exports`) || + // CommonJS library bundle entries always need a namespace. + (this.bundle.env.isLibrary && + this.bundle.env.outputFormat === 'commonjs' && + asset === this.bundle.getMainEntry()); // If the asset doesn't have static exports, should wrap, the namespace is used, // or we need default interop, then we need to synthesize a namespace object for @@ -1118,7 +1192,13 @@ ${code} (!resolved.meta.hasCJSExports && resolved.symbols.hasExportSymbol('*')) ) { - let obj = this.getSymbolResolution(asset, resolved, '*', dep); + let obj = this.getSymbolResolution( + asset, + resolved, + '*', + dep, + replacements, + ); append += `$parcel$exportWildcard($${assetId}$exports, ${obj});\n`; this.usedHelpers.add('$parcel$exportWildcard'); } else { @@ -1136,6 +1216,8 @@ ${code} asset, resolved, symbol, + undefined, + replacements, ); let get = this.buildFunctionExpression([], resolvedSymbol); let set = asset.meta.hasCJSExports @@ -1182,7 +1264,13 @@ ${code} // additional assignments after each mutation of the original binding. prepend += `\n${usedExports .map(exp => { - let resolved = this.getSymbolResolution(asset, asset, exp); + let resolved = this.getSymbolResolution( + asset, + asset, + exp, + undefined, + replacements, + ); let get = this.buildFunctionExpression([], resolved); let isEsmExport = !!asset.symbols.get(exp)?.meta?.isEsm; let set = diff --git a/packages/packagers/js/src/utils.js b/packages/packagers/js/src/utils.js index 71c551c1cae..59e981f43eb 100644 --- a/packages/packagers/js/src/utils.js +++ b/packages/packagers/js/src/utils.js @@ -55,3 +55,20 @@ export function getSpecifier(dep: Dependency): string { return dep.specifier; } + +// https://262.ecma-international.org/6.0/#sec-names-and-keywords +const IDENTIFIER_RE = /^[$_\p{ID_Start}][$_\u200C\u200D\p{ID_Continue}]*$/u; +const ID_START_RE = /^[$_\p{ID_Start}]/u; +const NON_ID_CONTINUE_RE = /[^$_\u200C\u200D\p{ID_Continue}]/gu; + +export function isValidIdentifier(id: string): boolean { + return IDENTIFIER_RE.test(id); +} + +export function makeValidIdentifier(name: string): string { + name = name.replace(NON_ID_CONTINUE_RE, ''); + if (!ID_START_RE.test(name)) { + name = '_' + name; + } + return name; +} diff --git a/packages/runtimes/js/src/JSRuntime.js b/packages/runtimes/js/src/JSRuntime.js index a083bafbd69..d66e1d6b530 100644 --- a/packages/runtimes/js/src/JSRuntime.js +++ b/packages/runtimes/js/src/JSRuntime.js @@ -232,7 +232,7 @@ export default (new Runtime({ // Skip URL runtimes for library builds. This is handled in packaging so that // the url is inlined and statically analyzable. - if (bundle.env.isLibrary && dependency.meta?.placeholder != null) { + if (bundle.env.isLibrary && mainBundle.bundleBehavior !== 'isolated') { continue; }