diff --git a/build/compiler.js b/build/compiler.js index 30b404ce..b1bf4fbd 100644 --- a/build/compiler.js +++ b/build/compiler.js @@ -153,7 +153,8 @@ function Compiler( clientChunkMetadata, legacyClientChunkMetadata, mergedClientChunkMetadata, - i18nManifest: new DeferredState(), + i18nManifest: new Map(), + i18nDeferredManifest: new DeferredState(), legacyBuildEnabled, }; const root = path.resolve(dir); diff --git a/build/get-webpack-config.js b/build/get-webpack-config.js index cb781028..d4dcf489 100644 --- a/build/get-webpack-config.js +++ b/build/get-webpack-config.js @@ -65,6 +65,7 @@ const JS_EXT_PATTERN = /\.jsx?$/; /*:: import type { ClientChunkMetadataState, + TranslationsManifest, TranslationsManifestState, LegacyBuildEnabledState, } from "./types.js"; @@ -86,7 +87,8 @@ export type WebpackConfigOpts = {| clientChunkMetadata: ClientChunkMetadataState, legacyClientChunkMetadata: ClientChunkMetadataState, mergedClientChunkMetadata: ClientChunkMetadataState, - i18nManifest: TranslationsManifestState, + i18nManifest: TranslationsManifest, + i18nDeferredManifest: TranslationsManifestState, legacyBuildEnabled: LegacyBuildEnabledState, }, fusionConfig: FusionRC, @@ -451,10 +453,13 @@ function getWebpackConfig(opts /*: WebpackConfigOpts */) { state.mergedClientChunkMetadata ), runtime === 'client' - ? new I18nDiscoveryPlugin(state.i18nManifest) + ? new I18nDiscoveryPlugin( + state.i18nDeferredManifest, + state.i18nManifest + ) : new LoaderContextProviderPlugin( translationsManifestContextKey, - state.i18nManifest + state.i18nDeferredManifest ), !dev && zopfli && zopfliWebpackPlugin, !dev && brotliWebpackPlugin, @@ -465,17 +470,21 @@ function getWebpackConfig(opts /*: WebpackConfigOpts */) { // in dev because the CLI will not exit with an error code if the option is enabled, // so failed builds would look like successful ones. watch && new webpack.NoEmitOnErrorsPlugin(), - new InstrumentedImportDependencyTemplatePlugin( - runtime !== 'client' - ? // Server - state.mergedClientChunkMetadata - : /** - * Client - * Don't wait for the client manifest on the client. - * The underlying plugin handles client instrumentation on its own. - */ - void 0 - ), + runtime === 'server' + ? // Server + new InstrumentedImportDependencyTemplatePlugin({ + compilation: 'server', + clientChunkMetadata: state.mergedClientChunkMetadata, + }) + : /** + * Client + * Don't wait for the client manifest on the client. + * The underlying plugin is able determine client chunk metadata on its own. + */ + new InstrumentedImportDependencyTemplatePlugin({ + compilation: 'client', + i18nManifest: state.i18nManifest, + }), dev && hmr && watch && new webpack.HotModuleReplacementPlugin(), !dev && runtime === 'client' && new webpack.HashedModuleIdsPlugin(), runtime === 'client' && @@ -527,7 +536,10 @@ function getWebpackConfig(opts /*: WebpackConfigOpts */) { options.optimization.splitChunks ), // need to re-apply template - new InstrumentedImportDependencyTemplatePlugin(void 0), + new InstrumentedImportDependencyTemplatePlugin({ + compilation: 'client', + i18nManifest: state.i18nManifest, + }), new ClientChunkMetadataStateHydratorPlugin( state.legacyClientChunkMetadata ), diff --git a/build/plugins/i18n-discovery-plugin.js b/build/plugins/i18n-discovery-plugin.js index ddcc22e1..9c081197 100644 --- a/build/plugins/i18n-discovery-plugin.js +++ b/build/plugins/i18n-discovery-plugin.js @@ -16,27 +16,30 @@ import type {TranslationsManifestState, TranslationsManifest} from "../types.js" class I18nDiscoveryPlugin { /*:: - manifest: TranslationsManifestState; - discoveryState: TranslationsManifest; + manifestState: TranslationsManifestState; + manifest: TranslationsManifest; */ - constructor(manifest /*: TranslationsManifestState*/) { + constructor( + manifestState /*: TranslationsManifestState*/, + manifest /*: TranslationsManifest*/ + ) { + this.manifestState = manifestState; this.manifest = manifest; - this.discoveryState = new Map(); } apply(compiler /*: any */) { const name = this.constructor.name; // "thisCompilation" is not run in child compilations compiler.hooks.thisCompilation.tap(name, compilation => { compilation.hooks.normalModuleLoader.tap(name, (context, module) => { - context[translationsDiscoveryKey] = this.discoveryState; + context[translationsDiscoveryKey] = this.manifest; }); }); compiler.hooks.done.tap(name, () => { - this.manifest.resolve(this.discoveryState); + this.manifestState.resolve(this.manifest); }); compiler.hooks.invalid.tap(name, filename => { - this.manifest.reset(); - this.discoveryState.delete(filename); + this.manifestState.reset(); + this.manifest.delete(filename); }); } } diff --git a/build/plugins/instrumented-import-dependency-template-plugin.js b/build/plugins/instrumented-import-dependency-template-plugin.js index 7f09e4c1..04bd0cf1 100644 --- a/build/plugins/instrumented-import-dependency-template-plugin.js +++ b/build/plugins/instrumented-import-dependency-template-plugin.js @@ -9,7 +9,25 @@ /* eslint-env node */ /*:: -import type {ClientChunkMetadataState, ClientChunkMetadata} from "../types.js"; +import type { + ClientChunkMetadataState, + ClientChunkMetadata, + TranslationsManifest, +} from "../types.js"; + +type InstrumentationPluginOpts = + | ClientPluginOpts + | ServerPluginOpts; + +type ServerPluginOpts = { + compilation: "server", + clientChunkMetadata: ClientChunkMetadataState +}; + +type ClientPluginOpts = { + compilation: "client", + i18nManifest: TranslationsManifest +}; */ const ImportDependency = require('webpack/lib/dependencies/ImportDependency'); @@ -34,9 +52,16 @@ const ImportDependencyTemplate = require('webpack/lib/dependencies/ImportDepende class InstrumentedImportDependencyTemplate extends ImportDependencyTemplate { /*:: clientChunkIndex: ?$PropertyType; */ - - constructor(clientChunkMetadata /*: ?ClientChunkMetadata */) { + /*:: manifest: ?TranslationsManifest; */ + + constructor( + { + clientChunkMetadata, + translationsManifest, + } /*: {clientChunkMetadata?: ClientChunkMetadata, translationsManifest?: TranslationsManifest}*/ + ) { super(); + this.translationsManifest = translationsManifest; if (clientChunkMetadata) { this.clientChunkIndex = clientChunkMetadata.fileManifest; } @@ -69,13 +94,26 @@ class InstrumentedImportDependencyTemplate extends ImportDependencyTemplate { chunkIds = getChunkGroupIds(depBlock.chunkGroup); } + let translationKeys = []; + if (this.translationsManifest) { + const modules = getChunkGroupModules(dep); + for (const module of modules) { + if (this.translationsManifest.has(module)) { + const keys = this.translationsManifest.get(module).keys(); + translationKeys.push(...keys); + } + } + } + // Add the following properties to the promise returned by import() // - `__CHUNK_IDS`: the webpack chunk ids for the dynamic import // - `__MODULE_ID`: the webpack module id of the dynamically imported module. Equivalent to require.resolveWeak(path) + // - `__I18N_KEYS`: the translation keys used in the client chunk group for this import() const customContent = chunkIds ? `Object.defineProperties(${content}, { "__CHUNK_IDS": {value:${JSON.stringify(chunkIds)}}, - "__MODULE_ID": {value:${JSON.stringify(dep.module.id)}} + "__MODULE_ID": {value:${JSON.stringify(dep.module.id)}}, + "__I18N_KEYS": {value:${JSON.stringify(translationKeys)}} })` : content; @@ -90,10 +128,10 @@ class InstrumentedImportDependencyTemplate extends ImportDependencyTemplate { */ class InstrumentedImportDependencyTemplatePlugin { - /*:: clientChunkIndexState: ?ClientChunkMetadataState; */ + /*:: opts: InstrumentationPluginOpts;*/ - constructor(clientChunkIndexState /*: ?ClientChunkMetadataState*/) { - this.clientChunkIndexState = clientChunkIndexState; + constructor(opts /*: InstrumentationPluginOpts*/) { + this.opts = opts; } apply(compiler /*: any */) { @@ -104,22 +142,30 @@ class InstrumentedImportDependencyTemplatePlugin { * `make` is the subsequent lifeycle method, so we can override this value here. */ compiler.hooks.make.tapAsync(name, (compilation, done) => { - if (this.clientChunkIndexState) { + if (this.opts.compilation === 'server') { // server - this.clientChunkIndexState.result.then(chunkIndex => { + this.opts.clientChunkMetadata.result.then(chunkIndex => { compilation.dependencyTemplates.set( ImportDependency, - new InstrumentedImportDependencyTemplate(chunkIndex) + new InstrumentedImportDependencyTemplate({ + clientChunkMetadata: chunkIndex, + }) ); done(); }); - } else { + } else if (this.opts.compilation === 'client') { // client compilation.dependencyTemplates.set( ImportDependency, - new InstrumentedImportDependencyTemplate() + new InstrumentedImportDependencyTemplate({ + translationsManifest: this.opts.i18nManifest, + }) ); done(); + } else { + throw new Error( + 'InstrumentationImportDependencyPlugin called without clientChunkIndexState or translationsManifest' + ); } }); } @@ -139,3 +185,22 @@ function getChunkGroupIds(chunkGroup) { return [chunkGroup.id]; } } + +function getChunkGroupModules(dep) { + const modulesSet = new Set(); + // For ConcatenatedModules in production build + if (dep.module && dep.module.dependencies) { + dep.module.dependencies.forEach(dependency => { + if (dependency.originModule) { + modulesSet.add(dependency.originModule.userRequest); + } + }); + } + // For NormalModules + dep.block.chunkGroup.chunks.forEach(chunk => { + for (const module of chunk._modules) { + modulesSet.add(module.resource); + } + }); + return modulesSet; +} diff --git a/test/e2e/assets/test.js b/test/e2e/assets/test.js index 46c2faf6..87f5b6e7 100644 --- a/test/e2e/assets/test.js +++ b/test/e2e/assets/test.js @@ -68,7 +68,7 @@ test('`fusion dev` works with assets', async () => { const clientMain = await request(`${url}/_static/client-main.js`); t.ok(clientMain, 'serves client-main from memory correctly'); t.ok( - clientMain.includes('"src", "src/main.js")'), + clientMain.includes('"src","src/main.js")'), 'transpiles __dirname and __filename' ); t.ok( diff --git a/test/e2e/dynamic-import-translations/fixture/src/main.js b/test/e2e/dynamic-import-translations/fixture/src/main.js new file mode 100644 index 00000000..f5b810f6 --- /dev/null +++ b/test/e2e/dynamic-import-translations/fixture/src/main.js @@ -0,0 +1,24 @@ +// @noflow + +import React from 'react'; +import App from 'fusion-react'; + +function Root () { + const split = import('./split.js'); + const splitWithChild = import('./split-with-child.js'); + return ( +
+
+ {JSON.stringify(split.__I18N_KEYS)} +
+
+ {JSON.stringify(splitWithChild.__I18N_KEYS)} +
+
+ ); +} + +export default async function start() { + const app = new App(); + return app; +} diff --git a/test/e2e/dynamic-import-translations/fixture/src/split-child.js b/test/e2e/dynamic-import-translations/fixture/src/split-child.js new file mode 100644 index 00000000..313ab5f3 --- /dev/null +++ b/test/e2e/dynamic-import-translations/fixture/src/split-child.js @@ -0,0 +1,10 @@ +// @noflow + +import React from 'react'; +import {Translate} from 'fusion-plugin-i18n-react'; + +export default function SplitRouteChild() { + return ( + + ); +} diff --git a/test/e2e/dynamic-import-translations/fixture/src/split-with-child.js b/test/e2e/dynamic-import-translations/fixture/src/split-with-child.js new file mode 100644 index 00000000..7740f286 --- /dev/null +++ b/test/e2e/dynamic-import-translations/fixture/src/split-with-child.js @@ -0,0 +1,12 @@ +// @noflow + +import React, {Component} from 'react'; +import {withTranslations} from 'fusion-plugin-i18n-react'; + +import SplitRouteChild from './split-child.js'; + +function SplitRouteWithChild () { + return ; +} + +export default withTranslations(['__SPLIT_WITH_CHILD__'])(SplitRouteWithChild); diff --git a/test/e2e/dynamic-import-translations/fixture/src/split.js b/test/e2e/dynamic-import-translations/fixture/src/split.js new file mode 100644 index 00000000..ebcc7d93 --- /dev/null +++ b/test/e2e/dynamic-import-translations/fixture/src/split.js @@ -0,0 +1,10 @@ +// @noflow + +import React, {Component} from 'react'; +import {withTranslations} from 'fusion-plugin-i18n-react'; + +function SplitRoute () { + return
+} + +export default withTranslations(['__SPLIT__'])(SplitRoute); diff --git a/test/e2e/dynamic-import-translations/fixture/translations/en-US.json b/test/e2e/dynamic-import-translations/fixture/translations/en-US.json new file mode 100644 index 00000000..5b8ac225 --- /dev/null +++ b/test/e2e/dynamic-import-translations/fixture/translations/en-US.json @@ -0,0 +1,5 @@ +{ + "__SPLIT__": "", + "__SPLIT_WITH_CHILD__": "", + "__SPLIT_CHILD__": "" +} diff --git a/test/e2e/dynamic-import-translations/test.js b/test/e2e/dynamic-import-translations/test.js new file mode 100644 index 00000000..681abcff --- /dev/null +++ b/test/e2e/dynamic-import-translations/test.js @@ -0,0 +1,40 @@ +// @flow +/* eslint-env node */ + +const t = require('assert'); +const path = require('path'); +const puppeteer = require('puppeteer'); + +const {cmd, start} = require('../utils.js'); + +const dir = path.resolve(__dirname, './fixture'); + +test('`fusion build` app with split translations integration', async () => { + var env = Object.create(process.env); + env.NODE_ENV = 'production'; + + await cmd(`build --dir=${dir} --production`, {env}); + + const {proc, port} = await start(`--dir=${dir}`, {env, cwd: dir}); + const browser = await puppeteer.launch({ + args: ['--no-sandbox', '--disable-setuid-sandbox'], + }); + const page = await browser.newPage(); + await page.goto(`http://localhost:${port}/`, {waitUntil: 'load'}); + const content = await page.content(); + t.ok( + content.includes('
["__SPLIT__"]
'), + 'translation keys are added to promise instrumentation' + ); + t.ok( + content.includes( + '
' + + '["__SPLIT_CHILD__","__SPLIT_WITH_CHILD__"]' + + '
' + ), + 'translation keys contain keys from child imports' + ); + + browser.close(); + proc.kill(); +}, 100000); diff --git a/test/e2e/empty/test.js b/test/e2e/empty/test.js index 957cec72..b3e0a6ff 100644 --- a/test/e2e/empty/test.js +++ b/test/e2e/empty/test.js @@ -26,7 +26,7 @@ test('generates error if missing default export', async () => { // $FlowFixMe t.fail('did not error'); } catch (e) { - t.ok(e.stderr.includes('initialize is not a function')); + t.ok(e.stderr.includes(' is not a function')); } finally { proc.kill(); } diff --git a/test/e2e/noop-test/test.js b/test/e2e/noop-test/test.js index af7a773c..bdb42aed 100644 --- a/test/e2e/noop-test/test.js +++ b/test/e2e/noop-test/test.js @@ -31,11 +31,11 @@ test('development env globals', async () => { const clientContent = await readFile(clientEntryPath, 'utf8'); t.ok( - clientContent.includes(`'main __BROWSER__ is', true`), + clientContent.includes(`"main __BROWSER__ is",!0`), `__BROWSER__ is transpiled to be true in development` ); t.ok( - clientContent.includes(`'main __NODE__ is', false`), + clientContent.includes(`"main __NODE__ is",!1`), '__NODE__ is transpiled to be false' ); diff --git a/test/e2e/transpile-node-modules/test.js b/test/e2e/transpile-node-modules/test.js index 525094c5..ba1cb1ac 100644 --- a/test/e2e/transpile-node-modules/test.js +++ b/test/e2e/transpile-node-modules/test.js @@ -55,5 +55,5 @@ test('transpiles node_modules', async () => { ], }); - t.ok(clientVendor.includes(`'fixturepkg_string'`)); + t.ok(clientVendor.includes(`"fixturepkg_string"`)); }, 100000);