diff --git a/packages/core/integration-tests/test/integration/bundle-queue-runtime/a.html b/packages/core/integration-tests/test/integration/bundle-queue-runtime/a.html new file mode 100644 index 00000000000..ac8c6e251ad --- /dev/null +++ b/packages/core/integration-tests/test/integration/bundle-queue-runtime/a.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/core/integration-tests/test/integration/bundle-queue-runtime/a.js b/packages/core/integration-tests/test/integration/bundle-queue-runtime/a.js new file mode 100644 index 00000000000..0ed5c30080a --- /dev/null +++ b/packages/core/integration-tests/test/integration/bundle-queue-runtime/a.js @@ -0,0 +1 @@ +export default 'a'; diff --git a/packages/core/integration-tests/test/integration/bundle-queue-runtime/b.js b/packages/core/integration-tests/test/integration/bundle-queue-runtime/b.js new file mode 100644 index 00000000000..a68ac2819dc --- /dev/null +++ b/packages/core/integration-tests/test/integration/bundle-queue-runtime/b.js @@ -0,0 +1 @@ +export default 'b'; diff --git a/packages/core/integration-tests/test/integration/bundle-queue-runtime/c.js b/packages/core/integration-tests/test/integration/bundle-queue-runtime/c.js new file mode 100644 index 00000000000..37a4d86fac7 --- /dev/null +++ b/packages/core/integration-tests/test/integration/bundle-queue-runtime/c.js @@ -0,0 +1 @@ +export default 'c'; diff --git a/packages/core/integration-tests/test/integration/bundle-queue-runtime/index.html b/packages/core/integration-tests/test/integration/bundle-queue-runtime/index.html new file mode 100644 index 00000000000..95526304133 --- /dev/null +++ b/packages/core/integration-tests/test/integration/bundle-queue-runtime/index.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/core/integration-tests/test/integration/bundle-queue-runtime/index.js b/packages/core/integration-tests/test/integration/bundle-queue-runtime/index.js new file mode 100644 index 00000000000..36dc3214941 --- /dev/null +++ b/packages/core/integration-tests/test/integration/bundle-queue-runtime/index.js @@ -0,0 +1,5 @@ +import a from './a'; +import b from './b'; +import c from './c'; + +result([a, b, c]); diff --git a/packages/core/integration-tests/test/integration/bundle-queue-runtime/package.json b/packages/core/integration-tests/test/integration/bundle-queue-runtime/package.json new file mode 100644 index 00000000000..787e5fddbda --- /dev/null +++ b/packages/core/integration-tests/test/integration/bundle-queue-runtime/package.json @@ -0,0 +1,8 @@ +{ + "@parcel/bundler-default": { + "minBundleSize": 0 + }, + "@parcel/packager-js": { + "unstable_asyncBundleRuntime": true + } +} \ No newline at end of file diff --git a/packages/core/integration-tests/test/integration/bundle-queue-runtime/yarn.lock b/packages/core/integration-tests/test/integration/bundle-queue-runtime/yarn.lock new file mode 100644 index 00000000000..e69de29bb2d diff --git a/packages/core/integration-tests/test/scope-hoisting.js b/packages/core/integration-tests/test/scope-hoisting.js index 91ac3df6921..af5be86b833 100644 --- a/packages/core/integration-tests/test/scope-hoisting.js +++ b/packages/core/integration-tests/test/scope-hoisting.js @@ -5861,4 +5861,36 @@ describe('scope hoisting', function () { assert.equal(res, 'target'); }); }); + + it('should add experimental bundle queue runtime for out of order bundle execution', async function () { + let b = await bundle( + [ + path.join(__dirname, 'integration/bundle-queue-runtime/index.html'), + path.join(__dirname, 'integration/bundle-queue-runtime/a.html'), + ], + { + mode: 'production', + defaultTargetOptions: { + shouldScopeHoist: true, + shouldOptimize: false, + outputFormat: 'esmodule', + }, + }, + ); + + let contents = await outputFS.readFile( + b.getBundles().find(b => /index.*\.js/.test(b.filePath)).filePath, + 'utf8', + ); + assert(contents.includes('$parcel$global.rwr(')); + + let result; + await run(b, { + result: r => { + result = r; + }, + }); + + assert.deepEqual(await result, ['a', 'b', 'c']); + }); }); diff --git a/packages/packagers/js/src/ESMOutputFormat.js b/packages/packagers/js/src/ESMOutputFormat.js index aa53bdd02cc..ef79036c9cd 100644 --- a/packages/packagers/js/src/ESMOutputFormat.js +++ b/packages/packagers/js/src/ESMOutputFormat.js @@ -106,6 +106,14 @@ export class ESMOutputFormat implements OutputFormat { lines++; } + if (this.packager.shouldBundleQueue(this.packager.bundle)) { + // Should be last thing the bundle executes on intial eval + res += `\n$parcel$global.rlb(${JSON.stringify( + this.packager.bundle.publicId, + )})`; + lines++; + } + return [res, lines]; } } diff --git a/packages/packagers/js/src/ScopeHoistingPackager.js b/packages/packagers/js/src/ScopeHoistingPackager.js index 477bc179a7f..18da60fa952 100644 --- a/packages/packagers/js/src/ScopeHoistingPackager.js +++ b/packages/packagers/js/src/ScopeHoistingPackager.js @@ -27,7 +27,7 @@ import path from 'path'; import {ESMOutputFormat} from './ESMOutputFormat'; import {CJSOutputFormat} from './CJSOutputFormat'; import {GlobalOutputFormat} from './GlobalOutputFormat'; -import {prelude, helpers} from './helpers'; +import {prelude, helpers, bundleQueuePrelude, fnExpr} from './helpers'; import {replaceScriptDependencies, getSpecifier} from './utils'; // https://262.ecma-international.org/6.0/#sec-names-and-keywords @@ -74,6 +74,7 @@ export class ScopeHoistingPackager { bundleGraph: BundleGraph; bundle: NamedBundle; parcelRequireName: string; + useAsyncBundleRuntime: boolean; outputFormat: OutputFormat; isAsyncBundle: boolean; globalNames: $ReadOnlySet; @@ -101,11 +102,13 @@ export class ScopeHoistingPackager { bundleGraph: BundleGraph, bundle: NamedBundle, parcelRequireName: string, + useAsyncBundleRuntime: boolean, ) { this.options = options; this.bundleGraph = bundleGraph; this.bundle = bundle; this.parcelRequireName = parcelRequireName; + this.useAsyncBundleRuntime = useAsyncBundleRuntime; let OutputFormat = OUTPUT_FORMATS[this.bundle.env.outputFormat]; this.outputFormat = new OutputFormat(this); @@ -202,6 +205,8 @@ export class ScopeHoistingPackager { mainEntry = null; } + let needsBundleQueue = this.shouldBundleQueue(this.bundle); + // If any of the entry assets are wrapped, call parcelRequire so they are executed. for (let entry of entries) { if (this.wrappedAssets.has(entry.id) && !this.isScriptEntry(entry)) { @@ -210,13 +215,22 @@ export class ScopeHoistingPackager { )});\n`; let entryExports = entry.symbols.get('*')?.local; + if ( entryExports && entry === mainEntry && this.exportedSymbols.has(entryExports) ) { + invariant( + !needsBundleQueue, + 'Entry exports are not yet compaitble with async bundles', + ); res += `\nvar ${entryExports} = ${parcelRequire}`; } else { + if (needsBundleQueue) { + parcelRequire = this.runWhenReady(this.bundle, parcelRequire); + } + res += `\n${parcelRequire}`; } @@ -264,6 +278,38 @@ export class ScopeHoistingPackager { }; } + shouldBundleQueue(bundle: NamedBundle): boolean { + return ( + this.useAsyncBundleRuntime && + bundle.type === 'js' && + bundle.bundleBehavior !== 'inline' && + bundle.env.outputFormat === 'esmodule' && + !bundle.env.isIsolated() && + bundle.bundleBehavior !== 'isolated' && + !this.bundleGraph.hasParentBundleOfType(bundle, 'js') + ); + } + + runWhenReady(bundle: NamedBundle, codeToRun: string): string { + let deps = this.bundleGraph + .getReferencedBundles(bundle) + .filter(b => this.shouldBundleQueue(b)) + .map(b => b.publicId); + + if (deps.length === 0) { + // If no deps we can safely execute immediately + return codeToRun; + } + + let params = [ + JSON.stringify(this.bundle.publicId), + fnExpr(this.bundle.env, [], [codeToRun]), + JSON.stringify(deps), + ]; + + return `$parcel$global.rwr(${params.join(', ')});`; + } + async loadAssets(): Promise> { let queue = new PromiseQueue({maxConcurrent: 32}); let wrapped = []; @@ -599,6 +645,14 @@ ${code} this.needsPrelude = true; } + if ( + !shouldWrap && + this.shouldBundleQueue(this.bundle) && + this.bundle.getEntryAssets().some(entry => entry.id === asset.id) + ) { + code = this.runWhenReady(this.bundle, code); + } + return [code, sourceMap, lineCount]; } @@ -1199,6 +1253,14 @@ ${code} if (enableSourceMaps) { lines += countLines(preludeCode) - 1; } + + if (this.shouldBundleQueue(this.bundle)) { + let bundleQueuePreludeCode = bundleQueuePrelude(this.bundle.env); + res += bundleQueuePreludeCode; + if (enableSourceMaps) { + lines += countLines(bundleQueuePreludeCode) - 1; + } + } } else { // Otherwise, get the current parcelRequire global. res += `var parcelRequire = $parcel$global[${JSON.stringify( diff --git a/packages/packagers/js/src/helpers.js b/packages/packagers/js/src/helpers.js index 581269db6eb..4d31038755b 100644 --- a/packages/packagers/js/src/helpers.js +++ b/packages/packagers/js/src/helpers.js @@ -32,6 +32,74 @@ if (parcelRequire == null) { } `; +export const fnExpr = ( + env: Environment, + params: Array, + body: Array, +): string => { + let block = `{ ${body.join(' ')} }`; + + if (env.supports('arrow-functions')) { + return `(${params.join(', ')}) => ${block}`; + } + + return `function (${params.join(', ')}) ${block}`; +}; + +export const bundleQueuePrelude = (env: Environment): string => ` +if (!$parcel$global.lb) { + // Set of loaded bundles + $parcel$global.lb = new Set(); + // Queue of bundles to execute once they're dep bundles are loaded + $parcel$global.bq = []; + + // Register loaded bundle + $parcel$global.rlb = ${fnExpr( + env, + ['bundle'], + ['$parcel$global.lb.add(bundle);', '$parcel$global.pq();'], + )} + + // Run when ready + $parcel$global.rwr = ${fnExpr( + env, + // b = bundle public id + // r = run function to execute the bundle entry + // d = list of dependent bundles this bundle requires before executing + ['b', 'r', 'd'], + ['$parcel$global.bq.push({b, r, d});', '$parcel$global.pq();'], + )} + + // Process queue + $parcel$global.pq = ${fnExpr( + env, + [], + [ + `var runnableEntry = $parcel$global.bq.find(${fnExpr( + env, + ['i'], + [ + `return i.d.every(${fnExpr( + env, + ['dep'], + ['return $parcel$global.lb.has(dep);'], + )});`, + ], + )});`, + 'if (runnableEntry) {', + `$parcel$global.bq = $parcel$global.bq.filter(${fnExpr( + env, + ['i'], + ['return i.b !== runnableEntry.b;'], + )});`, + 'runnableEntry.r();', + '$parcel$global.pq();', + '}', + ], + )} +} +`; + const $parcel$export = ` function $parcel$export(e, n, v, s) { Object.defineProperty(e, n, {get: v, set: s, enumerable: true, configurable: true}); diff --git a/packages/packagers/js/src/index.js b/packages/packagers/js/src/index.js index 73f1ebc0aa6..f73e7d51ed7 100644 --- a/packages/packagers/js/src/index.js +++ b/packages/packagers/js/src/index.js @@ -2,24 +2,65 @@ import type {Async} from '@parcel/types'; import type SourceMap from '@parcel/source-map'; import {Packager} from '@parcel/plugin'; -import {replaceInlineReferences, replaceURLReferences} from '@parcel/utils'; +import { + replaceInlineReferences, + replaceURLReferences, + validateSchema, + type SchemaEntity, +} from '@parcel/utils'; +import {encodeJSONKeyComponent} from '@parcel/diagnostic'; import {hashString} from '@parcel/hash'; import path from 'path'; import nullthrows from 'nullthrows'; import {DevPackager} from './DevPackager'; import {ScopeHoistingPackager} from './ScopeHoistingPackager'; +type JSPackagerConfig = {| + parcelRequireName: string, + unstable_asyncBundleRuntime: boolean, +|}; + +const CONFIG_SCHEMA: SchemaEntity = { + type: 'object', + properties: { + unstable_asyncBundleRuntime: { + type: 'boolean', + }, + }, + additionalProperties: false, +}; + export default (new Packager({ - async loadConfig({config, options}) { + async loadConfig({config, options}): Promise { // Generate a name for the global parcelRequire function that is unique to this project. // This allows multiple parcel builds to coexist on the same page. let pkg = await config.getConfigFrom( path.join(options.projectRoot, 'index'), ['package.json'], ); + + let packageKey = '@parcel/packager-js'; + + if (pkg?.contents[packageKey]) { + validateSchema.diagnostic( + CONFIG_SCHEMA, + { + data: pkg?.contents[packageKey], + source: await options.inputFS.readFile(pkg.filePath, 'utf8'), + filePath: pkg.filePath, + prependKey: `/${encodeJSONKeyComponent(packageKey)}`, + }, + packageKey, + `Invalid config for ${packageKey}`, + ); + } + let name = pkg?.contents?.name ?? ''; return { parcelRequireName: 'parcelRequire' + hashString(name).slice(-4), + unstable_asyncBundleRuntime: Boolean( + pkg?.contents[packageKey]?.unstable_asyncBundleRuntime, + ), }; }, async package({ @@ -51,6 +92,7 @@ export default (new Packager({ bundleGraph, bundle, nullthrows(config).parcelRequireName, + nullthrows(config).unstable_asyncBundleRuntime, ) : new DevPackager( options,