diff --git a/packages/bundlers/default/package.json b/packages/bundlers/default/package.json index 12141e02bb1..7437a78518f 100644 --- a/packages/bundlers/default/package.json +++ b/packages/bundlers/default/package.json @@ -21,6 +21,7 @@ }, "dependencies": { "@parcel/diagnostic": "2.12.0", + "@parcel/feature-flags": "2.12.0", "@parcel/graph": "3.2.0", "@parcel/plugin": "2.12.0", "@parcel/rust": "2.12.0", diff --git a/packages/bundlers/default/src/DefaultBundler.js b/packages/bundlers/default/src/DefaultBundler.js index 23a7282028e..81358aa91df 100644 --- a/packages/bundlers/default/src/DefaultBundler.js +++ b/packages/bundlers/default/src/DefaultBundler.js @@ -23,6 +23,7 @@ import {validateSchema, DefaultMap, globToRegex} from '@parcel/utils'; import nullthrows from 'nullthrows'; import path from 'path'; import {encodeJSONKeyComponent} from '@parcel/diagnostic'; +import {getFeatureFlag} from '@parcel/feature-flags'; type Glob = string; @@ -95,6 +96,7 @@ const dependencyPriorityEdges = { sync: 1, parallel: 2, lazy: 3, + conditional: 4, }; type DependencyBundleGraph = ContentGraph< @@ -495,7 +497,9 @@ function createIdealGraph( if ( node.type === 'dependency' && - node.value.priority === 'lazy' && + (node.value.priority === 'lazy' || + (getFeatureFlag('conditionalBundling') && + node.value.priority === 'conditional')) && parentAsset ) { // Don't walk past the bundle group assets @@ -584,6 +588,8 @@ function createIdealGraph( } if ( dependency.priority === 'lazy' || + (getFeatureFlag('conditionalBundling') && + dependency.priority === 'conditional') || childAsset.bundleBehavior === 'isolated' // An isolated Dependency, or Bundle must contain all assets it needs to load. ) { if (bundleId == null) { diff --git a/packages/core/core/src/BundleGraph.js b/packages/core/core/src/BundleGraph.js index a4a9122056c..19687b5d553 100644 --- a/packages/core/core/src/BundleGraph.js +++ b/packages/core/core/src/BundleGraph.js @@ -26,6 +26,7 @@ import type { Environment, InternalSourceLocation, Target, + Condition, } from './types'; import type AssetGraph from './AssetGraph'; import type {ProjectPath} from './projectPath'; @@ -42,6 +43,7 @@ import {getBundleGroupId, getPublicId} from './utils'; import {ISOLATED_ENVS} from './public/Environment'; import {fromProjectPath, fromProjectPathRelative} from './projectPath'; import {HASH_REF_PREFIX} from './constants'; +import {getFeatureFlag} from '@parcel/feature-flags'; export const bundleGraphEdgeTypes = { // A lack of an edge type indicates to follow the edge while traversing @@ -87,6 +89,7 @@ type BundleGraphOpts = {| bundleContentHashes: Map, assetPublicIds: Set, publicIdByAssetId: Map, + conditions: Map, |}; type SerializedBundleGraph = {| @@ -95,6 +98,7 @@ type SerializedBundleGraph = {| bundleContentHashes: Map, assetPublicIds: Set, publicIdByAssetId: Map, + conditions: Map, |}; function makeReadOnlySet(set: Set): $ReadOnlySet { @@ -135,22 +139,26 @@ export default class BundleGraph { /** The internal core Graph structure */ _graph: ContentGraph; _bundlePublicIds /*: Set */ = new Set(); + _conditions /*: Set */ = new Set(); constructor({ graph, publicIdByAssetId, assetPublicIds, bundleContentHashes, + conditions, }: {| graph: ContentGraph, publicIdByAssetId: Map, assetPublicIds: Set, bundleContentHashes: Map, + conditions: Set, |}) { this._graph = graph; this._assetPublicIds = assetPublicIds; this._publicIdByAssetId = publicIdByAssetId; this._bundleContentHashes = bundleContentHashes; + this._conditions = conditions; } /** @@ -167,6 +175,9 @@ export default class BundleGraph { let assetGroupIds = new Map(); let dependencies = new Map(); let assetGraphNodeIdToBundleGraphNodeId = new Map(); + let conditions = new Map(); + + let placeholderToDependency = new Map(); let assetGraphRootNode = assetGraph.rootNodeId != null @@ -189,6 +200,18 @@ export default class BundleGraph { } } else if (node != null && node.type === 'asset_group') { assetGroupIds.set(nodeId, assetGraph.getNodeIdsConnectedFrom(nodeId)); + } else if ( + getFeatureFlag('conditionalBundling') && + node != null && + node.type === 'dependency' + ) { + // The dependency placeholders in the `importCond` calls that will be in the transformed + // code need to be mapped to the "real" depenencies, so we need access to a map of placeholders + // to dependencies + const dep = node.value; + if (dep.meta?.placeholder != null) { + placeholderToDependency.set(dep.meta.placeholder, dep); + } } } @@ -198,6 +221,44 @@ export default class BundleGraph { walkVisited.add(nodeId); let node = nullthrows(assetGraph.getNode(nodeId)); + + if (getFeatureFlag('conditionalBundling') && node.type === 'asset') { + const asset = node.value; + if (Array.isArray(asset.meta.conditions)) { + for (const condition of asset.meta.conditions ?? []) { + // Resolve the placeholders that were attached to the asset in JSTransformer to dependencies, + // as well as create a public id for the condition. + + // $FlowFixMe[incompatible-type] + const { + key, + ifTruePlaceholder, + ifFalsePlaceholder, + }: { + key: string, + ifTruePlaceholder: string, + ifFalsePlaceholder: string, + ... + } = condition; + + const condHash = hashString( + `${key}:${ifTruePlaceholder}:${ifFalsePlaceholder}`, + ); + const condPublicId = getPublicId(condHash, v => conditions.has(v)); + + conditions.set(condition, { + publicId: condPublicId, + // FIXME support the same condition used across multiple assets.. + assets: new Set([asset]), + key, + ifTrueDependency: placeholderToDependency.get(ifTruePlaceholder), + ifFalseDependency: + placeholderToDependency.get(ifFalsePlaceholder), + }); + } + } + } + if ( node.type === 'dependency' && node.value.symbols != null && @@ -434,11 +495,13 @@ export default class BundleGraph { ); } } + return new BundleGraph({ graph, assetPublicIds, bundleContentHashes: new Map(), publicIdByAssetId, + conditions, }); } @@ -449,6 +512,7 @@ export default class BundleGraph { assetPublicIds: this._assetPublicIds, bundleContentHashes: this._bundleContentHashes, publicIdByAssetId: this._publicIdByAssetId, + conditions: this._conditions, }; } @@ -458,6 +522,7 @@ export default class BundleGraph { assetPublicIds: serialized.assetPublicIds, bundleContentHashes: serialized.bundleContentHashes, publicIdByAssetId: serialized.publicIdByAssetId, + conditions: serialized.conditions, }); } @@ -1209,7 +1274,8 @@ export default class BundleGraph { .some( node => node?.type === 'dependency' && - node.value.priority === Priority.lazy && + (node.value.priority === Priority.lazy || + node.value.priority === Priority.conditional) && node.value.specifierType !== SpecifierType.url, ) ) { diff --git a/packages/core/core/src/SymbolPropagation.js b/packages/core/core/src/SymbolPropagation.js index 7bac77187dd..b5876ccf08c 100644 --- a/packages/core/core/src/SymbolPropagation.js +++ b/packages/core/core/src/SymbolPropagation.js @@ -101,7 +101,10 @@ export function propagateSymbols({ namespaceReexportedSymbols.add('*'); } else { for (let incomingDep of incomingDeps) { - if (incomingDep.value.symbols == null) { + if ( + incomingDep.value.symbols == null || + incomingDep.value.priority === 3 + ) { if (incomingDep.value.sourceAssetId == null) { // The root dependency on non-library builds isEntry = true; diff --git a/packages/core/core/src/public/BundleGraph.js b/packages/core/core/src/public/BundleGraph.js index 39d12920d42..b7bdf063dd1 100644 --- a/packages/core/core/src/public/BundleGraph.js +++ b/packages/core/core/src/public/BundleGraph.js @@ -30,6 +30,7 @@ import Dependency, { import {targetToInternalTarget} from './Target'; import {fromInternalSourceLocation} from '../utils'; import BundleGroup, {bundleGroupToInternalBundleGroup} from './BundleGroup'; +import {getFeatureFlag} from '@parcel/feature-flags'; // Friendly access for other modules within this package that need access // to the internal bundle. @@ -326,4 +327,91 @@ export default class BundleGraph targetToInternalTarget(target), ); } + + // Given a set of dependencies, return any conditions where those dependencies are either + // the true or false dependency for those conditions. This is currently used to work out which + // conditions belong to a bundle in packaging. + getConditionsForDependencies(deps: Array): Set<{| + key: string, + dependency: Dependency, + ifTrue: string, + ifFalse: string, + |}> { + // FIXME improve these lookups + const conditions = new Set(); + const depIds = deps.map(dep => dep.id); + for (const condition of this.#graph._conditions.values()) { + if ( + depIds.includes(condition.ifTrueDependency.id) || + depIds.includes(condition.ifFalseDependency.id) + ) { + const [trueAsset, falseAsset] = [ + condition.ifTrueDependency, + condition.ifFalseDependency, + ].map(dep => { + const resolved = nullthrows(this.#graph.resolveAsyncDependency(dep)); + if (resolved.type === 'asset') { + return resolved.value; + } else { + return this.#graph.getAssetById(resolved.value.entryAssetId); + } + }); + conditions.add({ + key: condition.key, + dependency: deps.find( + dep => dep.id === condition.ifTrueDependency.id, + ), + ifTrue: this.#graph.getAssetPublicId(trueAsset), + ifFalse: this.#graph.getAssetPublicId(falseAsset), + }); + } + } + // FIXME work out what's missing here.. (Flow) + return conditions; + } + + // This is used to generate information for building a manifest that can + // be used by a webserver to understand which conditions are used by which bundles, + // and which bundles those conditions require depending on what they evaluate to. + unstable_getConditionalBundleMapping(): {| + [string]: {| + bundlesWithCondition: Array, + ifTrueBundles: Array, + ifFalseBundles: Array, + |}, + |} { + let conditions = {}; + // Convert the internal references in conditions to public API references + for (const cond of this.#graph._conditions.values()) { + let assets = Array.from(cond.assets).map(asset => + nullthrows(this.getAssetById(asset.id)), + ); + let bundles = new Set(); + let ifTrueBundles = []; + let ifFalseBundles = []; + for (const asset of assets) { + const bundlesWithAsset = this.getBundlesWithAsset(asset); + for (const bundle of bundlesWithAsset) { + bundles.add(bundle); + } + const assetDeps = this.getDependencies(asset); + const depToBundles = dep => { + const publicDep = nullthrows( + assetDeps.find(assetDep => dep.id === assetDep.id), + ); + const resolved = nullthrows(this.resolveAsyncDependency(publicDep)); + invariant(resolved.type === 'bundle_group'); + return this.getBundlesInBundleGroup(resolved.value); + }; + ifTrueBundles.push(...depToBundles(cond.ifTrueDependency)); + ifFalseBundles.push(...depToBundles(cond.ifFalseDependency)); + } + conditions[cond.key] = { + bundlesWithCondition: Array.from(bundles), + ifTrueBundles, + ifFalseBundles, + }; + } + return conditions; + } } diff --git a/packages/core/core/src/public/MutableBundleGraph.js b/packages/core/core/src/public/MutableBundleGraph.js index 7144aad779c..ec8adcff5f0 100644 --- a/packages/core/core/src/public/MutableBundleGraph.js +++ b/packages/core/core/src/public/MutableBundleGraph.js @@ -13,6 +13,7 @@ import type { ParcelOptions, BundleGroup as InternalBundleGroup, BundleNode, + Condition, } from '../types'; import invariant from 'assert'; diff --git a/packages/core/core/src/types.js b/packages/core/core/src/types.js index b355f968b09..848f90cc8a0 100644 --- a/packages/core/core/src/types.js +++ b/packages/core/core/src/types.js @@ -114,6 +114,7 @@ export const Priority = { sync: 0, parallel: 1, lazy: 2, + conditional: 3, }; // Must match package_json.rs in node-resolver-rs. @@ -540,6 +541,7 @@ export type Bundle = {| displayName: ?string, pipeline: ?string, manualSharedBundle?: ?string, + conditions?: Map, |}; export type BundleNode = {| @@ -578,3 +580,11 @@ export type ValidationOpts = {| |}; export type ReportFn = (event: ReporterEvent) => void | Promise; + +export type Condition = {| + publicId: string, + assets: Set, + key: string, + ifTrueDependency: Dependency, + ifFalseDependency: Dependency, +|}; diff --git a/packages/core/feature-flags/src/index.js b/packages/core/feature-flags/src/index.js index c66b372d0dd..2bbe701c7ce 100644 --- a/packages/core/feature-flags/src/index.js +++ b/packages/core/feature-flags/src/index.js @@ -8,6 +8,7 @@ export type FeatureFlags = _FeatureFlags; export const DEFAULT_FEATURE_FLAGS: FeatureFlags = { exampleFeature: false, configKeyInvalidation: false, + conditionalBundling: false, }; let featureFlagValues: FeatureFlags = {...DEFAULT_FEATURE_FLAGS}; diff --git a/packages/core/feature-flags/src/types.js b/packages/core/feature-flags/src/types.js index 4edb1cf42f0..1ccd0a43205 100644 --- a/packages/core/feature-flags/src/types.js +++ b/packages/core/feature-flags/src/types.js @@ -9,4 +9,10 @@ export type FeatureFlags = {| * `config.getConfigFrom(..., {packageKey: '...'})` and the value itself hasn't changed. */ +configKeyInvalidation: boolean, + /** + * Enables experimental "conditional bundling" - this allows the use of `importCond` syntax + * in order to have (consumer) feature flag driven bundling. This feature is very experimental, + * and requires server-side support. + */ + +conditionalBundling: boolean, |}; diff --git a/packages/core/types-internal/src/index.js b/packages/core/types-internal/src/index.js index 39b4e9bf9f1..99a11667ac5 100644 --- a/packages/core/types-internal/src/index.js +++ b/packages/core/types-internal/src/index.js @@ -529,7 +529,7 @@ export interface MutableDependencySymbols // eslint-disable-next-line no-undef delete(exportSymbol: Symbol): void; } -export type DependencyPriority = 'sync' | 'parallel' | 'lazy'; +export type DependencyPriority = 'sync' | 'parallel' | 'lazy' | 'conditional'; export type SpecifierType = 'commonjs' | 'esm' | 'url' | 'custom'; /** @@ -1294,6 +1294,7 @@ export type CreateBundleOpts = +bundleBehavior?: ?BundleBehavior, /** Name of the manual shared bundle config that caused this bundle to be created */ +manualSharedBundle?: ?string, + +conditions?: Array, |} // If an entryAsset is not provided, a bundle id, type, and environment must // be provided. @@ -1329,6 +1330,7 @@ export type CreateBundleOpts = +pipeline?: ?string, /** Name of the manual shared bundle config that caused this bundle to be created */ +manualSharedBundle?: ?string, + +conditions?: Array, |}; /** @@ -1611,6 +1613,14 @@ export interface BundleGraph { getUsedSymbols(Asset | Dependency): ?$ReadOnlySet; /** Returns the common root directory for the entry assets of a target. */ getEntryRoot(target: Target): FilePath; + unstable_getConditionPublicId(condition: string): ?string; + unstable_getConditionalBundleMapping(): {| + [string]: {| + bundlesWithCondition: Array, + ifTrueBundles: Array, + ifFalseBundles: Array, + |}, + |}; } /** diff --git a/packages/packagers/js/package.json b/packages/packagers/js/package.json index 81033c16210..6ea2cc74850 100644 --- a/packages/packagers/js/package.json +++ b/packages/packagers/js/package.json @@ -21,6 +21,7 @@ }, "dependencies": { "@parcel/diagnostic": "2.12.0", + "@parcel/feature-flags": "2.12.0", "@parcel/plugin": "2.12.0", "@parcel/rust": "2.12.0", "@parcel/source-map": "^2.1.1", diff --git a/packages/packagers/js/src/DevPackager.js b/packages/packagers/js/src/DevPackager.js index d0b5b5234b6..f14b5224c71 100644 --- a/packages/packagers/js/src/DevPackager.js +++ b/packages/packagers/js/src/DevPackager.js @@ -8,6 +8,7 @@ import { normalizeSeparators, } from '@parcel/utils'; import SourceMap from '@parcel/source-map'; +import {getFeatureFlag} from '@parcel/feature-flags'; import invariant from 'assert'; import path from 'path'; import fs from 'fs'; diff --git a/packages/packagers/js/src/ScopeHoistingPackager.js b/packages/packagers/js/src/ScopeHoistingPackager.js index ed58dcb7f91..14d6378c013 100644 --- a/packages/packagers/js/src/ScopeHoistingPackager.js +++ b/packages/packagers/js/src/ScopeHoistingPackager.js @@ -34,11 +34,15 @@ import { isValidIdentifier, makeValidIdentifier, } from './utils'; +import {getFeatureFlag} from '@parcel/feature-flags'; // 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. -const REPLACEMENT_RE = - /\n|import\s+"([0-9a-f]{16}:.+?)";|(?:\$[0-9a-f]{16}\$exports)|(?:\$[0-9a-f]{16}\$(?:import|importAsync|require)\$[0-9a-f]+(?:\$[0-9a-f]+)?)/g; +// +// For conditional bundling the only difference in this regex is adding `importCond` where we have `importAsync` etc.. +const REPLACEMENT_RE = getFeatureFlag('conditionalBundling') + ? /\n|import\s+"([0-9a-f]{16}:.+?)";|(?:\$[0-9a-f]{16}\$exports)|(?:\$[0-9a-f]{16}\$(?:import|importAsync|importCond|require)\$[0-9a-f]+(?:\$[0-9a-f]+)?)/g + : /\n|import\s+"([0-9a-f]{16}:.+?)";|(?:\$[0-9a-f]{16}\$exports)|(?:\$[0-9a-f]{16}\$(?:import|importAsync|require)\$[0-9a-f]+(?:\$[0-9a-f]+)?)/g; const BUILTINS = Object.keys(globals.builtin); const GLOBALS_BY_CONTEXT = { @@ -535,8 +539,10 @@ export class ScopeHoistingPackager { return '\n'; } - // If we matched an import, replace with the source code for the dependency. if (d != null) { + // console.log(`mdi`, m, d, i); + + // If we matched an import, replace with the source code for the dependency. let deps = depMap.get(d); if (!deps) { return m; @@ -698,7 +704,6 @@ ${code} if (!resolved) { continue; } - // Handle imports from other bundles in libraries. if (this.bundle.env.isLibrary && !this.bundle.hasAsset(resolved)) { let referencedBundle = this.bundleGraph.getReferencedBundle( @@ -718,11 +723,21 @@ ${code} } for (let [imported, {local}] of dep.symbols) { + if (dep.priority === 'conditional') { + // console.log(`dep.symbols:`, dep.id, resolved, imported, local); + } + if (local === '*') { continue; } let symbol = this.getSymbolResolution(asset, resolved, imported, dep); + // FIXME lol + if (dep.priority === 'conditional' && replacements.has(local)) { + // console.log('\t', symbol); + continue; + } + // console.log(`replacement dep.symbols ${local} -> ${symbol}`); replacements.set( local, // If this was an internalized async asset, wrap in a Promise.resolve. @@ -735,10 +750,18 @@ ${code} // Async dependencies need a namespace object even if all used symbols were statically analyzed. // This is recorded in the promiseSymbol meta property set by the transformer rather than in // symbols so that we don't mark all symbols as used. - if (dep.priority === 'lazy' && dep.meta.promiseSymbol) { + if ( + (dep.priority === 'lazy' || dep.priority === 'conditional') && + dep.meta.promiseSymbol + ) { let promiseSymbol = dep.meta.promiseSymbol; invariant(typeof promiseSymbol === 'string'); let symbol = this.getSymbolResolution(asset, resolved, '*', dep); + // console.log(`replacement promiseSymbol ${promiseSymbol} -> ${symbol}`); + // FIXME lol + if (dep.priority === 'conditional' && replacements.has(promiseSymbol)) { + continue; + } replacements.set( promiseSymbol, asyncResolution?.type === 'asset' @@ -800,6 +823,7 @@ ${code} // If already imported, just add the already renamed variable to the mapping. let renamed = external.get(imported); if (renamed && local !== '*' && replacements) { + // console.log(`replacement renamed ${local} -> ${renamed}`); replacements.set(local, renamed); continue; } @@ -897,6 +921,7 @@ ${code} } else if (property) { replacement = this.getPropertyAccess(renamed, property); } + // console.log(`replacement at the bottom ${local} -> ${replacement}`); replacements.set(local, replacement); } } @@ -930,6 +955,9 @@ ${code} symbol, } = this.bundleGraph.getSymbolResolution(resolved, imported, this.bundle); + if (dep?.priority === 'conditional' || dep?.priority === 'lazy') { + debugger; + } if ( resolvedAsset.type !== 'js' || (dep && this.bundleGraph.isDependencySkipped(dep)) @@ -1340,8 +1368,10 @@ ${code} lines += countLines(currentHelper) - 1; } } - - if (this.needsPrelude) { + // FIXME let's make all the bundles have the runtime with conditional bundling + // - we need to just make sure that _conditional bundles_ only have the runtime + // (as they likely get loaded before the entry bundle) + if (this.needsPrelude || getFeatureFlag('conditionalBundling')) { // Add the prelude if this is potentially the first JS bundle to load in a // particular context (e.g. entry scripts in HTML, workers, etc.). let parentBundles = this.bundleGraph.getParentBundles(this.bundle); @@ -1352,7 +1382,8 @@ ${code} .getBundleGroupsContainingBundle(this.bundle) .some(g => this.bundleGraph.isEntryBundleGroup(g)) || this.bundle.env.isIsolated() || - this.bundle.bundleBehavior === 'isolated'; + this.bundle.bundleBehavior === 'isolated' || + getFeatureFlag('conditionalBundling'); if (mightBeFirstJS) { let preludeCode = prelude(this.parcelRequireName); diff --git a/packages/runtimes/js/src/JSRuntime.js b/packages/runtimes/js/src/JSRuntime.js index d66e1d6b530..0bf0a59f9ae 100644 --- a/packages/runtimes/js/src/JSRuntime.js +++ b/packages/runtimes/js/src/JSRuntime.js @@ -19,6 +19,7 @@ import { import {encodeJSONKeyComponent} from '@parcel/diagnostic'; import path from 'path'; import nullthrows from 'nullthrows'; +import {getFeatureFlag} from '@parcel/feature-flags'; // Used for as="" in preload/prefetch const TYPE_TO_RESOURCE_PRIORITY = { @@ -66,6 +67,7 @@ let bundleDependencies = new WeakMap< NamedBundle, {| asyncDependencies: Array, + conditionalDependencies: Array, otherDependencies: Array, |}, >(); @@ -127,7 +129,8 @@ export default (new Runtime({ return; } - let {asyncDependencies, otherDependencies} = getDependencies(bundle); + let {asyncDependencies, conditionalDependencies, otherDependencies} = + getDependencies(bundle); let assets = []; for (let dependency of asyncDependencies) { @@ -186,6 +189,27 @@ export default (new Runtime({ } } + if (getFeatureFlag('conditionalBundling')) { + // For any conditions that are used in this bundle, we want to produce a runtime asset that is used to + // select the correct dependency that condition maps to at runtime - the conditions in the bundle will then be + // replaced with a reference to this asset to implement the selection. + const conditions = bundleGraph.getConditionsForDependencies( + conditionalDependencies, + ); + for (const cond of conditions) { + const requireName = bundle.env.shouldScopeHoist + ? 'parcelRequire' + : '__parcel__require__'; + const assetCode = `module.exports = globalThis.__conditions['${cond.key}'] ? ${requireName}('${cond.ifTrue}') : ${requireName}('${cond.ifFalse}');`; + assets.push({ + filePath: path.join(__dirname, `/conditions/${cond.publicId}.js`), + code: assetCode, + dependency: cond.dependency, + env: {sourceType: 'module'}, + }); + } + } + for (let dependency of otherDependencies) { // Resolve the dependency to a bundle. If inline, export the dependency id, // which will be replaced with the contents of that bundle later. @@ -299,6 +323,7 @@ export default (new Runtime({ function getDependencies(bundle: NamedBundle): {| asyncDependencies: Array, + conditionalDependencies: Array, otherDependencies: Array, |} { let cachedDependencies = bundleDependencies.get(bundle); @@ -308,6 +333,7 @@ function getDependencies(bundle: NamedBundle): {| } else { let asyncDependencies = []; let otherDependencies = []; + let conditionalDependencies = []; bundle.traverse(node => { if (node.type !== 'dependency') { return; @@ -319,12 +345,18 @@ function getDependencies(bundle: NamedBundle): {| dependency.specifierType !== 'url' ) { asyncDependencies.push(dependency); + } else if (dependency.priority === 'conditional') { + conditionalDependencies.push(dependency); } else { otherDependencies.push(dependency); } }); - bundleDependencies.set(bundle, {asyncDependencies, otherDependencies}); - return {asyncDependencies, otherDependencies}; + bundleDependencies.set(bundle, { + asyncDependencies, + conditionalDependencies, + otherDependencies, + }); + return {asyncDependencies, conditionalDependencies, otherDependencies}; } } diff --git a/packages/runtimes/js/src/helpers/bundle-manifest.js b/packages/runtimes/js/src/helpers/bundle-manifest.js index 916b789e772..7aaa4dda570 100644 --- a/packages/runtimes/js/src/helpers/bundle-manifest.js +++ b/packages/runtimes/js/src/helpers/bundle-manifest.js @@ -1,5 +1,4 @@ var mapping = new Map(); - function register(baseUrl, manifest) { for (var i = 0; i < manifest.length - 1; i += 2) { mapping.set(manifest[i], { diff --git a/packages/transformers/js/core/src/collect.rs b/packages/transformers/js/core/src/collect.rs index 3bf6c2ddf12..949b12066f1 100644 --- a/packages/transformers/js/core/src/collect.rs +++ b/packages/transformers/js/core/src/collect.rs @@ -1,7 +1,7 @@ use crate::id; use crate::utils::{ - is_unresolved, match_export_name, match_export_name_ident, match_import, match_member_expr, - match_property_name, match_require, Bailout, BailoutReason, SourceLocation, + is_unresolved, match_export_name, match_export_name_ident, match_import, match_import_cond, + match_member_expr, match_property_name, match_require, Bailout, BailoutReason, SourceLocation, }; use serde::{Deserialize, Serialize}; use std::collections::{HashMap, HashSet}; @@ -29,6 +29,7 @@ pub enum ImportKind { Require, Import, DynamicImport, + ConditionalImport, } #[derive(Debug)] @@ -746,6 +747,16 @@ impl Visit for Collect { self.add_bailout(span, BailoutReason::NonStaticDynamicImport); } + if let Some((source_true, source_false)) = match_import_cond(node, self.ignore_mark) { + self.wrapped_requires.insert(source_true.to_string()); + self.wrapped_requires.insert(source_false.to_string()); + let span = match node { + Expr::Call(c) => c.span, + _ => unreachable!(), + }; + self.add_bailout(span, BailoutReason::NonStaticDynamicImport); + } + match node { Expr::Ident(ident) => { // Bail if `module` or `exports` are accessed non-statically. @@ -959,11 +970,11 @@ impl Collect { ImportKind::Import => self .wrapped_requires .insert(format!("{}{}", src.clone(), "esm")), - ImportKind::DynamicImport | ImportKind::Require => { + ImportKind::DynamicImport | ImportKind::Require | ImportKind::ConditionalImport => { self.wrapped_requires.insert(src.to_string()) } }; - if kind != ImportKind::DynamicImport { + if kind != ImportKind::DynamicImport && kind != ImportKind::ConditionalImport { self.non_static_requires.insert(src.clone()); let span = match node { Pat::Ident(id) => id.id.span, diff --git a/packages/transformers/js/core/src/dependency_collector.rs b/packages/transformers/js/core/src/dependency_collector.rs index 329c09355d0..ec288234350 100644 --- a/packages/transformers/js/core/src/dependency_collector.rs +++ b/packages/transformers/js/core/src/dependency_collector.rs @@ -1,10 +1,10 @@ use path_slash::PathBufExt; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::fmt; use std::path::Path; use serde::{Deserialize, Serialize}; -use swc_core::common::{Mark, SourceMap, Span, DUMMY_SP}; +use swc_core::common::{Mark, SourceMap, Span, Spanned, DUMMY_SP}; use swc_core::ecma::ast::{self, Callee, MemberProp}; use swc_core::ecma::atoms::{js_word, JsWord}; use swc_core::ecma::visit::{Fold, FoldWith}; @@ -28,6 +28,7 @@ pub enum DependencyKind { Import, Export, DynamicImport, + ConditionalImport, Require, WebWorker, ServiceWorker, @@ -55,6 +56,13 @@ pub struct DependencyDescriptor { pub placeholder: Option, } +#[derive(Clone, Debug, Eq, Hash, PartialEq, Serialize, Deserialize)] +pub struct Condition { + pub key: JsWord, + pub if_true_placeholder: Option, + pub if_false_placeholder: Option, +} + /// This pass collects dependencies in a module and compiles references as needed to work with Parcel's JSRuntime. pub fn dependency_collector<'a>( source_map: &'a SourceMap, @@ -63,6 +71,7 @@ pub fn dependency_collector<'a>( unresolved_mark: swc_core::common::Mark, config: &'a Config, diagnostics: &'a mut Vec, + conditions: &'a mut HashSet, ) -> impl Fold + 'a { DependencyCollector { source_map, @@ -75,6 +84,7 @@ pub fn dependency_collector<'a>( config, diagnostics, import_meta: None, + conditions, } } @@ -89,6 +99,7 @@ struct DependencyCollector<'a> { config: &'a Config, diagnostics: &'a mut Vec, import_meta: Option, + conditions: &'a mut HashSet, } impl<'a> DependencyCollector<'a> { @@ -363,6 +374,13 @@ impl<'a> Fold for DependencyCollector<'a> { Callee::Import(_) => DependencyKind::DynamicImport, Callee::Expr(expr) => { match &**expr { + // Handle this here becuase we want to treat importCond like it was a native Callee::Import + Ident(ident) + if self.config.conditional_bundling + && ident.sym.to_string().as_str() == "importCond" => + { + DependencyKind::ConditionalImport + } Ident(ident) => { // Bail if defined in scope if !is_unresolved(&ident, self.unresolved_mark) { @@ -644,25 +662,29 @@ impl<'a> Fold for DependencyCollector<'a> { return node; } - let placeholder = self.add_dependency( - specifier, - span, - kind.clone(), - attributes, - kind == DependencyKind::Require && self.in_try, - self.config.source_type, - ); - - if let Some(placeholder) = placeholder { - let mut node = node.clone(); - node.args[0].expr = Box::new(ast::Expr::Lit(ast::Lit::Str(ast::Str { - value: placeholder, - span, - raw: None, - }))); + if kind == DependencyKind::ConditionalImport { node } else { - node + let placeholder = self.add_dependency( + specifier, + span, + kind.clone(), + attributes, + kind == DependencyKind::Require && self.in_try, + self.config.source_type, + ); + + if let Some(placeholder) = placeholder { + let mut node = node.clone(); + node.args[0].expr = Box::new(ast::Expr::Lit(ast::Lit::Str(ast::Str { + value: placeholder, + span, + raw: None, + }))); + node + } else { + node + } } } else { node @@ -695,6 +717,69 @@ impl<'a> Fold for DependencyCollector<'a> { } else if kind == DependencyKind::Require { // Don't continue traversing so that the `require` isn't replaced with undefined rewrite_require_specifier(node, self.unresolved_mark) + } else if self.config.conditional_bundling && kind == DependencyKind::ConditionalImport { + let mut call = node; + // If we're not scope hositing, then change this `importCond` to a `require` + if !self.config.scope_hoist { + call.callee = ast::Callee::Expr(Box::new(ast::Expr::Ident(ast::Ident::new( + "require".into(), + DUMMY_SP, + )))); + } + + if call.args.len() != 3 { + // FIXME make this a diagnostic + panic!("importCond requires 3 arguments"); + } + let mut placeholders = Vec::new(); + // For the if_true and if_false arms of the conditional import, create a dependency for each arm + for arg in &call.args[1..] { + let specifier = match_str(&arg.expr).unwrap().0; + let placeholder = self.add_dependency( + specifier.clone(), + arg.span(), + DependencyKind::ConditionalImport, + None, + false, + self.config.source_type, + ); + println!( + "Conditional specifier: {} -> {:?}", + specifier.clone(), + placeholder + ); + placeholders.push(placeholder.unwrap()); + } + + // Create a condition we pass back to JS + let condition = Condition { + key: match_str(&call.args[0].expr).unwrap().0, + if_true_placeholder: Some(placeholders[0].clone()), + if_false_placeholder: Some(placeholders[1].clone()), + }; + self.conditions.insert(condition); + + // write out code like importCond(depIfTrue, depIfFalse) - while we use the first dep as the link to the conditions + // we need both deps to ensure scope hoisting can make sure both arms are treated as "used" + call.args[0] = ast::ExprOrSpread { + spread: None, + expr: Box::new(ast::Expr::Lit(ast::Lit::Str(ast::Str { + value: format!("{}", placeholders[0]).into(), + span: DUMMY_SP, + raw: None, + }))), + }; + call.args[1] = ast::ExprOrSpread { + spread: None, + expr: Box::new(ast::Expr::Lit(ast::Lit::Str(ast::Str { + value: format!("{}", placeholders[1]).into(), + span: DUMMY_SP, + raw: None, + }))), + }; + call.args.truncate(2); + + call } else { node.fold_children_with(self) } diff --git a/packages/transformers/js/core/src/hoist.rs b/packages/transformers/js/core/src/hoist.rs index 6c701d39eff..3820fb6beeb 100644 --- a/packages/transformers/js/core/src/hoist.rs +++ b/packages/transformers/js/core/src/hoist.rs @@ -1,7 +1,7 @@ use crate::collect::{Collect, Export, Import, ImportKind}; use crate::utils::{ get_undefined_ident, is_unresolved, match_export_name, match_export_name_ident, - match_property_name, + match_import_cond, match_property_name, }; use indexmap::IndexMap; use serde::{Deserialize, Serialize}; @@ -708,6 +708,42 @@ impl<'a> Fold for Hoist<'a> { } return Expr::Ident(Ident::new(name, call.span)); } + + // FIXME add match for importCond here? Treat it similar to above.. + if let Some((source_true, source_false)) = + match_import_cond(&node, self.collect.ignore_mark) + { + println!( + "Hoist conditional import -> {},{}", + source_true, source_false + ); + let name: JsWord = + format!("${}$importCond${}", self.module_id, hash!(source_true)).into(); + self.add_require(&source_true, ImportKind::ConditionalImport); + self.add_require(&source_false, ImportKind::ConditionalImport); + // ???? + self + .dynamic_imports + .insert(name.clone(), source_true.clone()); + self + .dynamic_imports + .insert(name.clone(), source_false.clone()); + self.imported_symbols.push(ImportedSymbol { + source: source_true, + local: name.clone(), + imported: "*".into(), + loc: SourceLocation::from(&self.collect.source_map, call.span), + kind: ImportKind::ConditionalImport, + }); + self.imported_symbols.push(ImportedSymbol { + source: source_false, + local: name.clone(), + imported: "*".into(), + loc: SourceLocation::from(&self.collect.source_map, call.span), + kind: ImportKind::ConditionalImport, + }); + return Expr::Ident(Ident::new(name, call.span)); + } } Expr::This(this) => { if !self.in_function_scope { @@ -1000,7 +1036,9 @@ impl<'a> Hoist<'a> { fn add_require(&mut self, source: &JsWord, import_kind: ImportKind) { let src = match import_kind { ImportKind::Import => format!("{}:{}:{}", self.module_id, source, "esm"), - ImportKind::DynamicImport | ImportKind::Require => format!("{}:{}", self.module_id, source), + ImportKind::DynamicImport | ImportKind::Require | ImportKind::ConditionalImport => { + format!("{}:{}", self.module_id, source) + } }; self .module_items diff --git a/packages/transformers/js/core/src/lib.rs b/packages/transformers/js/core/src/lib.rs index 83814a998d2..75fe8b10957 100644 --- a/packages/transformers/js/core/src/lib.rs +++ b/packages/transformers/js/core/src/lib.rs @@ -93,6 +93,7 @@ pub struct Config { is_swc_helpers: bool, standalone: bool, inline_constants: bool, + conditional_bundling: bool, } #[derive(Serialize, Debug, Default)] @@ -109,6 +110,7 @@ pub struct TransformResult { used_env: HashSet, has_node_replacements: bool, is_constant_module: bool, + conditions: HashSet, } fn targets_to_versions(targets: &Option>) -> Option { @@ -434,6 +436,7 @@ pub fn transform( unresolved_mark, &config, &mut diagnostics, + &mut result.conditions, ), ); diff --git a/packages/transformers/js/core/src/utils.rs b/packages/transformers/js/core/src/utils.rs index aacd2bc5ea3..156da40296a 100644 --- a/packages/transformers/js/core/src/utils.rs +++ b/packages/transformers/js/core/src/utils.rs @@ -156,6 +156,36 @@ pub fn match_require(node: &ast::Expr, unresolved_mark: Mark, ignore_mark: Mark) } } +/// This matches an expression like `importCond('if_true_dependency_id`, 'if_false_dependency_id')` and +/// returns the two dependency ids. +pub fn match_import_cond(node: &ast::Expr, ignore_mark: Mark) -> Option<(JsWord, JsWord)> { + use ast::*; + + match node { + Expr::Call(call) => match &call.callee { + Callee::Expr(expr) => match &**expr { + Expr::Ident(ident) => { + if ident.sym == js_word!("importCond") + && !is_marked(ident.span, ignore_mark) + && call.args.len() == 2 + { + let if_true = match_str(&call.args[0].expr).map(|(name, _)| name); + let if_false = match_str(&call.args[1].expr).map(|(name, _)| name); + return match (if_true, if_false) { + (Some(if_true), Some(if_false)) => Some((if_true, if_false)), + _ => None, + }; + } + None + } + _ => None, + }, + _ => None, + }, + _ => None, + } +} + pub fn match_import(node: &ast::Expr, ignore_mark: Mark) -> Option { use ast::*; diff --git a/packages/transformers/js/src/JSTransformer.js b/packages/transformers/js/src/JSTransformer.js index 5db4ac97947..9df7b842aae 100644 --- a/packages/transformers/js/src/JSTransformer.js +++ b/packages/transformers/js/src/JSTransformer.js @@ -419,6 +419,7 @@ export default (new Transformer({ used_env, has_node_replacements, is_constant_module, + conditions, } = await (transformAsync || transform)({ filename: asset.filePath, code, @@ -459,6 +460,7 @@ export default (new Transformer({ is_swc_helpers: /@swc[/\\]helpers/.test(asset.filePath), standalone: asset.query.has('standalone'), inline_constants: config.inlineConstants, + conditional_bundling: options.featureFlags.conditionalBundling, callMacro: asset.isSource ? async (err, src, exportName, args, loc) => { let mod; @@ -576,6 +578,12 @@ export default (new Transformer({ : null, }); + asset.meta.conditions = conditions.map(c => ({ + key: c.key, + ifTruePlaceholder: c.if_true_placeholder, + ifFalsePlaceholder: c.if_false_placeholder, + })); + if (is_constant_module) { asset.meta.isConstantModule = true; } @@ -850,7 +858,12 @@ export default (new Transformer({ specifier: dep.specifier, specifierType: dep.kind === 'Require' ? 'commonjs' : 'esm', loc: convertLoc(dep.loc), - priority: dep.kind === 'DynamicImport' ? 'lazy' : 'sync', + priority: + dep.kind === 'DynamicImport' + ? 'lazy' + : dep.kind === 'ConditionalImport' + ? 'conditional' + : 'sync', isOptional: dep.is_optional, meta, resolveFrom: isHelper ? __filename : undefined, @@ -882,6 +895,7 @@ export default (new Transformer({ .getDependencies() .map(dep => [dep.meta.placeholder ?? dep.specifier, dep]), ); + for (let dep of deps.values()) { dep.symbols.ensure(); } @@ -1055,6 +1069,7 @@ export default (new Transformer({ } asset.type = 'js'; + // console.log("Compiled code: " + compiledCode); asset.setBuffer(compiledCode); if (map) {