From e394803a72ad60c1172ae1ac8c15b9bce9c7ed78 Mon Sep 17 00:00:00 2001 From: Brian Vaughn Date: Thu, 13 Feb 2020 18:50:42 -0800 Subject: [PATCH 1/7] useMutableSource RFC --- text/0000-use-mutable-source.md | 337 ++++++++++++++++++++++++++++++++ 1 file changed, 337 insertions(+) create mode 100644 text/0000-use-mutable-source.md diff --git a/text/0000-use-mutable-source.md b/text/0000-use-mutable-source.md new file mode 100644 index 00000000..b6888b1f --- /dev/null +++ b/text/0000-use-mutable-source.md @@ -0,0 +1,337 @@ +- Start Date: 2020-02-13 +- RFC PR: [18000](https://github.com/facebook/react/pull/18000) +- React Issue: N/A + +# useMutableSource + +`useMutableSource()` enables React components to **safely** and **efficiently** read from a mutable external source in Concurrent Mode. The API will detect mutations that occur during a render to avoid tearing and it will automatically schedule updates when the source is mutated. + +# Basic example + +This hook is designed to support a variety of mutable sources. Below are a few example cases. + +### Redux stores + +`useMutableSource()` can be used with Redux stores: + +```js +// May be created in module scope, like context: +const reduxSource = createMutableSource(store, { + // Because the state is immutable, it can be used as the "version". + getVersion: () => reduxStore.getState() +}); + +function Example() { + const config = useMemo( + () => ({ + // Redux state is already immutable, so it can be returned as-is. + // Like a Redux selector, this method could also return a filtered/derived value. + getSnapshot: store => store.getState(), + + // Redux subscribe method already returns an unsubscribe handler. + subscribe: (store, callback) => store.subscribe(callback) + }), + [reduxSource] + ); + + const state = useMutableSource(reduxSource, config); + + // ... +} +``` + +### Event dispatchers + +Given a data source that dispatches change events, `useMutableSource()` can be used to safely consume some part of that data: + +```js +// May be created in module scope, like context: +const userDataSource = createMutableSource(store, { + getVersion: () => store.version +}); + +function Example() { + const config = useMemo( + () => ({ + // The underlying source object ("store" in this case) + // will be passed to the config methods: + getSnapshot: store => store.friends.map(friend => friend.id), + + // This method can subscribe to root level change events, + // or more snapshot-specific events. + // In this case, since Example is only reading the "friends" value, + // we only have to subscribe to a change in that value + // (e.g. a "friends" event) + subscribe: (store, callback) => { + store.addEventListener("friends", callback); + return () => store.removeEventListener("friends", callback); + } + }), + + // If e.g. props or state were used in the selector snapshot function, + // they would also be specified in the dependency array. + [userDataSource] + ); + + const friendIDs = useMutableSource(userDataSource, config); + + // ... +} +``` + +### Browser APIs + +`useMutableSource()` can also read from non traditional sources, e.g. the shared Location object, so long as they can be subscribed to and have a "version". + +```js +// May be created in module scope, like context: +const locationSource = createMutableSource(window, { + // Although not the typical "version", the href attribute is stable, + // and will change whenever part of the Location changes, + // so it's safe to use as a version. + getVersion: () => window.location.href +}); + +function Example() { + const config = useMemo( + () => ({ + getSnapshot: window => win.location.pathname, + subscribe: (window, callback) => { + window.addEventListener("popstate", callback); + return () => window.removeEventListener("popstate", callback); + } + }), + [locationSource] + ); + + const pathName = useMutableSource(locationSource, config); + + // ... +} +``` + +### Observables + +Observables don’t have an intrinsic version number and so they are incompatible with this API. It might be possible to add a derived version number to an observable, as shown below, but **it would not be safe to do this during render** without causing potential memory leaks. + +```js +function createBehaviorSubjectWithVersion(behaviorSubject) { + let version = 0; + + const subscription = behaviorSubject.subscribe(() => { + version++; + }); + + return new Proxy(behaviorSubject, { + get: function(object, prop, receiver) { + if (prop === "version") { + return version; + } else if (prop === "destroy") { + return () => subscription.unsubscribe(); + } else { + return object[prop]; + } + } + }); +} +``` + +# Motivation + +The current best "alternates" to this API are the [Context API](https://reactjs.org/docs/context.html) and [`useSubscription` hook](https://www.npmjs.com/package/use-subscription). + +### Context API + +The Context API is not currently suited for sources that are used by many components throughout the tree, as changes to the context result in updates that are very heavy (for example, see [Redux v6 performance challenges](https://github.com/reduxjs/react-redux/issues/1177)). (There are currently proposals to improve this: [RFC 118](https://github.com/reactjs/rfcs/pull/118) and [RFC 119](https://github.com/reactjs/rfcs/pull/119).) + +### `useSubscription` + +[This Gist](https://gist.github.com/bvaughn/054b82781bec875345bd85a5b1344698) outlines the differences between `useMutableSource` and `useSubscription`. The main advantages of this new API are: + +- No temporary tearing will occur during render (even before the initial subscription). +- Subscriptions can be "scoped" so that updates to parts of a mutable source only impact the relevant components (and not all components reading from the source). This means that in the common case, this hook should perform much better. + +# Detailed design + +`useMutableSource` is similar to [`useSubscription`](https://github.com/facebook/react/tree/master/packages/use-subscription). + +- Both require a memoized “config” object with callbacks to read values from an external “source”. +- Both require a way to subscribe and unsubscribe to the source. + +There are some differences though: + +- `useMutableSource` requires the source as an explicit parameter. (React uses this value to protect against "tearing" and ensure that all components reading from a particular source render with the same version of data.) +- `useMutableSource` requires values read from the source to be immutable snapshots. This enables values to be reused during high priority render, allowing more expensive re-renders to be deferred when needed. + +### Public API + +```js +type MutableSource = {| + /*…*/ +|}; + +function createMutableSource( + source: Source, + config: {| + getVersion: () => $NonMaybeType + |} +): MutableSource { + // ... +} + +function useMutableSource( + source: MutableSource, + config: {| + getSnapshot: (source: Source) => Snapshot, + subscribe: (source: Source, callback: Function) => () => void + |} +): Snapshot { + // ... +} +``` + +## Implementation + +### Root or module scope changes + +Mutable source requires tracking two pieces of info at the module level: + +1. Work-in-progress version number (tracked per source, per renderer) +1. Pending update expiration times (tracked per root, per source) + +#### Version number + +Tracking a source's version allows us to avoid tearing during a mount (before our component has subscribed to the source). Whenever a mounting component reads from a mutable source, this number should be checked to ensure that either (1) this is the first mounting component to read from the source during the current render or (2) the version number has not changed since the last read. A changed version number indicates a change in the underlying store data, which may result in a tear. + +Like Context, this hook should support multiple concurrent renderers (e.g. ReactDOM and ReactART, React Native and React Fabric). To support this, we will track two work-in-progress versions (one for a "primary" renderer and one for a "secondary" renderer). + +This value should be reset either when a renderer starts a new batch of work or when it finishes (or discards) a batch of work. This information could be stored: + +- On each mutable source itself in a primary and secondary field. + - **Con**: Requires a separate array/list to track mutable sources with outstanding changes. +- At the module level as a `Map` of mutable source to pending primary and secondary version numbers. + - **Con**: Requires at least one extra `Map` structure. + +> ⚠️ **Decision** Store versions directly on the source itself and track pending changes with an array. + +#### Pending update expiration times + +Tracking pending update times enables already mounted components to safely reuse cached snapshot values without tearing in order to support higher priority updates. During an update, if the current render’s expiration time is **≤** the stored expiration time for a source, it is safe to read new values from the source. Otherwise a cached snapshot value should be used temporarily1. + +When a root is committed, all pending expiration times that are **≤** the committed time can be discarded for that root. + +This information could be stored: + +- On each Fiber root as a `Map` of mutable source to pending update expiration time. +- On each mutable source as a `Map` of Fiber root to pending update expiration time. + - **Con**: Requires a separate data structure to map roots to mutable sources with outstanding changes (since outstanding changes are cleaned up per-root on commit). + +> ⚠️ **Decision** Store pending update times in a `Map` on the Fiber root. + +1 Cached snapshot values can't be reused when a config changes between render. More on this below... + +#### A word about why both pending expiration and version are required + +Although useful for updates, pending update expiration times are not sufficient to avoid tearing for newly mounted components even if the source has already been used by another component. Since each component may subscribe to a different part of the store, the following scenario is possible: + +1. Some components mount and subscribe to source A. +2. React starts a new render. +3. A new component (not previously mounted) reads from source A, and then React yields. +4. Source A changes in a way that does not notify any of the currently-subscribed components, but would impact the new component (which is not yet subscribed). +5. Another new component (not previously mounted) reads from source A. At this point, there are no pending updates for the source, but it has changed and so reading from it may cause a tear. + +### Hook state + +The `useMutableSource()` hook’s memoizedState will need to track the following values: + +- The user-provided config object (with getter functions). +- The latest (cached) snapshot value. +- The mutable source itself (in order to detect if a new source is provided). +- A destroy function (to unsubscribe from a source) + +### Scenarios to handle + +#### Initial mount (before subscription) + +When a component reads from a mutable source that it has not yet subscribed to1, React first checks to see if there are any pending updates for the source already scheduled on the current root. + +- ✗ If there is a pending update and the current expiration time is **>** the pending time, the read is **not safe**. + - Throw and restart the render. +- If there are no pending updates, or if the current expiration time is **≤** the pending time, has the component already subscribed to this source? + - ✓ If yes, the read is **safe**. + - Store the snapshot value on `memoizedState`. + - If no, the the read **may be safe**. + +For components that have not yet subscribed to their source, React reads the version of the source and compares it to the tracked work-in-progress version numbers. + +- ✓ If there is no recorded version, this is the first time the source has been used. The read is **safe**. + - Record the current version number (on the root) for later reads during mount. + - Store the snapshot value on `memoizedState`. +- ✓ If the recorded version matches the store version used previously, the read is **safe**. + - Store the snapshot value on `memoizedState`. +- ✗ If the recorded version is different, the read is **not safe**. + - Throw and restart the render. + +¹ This case could occur during a mount or an update (if a new mutable source was read from for the first time). + +#### Mutation + +React will subscribe to sources after commit so that it can schedule updates in response to mutations. When a mutation occurs1, React will calculate an expiration time for processing the change, and will: + +- Schedule an update for that expiration time. +- Update a root level entry for this source to specify the next scheduled expiration time. + - This enables us to avoid tearing within the root during subsequent renders. + +¹ Component subscriptions may only subscribe to parts of the external source they care about. Updates will only be scheduled for component’s whose subscriptions fire. + +#### Update (after subscription) + +React will eventually re-render when a source is mutated, but it may also re-render for other reasons. Even in the event of a mutation, React may need to render a higher priority update before processing the mutation. In that case, it’s important that components do not read from a changed source since it may cause tearing. + +In order to process updates safely, React will track pending root level expiration times per source. + +- ✓ If the current render’s expiration time is **≤** the stored expiration time for a source, it is **safe** to read. + - Store an updated snapshot value on `memoizedState`. +- If the current render expiration time is **>** than the root priority for a source, consider the config object. + - ✓ If the config object has not changed, we can re-use the **cached snapshot value**.1 + - ✗ If the config object has changed, the **cached snapshot is stale**. + - Throw and restart the render. + +¹ React will later re-render with new data, but it’s okay to use a cached value if the memoized config has not changed- because if the inputs haven’t changed, the output will not have changed. + +#### React render new subtree + +React may render a new subtree that reads from a source that was also used to render an existing part of the tree. The rules for this scenario is the same as the initial mount case described above. + +# Design constraints + +- Tearing guarantees are only enforced within a root, for components using the same MutableSource value. Tearing between roots is possible. +- Values read and returned from the store must be immutable in the same way as e.g. class state or props objects. + - e.g. ✓ `getSnapshot: source => Array.from(source.friendIDs)` + - e.g. ✗ `getSnapshot: source => source.friendIDs` + - Values don't need to literally be immutable but should at least be cloned so they are disconnected from the store and are not mutated by changes to the external source. + +* Mutable source must have some form of stable version. + - Version should be global (for the entire source, not parts of the source). + - e.g. ✓ `getVersion: () => source.version` + - e.g. ✗ `getVersion: () => source.user.version` + - Version should change whenever any part of the source is mutated. + - Version does not have to be a number or even a single attribute. + - It can be a serialized form of the data, so long as it is stable and unique. (For example, reading query parameters might treat the entire URL string as the version.) + - It can be the state itself, if the value is mutable (e.g. a Redux store is mutable, but its state is immutable). + +# Alternatives + +See "Motivation" section above. + +# Adoption strategy + +This hook is primarily intended for use by libraries like Redux (and possibly Relay). Work with the maintainers of those libraries to integrate with the hook. + +# How we teach this + +New [reactjs.org](https://reactjs.org/) documentation and blog post. + +# Unresolved questions + +- Are there any common/important types of mutable sources that this proposal will not be able to support? From 536c00dcb3d1afed8cda48c3ae3fbe39a285360f Mon Sep 17 00:00:00 2001 From: Brian Vaughn Date: Fri, 14 Feb 2020 08:21:02 -0800 Subject: [PATCH 2/7] Fixed typo ("mutable" -> "immutable") --- text/0000-use-mutable-source.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/text/0000-use-mutable-source.md b/text/0000-use-mutable-source.md index b6888b1f..1948ce79 100644 --- a/text/0000-use-mutable-source.md +++ b/text/0000-use-mutable-source.md @@ -318,7 +318,7 @@ React may render a new subtree that reads from a source that was also used to re - Version should change whenever any part of the source is mutated. - Version does not have to be a number or even a single attribute. - It can be a serialized form of the data, so long as it is stable and unique. (For example, reading query parameters might treat the entire URL string as the version.) - - It can be the state itself, if the value is mutable (e.g. a Redux store is mutable, but its state is immutable). + - It can be the state itself, if the value is immutable (e.g. a Redux store is mutable, but its state is immutable). # Alternatives From e3ecb1b50370ce22df4ea1400fc5b87991980a9c Mon Sep 17 00:00:00 2001 From: Brian Vaughn Date: Sat, 15 Feb 2020 09:40:58 -0800 Subject: [PATCH 3/7] Updated RFC to remove config wrapper object and show useCallback() used instead --- text/0000-use-mutable-source.md | 145 +++++++++++++++++--------------- 1 file changed, 77 insertions(+), 68 deletions(-) diff --git a/text/0000-use-mutable-source.md b/text/0000-use-mutable-source.md index 1948ce79..a5fa55c0 100644 --- a/text/0000-use-mutable-source.md +++ b/text/0000-use-mutable-source.md @@ -16,98 +16,111 @@ This hook is designed to support a variety of mutable sources. Below are a few e ```js // May be created in module scope, like context: -const reduxSource = createMutableSource(store, { +const reduxSource = createMutableSource( + store, // Because the state is immutable, it can be used as the "version". - getVersion: () => reduxStore.getState() -}); + () => reduxStore.getState() +); + +// Redux state is already immutable, so it can be returned as-is. +// Like a Redux selector, this method could also return a filtered/derived value. +// +// Because this method doesn't require access to props, +// it can be declared in module scope to be shared between components. +const getSnapshot = store => store.getState(); + +// Redux subscribe method already returns an unsubscribe handler. +// +// Because this method doesn't require access to props, +// it can be declared in module scope to be shared between components. +const subscribe = (store, callback) => store.subscribe(callback); function Example() { - const config = useMemo( - () => ({ - // Redux state is already immutable, so it can be returned as-is. - // Like a Redux selector, this method could also return a filtered/derived value. - getSnapshot: store => store.getState(), - - // Redux subscribe method already returns an unsubscribe handler. - subscribe: (store, callback) => store.subscribe(callback) - }), - [reduxSource] - ); - - const state = useMutableSource(reduxSource, config); + const state = useMutableSource(reduxSource, getSnapshot, subscribe); // ... } ``` -### Event dispatchers +### Browser APIs -Given a data source that dispatches change events, `useMutableSource()` can be used to safely consume some part of that data: +`useMutableSource()` can also read from non traditional sources, e.g. the shared Location object, so long as they can be subscribed to and have a "version". ```js // May be created in module scope, like context: -const userDataSource = createMutableSource(store, { - getVersion: () => store.version +const locationSource = createMutableSource(window, { + // Although not the typical "version", the href attribute is stable, + // and will change whenever part of the Location changes, + // so it's safe to use as a version. + getVersion: () => window.location.href }); -function Example() { - const config = useMemo( - () => ({ - // The underlying source object ("store" in this case) - // will be passed to the config methods: - getSnapshot: store => store.friends.map(friend => friend.id), - - // This method can subscribe to root level change events, - // or more snapshot-specific events. - // In this case, since Example is only reading the "friends" value, - // we only have to subscribe to a change in that value - // (e.g. a "friends" event) - subscribe: (store, callback) => { - store.addEventListener("friends", callback); - return () => store.removeEventListener("friends", callback); - } - }), +// Because this method doesn't require access to props, +// it can be declared in module scope to be shared between components. +const getSnapshot = window => win.location.pathname; + +// This method can subscribe to root level change events, +// or more snapshot-specific events. +// In this case, since Example is only reading the "friends" value, +// we only have to subscribe to a change in that value +// (e.g. a "friends" event) +// +// Because this method doesn't require access to props, +// it can be declared in module scope to be shared between components. +const subscribe = (window, callback) => { + window.addEventListener("popstate", callback); + return () => window.removeEventListener("popstate", callback); +}; - // If e.g. props or state were used in the selector snapshot function, - // they would also be specified in the dependency array. - [userDataSource] - ); - - const friendIDs = useMutableSource(userDataSource, config); +function Example() { + const pathName = useMutableSource(locationSource, getSnapshot, subscribe); // ... } ``` -### Browser APIs +### Selectors that use props -`useMutableSource()` can also read from non traditional sources, e.g. the shared Location object, so long as they can be subscribed to and have a "version". +Sometimes a state value is derived using component `props`. In this case, `useCallback` should be used to keep the snapshot and subscribe functions stable. ```js // May be created in module scope, like context: -const locationSource = createMutableSource(window, { - // Although not the typical "version", the href attribute is stable, - // and will change whenever part of the Location changes, - // so it's safe to use as a version. - getVersion: () => window.location.href +const userDataSource = createMutableSource(store, { + getVersion: () => data.version }); -function Example() { - const config = useMemo( - () => ({ - getSnapshot: window => win.location.pathname, - subscribe: (window, callback) => { - window.addEventListener("popstate", callback); - return () => window.removeEventListener("popstate", callback); - } - }), - [locationSource] +// This method can subscribe to root level change events, +// or more snapshot-specific events. +// In this case, since Example is only reading the "friends" value, +// we only have to subscribe to a change in that value +// (e.g. a "friends" event) +// +// Because this method doesn't require access to props, +// it can be declared in module scope to be shared between components. +const subscribe = (data, callback) => { + data.addEventListener("friends", callback); + return () => data.removeEventListener("friends", callback); +}; + +function Example({ onlyShowFamily }) { + // Because the snapshot depends on props, it has to be created inline. + // useCallback() memoizes the function though, + // which lets useMutableSource() know when it's safe to reuse a snapshot value. + const getSnapshot = useCallback( + data => + data.friends + .filter( + friend => !onlyShowFamily || friend.relationshipType === "family" + ) + .friends.map(friend => friend.id), + [onlyShowFamily] ); - const pathName = useMutableSource(locationSource, config); + const friendIDs = useMutableSource(userDataSource, getSnapshot, subscribe); // ... } + ``` ### Observables @@ -172,19 +185,15 @@ type MutableSource = {| function createMutableSource( source: Source, - config: {| - getVersion: () => $NonMaybeType - |} + getVersion: () => $NonMaybeType ): MutableSource { // ... } function useMutableSource( source: MutableSource, - config: {| - getSnapshot: (source: Source) => Snapshot, - subscribe: (source: Source, callback: Function) => () => void - |} + getSnapshot: (source: Source) => Snapshot, + subscribe: (source: Source, callback: Function) => () => void ): Snapshot { // ... } From 4048476e966589d28e5e796b435f23c920e0f6a3 Mon Sep 17 00:00:00 2001 From: Brian Vaughn Date: Mon, 17 Feb 2020 08:56:23 -0800 Subject: [PATCH 4/7] Typofix --- text/0000-use-mutable-source.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/text/0000-use-mutable-source.md b/text/0000-use-mutable-source.md index a5fa55c0..c5e75d84 100644 --- a/text/0000-use-mutable-source.md +++ b/text/0000-use-mutable-source.md @@ -57,7 +57,7 @@ const locationSource = createMutableSource(window, { // Because this method doesn't require access to props, // it can be declared in module scope to be shared between components. -const getSnapshot = window => win.location.pathname; +const getSnapshot = window => window.location.pathname; // This method can subscribe to root level change events, // or more snapshot-specific events. From fd8b9887b7f64ec4fad842bde066f87a9684bf4c Mon Sep 17 00:00:00 2001 From: Brian Vaughn Date: Thu, 20 Feb 2020 12:25:29 -0800 Subject: [PATCH 5/7] Updated RFC to include eager snapshot evaluation and other recent changes in implementation --- text/0000-use-mutable-source.md | 203 ++++++++++++++++++-------------- 1 file changed, 113 insertions(+), 90 deletions(-) diff --git a/text/0000-use-mutable-source.md b/text/0000-use-mutable-source.md index c5e75d84..df9308ab 100644 --- a/text/0000-use-mutable-source.md +++ b/text/0000-use-mutable-source.md @@ -10,38 +10,6 @@ This hook is designed to support a variety of mutable sources. Below are a few example cases. -### Redux stores - -`useMutableSource()` can be used with Redux stores: - -```js -// May be created in module scope, like context: -const reduxSource = createMutableSource( - store, - // Because the state is immutable, it can be used as the "version". - () => reduxStore.getState() -); - -// Redux state is already immutable, so it can be returned as-is. -// Like a Redux selector, this method could also return a filtered/derived value. -// -// Because this method doesn't require access to props, -// it can be declared in module scope to be shared between components. -const getSnapshot = store => store.getState(); - -// Redux subscribe method already returns an unsubscribe handler. -// -// Because this method doesn't require access to props, -// it can be declared in module scope to be shared between components. -const subscribe = (store, callback) => store.subscribe(callback); - -function Example() { - const state = useMutableSource(reduxSource, getSnapshot, subscribe); - - // ... -} -``` - ### Browser APIs `useMutableSource()` can also read from non traditional sources, e.g. the shared Location object, so long as they can be subscribed to and have a "version". @@ -67,9 +35,12 @@ const getSnapshot = window => window.location.pathname; // // Because this method doesn't require access to props, // it can be declared in module scope to be shared between components. -const subscribe = (window, callback) => { - window.addEventListener("popstate", callback); - return () => window.removeEventListener("popstate", callback); +const subscribe = (window, handleChange) => { + const onPopState = () => { + handleChange(window.location.pathname); + }; + window.addEventListener("popstate", onPopState); + return () => window.removeEventListener("popstate", onPopState); }; function Example() { @@ -89,19 +60,6 @@ const userDataSource = createMutableSource(store, { getVersion: () => data.version }); -// This method can subscribe to root level change events, -// or more snapshot-specific events. -// In this case, since Example is only reading the "friends" value, -// we only have to subscribe to a change in that value -// (e.g. a "friends" event) -// -// Because this method doesn't require access to props, -// it can be declared in module scope to be shared between components. -const subscribe = (data, callback) => { - data.addEventListener("friends", callback); - return () => data.removeEventListener("friends", callback); -}; - function Example({ onlyShowFamily }) { // Because the snapshot depends on props, it has to be created inline. // useCallback() memoizes the function though, @@ -116,11 +74,88 @@ function Example({ onlyShowFamily }) { [onlyShowFamily] ); + // This method can subscribe to root level change events, + // or more snapshot-specific events. + // In this case, since Example is only reading the "friends" value, + // we only have to subscribe to a change in that value + // (e.g. a "friends" event) + // + // Because the selector depends on props, + // the subscribe function needs to be defined inline as well. + const subscribe = useCallback( + (data, handleChange) => { + const onFriends = () => handleChange(getSnapshot(data)); + data.addEventListener("friends", onFriends); + return () => data.removeEventListener("friends", onFriends); + }, + [getSnapshot] + ); + const friendIDs = useMutableSource(userDataSource, getSnapshot, subscribe); // ... } +``` +### Redux stores + +Redux users would likely never use the `useMutableSource` hook directly. They would use a hook provided by Redux that uses `useMutableSource` internally. + +##### Mock Redux implementation +```js +// Somewhere, the Redux store needs to be wrapped in a mutable source object... +const mutableSource = createMutableSource( + reduxStore, + // Because the state is immutable, it can be used as the "version". + () => reduxStore.getState() +); + +// It would probably be shared via the Context API... +const MutableSourceContext = createContext(mutableSource); + +// Oversimplified example of how Redux could use the mutable source hook: +function useSelector(selector) { + const mutableSource = useContext(MutableSourceContext); + + const getSnapshot = useCallback( + store => selector(store.getState()), + [selector] + ); + + const subscribe = useCallback( + (store, handleChange) => { + return store.subscribe(() => { + // The store changed, so let's get an updated snapshot. + const newSnapshot = getSnapshot(store); + + // Tell React what the snapshot value is after the most recent store update. + // If it has not changed, React will not schedule any render work. + handleChange(newSnapshot); + }); + }, + [getSnapshot] + ); + + return useMutableSource(mutableSource, getSnapshot, subscribe); +} +``` + +#### Example user component code + +```js +import { useSelector } from "react-redux"; + +function Example() { + // The user-provided selector should be memoized with useCallback. + // This will prevent unnecessary re-subscriptions each update. + // This selector can also use e.g. props values if needed. + const memoizedSelector = useCallback(state => state.users, []); + + // The Redux hook will connect user code to useMutableSource. + const users = useSelector(memoizedSelector); + + // ... +} ``` ### Observables @@ -193,7 +228,10 @@ function createMutableSource( function useMutableSource( source: MutableSource, getSnapshot: (source: Source) => Snapshot, - subscribe: (source: Source, callback: Function) => () => void + subscribe: ( + source: Source, + handleChange: (snapshot: Snapshot) => void + ) => () => void ): Snapshot { // ... } @@ -210,7 +248,11 @@ Mutable source requires tracking two pieces of info at the module level: #### Version number -Tracking a source's version allows us to avoid tearing during a mount (before our component has subscribed to the source). Whenever a mounting component reads from a mutable source, this number should be checked to ensure that either (1) this is the first mounting component to read from the source during the current render or (2) the version number has not changed since the last read. A changed version number indicates a change in the underlying store data, which may result in a tear. +Tracking a source's version allows us to avoid tearing when reading from a source that a component has not yet subscribed to. + +In this case, the version should be checked to ensure that either: +1. This is the first mounting component to read from the source during the current render, or +2. The version number has not changed since the last read. (A changed version number indicates a change in the underlying store data, which may result in a tear.) Like Context, this hook should support multiple concurrent renderers (e.g. ReactDOM and ReactART, React Native and React Fabric). To support this, we will track two work-in-progress versions (one for a "primary" renderer and one for a "secondary" renderer). @@ -225,7 +267,9 @@ This value should be reset either when a renderer starts a new batch of work or #### Pending update expiration times -Tracking pending update times enables already mounted components to safely reuse cached snapshot values without tearing in order to support higher priority updates. During an update, if the current render’s expiration time is **≤** the stored expiration time for a source, it is safe to read new values from the source. Otherwise a cached snapshot value should be used temporarily1. +Tracking pending updates per source enables newly-mounting components to read without potentially conflicting with components that read from the same source during a previous render. + +During an update, if the current render’s expiration time is **≤** the stored expiration time for a source, it is safe to read new values from the source. Otherwise a cached snapshot value should be used temporarily1. When a root is committed, all pending expiration times that are **≤** the committed time can be discarded for that root. @@ -253,64 +297,43 @@ Although useful for updates, pending update expiration times are not sufficient The `useMutableSource()` hook’s memoizedState will need to track the following values: -- The user-provided config object (with getter functions). +- The user-provided `getSnapshot` and `subscribe` functions. - The latest (cached) snapshot value. - The mutable source itself (in order to detect if a new source is provided). -- A destroy function (to unsubscribe from a source) +- The (user-returned) unsubscribe function ### Scenarios to handle -#### Initial mount (before subscription) +#### Reading from a source before subscribing -When a component reads from a mutable source that it has not yet subscribed to1, React first checks to see if there are any pending updates for the source already scheduled on the current root. +When a component reads from a mutable source that it has not yet subscribed to1, React first checks the version number to see if anything else has read from this source during the current render. -- ✗ If there is a pending update and the current expiration time is **>** the pending time, the read is **not safe**. - - Throw and restart the render. -- If there are no pending updates, or if the current expiration time is **≤** the pending time, has the component already subscribed to this source? - - ✓ If yes, the read is **safe**. +- If there is a recorded version number (i.e. this is not the first read) does it match the source's current version? + - ✓ If both versions match, the read is **safe**. - Store the snapshot value on `memoizedState`. - - If no, the the read **may be safe**. + - ✗ If the version has changed, the read is **not safe**. + - Throw and restart the render. -For components that have not yet subscribed to their source, React reads the version of the source and compares it to the tracked work-in-progress version numbers. +If there is no version number, the the read **may be safe**. We'll need to next check pending updates for the source to determine this. -- ✓ If there is no recorded version, this is the first time the source has been used. The read is **safe**. - - Record the current version number (on the root) for later reads during mount. +- ✓ If there are no pending updates the read is **safe**. - Store the snapshot value on `memoizedState`. -- ✓ If the recorded version matches the store version used previously, the read is **safe**. + - Store the version number for subsequent reads during this render. +- ✓ If the current expiration time is **≤** the pending time, the read is **safe**. - Store the snapshot value on `memoizedState`. -- ✗ If the recorded version is different, the read is **not safe**. + - Store the version number for subsequent reads during this render. +- ✗ If the current expiration time is **>** the pending time, the read is **not safe**. - Throw and restart the render. -¹ This case could occur during a mount or an update (if a new mutable source was read from for the first time). - -#### Mutation - -React will subscribe to sources after commit so that it can schedule updates in response to mutations. When a mutation occurs1, React will calculate an expiration time for processing the change, and will: - -- Schedule an update for that expiration time. -- Update a root level entry for this source to specify the next scheduled expiration time. - - This enables us to avoid tearing within the root during subsequent renders. - -¹ Component subscriptions may only subscribe to parts of the external source they care about. Updates will only be scheduled for component’s whose subscriptions fire. +1 This case could occur during a mount or an update (if a new mutable source was read from for the first time). -#### Update (after subscription) +#### Reading from a source after subscription React will eventually re-render when a source is mutated, but it may also re-render for other reasons. Even in the event of a mutation, React may need to render a higher priority update before processing the mutation. In that case, it’s important that components do not read from a changed source since it may cause tearing. -In order to process updates safely, React will track pending root level expiration times per source. - -- ✓ If the current render’s expiration time is **≤** the stored expiration time for a source, it is **safe** to read. - - Store an updated snapshot value on `memoizedState`. -- If the current render expiration time is **>** than the root priority for a source, consider the config object. - - ✓ If the config object has not changed, we can re-use the **cached snapshot value**.1 - - ✗ If the config object has changed, the **cached snapshot is stale**. - - Throw and restart the render. - -¹ React will later re-render with new data, but it’s okay to use a cached value if the memoized config has not changed- because if the inputs haven’t changed, the output will not have changed. - -#### React render new subtree +In the event the a component renders again without its subscription firing (or as part of a high priority update that does not include the subscription change) it will typically be able to re-use the cached snapshot. -React may render a new subtree that reads from a source that was also used to render an existing part of the tree. The rules for this scenario is the same as the initial mount case described above. +The one case where this will not be possible is when the `getSnapshot` function has changed. Snapshot selectors that are dependent on `props` (or other component `state`) may change even if the underlying source has not changed. In that case, the cached snapshot is not safe to reuse, and `useMutableSource` will have to throw and restart the render. # Design constraints From b5cec6a40e4f39340d31025ff3f07c33dd488aae Mon Sep 17 00:00:00 2001 From: Brian Vaughn Date: Thu, 20 Feb 2020 14:18:24 -0800 Subject: [PATCH 6/7] Updated RFC to reflect that change handler no longer needs to pass new snapshot value to callback --- text/0000-use-mutable-source.md | 84 ++++++++++++--------------------- 1 file changed, 31 insertions(+), 53 deletions(-) diff --git a/text/0000-use-mutable-source.md b/text/0000-use-mutable-source.md index df9308ab..ca638c5f 100644 --- a/text/0000-use-mutable-source.md +++ b/text/0000-use-mutable-source.md @@ -16,12 +16,13 @@ This hook is designed to support a variety of mutable sources. Below are a few e ```js // May be created in module scope, like context: -const locationSource = createMutableSource(window, { +const locationSource = createMutableSource( + window, // Although not the typical "version", the href attribute is stable, // and will change whenever part of the Location changes, // so it's safe to use as a version. - getVersion: () => window.location.href -}); + () => window.location.href +); // Because this method doesn't require access to props, // it can be declared in module scope to be shared between components. @@ -35,12 +36,9 @@ const getSnapshot = window => window.location.pathname; // // Because this method doesn't require access to props, // it can be declared in module scope to be shared between components. -const subscribe = (window, handleChange) => { - const onPopState = () => { - handleChange(window.location.pathname); - }; - window.addEventListener("popstate", onPopState); - return () => window.removeEventListener("popstate", onPopState); +const subscribe = (window, callback) => { + window.addEventListener("popstate", callback); + return () => window.removeEventListener("popstate", callback); }; function Example() { @@ -56,17 +54,28 @@ Sometimes a state value is derived using component `props`. In this case, `useCa ```js // May be created in module scope, like context: -const userDataSource = createMutableSource(store, { - getVersion: () => data.version -}); +const userDataSource = createMutableSource(userData, () => userData.version); + +// This method can subscribe to root level change events, +// or more snapshot-specific events. +// In this case, since Example is only reading the "friends" value, +// we only have to subscribe to a change in that value +// (e.g. a "friends" event) +// +// Because this method doesn't require access to props, +// it can be declared in module scope to be shared between components. +const subscribe = (userData, callback) => { + userData.addEventListener("friends", callback); + return () => userData.removeEventListener("friends", callback); +}; function Example({ onlyShowFamily }) { // Because the snapshot depends on props, it has to be created inline. // useCallback() memoizes the function though, // which lets useMutableSource() know when it's safe to reuse a snapshot value. const getSnapshot = useCallback( - data => - data.friends + userData => + userData.friends .filter( friend => !onlyShowFamily || friend.relationshipType === "family" ) @@ -74,23 +83,6 @@ function Example({ onlyShowFamily }) { [onlyShowFamily] ); - // This method can subscribe to root level change events, - // or more snapshot-specific events. - // In this case, since Example is only reading the "friends" value, - // we only have to subscribe to a change in that value - // (e.g. a "friends" event) - // - // Because the selector depends on props, - // the subscribe function needs to be defined inline as well. - const subscribe = useCallback( - (data, handleChange) => { - const onFriends = () => handleChange(getSnapshot(data)); - data.addEventListener("friends", onFriends); - return () => data.removeEventListener("friends", onFriends); - }, - [getSnapshot] - ); - const friendIDs = useMutableSource(userDataSource, getSnapshot, subscribe); // ... @@ -113,28 +105,17 @@ const mutableSource = createMutableSource( // It would probably be shared via the Context API... const MutableSourceContext = createContext(mutableSource); +// Because this method doesn't require access to props, +// it can be declared in module scope to be shared between hooks. +const subscribe = (store, callback) => store.subscribe(callback); + // Oversimplified example of how Redux could use the mutable source hook: function useSelector(selector) { const mutableSource = useContext(MutableSourceContext); - const getSnapshot = useCallback( - store => selector(store.getState()), - [selector] - ); - - const subscribe = useCallback( - (store, handleChange) => { - return store.subscribe(() => { - // The store changed, so let's get an updated snapshot. - const newSnapshot = getSnapshot(store); - - // Tell React what the snapshot value is after the most recent store update. - // If it has not changed, React will not schedule any render work. - handleChange(newSnapshot); - }); - }, - [getSnapshot] - ); + const getSnapshot = useCallback(store => selector(store.getState()), [ + selector + ]); return useMutableSource(mutableSource, getSnapshot, subscribe); } @@ -228,10 +209,7 @@ function createMutableSource( function useMutableSource( source: MutableSource, getSnapshot: (source: Source) => Snapshot, - subscribe: ( - source: Source, - handleChange: (snapshot: Snapshot) => void - ) => () => void + subscribe: (source: Source, callback: () => void) => () => void ): Snapshot { // ... } From 99721c1c20a0166abf7fc35d17896aa83f98397e Mon Sep 17 00:00:00 2001 From: Brian Vaughn Date: Fri, 21 Feb 2020 08:06:07 -0800 Subject: [PATCH 7/7] Fixed typos --- text/0000-use-mutable-source.md | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/text/0000-use-mutable-source.md b/text/0000-use-mutable-source.md index ca638c5f..903278a1 100644 --- a/text/0000-use-mutable-source.md +++ b/text/0000-use-mutable-source.md @@ -30,9 +30,6 @@ const getSnapshot = window => window.location.pathname; // This method can subscribe to root level change events, // or more snapshot-specific events. -// In this case, since Example is only reading the "friends" value, -// we only have to subscribe to a change in that value -// (e.g. a "friends" event) // // Because this method doesn't require access to props, // it can be declared in module scope to be shared between components. @@ -79,7 +76,7 @@ function Example({ onlyShowFamily }) { .filter( friend => !onlyShowFamily || friend.relationshipType === "family" ) - .friends.map(friend => friend.id), + .map(friend => friend.id), [onlyShowFamily] );