diff --git a/.changeset/serious-rivers-rest.md b/.changeset/serious-rivers-rest.md new file mode 100644 index 0000000000..d72f2ac912 --- /dev/null +++ b/.changeset/serious-rivers-rest.md @@ -0,0 +1,7 @@ +--- +"react-router-dom": patch +"react-router": patch +"@remix-run/router": patch +--- + +[REMOVE] Refactor internals for partial hydration diff --git a/package.json b/package.json index add36b5a32..a87b49acfe 100644 --- a/package.json +++ b/package.json @@ -110,19 +110,19 @@ }, "filesize": { "packages/router/dist/router.umd.min.js": { - "none": "50.1 kB" + "none": "50.4 kB" }, "packages/react-router/dist/react-router.production.min.js": { - "none": "14.6 kB" + "none": "14.7 kB" }, "packages/react-router/dist/umd/react-router.production.min.js": { - "none": "17.0 kB" + "none": "17.1 kB" }, "packages/react-router-dom/dist/react-router-dom.production.min.js": { - "none": "16.8 kB" + "none": "16.9 kB" }, "packages/react-router-dom/dist/umd/react-router-dom.production.min.js": { - "none": "23.0 kB" + "none": "23.1 kB" } } } diff --git a/packages/react-router-dom/__tests__/data-browser-router-test.tsx b/packages/react-router-dom/__tests__/data-browser-router-test.tsx index 16b195c9ef..9a3571fdf5 100644 --- a/packages/react-router-dom/__tests__/data-browser-router-test.tsx +++ b/packages/react-router-dom/__tests__/data-browser-router-test.tsx @@ -7464,419 +7464,6 @@ function testDomRouter( expect(spy).toHaveBeenCalledTimes(2); }); }); - - // TODO: Probably want these running against RouterProvider in react-router too? - // Look into extracting the setState stuff and sharing the subscriber, - // layout effect, navigator, render stuff - describe("partial hydration", () => { - it("does not handle partial hydration by default", async () => { - let router = createTestRouter( - [ - { - id: "root", - path: "/", - loader: () => "ROOT", - Component() { - let data = useLoaderData() as string; - return ( -
-

{`Home - ${data}`}

- -
- ); - }, - children: [ - { - id: "index", - index: true, - loader: () => "INDEX", - HydrateFallback: () =>

Should not see me

, - Component() { - let data = useLoaderData() as string; - return

{`Index - ${data}`}

; - }, - }, - ], - }, - ], - { - window: getWindow("/"), - hydrationData: { - loaderData: { - root: "HYDRATED ROOT", - }, - }, - } - ); - let { container } = render(); - - expect(getHtml(container)).toMatchInlineSnapshot(` - "
-
-

- Home - HYDRATED ROOT -

-

- Index - undefined -

-
-
" - `); - }); - - it("supports partial hydration w/leaf fallback", async () => { - let dfd = createDeferred(); - let router = createTestRouter( - [ - { - id: "root", - path: "/", - loader: () => "ROOT", - Component() { - let data = useLoaderData() as string; - return ( -
-

{`Home - ${data}`}

- -
- ); - }, - children: [ - { - id: "index", - index: true, - loader: () => dfd.promise, - HydrateFallback: () =>

Index Loading...

, - Component() { - let data = useLoaderData() as string; - return

{`Index - ${data}`}

; - }, - }, - ], - }, - ], - { - window: getWindow("/"), - hydrationData: { - loaderData: { - root: "HYDRATED ROOT", - }, - }, - future: { - v7_partialHydration: true, - }, - } - ); - let { container } = render(); - - expect(getHtml(container)).toMatchInlineSnapshot(` - "
-
-

- Home - HYDRATED ROOT -

-

- Index Loading... -

-
-
" - `); - - dfd.resolve("INDEX DATA"); - await waitFor(() => screen.getByText(/INDEX DATA/)); - expect(getHtml(container)).toMatchInlineSnapshot(` - "
-
-

- Home - HYDRATED ROOT -

-

- Index - INDEX DATA -

-
-
" - `); - }); - - it("supports partial hydration w/root fallback", async () => { - let dfd = createDeferred(); - let router = createTestRouter( - [ - { - id: "root", - path: "/", - loader: () => "ROOT", - HydrateFallback: () =>

Root Loading...

, - Component() { - let data = useLoaderData() as string; - return ( -
-

{`Home - ${data}`}

- -
- ); - }, - children: [ - { - id: "index", - index: true, - loader: () => dfd.promise, - Component() { - let data = useLoaderData() as string; - return

{`Index - ${data}`}

; - }, - }, - ], - }, - ], - { - window: getWindow("/"), - hydrationData: { - loaderData: { - root: "HYDRATED ROOT", - }, - }, - future: { - v7_partialHydration: true, - }, - } - ); - let { container } = render(); - - expect(getHtml(container)).toMatchInlineSnapshot(` - "
-

- Root Loading... -

-
" - `); - - dfd.resolve("INDEX DATA"); - await waitFor(() => screen.getByText(/INDEX DATA/)); - expect(getHtml(container)).toMatchInlineSnapshot(` - "
-
-

- Home - HYDRATED ROOT -

-

- Index - INDEX DATA -

-
-
" - `); - }); - - it("supports partial hydration w/no fallback", async () => { - let dfd = createDeferred(); - let router = createTestRouter( - [ - { - id: "root", - path: "/", - loader: () => "ROOT", - Component() { - let data = useLoaderData() as string; - return ( -
-

{`Home - ${data}`}

- -
- ); - }, - children: [ - { - id: "index", - index: true, - loader: () => dfd.promise, - Component() { - let data = useLoaderData() as string; - return

{`Index - ${data}`}

; - }, - }, - ], - }, - ], - { - window: getWindow("/"), - hydrationData: { - loaderData: { - root: "HYDRATED ROOT", - }, - }, - future: { - v7_partialHydration: true, - }, - } - ); - let { container } = render(); - - expect(getHtml(container)).toMatchInlineSnapshot(`"
"`); - - dfd.resolve("INDEX DATA"); - await waitFor(() => screen.getByText(/INDEX DATA/)); - expect(getHtml(container)).toMatchInlineSnapshot(` - "
-
-

- Home - HYDRATED ROOT -

-

- Index - INDEX DATA -

-
-
" - `); - }); - - it("deprecates fallbackElement", async () => { - let dfd1 = createDeferred(); - let dfd2 = createDeferred(); - let router = createTestRouter( - [ - { - id: "root", - path: "/", - loader: () => dfd1.promise, - HydrateFallback: () =>

Root Loading...

, - Component() { - let data = useLoaderData() as string; - return ( -
-

{`Home - ${data}`}

- -
- ); - }, - children: [ - { - id: "index", - index: true, - loader: () => dfd2.promise, - Component() { - let data = useLoaderData() as string; - return

{`Index - ${data}`}

; - }, - }, - ], - }, - ], - { - window: getWindow("/"), - hydrationData: { - loaderData: { - root: "HYDRATED ROOT", - }, - }, - future: { - v7_partialHydration: true, - }, - } - ); - let { container } = render( - fallbackElement...

} - /> - ); - - expect(getHtml(container)).toMatchInlineSnapshot(` - "
-

- Root Loading... -

-
" - `); - - expect(consoleWarn).toHaveBeenCalledWith( - "`` is deprecated when using `v7_partialHydration`" - ); - - dfd1.resolve("ROOT DATA"); - dfd2.resolve("INDEX DATA"); - await waitFor(() => screen.getByText(/INDEX DATA/)); - expect(getHtml(container)).toMatchInlineSnapshot(` - "
-
-

- Home - HYDRATED ROOT -

-

- Index - INDEX DATA -

-
-
" - `); - }); - - it("does not re-run loaders that don't have loader data due to errors", async () => { - let spy = jest.fn(); - let router = createTestRouter( - [ - { - id: "root", - path: "/", - loader: () => "ROOT", - Component() { - let data = useLoaderData() as string; - return ( -
-

{`Home - ${data}`}

- -
- ); - }, - children: [ - { - id: "index", - index: true, - loader: spy, - HydrateFallback: () =>

Index Loading...

, - Component() { - let data = useLoaderData() as string; - return

{`Index - ${data}`}

; - }, - ErrorBoundary() { - let error = useRouteError() as string; - return

{error}

; - }, - }, - ], - }, - ], - { - window: getWindow("/"), - hydrationData: { - loaderData: { - root: "HYDRATED ROOT", - }, - errors: { - index: "INDEX ERROR", - }, - }, - future: { - v7_partialHydration: true, - }, - } - ); - let { container } = render(); - - expect(getHtml(container)).toMatchInlineSnapshot(` - "
-
-

- Home - HYDRATED ROOT -

-

- INDEX ERROR -

-
-
" - `); - - expect(spy).not.toHaveBeenCalled(); - }); - }); }); } diff --git a/packages/react-router-dom/__tests__/partial-hydration-test.tsx b/packages/react-router-dom/__tests__/partial-hydration-test.tsx new file mode 100644 index 0000000000..49bfe12f8f --- /dev/null +++ b/packages/react-router-dom/__tests__/partial-hydration-test.tsx @@ -0,0 +1,524 @@ +import "@testing-library/jest-dom"; +import { render, screen, waitFor } from "@testing-library/react"; +import * as React from "react"; +import type { LoaderFunction } from "react-router"; +import { RouterProvider as ReactRouter_RouterPRovider } from "react-router"; +import { + Outlet, + RouterProvider as ReactRouterDom_RouterProvider, + createBrowserRouter, + createHashRouter, + createMemoryRouter, + useLoaderData, + useRouteError, +} from "react-router-dom"; + +import getHtml from "../../react-router/__tests__/utils/getHtml"; +import { createDeferred } from "../../router/__tests__/utils/utils"; + +let didAssertMissingHydrateFallback = false; + +describe("v7_partialHydration", () => { + describe("createBrowserRouter", () => { + testPartialHydration(createBrowserRouter, ReactRouterDom_RouterProvider); + }); + + describe("createHashRouter", () => { + testPartialHydration(createHashRouter, ReactRouterDom_RouterProvider); + }); + + describe("createMemoryRouter", () => { + testPartialHydration(createMemoryRouter, ReactRouter_RouterPRovider); + }); +}); + +function testPartialHydration( + createTestRouter: + | typeof createBrowserRouter + | typeof createHashRouter + | typeof createMemoryRouter, + RouterProvider: + | typeof ReactRouterDom_RouterProvider + | typeof ReactRouter_RouterPRovider +) { + let consoleWarn: jest.SpyInstance; + + beforeEach(() => { + consoleWarn = jest.spyOn(console, "warn").mockImplementation(() => {}); + }); + + afterEach(() => { + consoleWarn.mockRestore(); + }); + + it("does not handle partial hydration by default", async () => { + let router = createTestRouter( + [ + { + id: "root", + path: "/", + loader: () => "ROOT", + Component() { + let data = useLoaderData() as string; + return ( + <> +

{`Home - ${data}`}

+ + + ); + }, + children: [ + { + id: "index", + index: true, + loader: () => "INDEX", + HydrateFallback: () =>

Should not see me

, + Component() { + let data = useLoaderData() as string; + return

{`Index - ${data}`}

; + }, + }, + ], + }, + ], + { + hydrationData: { + loaderData: { + root: "HYDRATED ROOT", + }, + }, + } + ); + let { container } = render(); + + expect(getHtml(container)).toMatchInlineSnapshot(` + "
+

+ Home - HYDRATED ROOT +

+

+ Index - undefined +

+
" + `); + }); + + it("supports partial hydration w/leaf fallback", async () => { + let dfd = createDeferred(); + let router = createTestRouter( + [ + { + id: "root", + path: "/", + loader: () => "ROOT", + Component() { + let data = useLoaderData() as string; + return ( + <> +

{`Home - ${data}`}

+ + + ); + }, + children: [ + { + id: "index", + index: true, + loader: () => dfd.promise, + HydrateFallback: () =>

Index Loading...

, + Component() { + let data = useLoaderData() as string; + return

{`Index - ${data}`}

; + }, + }, + ], + }, + ], + { + hydrationData: { + loaderData: { + root: "HYDRATED ROOT", + }, + }, + future: { + v7_partialHydration: true, + }, + } + ); + let { container } = render(); + + expect(getHtml(container)).toMatchInlineSnapshot(` + "
+

+ Home - HYDRATED ROOT +

+

+ Index Loading... +

+
" + `); + + dfd.resolve("INDEX DATA"); + await waitFor(() => screen.getByText(/INDEX DATA/)); + expect(getHtml(container)).toMatchInlineSnapshot(` + "
+

+ Home - HYDRATED ROOT +

+

+ Index - INDEX DATA +

+
" + `); + }); + + it("supports partial hydration w/root fallback", async () => { + let dfd = createDeferred(); + let router = createTestRouter( + [ + { + id: "root", + path: "/", + loader: () => "ROOT", + HydrateFallback: () =>

Root Loading...

, + Component() { + let data = useLoaderData() as string; + return ( + <> +

{`Home - ${data}`}

+ + + ); + }, + children: [ + { + id: "index", + index: true, + loader: () => dfd.promise, + Component() { + let data = useLoaderData() as string; + return

{`Index - ${data}`}

; + }, + }, + ], + }, + ], + { + hydrationData: { + loaderData: { + root: "HYDRATED ROOT", + }, + }, + future: { + v7_partialHydration: true, + }, + } + ); + let { container } = render(); + + expect(getHtml(container)).toMatchInlineSnapshot(` + "
+

+ Root Loading... +

+
" + `); + + dfd.resolve("INDEX DATA"); + await waitFor(() => screen.getByText(/INDEX DATA/)); + expect(getHtml(container)).toMatchInlineSnapshot(` + "
+

+ Home - HYDRATED ROOT +

+

+ Index - INDEX DATA +

+
" + `); + }); + + it("supports partial hydration w/no fallback", async () => { + let dfd = createDeferred(); + let router = createTestRouter( + [ + { + id: "root", + path: "/", + loader: () => "ROOT", + Component() { + let data = useLoaderData() as string; + return ( + <> +

{`Home - ${data}`}

+ + + ); + }, + children: [ + { + id: "index", + index: true, + loader: () => dfd.promise, + Component() { + let data = useLoaderData() as string; + return

{`Index - ${data}`}

; + }, + }, + ], + }, + ], + { + hydrationData: { + loaderData: { + root: "HYDRATED ROOT", + }, + }, + future: { + v7_partialHydration: true, + }, + } + ); + let { container } = render(); + + expect(getHtml(container)).toMatchInlineSnapshot(`"
"`); + + // We can't assert this in all 3 test executions because we use `warningOnce` + // internally to avoid logging on every render + if (!didAssertMissingHydrateFallback) { + didAssertMissingHydrateFallback = true; + // eslint-disable-next-line jest/no-conditional-expect + expect(consoleWarn).toHaveBeenCalledWith( + "No `HydrateFallback` element provided to render during initial hydration" + ); + } + + dfd.resolve("INDEX DATA"); + await waitFor(() => screen.getByText(/INDEX DATA/)); + expect(getHtml(container)).toMatchInlineSnapshot(` + "
+

+ Home - HYDRATED ROOT +

+

+ Index - INDEX DATA +

+
" + `); + }); + + it("deprecates fallbackElement", async () => { + let dfd1 = createDeferred(); + let dfd2 = createDeferred(); + let router = createTestRouter( + [ + { + id: "root", + path: "/", + loader: () => dfd1.promise, + HydrateFallback: () =>

Root Loading...

, + Component() { + let data = useLoaderData() as string; + return ( + <> +

{`Home - ${data}`}

+ + + ); + }, + children: [ + { + id: "index", + index: true, + loader: () => dfd2.promise, + Component() { + let data = useLoaderData() as string; + return

{`Index - ${data}`}

; + }, + }, + ], + }, + ], + { + hydrationData: { + loaderData: { + root: "HYDRATED ROOT", + }, + }, + future: { + v7_partialHydration: true, + }, + } + ); + let { container } = render( + fallbackElement...

} + /> + ); + + expect(getHtml(container)).toMatchInlineSnapshot(` + "
+

+ Root Loading... +

+
" + `); + + expect(consoleWarn).toHaveBeenCalledWith( + "`` is deprecated when using " + + "`v7_partialHydration`, use a `HydrateFallback` component instead" + ); + + dfd1.resolve("ROOT DATA"); + dfd2.resolve("INDEX DATA"); + await waitFor(() => screen.getByText(/INDEX DATA/)); + expect(getHtml(container)).toMatchInlineSnapshot(` + "
+

+ Home - HYDRATED ROOT +

+

+ Index - INDEX DATA +

+
" + `); + }); + + it("does not re-run loaders that don't have loader data due to errors", async () => { + let spy = jest.fn(); + let router = createTestRouter( + [ + { + id: "root", + path: "/", + loader: () => "ROOT", + Component() { + let data = useLoaderData() as string; + return ( + <> +

{`Home - ${data}`}

+ + + ); + }, + children: [ + { + id: "index", + index: true, + loader: spy, + HydrateFallback: () =>

Index Loading...

, + Component() { + let data = useLoaderData() as string; + return

{`Index - ${data}`}

; + }, + ErrorBoundary() { + let error = useRouteError() as string; + return

{error}

; + }, + }, + ], + }, + ], + { + hydrationData: { + loaderData: { + root: "HYDRATED ROOT", + }, + errors: { + index: "INDEX ERROR", + }, + }, + future: { + v7_partialHydration: true, + }, + } + ); + let { container } = render(); + + expect(getHtml(container)).toMatchInlineSnapshot(` + "
+

+ Home - HYDRATED ROOT +

+

+ INDEX ERROR +

+
" + `); + + expect(spy).not.toHaveBeenCalled(); + }); + + it("lets users force hydration loader execution with loader.hydrate=true", async () => { + let dfd = createDeferred(); + let indexLoader: LoaderFunction = () => dfd.promise; + indexLoader.hydrate = true; + let router = createTestRouter( + [ + { + id: "root", + path: "/", + loader: () => "ROOT", + Component() { + let data = useLoaderData() as string; + return ( + <> +

{`Home - ${data}`}

+ + + ); + }, + children: [ + { + id: "index", + index: true, + loader: indexLoader, + HydrateFallback: () =>

Index Loading...

, + Component() { + let data = useLoaderData() as string; + return

{`Index - ${data}`}

; + }, + }, + ], + }, + ], + { + hydrationData: { + loaderData: { + root: "HYDRATED ROOT", + index: "INDEX INITIAL", + }, + }, + future: { + v7_partialHydration: true, + }, + } + ); + let { container } = render(); + + expect(getHtml(container)).toMatchInlineSnapshot(` + "
+

+ Home - HYDRATED ROOT +

+

+ Index - INDEX INITIAL +

+
" + `); + + dfd.resolve("INDEX UPDATED"); + await waitFor(() => screen.getByText(/INDEX UPDATED/)); + expect(getHtml(container)).toMatchInlineSnapshot(` + "
+

+ Home - HYDRATED ROOT +

+

+ Index - INDEX UPDATED +

+
" + `); + }); +} diff --git a/packages/react-router-dom/index.tsx b/packages/react-router-dom/index.tsx index 49b707daae..54f37a1121 100644 --- a/packages/react-router-dom/index.tsx +++ b/packages/react-router-dom/index.tsx @@ -634,7 +634,8 @@ export function RouterProvider({ React.useEffect(() => { warning( fallbackElement == null || !router.future.v7_partialHydration, - "`` is deprecated when using `v7_partialHydration`" + "`` is deprecated when using " + + "`v7_partialHydration`, use a `HydrateFallback` component instead" ); // Only log this once on initial mount // eslint-disable-next-line react-hooks/exhaustive-deps @@ -692,7 +693,7 @@ export function RouterProvider({ v7_relativeSplatPath: router.future.v7_relativeSplatPath, }} > - {state.initialized ? ( + {state.initialized || router.future.v7_partialHydration ? ( { warning( fallbackElement == null || !router.future.v7_partialHydration, - "`` is deprecated when using `v7_partialHydration`" + "`` is deprecated when using " + + "`v7_partialHydration`, use a `HydrateFallback` component instead" ); // Only log this once on initial mount // eslint-disable-next-line react-hooks/exhaustive-deps @@ -172,7 +173,7 @@ export function RouterProvider({ v7_relativeSplatPath: router.future.v7_relativeSplatPath, }} > - {state.initialized ? ( + {state.initialized || router.future.v7_partialHydration ? ( { }); describe("when set to true", () => { - it("starts with initialized=true, runs unhydrated loaders with partial hydrationData", async () => { + it("starts with initialized=false, runs unhydrated loaders with partial hydrationData", async () => { let spy = jest.fn(); let shouldRevalidateSpy = jest.fn((args) => args.defaultShouldRevalidate); let dfd = createDeferred(); @@ -291,7 +291,7 @@ describe("future.v7_partialHydration", () => { historyAction: "POP", location: { pathname: "/" }, loaderData: { root: "LOADER DATA" }, - initialized: true, + initialized: false, navigation: { state: "idle" }, }); @@ -322,7 +322,7 @@ describe("future.v7_partialHydration", () => { }); }); - it("starts with initialized=true, runs hydrated loaders when loader.hydrate=true", async () => { + it("starts with initialized=false, runs hydrated loaders when loader.hydrate=true", async () => { let spy = jest.fn(); let shouldRevalidateSpy = jest.fn((args) => args.defaultShouldRevalidate); let dfd = createDeferred(); @@ -367,7 +367,7 @@ describe("future.v7_partialHydration", () => { root: "LOADER DATA", index: "INDEX INITIAL", }, - initialized: true, + initialized: false, navigation: { state: "idle" }, }); diff --git a/packages/router/router.ts b/packages/router/router.ts index 5992912a11..8fefa26d9b 100644 --- a/packages/router/router.ts +++ b/packages/router/router.ts @@ -817,19 +817,34 @@ export function createRouter(init: RouterInit): Router { initialErrors = { [route.id]: error }; } - // "Initialized" here really means "Can `RouterProvider` render my route tree?" - // Prior to `route.HydrateFallback`, we only had a root `fallbackElement` so we used - // `state.initialized` to render that instead of ``. Now that we - // support route level fallbacks we can always render and we'll just render - // as deep as we have data for and detect the nearest ancestor HydrateFallback - let initialized = - future.v7_partialHydration || + let initialized: boolean; + let hasLazyRoutes = initialMatches.some((m) => m.route.lazy); + let hasLoaders = initialMatches.some((m) => m.route.loader); + if (hasLazyRoutes) { // All initialMatches need to be loaded before we're ready. If we have lazy // functions around still then we'll need to run them in initialize() - (!initialMatches.some((m) => m.route.lazy) && - // And we have to either have no loaders or have been provided hydrationData - (!initialMatches.some((m) => m.route.loader) || - init.hydrationData != null)); + initialized = false; + } else if (!hasLoaders) { + // If we've got no loaders to run, then we're good to go + initialized = true; + } else if (future.v7_partialHydration) { + // If partial hydration is enabled, we're initialized so long as we were + // provided with hydrationData for every route with a loader, and no loaders + // were marked for explicit hydration + let loaderData = init.hydrationData ? init.hydrationData.loaderData : null; + let errors = init.hydrationData ? init.hydrationData.errors : null; + initialized = initialMatches.every( + (m) => + m.route.loader && + m.route.loader.hydrate !== true && + ((loaderData && loaderData[m.route.id] !== undefined) || + (errors && errors[m.route.id] !== undefined)) + ); + } else { + // Without partial hydration - we're initialized if we were provided any + // hydrationData - which is expected to be complete + initialized = init.hydrationData != null; + } let router: Router; let state: RouterState = { @@ -1010,11 +1025,7 @@ export function createRouter(init: RouterInit): Router { // in the normal navigation flow. For SSR it's expected that lazy modules are // resolved prior to router creation since we can't go into a fallbackElement // UI for SSR'd apps - if ( - !state.initialized || - (future.v7_partialHydration && - state.matches.some((m) => isUnhydratedRoute(state, m.route))) - ) { + if (!state.initialized) { startNavigation(HistoryAction.Pop, state.location, { initialHydration: true, });