From 1994c9aba5350c3d10b8f1f6cae79b3f4dc9c3cd Mon Sep 17 00:00:00 2001 From: Whaileee Date: Tue, 27 Feb 2024 16:01:13 +0100 Subject: [PATCH] add changeset --- .changeset/khaki-beers-admire.md | 6 + .../concurrent-mode-navigations-test.tsx | 428 +++++++++--------- ...ta-browser-router-legacy-formdata-test.tsx | 6 +- .../__tests__/data-browser-router-test.tsx | 8 +- .../data-hash-router-noslash-test.tsx | 11 + packages/router/history.ts | 160 +------ 6 files changed, 245 insertions(+), 374 deletions(-) create mode 100644 .changeset/khaki-beers-admire.md create mode 100644 packages/react-router-dom/__tests__/data-hash-router-noslash-test.tsx diff --git a/.changeset/khaki-beers-admire.md b/.changeset/khaki-beers-admire.md new file mode 100644 index 0000000000..b091900dce --- /dev/null +++ b/.changeset/khaki-beers-admire.md @@ -0,0 +1,6 @@ +--- +"react-router": patch +"react-router-dom": patch +--- + +HashRouter hashType implementation for backwards compatibility with project migrating from React-Router v4/v5 diff --git a/packages/react-router-dom/__tests__/concurrent-mode-navigations-test.tsx b/packages/react-router-dom/__tests__/concurrent-mode-navigations-test.tsx index babc8222ec..00f1d7b385 100644 --- a/packages/react-router-dom/__tests__/concurrent-mode-navigations-test.tsx +++ b/packages/react-router-dom/__tests__/concurrent-mode-navigations-test.tsx @@ -70,219 +70,219 @@ describe("Handles concurrent mode features during navigations", () => { }; } - // describe("when the destination route suspends with a boundary", () => { - // async function assertNavigation( - // container: HTMLElement, - // resolve: () => void, - // resolveLazy: () => void - // ) { - // // Start on home - // expect(getHtml(container)).toMatch("Home"); - - // // Click to /about and should see Suspense boundary - // await act(() => { - // fireEvent.click(screen.getByText("/about")); - // }); - // await waitFor(() => screen.getByText("Loading...")); - // expect(getHtml(container)).toMatch("Loading..."); - - // // Resolve the destination UI to clear the boundary - // await act(() => resolve()); - // await waitFor(() => screen.getByText("About")); - // expect(getHtml(container)).toMatch("About"); - - // // Back to home - // await act(() => { - // fireEvent.click(screen.getByText("back")); - // }); - // await waitFor(() => screen.getByText("Home")); - // expect(getHtml(container)).toMatch("Home"); - - // // Click to /lazy and should see Suspense boundary - // await act(() => { - // fireEvent.click(screen.getByText("/lazy")); - // }); - // await waitFor(() => screen.getByText("Loading Lazy Component...")); - // expect(getHtml(container)).toMatch("Loading Lazy Component..."); - - // // Resolve the lazy component to clear the boundary - // await act(() => resolveLazy()); - // await waitFor(() => screen.getByText("Lazy")); - // expect(getHtml(container)).toMatch("Lazy"); - // } - - // // eslint-disable-next-line jest/expect-expect - // it("MemoryRouter", async () => { - // let { Home, About, LazyComponent, resolve, resolveLazy } = - // getComponents(); - - // let { container } = render( - // - // - // } /> - // Loading...

}> - // - // - // } - // /> - // Loading Lazy Component...

}> - // - // - // } - // /> - //
- //
- // ); - - // await assertNavigation(container, resolve, resolveLazy); - // }); - - // // eslint-disable-next-line jest/expect-expect - // it("BrowserRouter", async () => { - // let { Home, About, LazyComponent, resolve, resolveLazy } = - // getComponents(); - - // let { container } = render( - // - // - // } /> - // Loading...

}> - // - // - // } - // /> - // Loading Lazy Component...

}> - // - // - // } - // /> - //
- //
- // ); - - // await assertNavigation(container, resolve, resolveLazy); - // }); - - // // eslint-disable-next-line jest/expect-expect - // it("HashRouter", async () => { - // let { Home, About, LazyComponent, resolve, resolveLazy } = - // getComponents(); - - // let { container } = render( - // - // - // } /> - // Loading...

}> - // - // - // } - // /> - // Loading Lazy Component...

}> - // - // - // } - // /> - //
- //
- // ); - - // await assertNavigation(container, resolve, resolveLazy); - // }); - // // eslint-disable-next-line jest/expect-expect - // it("HashRouter with noslash", async () => { - // let { Home, About, LazyComponent, resolve, resolveLazy } = - // getComponents(); - - // let { container } = render( - // - // - // } /> - // Loading...

}> - // - // - // } - // /> - // Loading Lazy Component...

}> - // - // - // } - // /> - //
- //
- // ); - - // await assertNavigation(container, resolve, resolveLazy); - // }); - - // // eslint-disable-next-line jest/expect-expect - // it("RouterProvider", async () => { - // let { Home, About, LazyComponent, resolve, resolveLazy } = - // getComponents(); - - // let router = createMemoryRouter( - // createRoutesFromElements( - // <> - // } /> - // Loading...

}> - // - // - // } - // /> - // Loading Lazy Component...

}> - // - // - // } - // /> - // - // ) - // ); - // let { container } = render( - // - // ); - - // await assertNavigation(container, resolve, resolveLazy); - // }); - // }); + describe("when the destination route suspends with a boundary", () => { + async function assertNavigation( + container: HTMLElement, + resolve: () => void, + resolveLazy: () => void + ) { + // Start on home + expect(getHtml(container)).toMatch("Home"); + + // Click to /about and should see Suspense boundary + await act(() => { + fireEvent.click(screen.getByText("/about")); + }); + await waitFor(() => screen.getByText("Loading...")); + expect(getHtml(container)).toMatch("Loading..."); + + // Resolve the destination UI to clear the boundary + await act(() => resolve()); + await waitFor(() => screen.getByText("About")); + expect(getHtml(container)).toMatch("About"); + + // Back to home + await act(() => { + fireEvent.click(screen.getByText("back")); + }); + await waitFor(() => screen.getByText("Home")); + expect(getHtml(container)).toMatch("Home"); + + // Click to /lazy and should see Suspense boundary + await act(() => { + fireEvent.click(screen.getByText("/lazy")); + }); + await waitFor(() => screen.getByText("Loading Lazy Component...")); + expect(getHtml(container)).toMatch("Loading Lazy Component..."); + + // Resolve the lazy component to clear the boundary + await act(() => resolveLazy()); + await waitFor(() => screen.getByText("Lazy")); + expect(getHtml(container)).toMatch("Lazy"); + } + + // eslint-disable-next-line jest/expect-expect + it("MemoryRouter", async () => { + let { Home, About, LazyComponent, resolve, resolveLazy } = + getComponents(); + + let { container } = render( + + + } /> + Loading...

}> + + + } + /> + Loading Lazy Component...

}> + + + } + /> +
+
+ ); + + await assertNavigation(container, resolve, resolveLazy); + }); + + // eslint-disable-next-line jest/expect-expect + it("BrowserRouter", async () => { + let { Home, About, LazyComponent, resolve, resolveLazy } = + getComponents(); + + let { container } = render( + + + } /> + Loading...

}> + + + } + /> + Loading Lazy Component...

}> + + + } + /> +
+
+ ); + + await assertNavigation(container, resolve, resolveLazy); + }); + + // eslint-disable-next-line jest/expect-expect + it("HashRouter", async () => { + let { Home, About, LazyComponent, resolve, resolveLazy } = + getComponents(); + + let { container } = render( + + + } /> + Loading...

}> + + + } + /> + Loading Lazy Component...

}> + + + } + /> +
+
+ ); + + await assertNavigation(container, resolve, resolveLazy); + }); + // eslint-disable-next-line jest/expect-expect + it("HashRouter with noslash", async () => { + let { Home, About, LazyComponent, resolve, resolveLazy } = + getComponents(); + + let { container } = render( + + + } /> + Loading...

}> + + + } + /> + Loading Lazy Component...

}> + + + } + /> +
+
+ ); + + await assertNavigation(container, resolve, resolveLazy); + }); + + // eslint-disable-next-line jest/expect-expect + it("RouterProvider", async () => { + let { Home, About, LazyComponent, resolve, resolveLazy } = + getComponents(); + + let router = createMemoryRouter( + createRoutesFromElements( + <> + } /> + Loading...

}> + + + } + /> + Loading Lazy Component...

}> + + + } + /> + + ) + ); + let { container } = render( + + ); + + await assertNavigation(container, resolve, resolveLazy); + }); + }); describe("when the destination route suspends without a boundary", () => { async function assertNavigation( @@ -341,7 +341,7 @@ describe("Handles concurrent mode features during navigations", () => { await assertNavigation(container, resolve, resolveLazy); }); - + // eslint-disable-next-line jest/expect-expect it("HashRouter with noslash", async () => { let { Home, About, resolve, LazyComponent, resolveLazy } = diff --git a/packages/react-router-dom/__tests__/data-browser-router-legacy-formdata-test.tsx b/packages/react-router-dom/__tests__/data-browser-router-legacy-formdata-test.tsx index 02b9c03bb4..38274a9115 100644 --- a/packages/react-router-dom/__tests__/data-browser-router-legacy-formdata-test.tsx +++ b/packages/react-router-dom/__tests__/data-browser-router-legacy-formdata-test.tsx @@ -23,7 +23,7 @@ testDomRouter("", createHashRouter, (url) => getWindowImpl(url, true) ); -testDomRouter("", (routes, opts) => createHashRouter(routes, {...opts, hashType: 'noslash'}), (url) => +testDomRouter("", (routes, opts) => createHashRouter(routes, { ...opts, hashType: 'noslash' }), (url) => getWindowImpl(url, true) ); @@ -37,8 +37,8 @@ function testDomRouter( let consoleError: jest.SpyInstance; beforeEach(() => { - consoleWarn = jest.spyOn(console, "warn").mockImplementation(() => {}); - consoleError = jest.spyOn(console, "error").mockImplementation(() => {}); + consoleWarn = jest.spyOn(console, "warn").mockImplementation(() => { }); + consoleError = jest.spyOn(console, "error").mockImplementation(() => { }); }); afterEach(() => { 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 781eba3ede..55efda3a84 100644 --- a/packages/react-router-dom/__tests__/data-browser-router-test.tsx +++ b/packages/react-router-dom/__tests__/data-browser-router-test.tsx @@ -47,11 +47,7 @@ testDomRouter("", createBrowserRouter, (url) => getWindowImpl(url, false) ); -testDomRouter("", (routes, opts) => createHashRouter(routes, {...opts, hashType: 'noslash'}), (url) => - getWindowImpl(url.substring(1), true) -); - -function testDomRouter( +export function testDomRouter( name: string, createTestRouter: typeof createBrowserRouter | typeof createHashRouter, getWindow: (initialUrl: string, isHash?: boolean) => Window @@ -7475,7 +7471,7 @@ function testDomRouter( }); } -function getWindowImpl(initialUrl: string, isHash = false): Window { +export function getWindowImpl(initialUrl: string, isHash = false): Window { // Need to use our own custom DOM in order to get a working history const dom = new JSDOM(``, { url: "http://localhost/" }); dom.window.history.replaceState(null, "", (isHash ? "#" : "") + initialUrl); diff --git a/packages/react-router-dom/__tests__/data-hash-router-noslash-test.tsx b/packages/react-router-dom/__tests__/data-hash-router-noslash-test.tsx new file mode 100644 index 0000000000..7f0dfc1b7c --- /dev/null +++ b/packages/react-router-dom/__tests__/data-hash-router-noslash-test.tsx @@ -0,0 +1,11 @@ +import { testDomRouter, getWindowImpl } from "./data-browser-router-test"; +import { createHashRouter } from "react-router-dom"; + + +function createRouter (routes, opts?) { + return opts === undefined ? createHashRouter(routes, {hashType: 'noslash'}) : createHashRouter(routes, {...opts, hashType:'noslash'}) +} +// this test cannot be put in data-browser-router-test.tsx file, because it fails on node 16 and 18 in combination with . +// for some reason the order and execution time of these tests matter and they are influenced by each other. +testDomRouter("", createRouter, (url) => + getWindowImpl(url.substring(1), true)) diff --git a/packages/router/history.ts b/packages/router/history.ts index b47ce339b6..8191df8a45 100644 --- a/packages/router/history.ts +++ b/packages/router/history.ts @@ -425,8 +425,8 @@ export function createHashHistory( hashType: HashType ) { let path; - if (hashType !== 'slash' && window.location.hash.startsWith("#/")){ - path = "/" + window.location.hash.substring(2) + if (hashType !== 'slash' && window.location.hash.startsWith("#/") && window.location.hash.length > 2){ + path = "/" + window.location.hash.substring(1) } else { path = window.location.hash.substring(1) @@ -477,7 +477,7 @@ export function createHashHistory( ); } - return getHashBasedHistory( + return getUrlBasedHistory( createHashLocation, createHashHref, validateHashLocation, @@ -607,158 +607,16 @@ export interface UrlHistory extends History {} export type UrlHistoryOptions = { window?: Window; v5Compat?: boolean; + hashType?: HashType; }; -function getHashBasedHistory( - getLocation: (window: Window, globalHistory: Window["history"], hashType: HashType) => Location, - createHref: (window: Window, to: To, hashType: HashType) => string, - validateLocation: ((location: Location, to: To) => void) | null, - options: HashHistoryOptions = {}): HashHistory { - let { window = document.defaultView!, v5Compat = false, hashType = 'slash' } = options; - let globalHistory = window.history; - let action = Action.Pop; - let listener: Listener | null = null; - - let index = getIndex()!; - // Index should only be null when we initialize. If not, it's because the - // user called history.pushState or history.replaceState directly, in which - // case we should log a warning as it will result in bugs. - if (index == null) { - index = 0; - globalHistory.replaceState({ ...globalHistory.state, idx: index }, ""); - } - - function getIndex(): number { - let state = globalHistory.state || { idx: null }; - return state.idx; - } - - function handlePop() { - action = Action.Pop; - let nextIndex = getIndex(); - let delta = nextIndex == null ? null : nextIndex - index; - index = nextIndex; - if (listener) { - listener({ action, location: history.location, delta }); - } - } - - function push(to: To, state?: any) { - action = Action.Push; - let location = createLocation(history.location, to, state); - if (validateLocation) validateLocation(location, to); - - index = getIndex() + 1; - let historyState = getHistoryState(location, index); - let url = history.createHref(location); - - // try...catch because iOS limits us to 100 pushState calls :/ - try { - globalHistory.pushState(historyState, "", url); - } catch (error) { - // If the exception is because `state` can't be serialized, let that throw - // outwards just like a replace call would so the dev knows the cause - // https://html.spec.whatwg.org/multipage/nav-history-apis.html#shared-history-push/replace-state-steps - // https://html.spec.whatwg.org/multipage/structured-data.html#structuredserializeinternal - if (error instanceof DOMException && error.name === "DataCloneError") { - throw error; - } - // They are going to lose state here, but there is no real - // way to warn them about it since the page will refresh... - window.location.assign(url); - } - - if (v5Compat && listener) { - listener({ action, location: history.location, delta: 1 }); - } - } - - function replace(to: To, state?: any) { - action = Action.Replace; - let location = createLocation(history.location, to, state); - if (validateLocation) validateLocation(location, to); - - index = getIndex(); - let historyState = getHistoryState(location, index); - let url = history.createHref(location); - globalHistory.replaceState(historyState, "", url); - - if (v5Compat && listener) { - listener({ action, location: history.location, delta: 0 }); - } - } - - function createURL(to: To): URL { - // window.location.origin is "null" (the literal string value) in Firefox - // under certain conditions, notably when serving from a local HTML file - // See https://bugzilla.mozilla.org/show_bug.cgi?id=878297 - let base = - window.location.origin !== "null" - ? window.location.origin - : window.location.href; - - let href = typeof to === "string" ? to : createPath(to); - // Treating this as a full URL will strip any trailing spaces so we need to - // pre-encode them since they might be part of a matching splat param from - // an ancestor route - href = href.replace(/ $/, "%20"); - invariant( - base, - `No window.location.(origin|href) available to create URL for href: ${href}` - ); - return new URL(href, base); - } - - let history: History = { - get action() { - return action; - }, - get location() { - return getLocation(window, globalHistory, hashType); - }, - listen(fn: Listener) { - if (listener) { - throw new Error("A history only accepts one active listener"); - } - window.addEventListener(PopStateEventType, handlePop); - listener = fn; - - return () => { - window.removeEventListener(PopStateEventType, handlePop); - listener = null; - }; - }, - createHref(to) { - return createHref(window, to, hashType); - }, - createURL, - encodeLocation(to) { - // Encode a Location the same way window.location would - let url = createURL(to); - let pathname = hashType === 'slash' ? url.pathname : "/" + url.pathname.slice(1).concat(); - //let pathname = url.pathname; - return { - pathname: pathname, - search: url.search, - hash: url.hash, - }; - }, - push, - replace, - go(n) { - return globalHistory.go(n); - }, - }; - - return history; - } function getUrlBasedHistory( - getLocation: (window: Window, globalHistory: Window["history"]) => Location, - createHref: (window: Window, to: To) => string, + getLocation: (window: Window, globalHistory: Window["history"], hashType: HashType) => Location, + createHref: (window: Window, to: To, hashType: HashType) => string, validateLocation: ((location: Location, to: To) => void) | null, options: UrlHistoryOptions = {} ): UrlHistory { - let { window = document.defaultView!, v5Compat = false } = options; + let { window = document.defaultView!, v5Compat = false, hashType = 'slash' } = options; let globalHistory = window.history; let action = Action.Pop; let listener: Listener | null = null; @@ -858,7 +716,7 @@ function getUrlBasedHistory( return action; }, get location() { - return getLocation(window, globalHistory); + return getLocation(window, globalHistory, hashType); }, listen(fn: Listener) { if (listener) { @@ -873,7 +731,7 @@ function getUrlBasedHistory( }; }, createHref(to) { - return createHref(window, to); + return createHref(window, to, hashType); }, createURL, encodeLocation(to) {