Skip to content

Commit dca8f6b

Browse files
bvaughngaearon
authored andcommitted
New commit phase lifecycle getSnapshotBeforeUpdate() (#33)
* WIP new commit phase lifecycles * Renamed componentIsUpdating to getSnapshotBeforeUpdate * Wording * Added Flow types to example * Tweaked text a bit * Added alternatives section * Word tweaking * Fixed static lifecycle example * Typos * Fill in Detailed Design" section * Wordsmithing * Added some notes about Flow * Added a note about polyfilling considerations * Updated polyfill section * Clarified wording
1 parent 8f56793 commit dca8f6b

File tree

1 file changed

+199
-0
lines changed

1 file changed

+199
-0
lines changed
Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
1+
- Start Date: 2018-03-10
2+
- RFC PR: (leave this empty)
3+
- React Issue: (leave this empty)
4+
5+
# Summary
6+
7+
Add new "commit" phase lifecycle, `getSnapshotBeforeUpdate`, that gets called _before_ mutations are made. Any value returned by this lifecycle will be passed as the third parameter to `componentDidUpdate`.
8+
9+
This lifecycle is important for [async rendering](https://reactjs.org/blog/2018/03/01/sneak-peek-beyond-react-16.html), where there may be delays between "render" phase lifecycles (e.g. `componentWillUpdate` and `render`) and "commit" phase lifecycles (e.g. `componentDidUpdate`).
10+
11+
# Basic example
12+
13+
Consider the use case of preserving scroll position within a list as its contents are updated. The way this is typically done is to read `scrollHeight` during render (`componentWillUpdate`) and then adjust it after the update has been committed (`componentDidUpdate`).
14+
15+
Unfortunately this approach **does not work with async rendering**, because there might be a delay between these lifecycles during which the user continues scrolling. The only way to ensure an accurate scroll position is read would be to _force a synchronous render_.
16+
17+
The solution is to introduce a new lifecycle that gets called during the commit phase before mutations have been made to e.g. the DOM. For example:
18+
19+
```js
20+
type Snapshot = number | null;
21+
22+
class ScrollingList extends React.Component<Props, State, Snapshot> {
23+
listRef = React.createRef();
24+
25+
getSnapshotBeforeUpdate(
26+
prevProps: Props,
27+
prevState: State
28+
): Snapshot {
29+
// Are we adding new items to the list?
30+
// Capture the current height of the list so we can adjust scroll later.
31+
if (prevProps.list.length < this.props.list.length) {
32+
return this.listRef.value.scrollHeight;
33+
}
34+
35+
return null;
36+
}
37+
38+
componentDidUpdate(
39+
prevProps: Props,
40+
prevState: State,
41+
snapshot: Snapshot
42+
) {
43+
// If we have a snapshot value, then we've just added new items.
44+
// Adjust scroll so these new items don't push the old ones out of view.
45+
if (snapshot !== null) {
46+
this.listRef.value.scrollTop +=
47+
this.listRef.value.scrollHeight - snapshot;
48+
}
49+
}
50+
51+
render() {
52+
return (
53+
<div ref={this.listRef}>{/* ...contents... */}</div>
54+
);
55+
}
56+
}
57+
```
58+
59+
# Motivation
60+
61+
This lifecycle provides a way for asynchronously rendered components to accurately read values from the host environment (e.g. the DOM) before it is mutated.
62+
63+
The [example above](#basic-example) describes one use case in which this could be useful. Others might involve text selection and cursor position, audio/video playback position, etc.
64+
65+
# Detailed design
66+
67+
Add a new effect type, `Snapshot`, and update `ReactFiberClassComponent` to assign this type when updating components that define the new `getSnapshotBeforeUpdate` lifecycle.
68+
69+
During the `commitAllHostEffects` traversal, call `getSnapshotBeforeUpdate` for any fiber tagged with the new `Snapshot` effect type. Store return value on the instance (as `__reactInternalSnapshotBeforeUpdate`) and later pass to `componentDidUpdate` during `commitLifeCycles`.
70+
71+
### New DEV warnings
72+
73+
Add DEV warnings for the following conditions:
74+
* Undefined return values for `getSnapshotBeforeUpdate`
75+
* Components that define `getSnapshotBeforeUpdate` without also defining `componentDidUpdate
76+
77+
### Flow
78+
79+
Flow will also need to be updated to add a third `Snapshot` type parameter to `React.Component` to ensure consistency for the return type fo `getSnapshotBeforeUpdate` adn the new parameter passed to `componentDidUpdate`. This new type parameter will be declared like so:
80+
81+
```js
82+
// If there is a State type:
83+
class Example extends React.Component<Props, State, Snapshot> {}
84+
85+
// If there is no State type:
86+
class Example extends React.Component<Props, State = void, Snapshot> {}
87+
```
88+
89+
### Polyfill support
90+
91+
It would be possible for [react-lifecycles-compat](https://github.com/reactjs/react-lifecycles-compat) to polyfill this new method for older, synchronous versions of React using the `componentWillUpdate` lifecycle. It would require a couple of hacks though:
92+
* The polyfilled `componentWillUpdate` method would need to temporarily mutate instance props (`this.props` and `this.state`) before calling the new `getSnapshotFromUdate` method in order to maintain next/prev semantics.
93+
* The polyfill would need to mutate the component's `prototype` to decorate `componentDidUpdate` in order to add the new `snapshot` parameter. This would not work in all cases (e.g. methods that get attached to the instance in the constructor rather than as part of the prototype).
94+
95+
Regardless, I think it's probably reasonable to follow the precedent set by `getDerivedStateFromProps` and _not_ call the call _unsafe_ legacy lifecycles `componentWillMount`, `componentWillReceiveProps`, or `componentWillUpdate` for any component that defines the new `getSnapshotBeforeUpdate` method.
96+
97+
A DEV warning can be added for components that define both `getSnapshotBeforeUpdate` and any of the unsafe legacy lifecycles.
98+
99+
# Drawbacks
100+
101+
Each new lifecycle adds complexity and makes the component API harder for beginners to understand. Although this lifecycle _is important_, it will probably _not be used often_, and so I think the impact is minimal.
102+
103+
# Alternatives
104+
105+
A new commit-phase lifecycle is necessary. The signature does not have to match the one proposed by this RFC however. Below are some alternatives that were considered.
106+
107+
### Static method
108+
109+
The most recently-added lifecycle, `getDerivedStateFromProps`, was a static method in order to prevent unsafe access of instance properties. That concern is less relevant in this case though, because this lifecycle is called during the commit phase.
110+
111+
```js
112+
class ScrollingList extends React.Component<Props, State> {
113+
state = {
114+
listHasGrown: false,
115+
listRef: React.createRef(),
116+
prevList: this.props.list
117+
};
118+
119+
static getDerivedStateFromProps(
120+
nextProps: Props,
121+
prevState: State
122+
): $Shape<State> | null {
123+
if (nextProps.list !== prevState.prevList) {
124+
return {
125+
listHasGrown:
126+
nextProps.list.length > prevState.prevList.length,
127+
prevList: nextProps.list
128+
};
129+
} else if (prevState.listHasGrown) {
130+
return {
131+
listHasGrown: false
132+
};
133+
}
134+
135+
return null;
136+
}
137+
138+
static getSnapshotBeforeUpdate(
139+
prevProps: Props,
140+
prevState: State
141+
): Snapshot | null {
142+
if (prevState.listHasGrown) {
143+
return prevState.listRef.value.scrollHeight;
144+
}
145+
146+
return null;
147+
}
148+
149+
// ...
150+
}
151+
```
152+
153+
This approach was not chosen because of the added complexity of storing additional values (including refs) in `state`.
154+
155+
### No return value
156+
157+
The proposed lifecycle will be the first commit phase lifecycle with a meaningful return value and the first lifecycle whose return value is passed as a parameter to another lifecycle. Likewise, the new parameter for `componentDidUpdate` will be the first passed to a lifecycle that isn't some form of `Props` or `State`. This adds some complexity to the API, since it requires a more nuanced understanding the relationship between `getSnapshotBeforeUpdate` and `componentDidUpdate`.
158+
159+
An alternative would be to scrap the return value in favor of storing snapshot values on the instance. This has the added benefit of not requiring any changes to be made to Flow.
160+
161+
```js
162+
class ScrollingList extends React.Component<Props, State> {
163+
listRef = React.createRef();
164+
listScrollHeight = null;
165+
166+
getSnapshotBeforeUpdate(
167+
prevProps: Props,
168+
prevState: State
169+
) {
170+
if (prevProps.list.length < this.props.list.length) {
171+
this.listScrollHeight = this.listRef.value.scrollHeight;
172+
}
173+
}
174+
175+
componentDidUpdate(prevProps: Props, prevState: State) {
176+
if (this.listScrollHeight !== null) {
177+
this.listRef.value.scrollTop +=
178+
this.listRef.value.scrollHeight - snapshot;
179+
this.listScrollHeight = null;
180+
}
181+
}
182+
183+
// ...
184+
}
185+
```
186+
187+
Ultimately, the team voted against this approach because it encourages mutations and may invite other side-effects in a lifecycle that is intended to be used for a very specific purpose.
188+
189+
# Adoption strategy
190+
191+
Since this lifecycle- and async rendering in general- is new functionality, adoption will be organic. Documentation and dev-mode warnings have already been created to encourage people to move away from render phase lifecycles like `componentWillUpdate` in favor of commit phase lifecycles.
192+
193+
# How we teach this
194+
195+
Lifecycle documentation on the website. Add a before an after example (like [the one above](#basic-example)) to the [Update on Async Rendering](https://github.com/reactjs/reactjs.org/pull/596) blog post "recipes".
196+
197+
# Unresolved questions
198+
199+
None presently.

0 commit comments

Comments
 (0)