Skip to content

Commit 4c40cd8

Browse files
committed
translation context: Eliminate key hack (the last one!).
In putting together a recent commit, I realized that this would be possible if our `TranslationContextTranslator` more carefully spoke the language of the old Context API. By making it a `Component` instead of a `PureComponent`, and making sure there aren't any disruptive ancestors between it and IntlProvider (in particular, there are *no* ancestors at all) it'll be updated when `intl` changes. It already speaks the new Context API as well as it needs to (i.e., nothing special has to happen to get its consumers to update), and we don't have any consumers that directly consume the old one straight from `react-intl`. One necessary tweak was to stop using `this._`, which is only set in the constructor. (Reminiscent of b530f6c, when we were removing the `key` hack for styles.) Fixes: zulip#1946
1 parent b482993 commit 4c40cd8

File tree

2 files changed

+99
-71
lines changed

2 files changed

+99
-71
lines changed

docs/architecture/react.md

+66-50
Original file line numberDiff line numberDiff line change
@@ -102,76 +102,92 @@ long as the code adheres to core Redux principles:
102102

103103
### Context
104104

105-
As long as you use the [current Context
105+
We're on board with the current API where possible; there's a
106+
third-party library we use that isn't there yet.
107+
108+
#### Current Context API
109+
110+
We should use the [current Context
106111
API](https://reactjs.org/docs/context.html) instead of the [legacy
107-
one](https://reactjs.org/docs/legacy-context.html), there's not much
108-
to worry about here. From the doc for the current one:
112+
one](https://reactjs.org/docs/legacy-context.html) wherever possible.
113+
The new API aggressively ensures consumers will be updated
114+
(re-`render`ed) on context changes, and the old one doesn't (see
115+
below). From the [new API's
116+
doc](https://reactjs.org/docs/context.html):
109117

110118
> All consumers that are descendants of a Provider will re-render
111119
> whenever the Provider’s `value` prop changes.
112120
113-
Then, anticipating the question "What about `shouldComponentUpdate`?"
114-
(and therefore, "What about `PureComponent`s?", because
115-
`PureComponents`
116-
[implement](https://reactjs.org/docs/react-api.html#reactpurecomponent)
117-
`shouldComponentUpdate`):
121+
It's so aggressive that there's a potential "gotcha" with the new API:
122+
context consumers are the first occurrence of the following that we're
123+
aware of (from the [doc on
124+
`shouldComponentUpdate`](https://reactjs.org/docs/react-component.html#shouldcomponentupdate)):
125+
126+
> In the future React may treat `shouldComponentUpdate()` as a hint
127+
> rather than a strict directive, and returning `false` may still
128+
> result in a re-rendering of the component.
129+
130+
We gather this from the following (in the [new API's
131+
doc](https://reactjs.org/docs/context.html)):
118132

119133
> The propagation from Provider to its descendant consumers (including
120134
> [`.contextType`](https://reactjs.org/docs/context.html#classcontexttype)
121-
> and
122-
> [`useContext`](https://reactjs.org/docs/hooks-reference.html#usecontext))
123-
> is not subject to the shouldComponentUpdate method, so the consumer is
124-
> updated even when an ancestor component skips an update.
125-
126-
(I think word "even" means to emphasize "ancestor" -- i.e., it's not
127-
only the case that a consumer's *own* `shouldComponentUpdate` is
128-
ignored, but that, remarkably, so is the `shouldComponentUpdate` of
129-
ancestor components. I think it's just as remarkable that a
130-
component's own `shouldComponentUpdate` is ignored: this sounds like a
131-
clear instance of the
132-
[following](https://reactjs.org/docs/react-component.html#shouldcomponentupdate):
133-
"In the future React may treat `shouldComponentUpdate()` as a hint
134-
rather than a strict directive, and returning `false` may still result
135-
in a re-rendering of the component.")
135+
> [...])
136+
> is not subject to the shouldComponentUpdate method
137+
138+
Concretely, this means that our `MessageList` component updates
139+
(re-`render`s) when the theme changes, since it's a `ThemeContext`
140+
consumer, *even though its `shouldComponentUpdate` always returns
141+
`false`*. So far, this hasn't been a problem because the UI doesn't
142+
allow changing the theme while a `MessageList` is in the navigation
143+
stack. If it were possible, it would be a concern: setting a short
144+
interval to automatically toggle the theme, we see that the message
145+
list's color scheme changes as we'd want it to, but we also see the
146+
effects that `shouldComponentUpdate` returning `false` is meant to
147+
prevent: losing the scroll position, mainly (but also, we expect,
148+
discarding the image cache, etc.).
149+
150+
#### Legacy Context API
136151

137152
The legacy Context API is
138153
[declared](https://reactjs.org/docs/legacy-context.html#updating-context)
139-
fundamentally broken because `shouldComponentUpdate` and
140-
`PureComponent`s can block updates.
141-
142-
We're forced to reckon with the legacy Context API in one place: the
143-
`react-intl` library uses it for the `intl` context. We "translate"
144-
the old API to the new one ASAP, in `TranslationContextTranslator`, so
145-
we can get used to using Context in the new way. But we've made
146-
`TranslationContextTranslator` a `PureComponent`, exposing ourselves
147-
to the legacy API's flaw. So we have to use a "`key` hack", which is
148-
the following:
149-
150-
If `locale` changes, we make the entire React component tree at and
151-
below `IntlProvider` remount. (Not merely re-`render`: completely
152-
vanish and start over with `componentDidMount`; see the note at [this
153-
doc](https://5d4b5feba32acd0008d0df98--reactjs.netlify.app/docs/reconciliation.html)
154-
starting with "Keys should be stable, predictable, and unique".) We do
155-
this with the [`key`
156-
attribute](https://reactjs.org/docs/lists-and-keys.html), which isn't
157-
recommended for use except in lists.
158-
159-
Previously, we used the same `key` hack for our own styles data, to
160-
get changes in the theme to propagate. No longer! :)
154+
fundamentally broken because consumers could be blocked from receiving
155+
updates to the context, and not just by the consumer's own
156+
`shouldComponentUpdate`:
157+
158+
> The problem is, if a context value provided by component changes,
159+
> descendants that use that value won’t update if an intermediate
160+
> parent returns `false` from `shouldComponentUpdate`. This is totally
161+
> out of control of the components using context, so there’s basically
162+
> no way to reliably update the context.
163+
164+
We have to think about the legacy Context API in just one place. The
165+
`react-intl` library's `IntlProvider` uses it to provide the `intl`
166+
context. The only consumer of `intl` is
167+
`TranslationContextTranslator`, which "speaks" the old API by being
168+
the direct child of `IntlProvider` and by being a `Component`, not a
169+
`PureComponent` (whose under-the-hood `shouldComponentUpdate` would
170+
suppress updates on context changes)—all to make sure that it
171+
re-`render`s whenever `intl` changes. Then,
172+
`TranslationContextTranslator` is itself a provider, and it provides
173+
the same value, but it does so in the new Context API way. All its
174+
consumers are updated appropriately, which is what we want.
161175

162176
### The exception: `MessageList`
163177

164-
We have one React component that we wrote (beyond `connect` calls) that
165-
deviates from the above design: `MessageList`. This is the only
166-
component that extends plain `Component` rather than `PureComponent`, and
167-
the only component that implements `shouldComponentUpdate`.
178+
We have one React component that we wrote (beyond `connect` calls)
179+
that deviates from the above design: `MessageList`. It extends plain
180+
`Component` rather than `PureComponent`, and it's the only component
181+
in which we implement `shouldComponentUpdate`.
168182

169183
In fact, `MessageList` does adhere to the Pure Component Principle -- its
170184
`render` method is a pure function of `this.props` and `this.context`. So
171185
it could use `PureComponent`, but it doesn't -- instead we have a
172186
`shouldComponentUpdate` that always returns `false`, so even when `props`
173187
change quite materially (e.g., a new Zulip message arrives which should be
174-
displayed) we don't have React re-render the component.
188+
displayed) we don't have React re-render the component. (See the note
189+
on the current Context API, above, for a known case where our
190+
`shouldComponentUpdate` is ignored.)
175191

176192
The specifics of why not, and what we do instead, deserve an architecture
177193
doc of their own. In brief: `render` returns a single React element, a

src/boot/TranslationProvider.js

+33-21
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/* @flow strict-local */
2-
import React, { PureComponent } from 'react';
2+
import React, { PureComponent, Component } from 'react';
33
import type { ComponentType, ElementConfig, Node as React$Node } from 'react';
44
import { Text } from 'react-native';
55
import { IntlProvider } from 'react-intl';
@@ -51,9 +51,37 @@ const makeGetText = (intl: IntlShape): GetText => {
5151
* new API.
5252
*
5353
* See https://reactjs.org/docs/context.html
54-
* vs. https://reactjs.org/docs/legacy-context.html .
54+
* vs. https://reactjs.org/docs/legacy-context.html.
55+
*
56+
* Why do we need this? `IntlProvider` uses React's "legacy context
57+
* API", deprecated since React 16.3, of which the docs say:
58+
*
59+
* ## Updating Context
60+
*
61+
* Don't do it.
62+
*
63+
* React has an API to update context, but it is fundamentally
64+
* broken and you should not use it.
65+
*
66+
* It's broken because a consumer in the old way would never
67+
* re-`render` on changes to the context if they, or any of their
68+
* ancestors below the provider, implemented `shouldComponentUpdate`
69+
* in a way that blocked updates from the context. This meant that
70+
* neither the provider nor the consumer had the power to fix many
71+
* non-re-`render`ing bugs. A very common context-update-blocking
72+
* implementation of `shouldComponentUpdate` is the one
73+
* `PureComponent` uses, so the effect is widespread.
74+
*
75+
* In the new way, `shouldComponentUpdate`s (as implemented by hand or
76+
* by using `PureComponent`) in the hierarchy all the way down to the
77+
* consumer (inclusive) are ignored when the context updates.
78+
*
79+
* Consumers should consume `TranslationContext` as it's provided
80+
* here, so they don't have to worry about not updating when it
81+
* changes.
5582
*/
56-
class TranslationContextTranslator extends PureComponent<{|
83+
// This component MUST NOT be a `PureComponent`; see above.
84+
class TranslationContextTranslator extends Component<{|
5785
+children: React$Node,
5886
|}> {
5987
context: { intl: IntlShape };
@@ -62,11 +90,9 @@ class TranslationContextTranslator extends PureComponent<{|
6290
intl: () => null,
6391
};
6492

65-
_ = makeGetText(this.context.intl);
66-
6793
render() {
6894
return (
69-
<TranslationContext.Provider value={this._}>
95+
<TranslationContext.Provider value={makeGetText(this.context.intl)}>
7096
{this.props.children}
7197
</TranslationContext.Provider>
7298
);
@@ -84,21 +110,7 @@ class TranslationProvider extends PureComponent<Props> {
84110
const { locale, children } = this.props;
85111

86112
return (
87-
/* `IntlProvider` uses React's "legacy context API", deprecated since
88-
* React 16.3, of which the docs say:
89-
*
90-
* ## Updating Context
91-
*
92-
* Don't do it.
93-
*
94-
* React has an API to update context, but it is fundamentally
95-
* broken and you should not use it.
96-
*
97-
* To work around that, we set `key={locale}` to force the whole tree
98-
* to rerender if the locale changes. Not cheap, but the locale
99-
* changing is rare.
100-
*/
101-
<IntlProvider key={locale} locale={locale} textComponent={Text} messages={messages[locale]}>
113+
<IntlProvider locale={locale} textComponent={Text} messages={messages[locale]}>
102114
<TranslationContextTranslator>{children}</TranslationContextTranslator>
103115
</IntlProvider>
104116
);

0 commit comments

Comments
 (0)