I've been investigating Dart deferred imports to split monolithic Flutter Web apps into smaller chunks. For context, see this.
I attempted the simplest split of my Flutter app in two chunks, doing this:
import 'package:flutter/material.dart';
import 'src/app.dart' deferred as app;
void main() {
final Future<void> loadedLibrary = app.loadLibrary();
runApp(
FutureBuilder(
future: loadedLibrary,
builder: (snapshot, context) => app.MyApp(),
),
);
}
With the chunking above, the idea is that one of the two chunks would contain only the core parts of the framework required to bootstrap my app (the "main" chunk), and the other part would contain my app code and its dependencies (the "app" chunk).
The goal here is that if I change anything in my app code, only the "app" chunk would redownload, while the "main" chunk could be reused from the browser cache.
The above seems to work, because my "monolithic" ~1.4MB main.dart.js got split in two parts:
Very promising! However when I started looking at the checksums of the generated files:
# Some app code
$ cksum build/web/*.js
2671938611 953221 build/web/main.dart.js
1421720584 554491 build/web/main.dart.js_1.part.js
# Removed some characters form a string in my app
$ cksum build/web/*.js
618399991 953221 build/web/main.dart.js
1722649475 554485 build/web/main.dart.js_1.part.js
As expected, the "app" chunk main.dart.js_1.part.js changed its size and checksum (I did modify some contents of one of its files, I removed some characters from a string).
UNEXPECTEDLY, the "main" chunk main.dart.js kept its size but changed its checksum. Why?
Diffing the "main" chunk after changes in my code that end up in my "app" chunk yields this:
$ diff main.dart.1.js main.dart.2.js
30618c30618
< deferredPartHashes:["S/JWhSs2AckMLrY+gn/N8qjdrc8="],
---
> deferredPartHashes:["lwJ8GQYiMTu4Oz+hIYrsHb9+37o="],
It seems that the "main" chunk is being updated with a hash of the contents of my "app" chunk, so every time I do a minor change to my "app" chunk, the "main" chunk changes and needs to be re-downloaded (instead of used from cache.)
Why do we need that deferredPartHashes? It seems that's only used as a unique identifier to track the download and activation progress of each chunk, but I'd say that anything moderately unique would suffice as an identifier (like the filename of the chunk, or even the order in which the import was detected while compiling the source.)
Can we track the deferredPartHashes differently, so the "main" chunk is more stable, and browsers have an opportunity to use it from cache?
$ which dart
~/flutter/bin/cache/dart-sdk/bin/dart
$ dart --version
Dart VM version: 2.8.0-dev.8.0.flutter-514a8d4c84
(Fri Feb 7 13:36:03 2020 +0000) on "linux_x64"
I don't think dart2js chunking is designed for stability. I.e., changes to the app chunk will usually change the first chunk.
fyi - @sigmundch
@vsmenon is correct - compiling separately and caching and reusing a separate shared piece of compiled code is simply not supported by dart2js today. The only support we have is to split apps entirely and compile each app separately. This will result in some code duplicated in both apps.
Deferred loading is designed to work in the context of a single app and it is made under the assumption that the entire app is compiled together. We choose how to split things based on how they are used by the application and we optimize code based on what the entire app does with it. If code is in a shared library that is downloaded into the main chunk, the main chunk will be different when other parts of the app start using that shared library in different ways.
OK, working as designed I guess!
In this case, I'm only trying to compile one single app. The goal was to trick the compiler into reusing the first chunk by making the code in there never change (my first chunk is just the main.dart that imports the whole App in a deferred way).
From what I understand from @vsmenon's message is that the compiler may change the code of the first chunk more than just the contents of the deferredPartHashes array, depending on what the second chunk is doing. Am I understanding right, @sigmundch? I guess that adding/removing imports or using/not using features from a dependency would change that.
I find it surprising that each "deferred import" is not isolated from others.
Thanks for taking the time to read through my rant-ticket!
I don't think @ditman is asking for any guarantees here - only the possibility of getting some caching in the context of a very specific pattern which might actually be fairly likely to enable _some_ caching of the first chunk.
The pattern is where you have _exactly on deferred import_ in your top level main.dart file - which loads your entire app.
Is there a concrete reason we need to be using hashes in the dart2js file to represent the chunks?
Oh I see in a separate conversation the minified names came up - that part probably does make this pretty unlikely to ever work as intended :(
From what I understand from @vsmenon's message is that the compiler may change the code of the first chunk more than just the contents of the deferredPartHashes array, depending on what the second chunk is doing. Am I understanding right, @sigmundch?
Correct. We have a lot of decisions that are global and do not relate to how the code is split. Some examples:
foo(List l) { ... l.length... }. We generate code for l.length one way if we know that every time a list is passed it happens to be a low-level JSArray. If one of the deferred units uses this same method and passes a different implementation of a List (e.g. a NodeList from the browser, or an observable list, or something else), then our code is generated differently to accommodate for that new polymorphism.These kind of differences are subtle and even with a lot of care I don't believe it is possible to completely avoid them.
Thanks for the thorough explanation @sigmundch! I think flutter web should emphasize the "chunking is for deferred loading but does not imply reusability" bit, because I think users assume the latter (I did).