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

Nesting Fixture #19531

Merged
merged 11 commits into from
Aug 7, 2020
Merged

Nesting Fixture #19531

merged 11 commits into from
Aug 7, 2020

Conversation

gaearon
Copy link
Collaborator

@gaearon gaearon commented Aug 5, 2020

This is a fixture that demonstrates a build and runtime setup for using two copies of React on the same page.

Note: this is usually a really bad idea. But in large legacy apps, sometimes the only alternative is to stop upgrading React altogether. This fixture is meant to demonstrate an alternative solution — partial (e.g. per-route) upgrades/holdouts.

demo

  • Hooks are working: People often struggle setting this up (Hooks + multiple instances of React #13991). In particular, the constraint is that the version of react seen by a component needs to be the same version of react that is seen by react-dom that rendered this component.

  • No bundler config tweaks: This is intentionally using an unejected CRA. I wanted to show an approach that should work regardless of your bundler (as long as it respects the Node resolution mechanism). Also works with both Yarn and npm.

  • Context bridge: This demo includes passing context through a cross-React bridge. This is vital in real apps. Without it, you can't use anything — even a router — in practice. This example includes React Router to show that. (It relies on a private API, but I'll add a scary comment, and I got an okay from @mjackson for this particular use case).

  • Code splitting: You pretty much never want to load two Reacts if it's avoidable. So ideally it would only be used for lazy-loaded code, e.g. a legacy popup dialog or a legacy route. To encourage this, I made a lazyLegacyRoot(() => import(...)) wrapper that is similar in spirit to lazy(). You use <Suspense> to show the loading state.

  • Code sharing: When you split code between legacy and modern folders, there will be a bunch of React code you want to share. But since we rely on Node resolution, we can't have the same files having different meaning of the react import depending on whether they were imported from src/modern or src/legacy. As a workaround, I added src/shared folder that is being watched. Every change in it gets replicated by a watcher into src/modern/shared and src/legacy/shared which are ignored in source control. This is pretty low-tech and the part I like about this setup the least. But it seems like that's also the part you can replace on your own if you're ready to fiddle with the configs. Note that this does mean this code gets duplicated between the two bundles. I don't have a good solution to that.

  • Third-party libraries: We have three package.json: one at the root for build tooling, one in src/modern/package.json and one in src/legacy/package.json. Reacts are defined in the nested ones. This is where you put third-party libraries that depend on React. The nested package.jsons may use different libraries or even different versions. Note that src/shared has no package.json, but files there can use third-party libs (else we wouldn't be able to share Hooks and Components). Again, this works because src/shared/* gets copied into shared subdirectories of both modern and legacy.

As an exercise and to verify this setup works, I converted a simple (but not trivial) app to this setup locally, extracting one of the component subtrees into legacy. The most frustrating part of this process was figuring out which code needs to be shared and which parts are not, and then changing a bunch of relative paths. The rest went fairly smoothly and the end result worked as soon as I bridged all contexts. So overall I think this works but maybe it could be more ergonomic. However, I still think requiring fiddling with config would make this example a lot less versatile and useful.

This repo in particular demonstrates a legacy app nested into a modern one. But we could swap folders and do the opposite. There's nothing in particular that enforces the direction.

Note: In React repo I notice an issue where npm start shows a bunch of ESLint warnings, but a second npm start works. I couldn't reproduce it outside of our repo so this seems like a CRA quirk that's unrelated to this demo.

@facebook-github-bot facebook-github-bot added CLA Signed React Core Team Opened by a member of the React Core Team labels Aug 5, 2020
@codesandbox-ci
Copy link

codesandbox-ci bot commented Aug 5, 2020

This pull request is automatically built and testable in CodeSandbox.

To see build info of the built libraries, click here or the icon next to each commit SHA.

Latest deployment of this branch, based on commit 6b95f87:

Sandbox Source
React Configuration

@sizebot
Copy link

sizebot commented Aug 5, 2020

No significant bundle size changes to report.

Size changes (stable)

Generated by 🚫 dangerJS against 6b95f87

@sizebot
Copy link

sizebot commented Aug 5, 2020

No significant bundle size changes to report.

Size changes (experimental)

Generated by 🚫 dangerJS against 6b95f87

@@ -0,0 +1 @@
src/*/node_modules
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 is awkward. I wish it just worked in CRA, but it doesn't.


# copies of shared
src/*/shared
src/*/node_modules
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 is the new part compared to vanilla CRA.

import ThemeContext from './shared/ThemeContext';
import withLegacyRoot from './withLegacyRoot';

const Greeting = withLegacyRoot(() => import('../legacy/Greeting'));
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 is the API.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

(If you build in prod mode, webpack puts the second React in a chunk, as expected.)

}
);
}
throw record.promise;
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

So.. This is Suspense. I wanted to use lazy but that doesn't work because the component is going to be rendered by the other React but we want this React to wait. And we can't suspend in the commit phase.

I don't know if this is bad. It seems like it's part of the contract anyway and there are fairly major libs relying on it. Including Loadable Components. So we might as well use it in an example?

The benefit is that I think this pretty strongly encourages you to code-split the other React.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

We could also tuck it into a library but seems nice to have a full view of the setup for customization.

import {useState, useEffect} from 'react';

export default function useTimer() {
const [value, setValue] = useState(() => new Date());
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 is just to show that Hooks work in "shared" code.


const theme = useContext(ThemeContext);
const router = useContext(__RouterContext);
const context = useMemo(
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Context collected to be bridged.

);
}

export default function createLegacyRoot(container) {
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 intentionally mirrors createRoot so it's easier to transition a file like this to it when it exists — for people bridging in the reverse direction.

@bvaughn
Copy link
Contributor

bvaughn commented Aug 5, 2020

Nice! This mostly even works with DevTools. (The "inspect" button doesn't work for the inner React but that's probably fine.

@mjackson
Copy link
Contributor

mjackson commented Aug 5, 2020

Just a quick note here (already spoke with @gaearon about this on Twitter DM): This won't work with React Router v6+ because we don't expose context at all in the react-router package. The reason we did it in v5 was because changes in the context API required us to maintain a reference to the context object. But v6 uses hooks to access things on context instead of exposing the object itself.

Copy link
Member

@rickhanlonii rickhanlonii left a comment

Choose a reason for hiding this comment

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

LGTM, this is a nice way to brute force it to work generically.

There's gotta be a way to do this without copying the shared files so you can hook it up in place, but that would probably require a deeper integration into the build system or environment.

Also, I added a README 🚀

fixtures/nesting/src/modern/lazyLegacyRoot.js Outdated Show resolved Hide resolved
fixtures/nesting/src/modern/lazyLegacyRoot.js Outdated Show resolved Hide resolved
fixtures/nesting/src/modern/lazyLegacyRoot.js Show resolved Hide resolved
fixtures/nesting/package.json Show resolved Hide resolved
@rickhanlonii
Copy link
Member

How do you expect the nested root to unmount?

The more I toggle the about page, the more roots are created:

Screen Shot 2020-08-06 at 1 35 56 AM

@sebmarkbage
Copy link
Collaborator

Where do I put a Flux store that is used by both legacy and modern? It's only ok if it doesn't any have dependencies on React.

@gaearon
Copy link
Collaborator Author

gaearon commented Aug 6, 2020

You'd put it above. (But yes, that presumes no dependency on React.) Why would a Flux store depend on React?

@sebmarkbage
Copy link
Collaborator

Maybe we should add a Flux store to demonstrate that? Seems non-obvious since it seems like you could put it in shared but that won't work.

@gaearon
Copy link
Collaborator Author

gaearon commented Aug 6, 2020

Does Redux count as Flux? :-)

@sebmarkbage
Copy link
Collaborator

Does Redux count as Flux? :-)

Yes. A wolf in sheep's clothing is still a wolf.

@sebmarkbage
Copy link
Collaborator

It might be good to try this with a build of React that renames all the internal fields and symbols to make sure it actually works with a different implementation.

gaearon and others added 2 commits August 6, 2020 17:39
Co-authored-by: Ricky <rickhanlonii@gmail.com>
@gaearon
Copy link
Collaborator Author

gaearon commented Aug 6, 2020

The more I toggle the about page, the more roots are created:

Yeah I messed it up. Should be fixed.

@gaearon gaearon force-pushed the nesting branch 2 times, most recently from 35c4791 to aaac26f Compare August 6, 2020 21:22
@gaearon
Copy link
Collaborator Author

gaearon commented Aug 6, 2020

Added Redux to the example. Tested with a version that has different Symbols and internal Fiber field names.

@gaearon gaearon merged commit 3367298 into facebook:master Aug 7, 2020
@gaearon gaearon deleted the nesting branch August 7, 2020 01:18
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.

7 participants