Skip to content

Commit 80157ee

Browse files
biggs0125natebiggsharryterkelsen
authored
Add support for WASM deferred loading. (#189308)
The dart2wasm compiler supports deferred loading now producing multiple wasm modules when deferred imports are present. This change exposes wasm deferred loading for Flutter apps as well. It adds a few things: 1) Add a `--enable-wasm-deferred-loading` flag that gets plumbed through to the compiler. 2) Add a loader config entry that allows the user to specify custom module loading logic. Provide a default implementation that works for standard Flutter builds. 3) Add tests for all of this code. --------- Co-authored-by: Nate Biggs <[email protected]> Co-authored-by: Harry Terkelsen <[email protected]>
1 parent 4d8cc26 commit 80157ee

8 files changed

Lines changed: 257 additions & 14 deletions

File tree

‎engine/src/flutter/lib/web_ui/flutter_js/src/entrypoint_loader.js‎

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,7 @@ export class FlutterEntrypointLoader {
7272
entrypointBaseUrl = entryPointBaseUrl;
7373
}
7474
if (build.compileTarget === "dart2wasm") {
75-
return this._loadWasmEntrypoint(build, deps, entrypointBaseUrl, onEntrypointLoaded);
75+
return this._loadWasmEntrypoint(build, deps, entrypointBaseUrl, onEntrypointLoaded, config?.wasmDeferredModulesLoader);
7676
} else {
7777
const mainPath = build.mainJsPath ?? "main.dart.js";
7878
const entrypointUrl = resolveUrlWithSegments(entrypointBaseUrl, mainPath);
@@ -148,8 +148,9 @@ export class FlutterEntrypointLoader {
148148
* @param {*} deps
149149
* @param {string} entrypointBaseUrl
150150
* @param {import("./types").OnEntrypointLoadedCallback} onEntrypointLoaded
151+
* @param {import("./types").DeferredWasmModulesLoader} wasmDeferredModulesLoader
151152
*/
152-
async _loadWasmEntrypoint(build, deps, entrypointBaseUrl, onEntrypointLoaded) {
153+
async _loadWasmEntrypoint(build, deps, entrypointBaseUrl, onEntrypointLoaded, wasmDeferredModulesLoader) {
153154
if (!this._scriptLoaded) {
154155
this._scriptLoaded = true;
155156

@@ -181,7 +182,19 @@ export class FlutterEntrypointLoader {
181182
importsPromise = Promise.resolve({});
182183
}
183184
const compiledDartApp = await compiledDartAppPromise;
184-
const dartApp = await compiledDartApp.instantiate(await importsPromise);
185+
const defaultLoadDeferredModules = (moduleNames, handleModule) =>
186+
Promise.all(
187+
moduleNames.map((moduleName) =>
188+
handleModule(
189+
moduleName,
190+
fetch(resolveUrlWithSegments(entrypointBaseUrl, moduleName))
191+
)
192+
)
193+
);
194+
const dartApp = await compiledDartApp.instantiate(await importsPromise, {
195+
loadDeferredModules:
196+
wasmDeferredModulesLoader || defaultLoadDeferredModules,
197+
});
185198
await dartApp.invokeMain();
186199
}
187200
}

‎engine/src/flutter/lib/web_ui/flutter_js/src/types.d.ts‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,12 @@ type WasmAllowList = {
5454
[k in BrowserEngine]?: boolean;
5555
}
5656

57+
export type DeferredWasmModuleLoadedCallback =
58+
(moduleName: string, module: Response | Promise<Response> | ArrayBuffer) => Promise<void>;
59+
60+
export type DeferredWasmModulesLoader =
61+
(moduleNames: string[], onModuleLoaded: DeferredWasmModuleLoadedCallback) => Promise<void>;
62+
5763
export interface FlutterConfiguration {
5864
assetBase?: string;
5965
canvasKitBaseUrl?: string;
@@ -80,6 +86,16 @@ export interface FlutterConfiguration {
8086
* When false (default), per-build skip details are not printed; the
8187
* fatal "no compatible build found" warning is always printed. */
8288
verboseBuildSelection?: boolean;
89+
/**
90+
* The loader for deferred Wasm modules. The loader is called with a list
91+
* of module names, and a callback for each module. The callback takes
92+
* the module name, and a loaded module in a format supported by
93+
* `WebAssembly.compile` or `WebAssembly.compileStreaming`
94+
* (e.g. Promise<Response> as returned from the 'fetch' API). The callback
95+
* returns a Promise that resolves when the module is instantiated. The loader
96+
* should return a Promise that resolves when all callback promises resolve.
97+
*/
98+
wasmDeferredModulesLoader?: DeferredWasmModulesLoader;
8399
}
84100

85101
/** @deprecated Flutter's service worker is deprecated and will be removed in a future Flutter release*/
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
// Copyright 2013 The Flutter Authors. All rights reserved.
2+
// Use of this source code is governed by a BSD-style license that can be
3+
// found in the LICENSE file.
4+
5+
@TestOn('browser')
6+
library;
7+
8+
import 'dart:convert';
9+
import 'dart:js_interop';
10+
import 'dart:js_interop_unsafe';
11+
12+
import 'package:test/bootstrap/browser.dart';
13+
import 'package:test/test.dart';
14+
15+
@JS('eval')
16+
external JSAny? _eval(JSString code);
17+
18+
@JS('window._capturedDeferredModulesLoader')
19+
external JSAny? get _capturedDeferredModulesLoader;
20+
21+
@JS('window._capturedDeferredModulesLoader')
22+
external set _capturedDeferredModulesLoader(JSAny? value);
23+
24+
void main() {
25+
internalBootstrapBrowserTest(() => testMain);
26+
}
27+
28+
Future<JSObject> createEntrypointLoader() async {
29+
final promise = _eval('import("/flutter_js/src/entrypoint_loader.js")'.toJS) as JSPromise?;
30+
final module = await promise!.toDart as JSObject?;
31+
final JSFunction constructor = module!.getProperty<JSFunction>('FlutterEntrypointLoader'.toJS);
32+
return constructor.callAsConstructor<JSObject>();
33+
}
34+
35+
String createFakeRuntimeModuleCode() {
36+
return '''
37+
export function compileStreaming(promise) {
38+
return Promise.resolve({
39+
instantiate: async (imports, options) => {
40+
globalThis._capturedDeferredModulesLoader =
41+
options.loadDeferredModules;
42+
return {
43+
invokeMain: async () => {}
44+
};
45+
}
46+
});
47+
}
48+
''';
49+
}
50+
51+
JSString createFakeRuntimeUrl() {
52+
final String code = createFakeRuntimeModuleCode();
53+
return _eval(
54+
'URL.createObjectURL(new Blob([${jsonEncode(code)}], {type: "application/javascript"}))'
55+
.toJS,
56+
)!
57+
as JSString;
58+
}
59+
60+
JSObject createDummyWasmBuild() {
61+
return <String, Object>{
62+
'compileTarget': 'dart2wasm',
63+
'mainWasmPath': 'data:application/wasm,',
64+
'jsSupportRuntimePath': createFakeRuntimeUrl().toDart,
65+
}.jsify()!
66+
as JSObject;
67+
}
68+
69+
void testMain() {
70+
group('Deferred loading helper', () {
71+
setUp(() {
72+
_capturedDeferredModulesLoader = null;
73+
});
74+
75+
test('config options configure Wasm deferred loader in FlutterEntrypointLoader', () async {
76+
final JSObject entrypointLoader = await createEntrypointLoader();
77+
final JSFunction dummyModulesLoader = (() {}).toJS;
78+
79+
final config = JSObject();
80+
config.setProperty('wasmDeferredModulesLoader'.toJS, dummyModulesLoader);
81+
82+
final JSObject dummyBuild = createDummyWasmBuild();
83+
84+
final JSPromise promise = entrypointLoader.callMethodVarArgs<JSPromise>('load'.toJS, <JSAny?>[
85+
dummyBuild,
86+
JSObject(),
87+
config,
88+
]);
89+
await promise.toDart;
90+
91+
expect(_capturedDeferredModulesLoader, equals(dummyModulesLoader));
92+
});
93+
94+
test('Wasm entrypoint load passes explicit deferred modules loader to instantiate', () async {
95+
final JSObject entrypointLoader = await createEntrypointLoader();
96+
final JSFunction dummyModulesLoader = (() {}).toJS;
97+
98+
final JSObject dummyBuild = createDummyWasmBuild();
99+
100+
final JSPromise promise = entrypointLoader.callMethodVarArgs<JSPromise>(
101+
'_loadWasmEntrypoint'.toJS,
102+
<JSAny?>[dummyBuild, JSObject(), ''.toJS, (() {}).toJS, dummyModulesLoader],
103+
);
104+
await promise.toDart;
105+
106+
expect(_capturedDeferredModulesLoader, equals(dummyModulesLoader));
107+
});
108+
109+
test('Wasm entrypoint load provides default fallback loader when unconfigured', () async {
110+
final JSObject entrypointLoader = await createEntrypointLoader();
111+
112+
final JSObject dummyBuild = createDummyWasmBuild();
113+
114+
final JSPromise promise = entrypointLoader.callMethod<JSPromise>(
115+
'_loadWasmEntrypoint'.toJS,
116+
dummyBuild,
117+
JSObject(),
118+
''.toJS,
119+
(() {}).toJS,
120+
);
121+
await promise.toDart;
122+
123+
expect(_capturedDeferredModulesLoader, isNotNull);
124+
expect(_capturedDeferredModulesLoader!.isA<JSFunction>(), isTrue);
125+
});
126+
});
127+
}

‎packages/flutter_tools/lib/src/build_system/targets/web.dart‎

Lines changed: 23 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -413,27 +413,39 @@ class Dart2WasmTarget extends Dart2WebTarget {
413413
'jsSupportRuntimePath': 'main.dart.mjs',
414414
};
415415

416+
static final RegExp _partWasmRegex = RegExp(r'^main\.dart_module[0-9].*\.wasm$');
417+
static final RegExp _partWasmMapRegex = RegExp(r'^main\.dart_module[0-9].*\.wasm\.map$');
418+
416419
@override
417420
Iterable<File> buildFiles(Environment environment) => compilerConfig.dryRun
418421
? const <File>[]
419-
: environment.buildDir
420-
.listSync(recursive: true)
421-
.whereType<File>()
422-
.where(
423-
(File file) => switch (file.basename) {
424-
'main.dart.wasm' || 'main.dart.mjs' => true,
425-
'main.dart.wasm.map' => compilerConfig.sourceMaps,
426-
_ => false,
427-
},
428-
);
422+
: environment.buildDir.listSync(recursive: true).whereType<File>().where((File file) {
423+
if (file.basename == 'main.dart.wasm' || file.basename == 'main.dart.mjs') {
424+
return true;
425+
}
426+
if (compilerConfig.sourceMaps && file.basename == 'main.dart.wasm.map') {
427+
return true;
428+
}
429+
if (_partWasmRegex.hasMatch(file.basename)) {
430+
return true;
431+
}
432+
if (compilerConfig.sourceMaps && _partWasmMapRegex.hasMatch(file.basename)) {
433+
return true;
434+
}
435+
return false;
436+
});
429437

430438
@override
431439
Iterable<String> get buildPatternStems => compilerConfig.dryRun
432440
? const <String>[]
433441
: <String>[
434442
'main.dart.wasm',
443+
'main.dart_module*.wasm',
435444
'main.dart.mjs',
436-
if (compilerConfig.sourceMaps) 'main.dart.wasm.map',
445+
if (compilerConfig.sourceMaps) ...<String>[
446+
'main.dart.wasm.map',
447+
'main.dart_module*.wasm.map',
448+
],
437449
if (featureFlags.isRecordUseEnabled) LinkHooks.recordedUsesWasmFileName,
438450
];
439451

‎packages/flutter_tools/lib/src/commands/build_web.dart‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,11 @@ class BuildWebCommand extends BuildSubCommand {
115115
'If not explicitly set, uses the compilation mode (debug, profile, release).',
116116
hide: !verboseHelp,
117117
);
118+
argParser.addFlag(
119+
'enable-wasm-deferred-loading',
120+
help: 'Enable multi-module deferred loading for Wasm.',
121+
hide: !verboseHelp,
122+
);
118123
argParser.addFlag(
119124
'wasm-dry-run',
120125
defaultsTo: true,
@@ -210,6 +215,7 @@ class BuildWebCommand extends BuildSubCommand {
210215
stripWasm: boolArg('strip-wasm'),
211216
sourceMaps: sourceMaps,
212217
minify: minifyWasm,
218+
enableWasmDeferredLoading: boolArg('enable-wasm-deferred-loading'),
213219
),
214220
JsCompilerConfig(
215221
csp: boolArg('csp'),
@@ -239,6 +245,7 @@ class BuildWebCommand extends BuildSubCommand {
239245
stripWasm: boolArg('strip-wasm'),
240246
sourceMaps: sourceMaps,
241247
minify: minifyWasm,
248+
enableWasmDeferredLoading: boolArg('enable-wasm-deferred-loading'),
242249
dryRun: true,
243250
),
244251
];

‎packages/flutter_tools/lib/src/web/compiler_config.dart‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,7 @@ class WasmCompilerConfig extends WebCompilerConfig {
150150
this.minify,
151151
this.dryRun = false,
152152
super.sourceMaps = true,
153+
this.enableWasmDeferredLoading = false,
153154
super.renderer = WebRendererMode.defaultForWasm,
154155
});
155156

@@ -163,6 +164,8 @@ class WasmCompilerConfig extends WebCompilerConfig {
163164

164165
final bool dryRun;
165166

167+
final bool enableWasmDeferredLoading;
168+
166169
@override
167170
CompileTarget get compileTarget => CompileTarget.wasm;
168171

@@ -189,6 +192,7 @@ class WasmCompilerConfig extends WebCompilerConfig {
189192
if (minify ?? buildMode == BuildMode.release) '--minify' else '--no-minify',
190193
if (buildMode == BuildMode.debug) '--extra-compiler-option=--enable-asserts',
191194
if (dryRun) '--extra-compiler-option=--dry-run',
195+
if (enableWasmDeferredLoading) '--enable-deferred-loading',
192196
];
193197
}
194198

@@ -200,6 +204,7 @@ class WasmCompilerConfig extends WebCompilerConfig {
200204
'minify': minify,
201205
'dryRun': dryRun,
202206
WebCompilerConfig.kSourceMapsEnabled: sourceMaps,
207+
'enableWasmDeferredLoading': enableWasmDeferredLoading,
203208
};
204209
return jsonEncode(settings);
205210
}

‎packages/flutter_tools/test/commands.shard/hermetic/build_web_test.dart‎

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -783,6 +783,41 @@ void main() {
783783
},
784784
);
785785

786+
testUsingContext(
787+
'Passes enabled-deferred-loading to wasm when specified',
788+
() async {
789+
final buildCommand = TestWebBuildCommand(fileSystem: fileSystem);
790+
final CommandRunner<void> runner = createTestCommandRunner(buildCommand);
791+
setupFileSystemForEndToEndTest(fileSystem);
792+
await runner.run(<String>[
793+
'build',
794+
'web',
795+
'--no-pub',
796+
'--wasm',
797+
'--enable-wasm-deferred-loading',
798+
]);
799+
},
800+
overrides: <Type, Generator>{
801+
Platform: () => fakePlatform,
802+
FileSystem: () => fileSystem,
803+
FeatureFlags: () => TestFeatureFlags(isWebEnabled: true),
804+
ProcessManager: () => processManager,
805+
BuildSystem: () =>
806+
TestBuildSystem.all(BuildResult(success: true), (Target target, Environment environment) {
807+
final List<WebCompilerConfig> configs = (target as WebServiceWorker).compileConfigs;
808+
809+
expect(
810+
configs[0].toCommandOptions(BuildMode.release),
811+
contains('--enable-deferred-loading'),
812+
);
813+
expect(
814+
configs[1].toCommandOptions(BuildMode.release),
815+
isNot(contains('--enable-deferred-loading')),
816+
);
817+
}),
818+
},
819+
);
820+
786821
testUsingContext(
787822
'Web build supports build-name and build-number',
788823
() async {

‎packages/flutter_tools/test/general.shard/build_system/targets/web_test.dart‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1382,6 +1382,34 @@ _flutter.loader.load();
13821382
}
13831383
}
13841384

1385+
test('Dart2WasmTarget.buildFiles respects compilerConfig.sourceMaps and matches modules', () {
1386+
final File wasmFile = environment.buildDir.childFile('main.dart.wasm')..createSync();
1387+
final File mjsFile = environment.buildDir.childFile('main.dart.mjs')..createSync();
1388+
final File mapFile = environment.buildDir.childFile('main.dart.wasm.map')..createSync();
1389+
1390+
final File partWasmFile = environment.buildDir.childFile('main.dart_module1.wasm')
1391+
..createSync();
1392+
final File partMapFile = environment.buildDir.childFile('main.dart_module1.wasm.map')
1393+
..createSync();
1394+
1395+
final targetWithMaps = Dart2WasmTarget(const WasmCompilerConfig(), const NoOpAnalytics());
1396+
expect(
1397+
targetWithMaps.buildFiles(environment).map((f) => f.path),
1398+
containsAll(<File>[wasmFile, mjsFile, mapFile, partWasmFile, partMapFile].map((f) => f.path)),
1399+
);
1400+
1401+
final targetWithoutMaps = Dart2WasmTarget(
1402+
const WasmCompilerConfig(sourceMaps: false),
1403+
const NoOpAnalytics(),
1404+
);
1405+
expect(
1406+
targetWithoutMaps.buildFiles(environment).map((f) => f.path),
1407+
containsAll(<File>[wasmFile, mjsFile, partWasmFile].map((f) => f.path)),
1408+
);
1409+
expect(targetWithoutMaps.buildFiles(environment), isNot(contains(mapFile)));
1410+
expect(targetWithoutMaps.buildFiles(environment), isNot(contains(partMapFile)));
1411+
});
1412+
13851413
test('Dart2JSTarget has unique build keys for compiler configurations', () {
13861414
const testConfigs = <JsCompilerConfig>[
13871415
// Default values

0 commit comments

Comments
 (0)