Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add unstable async bundle runtime to the JS Packager #9227

Merged
merged 11 commits into from
Sep 7, 2023
Merged
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<script type="module" src="./a.js"></script>
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export default 'a';
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export default 'b';
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export default 'c';
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<script type="module" src="./index.js"></script>
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import a from './a';
import b from './b';
import c from './c';

result([a, b, c]);
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"@parcel/bundler-default": {
"minBundleSize": 0
},
"@parcel/packager-js": {
"unstable_asyncBundleRuntime": true
}
}
32 changes: 32 additions & 0 deletions packages/core/integration-tests/test/scope-hoisting.js
Original file line number Diff line number Diff line change
Expand Up @@ -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']);
});
});
8 changes: 8 additions & 0 deletions packages/packagers/js/src/ESMOutputFormat.js
Original file line number Diff line number Diff line change
Expand Up @@ -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];
}
}
64 changes: 63 additions & 1 deletion packages/packagers/js/src/ScopeHoistingPackager.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -74,6 +74,7 @@ export class ScopeHoistingPackager {
bundleGraph: BundleGraph<NamedBundle>;
bundle: NamedBundle;
parcelRequireName: string;
useAsyncBundleRuntime: boolean;
outputFormat: OutputFormat;
isAsyncBundle: boolean;
globalNames: $ReadOnlySet<string>;
Expand Down Expand Up @@ -101,11 +102,13 @@ export class ScopeHoistingPackager {
bundleGraph: BundleGraph<NamedBundle>,
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);
Expand Down Expand Up @@ -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)) {
Expand All @@ -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}`;
}

Expand Down Expand Up @@ -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<Array<Asset>> {
let queue = new PromiseQueue({maxConcurrent: 32});
let wrapped = [];
Expand Down Expand Up @@ -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];
}

Expand Down Expand Up @@ -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(
Expand Down
68 changes: 68 additions & 0 deletions packages/packagers/js/src/helpers.js
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,74 @@ if (parcelRequire == null) {
}
`;

export const fnExpr = (
env: Environment,
params: Array<string>,
body: Array<string>,
): 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) {
mattcompiles marked this conversation as resolved.
Show resolved Hide resolved
// 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'],
mattcompiles marked this conversation as resolved.
Show resolved Hide resolved
['$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});
Expand Down
46 changes: 44 additions & 2 deletions packages/packagers/js/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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<JSPackagerConfig> {
// 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({
Expand Down Expand Up @@ -51,6 +92,7 @@ export default (new Packager({
bundleGraph,
bundle,
nullthrows(config).parcelRequireName,
nullthrows(config).unstable_asyncBundleRuntime,
)
: new DevPackager(
options,
Expand Down