diff --git a/packages/docusaurus/src/client/__tests__/__mocks__/@generated/routes.ts b/packages/docusaurus/src/client/__tests__/__mocks__/@generated/routes.ts index ce2206070e1d..b0c1258b158e 100644 --- a/packages/docusaurus/src/client/__tests__/__mocks__/@generated/routes.ts +++ b/packages/docusaurus/src/client/__tests__/__mocks__/@generated/routes.ts @@ -11,6 +11,11 @@ export default [ exact: true, component: '', }, + { + path: '/café.html', + exact: true, + component: '', + }, { path: '/docs', exact: false, diff --git a/packages/docusaurus/src/client/__tests__/normalizeLocation.test.ts b/packages/docusaurus/src/client/__tests__/normalizeLocation.test.ts index 001cc22b1f28..c29a344df31e 100644 --- a/packages/docusaurus/src/client/__tests__/normalizeLocation.test.ts +++ b/packages/docusaurus/src/client/__tests__/normalizeLocation.test.ts @@ -6,7 +6,9 @@ */ import {describe, expect, it, vi} from 'vitest'; -import normalizeLocation from '../normalizeLocation'; +import normalizeLocation, { + normalizeBrowserPathname, +} from '../normalizeLocation'; import type {Location} from 'history'; vi.mock('@generated/routes', () => import('./__mocks__/@generated/routes')); @@ -111,3 +113,19 @@ describe('normalizeLocation', () => { }); }); }); + +describe('normalizeBrowserPathname', () => { + it('normalizes the decoded pathname', () => { + expect(normalizeBrowserPathname('/index.html')).toBe('/'); + expect(normalizeBrowserPathname('/docs/installation.html')).toBe( + '/docs/installation', + ); + expect(normalizeBrowserPathname('/caf%C3%A9.html')).toBe('/caf%C3%A9.html'); + }); + + it('returns a pathname that can be decoded', () => { + const pathname = normalizeBrowserPathname('/100%25.html'); + expect(pathname).toBe('/100%25'); + expect(decodeURI(pathname)).toBe('/100%'); + }); +}); diff --git a/packages/docusaurus/src/client/clientEntry.tsx b/packages/docusaurus/src/client/clientEntry.tsx index cce23f98a6e6..977c0979c810 100644 --- a/packages/docusaurus/src/client/clientEntry.tsx +++ b/packages/docusaurus/src/client/clientEntry.tsx @@ -13,6 +13,7 @@ import siteConfig from '@generated/docusaurus.config'; import ExecutionEnvironment from './exports/ExecutionEnvironment'; import App from './App'; import preload from './preload'; +import {normalizeBrowserPathname} from './normalizeLocation'; import docusaurus from './docusaurus'; function Router({children}: {children: ReactNode}): ReactNode { @@ -63,7 +64,9 @@ if (ExecutionEnvironment.canUseDOM) { } }; - preload(window.location.pathname).then(() => { + // Preload the route App renders, or hydration of /page.html URLs can leave + // the server HTML in place: https://github.com/facebook/docusaurus/pull/10059 + preload(normalizeBrowserPathname(window.location.pathname)).then(() => { startTransition(renderApp); }); diff --git a/packages/docusaurus/src/client/normalizeLocation.ts b/packages/docusaurus/src/client/normalizeLocation.ts index 74192a451c08..d4fa86d2f952 100644 --- a/packages/docusaurus/src/client/normalizeLocation.ts +++ b/packages/docusaurus/src/client/normalizeLocation.ts @@ -12,29 +12,35 @@ import type {Location} from 'history'; // Memoize previously normalized pathnames. const pathnames = new Map(); -export default function normalizeLocation(location: T): T { - if (pathnames.has(location.pathname)) { - return { - ...location, - pathname: pathnames.get(location.pathname), - }; - } - +function computeNormalizedPathname(pathname: string): string { // If the location was registered with an `.html` extension, we don't strip it // away, or it will render to a 404 page. - const matchedRoutes = matchRoutes(routes, location.pathname); + const matchedRoutes = matchRoutes(routes, pathname); if (matchedRoutes.some(({route}) => route.exact === true)) { - pathnames.set(location.pathname, location.pathname); - return location; + return pathname; } + return pathname.trim().replace(/(?:\/index)?\.html$/, '') || '/'; +} - const pathname = - location.pathname.trim().replace(/(?:\/index)?\.html$/, '') || '/'; - - pathnames.set(location.pathname, pathname); +function normalizePathname(pathname: string): string { + let normalizedPathname = pathnames.get(pathname); + if (normalizedPathname === undefined) { + normalizedPathname = computeNormalizedPathname(pathname); + pathnames.set(pathname, normalizedPathname); + } + return normalizedPathname; +} +export default function normalizeLocation(location: T): T { return { ...location, - pathname, + pathname: normalizePathname(location.pathname), }; } + +// The router decodes the browser pathname before App normalizes it. Encode it +// back for preload(), which decodes again and throws on /100%25 if not encoded +export function normalizeBrowserPathname(browserPathname: string): string { + const routerPathname = decodeURI(browserPathname); + return encodeURI(normalizePathname(routerPathname)); +}