diff --git a/.changeset/afraid-kiwis-grow.md b/.changeset/afraid-kiwis-grow.md new file mode 100644 index 0000000000..1fd527e245 --- /dev/null +++ b/.changeset/afraid-kiwis-grow.md @@ -0,0 +1,5 @@ +--- +"react-router-dom": minor +--- + +Add `useBeforeUnload()` hook diff --git a/.changeset/bright-gorillas-pump.md b/.changeset/bright-gorillas-pump.md new file mode 100644 index 0000000000..52bceecc5a --- /dev/null +++ b/.changeset/bright-gorillas-pump.md @@ -0,0 +1,5 @@ +--- +"react-router-dom": patch +--- + +Support uppercase `
` and `useSubmit` method values diff --git a/.changeset/empty-teachers-tie.md b/.changeset/empty-teachers-tie.md new file mode 100644 index 0000000000..e54ba35689 --- /dev/null +++ b/.changeset/empty-teachers-tie.md @@ -0,0 +1,5 @@ +--- +"react-router-dom": major +--- + +Proper hydration of `Error` objects from `StaticRouterProvider` diff --git a/.changeset/shiny-pants-decide.md b/.changeset/shiny-pants-decide.md new file mode 100644 index 0000000000..b5d57d185e --- /dev/null +++ b/.changeset/shiny-pants-decide.md @@ -0,0 +1,6 @@ +--- +"react-router-dom": patch +"@remix-run/router": patch +--- + +Skip initial scroll restoration for SSR apps with hydrationData diff --git a/.changeset/small-dots-try.md b/.changeset/small-dots-try.md new file mode 100644 index 0000000000..798dd37e49 --- /dev/null +++ b/.changeset/small-dots-try.md @@ -0,0 +1,5 @@ +--- +"react-router-dom": patch +--- + +Fix ` +
+
+

{navigation.state}

+

{data}

+
+ + + ); + } + + expect(getHtml(container.querySelector("#output"))) + .toMatchInlineSnapshot(` + "
+

+ idle +

+

+

" + `); + + fireEvent.click(screen.getByText("Submit Form")); + await waitFor(() => screen.getByText("loading")); + expect(getHtml(container.querySelector("#output"))) + .toMatchInlineSnapshot(` + "
+

+ loading +

+

+

" + `); + + loaderDefer.resolve("Loader Data"); + await waitFor(() => screen.getByText("idle")); + expect(getHtml(container.querySelector("#output"))) + .toMatchInlineSnapshot(` + "
+

+ idle +

+

+ Loader Data +

+
" + `); + }); + + it("supports uppercase form method attributes", async () => { + let loaderDefer = createDeferred(); + let actionDefer = createDeferred(); + + let { container } = render( + + { + let resolvedValue = await actionDefer.promise; + let formData = await request.formData(); + return `${resolvedValue}:${formData.get("test")}`; + }} + loader={() => loaderDefer.promise} + element={} + /> + + ); + + function Home() { + let data = useLoaderData(); + let actionData = useActionData(); + let navigation = useNavigation(); + return ( +
+
+ + +
+
+

{navigation.state}

+

{data}

+

{actionData}

+
+ +
+ ); + } + + fireEvent.click(screen.getByText("Submit Form")); + await waitFor(() => screen.getByText("submitting")); + actionDefer.resolve("Action Data"); + await waitFor(() => screen.getByText("loading")); + loaderDefer.resolve("Loader Data"); + await waitFor(() => screen.getByText("idle")); + expect(getHtml(container.querySelector("#output"))) + .toMatchInlineSnapshot(` + "
+

+ idle +

+

+ Loader Data +

+

+ Action Data:value +

+
" + `); + }); + describe("
", () => { function NoActionComponent() { return ( diff --git a/packages/react-router-dom/__tests__/data-static-router-test.tsx b/packages/react-router-dom/__tests__/data-static-router-test.tsx index a1c16550eb..40a674ac86 100644 --- a/packages/react-router-dom/__tests__/data-static-router-test.tsx +++ b/packages/react-router-dom/__tests__/data-static-router-test.tsx @@ -321,6 +321,50 @@ describe("A ", () => { ); }); + it("serializes Error instances", async () => { + let routes = [ + { + path: "/", + loader: () => { + throw new Error("oh no"); + }, + }, + ]; + let { query } = createStaticHandler(routes); + + let context = (await query( + new Request("http://localhost/", { + signal: new AbortController().signal, + }) + )) as StaticHandlerContext; + + let html = ReactDOMServer.renderToStaticMarkup( + + + + ); + + // stack is stripped by default from SSR errors + let expectedJsonString = JSON.stringify( + JSON.stringify({ + loaderData: {}, + actionData: null, + errors: { + "0": { + message: "oh no", + __type: "Error", + }, + }, + }) + ); + expect(html).toMatch( + `` + ); + }); + it("supports a nonce prop", async () => { let routes = [ { @@ -355,7 +399,10 @@ describe("A ", () => { let expectedJsonString = JSON.stringify( JSON.stringify({ - loaderData: {}, + loaderData: { + 0: null, + "0-0": null, + }, actionData: null, errors: null, }) diff --git a/packages/react-router-dom/dom.ts b/packages/react-router-dom/dom.ts index dd5ad3e63c..e6fd5aa73f 100644 --- a/packages/react-router-dom/dom.ts +++ b/packages/react-router-dom/dom.ts @@ -245,5 +245,5 @@ export function getFormSubmissionInfo( let { protocol, host } = window.location; let url = new URL(action, `${protocol}//${host}`); - return { url, method, encType, formData }; + return { url, method: method.toLowerCase(), encType, formData }; } diff --git a/packages/react-router-dom/index.tsx b/packages/react-router-dom/index.tsx index a1bb9430ea..831ae90c96 100644 --- a/packages/react-router-dom/index.tsx +++ b/packages/react-router-dom/index.tsx @@ -64,6 +64,7 @@ import { export type { FormEncType, FormMethod, + GetScrollRestorationKeyFunction, ParamKeyValuePair, SubmitOptions, URLSearchParamsInit, @@ -252,6 +253,12 @@ function deserializeErrors( val.data, val.internal === true ); + } else if (val && val.__type === "Error") { + let error = new Error(val.message); + // Wipe away the client-side stack trace. Nothing to fill it in with + // because we don't serialize SSR stack traces for security reasons + error.stack = ""; + serialized[key] = error; } else { serialized[key] = val; } @@ -650,7 +657,15 @@ const FormImpl = React.forwardRef( let submitter = (event as unknown as HTMLSubmitEvent).nativeEvent .submitter as HTMLFormSubmitter | null; - submit(submitter || event.currentTarget, { method, replace, relative }); + let submitMethod = + (submitter?.getAttribute("formmethod") as FormMethod | undefined) || + method; + + submit(submitter || event.currentTarget, { + method: submitMethod, + replace, + relative, + }); }; return ( @@ -669,7 +684,7 @@ if (__DEV__) { FormImpl.displayName = "FormImpl"; } -interface ScrollRestorationProps { +export interface ScrollRestorationProps { getKey?: GetScrollRestorationKeyFunction; storageKey?: string; } @@ -916,10 +931,9 @@ export function useFormAction( invariant(routeContext, "useFormAction must be used inside a RouteContext"); let [match] = routeContext.matches.slice(-1); - let resolvedAction = action ?? "."; // Shallow clone path so we can modify it below, otherwise we modify the // object referenced by useMemo inside useResolvedPath - let path = { ...useResolvedPath(resolvedAction, { relative }) }; + let path = { ...useResolvedPath(action ? action : ".", { relative }) }; // Previously we set the default action to ".". The problem with this is that // `useResolvedPath(".")` excludes search params and the hash of the resolved @@ -1103,62 +1117,77 @@ function useScrollRestoration({ ); // Read in any saved scroll locations - React.useLayoutEffect(() => { - try { - let sessionPositions = sessionStorage.getItem( - storageKey || SCROLL_RESTORATION_STORAGE_KEY + if (typeof document !== "undefined") { + // eslint-disable-next-line react-hooks/rules-of-hooks + React.useLayoutEffect(() => { + try { + let sessionPositions = sessionStorage.getItem( + storageKey || SCROLL_RESTORATION_STORAGE_KEY + ); + if (sessionPositions) { + savedScrollPositions = JSON.parse(sessionPositions); + } + } catch (e) { + // no-op, use default empty object + } + }, [storageKey]); + + // Enable scroll restoration in the router + // eslint-disable-next-line react-hooks/rules-of-hooks + React.useLayoutEffect(() => { + let disableScrollRestoration = router?.enableScrollRestoration( + savedScrollPositions, + () => window.scrollY, + getKey ); - if (sessionPositions) { - savedScrollPositions = JSON.parse(sessionPositions); + return () => disableScrollRestoration && disableScrollRestoration(); + }, [router, getKey]); + + // Restore scrolling when state.restoreScrollPosition changes + // eslint-disable-next-line react-hooks/rules-of-hooks + React.useLayoutEffect(() => { + // Explicit false means don't do anything (used for submissions) + if (restoreScrollPosition === false) { + return; } - } catch (e) { - // no-op, use default empty object - } - }, [storageKey]); - - // Enable scroll restoration in the router - React.useLayoutEffect(() => { - let disableScrollRestoration = router?.enableScrollRestoration( - savedScrollPositions, - () => window.scrollY, - getKey - ); - return () => disableScrollRestoration && disableScrollRestoration(); - }, [router, getKey]); - - // Restore scrolling when state.restoreScrollPosition changes - React.useLayoutEffect(() => { - // Explicit false means don't do anything (used for submissions) - if (restoreScrollPosition === false) { - return; - } - - // been here before, scroll to it - if (typeof restoreScrollPosition === "number") { - window.scrollTo(0, restoreScrollPosition); - return; - } - // try to scroll to the hash - if (location.hash) { - let el = document.getElementById(location.hash.slice(1)); - if (el) { - el.scrollIntoView(); + // been here before, scroll to it + if (typeof restoreScrollPosition === "number") { + window.scrollTo(0, restoreScrollPosition); return; } - } - // Opt out of scroll reset if this link requested it - if (preventScrollReset === true) { - return; - } + // try to scroll to the hash + if (location.hash) { + let el = document.getElementById(location.hash.slice(1)); + if (el) { + el.scrollIntoView(); + return; + } + } + + // Opt out of scroll reset if this link requested it + if (preventScrollReset === true) { + return; + } - // otherwise go to the top on new locations - window.scrollTo(0, 0); - }, [location, restoreScrollPosition, preventScrollReset]); + // otherwise go to the top on new locations + window.scrollTo(0, 0); + }, [location, restoreScrollPosition, preventScrollReset]); + } } -function useBeforeUnload(callback: () => any): void { +/** + * Setup a callback to be fired on the window's `beforeunload` event. This is + * useful for saving some data to `window.localStorage` just before the page + * refreshes. + * + * Note: The `callback` argument should be a function created with + * `React.useCallback()`. + */ +export function useBeforeUnload( + callback: (event: BeforeUnloadEvent) => any +): void { React.useEffect(() => { window.addEventListener("beforeunload", callback); return () => { @@ -1166,7 +1195,6 @@ function useBeforeUnload(callback: () => any): void { }; }, [callback]); } - //#endregion //////////////////////////////////////////////////////////////////////////////// @@ -1190,3 +1218,5 @@ function warning(cond: boolean, message: string): void { } } //#endregion + +export { useScrollRestoration as UNSAFE_useScrollRestoration }; diff --git a/packages/react-router-dom/server.tsx b/packages/react-router-dom/server.tsx index f0cfbff171..28bde589b3 100644 --- a/packages/react-router-dom/server.tsx +++ b/packages/react-router-dom/server.tsx @@ -157,6 +157,12 @@ function serializeErrors( // deserializeErrors in react-router-dom/index.tsx :) if (isRouteErrorResponse(val)) { serialized[key] = { ...val, __type: "RouteErrorResponse" }; + } else if (val instanceof Error) { + // Do not serialize stack traces from SSR for security reasons + serialized[key] = { + message: val.message, + __type: "Error", + }; } else { serialized[key] = val; } diff --git a/packages/react-router/.eslintrc b/packages/react-router/.eslintrc index a64f72e629..7258667c7e 100644 --- a/packages/react-router/.eslintrc +++ b/packages/react-router/.eslintrc @@ -7,6 +7,7 @@ "__DEV__": true }, "rules": { - "strict": 0 + "strict": 0, + "no-restricted-syntax": ["error", "LogicalExpression[operator='??']"] } } diff --git a/packages/router/.eslintrc b/packages/router/.eslintrc index 5482700d18..7258667c7e 100644 --- a/packages/router/.eslintrc +++ b/packages/router/.eslintrc @@ -3,7 +3,11 @@ "browser": true, "commonjs": true }, + "globals": { + "__DEV__": true + }, "rules": { - "strict": 0 + "strict": 0, + "no-restricted-syntax": ["error", "LogicalExpression[operator='??']"] } } diff --git a/packages/router/__tests__/router-test.ts b/packages/router/__tests__/router-test.ts index fa18d8c0b7..313b503df6 100644 --- a/packages/router/__tests__/router-test.ts +++ b/packages/router/__tests__/router-test.ts @@ -875,7 +875,57 @@ afterEach(() => { describe("a router", () => { describe("init", () => { - it("with initial values", async () => { + it("initial state w/o hydrationData", async () => { + let history = createMemoryHistory({ initialEntries: ["/"] }); + let router = createRouter({ + routes: [ + { + id: "root", + path: "/", + hasErrorBoundary: true, + loader: () => Promise.resolve(), + }, + ], + history, + }); + expect(router.state).toEqual({ + historyAction: "POP", + loaderData: {}, + actionData: null, + errors: null, + location: { + hash: "", + key: expect.any(String), + pathname: "/", + search: "", + state: null, + }, + matches: [ + { + params: {}, + pathname: "/", + pathnameBase: "/", + route: { + hasErrorBoundary: true, + id: "root", + loader: expect.any(Function), + path: "/", + }, + }, + ], + initialized: false, + navigation: { + location: undefined, + state: "idle", + }, + preventScrollReset: false, + restoreScrollPosition: null, + revalidation: "idle", + fetchers: new Map(), + }); + }); + + it("initial state w/hydrationData values", async () => { let history = createMemoryHistory({ initialEntries: ["/"] }); let router = createRouter({ routes: [ @@ -930,7 +980,7 @@ describe("a router", () => { state: "idle", }, preventScrollReset: false, - restoreScrollPosition: null, + restoreScrollPosition: false, revalidation: "idle", fetchers: new Map(), }); @@ -5835,19 +5885,77 @@ describe("a router", () => { }); describe("scroll restoration", () => { + // Version of TASK_ROUTES with no root loader to allow for initialized + // hydrationData:null usage + const SCROLL_ROUTES: TestRouteObject[] = [ + { + path: "/", + children: [ + { + id: "index", + index: true, + loader: true, + }, + { + id: "tasks", + path: "tasks", + loader: true, + action: true, + }, + { + path: "no-loader", + }, + ], + }, + ]; + + it("restores scroll on initial load (w/o hydrationData)", async () => { + let t = setup({ + routes: SCROLL_ROUTES, + initialEntries: ["/no-loader"], + }); + + expect(t.router.state.restoreScrollPosition).toBe(null); + expect(t.router.state.preventScrollReset).toBe(false); + + // Assume initial location had a saved position + let positions = { default: 50 }; + t.router.enableScrollRestoration(positions, () => 0); + expect(t.router.state.restoreScrollPosition).toBe(50); + }); + + it("restores scroll on initial load (w/ hydrationData)", async () => { + let t = setup({ + routes: SCROLL_ROUTES, + initialEntries: ["/"], + hydrationData: { + loaderData: { + index: "INDEX", + }, + }, + }); + + expect(t.router.state.restoreScrollPosition).toBe(false); + expect(t.router.state.preventScrollReset).toBe(false); + + // Assume initial location had a saved position + let positions = { default: 50 }; + t.router.enableScrollRestoration(positions, () => 0); + expect(t.router.state.restoreScrollPosition).toBe(false); + }); + it("restores scroll on navigations", async () => { let t = setup({ - routes: TASK_ROUTES, + routes: SCROLL_ROUTES, initialEntries: ["/"], hydrationData: { loaderData: { - root: "ROOT_DATA", index: "INDEX_DATA", }, }, }); - expect(t.router.state.restoreScrollPosition).toBe(null); + expect(t.router.state.restoreScrollPosition).toBe(false); expect(t.router.state.preventScrollReset).toBe(false); let positions = {}; @@ -5881,17 +5989,16 @@ describe("a router", () => { it("restores scroll using custom key", async () => { let t = setup({ - routes: TASK_ROUTES, + routes: SCROLL_ROUTES, initialEntries: ["/"], hydrationData: { loaderData: { - root: "ROOT_DATA", index: "INDEX_DATA", }, }, }); - expect(t.router.state.restoreScrollPosition).toBe(null); + expect(t.router.state.restoreScrollPosition).toBe(false); expect(t.router.state.preventScrollReset).toBe(false); let positions = { "/tasks": 100 }; @@ -5910,17 +6017,16 @@ describe("a router", () => { it("does not restore scroll on submissions", async () => { let t = setup({ - routes: TASK_ROUTES, + routes: SCROLL_ROUTES, initialEntries: ["/"], hydrationData: { loaderData: { - root: "ROOT_DATA", index: "INDEX_DATA", }, }, }); - expect(t.router.state.restoreScrollPosition).toBe(null); + expect(t.router.state.restoreScrollPosition).toBe(false); expect(t.router.state.preventScrollReset).toBe(false); let positions = { "/tasks": 100 }; @@ -5936,7 +6042,6 @@ describe("a router", () => { formData: createFormData({}), }); await nav1.actions.tasks.resolve("ACTION"); - await nav1.loaders.root.resolve("ROOT"); await nav1.loaders.tasks.resolve("TASKS"); expect(t.router.state.restoreScrollPosition).toBe(false); expect(t.router.state.preventScrollReset).toBe(false); @@ -5944,17 +6049,16 @@ describe("a router", () => { it("does not reset scroll", async () => { let t = setup({ - routes: TASK_ROUTES, + routes: SCROLL_ROUTES, initialEntries: ["/"], hydrationData: { loaderData: { - root: "ROOT_DATA", index: "INDEX_DATA", }, }, }); - expect(t.router.state.restoreScrollPosition).toBe(null); + expect(t.router.state.restoreScrollPosition).toBe(false); expect(t.router.state.preventScrollReset).toBe(false); let positions = {}; @@ -6900,6 +7004,7 @@ describe("a router", () => { formEncType: undefined, formData: undefined, data: undefined, + " _hasFetcherDoneAnything ": true, }); await dfd.resolve("DATA"); @@ -6909,6 +7014,7 @@ describe("a router", () => { formEncType: undefined, formData: undefined, data: "DATA", + " _hasFetcherDoneAnything ": true, }); expect(router._internalFetchControllers.size).toBe(0); @@ -7401,6 +7507,7 @@ describe("a router", () => { formAction: undefined, formEncType: undefined, formData: undefined, + " _hasFetcherDoneAnything ": true, }); expect(t.router.state.historyAction).toBe("PUSH"); expect(t.router.state.location.pathname).toBe("/bar"); @@ -10470,6 +10577,57 @@ describe("a router", () => { }); }); + it("should fill in null loaderData values for routes without loaders", async () => { + let { query } = createStaticHandler([ + { + id: "root", + path: "/", + children: [ + { + id: "none", + path: "none", + }, + { + id: "a", + path: "a", + loader: () => "A", + children: [ + { + id: "b", + path: "b", + }, + ], + }, + ], + }, + ]); + + // No loaders at all + let context = await query(createRequest("/none")); + expect(context).toMatchObject({ + actionData: null, + loaderData: { + root: null, + none: null, + }, + errors: null, + location: { pathname: "/none" }, + }); + + // Mix of loaders and no loaders + context = await query(createRequest("/a/b")); + expect(context).toMatchObject({ + actionData: null, + loaderData: { + root: null, + a: "A", + b: null, + }, + errors: null, + location: { pathname: "/a/b" }, + }); + }); + it("should support document load navigations returning responses", async () => { let { query } = createStaticHandler(SSR_ROUTES); let context = await query(createRequest("/parent/json")); diff --git a/packages/router/router.ts b/packages/router/router.ts index bf8b979da9..2ea4621da9 100644 --- a/packages/router/router.ts +++ b/packages/router/router.ts @@ -435,6 +435,7 @@ type FetcherStates = { formEncType: undefined; formData: undefined; data: TData | undefined; + " _hasFetcherDoneAnything "?: boolean; }; Loading: { state: "loading"; @@ -443,6 +444,7 @@ type FetcherStates = { formEncType: FormEncType | undefined; formData: FormData | undefined; data: TData | undefined; + " _hasFetcherDoneAnything "?: boolean; }; Submitting: { state: "submitting"; @@ -451,6 +453,7 @@ type FetcherStates = { formEncType: FormEncType; formData: FormData; data: TData | undefined; + " _hasFetcherDoneAnything "?: boolean; }; }; @@ -593,7 +596,9 @@ export function createRouter(init: RouterInit): Router { // we don't get the saved positions from until _after_ // the initial render, we need to manually trigger a separate updateState to // send along the restoreScrollPosition - let initialScrollRestored = false; + // Set to true if we have `hydrationData` since we assume we were SSR'd and that + // SSR did the initial scroll restoration. + let initialScrollRestored = init.hydrationData != null; let initialMatches = matchRoutes( dataRoutes, @@ -623,7 +628,8 @@ export function createRouter(init: RouterInit): Router { matches: initialMatches, initialized, navigation: IDLE_NAVIGATION, - restoreScrollPosition: null, + // Don't restore on initial updateState() if we were SSR'd + restoreScrollPosition: init.hydrationData != null ? false : null, preventScrollReset: false, revalidation: "idle", loaderData: (init.hydrationData && init.hydrationData.loaderData) || {}, @@ -1158,6 +1164,7 @@ export function createRouter(init: RouterInit): Router { formAction: undefined, formEncType: undefined, formData: undefined, + " _hasFetcherDoneAnything ": true, }; state.fetchers.set(key, revalidatingFetcher); }); @@ -1310,6 +1317,7 @@ export function createRouter(init: RouterInit): Router { state: "submitting", ...submission, data: existingFetcher && existingFetcher.data, + " _hasFetcherDoneAnything ": true, }; state.fetchers.set(key, fetcher); updateState({ fetchers: new Map(state.fetchers) }); @@ -1347,11 +1355,12 @@ export function createRouter(init: RouterInit): Router { state: "loading", ...submission, data: undefined, + " _hasFetcherDoneAnything ": true, }; state.fetchers.set(key, loadingFetcher); updateState({ fetchers: new Map(state.fetchers) }); - return startRedirectNavigation(state, actionResult); + return startRedirectNavigation(state, actionResult, false, true); } // Process any non-redirect errors thrown @@ -1385,6 +1394,7 @@ export function createRouter(init: RouterInit): Router { state: "loading", data: actionResult.data, ...submission, + " _hasFetcherDoneAnything ": true, }; state.fetchers.set(key, loadFetcher); @@ -1415,6 +1425,7 @@ export function createRouter(init: RouterInit): Router { formAction: undefined, formEncType: undefined, formData: undefined, + " _hasFetcherDoneAnything ": true, }; state.fetchers.set(staleKey, revalidatingFetcher); fetchControllers.set(staleKey, abortController); @@ -1465,6 +1476,7 @@ export function createRouter(init: RouterInit): Router { formAction: undefined, formEncType: undefined, formData: undefined, + " _hasFetcherDoneAnything ": true, }; state.fetchers.set(key, doneFetcher); @@ -1518,6 +1530,7 @@ export function createRouter(init: RouterInit): Router { formData: undefined, ...submission, data: existingFetcher && existingFetcher.data, + " _hasFetcherDoneAnything ": true, }; state.fetchers.set(key, loadingFetcher); updateState({ fetchers: new Map(state.fetchers) }); @@ -1586,6 +1599,7 @@ export function createRouter(init: RouterInit): Router { formAction: undefined, formEncType: undefined, formData: undefined, + " _hasFetcherDoneAnything ": true, }; state.fetchers.set(key, doneFetcher); updateState({ fetchers: new Map(state.fetchers) }); @@ -1613,13 +1627,22 @@ export function createRouter(init: RouterInit): Router { async function startRedirectNavigation( state: RouterState, redirect: RedirectResult, - replace?: boolean + replace?: boolean, + isFetchActionRedirect?: boolean ) { if (redirect.revalidate) { isRevalidationRequired = true; } - let redirectLocation = createLocation(state.location, redirect.location); + let redirectLocation = createLocation( + state.location, + redirect.location, + // TODO: This can be removed once we get rid of useTransition in Remix v2 + { + _isRedirect: true, + ...(isFetchActionRedirect ? { _isFetchActionRedirect: true } : {}), + } + ); invariant( redirectLocation, "Expected a location on the redirect navigation" @@ -1782,6 +1805,7 @@ export function createRouter(init: RouterInit): Router { formAction: undefined, formEncType: undefined, formData: undefined, + " _hasFetcherDoneAnything ": true, }; state.fetchers.set(key, doneFetcher); } @@ -2313,7 +2337,11 @@ export function unstable_createStaticHandler( if (matchesToLoad.length === 0) { return { matches, - loaderData: {}, + // Add a null for all matched routes for proper revalidation on the client + loaderData: matches.reduce( + (acc, m) => Object.assign(acc, { [m.route.id]: null }), + {} + ), errors: pendingActionError || null, statusCode: 200, loaderHeaders: {}, @@ -2340,9 +2368,11 @@ export function unstable_createStaticHandler( throw new Error(`${method}() call aborted`); } - // Can't do anything with these without the Remix side of things, so just - // cancel them for now - results.forEach((result) => { + let executedLoaders = new Set(); + results.forEach((result, i) => { + executedLoaders.add(matchesToLoad[i].route.id); + // Can't do anything with these without the Remix side of things, so just + // cancel them for now if (isDeferredResult(result)) { result.deferredData.cancel(); } @@ -2356,6 +2386,13 @@ export function unstable_createStaticHandler( pendingActionError ); + // Add a null for any non-loader matches for proper revalidation on the client + matches.forEach((match) => { + if (!executedLoaders.has(match.route.id)) { + context.loaderData[match.route.id] = null; + } + }); + return { ...context, matches, @@ -2742,7 +2779,9 @@ async function callLoaderOrAction( let data: any; let contentType = result.headers.get("Content-Type"); - if (contentType && contentType.startsWith("application/json")) { + // Check between word boundaries instead of startsWith() due to the last + // paragraph of https://httpwg.org/specs/rfc9110.html#field.content-type + if (contentType && /\bapplication\/json\b/.test(contentType)) { data = await result.json(); } else { data = await result.text(); @@ -2962,6 +3001,7 @@ function processLoaderData( formAction: undefined, formEncType: undefined, formData: undefined, + " _hasFetcherDoneAnything ": true, }; state.fetchers.set(key, doneFetcher); }