Add option for deferred loading to gen_l10n (#53824)

diff --git a/dev/tools/localization/bin/gen_l10n.dart b/dev/tools/localization/bin/gen_l10n.dart
index 81163be..a23580d 100644
--- a/dev/tools/localization/bin/gen_l10n.dart
+++ b/dev/tools/localization/bin/gen_l10n.dart
@@ -81,6 +81,24 @@
       'Alternatively, see the `header` option to pass in a string '
       'for a simpler header.'
   );
+  parser.addFlag(
+    'use-deferred-loading',
+    defaultsTo: false,
+    help: 'Whether to generate the Dart localization file with locales imported'
+      ' as deferred, allowing for lazy loading of each locale in Flutter web.\n'
+      '\n'
+      'This can reduce a web app’s initial startup time by decreasing the '
+      'size of the JavaScript bundle. When this flag is set to true, the '
+      'messages for a particular locale are only downloaded and loaded by the '
+      'Flutter app as they are needed. For projects with a lot of different '
+      'locales and many localization strings, it can be an performance '
+      'improvement to have deferred loading. For projects with a small number '
+      'of locales, the difference is negligible, and might slow down the start '
+      'up compared to bundling the localizations with the rest of the '
+      'application.\n\n'
+      'Note that this flag does not affect other platforms such as mobile or '
+      'desktop.',
+  );
 
   final argslib.ArgResults results = parser.parse(arguments);
   if (results['help'] == true) {
@@ -98,6 +116,7 @@
   final String preferredSupportedLocaleString = results['preferred-supported-locales'] as String;
   final String headerString = results['header'] as String;
   final String headerFile = results['header-file'] as String;
+  final bool useDeferredLoading = results['use-deferred-loading'] as bool;
 
   const local.LocalFileSystem fs = local.LocalFileSystem();
   final LocalizationsGenerator localizationsGenerator = LocalizationsGenerator(fs);
@@ -112,6 +131,7 @@
         preferredSupportedLocaleString: preferredSupportedLocaleString,
         headerString: headerString,
         headerFile: headerFile,
+        useDeferredLoading: useDeferredLoading,
       )
       ..loadResources()
       ..writeOutputFile()
diff --git a/dev/tools/localization/gen_l10n.dart b/dev/tools/localization/gen_l10n.dart
index dac09d9..8baf0cb 100644
--- a/dev/tools/localization/gen_l10n.dart
+++ b/dev/tools/localization/gen_l10n.dart
@@ -206,7 +206,10 @@
     .replaceAll('@(name)', message.resourceId);
 }
 
-String _generateLookupByAllCodes(AppResourceBundleCollection allBundles, String className) {
+String _generateLookupByAllCodes(
+  AppResourceBundleCollection allBundles,
+  String Function(LocaleInfo) generateSwitchClauseTemplate,
+) {
   final Iterable<LocaleInfo> localesWithAllCodes = allBundles.locales.where((LocaleInfo locale) {
     return locale.scriptCode != null && locale.countryCode != null;
   });
@@ -216,9 +219,8 @@
   }
 
   final Iterable<String> switchClauses = localesWithAllCodes.map<String>((LocaleInfo locale) {
-    return switchClauseTemplate
-      .replaceAll('@(case)', locale.toString())
-      .replaceAll('@(class)', '$className${locale.camelCase()}');
+    return generateSwitchClauseTemplate(locale)
+      .replaceAll('@(case)', locale.toString());
   });
 
   return allCodesLookupTemplate.replaceAll(
@@ -227,7 +229,10 @@
   );
 }
 
-String _generateLookupByScriptCode(AppResourceBundleCollection allBundles, String className) {
+String _generateLookupByScriptCode(
+  AppResourceBundleCollection allBundles,
+  String Function(LocaleInfo) generateSwitchClauseTemplate,
+) {
   final Iterable<String> switchClauses = allBundles.languages.map((String language) {
     final Iterable<LocaleInfo> locales = allBundles.localesForLanguage(language);
     final Iterable<LocaleInfo> localesWithScriptCodes = locales.where((LocaleInfo locale) {
@@ -240,11 +245,9 @@
     return nestedSwitchTemplate
       .replaceAll('@(languageCode)', language)
       .replaceAll('@(code)', 'scriptCode')
-      .replaceAll('@(class)', '$className${LocaleInfo.fromString(language).camelCase()}')
       .replaceAll('@(switchClauses)', localesWithScriptCodes.map((LocaleInfo locale) {
-          return switchClauseTemplate
-            .replaceAll('@(case)', locale.scriptCode)
-            .replaceAll('@(class)', '$className${locale.camelCase()}');
+          return generateSwitchClauseTemplate(locale)
+            .replaceAll('@(case)', locale.scriptCode);
         }).join('\n        '));
   }).where((String switchClause) => switchClause != null);
 
@@ -258,7 +261,10 @@
   );
 }
 
-String _generateLookupByCountryCode(AppResourceBundleCollection allBundles, String className) {
+String _generateLookupByCountryCode(
+  AppResourceBundleCollection allBundles,
+  String Function(LocaleInfo) generateSwitchClauseTemplate,
+) {
   final Iterable<String> switchClauses = allBundles.languages.map((String language) {
     final Iterable<LocaleInfo> locales = allBundles.localesForLanguage(language);
     final Iterable<LocaleInfo> localesWithCountryCodes = locales.where((LocaleInfo locale) {
@@ -271,11 +277,9 @@
     return nestedSwitchTemplate
       .replaceAll('@(languageCode)', language)
       .replaceAll('@(code)', 'countryCode')
-      .replaceAll('@(class)', '$className${LocaleInfo.fromString(language).camelCase()}')
       .replaceAll('@(switchClauses)', localesWithCountryCodes.map((LocaleInfo locale) {
-          return switchClauseTemplate
-            .replaceAll('@(case)', locale.countryCode)
-            .replaceAll('@(class)', '$className${locale.camelCase()}');
+          return generateSwitchClauseTemplate(locale)
+            .replaceAll('@(case)', locale.countryCode);
         }).join('\n        '));
   }).where((String switchClause) => switchClause != null);
 
@@ -288,7 +292,10 @@
     .replaceAll('@(switchClauses)', switchClauses.join('\n    '));
 }
 
-String _generateLookupByLanguageCode(AppResourceBundleCollection allBundles, String className) {
+String _generateLookupByLanguageCode(
+  AppResourceBundleCollection allBundles,
+  String Function(LocaleInfo) generateSwitchClauseTemplate,
+) {
   final Iterable<String> switchClauses = allBundles.languages.map((String language) {
     final Iterable<LocaleInfo> locales = allBundles.localesForLanguage(language);
     final Iterable<LocaleInfo> localesWithLanguageCode = locales.where((LocaleInfo locale) {
@@ -299,9 +306,8 @@
       return null;
 
     return localesWithLanguageCode.map((LocaleInfo locale) {
-      return switchClauseTemplate
-        .replaceAll('@(case)', locale.languageCode)
-        .replaceAll('@(class)', '$className${locale.camelCase()}');
+      return generateSwitchClauseTemplate(locale)
+        .replaceAll('@(case)', locale.languageCode);
     }).join('\n        ');
   }).where((String switchClause) => switchClause != null);
 
@@ -314,12 +320,67 @@
     .replaceAll('@(switchClauses)', switchClauses.join('\n    '));
 }
 
-String _generateLookupBody(AppResourceBundleCollection allBundles, String className) {
+String _generateLookupBody(
+  AppResourceBundleCollection allBundles,
+  String className,
+  bool useDeferredLoading,
+  String fileName,
+) {
+  final String Function(LocaleInfo) generateSwitchClauseTemplate = (LocaleInfo locale) {
+    return (useDeferredLoading ?
+      switchClauseDeferredLoadingTemplate : switchClauseTemplate)
+      .replaceAll('@(localeClass)', '$className${locale.camelCase()}')
+      .replaceAll('@(appClass)', className)
+      .replaceAll('@(library)', '${fileName}_${locale.languageCode}');
+  };
   return lookupBodyTemplate
-    .replaceAll('@(lookupAllCodesSpecified)', _generateLookupByAllCodes(allBundles, className))
-    .replaceAll('@(lookupScriptCodeSpecified)', _generateLookupByScriptCode(allBundles, className))
-    .replaceAll('@(lookupCountryCodeSpecified)', _generateLookupByCountryCode(allBundles, className))
-    .replaceAll('@(lookupLanguageCodeSpecified)', _generateLookupByLanguageCode(allBundles, className));
+    .replaceAll('@(lookupAllCodesSpecified)', _generateLookupByAllCodes(
+      allBundles,
+      generateSwitchClauseTemplate,
+    ))
+    .replaceAll('@(lookupScriptCodeSpecified)', _generateLookupByScriptCode(
+      allBundles,
+      generateSwitchClauseTemplate,
+    ))
+    .replaceAll('@(lookupCountryCodeSpecified)', _generateLookupByCountryCode(
+      allBundles,
+      generateSwitchClauseTemplate,
+    ))
+    .replaceAll('@(lookupLanguageCodeSpecified)', _generateLookupByLanguageCode(
+      allBundles,
+      generateSwitchClauseTemplate,
+    ));
+}
+
+String _generateDelegateClass({
+  AppResourceBundleCollection allBundles,
+  String className,
+  Set<String> supportedLanguageCodes,
+  bool useDeferredLoading,
+  String fileName,
+}) {
+
+  final String lookupBody = _generateLookupBody(
+    allBundles,
+    className,
+    useDeferredLoading,
+    fileName,
+  );
+  final String loadBody = (
+    useDeferredLoading ? loadBodyDeferredLoadingTemplate : loadBodyTemplate
+  )
+    .replaceAll('@(class)', className)
+    .replaceAll('@(lookupName)', '_lookup$className');
+  final String lookupFunction = (useDeferredLoading ?
+  lookupFunctionDeferredLoadingTemplate : lookupFunctionTemplate)
+    .replaceAll('@(class)', className)
+    .replaceAll('@(lookupName)', '_lookup$className')
+    .replaceAll('@(lookupBody)', lookupBody);
+  return delegateClassTemplate
+    .replaceAll('@(class)', className)
+    .replaceAll('@(loadBody)', loadBody)
+    .replaceAll('@(supportedLanguageCodes)', supportedLanguageCodes.join(', '))
+    .replaceAll('@(lookupFunction)', lookupFunction);
 }
 
 class LocalizationsGenerator {
@@ -397,6 +458,23 @@
 
   final Map<LocaleInfo, List<String>> _unimplementedMessages = <LocaleInfo, List<String>>{};
 
+  /// Whether to generate the Dart localization file with locales imported as
+  /// deferred, allowing for lazy loading of each locale in Flutter web.
+  ///
+  /// This can reduce a web app’s initial startup time by decreasing the size of
+  /// the JavaScript bundle. When [_useDeferredLoading] is set to true, the
+  /// messages for a particular locale are only downloaded and loaded by the
+  /// Flutter app as they are needed. For projects with a lot of different
+  /// locales and many localization strings, it can be an performance
+  /// improvement to have deferred loading. For projects with a small number of
+  /// locales, the difference is negligible, and might slow down the start up
+  /// compared to bundling the localizations with the rest of the application.
+  ///
+  /// Note that this flag does not affect other platforms such as mobile or
+  /// desktop.
+  bool get useDeferredLoading => _useDeferredLoading;
+  bool _useDeferredLoading;
+
   /// Initializes [l10nDirectory], [templateArbFile], [outputFile] and [className].
   ///
   /// Throws an [L10nException] when a provided configuration is not allowed
@@ -412,12 +490,14 @@
     String preferredSupportedLocaleString,
     String headerString,
     String headerFile,
+    bool useDeferredLoading = false,
   }) {
     setL10nDirectory(l10nDirectoryPath);
     setTemplateArbFile(templateArbFileName);
     setOutputFile(outputFileString);
     setPreferredSupportedLocales(preferredSupportedLocaleString);
     _setHeader(headerString, headerFile);
+    _setUseDeferredLoading(useDeferredLoading);
     className = classNameString;
   }
 
@@ -550,6 +630,13 @@
     }
   }
 
+  void _setUseDeferredLoading(bool useDeferredLoading) {
+    if (useDeferredLoading == null) {
+      throw L10nException('useDeferredLoading argument cannot be null.');
+    }
+    _useDeferredLoading = useDeferredLoading;
+  }
+
   static bool _isValidGetterAndMethodName(String name) {
     // Public Dart method name must not start with an underscore
     if (name[0] == '_')
@@ -746,13 +833,26 @@
       }
     }
 
-    final Iterable<String> localeImports = supportedLocales
+    final List<String> sortedClassImports = supportedLocales
       .where((LocaleInfo locale) => isBaseClassLocale(locale, locale.languageCode))
       .map((LocaleInfo locale) {
-        return "import '${fileName}_${locale.toString()}.dart';";
-      });
+        final String library = '${fileName}_${locale.toString()}';
+        if (useDeferredLoading) {
+          return "import '$library.dart' deferred as $library;";
+        } else {
+          return "import '$library.dart';";
+        }
+      })
+      .toList()
+      ..sort();
 
-    final String lookupBody = _generateLookupBody(_allBundles, className);
+    final String delegateClass = _generateDelegateClass(
+      allBundles: _allBundles,
+      className: className,
+      supportedLanguageCodes: supportedLanguageCodes,
+      useDeferredLoading: useDeferredLoading,
+      fileName: fileName,
+    );
 
     return fileTemplate
       .replaceAll('@(header)', header)
@@ -761,9 +861,8 @@
       .replaceAll('@(importFile)', '$directory/$outputFileName')
       .replaceAll('@(supportedLocales)', supportedLocalesCode.join(',\n    '))
       .replaceAll('@(supportedLanguageCodes)', supportedLanguageCodes.join(', '))
-      .replaceAll('@(messageClassImports)', localeImports.join('\n'))
-      .replaceAll('@(lookupName)', '_lookup$className')
-      .replaceAll('@(lookupBody)', lookupBody);
+      .replaceAll('@(messageClassImports)', sortedClassImports.join('\n'))
+      .replaceAll('@(delegateClass)', delegateClass);
   }
 
   void writeOutputFile() {
diff --git a/dev/tools/localization/gen_l10n_templates.dart b/dev/tools/localization/gen_l10n_templates.dart
index 434e67c..90cd6d8 100644
--- a/dev/tools/localization/gen_l10n_templates.dart
+++ b/dev/tools/localization/gen_l10n_templates.dart
@@ -6,6 +6,7 @@
 @(header)
 import 'dart:async';
 
+// ignore: unused_import
 import 'package:flutter/foundation.dart';
 import 'package:flutter/widgets.dart';
 import 'package:flutter_localizations/flutter_localizations.dart';
@@ -104,26 +105,7 @@
 
 @(methods)}
 
-class _@(class)Delegate extends LocalizationsDelegate<@(class)> {
-  const _@(class)Delegate();
-
-  @override
-  Future<@(class)> load(Locale locale) {
-    return SynchronousFuture<@(class)>(@(lookupName)(locale));
-  }
-
-  @override
-  bool isSupported(Locale locale) => <String>[@(supportedLanguageCodes)].contains(locale.languageCode);
-
-  @override
-  bool shouldReload(_@(class)Delegate old) => false;
-}
-
-@(class) @(lookupName)(Locale locale) {
-  @(lookupBody)
-  assert(false, '@(class).delegate failed to load unsupported locale "\$locale"');
-  return null;
-}
+@(delegateClass)
 ''';
 
 const String numberFormatTemplate = '''
@@ -205,14 +187,67 @@
   String @(name)(@(parameters));
 ''';
 
+// DELEGATE CLASS TEMPLATES
+
+const String delegateClassTemplate = '''
+class _@(class)Delegate extends LocalizationsDelegate<@(class)> {
+  const _@(class)Delegate();
+
+  @override
+  Future<@(class)> load(Locale locale) {
+    @(loadBody)
+  }
+
+  @override
+  bool isSupported(Locale locale) => <String>[@(supportedLanguageCodes)].contains(locale.languageCode);
+
+  @override
+  bool shouldReload(_@(class)Delegate old) => false;
+}
+
+@(lookupFunction)''';
+
+const String loadBodyTemplate = '''return SynchronousFuture<@(class)>(@(lookupName)(locale));''';
+
+const String loadBodyDeferredLoadingTemplate = '''return @(lookupName)(locale);''';
+
 // DELEGATE LOOKUP TEMPLATES
 
+const String lookupFunctionTemplate = '''
+@(class) @(lookupName)(Locale locale) {
+  @(lookupBody)
+  assert(false, '@(class).delegate failed to load unsupported locale "\$locale"');
+  return null;
+}''';
+
+const String lookupFunctionDeferredLoadingTemplate = '''
+/// Lazy load the library for web, on other platforms we return the
+/// localizations synchronously.
+Future<@(class)> _loadLibraryForWeb(
+  Future<dynamic> Function() loadLibrary,
+  @(class) Function() localizationClosure,
+) {
+  if (kIsWeb) {
+    return loadLibrary().then((dynamic _) => localizationClosure());
+  } else {
+    return SynchronousFuture<@(class)>(localizationClosure());
+  }
+}
+
+Future<@(class)> @(lookupName)(Locale locale) {
+  @(lookupBody)
+  assert(false, '@(class).delegate failed to load unsupported locale "\$locale"');
+  return null;
+}''';
+
 const String lookupBodyTemplate = '''@(lookupAllCodesSpecified)
   @(lookupScriptCodeSpecified)
   @(lookupCountryCodeSpecified)
   @(lookupLanguageCodeSpecified)''';
 
-const String switchClauseTemplate = '''case '@(case)': return @(class)();''';
+const String switchClauseTemplate = '''case '@(case)': return @(localeClass)();''';
+
+const String switchClauseDeferredLoadingTemplate = '''case '@(case)': return _loadLibraryForWeb(@(library).loadLibrary, () => @(library).@(localeClass)());''';
 
 const String nestedSwitchTemplate = '''case '@(languageCode)': {
       switch (locale.@(code)) {
diff --git a/dev/tools/test/localization/gen_l10n_test.dart b/dev/tools/test/localization/gen_l10n_test.dart
index 6864cd1..12be1f1 100644
--- a/dev/tools/test/localization/gen_l10n_test.dart
+++ b/dev/tools/test/localization/gen_l10n_test.dart
@@ -380,6 +380,28 @@
     fail('Setting headerFile that does not exist should fail');
   });
 
+  test('setting useDefferedLoading to null should fail', () {
+    _standardFlutterDirectoryL10nSetup(fs);
+
+    LocalizationsGenerator generator;
+    try {
+      generator = LocalizationsGenerator(fs);
+      generator.initialize(
+        l10nDirectoryPath: defaultArbPathString,
+        templateArbFileName: defaultTemplateArbFileName,
+        outputFileString: defaultOutputFileString,
+        classNameString: defaultClassNameString,
+        headerString: '/// Sample header',
+        useDeferredLoading: null,
+      );
+    } on L10nException catch (e) {
+      expect(e.message, contains('useDeferredLoading argument cannot be null.'));
+      return;
+    }
+
+    fail('Setting useDefferedLoading to null should fail');
+  });
+
   group('loadResources', () {
     test('correctly initializes supportedLocales and supportedLanguageCodes properties', () {
       _standardFlutterDirectoryL10nSetup(fs);
@@ -792,6 +814,67 @@
       expect(englishLocalizationsFile, contains('class AppLocalizationsEn extends AppLocalizations'));
     });
 
+    test('language imports are sorted when preferredSupportedLocaleString is given', () {
+      fs.currentDirectory.childDirectory('lib').childDirectory('l10n')..createSync(recursive: true)
+        ..childFile(defaultTemplateArbFileName).writeAsStringSync(singleMessageArbFileString)
+        ..childFile('app_zh.arb').writeAsStringSync(singleZhMessageArbFileString)
+        ..childFile('app_es.arb').writeAsStringSync(singleEsMessageArbFileString);
+
+      const String preferredSupportedLocaleString = '["zh"]';
+      final LocalizationsGenerator generator = LocalizationsGenerator(fs);
+      try {
+        generator.initialize(
+          l10nDirectoryPath: defaultArbPathString,
+          templateArbFileName: defaultTemplateArbFileName,
+          outputFileString: defaultOutputFileString,
+          classNameString: defaultClassNameString,
+          preferredSupportedLocaleString: preferredSupportedLocaleString,
+        );
+        generator.loadResources();
+        generator.writeOutputFile();
+      } on Exception catch (e) {
+        fail('Generating output files should not fail: $e');
+      }
+
+      final String localizationsFile = fs.file(
+        path.join('lib', 'l10n', defaultOutputFileString),
+      ).readAsStringSync();
+      expect(localizationsFile, contains(
+'''
+import '${defaultOutputFileString}_en.dart';
+import '${defaultOutputFileString}_es.dart';
+import '${defaultOutputFileString}_zh.dart';
+'''));
+    });
+
+    test('imports are deferred when useDeferredImports are set', () {
+      fs.currentDirectory.childDirectory('lib').childDirectory('l10n')..createSync(recursive: true)
+        ..childFile(defaultTemplateArbFileName).writeAsStringSync(singleMessageArbFileString);
+
+      final LocalizationsGenerator generator = LocalizationsGenerator(fs);
+      try {
+        generator.initialize(
+          l10nDirectoryPath: defaultArbPathString,
+          templateArbFileName: defaultTemplateArbFileName,
+          outputFileString: defaultOutputFileString,
+          classNameString: defaultClassNameString,
+          useDeferredLoading: true,
+        );
+        generator.loadResources();
+        generator.writeOutputFile();
+      } on Exception catch (e) {
+        fail('Generating output files should not fail: $e');
+      }
+
+      final String localizationsFile = fs.file(
+        path.join('lib', 'l10n', defaultOutputFileString),
+      ).readAsStringSync();
+      expect(localizationsFile, contains(
+'''
+import '${defaultOutputFileString}_en.dart' deferred as ${defaultOutputFileString}_en;
+'''));
+    });
+
     group('DateTime tests', () {
       test('throws an exception when improperly formatted date is passed in', () {
         const String singleDateMessageArbFileString = '''
diff --git a/packages/flutter_tools/test/integration.shard/gen_l10n_test.dart b/packages/flutter_tools/test/integration.shard/gen_l10n_test.dart
index 949b0ec..017c260 100644
--- a/packages/flutter_tools/test/integration.shard/gen_l10n_test.dart
+++ b/packages/flutter_tools/test/integration.shard/gen_l10n_test.dart
@@ -48,7 +48,7 @@
     }
   }
 
-  test('generated l10n classes produce expected localized strings', () async {
+  void setUpAndRunGenL10n({List<String> args}) {
     // Get the intl packages before running gen_l10n.
     final String flutterBin = globals.platform.isWindows ? 'flutter.bat' : 'flutter';
     final String flutterPath = globals.fs.path.join(getFlutterRoot(), 'bin', flutterBin);
@@ -58,8 +58,10 @@
     final String genL10nPath = globals.fs.path.join(getFlutterRoot(), 'dev', 'tools', 'localization', 'bin', 'gen_l10n.dart');
     final String dartBin = globals.platform.isWindows ? 'dart.exe' : 'dart';
     final String dartPath = globals.fs.path.join(getFlutterRoot(), 'bin', 'cache', 'dart-sdk', 'bin', dartBin);
-    runCommand(<String>[dartPath, genL10nPath]);
+    runCommand(<String>[dartPath, genL10nPath, args?.join(' ')]);
+  }
 
+  Future<StringBuffer> runApp() async {
     // Run the app defined in GenL10nProject.main and wait for it to
     // send '#l10n END' to its stdout.
     final Completer<void> l10nEnd = Completer<void>();
@@ -75,6 +77,10 @@
     await _flutter.run();
     await l10nEnd.future;
     await subscription.cancel();
+    return stdout;
+  }
+
+  void expectOutput(StringBuffer stdout) {
     expect(stdout.toString(),
       '#l10n 0 (--- supportedLocales tests ---)\n'
       '#l10n 1 (supportedLocales[0]: languageCode: en, countryCode: null, scriptCode: null)\n'
@@ -133,5 +139,17 @@
       '#l10n 54 (Flutter is "amazing", times 2!)\n'
       '#l10n END\n'
     );
+  }
+
+  test('generated l10n classes produce expected localized strings', () async {
+    setUpAndRunGenL10n();
+    final StringBuffer stdout = await runApp();
+    expectOutput(stdout);
+  });
+
+  test('generated l10n classes produce expected localized strings with deferred loading', () async {
+    setUpAndRunGenL10n(args: <String>['--use-deferred-loading']);
+    final StringBuffer stdout = await runApp();
+    expectOutput(stdout);
   });
 }