diff --git a/crates/parcel_core/src/types/dependency.rs b/crates/parcel_core/src/types/dependency.rs index f850ea20c91..b8869e7d7cc 100644 --- a/crates/parcel_core/src/types/dependency.rs +++ b/crates/parcel_core/src/types/dependency.rs @@ -224,6 +224,8 @@ pub enum Priority { Parallel = 1, /// The dependency should be placed in a separate bundle that is loaded later Lazy = 2, + /// The dependency should be deferred to a different tier + Tier = 3, } impl Default for Priority { diff --git a/crates/parcel_plugin_transformer_js/src/transformer/conversion/dependency_kind.rs b/crates/parcel_plugin_transformer_js/src/transformer/conversion/dependency_kind.rs index feaa281168d..e8154bc0f4d 100644 --- a/crates/parcel_plugin_transformer_js/src/transformer/conversion/dependency_kind.rs +++ b/crates/parcel_plugin_transformer_js/src/transformer/conversion/dependency_kind.rs @@ -15,6 +15,8 @@ pub(crate) fn convert_priority( DependencyKind::Export => Priority::Sync, DependencyKind::Require => Priority::Sync, DependencyKind::File => Priority::Sync, + DependencyKind::DeferredForDisplayImport => Priority::Tier, + DependencyKind::DeferredImport => Priority::Tier, } } @@ -33,13 +35,17 @@ pub(crate) fn convert_specifier_type( DependencyKind::Worklet => SpecifierType::Url, DependencyKind::Url => SpecifierType::Url, DependencyKind::File => SpecifierType::Custom, + DependencyKind::DeferredForDisplayImport => SpecifierType::Esm, + DependencyKind::DeferredImport => SpecifierType::Esm, } } #[cfg(test)] mod test { - use crate::transformer::test_helpers::run_swc_core_transform; - use parcel_js_swc_core::DependencyKind; + use crate::transformer::test_helpers::{ + make_test_swc_config, run_swc_core_transform, run_swc_core_transform_with_config, + }; + use parcel_js_swc_core::{Config, DependencyKind}; use super::*; @@ -55,6 +61,36 @@ mod test { assert_eq!(convert_specifier_type(&dependency), SpecifierType::Esm); } + #[test] + fn test_deferred_for_display_dependency_kind() { + let dependency = get_last_dependency_with_config(Config { + tier_imports: true, + ..make_test_swc_config( + r#" + const x = importDeferredForDisplay('other'); + "#, + ) + }); + assert_eq!(dependency.kind, DependencyKind::DeferredForDisplayImport); + assert_eq!(convert_priority(&dependency), Priority::Tier); + assert_eq!(convert_specifier_type(&dependency), SpecifierType::Esm); + } + + #[test] + fn test_deferred_dependency_kind() { + let dependency = get_last_dependency_with_config(Config { + tier_imports: true, + ..make_test_swc_config( + r#" + const x = importDeferred('other'); + "#, + ) + }); + assert_eq!(dependency.kind, DependencyKind::DeferredImport); + assert_eq!(convert_priority(&dependency), Priority::Tier); + assert_eq!(convert_specifier_type(&dependency), SpecifierType::Esm); + } + #[test] fn test_dynamic_import_dependency_kind() { let dependency = get_last_dependency( @@ -167,4 +203,10 @@ mod test { let swc_output = run_swc_core_transform(source); swc_output.dependencies.last().unwrap().clone() } + + /// Run the SWC transformer with a config and return the last dependency descriptor listed + fn get_last_dependency_with_config(config: Config) -> parcel_js_swc_core::DependencyDescriptor { + let swc_output = run_swc_core_transform_with_config(config); + swc_output.dependencies.last().unwrap().clone() + } } diff --git a/crates/parcel_plugin_transformer_js/src/transformer/test_helpers.rs b/crates/parcel_plugin_transformer_js/src/transformer/test_helpers.rs index c89c1518bf8..7b3da009ff3 100644 --- a/crates/parcel_plugin_transformer_js/src/transformer/test_helpers.rs +++ b/crates/parcel_plugin_transformer_js/src/transformer/test_helpers.rs @@ -6,6 +6,12 @@ pub(crate) fn run_swc_core_transform(source: &str) -> TransformResult { swc_output } +/// Parse a file with the `parcel_js_swc_core` parser for testing and specify a config +pub(crate) fn run_swc_core_transform_with_config(config: Config) -> TransformResult { + let swc_output = parcel_js_swc_core::transform(config, None).unwrap(); + swc_output +} + /// SWC configuration for testing pub(crate) fn make_test_swc_config(source: &str) -> Config { Config { diff --git a/packages/bundlers/default/src/DefaultBundler.js b/packages/bundlers/default/src/DefaultBundler.js index f5e0d4987c4..3179c355b1a 100644 --- a/packages/bundlers/default/src/DefaultBundler.js +++ b/packages/bundlers/default/src/DefaultBundler.js @@ -95,6 +95,7 @@ const dependencyPriorityEdges = { sync: 1, parallel: 2, lazy: 3, + tier: 4, }; type DependencyBundleGraph = ContentGraph< @@ -845,6 +846,7 @@ function createIdealGraph( if ( dependency.priority !== 'sync' && + dependency.priority !== 'tier' && dependencyBundleGraph.hasContentKey(dependency.id) ) { let assets = assetGraph.getDependencyAssets(dependency); @@ -872,7 +874,10 @@ function createIdealGraph( } } - if (dependency.priority !== 'sync') { + if ( + dependency.priority !== 'sync' && + dependency.priority !== 'tier' + ) { actions.skipChildren(); } return; diff --git a/packages/core/core/src/types.js b/packages/core/core/src/types.js index e165e24c57f..0df0a5d459b 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, + tier: 3, }; // Must match package_json.rs in node-resolver-rs. diff --git a/packages/core/core/test/test-utils.js b/packages/core/core/test/test-utils.js index 16e50320b8f..dc930ab932f 100644 --- a/packages/core/core/test/test-utils.js +++ b/packages/core/core/test/test-utils.js @@ -57,6 +57,7 @@ export const DEFAULT_OPTIONS: ParcelOptions = { ...DEFAULT_FEATURE_FLAGS, exampleFeature: false, parcelV3: false, + tieredImports: false, importRetry: false, }, }; diff --git a/packages/core/feature-flags/src/index.js b/packages/core/feature-flags/src/index.js index 8d033d2e3ea..6ad935878d1 100644 --- a/packages/core/feature-flags/src/index.js +++ b/packages/core/feature-flags/src/index.js @@ -10,6 +10,7 @@ export const DEFAULT_FEATURE_FLAGS: FeatureFlags = { parcelV3: false, importRetry: false, ownedResolverStructures: false, + tieredImports: 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 d2a8d774668..a3578215cdd 100644 --- a/packages/core/feature-flags/src/types.js +++ b/packages/core/feature-flags/src/types.js @@ -15,4 +15,11 @@ export type FeatureFlags = {| * Enable resolver refactor into owned data structures. */ ownedResolverStructures: boolean, + /** + * Tiered imports API + * Enable tier imports + * + * Tier imports allow developers to have control over when code is loaded + */ + +tieredImports: boolean, |}; diff --git a/packages/core/integration-tests/test/sourcemaps.js b/packages/core/integration-tests/test/sourcemaps.js index a22caf8b57d..e700ef423d7 100644 --- a/packages/core/integration-tests/test/sourcemaps.js +++ b/packages/core/integration-tests/test/sourcemaps.js @@ -468,7 +468,7 @@ describe.v2('sourcemaps', function () { source: inputs[1], generated: raw, str: 'exports.a', - generatedStr: 'o.a', + generatedStr: 't.a', sourcePath: 'local.js', }); @@ -477,7 +477,7 @@ describe.v2('sourcemaps', function () { source: inputs[2], generated: raw, str: 'exports.count = function(a, b) {', - generatedStr: 'o.count=function(e,n){', + generatedStr: 't.count=function(e,n){', sourcePath: 'utils/util.js', }); }); diff --git a/packages/core/types-internal/src/index.js b/packages/core/types-internal/src/index.js index 782fc4c331a..a990ad44580 100644 --- a/packages/core/types-internal/src/index.js +++ b/packages/core/types-internal/src/index.js @@ -530,7 +530,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' | 'tier'; export type SpecifierType = 'commonjs' | 'esm' | 'url' | 'custom'; /** diff --git a/packages/examples/phases/.parcelrc b/packages/examples/phases/.parcelrc new file mode 100644 index 00000000000..47d1b5e3e88 --- /dev/null +++ b/packages/examples/phases/.parcelrc @@ -0,0 +1,3 @@ +{ + "extends": "@parcel/config-default" +} diff --git a/packages/examples/phases/package.json b/packages/examples/phases/package.json new file mode 100644 index 00000000000..7fd42eec7ae --- /dev/null +++ b/packages/examples/phases/package.json @@ -0,0 +1,20 @@ +{ + "name": "@parcel/phases-example", + "version": "2.12.0", + "license": "MIT", + "private": true, + "scripts": { + "start": "parcel serve src/index.html --no-cache --https --feature-flag tieredImports=true", + "start:prod": "yarn build && npx http-server -p 1234 dist/", + "build": "PARCEL_WORKERS=0 parcel build src/index.html --no-cache --feature-flag tieredImports=true", + "debug": "PARCEL_WORKERS=0 node --inspect-brk $(yarn bin parcel) serve src/index.html --no-cache --https --feature-flag tieredImports=true --no-hmr", + "debug:prod": "PARCEL_WORKERS=0 node --inspect-brk $(yarn bin parcel) build src/index.html --no-cache --feature-flag tieredImports=true" + }, + "devDependencies": { + "parcel": "2.12.0" + }, + "dependencies": { + "react": "^17.0.2", + "react-dom": "^17.0.2" + } +} diff --git a/packages/examples/phases/phases.d.ts b/packages/examples/phases/phases.d.ts new file mode 100644 index 00000000000..c914e1855df --- /dev/null +++ b/packages/examples/phases/phases.d.ts @@ -0,0 +1,14 @@ +type ModuleRef<_> = string; +type ErrorMessage = 'You must annotate type with ""'; + +interface DeferredImport { + onReady(resource: (mod: T['default']) => void): void; +} + +declare function importDeferredForDisplay( + source: T extends void ? ErrorMessage : ModuleRef, +): DeferredImport; + +declare function importDeferred( + source: T extends void ? ErrorMessage : ModuleRef, +): DeferredImport; diff --git a/packages/examples/phases/src/index.html b/packages/examples/phases/src/index.html new file mode 100644 index 00000000000..a7cd70b7c6f --- /dev/null +++ b/packages/examples/phases/src/index.html @@ -0,0 +1,14 @@ + + + + + + + Parcel | React Phases + + +
+ + + + diff --git a/packages/examples/phases/src/index.tsx b/packages/examples/phases/src/index.tsx new file mode 100644 index 00000000000..b08a0ac1db6 --- /dev/null +++ b/packages/examples/phases/src/index.tsx @@ -0,0 +1,51 @@ +import React, {StrictMode, Suspense, useState} from 'react'; +import ReactDOM from 'react-dom'; + +import Tier1 from './tier1'; +const DeferredTier2 = + importDeferredForDisplay('./tier2'); +const DeferredTier3 = importDeferred('./tier3'); + +import {deferredLoadComponent} from './utils'; + +const Tier2 = deferredLoadComponent(DeferredTier2); +const Tier3Instance1 = deferredLoadComponent(DeferredTier3); +const Tier3Instance2 = deferredLoadComponent(DeferredTier3); + +function App() { + const [count, setCount] = useState(0); + + return ( + <> + +
App
+ + Loading Tier 2...}> + + + Loading Tier 3 instance 1...}> + + + Loading Tier 3 instance 2...}> + + +
+ Tier3Instance1 and Tier3Instance2 are{' '} + {Tier3Instance1 === Tier3Instance2 ? 'the same' : 'different'} +
+ + ); +} + +ReactDOM.render( + + + , + document.getElementById('app'), +); diff --git a/packages/examples/phases/src/lazy.tsx b/packages/examples/phases/src/lazy.tsx new file mode 100644 index 00000000000..03031ad99ee --- /dev/null +++ b/packages/examples/phases/src/lazy.tsx @@ -0,0 +1,5 @@ +import React from 'react'; + +export default function Lazy() { + return
Lazy
; +} diff --git a/packages/examples/phases/src/tier1.tsx b/packages/examples/phases/src/tier1.tsx new file mode 100644 index 00000000000..6a3b9bf8830 --- /dev/null +++ b/packages/examples/phases/src/tier1.tsx @@ -0,0 +1,5 @@ +import React from 'react'; + +export default function Tier1() { + return
Tier 1
; +} diff --git a/packages/examples/phases/src/tier2.tsx b/packages/examples/phases/src/tier2.tsx new file mode 100644 index 00000000000..3428b1c91cf --- /dev/null +++ b/packages/examples/phases/src/tier2.tsx @@ -0,0 +1,6 @@ +import React from 'react'; + +export default function Tier2({enabled}: {enabled: boolean}) { + if (enabled) return
Tier 2
; + throw new Error('Enabled prop missing'); +} diff --git a/packages/examples/phases/src/tier3.tsx b/packages/examples/phases/src/tier3.tsx new file mode 100644 index 00000000000..f0d82f99775 --- /dev/null +++ b/packages/examples/phases/src/tier3.tsx @@ -0,0 +1,5 @@ +import React from 'react'; + +export default function Tier3() { + return
Tier 3
; +} diff --git a/packages/examples/phases/src/utils.tsx b/packages/examples/phases/src/utils.tsx new file mode 100644 index 00000000000..e125e8e7637 --- /dev/null +++ b/packages/examples/phases/src/utils.tsx @@ -0,0 +1,57 @@ +import React, { + ComponentType, + ForwardRefExoticComponent, + ForwardedRef, + MemoExoticComponent, + PropsWithChildren, + PropsWithoutRef, + RefAttributes, + forwardRef, + memo, +} from 'react'; + +export function deferredLoadComponent

( + resource: DeferredImport<{default: ComponentType

}>, +): MemoExoticComponent< + ForwardRefExoticComponent< + PropsWithoutRef

& RefAttributes> + > +> { + // Create a deferred component map in the global context, so we can reuse the components everywhere + if (!globalThis.deferredComponentMap) { + globalThis.deferredComponentMap = new WeakMap, any>(); + } + + if (globalThis.deferredComponentMap.has(resource)) { + return globalThis.deferredComponentMap.get(resource); + } + + let Component: ComponentType | undefined; + let loader = new Promise(resolve => { + resource.onReady(loaded => { + Component = loaded; + resolve(loaded); + }); + }); + + const wrapper = function DeferredComponent( + props: PropsWithChildren

, + ref: ForwardedRef>, + ) { + if (Component) { + return ; + } + + throw loader; + }; + + // Support refs in the deferred component + const forwardedRef = forwardRef(wrapper); + + // Memoise so we avoid re-renders + const memoised = memo(forwardedRef); + + // Store in weak map so we only have one instance + globalThis.deferredComponentMap.set(resource, memoised); + return memoised; +} diff --git a/packages/examples/phases/tsconfig.json b/packages/examples/phases/tsconfig.json new file mode 100644 index 00000000000..c2c9ac7191d --- /dev/null +++ b/packages/examples/phases/tsconfig.json @@ -0,0 +1,8 @@ +{ + "compilerOptions": { + "typeRoots": ["phases.d.ts"], + "jsx": "react", + "lib": ["ESNext", "DOM"], + "esModuleInterop": true + } +} 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/ScopeHoistingPackager.js b/packages/packagers/js/src/ScopeHoistingPackager.js index 4f3157b7849..0b1bc0d6a93 100644 --- a/packages/packagers/js/src/ScopeHoistingPackager.js +++ b/packages/packagers/js/src/ScopeHoistingPackager.js @@ -36,6 +36,8 @@ import { } 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. +const REPLACEMENT_RE_TIERED = + /\n|import\s+"([0-9a-f]{16}:.+?)";|(?:\$[0-9a-f]{16}\$exports)|(?:\$[0-9a-f]{16}\$(?:import|importAsync|require|importDeferredForDisplay|importDeferred)\$[0-9a-f]+(?:\$[0-9a-f]+)?)/g; 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; @@ -536,92 +538,100 @@ export class ScopeHoistingPackager { // in a single regex so that we only do one pass over the whole code. let offset = 0; let columnStartIndex = 0; - code = code.replace(REPLACEMENT_RE, (m, d, i) => { - if (m === '\n') { - columnStartIndex = i + offset + 1; - lineCount++; - return '\n'; - } - - // If we matched an import, replace with the source code for the dependency. - if (d != null) { - let deps = depMap.get(d); - if (!deps) { - return m; + code = code.replace( + this.options.featureFlags.tieredImports + ? REPLACEMENT_RE_TIERED + : REPLACEMENT_RE, + (m, d, i) => { + if (m === '\n') { + columnStartIndex = i + offset + 1; + lineCount++; + return '\n'; } - let replacement = ''; - - // A single `${id}:${specifier}:esm` might have been resolved to multiple assets due to - // reexports. - for (let dep of deps) { - let resolved = this.bundleGraph.getResolvedAsset(dep, this.bundle); - let skipped = this.bundleGraph.isDependencySkipped(dep); - if (resolved && !skipped) { - // Hoist variable declarations for the referenced parcelRequire dependencies - // after the dependency is declared. This handles the case where the resulting asset - // is wrapped, but the dependency in this asset is not marked as wrapped. This means - // that it was imported/required at the top-level, so its side effects should run immediately. - let [res, lines] = this.getHoistedParcelRequires( - asset, + // If we matched an import, replace with the source code for the dependency. + if (d != null) { + let deps = depMap.get(d); + if (!deps) { + return m; + } + + let replacement = ''; + + // A single `${id}:${specifier}:esm` might have been resolved to multiple assets due to + // reexports. + for (let dep of deps) { + let resolved = this.bundleGraph.getResolvedAsset( dep, - resolved, + this.bundle, ); - let map; - if ( - this.bundle.hasAsset(resolved) && - !this.seenAssets.has(resolved.id) - ) { - // If this asset is wrapped, we need to hoist the code for the dependency - // outside our parcelRequire.register wrapper. This is safe because all - // assets referenced by this asset will also be wrapped. Otherwise, inline the - // asset content where the import statement was. - if (shouldWrap) { - depContent.push(this.visitAsset(resolved)); - } else { - let [depCode, depMap, depLines] = this.visitAsset(resolved); - res = depCode + '\n' + res; - lines += 1 + depLines; - map = depMap; + let skipped = this.bundleGraph.isDependencySkipped(dep); + if (resolved && !skipped) { + // Hoist variable declarations for the referenced parcelRequire dependencies + // after the dependency is declared. This handles the case where the resulting asset + // is wrapped, but the dependency in this asset is not marked as wrapped. This means + // that it was imported/required at the top-level, so its side effects should run immediately. + let [res, lines] = this.getHoistedParcelRequires( + asset, + dep, + resolved, + ); + let map; + if ( + this.bundle.hasAsset(resolved) && + !this.seenAssets.has(resolved.id) + ) { + // If this asset is wrapped, we need to hoist the code for the dependency + // outside our parcelRequire.register wrapper. This is safe because all + // assets referenced by this asset will also be wrapped. Otherwise, inline the + // asset content where the import statement was. + if (shouldWrap) { + depContent.push(this.visitAsset(resolved)); + } else { + let [depCode, depMap, depLines] = this.visitAsset(resolved); + res = depCode + '\n' + res; + lines += 1 + depLines; + map = depMap; + } } - } - // Push this asset's source mappings down by the number of lines in the dependency - // plus the number of hoisted parcelRequires. Then insert the source map for the dependency. - if (sourceMap) { - if (lines > 0) { - sourceMap.offsetLines(lineCount + 1, lines); - } + // Push this asset's source mappings down by the number of lines in the dependency + // plus the number of hoisted parcelRequires. Then insert the source map for the dependency. + if (sourceMap) { + if (lines > 0) { + sourceMap.offsetLines(lineCount + 1, lines); + } - if (map) { - sourceMap.addSourceMap(map, lineCount); + if (map) { + sourceMap.addSourceMap(map, lineCount); + } } - } - replacement += res; - lineCount += lines; + replacement += res; + lineCount += lines; + } } + return replacement; } - return replacement; - } - // If it wasn't a dependency, then it was an inline replacement (e.g. $id$import$foo -> $id$export$foo). - let replacement = replacements.get(m) ?? m; - if (sourceMap) { - // Offset the source map columns for this line if the replacement was a different length. - // This assumes that the match and replacement both do not contain any newlines. - let lengthDifference = replacement.length - m.length; - if (lengthDifference !== 0) { - sourceMap.offsetColumns( - lineCount + 1, - i + offset - columnStartIndex + m.length, - lengthDifference, - ); - offset += lengthDifference; + // If it wasn't a dependency, then it was an inline replacement (e.g. $id$import$foo -> $id$export$foo). + let replacement = replacements.get(m) ?? m; + if (sourceMap) { + // Offset the source map columns for this line if the replacement was a different length. + // This assumes that the match and replacement both do not contain any newlines. + let lengthDifference = replacement.length - m.length; + if (lengthDifference !== 0) { + sourceMap.offsetColumns( + lineCount + 1, + i + offset - columnStartIndex + m.length, + lengthDifference, + ); + offset += lengthDifference; + } } - } - return replacement; - }); + return replacement; + }, + ); } // If the asset is wrapped, we need to insert the dependency code outside the parcelRequire.register @@ -731,13 +741,22 @@ ${code} } let symbol = this.getSymbolResolution(asset, resolved, imported, dep); - replacements.set( - local, - // If this was an internalized async asset, wrap in a Promise.resolve. - asyncResolution?.type === 'asset' - ? `Promise.resolve(${symbol})` - : symbol, - ); + if ( + this.options.featureFlags.tieredImports && + dep.priority === 'tier' + ) { + // Wrap tiered import symbols with tier helper + replacements.set(local, `$parcel$tier(${symbol})`); + this.usedHelpers.add('$parcel$tier'); + } else { + replacements.set( + local, + // If this was an internalized async asset, wrap in a Promise.resolve. + asyncResolution?.type === 'asset' + ? `Promise.resolve(${symbol})` + : symbol, + ); + } } // Async dependencies need a namespace object even if all used symbols were statically analyzed. diff --git a/packages/packagers/js/src/dev-prelude.js b/packages/packagers/js/src/dev-prelude.js index a44249cef3d..f6de693a470 100644 --- a/packages/packagers/js/src/dev-prelude.js +++ b/packages/packagers/js/src/dev-prelude.js @@ -109,6 +109,29 @@ {}, ]; }; + newRequire.tier = function (loader) { + var listeners = new Set(); + var resolved = false; + if (loader instanceof Promise) { + loader.then(function (mod) { + resolved = true; + listeners.forEach(function (listener) { + if (listener) listener(mod.default); + }); + }); + } else { + resolved = true; + } + return { + onReady: function (listener) { + if (resolved) { + listener(loader.default); + } else { + listeners.add(listener); + } + }, + }; + }; Object.defineProperty(newRequire, 'root', { get: function () { diff --git a/packages/packagers/js/src/helpers.js b/packages/packagers/js/src/helpers.js index de455c32c57..00c66f96e17 100644 --- a/packages/packagers/js/src/helpers.js +++ b/packages/packagers/js/src/helpers.js @@ -160,10 +160,37 @@ function $parcel$defineInteropFlag(a) { } `; +const $parcel$tier = ` +function $parcel$tier(loader) { + var listeners = new Set(); + var resolved = false; + if (loader instanceof Promise) { + loader.then(function (mod) { + resolved = true; + listeners.forEach(function (listener) { + if (listener) listener(mod.default); + }); + }); + } else { + resolved = true; + } + return { + onReady: function (listener) { + if (resolved) { + listener(loader.default); + } else { + listeners.add(listener); + } + }, + }; +} +`; + export const helpers = { $parcel$export, $parcel$exportWildcard, $parcel$interopDefault, $parcel$global, $parcel$defineInteropFlag, + $parcel$tier, }; diff --git a/packages/transformers/js/core/src/collect.rs b/packages/transformers/js/core/src/collect.rs index 1912b72f97e..aa31dc7fa97 100644 --- a/packages/transformers/js/core/src/collect.rs +++ b/packages/transformers/js/core/src/collect.rs @@ -20,6 +20,7 @@ use crate::utils::is_unresolved; use crate::utils::match_export_name; use crate::utils::match_export_name_ident; use crate::utils::match_import; +use crate::utils::match_import_tier; use crate::utils::match_member_expr; use crate::utils::match_property_name; use crate::utils::match_require; @@ -46,6 +47,7 @@ pub enum ImportKind { Require, Import, DynamicImport, + TierImport, } #[derive(Debug)] @@ -768,6 +770,15 @@ impl Visit for Collect { self.add_bailout(span, BailoutReason::NonStaticDynamicImport); } + if let Some(source) = match_import_tier(node, self.ignore_mark) { + self.wrapped_requires.insert(source.to_string()); + let span = match node { + Expr::Call(c) => c.span, + _ => unreachable!(), + }; + self.add_bailout(span, BailoutReason::NonTopLevelRequire); + } + match node { Expr::Ident(ident) => { // Bail if `module` or `exports` are accessed non-statically. @@ -979,7 +990,7 @@ impl Collect { ImportKind::Import => self .wrapped_requires .insert(format!("{}{}", src.clone(), "esm")), - ImportKind::DynamicImport | ImportKind::Require => { + ImportKind::DynamicImport | ImportKind::Require | ImportKind::TierImport => { self.wrapped_requires.insert(src.to_string()) } }; diff --git a/packages/transformers/js/core/src/dependency_collector.rs b/packages/transformers/js/core/src/dependency_collector.rs index 3d176f14949..f2a84adbb23 100644 --- a/packages/transformers/js/core/src/dependency_collector.rs +++ b/packages/transformers/js/core/src/dependency_collector.rs @@ -4,6 +4,7 @@ use std::fmt; use std::hash::Hash; use std::hash::Hasher; use std::path::Path; +use swc_core::ecma::ast::MemberExpr; use path_slash::PathBufExt; use serde::Deserialize; @@ -19,6 +20,7 @@ use swc_core::ecma::ast::{self}; use swc_core::ecma::atoms::js_word; use swc_core::ecma::atoms::JsWord; use swc_core::ecma::utils::stack_size::maybe_grow_default; +use swc_core::ecma::utils::ExprFactory; use swc_core::ecma::visit::Fold; use swc_core::ecma::visit::FoldWith; @@ -51,6 +53,16 @@ pub enum DependencyKind { /// import('./dependency').then(({x}) => {/* ... */}); /// ``` DynamicImport, + /// Corresponds to a deferred for display import statement + /// ```skip + /// const DependencyDeferredForDisplay = importDeferredForDisplay('./dependency'); + /// ``` + DeferredForDisplayImport, + /// Corresponds to a deferred import statement + /// ```skip + /// const DependencyDeferred = importDeferred('./dependency'); + /// ``` + DeferredImport, /// Corresponds to CJS require statements /// ```skip /// const {x} = require('./dependency'); @@ -321,6 +333,7 @@ impl<'a> Fold for DependencyCollector<'a> { ast::ModuleItem::Stmt(ast::Stmt::Decl(ast::Decl::Var(Box::new(decl)))), ); } + res } @@ -430,6 +443,17 @@ impl<'a> Fold for DependencyCollector<'a> { Callee::Import(_) => DependencyKind::DynamicImport, Callee::Expr(expr) => { match &**expr { + Ident(ident) + if self.config.tier_imports + && ident.sym.to_string().as_str() == "importDeferredForDisplay" => + { + DependencyKind::DeferredForDisplayImport + } + Ident(ident) + if self.config.tier_imports && ident.sym.to_string().as_str() == "importDeferred" => + { + DependencyKind::DeferredImport + } Ident(ident) => { // Bail if defined in scope if !is_unresolved(&ident, self.unresolved_mark) { @@ -739,31 +763,89 @@ impl<'a> Fold for DependencyCollector<'a> { }; // Replace import() with require() - if kind == DependencyKind::DynamicImport { - let mut call = node; - if !self.config.scope_hoist && !self.config.standalone { - let name = match &self.config.source_type { - SourceType::Module => "require", - SourceType::Script => "__parcel__require__", - }; - call.callee = ast::Callee::Expr(Box::new(ast::Expr::Ident(ast::Ident::new( - name.into(), - DUMMY_SP, - )))); - } + match &kind { + DependencyKind::DynamicImport => { + let mut call = node; + if !self.config.scope_hoist && !self.config.standalone { + let name = match &self.config.source_type { + SourceType::Module => "require", + SourceType::Script => "__parcel__require__", + }; + call.callee = ast::Callee::Expr(Box::new(ast::Expr::Ident(ast::Ident::new( + name.into(), + DUMMY_SP, + )))); + } - // Drop import attributes - call.args.truncate(1); + // Drop import attributes + call.args.truncate(1); - // Track the returned require call to be replaced with a promise chain. - let rewritten_call = rewrite_require_specifier(call, self.unresolved_mark); - self.require_node = Some(rewritten_call.clone()); - rewritten_call - } else if kind == DependencyKind::Require { + // Track the returned require call to be replaced with a promise chain. + let rewritten_call = rewrite_require_specifier(call, self.unresolved_mark); + self.require_node = Some(rewritten_call.clone()); + rewritten_call + } + DependencyKind::Require => // Don't continue traversing so that the `require` isn't replaced with undefined - rewrite_require_specifier(node, self.unresolved_mark) - } else { - node.fold_children_with(self) + { + rewrite_require_specifier(node, self.unresolved_mark) + } + DependencyKind::DeferredForDisplayImport | DependencyKind::DeferredImport => { + if !self.config.tier_imports { + return node.fold_children_with(self); + } + let mut call = node; + if call.args.len() != 1 { + self.diagnostics.push(Diagnostic { + message: format!("{} requires 1 argument", kind), + code_highlights: Some(vec![CodeHighlight { + message: None, + loc: SourceLocation::from(&self.source_map, call.span), + }]), + hints: None, + show_environment: false, + severity: DiagnosticSeverity::Error, + documentation_url: None, + }); + } + + // Convert to require without scope hoisting + if !self.config.scope_hoist && !self.config.standalone { + let name = match &self.config.source_type { + SourceType::Module => "require", + SourceType::Script => "__parcel__require__", + }; + call.callee = ast::Callee::Expr(Box::new(ast::Expr::Ident(ast::Ident::new( + name.into(), + DUMMY_SP, + )))); + } + + // Track the returned require call to be replaced with a promise chain. + let rewritten_call = rewrite_require_specifier(call, self.unresolved_mark); + self.require_node = Some(rewritten_call.clone()); + + if !self.config.scope_hoist && !self.config.standalone { + // Wrap with parcel tiers when not scope hoisting + ast::CallExpr { + span: DUMMY_SP, + type_args: None, + args: vec![rewritten_call.as_arg()], + callee: ast::Callee::Expr(Box::new(Member(MemberExpr { + obj: Box::new(Member(MemberExpr { + obj: Box::new(ast::Expr::Ident(ast::Ident::new("module".into(), DUMMY_SP))), + prop: MemberProp::Ident(ast::Ident::new("bundle".into(), DUMMY_SP)), + span: DUMMY_SP, + })), + prop: MemberProp::Ident(ast::Ident::new("tier".into(), DUMMY_SP)), + span: DUMMY_SP, + }))), + } + } else { + rewritten_call + } + } + _ => node.fold_children_with(self), } } diff --git a/packages/transformers/js/core/src/hoist.rs b/packages/transformers/js/core/src/hoist.rs index 27ea16cae5e..bd59c72455c 100644 --- a/packages/transformers/js/core/src/hoist.rs +++ b/packages/transformers/js/core/src/hoist.rs @@ -27,6 +27,7 @@ use crate::utils::is_unresolved; use crate::utils::match_export_name; use crate::utils::match_export_name_ident; use crate::utils::match_import; +use crate::utils::match_import_tier; use crate::utils::match_member_expr; use crate::utils::match_property_name; use crate::utils::match_require; @@ -48,8 +49,9 @@ pub fn hoist( module_id: &str, unresolved_mark: Mark, collect: &Collect, + tiered_imports: bool, ) -> Result<(Module, HoistResult, Vec), Vec> { - let mut hoist = Hoist::new(module_id, unresolved_mark, collect); + let mut hoist = Hoist::new(module_id, unresolved_mark, collect, tiered_imports); let module = module.fold_with(&mut hoist); if !hoist.diagnostics.is_empty() { @@ -128,6 +130,7 @@ struct Hoist<'a> { in_function_scope: bool, diagnostics: Vec, unresolved_mark: Mark, + tiered_imports: bool, } /// Data pertaining to mangled identifiers replacing import and export statements @@ -336,7 +339,12 @@ pub struct HoistResult { } impl<'a> Hoist<'a> { - fn new(module_id: &'a str, unresolved_mark: Mark, collect: &'a Collect) -> Self { + fn new( + module_id: &'a str, + unresolved_mark: Mark, + collect: &'a Collect, + tiered_imports: bool, + ) -> Self { Hoist { module_id, collect, @@ -351,6 +359,7 @@ impl<'a> Hoist<'a> { in_function_scope: false, diagnostics: vec![], unresolved_mark, + tiered_imports, } } @@ -952,6 +961,19 @@ impl<'a> Fold for Hoist<'a> { } return Expr::Ident(Ident::new(name, call.span)); } + + if self.tiered_imports { + if let Some(source) = match_import_tier(&node, self.collect.ignore_mark) { + self.add_require(&source, ImportKind::TierImport); + return Expr::Ident(self.get_import_ident( + call.span, + &source, + &("*".into()), + SourceLocation::from(&self.collect.source_map, call.span), + ImportKind::TierImport, + )); + } + } } Expr::This(this) => { if !self.in_function_scope { @@ -1236,7 +1258,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::TierImport => { + format!("{}:{}", self.module_id, source) + } }; self .module_items @@ -1420,7 +1444,7 @@ mod tests { module.visit_with(&mut collect); let (module, res) = { - let mut hoist = Hoist::new("abc", unresolved_mark, &collect); + let mut hoist = Hoist::new("abc", unresolved_mark, &collect, false); let module = module.fold_with(&mut hoist); (module, hoist.get_result()) }; diff --git a/packages/transformers/js/core/src/lib.rs b/packages/transformers/js/core/src/lib.rs index d6b0fbd4677..f2dfc19319f 100644 --- a/packages/transformers/js/core/src/lib.rs +++ b/packages/transformers/js/core/src/lib.rs @@ -127,6 +127,7 @@ pub struct Config { pub is_swc_helpers: bool, pub standalone: bool, pub inline_constants: bool, + pub tier_imports: bool, } #[derive(Serialize, Debug, Default)] @@ -494,7 +495,13 @@ pub fn transform( } let module = if config.scope_hoist { - let res = hoist(module, config.module_id.as_str(), unresolved_mark, &collect); + let res = hoist( + module, + config.module_id.as_str(), + unresolved_mark, + &collect, + config.tier_imports, + ); match res { Ok((module, hoist_result, hoist_diagnostics)) => { result.hoist_result = Some(hoist_result); diff --git a/packages/transformers/js/core/src/utils.rs b/packages/transformers/js/core/src/utils.rs index a60628e7ea7..a31d3692fc5 100644 --- a/packages/transformers/js/core/src/utils.rs +++ b/packages/transformers/js/core/src/utils.rs @@ -168,6 +168,28 @@ pub fn match_require(node: &ast::Expr, unresolved_mark: Mark, ignore_mark: Mark) } } +/// This matches an expression like `importDeferredForDisplay('id')` or `importDeferred('id')` and returns the dependency id. +pub fn match_import_tier(node: &ast::Expr, ignore_mark: Mark) -> Option { + use ast::*; + + if let Expr::Call(call) = node { + if let Callee::Expr(expr) = &call.callee { + if let Expr::Ident(ident) = &**expr { + if ident.sym == js_word!("importDeferredForDisplay") + || ident.sym == js_word!("importDeferred") + { + if !is_marked(ident.span, ignore_mark) && call.args.len() == 1 { + if let Some(arg) = call.args.first() { + match_str(&arg.expr).map(|(name, _)| name); + } + } + } + } + } + } + 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..611e25cb48c 100644 --- a/packages/transformers/js/src/JSTransformer.js +++ b/packages/transformers/js/src/JSTransformer.js @@ -574,6 +574,7 @@ export default (new Transformer({ } } : null, + tier_imports: options.featureFlags.tieredImports, }); if (is_constant_module) { @@ -846,11 +847,25 @@ export default (new Transformer({ range = pkg.dependencies[module]; } + let priority; + switch (dep.kind) { + case 'DeferredForDisplayImport': + case 'DeferredImport': + priority = 'tier'; + break; + case 'DynamicImport': + priority = 'lazy'; + break; + default: + priority = 'sync'; + break; + } + asset.addDependency({ specifier: dep.specifier, specifierType: dep.kind === 'Require' ? 'commonjs' : 'esm', loc: convertLoc(dep.loc), - priority: dep.kind === 'DynamicImport' ? 'lazy' : 'sync', + priority, isOptional: dep.is_optional, meta, resolveFrom: isHelper ? __filename : undefined, @@ -882,6 +897,7 @@ export default (new Transformer({ .getDependencies() .map(dep => [dep.meta.placeholder ?? dep.specifier, dep]), ); + for (let dep of deps.values()) { dep.symbols.ensure(); }