Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Initial shim of useSyncExternalStore #22211

Merged

Conversation

acdlite
Copy link
Collaborator

@acdlite acdlite commented Aug 30, 2021

This sets up an initial shim implementation of useSyncExternalStore, via the use-sync-external-store package. It's designed to mimic the behavior of the built-in API, but is backwards compatible to any version of React that supports hooks.

I have not yet implemented the built-in API, but once it exists, the use-sync-external-store package will always prefer that one. Library authors can depend on the shim and trust that their users get the correct implementation.

See reactwg/react-18#86 for background on the API.

The tests I've added here are designed to run against both the shim and built-in implementation, using our variant test flag feature. Tests that only apply to concurrent roots will live in a separate suite.

Things to consider before landing:

  • Decide on API for hydration (getSSRSnapshot). Probably should be a required argument.
  • What should we call the hook that adds supports for isEqual? I called it useSyncExternalStoreExtra as a placeholder.

@facebook-github-bot facebook-github-bot added CLA Signed React Core Team Opened by a member of the React Core Team labels Aug 30, 2021
@acdlite acdlite force-pushed the initial-use-sync-external-store-shim branch 3 times, most recently from a3000c5 to 44b073b Compare August 30, 2021 14:29
@sizebot
Copy link

sizebot commented Aug 30, 2021

Comparing: 3385b37...505c398

Critical size changes

Includes critical production bundles, as well as any change greater than 2%:

Name +/- Base Current +/- gzip Base gzip Current gzip
oss-stable/react-dom/cjs/react-dom.production.min.js = 127.60 kB 127.60 kB = 40.73 kB 40.73 kB
oss-experimental/react-dom/cjs/react-dom.production.min.js = 130.42 kB 130.42 kB = 41.66 kB 41.66 kB
facebook-www/ReactDOM-prod.classic.js = 405.18 kB 405.18 kB = 75.05 kB 75.05 kB
facebook-www/ReactDOM-prod.modern.js = 393.75 kB 393.75 kB = 73.33 kB 73.33 kB
facebook-www/ReactDOMForked-prod.classic.js = 405.18 kB 405.18 kB = 75.05 kB 75.05 kB
oss-experimental/use-sync-external-store/cjs/use-sync-external-store-extra.development.js +∞% 0.00 kB 3.07 kB +∞% 0.00 kB 1.27 kB
oss-experimental/use-sync-external-store/cjs/use-sync-external-store-extra.production.min.js +∞% 0.00 kB 0.76 kB +∞% 0.00 kB 0.47 kB
oss-experimental/use-sync-external-store/extra.js +∞% 0.00 kB 0.24 kB +∞% 0.00 kB 0.16 kB
oss-stable-semver/use-sync-external-store/cjs/use-sync-external-store-extra.development.js +∞% 0.00 kB 3.07 kB +∞% 0.00 kB 1.27 kB
oss-stable-semver/use-sync-external-store/cjs/use-sync-external-store-extra.production.min.js +∞% 0.00 kB 0.76 kB +∞% 0.00 kB 0.47 kB
oss-stable-semver/use-sync-external-store/extra.js +∞% 0.00 kB 0.24 kB +∞% 0.00 kB 0.16 kB
oss-stable/use-sync-external-store/cjs/use-sync-external-store-extra.development.js +∞% 0.00 kB 3.07 kB +∞% 0.00 kB 1.27 kB
oss-stable/use-sync-external-store/cjs/use-sync-external-store-extra.production.min.js +∞% 0.00 kB 0.76 kB +∞% 0.00 kB 0.47 kB
oss-stable/use-sync-external-store/extra.js +∞% 0.00 kB 0.24 kB +∞% 0.00 kB 0.16 kB
oss-experimental/use-sync-external-store/cjs/use-sync-external-store.development.js +1,285.22% 0.49 kB 6.84 kB +779.57% 0.32 kB 2.84 kB
oss-stable-semver/use-sync-external-store/cjs/use-sync-external-store.development.js +1,285.22% 0.49 kB 6.84 kB +779.57% 0.32 kB 2.84 kB
oss-stable/use-sync-external-store/cjs/use-sync-external-store.development.js +1,285.22% 0.49 kB 6.84 kB +779.57% 0.32 kB 2.84 kB
oss-experimental/use-sync-external-store/cjs/use-sync-external-store.production.min.js +147.46% 0.35 kB 0.88 kB +95.47% 0.27 kB 0.52 kB
oss-stable-semver/use-sync-external-store/cjs/use-sync-external-store.production.min.js +147.46% 0.35 kB 0.88 kB +95.47% 0.27 kB 0.52 kB
oss-stable/use-sync-external-store/cjs/use-sync-external-store.production.min.js +147.46% 0.35 kB 0.88 kB +95.47% 0.27 kB 0.52 kB

Significant size changes

Includes any change greater than 0.2%:

Expand to show
Name +/- Base Current +/- gzip Base gzip Current gzip
oss-experimental/use-sync-external-store/cjs/use-sync-external-store-extra.development.js +∞% 0.00 kB 3.07 kB +∞% 0.00 kB 1.27 kB
oss-experimental/use-sync-external-store/cjs/use-sync-external-store-extra.production.min.js +∞% 0.00 kB 0.76 kB +∞% 0.00 kB 0.47 kB
oss-experimental/use-sync-external-store/extra.js +∞% 0.00 kB 0.24 kB +∞% 0.00 kB 0.16 kB
oss-stable-semver/use-sync-external-store/cjs/use-sync-external-store-extra.development.js +∞% 0.00 kB 3.07 kB +∞% 0.00 kB 1.27 kB
oss-stable-semver/use-sync-external-store/cjs/use-sync-external-store-extra.production.min.js +∞% 0.00 kB 0.76 kB +∞% 0.00 kB 0.47 kB
oss-stable-semver/use-sync-external-store/extra.js +∞% 0.00 kB 0.24 kB +∞% 0.00 kB 0.16 kB
oss-stable/use-sync-external-store/cjs/use-sync-external-store-extra.development.js +∞% 0.00 kB 3.07 kB +∞% 0.00 kB 1.27 kB
oss-stable/use-sync-external-store/cjs/use-sync-external-store-extra.production.min.js +∞% 0.00 kB 0.76 kB +∞% 0.00 kB 0.47 kB
oss-stable/use-sync-external-store/extra.js +∞% 0.00 kB 0.24 kB +∞% 0.00 kB 0.16 kB
oss-experimental/use-sync-external-store/cjs/use-sync-external-store.development.js +1,285.22% 0.49 kB 6.84 kB +779.57% 0.32 kB 2.84 kB
oss-stable-semver/use-sync-external-store/cjs/use-sync-external-store.development.js +1,285.22% 0.49 kB 6.84 kB +779.57% 0.32 kB 2.84 kB
oss-stable/use-sync-external-store/cjs/use-sync-external-store.development.js +1,285.22% 0.49 kB 6.84 kB +779.57% 0.32 kB 2.84 kB
oss-experimental/use-sync-external-store/cjs/use-sync-external-store.production.min.js +147.46% 0.35 kB 0.88 kB +95.47% 0.27 kB 0.52 kB
oss-stable-semver/use-sync-external-store/cjs/use-sync-external-store.production.min.js +147.46% 0.35 kB 0.88 kB +95.47% 0.27 kB 0.52 kB
oss-stable/use-sync-external-store/cjs/use-sync-external-store.production.min.js +147.46% 0.35 kB 0.88 kB +95.47% 0.27 kB 0.52 kB

Generated by 🚫 dangerJS against 505c398

@acdlite acdlite force-pushed the initial-use-sync-external-store-shim branch 8 times, most recently from 365cee7 to 27f0d23 Compare August 31, 2021 02:10
// When we initially subscribe, we need to check if there was a mutation in
// between render and commit. If there was, we may need to re-render.
// Subsequent changes will be detected in the subscription handler.
const valueDuringRender = value;
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

This shim doesn't need to support strict effects because that doesn't exist in versions <18.

However, if it did, then this value would represent the value during the initial subscribe, but during a "reconnect" (e.g. Offscreen going from hidden back to visible) it needs to represent the rendered value when we disconnected.

So, it should read from the ref, like we do in the subscription handler.

Same logic applies to getSnapshot.

So I think that means we can run exactly the same code when checking for tearing on subscribe and when receiving a subscription event.

inst.value = nextValue;
setVersion(bumpVersion);
}
} catch (error) {
Copy link
Collaborator

Choose a reason for hiding this comment

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

What errors do we expect here? Is it proper error or also errors that happen due to tearing that can be fixed? Should this intentionally silence errors or no?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Since we also schedule an update, it will throw again during render so you can catch it with an error boundary. Same thing that we do for reducers.

}

// Subscribe to the store and return a clean-up function.
return subscribe(handleStoreChange);
Copy link
Collaborator

Choose a reason for hiding this comment

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

This should move to useEffect.

// directly in render.
//
// We force a re-render by bumping a version number.
const [, setVersion] = useState(0);
Copy link
Collaborator

Choose a reason for hiding this comment

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

If we stored the snapshot in the state, and didn't use it for anything else. That would still enable certain types of bailouts to happen in React itself. Would that lead to the correct behavior? Would it give us any benefits?

It at least avoids the possibility of something updating more than long times (highly unlikely). :D

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Yeah that makes sense. I was thinking we could also enable lazy bailouts by using an inline reducer. If during render we detect that the state hasn’t changed, return the previous state. Otherwise return a new one. Could flip a Boolean back and forth.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I don’t think it can literally be the snapshot though since the subscription could change and then you have to disregard the queued updates some how.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Or rather, it might bail out incorrectly and not force an update.

@acdlite acdlite force-pushed the initial-use-sync-external-store-shim branch 2 times, most recently from 7b44303 to 9e9df92 Compare September 1, 2021 22:48
This sets up an initial shim implementation of useSyncExternalStore,
via the use-sync-external-store package. It's designed to mimic the
behavior of the built-in API, but is backwards compatible to any version
of React that supports hooks.

I have not yet implemented the built-in API, but once it exists, the
use-sync-external-store package will always prefer that one. Library
authors can depend on the shim and trust that their users get the
correct implementation.

See reactwg/react-18#86 for background
on the API.

The tests I've added here are designed to run against both the shim and
built-in implementation, using our variant test flag feature. Tests that
only apply to concurrent roots will live in a separate suite.
@acdlite acdlite force-pushed the initial-use-sync-external-store-shim branch from 9e9df92 to 505c398 Compare September 2, 2021 00:42
@acdlite acdlite merged commit 1314299 into facebook:main Sep 2, 2021
@markerikson
Copy link
Contributor

One question: how does the extra version fit in, exactly? I see that it adds the additional isEqual and selector args - is 18 itself going to have that also, or is this purely a convenience function along the lines of "y'all could have and were going to have to build this yourself anyway, I threw it in for free while I was working on this"?

@acdlite
Copy link
Collaborator Author

acdlite commented Sep 2, 2021

Yeah since it can be implemented pretty easily in userspace, we're not planning to add it to the built-in API. That way for cases where a custom isEqual isn't needed, we don't have to use the additional memory.

But since we expect it will be a frequent request, we went ahead and implemented our own.

If you don't need a custom isEqual, you can put your selector directly inside getSnapshot.

Another alternative to isEqual is to subscribe separately to individual fields. In other words, either of these work:

// Option 1: Separate subscription per field
const a = useSyncExternalStore(store.subscribe, () => store.getState().a);
const b = useSyncExternalStore(store.subscribe, () => store.getState().b);

// Option 2: One subscription for multiple fields
const {a, b} = useSyncExternalStoreExtra(
  store.subscribe,
  store.getState,
  state => ({ a: state.a, b: state.b }),
  shallowEqual,
);

The "extra" naming is a placeholder, not sure what we'll end up calling it.

@dai-shi
Copy link
Contributor

dai-shi commented Sep 2, 2021

it can be implemented pretty easily in userspace

If this is true for both react 17 and 18, "extra" part doesn't have to be in use-sync-external-store package, and lib authors can do the same.

If you don't need a custom isEqual, you can put your selector directly inside getSnapshot.

I can imagine some use cases fall into this pattern. But, the pure shim is not exported in use-sync-external-store package, is it? My bad, it is.

@markerikson
Copy link
Contributor

markerikson commented Sep 4, 2021

I'm starting to play around with this for React-Redux v8 right now. I'll leave a couple notes here for lack of somewhere better, and I can create a new issue if desired:

  • React-Redux currently catches errors thrown from selectors inside of subscriptions, saves them in a ref, and forces a re-render. If the selector runs clean while rendering, no problem. If it throws again, we attach the original error's message to the new error just in case it was two different things that happened. However, uSES always swallows errors in checkIfSnapshotChanged, so we can no longer check the original error ourselves. I was able to work around this by having useSelector wrap the original selector with one that does a try/catch and saves errors to a ref, but wow is that hacky.
  • Is the "not a valid React 18 version" check accidentally inverted? It currently says if (React.startTransition !== undefined) {, which seems wrong. I'm currently running 18.0.0-alpha-1314299c7-20210901, which most definitely has a startTransition function defined, and I am seeing that warning printed.
  • Our test for "checks for updates when subscribing and re-renders if different" is seeing a third render occur, when it used to only see two, and I can't figure out what's causing that extra render to happen (especially since renders 2 and 3 are seeing the same selected state). My vague hypothesis is that it may have something to do with the test dispatching an action in a useLayoutEffect in a child, while the subscription happens in a useEffect in the hook in the parent?
  • The way that useSyncExternalStoreExtra's memoization check is implemented means that if you get a new selector function, there's no way for it to be equal to the prior result and reuse it, because all of the memoization variables are internal to the useMemo hook. Our v7 useSelector does this behavior by keeping around the last selected value in a useRef. We have a test for that in our codebase, and it's failing atm, and I'm pretty sure this is a "correct" failure based on what I'm seeing in the uSES implementation.
        it('reuse latest selected state on selector re-run', () => {
          const alwaysEqual = () => true

          const Comp = () => {
            // triggers render on store change
            useNormalSelector((s) => s.count)
            const array = useSelector(() => [1, 2, 3], alwaysEqual)
            useLayoutEffect(() => {
              renderedItems.push(array)
            })
            return <div />
          }

          rtl.render(
            <ProviderMock store={normalStore}>
              <Comp />
            </ProviderMock>
          )

          expect(renderedItems.length).toBe(1)

          act(() => {
            normalStore.dispatch({ type: '' })
          })

          expect(renderedItems.length).toBe(2)
          expect(renderedItems[0]).toBe(renderedItems[1])
        })

If anyone's interested, here's my current PR:

reduxjs/react-redux#1808

and specific current commit is reduxjs/react-redux@8da5607

facebook-github-bot pushed a commit to facebook/react-native that referenced this pull request Sep 11, 2021
Summary:
This sync includes the following changes:
- **[95d762e40](facebook/react@95d762e40 )**: Remove duplicate test //<Andrew Clark>//
- **[d4d1dc085](facebook/react@d4d1dc085 )**: Reorder VARIANT feature flags ([#22266](facebook/react#22266)) //<Dan Abramov>//
- **[2f156eafb](facebook/react@2f156eafb )**: Adjust consoleManagedByDevToolsDuringStrictMode feature flag ([#22253](facebook/react#22253)) //<Dan Abramov>//
- **[cfd819332](facebook/react@cfd819332 )**: Add useSyncExternalStore to react-debug-tools ([#22240](facebook/react#22240)) //<Andrew Clark>//
- **[8e80592a3](facebook/react@8e80592a3 )**: Remove state queue from useSyncExternalStore ([#22265](facebook/react#22265)) //<Andrew Clark>//
- **[06f98c168](facebook/react@06f98c168 )**: Implement useSyncExternalStore in Fiber ([#22239](facebook/react#22239)) //<Andrew Clark>//
- **[77912d9a0](facebook/react@77912d9a0 )**: Wire up the native API for useSyncExternalStore ([#22237](facebook/react#22237)) //<Andrew Clark>//
- **[031abd24b](facebook/react@031abd24b )**: Add warning and test for useSyncExternalStore when getSnapshot isn't cached ([#22262](facebook/react#22262)) //<salazarm>//
- **[b8884de24](facebook/react@b8884de24 )**: break up import keyword to avoid being accidentally parsed as dynamic import statement in external code ([#21918](facebook/react#21918)) //<Jianhua Zheng>//
- **[6d6bba5bf](facebook/react@6d6bba5bf )**: Fix typo in ReactUpdatePriority-test.js ([#21958](facebook/react#21958)) //<Ikko Ashimine>//
- **[0c0d1ddae](facebook/react@0c0d1ddae )**: feat(eslint-plugin-react-hooks): support ESLint 8.x ([#22248](facebook/react#22248)) //<Michaël De Boey>//
- **[1314299c7](facebook/react@1314299c7 )**: Initial shim of useSyncExternalStore ([#22211](facebook/react#22211)) //<Andrew Clark>//
- **[fc40f02ad](facebook/react@fc40f02ad )**: Add consoleManagedByDevToolsDuringStrictMode feature flag in React Reconciler ([#22196](facebook/react#22196)) //<Luna Ruan>//
- **[46a0f050a](facebook/react@46a0f050a )**: Set up use-sync-external-store package ([#22202](facebook/react#22202)) //<Andrew Clark>//
- **[8723e772b](facebook/react@8723e772b )**: Fix a string interpolation typo in ReactHooks test ([#22174](facebook/react#22174)) //<Matt Hargett>//
- **[60a30cf32](facebook/react@60a30cf32 )**: Console Logging for StrictMode Double Rendering ([#22030](facebook/react#22030)) //<Luna Ruan>//
- **[76bbad3e3](facebook/react@76bbad3e3 )**: Add maxYieldMs feature flag in Scheduler ([#22165](facebook/react#22165)) //<Ricky>//
- **[b0b53ae2c](facebook/react@b0b53ae2c )**: Add feature flags for scheduler experiments ([#22105](facebook/react#22105)) //<Ricky>//

Changelog:
[General][Changed] - React Native sync for revisions bd5bf55...95d762e

jest_e2e[run_all_tests]

Reviewed By: mdvacca

Differential Revision: D30809906

fbshipit-source-id: 131cfdf91e15f67fa59a5d925467e538ee89fe10
@gaearon gaearon mentioned this pull request Mar 29, 2022
zhengjitf pushed a commit to zhengjitf/react that referenced this pull request Apr 15, 2022
This sets up an initial shim implementation of useSyncExternalStore,
via the use-sync-external-store package. It's designed to mimic the
behavior of the built-in API, but is backwards compatible to any version
of React that supports hooks.

I have not yet implemented the built-in API, but once it exists, the
use-sync-external-store package will always prefer that one. Library
authors can depend on the shim and trust that their users get the
correct implementation.

See reactwg/react-18#86 for background
on the API.

The tests I've added here are designed to run against both the shim and
built-in implementation, using our variant test flag feature. Tests that
only apply to concurrent roots will live in a separate suite.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
CLA Signed React Core Team Opened by a member of the React Core Team
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

6 participants