Skip to content
This repository has been archived by the owner on May 17, 2019. It is now read-only.

Add translation key instrumentation for dynamic imports #769

Open
wants to merge 14 commits into
base: master
Choose a base branch
from
3 changes: 2 additions & 1 deletion build/compiler.js
Original file line number Diff line number Diff line change
Expand Up @@ -153,7 +153,8 @@ function Compiler(
clientChunkMetadata,
legacyClientChunkMetadata,
mergedClientChunkMetadata,
i18nManifest: new DeferredState(),
i18nManifest: new Map(),
i18nDeferredManifest: new DeferredState(),
legacyBuildEnabled,
};
const root = path.resolve(dir);
Expand Down
39 changes: 25 additions & 14 deletions build/get-webpack-config.js
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ const JS_EXT_PATTERN = /\.jsx?$/;
/*::
import type {
ClientChunkMetadataState,
TranslationsManifest,
TranslationsManifestState,
LegacyBuildEnabledState,
} from "./types.js";
Expand All @@ -86,7 +87,8 @@ export type WebpackConfigOpts = {|
clientChunkMetadata: ClientChunkMetadataState,
legacyClientChunkMetadata: ClientChunkMetadataState,
mergedClientChunkMetadata: ClientChunkMetadataState,
i18nManifest: TranslationsManifestState,
i18nManifest: TranslationsManifest,
i18nDeferredManifest: TranslationsManifestState,
legacyBuildEnabled: LegacyBuildEnabledState,
},
fusionConfig: FusionRC,
Expand Down Expand Up @@ -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,
Expand All @@ -465,17 +470,20 @@ 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
runtime === 'server'
? // Server
new InstrumentedImportDependencyTemplatePlugin(
state.mergedClientChunkMetadata
: /**
* Client
* Don't wait for the client manifest on the client.
* The underlying plugin handles client instrumentation on its own.
*/
void 0
),
)
: /**
* Client
* Don't wait for the client manifest on the client.
* The underlying plugin handles client instrumentation on its own.
micburks marked this conversation as resolved.
Show resolved Hide resolved
*/
new InstrumentedImportDependencyTemplatePlugin(
void 0,
Copy link
Member

@rtsao rtsao May 8, 2019

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Now that this constructor now takes more than one parameter, I think an object might make this more understandable. This was already a bit confusing (my bad) where the argument is expected to be undefined in the client case, but server-side should be provided. Adding another parameter now makes it even worse.

Expressing the parameter type as a disjoint union of two different objects would help I think. For example:

type Opts = 
  | ClientOpts
  | ServerOpts;

type ServerOpts = {
  compilation: "server",
  clientChunkMetadata: ClientChunkMetadataState
};

type ClientOpts = {
  compilation: "client",
  i18nManifest: TranslationsManifest
};

Also, this makes me think: is it odd that __I18N_KEYS is only added on the client-side? The other two properties are added in both the server and client bundles.

I think in the future, potentially we could align the server code to work more like the client code. I think this particular promise instrumentation might be useful even in a Suspense-powered i18n system.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah I think that'll make the plugin easier to understand. I'll update to use those types.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was actually thinking the opposite for the promise instrumentation. I don't totally understand why __CHUNK_IDS are added to the server build since they're only used when dynamically loading chunks with new translations (at least I think that's the only time they're used).

Copy link
Member

@rtsao rtsao May 9, 2019

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

__CHUNK_IDS is necessary server-side because that's how the server knows which async chunks are used in the SSR and are thus considered "critical" and should therefore be preloaded. During SSR, when it encounters a split component, it adds the chunk ids (on the promise object) to the list of critical chunks that should be preloaded.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Okay I recall that now. Since I didn't change how the translation manifest is added to the server bundle, the server still looks up the necessary translations based on the __CHUNK_IDS of the promise. We could potentially change that to use use __I18N_KEYS but yes probably not really necessary at this point. It might require a different approach than what I have because this approach doesn't find any translation keys since they're all bundled together in the server build.

state.i18nManifest
),
dev && hmr && watch && new webpack.HotModuleReplacementPlugin(),
!dev && runtime === 'client' && new webpack.HashedModuleIdsPlugin(),
runtime === 'client' &&
Expand Down Expand Up @@ -527,7 +535,10 @@ function getWebpackConfig(opts /*: WebpackConfigOpts */) {
options.optimization.splitChunks
),
// need to re-apply template
new InstrumentedImportDependencyTemplatePlugin(void 0),
new InstrumentedImportDependencyTemplatePlugin(
void 0,
state.i18nManifest
),
new ClientChunkMetadataStateHydratorPlugin(
state.legacyClientChunkMetadata
),
Expand Down
19 changes: 11 additions & 8 deletions build/plugins/i18n-discovery-plugin.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
}
}
Expand Down
65 changes: 59 additions & 6 deletions build/plugins/instrumented-import-dependency-template-plugin.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,11 @@
/* eslint-env node */

/*::
import type {ClientChunkMetadataState, ClientChunkMetadata} from "../types.js";
import type {
ClientChunkMetadataState,
ClientChunkMetadata,
TranslationsManifest,
} from "../types.js";
*/

const ImportDependency = require('webpack/lib/dependencies/ImportDependency');
Expand All @@ -34,9 +38,14 @@ const ImportDependencyTemplate = require('webpack/lib/dependencies/ImportDepende

class InstrumentedImportDependencyTemplate extends ImportDependencyTemplate {
/*:: clientChunkIndex: ?$PropertyType<ClientChunkMetadata, "fileManifest">; */
/*:: manifest: ?TranslationsManifest; */

constructor(clientChunkMetadata /*: ?ClientChunkMetadata */) {
constructor(
clientChunkMetadata /*: ?ClientChunkMetadata */,
translationsManifest /*: ?TranslationsManifest*/
) {
super();
this.translationsManifest = translationsManifest;
if (clientChunkMetadata) {
this.clientChunkIndex = clientChunkMetadata.fileManifest;
}
Expand Down Expand Up @@ -69,13 +78,45 @@ class InstrumentedImportDependencyTemplate extends ImportDependencyTemplate {
chunkIds = getChunkGroupIds(depBlock.chunkGroup);
}

let translationKeys = [];
if (this.translationsManifest) {
const modulesSet = new Set();

// Module dependencies
if (dep.module && dep.module.dependencies) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Interesting, so these modules in module.dependencies are not already in chunk._modules for each chunk in the chunk group?

Regardless, I think it might be worth encapsulating logic this into a separate function:
dep => Set<string>.

Copy link
Contributor Author

@micburks micburks May 8, 2019

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I originally didn't have to use module.dependencies, but I learned through testing that chunk._modules wasn't working in a production build for the main bundle. I don't fully understand it, but module.dependencies works in production and chunk._modules works in development

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think I can elaborate on this a little more. In dev, chunk._modules only contains NormalModules whereas in the production build most modules (especially components that import other components) are replaced by a few ConcatenatedModules. In order to get the filenames from this I have to do a little digging into dep.module.dependencies. This makes the set of filenames larger than it probably has to be, but it also seems to be accurate (i.e. contains files we need and not ones we don't need).

dep.module.dependencies.map(d => {
if (d.originModule) {
modulesSet.add(d.originModule.userRequest);
}
});
}

// Chunks
depBlock.chunkGroup.chunks.forEach(chunk => {
const modules = Array.from(chunk._modules.keys());
modules.forEach(m => modulesSet.add(m.resource));
});

const modules = Array.from(modulesSet.keys());
translationKeys = modules.reduce((acc, module) => {
rtsao marked this conversation as resolved.
Show resolved Hide resolved
if (this.translationsManifest.has(module)) {
const keys = Array.from(this.translationsManifest.get(module).keys());
return acc.concat(keys);
} else {
return acc;
}
}, []);
}

// 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 that are used in this bundle
micburks marked this conversation as resolved.
Show resolved Hide resolved
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;

Expand All @@ -91,9 +132,14 @@ class InstrumentedImportDependencyTemplate extends ImportDependencyTemplate {

class InstrumentedImportDependencyTemplatePlugin {
/*:: clientChunkIndexState: ?ClientChunkMetadataState; */
/*:: translationsManifest: ?TranslationsManifest; */

constructor(clientChunkIndexState /*: ?ClientChunkMetadataState*/) {
constructor(
clientChunkIndexState /*: ?ClientChunkMetadataState*/,
translationsManifest /*: ?TranslationsManifest*/
) {
this.clientChunkIndexState = clientChunkIndexState;
this.translationsManifest = translationsManifest;
}

apply(compiler /*: any */) {
Expand All @@ -113,13 +159,20 @@ class InstrumentedImportDependencyTemplatePlugin {
);
done();
});
} else {
} else if (this.translationsManifest) {
// client
compilation.dependencyTemplates.set(
ImportDependency,
new InstrumentedImportDependencyTemplate()
new InstrumentedImportDependencyTemplate(
void 0,
this.translationsManifest
)
);
done();
} else {
throw new Error(
'InstrumentationImportDependencyPlugin called without clientChunkIndexState or translationsManifest'
);
}
});
}
Expand Down
2 changes: 1 addition & 1 deletion test/e2e/assets/test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
24 changes: 24 additions & 0 deletions test/e2e/dynamic-import-translations/fixture/src/main.js
Original file line number Diff line number Diff line change
@@ -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 (
<div>
<div data-testid="split">
{JSON.stringify(split.__I18N_KEYS)}
</div>
<div data-testid="split-with-child">
{JSON.stringify(splitWithChild.__I18N_KEYS)}
</div>
</div>
);
}

export default async function start() {
const app = new App(<Root />);
return app;
}
10 changes: 10 additions & 0 deletions test/e2e/dynamic-import-translations/fixture/src/split-child.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
// @noflow

import React from 'react';
import {Translate} from 'fusion-plugin-i18n-react';

export default function SplitRouteChild() {
return (
<Translate id="__SPLIT_CHILD__"/>
);
}
Original file line number Diff line number Diff line change
@@ -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 <SplitRouteChild />;
}

export default withTranslations(['__SPLIT_WITH_CHILD__'])(SplitRouteWithChild);
10 changes: 10 additions & 0 deletions test/e2e/dynamic-import-translations/fixture/src/split.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
// @noflow

import React, {Component} from 'react';
import {withTranslations} from 'fusion-plugin-i18n-react';

function SplitRoute () {
return <div />
}

export default withTranslations(['__SPLIT__'])(SplitRoute);
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"__SPLIT__": "",
"__SPLIT_WITH_CHILD__": "",
"__SPLIT_CHILD__": ""
}
40 changes: 40 additions & 0 deletions test/e2e/dynamic-import-translations/test.js
Original file line number Diff line number Diff line change
@@ -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('<div data-testid="split">["__SPLIT__"]</div>'),
'translation keys are added to promise instrumentation'
);
t.ok(
content.includes(
'<div data-testid="split-with-child">' +
'["__SPLIT_CHILD__","__SPLIT_WITH_CHILD__"]' +
'</div>'
),
'translation keys contain keys from child imports'
);

browser.close();
proc.kill();
}, 100000);
2 changes: 1 addition & 1 deletion test/e2e/empty/test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
Expand Down
4 changes: 2 additions & 2 deletions test/e2e/noop-test/test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'
);

Expand Down
2 changes: 1 addition & 1 deletion test/e2e/transpile-node-modules/test.js
Original file line number Diff line number Diff line change
Expand Up @@ -55,5 +55,5 @@ test('transpiles node_modules', async () => {
],
});

t.ok(clientVendor.includes(`'fixturepkg_string'`));
t.ok(clientVendor.includes(`"fixturepkg_string"`));
}, 100000);