Modify I18nProvider so that it does not generate new React components (#43556)

This fixes some edge-cases that caused infinite loops: React thinks the tree has changed because of a new root component, effects fire off which change the state and cause a re-render, React thinks the tree has changed because of a new root component...
This commit is contained in:
Chris Davies 2019-08-27 10:32:20 -04:00 committed by GitHub
parent 0f2324e445
commit f188b292c8
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 381 additions and 342 deletions

View file

@ -103,4 +103,8 @@ Object {
}
`;
exports[`I18nProvider renders children 1`] = `<ChildrenMock />`;
exports[`I18nProvider renders children 1`] = `
<PseudoLocaleWrapper>
<ChildrenMock />
</PseudoLocaleWrapper>
`;

View file

@ -19,7 +19,6 @@
import { mount, shallow } from 'enzyme';
import * as React from 'react';
import { intlShape } from 'react-intl';
import { injectI18n } from './inject';
import { I18nProvider } from './provider';
@ -46,7 +45,7 @@ describe('I18nProvider', () => {
</I18nProvider>,
{
childContextTypes: {
intl: intlShape,
intl: { formatMessage: jest.fn() },
},
}
);

View file

@ -22,47 +22,7 @@ import * as React from 'react';
import { IntlProvider } from 'react-intl';
import * as i18n from '../core';
import { isPseudoLocale, translateUsingPseudoLocale } from '../core/pseudo_locale';
import { injectI18n } from './inject';
/**
* To translate label that includes nested `FormattedMessage` instances React Intl
* replaces them with special placeholders (@__uid__@ELEMENT-uid-counter@__uid__@)
* and maps them back with nested translations after `formatMessage` processes
* original string, so we shouldn't modify these special placeholders with pseudo
* translations otherwise React Intl won't be able to properly replace placeholders.
* It's implementation detail of the React Intl, but since pseudo localization is dev
* only feature we should be fine here.
* @param message
*/
function translateFormattedMessageUsingPseudoLocale(message: string) {
const formattedMessageDelimiter = message.match(/@__.{10}__@/);
if (formattedMessageDelimiter !== null) {
return message
.split(formattedMessageDelimiter[0])
.map(part => (part.startsWith('ELEMENT-') ? part : translateUsingPseudoLocale(part)))
.join(formattedMessageDelimiter[0]);
}
return translateUsingPseudoLocale(message);
}
/**
* If pseudo locale is detected, default intl.formatMessage should be decorated
* with the pseudo localization function.
* @param child I18nProvider child component.
*/
function wrapIntlFormatMessage(child: React.ReactElement) {
return React.createElement(
injectI18n(({ intl }) => {
const formatMessage = intl.formatMessage;
intl.formatMessage = (...args) =>
translateFormattedMessageUsingPseudoLocale(formatMessage(...args));
return React.Children.only(child);
})
);
}
import { PseudoLocaleWrapper } from './pseudo_locale_wrapper';
/**
* The library uses the provider pattern to scope an i18n context to a tree
@ -81,9 +41,7 @@ export class I18nProvider extends React.PureComponent {
formats={i18n.getFormats()}
textComponent={React.Fragment}
>
{isPseudoLocale(i18n.getLocale()) && React.isValidElement(this.props.children)
? wrapIntlFormatMessage(this.props.children)
: this.props.children}
<PseudoLocaleWrapper>{this.props.children}</PseudoLocaleWrapper>
</IntlProvider>
);
}

View file

@ -0,0 +1,75 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import * as PropTypes from 'prop-types';
import * as React from 'react';
import * as i18n from '../core';
import { isPseudoLocale, translateUsingPseudoLocale } from '../core/pseudo_locale';
/**
* To translate label that includes nested `FormattedMessage` instances React Intl
* replaces them with special placeholders (@__uid__@ELEMENT-uid-counter@__uid__@)
* and maps them back with nested translations after `formatMessage` processes
* original string, so we shouldn't modify these special placeholders with pseudo
* translations otherwise React Intl won't be able to properly replace placeholders.
* It's implementation detail of the React Intl, but since pseudo localization is dev
* only feature we should be fine here.
* @param message
*/
function translateFormattedMessageUsingPseudoLocale(message: string) {
const formattedMessageDelimiter = message.match(/@__.{10}__@/);
if (formattedMessageDelimiter !== null) {
return message
.split(formattedMessageDelimiter[0])
.map(part => (part.startsWith('ELEMENT-') ? part : translateUsingPseudoLocale(part)))
.join(formattedMessageDelimiter[0]);
}
return translateUsingPseudoLocale(message);
}
/**
* If the locale is our pseudo locale (e.g. en-xa), we override the
* intl.formatMessage function to display scrambled characters. We are
* overriding the context rather than using injectI18n, because the
* latter creates a new React component, which causes React diffs to
* be inefficient in some cases, and can cause React hooks to lose
* their state.
*/
export class PseudoLocaleWrapper extends React.PureComponent {
public static propTypes = { children: PropTypes.element.isRequired };
public static contextTypes = {
intl: PropTypes.object.isRequired,
};
constructor(props: { children: React.ReactNode }, context: any) {
super(props, context);
if (isPseudoLocale(i18n.getLocale())) {
const formatMessage = context.intl.formatMessage;
context.intl.formatMessage = (...args: any[]) =>
translateFormattedMessageUsingPseudoLocale(formatMessage(...args));
}
}
public render() {
return this.props.children;
}
}

View file

@ -28,15 +28,8 @@ const getStateChildComponent = (
): // eslint-disable-next-line @typescript-eslint/no-explicit-any
React.Component<{}, {}, any> =>
wrapper
.childAt(0)
.childAt(0)
.childAt(0)
.childAt(0)
.childAt(0)
.childAt(0)
.childAt(0)
.childAt(0)
.childAt(0)
.find('[data-test-subj="stateful-timeline"]')
.last()
.instance();
describe('StatefulOpenTimeline', () => {
@ -49,6 +42,7 @@ describe('StatefulOpenTimeline', () => {
<TestProviderWithoutDragAndDrop>
<MockedProvider mocks={mockOpenTimelineQueryResults} addTypename={false}>
<StatefulOpenTimeline
data-test-subj="stateful-timeline"
apolloClient={apolloClient}
isModal={false}
defaultPageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE}
@ -81,6 +75,7 @@ describe('StatefulOpenTimeline', () => {
<TestProviderWithoutDragAndDrop>
<MockedProvider mocks={mockOpenTimelineQueryResults} addTypename={false}>
<StatefulOpenTimeline
data-test-subj="stateful-timeline"
apolloClient={apolloClient}
isModal={false}
defaultPageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE}
@ -306,6 +301,7 @@ describe('StatefulOpenTimeline', () => {
<TestProviderWithoutDragAndDrop>
<MockedProvider mocks={mockOpenTimelineQueryResults} addTypename={false}>
<StatefulOpenTimeline
data-test-subj="stateful-timeline"
apolloClient={apolloClient}
isModal={false}
defaultPageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE}
@ -336,6 +332,7 @@ describe('StatefulOpenTimeline', () => {
<TestProviderWithoutDragAndDrop>
<MockedProvider mocks={mockOpenTimelineQueryResults} addTypename={false}>
<StatefulOpenTimeline
data-test-subj="stateful-timeline"
apolloClient={apolloClient}
isModal={false}
defaultPageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE}
@ -377,6 +374,7 @@ describe('StatefulOpenTimeline', () => {
<TestProviderWithoutDragAndDrop>
<MockedProvider mocks={mockOpenTimelineQueryResults} addTypename={false}>
<StatefulOpenTimeline
data-test-subj="stateful-timeline"
apolloClient={apolloClient}
isModal={false}
defaultPageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE}
@ -416,6 +414,7 @@ describe('StatefulOpenTimeline', () => {
<TestProviderWithoutDragAndDrop>
<MockedProvider mocks={mockOpenTimelineQueryResults} addTypename={false}>
<StatefulOpenTimeline
data-test-subj="stateful-timeline"
apolloClient={apolloClient}
isModal={false}
defaultPageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE}
@ -468,6 +467,7 @@ describe('StatefulOpenTimeline', () => {
<TestProviderWithoutDragAndDrop>
<MockedProvider mocks={mockOpenTimelineQueryResults} addTypename={false}>
<StatefulOpenTimeline
data-test-subj="stateful-timeline"
apolloClient={apolloClient}
isModal={false}
defaultPageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE}
@ -505,6 +505,7 @@ describe('StatefulOpenTimeline', () => {
<TestProviderWithoutDragAndDrop>
<MockedProvider mocks={mockOpenTimelineQueryResults} addTypename={false}>
<StatefulOpenTimeline
data-test-subj="stateful-timeline"
apolloClient={apolloClient}
isModal={false}
defaultPageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE}
@ -532,6 +533,7 @@ describe('StatefulOpenTimeline', () => {
<TestProviderWithoutDragAndDrop>
<MockedProvider mocks={mockOpenTimelineQueryResults} addTypename={false}>
<StatefulOpenTimeline
data-test-subj="stateful-timeline"
apolloClient={apolloClient}
isModal={false}
defaultPageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE}
@ -569,6 +571,7 @@ describe('StatefulOpenTimeline', () => {
<MockedProvider mocks={mockOpenTimelineQueryResults} addTypename={false}>
<TestProviderWithoutDragAndDrop>
<StatefulOpenTimeline
data-test-subj="stateful-timeline"
apolloClient={apolloClient}
isModal={false}
defaultPageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE}
@ -600,6 +603,7 @@ describe('StatefulOpenTimeline', () => {
<TestProviderWithoutDragAndDrop>
<MockedProvider mocks={mockOpenTimelineQueryResults} addTypename={false}>
<StatefulOpenTimeline
data-test-subj="stateful-timeline"
apolloClient={apolloClient}
isModal={false}
defaultPageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE}
@ -637,6 +641,7 @@ describe('StatefulOpenTimeline', () => {
<TestProviderWithoutDragAndDrop>
<MockedProvider mocks={mockOpenTimelineQueryResults} addTypename={false}>
<StatefulOpenTimeline
data-test-subj="stateful-timeline"
apolloClient={apolloClient}
isModal={false}
defaultPageSize={DEFAULT_SEARCH_RESULTS_PER_PAGE}

View file

@ -24,17 +24,7 @@ const getStateChildComponent = (
// eslint-disable-next-line @typescript-eslint/no-explicit-any
wrapper: ReactWrapper<any, Readonly<{}>, React.Component<{}, {}, any>>
): // eslint-disable-next-line @typescript-eslint/no-explicit-any
React.Component<{}, {}, any> =>
wrapper
.childAt(0)
.childAt(0)
.childAt(0)
.childAt(0)
.childAt(0)
.childAt(0)
.childAt(0)
.childAt(0)
.instance();
React.Component<{}, {}, any> => wrapper.find('[data-test-subj="state-child-component"]').instance();
describe('OpenTimelineModalButton', () => {
const theme = () => ({ eui: euiDarkVars, darkMode: true });
@ -66,7 +56,10 @@ describe('OpenTimelineModalButton', () => {
<ThemeProvider theme={theme}>
<TestProviderWithoutDragAndDrop>
<MockedProvider mocks={mockOpenTimelineQueryResults} addTypename={false}>
<OpenTimelineModalButton onToggle={jest.fn()} />
<OpenTimelineModalButton
data-test-subj="state-child-component"
onToggle={jest.fn()}
/>
</MockedProvider>
</TestProviderWithoutDragAndDrop>
</ThemeProvider>
@ -158,7 +151,10 @@ describe('OpenTimelineModalButton', () => {
<ThemeProvider theme={theme}>
<TestProviderWithoutDragAndDrop>
<MockedProvider mocks={mockOpenTimelineQueryResults} addTypename={false}>
<OpenTimelineModalButton onToggle={jest.fn()} />
<OpenTimelineModalButton
data-test-subj="state-child-component"
onToggle={jest.fn()}
/>
</MockedProvider>
</TestProviderWithoutDragAndDrop>
</ThemeProvider>

View file

@ -198,55 +198,11 @@ exports[`UrlStateContainer mounts and renders 1`] = `
messages={Object {}}
textComponent={Symbol(react.fragment)}
>
<ApolloProvider
client={
ApolloClient {
"__operations_cache__": Map {},
"cache": InMemoryCache {
"addTypename": true,
"cacheKeyRoot": KeyTrie {
"weakness": true,
},
"config": Object {
"addTypename": true,
"dataIdFromObject": [Function],
"fragmentMatcher": HeuristicFragmentMatcher {},
"freezeResults": false,
"resultCaching": true,
},
"data": DepTrackingCache {
"data": Object {},
"depend": [Function],
},
"maybeBroadcastWatch": [Function],
"optimisticData": DepTrackingCache {
"data": Object {},
"depend": [Function],
},
"silenceBroadcast": false,
"storeReader": StoreReader {
"executeSelectionSet": [Function],
"executeStoreQuery": [Function],
"executeSubSelectedArray": [Function],
"freezeResults": false,
},
"storeWriter": StoreWriter {},
"typenameDocumentCache": Map {},
"watches": Set {},
},
"defaultOptions": Object {},
"disableNetworkFetches": false,
"link": ApolloLink {
"request": [Function],
},
"mutate": [Function],
"query": [Function],
"queryDeduplication": true,
"reFetchObservableQueries": [Function],
"resetStore": [Function],
"resetStoreCallbacks": Array [],
"ssrMode": false,
"store": DataStore {
<PseudoLocaleWrapper>
<ApolloProvider
client={
ApolloClient {
"__operations_cache__": Map {},
"cache": InMemoryCache {
"addTypename": true,
"cacheKeyRoot": KeyTrie {
@ -279,177 +235,142 @@ exports[`UrlStateContainer mounts and renders 1`] = `
"typenameDocumentCache": Map {},
"watches": Set {},
},
},
"version": "2.3.8",
"watchQuery": [Function],
}
}
>
<Provider
store={
Object {
"dispatch": [Function],
"getState": [Function],
"replaceReducer": [Function],
"subscribe": [Function],
Symbol(observable): [Function],
"defaultOptions": Object {},
"disableNetworkFetches": false,
"link": ApolloLink {
"request": [Function],
},
"mutate": [Function],
"query": [Function],
"queryDeduplication": true,
"reFetchObservableQueries": [Function],
"resetStore": [Function],
"resetStoreCallbacks": Array [],
"ssrMode": false,
"store": DataStore {
"cache": InMemoryCache {
"addTypename": true,
"cacheKeyRoot": KeyTrie {
"weakness": true,
},
"config": Object {
"addTypename": true,
"dataIdFromObject": [Function],
"fragmentMatcher": HeuristicFragmentMatcher {},
"freezeResults": false,
"resultCaching": true,
},
"data": DepTrackingCache {
"data": Object {},
"depend": [Function],
},
"maybeBroadcastWatch": [Function],
"optimisticData": DepTrackingCache {
"data": Object {},
"depend": [Function],
},
"silenceBroadcast": false,
"storeReader": StoreReader {
"executeSelectionSet": [Function],
"executeStoreQuery": [Function],
"executeSubSelectedArray": [Function],
"freezeResults": false,
},
"storeWriter": StoreWriter {},
"typenameDocumentCache": Map {},
"watches": Set {},
},
},
"version": "2.3.8",
"watchQuery": [Function],
}
}
>
<ThemeProvider
theme={[Function]}
<Provider
store={
Object {
"dispatch": [Function],
"getState": [Function],
"replaceReducer": [Function],
"subscribe": [Function],
Symbol(observable): [Function],
}
}
>
<DragDropContext
onDragEnd={[MockFunction]}
<ThemeProvider
theme={[Function]}
>
<Router
history={
Object {
"action": "POP",
"block": [MockFunction],
"createHref": [MockFunction],
"go": [MockFunction],
"goBack": [MockFunction],
"goForward": [MockFunction],
"length": 2,
"listen": [MockFunction] {
"calls": Array [
Array [
[Function],
<DragDropContext
onDragEnd={[MockFunction]}
>
<Router
history={
Object {
"action": "POP",
"block": [MockFunction],
"createHref": [MockFunction],
"go": [MockFunction],
"goBack": [MockFunction],
"goForward": [MockFunction],
"length": 2,
"listen": [MockFunction] {
"calls": Array [
Array [
[Function],
],
],
],
"results": Array [
Object {
"type": "return",
"value": undefined,
},
],
},
"location": Object {
"hash": "",
"pathname": "/network",
"search": "",
"state": "",
},
"push": [MockFunction],
"replace": [MockFunction] {
"calls": Array [
Array [
"results": Array [
Object {
"hash": "",
"pathname": "/network",
"search": "?kqlQuery=(filterQuery:!n,queryLocation:network.page,type:page)",
"state": "",
"type": "return",
"value": undefined,
},
],
],
"results": Array [
Object {
"type": "return",
"value": undefined,
},
],
},
}
}
>
<withRouter(Connect(UrlStateContainer))
indexPattern={
Object {
"fields": Array [
Object {
"aggregatable": true,
"name": "response",
"searchable": true,
"type": "number",
},
],
"title": "logstash-*",
},
"location": Object {
"hash": "",
"pathname": "/network",
"search": "",
"state": "",
},
"push": [MockFunction],
"replace": [MockFunction] {
"calls": Array [
Array [
Object {
"hash": "",
"pathname": "/network",
"search": "?kqlQuery=(filterQuery:!n,queryLocation:network.page,type:page)",
"state": "",
},
],
],
"results": Array [
Object {
"type": "return",
"value": undefined,
},
],
},
}
}
>
<Route>
<Connect(UrlStateContainer)
history={
Object {
"action": "POP",
"block": [MockFunction],
"createHref": [MockFunction],
"go": [MockFunction],
"goBack": [MockFunction],
"goForward": [MockFunction],
"length": 2,
"listen": [MockFunction] {
"calls": Array [
Array [
[Function],
],
],
"results": Array [
Object {
"type": "return",
"value": undefined,
},
],
<withRouter(Connect(UrlStateContainer))
indexPattern={
Object {
"fields": Array [
Object {
"aggregatable": true,
"name": "response",
"searchable": true,
"type": "number",
},
"location": Object {
"hash": "",
"pathname": "/network",
"search": "",
"state": "",
},
"push": [MockFunction],
"replace": [MockFunction] {
"calls": Array [
Array [
Object {
"hash": "",
"pathname": "/network",
"search": "?kqlQuery=(filterQuery:!n,queryLocation:network.page,type:page)",
"state": "",
},
],
],
"results": Array [
Object {
"type": "return",
"value": undefined,
},
],
},
}
],
"title": "logstash-*",
}
indexPattern={
Object {
"fields": Array [
Object {
"aggregatable": true,
"name": "response",
"searchable": true,
"type": "number",
},
],
"title": "logstash-*",
}
}
location={
Object {
"hash": "",
"pathname": "/network",
"search": "",
"state": "",
}
}
match={
Object {
"isExact": false,
"params": Object {},
"path": "/",
"url": "/",
}
}
>
<Component
}
>
<Route>
<Connect(UrlStateContainer)
history={
Object {
"action": "POP",
@ -528,74 +449,155 @@ exports[`UrlStateContainer mounts and renders 1`] = `
"url": "/",
}
}
setAbsoluteTimerange={[Function]}
setHostsKql={[Function]}
setNetworkKql={[Function]}
setRelativeTimerange={[Function]}
toggleTimelineLinkTo={[Function]}
urlState={
Object {
"kqlQuery": Object {
"hosts.details": Object {
"filterQuery": null,
"queryLocation": "hosts.details",
"type": "details",
},
"hosts.page": Object {
"filterQuery": null,
"queryLocation": "hosts.page",
"type": "page",
},
"network.details": Object {
"filterQuery": null,
"queryLocation": "network.details",
"type": "details",
},
"network.page": Object {
"filterQuery": null,
"queryLocation": "network.page",
"type": "page",
},
},
"timerange": Object {
"global": Object {
"linkTo": Array [
"timeline",
],
"timerange": Object {
"from": 0,
"fromStr": "now-24h",
"kind": "relative",
"to": 1,
"toStr": "now",
},
},
"timeline": Object {
"linkTo": Array [
"global",
],
"timerange": Object {
"from": 0,
"fromStr": "now-24h",
"kind": "relative",
"to": 1,
"toStr": "now",
},
},
},
}
}
>
<span />
</Component>
</Connect(UrlStateContainer)>
</Route>
</withRouter(Connect(UrlStateContainer))>
</Router>
</DragDropContext>
</ThemeProvider>
</Provider>
</ApolloProvider>
<Component
history={
Object {
"action": "POP",
"block": [MockFunction],
"createHref": [MockFunction],
"go": [MockFunction],
"goBack": [MockFunction],
"goForward": [MockFunction],
"length": 2,
"listen": [MockFunction] {
"calls": Array [
Array [
[Function],
],
],
"results": Array [
Object {
"type": "return",
"value": undefined,
},
],
},
"location": Object {
"hash": "",
"pathname": "/network",
"search": "",
"state": "",
},
"push": [MockFunction],
"replace": [MockFunction] {
"calls": Array [
Array [
Object {
"hash": "",
"pathname": "/network",
"search": "?kqlQuery=(filterQuery:!n,queryLocation:network.page,type:page)",
"state": "",
},
],
],
"results": Array [
Object {
"type": "return",
"value": undefined,
},
],
},
}
}
indexPattern={
Object {
"fields": Array [
Object {
"aggregatable": true,
"name": "response",
"searchable": true,
"type": "number",
},
],
"title": "logstash-*",
}
}
location={
Object {
"hash": "",
"pathname": "/network",
"search": "",
"state": "",
}
}
match={
Object {
"isExact": false,
"params": Object {},
"path": "/",
"url": "/",
}
}
setAbsoluteTimerange={[Function]}
setHostsKql={[Function]}
setNetworkKql={[Function]}
setRelativeTimerange={[Function]}
toggleTimelineLinkTo={[Function]}
urlState={
Object {
"kqlQuery": Object {
"hosts.details": Object {
"filterQuery": null,
"queryLocation": "hosts.details",
"type": "details",
},
"hosts.page": Object {
"filterQuery": null,
"queryLocation": "hosts.page",
"type": "page",
},
"network.details": Object {
"filterQuery": null,
"queryLocation": "network.details",
"type": "details",
},
"network.page": Object {
"filterQuery": null,
"queryLocation": "network.page",
"type": "page",
},
},
"timerange": Object {
"global": Object {
"linkTo": Array [
"timeline",
],
"timerange": Object {
"from": 0,
"fromStr": "now-24h",
"kind": "relative",
"to": 1,
"toStr": "now",
},
},
"timeline": Object {
"linkTo": Array [
"global",
],
"timerange": Object {
"from": 0,
"fromStr": "now-24h",
"kind": "relative",
"to": 1,
"toStr": "now",
},
},
},
}
}
>
<span />
</Component>
</Connect(UrlStateContainer)>
</Route>
</withRouter(Connect(UrlStateContainer))>
</Router>
</DragDropContext>
</ThemeProvider>
</Provider>
</ApolloProvider>
</PseudoLocaleWrapper>
</IntlProvider>
</I18nProvider>
</Component>