diff --git a/e2e/base-path.spec.ts b/e2e/base-path.spec.ts index 19feef8772..6a1e6fc0e9 100644 --- a/e2e/base-path.spec.ts +++ b/e2e/base-path.spec.ts @@ -44,6 +44,33 @@ test.describe(`base-path`, () => { text: 'hello', }, }); + + // get trailing slash + const resGetTrailing = await request.get(`${baseUrl}hello/`); + expect(resGetTrailing.ok()).toBe(true); + expect(await resGetTrailing.json()).toEqual({ + ok: true, + request: { + handler: 'GET', + method: 'GET', + pathname: '/hello/', + }, + }); + + // post trailing slash + const resPostTrailing = await request.post(`${baseUrl}hello/`, { + data: 'hello', + }); + expect(resPostTrailing.ok()).toBe(true); + expect(await resPostTrailing.json()).toEqual({ + ok: true, + request: { + handler: 'POST', + method: 'POST', + pathname: '/hello/', + text: 'hello', + }, + }); }); test('router', async ({ page }) => { @@ -52,16 +79,27 @@ test.describe(`base-path`, () => { await waitForHydration(page); // push - await page.getByText('dynamic-push').click(); + await page + .getByRole('button', { name: 'dynamic-push', exact: true }) + .click(); await page.waitForURL(`${baseUrl}dynamic`); await expect( page.getByRole('heading', { name: 'Dynamic page' }), ).toBeVisible(); + // push trailing slash + await page.goto(baseUrl); + await waitForHydration(page); + await page.getByRole('button', { name: 'dynamic-push-trailing' }).click(); + await page.waitForURL(`${baseUrl}dynamic/`); + await expect( + page.getByRole('heading', { name: 'Dynamic page' }), + ).toBeVisible(); + // replace await page.goto(baseUrl); await waitForHydration(page); - await page.getByText('dynamic-replace').click(); + await page.getByRole('button', { name: 'dynamic-replace' }).click(); await page.waitForURL(`${baseUrl}dynamic`); await expect( page.getByRole('heading', { name: 'Dynamic page' }), @@ -115,4 +153,9 @@ async function basicTest(page: Page, baseUrl: string) { await expect( page.getByRole('heading', { name: 'Static page' }), ).toBeVisible(); + + await page.goto(`${baseUrl}static/`); + await expect( + page.getByRole('heading', { name: 'Static page' }), + ).toBeVisible(); } diff --git a/e2e/fixtures/base-path/src/pages/_layout.tsx b/e2e/fixtures/base-path/src/pages/_layout.tsx index 8f646cc1b0..e98e631ce0 100644 --- a/e2e/fixtures/base-path/src/pages/_layout.tsx +++ b/e2e/fixtures/base-path/src/pages/_layout.tsx @@ -24,6 +24,9 @@ export default async function RootLayout({
  • dynamic-push
  • +
  • + dynamic-push-trailing +
  • dynamic-replace diff --git a/e2e/fixtures/router-client-no-404/src/components/route-state.tsx b/e2e/fixtures/router-client-no-404/src/components/route-state.tsx index c4f47454b8..7f359fc1d5 100644 --- a/e2e/fixtures/router-client-no-404/src/components/route-state.tsx +++ b/e2e/fixtures/router-client-no-404/src/components/route-state.tsx @@ -17,14 +17,25 @@ export function PushMissingButton() { const router = useRouter(); const push = router.push as unknown as (to: string) => Promise; return ( - + <> + + + ); } diff --git a/e2e/fixtures/router-client/src/components/route-state.tsx b/e2e/fixtures/router-client/src/components/route-state.tsx index e213d42253..bbcfae9d06 100644 --- a/e2e/fixtures/router-client/src/components/route-state.tsx +++ b/e2e/fixtures/router-client/src/components/route-state.tsx @@ -36,6 +36,15 @@ export function RouteState() { router.push hash missing

    +

    + +

    +

    +

    + + Go to static trailing + +

    ); } diff --git a/e2e/fs-router.spec.ts b/e2e/fs-router.spec.ts index 015bf0dfba..eee1e0d1a7 100644 --- a/e2e/fs-router.spec.ts +++ b/e2e/fs-router.spec.ts @@ -41,6 +41,11 @@ test.describe('fs-router', () => { await expect(page.getByRole('heading', { name: 'Foo' })).toBeVisible(); }); + test('foo with trailing slash', async ({ page }) => { + await page.goto(`http://localhost:${port}/foo/`); + await expect(page.getByRole('heading', { name: 'Foo' })).toBeVisible(); + }); + test('nested/foo', async ({ page }) => { // /nested/foo is defined as a staticPath of /nested/[id] which matches this layout await page.goto(`http://localhost:${port}/nested/foo`); @@ -56,6 +61,20 @@ test.describe('fs-router', () => { ).toBeVisible(); }); + test('nested/baz with trailing slash', async ({ page }) => { + await page.goto(`http://localhost:${port}/nested/baz/`); + await expect( + page.getByRole('heading', { name: 'Nested Layout' }), + ).toBeVisible(); + }); + + test('static-nested encoded path with trailing slash', async ({ page }) => { + await page.goto(`http://localhost:${port}/static-nested/encoded%20path/`); + await expect( + page.getByRole('heading', { name: 'Nested / encoded%20path' }), + ).toBeVisible(); + }); + test('check hydration error', async ({ page }) => { const messages: string[] = []; page.on('console', (msg) => messages.push(msg.text())); @@ -85,6 +104,12 @@ test.describe('fs-router', () => { expect(await res.text()).toBe('Hello from API!'); }); + test('api hi with trailing slash', async () => { + const res = await fetch(`http://localhost:${port}/hi/`); + expect(res.status).toBe(200); + expect(await res.text()).toBe('Hello from API!'); + }); + test('api hi.txt', async () => { const res = await fetch(`http://localhost:${port}/hi.txt`); expect(res.status).toBe(200); @@ -106,6 +131,15 @@ test.describe('fs-router', () => { expect(await res.text()).toBe('POST Hello from API! from the test!'); }); + test('api hi with POST and trailing slash', async () => { + const res = await fetch(`http://localhost:${port}/hi/`, { + method: 'POST', + body: 'from the test!', + }); + expect(res.status).toBe(200); + expect(await res.text()).toBe('POST Hello from API! from the test!'); + }); + test('api has-default GET', async () => { const res = await fetch(`http://localhost:${port}/has-default`); expect(res.status).toBe(200); diff --git a/e2e/router-client-no-404.spec.ts b/e2e/router-client-no-404.spec.ts index 7e4faaf603..a8d5e99ac8 100644 --- a/e2e/router-client-no-404.spec.ts +++ b/e2e/router-client-no-404.spec.ts @@ -33,4 +33,21 @@ test.describe('router-client-no-404', () => { ); await expect(page).toHaveURL(/\/missing$/); }); + + test('client navigation to missing trailing-slash route renders Not Found fallback without /404 page', async ({ + page, + }) => { + await page.goto(`http://localhost:${port}/start`); + await waitForHydration(page); + + await page.getByTestId('go-missing-trailing').click(); + + await expect( + page.getByRole('heading', { name: 'Not Found' }), + ).toBeVisible(); + await expect(page.getByRole('heading', { name: 'Custom 404' })).toHaveCount( + 0, + ); + await expect(page).toHaveURL(/\/missing\/$/); + }); }); diff --git a/e2e/router-client.spec.ts b/e2e/router-client.spec.ts index 65769b0d51..13a14fd18d 100644 --- a/e2e/router-client.spec.ts +++ b/e2e/router-client.spec.ts @@ -193,6 +193,26 @@ test.describe('router-client', () => { expect(scrollToCalls).toHaveLength(0); }); + test('same-route trailing-slash navigation preserves scroll and keeps canonical route path', async ({ + page, + }) => { + await page.goto(`http://localhost:${port}/start`); + await waitForHydration(page); + + await page.evaluate(() => { + window.scrollTo({ left: 0, top: 600 }); + }); + await installScrollToRecorder(page); + + await page.getByTestId('router-push-trailing-slash').click(); + + await expect(page.getByTestId('route-path')).toHaveText('/start'); + await expect(page.getByTestId('route-query')).toHaveText(''); + await expect(page.getByTestId('route-hash')).toHaveText(''); + await expect(page).toHaveURL(/\/start\/$/); + expect(await getScrollToCalls(page)).toHaveLength(0); + }); + test('path-change link navigation resets scroll position to top by default', async ({ page, }) => { diff --git a/e2e/use-router.spec.ts b/e2e/use-router.spec.ts index 6426207bd7..f2b3a37645 100644 --- a/e2e/use-router.spec.ts +++ b/e2e/use-router.spec.ts @@ -29,6 +29,20 @@ test.describe('useRouter', () => { await expect(page.getByRole('heading', { name: 'Static' })).toBeVisible(); await expect(page.getByTestId('path')).toHaveText('Path: /static'); }); + + test(`on dynamic pages with trailing slash`, async ({ page }) => { + await page.goto(`http://localhost:${port}/dynamic/`); + await expect( + page.getByRole('heading', { name: 'Dynamic' }), + ).toBeVisible(); + await expect(page.getByTestId('path')).toHaveText('Path: /dynamic'); + }); + + test(`on static pages with trailing slash`, async ({ page }) => { + await page.goto(`http://localhost:${port}/static/`); + await expect(page.getByRole('heading', { name: 'Static' })).toBeVisible(); + await expect(page.getByTestId('path')).toHaveText('Path: /static'); + }); }); test.describe('updates path on link navigation', () => { @@ -63,7 +77,9 @@ test.describe('useRouter', () => { }) => { await page.goto(`http://localhost:${port}/dynamic`); await waitForHydration(page); - await page.getByRole('link', { name: 'Go to static' }).click(); + await page + .getByRole('link', { name: 'Go to static', exact: true }) + .click(); await expect(page.getByRole('heading', { name: 'Static' })).toBeVisible(); const beforeReplaceHistoryLength = await page.evaluate( () => window.history.length, @@ -97,7 +113,9 @@ test.describe('useRouter', () => { await page.click('text=Increment query'); await expect(page.getByTestId('query')).toHaveText('Query: 1'); - await page.getByRole('link', { name: 'Go to static' }).click(); + await page + .getByRole('link', { name: 'Go to static', exact: true }) + .click(); await expect(page.getByRole('heading', { name: 'Static' })).toBeVisible(); await expect(page.getByTestId('query')).toHaveText('Query: 0'); @@ -174,6 +192,26 @@ test.describe('useRouter', () => { await expect(page.getByRole('heading', { name: 'Bar' })).toBeVisible(); expect(messages.some((msg) => msg.includes('Uncaught'))).toBe(false); }); + + test('router.push preserves trailing-slash URL while keeping canonical path', async ({ + page, + }) => { + await page.goto(`http://localhost:${port}/dynamic`); + await waitForHydration(page); + await page.getByTestId('router-push-static-trailing').click(); + await expect(page.getByRole('heading', { name: 'Static' })).toBeVisible(); + await expect(page).toHaveURL(`http://localhost:${port}/static/`); + await expect(page.getByTestId('path')).toHaveText('Path: /static'); + }); + + test('Link supports trailing-slash targets', async ({ page }) => { + await page.goto(`http://localhost:${port}/dynamic`); + await waitForHydration(page); + await page.getByTestId('link-static-trailing').click(); + await expect(page.getByRole('heading', { name: 'Static' })).toBeVisible(); + await expect(page).toHaveURL(`http://localhost:${port}/static/`); + await expect(page.getByTestId('path')).toHaveText('Path: /static'); + }); }); test.describe('retrieves query variables', () => { diff --git a/examples/22_define-router/src/waku.server.tsx b/examples/22_define-router/src/waku.server.tsx index 59743dbe0f..5402c67ac7 100644 --- a/examples/22_define-router/src/waku.server.tsx +++ b/examples/22_define-router/src/waku.server.tsx @@ -133,7 +133,7 @@ export default adapter( // using `[slug]` syntax is just an example and it technically conflicts with others. So, it's better to use a different prefix like `dynamic-page:`. 'page:/dynamic/[slug]': { isStatic: false, - renderer: ({ pathname }) =>

    {pathname}

    , + renderer: ({ routePath }) =>

    {routePath}

    , }, }, }, diff --git a/packages/waku/src/router/client.tsx b/packages/waku/src/router/client.tsx index cb2e5f5749..4f6360fecc 100644 --- a/packages/waku/src/router/client.tsx +++ b/packages/waku/src/router/client.tsx @@ -41,14 +41,19 @@ import { SKIP_HEADER, encodeRoutePath, encodeSliceId, + pathnameToRoutePath, } from './common.js'; import type { RouteProps } from './common.js'; +type AllowTrailingSlash = Path extends '/' + ? Path + : Path | `${Path}/`; + type AllowPathDecorators = Path extends unknown ? - | Path - | `${Path}?${string}` - | `${Path}#${string}` + | AllowTrailingSlash + | `${AllowTrailingSlash}?${string}` + | `${AllowTrailingSlash}#${string}` | `?${string}` | `#${string}` : never; @@ -59,20 +64,15 @@ type InferredPaths = RouteConfig extends { ? AllowPathDecorators : string; -const normalizeRoutePath = (path: string) => { - path = removeBase(path, import.meta.env.WAKU_CONFIG_BASE_PATH); - for (const suffix of ['/', '/index.html']) { - if (path.endsWith(suffix)) { - return path.slice(0, -suffix.length) || '/'; - } - } - return path; -}; +const pathnameToCurrentRoutePath = (pathname: string) => + pathnameToRoutePath( + removeBase(pathname, import.meta.env.WAKU_CONFIG_BASE_PATH), + ); const parseRoute = (url: URL): RouteProps => { const { pathname, searchParams, hash } = url; return { - path: normalizeRoutePath(pathname), + path: pathnameToCurrentRoutePath(pathname), query: searchParams.toString(), hash, }; @@ -107,7 +107,8 @@ const parseRouteFromLocation = (): RouteProps => { }; const shouldScrollByDefault = (url: URL) => - url.pathname !== window.location.pathname || + pathnameToCurrentRoutePath(url.pathname) !== + pathnameToCurrentRoutePath(window.location.pathname) || url.hash !== window.location.hash; const isPathChange = (next: RouteProps, prev: RouteProps) => @@ -141,7 +142,7 @@ const createRscParams = (query: string): URLSearchParams => { type ChangeRouteOptions = { shouldScroll: boolean; - skipRefetch?: boolean; + refetch?: boolean; // true: force refetch, false: don't refetch, undefined: auto-decide based on route change mode?: undefined | 'push' | 'replace'; url?: URL | undefined; unstable_startTransition?: ((fn: TransitionFunction) => void) | undefined; @@ -260,7 +261,7 @@ export function useRouter() { ); const reload = useCallback(async () => { const url = new URL(window.location.href); - await changeRoute(parseRoute(url), { shouldScroll: true }); + await changeRoute(parseRoute(url), { shouldScroll: true, refetch: true }); }, [changeRoute]); const back = useCallback(() => { // FIXME is this correct? @@ -812,14 +813,12 @@ const InnerRouter = ({ routeChangeListenersRef.current = createRouteChangeListeners(); } // Update the route post-load to include the current hash. + const routeRef = useRef(route); useEffect(() => { + routeRef.current = initialRoute; setRoute((prev) => (isSameRoute(prev, initialRoute) ? prev : initialRoute)); }, [initialRoute]); const [err, setErr] = useState(null); - const routeRef = useRef(route); - useEffect(() => { - routeRef.current = route; - }, [route]); const [routeChangeEvents, emitRouteChangeEvent] = routeChangeListenersRef.current; @@ -833,7 +832,6 @@ const InnerRouter = ({ emitRouteChangeEvent('start', nextRoute); const startTransitionFn = options.unstable_startTransition || ((fn: TransitionFunction) => fn()); - const { skipRefetch } = options; const historyPathnameBeforeChange = window.location.pathname; const writeHistoryIfNeeded = ( mode: undefined | 'push' | 'replace', @@ -854,9 +852,11 @@ const InnerRouter = ({ let { mode, url } = options; const requestedUrlToWrite = mode && (url || getRouteUrl(nextRoute)); const routeBeforeChange = routeRef.current; + const shouldRefetch = + options.refetch ?? !isSameRoute(nextRoute, routeBeforeChange); const pathChanged = isPathChange(nextRoute, routeBeforeChange); setErr(null); - if (!staticPathSetRef.current.has(nextRoute.path) && !skipRefetch) { + if (!staticPathSetRef.current.has(nextRoute.path) && shouldRefetch) { const rscPath = encodeRoutePath(nextRoute.path); const rscParams = createRscParams(nextRoute.query); const skipHeaderEnhancer = @@ -920,6 +920,7 @@ const InnerRouter = ({ return; } writeHistoryIfNeeded(mode, urlToWrite); + routeRef.current = nextRoute; setRoute(nextRoute); routeChangeAbortRef.current = null; if (options.shouldScroll) { @@ -948,7 +949,7 @@ const InnerRouter = ({ url.search = query; url.hash = ''; await changeRoute(parseRoute(url), { - skipRefetch: true, + refetch: false, shouldScroll: false, mode: path === '/404' ? undefined : 'push', url, @@ -1003,7 +1004,6 @@ const InnerRouter = ({ } changeRoute(nextRoute, { shouldScroll: shouldScrollForRouteChange(nextRoute, routeRef.current), - skipRefetch: isSameRoute(nextRoute, routeRef.current), }).catch((err) => { console.log('Error while navigating back:', err); }); diff --git a/packages/waku/src/router/common.ts b/packages/waku/src/router/common.ts index 25b1c5d260..1c4fb9b594 100644 --- a/packages/waku/src/router/common.ts +++ b/packages/waku/src/router/common.ts @@ -7,8 +7,24 @@ export type RouteProps = { hash: string; }; -export function getComponentIds(path: string): readonly string[] { - const pathItems = path.split('/').filter(Boolean); +export function pathnameToRoutePath(pathname: string): string { + if (!pathname.startsWith('/')) { + throw new Error('Pathname must start with `/`: ' + pathname); + } + if (pathname.length > 1 && pathname.endsWith('/')) { + pathname = pathname.slice(0, -1); + } + if (pathname.endsWith('/index.html')) { + pathname = pathname.slice(0, -'/index.html'.length) || '/'; + } + if (pathname.length > 1 && pathname.endsWith('/')) { + pathname = pathname.slice(0, -1); + } + return pathname || '/'; +} + +export function getComponentIds(routePath: string): readonly string[] { + const pathItems = routePath.split('/').filter(Boolean); const idSet = new Set(); for (let index = 0; index <= pathItems.length; ++index) { const id = [...pathItems.slice(0, index), 'layout'].join('/'); @@ -21,20 +37,23 @@ export function getComponentIds(path: string): readonly string[] { const ROUTE_PREFIX = 'R'; const SLICE_PREFIX = 'S/'; -export function encodeRoutePath(path: string): string { - if (!path.startsWith('/')) { - throw new Error('Path must start with `/`: ' + path); +export function encodeRoutePath(routePath: string): string { + if (!routePath.startsWith('/')) { + throw new Error('Route path must start with `/`: ' + routePath); + } + if (routePath.length > 1 && routePath.endsWith('/')) { + throw new Error('Route path must not end with `/`: ' + routePath); } - if (path.length > 1 && path.endsWith('/')) { - throw new Error('Path must not end with `/`: ' + path); + if (routePath.endsWith('/index.html')) { + throw new Error('Route path must not end with `/index.html`: ' + routePath); } - if (path === '/') { + if (routePath === '/') { return ROUTE_PREFIX + '/_root'; } - if (path.startsWith('/_')) { - return ROUTE_PREFIX + '/__' + path.slice(2); + if (routePath.startsWith('/_')) { + return ROUTE_PREFIX + '/__' + routePath.slice(2); } - return ROUTE_PREFIX + path; + return ROUTE_PREFIX + routePath; } export function decodeRoutePath(rscPath: string): string { diff --git a/packages/waku/src/router/create-pages.tsx b/packages/waku/src/router/create-pages.tsx index b26607f3ed..be797807d8 100644 --- a/packages/waku/src/router/create-pages.tsx +++ b/packages/waku/src/router/create-pages.tsx @@ -11,6 +11,7 @@ import { import type { PathSpec } from '../lib/utils/path.js'; import { Children, Slot } from '../minimal/client.js'; import { ErrorBoundary } from '../router/client.js'; +import { pathnameToRoutePath } from './common.js'; import type { AnyPage, GetSlugs, @@ -429,39 +430,41 @@ export const createPages = < if (configured) { throw new Error('createPage no longer available'); } - if (pagePathExists(page.path)) { + const pageRoutePath = pathnameToRoutePath(page.path); + if (pagePathExists(pageRoutePath)) { throw new Error(`Duplicated path: ${page.path}`); } - const pathSpec = parsePathWithSlug(page.path); + const pathSpec = parsePathWithSlug(pageRoutePath); const { numSlugs, numWildcards } = getSlugsAndWildcards(pathSpec); if (page.unstable_disableSSR) { noSsrSet.add(pathSpec); } if (page.exactPath) { - const spec = parseExactPath(page.path); + const routePath = pageRoutePath; + const spec = parseExactPath(routePath); if (page.render === 'static') { - staticPathMap.set(page.path, { + staticPathMap.set(routePath, { literalSpec: spec, }); - const id = joinPath(page.path, 'page').replace(/^\//, ''); + const id = joinPath(routePath, 'page').replace(/^\//, ''); if (page.component) { registerStaticComponent(id, page.component); } } else if (page.component) { - dynamicPagePathMap.set(page.path, [spec, page.component]); + dynamicPagePathMap.set(routePath, [spec, page.component]); } else { - dynamicPagePathMap.set(page.path, [spec, []]); + dynamicPagePathMap.set(routePath, [spec, []]); } } else if (page.render === 'static' && numSlugs === 0) { - const pagePath = getGrouplessPath(page.path); - staticPathMap.set(pagePath, { + const routePath = pathnameToRoutePath(getGrouplessPath(page.path)); + staticPathMap.set(routePath, { literalSpec: pathSpec, }); - const id = joinPath(pagePath, 'page').replace(/^\//, ''); - if (pagePath !== page.path) { - groupPathLookup.set(pagePath, page.path); + const id = joinPath(routePath, 'page').replace(/^\//, ''); + if (routePath !== pageRoutePath) { + groupPathLookup.set(routePath, pageRoutePath); } if (page.component) { registerStaticComponent(id, page.component); @@ -482,36 +485,37 @@ export const createPages = < pathSpec, staticPath, ); - const pagePath = getGrouplessPath(definedPath); - staticPathMap.set(pagePath, { + const routePath = pathnameToRoutePath(getGrouplessPath(definedPath)); + staticPathMap.set(routePath, { literalSpec: pathItems.map((name) => ({ type: 'literal', name })), originalSpec: pathSpec, }); - if (pagePath !== definedPath) { - groupPathLookup.set(pagePath, definedPath); + const definedRoutePath = pathnameToRoutePath(definedPath); + if (routePath !== definedRoutePath) { + groupPathLookup.set(routePath, definedRoutePath); } - const id = joinPath(pagePath, 'page').replace(/^\//, ''); + const id = joinPath(routePath, 'page').replace(/^\//, ''); const WrappedComponent = (props: Record) => createElement(page.component as any, { ...props, ...mapping }); registerStaticComponent(id, WrappedComponent); } } else if (page.render === 'dynamic' && numWildcards === 0) { - const pagePath = getGrouplessPath(page.path); - if (pagePath !== page.path) { - groupPathLookup.set(pagePath, page.path); + const routePath = pathnameToRoutePath(getGrouplessPath(page.path)); + if (routePath !== pageRoutePath) { + groupPathLookup.set(routePath, pageRoutePath); } - dynamicPagePathMap.set(pagePath, [pathSpec, page.component]); + dynamicPagePathMap.set(routePath, [pathSpec, page.component]); } else if (page.render === 'dynamic' && numWildcards === 1) { - const pagePath = getGrouplessPath(page.path); - if (pagePath !== page.path) { - groupPathLookup.set(pagePath, page.path); + const routePath = pathnameToRoutePath(getGrouplessPath(page.path)); + if (routePath !== pageRoutePath) { + groupPathLookup.set(routePath, pageRoutePath); } - wildcardPagePathMap.set(pagePath, [pathSpec, page.component]); + wildcardPagePathMap.set(routePath, [pathSpec, page.component]); } else { throw new Error('Invalid page configuration ' + JSON.stringify(page)); } if (page.slices?.length) { - slicePathMap.set(page.path, page.slices); + slicePathMap.set(pageRoutePath, page.slices); } return page as Exclude; }; @@ -520,15 +524,16 @@ export const createPages = < if (configured) { throw new Error('createLayout no longer available'); } + const routePath = pathnameToRoutePath(layout.path); if (layout.render === 'static') { - const id = joinPath(layout.path, 'layout').replace(/^\//, ''); + const id = joinPath(routePath, 'layout').replace(/^\//, ''); registerStaticComponent(id, layout.component); } else if (layout.render === 'dynamic') { - if (dynamicLayoutPathMap.has(layout.path)) { + if (dynamicLayoutPathMap.has(routePath)) { throw new Error(`Duplicated dynamic path: ${layout.path}`); } - const pathSpec = parsePathWithSlug(layout.path); - dynamicLayoutPathMap.set(layout.path, [pathSpec, layout.component]); + const pathSpec = parsePathWithSlug(routePath); + dynamicLayoutPathMap.set(routePath, [pathSpec, layout.component]); } else { throw new Error('Invalid layout configuration'); } @@ -538,10 +543,11 @@ export const createPages = < if (configured) { throw new Error('createApi no longer available'); } - if (apiPathMap.has(options.path)) { + const routePath = pathnameToRoutePath(options.path); + if (apiPathMap.has(routePath)) { throw new Error(`Duplicated api path: ${options.path}`); } - const pathSpec = parsePathWithSlug(options.path); + const pathSpec = parsePathWithSlug(routePath); if (options.render === 'static') { const { numSlugs, numWildcards } = getSlugsAndWildcards(pathSpec); if (numSlugs > 0 && options.staticPaths) { @@ -556,24 +562,25 @@ export const createPages = < pathSpec, staticPath, ); - if (apiPathMap.has(definedPath)) { + const definedRoutePath = pathnameToRoutePath(definedPath); + if (apiPathMap.has(definedRoutePath)) { throw new Error(`Duplicated api path: ${definedPath}`); } - apiPathMap.set(definedPath, { + apiPathMap.set(definedRoutePath, { render: 'static', pathSpec: pathItems.map((name) => ({ type: 'literal', name })), handlers: { GET: options.handler }, }); } } else { - apiPathMap.set(options.path, { + apiPathMap.set(routePath, { render: 'static', pathSpec, handlers: { GET: options.handler }, }); } } else { - apiPathMap.set(options.path, { + apiPathMap.set(routePath, { render: 'dynamic', pathSpec, handlers: options.handlers, @@ -646,7 +653,7 @@ export const createPages = < type ElementSpec = { isStatic: boolean; renderer: (options: { - pathname: string; + routePath: string; query: string | undefined; }) => ReactNode; }; @@ -691,9 +698,9 @@ export const createPages = < createElement( pageComponent, { - ...getPropsMapping(option.pathname), + ...getPropsMapping(option.routePath), ...(option.query ? { query: option.query } : {}), - path: option.pathname, + path: option.routePath, }, , ), @@ -745,9 +752,9 @@ export const createPages = < isStatic: comp.render === 'static', renderer: (option) => createElement(comp.component, { - ...getPropsMapping(option.pathname), + ...getPropsMapping(option.routePath), ...(option.query ? { query: option.query } : {}), - path: option.pathname, + path: option.routePath, }), }; } @@ -759,9 +766,9 @@ export const createPages = < createElement( components, { - ...getPropsMapping(option.pathname), + ...getPropsMapping(option.routePath), ...(option.query ? { query: option.query } : {}), - path: option.pathname, + path: option.routePath, }, , ), @@ -813,9 +820,9 @@ export const createPages = < isStatic: comp.render === 'static', renderer: (option) => createElement(comp.component, { - ...getPropsMapping(option.pathname), + ...getPropsMapping(option.routePath), ...(option.query ? { query: option.query } : {}), - path: option.pathname, + path: option.routePath, }), }; } @@ -827,9 +834,9 @@ export const createPages = < createElement( components, { - ...getPropsMapping(option.pathname), + ...getPropsMapping(option.routePath), ...(option.query ? { query: option.query } : {}), - path: option.pathname, + path: option.routePath, }, , ), diff --git a/packages/waku/src/router/define-router.tsx b/packages/waku/src/router/define-router.tsx index 080d6cd19a..b9c685baa8 100644 --- a/packages/waku/src/router/define-router.tsx +++ b/packages/waku/src/router/define-router.tsx @@ -16,6 +16,7 @@ import { decodeSliceId, encodeRoutePath, encodeSliceId, + pathnameToRoutePath, } from './common.js'; export type ApiHandler = ( @@ -124,18 +125,19 @@ const is404 = (pathSpec: PathSpec) => pathSpec[0]!.type === 'literal' && pathSpec[0]!.name === '404'; -const pathSpec2pathname = (pathSpec: PathSpec) => { +const pathSpecToRoutePath = (pathSpec: PathSpec) => { if (pathSpec.some(({ type }) => type !== 'literal')) { return undefined; } return '/' + pathSpec.map(({ name }) => name!).join('/'); }; -const htmlPath2pathname = (htmlPath: string): string => - htmlPath === '/404' ? '404.html' : htmlPath + '/index.html'; +const routePathToHtmlFilePath = (routePath: string): string => + routePath === '/404' ? '404.html' : routePath + '/index.html'; export function unstable_rerenderRoute(pathname: string, query?: string) { - const rscPath = encodeRoutePath(pathname); + const routePath = pathnameToRoutePath(pathname); + const rscPath = encodeRoutePath(routePath); getRerender()(rscPath, query && new URLSearchParams({ query })); } @@ -144,7 +146,7 @@ export function unstable_notFound(): never { } export function unstable_redirect( - location: string, + location: string, // only URL `pathname` is supported. status: 303 | 307 | 308 = 307, ): never { throw createCustomError('Redirect', { status, location }); @@ -166,7 +168,7 @@ const assertNonReservedSlotId = (slotId: SlotId) => { } }; -type RendererOption = { pathname: string; query: string | undefined }; +type RendererOption = { routePath: string; query: string | undefined }; type RouteConfig = { type: 'route'; @@ -256,11 +258,12 @@ export function unstable_defineRouter(fns: { }; const getPathConfigItem = async (pathname: string) => { + const routePath = pathnameToRoutePath(pathname); const myConfig = await getMyConfig(); const found = myConfig.configs.find( (item): item is typeof item & { type: 'route' | 'api' } => (item.type === 'route' || item.type === 'api') && - !!getPathMapping(item.path, pathname), + !!getPathMapping(item.path, routePath), ); return found; }; @@ -295,8 +298,8 @@ export function unstable_defineRouter(fns: { ) => { setRscPath(rscPath); setRscParams(rscParams); - const pathname = decodeRoutePath(rscPath); - const pathConfigItem = await getPathConfigItem(pathname); + const routePath = decodeRoutePath(rscPath); + const pathConfigItem = await getPathConfigItem(routePath); if (pathConfigItem?.type !== 'route') { return null; } @@ -308,9 +311,9 @@ export function unstable_defineRouter(fns: { } const skipIdSet = new Set(isStringArray(skipParam) ? skipParam : []); const { query } = parseRscParams(rscParams); - const routeId = ROUTE_SLOT_ID_PREFIX + pathname; + const routeId = ROUTE_SLOT_ID_PREFIX + routePath; const option: RendererOption = { - pathname, + routePath, query: pathConfigItem.isStatic ? undefined : query, }; const myConfig = await getMyConfig(); @@ -386,7 +389,7 @@ export function unstable_defineRouter(fns: { entries[id] = sliceElement; }), ]); - entries[ROUTE_ID] = [pathname, query]; + entries[ROUTE_ID] = [routePath, query]; entries[IS_STATIC_ID] = pathConfigItem.isStatic; sliceConfigMap.forEach((sliceConfig, sliceId) => { if (sliceConfig.isStatic) { @@ -538,7 +541,8 @@ export function unstable_defineRouter(fns: { } catch (e) { const info = getErrorInfo(e); if (info?.location) { - const rscPath = encodeRoutePath(info.location); + const routePath = pathnameToRoutePath(info.location); + const rscPath = encodeRoutePath(routePath); const entries = await getEntriesForRoute( rscPath, undefined, @@ -563,7 +567,8 @@ export function unstable_defineRouter(fns: { query: string, httpstatus = 200, ) => { - const rscPath = encodeRoutePath(pathname); + const routePath = pathnameToRoutePath(pathname); + const rscPath = encodeRoutePath(routePath); const rscParams = new URLSearchParams({ query }); const entries = await getEntriesForRoute( rscPath, @@ -578,7 +583,7 @@ export function unstable_defineRouter(fns: { const path2moduleIds = await getPath2moduleIds(); const html = ( ); @@ -661,15 +666,15 @@ export function unstable_defineRouter(fns: { if (!item.isStatic) { continue; } - const pathname = pathSpec2pathname(item.path); - if (!pathname) { + const routePath = pathSpecToRoutePath(item.path); + if (!routePath) { continue; } - const req = new Request(new URL(pathname, 'http://localhost:3000')); + const req = new Request(new URL(routePath, 'http://localhost:3000')); runTask(async () => { await withRequest(req, async () => { const res = await item.handler(req, { params: {} }); - await generateFile(pathname, res.body || ''); + await generateFile(routePath, res.body || ''); }); }); } @@ -685,12 +690,12 @@ export function unstable_defineRouter(fns: { if (!item.isStatic) { continue; } - const pathname = pathSpec2pathname(item.path); - if (!pathname) { + const routePath = pathSpecToRoutePath(item.path); + if (!routePath) { continue; } - const rscPath = encodeRoutePath(pathname); - const req = new Request(new URL(pathname, 'http://localhost:3000')); + const rscPath = encodeRoutePath(routePath); + const req = new Request(new URL(routePath, 'http://localhost:3000')); runTask(async () => { await withRequest(req, async () => { const entries = await getEntriesForRoute( @@ -719,7 +724,7 @@ export function unstable_defineRouter(fns: { htmlRenderTasks.add(async () => { const html = ( ); @@ -728,7 +733,10 @@ export function unstable_defineRouter(fns: { unstable_extraScriptContent: getRouterPrefetchCode(path2moduleIds), }); - await generateFile(htmlPath2pathname(pathname), res.body || ''); + await generateFile( + routePathToHtmlFilePath(routePath), + res.body || '', + ); }); }); }); @@ -743,12 +751,12 @@ export function unstable_defineRouter(fns: { continue; } if (item.noSsr) { - const pathname = pathSpec2pathname(item.path); - if (!pathname) { + const routePath = pathSpecToRoutePath(item.path); + if (!routePath) { throw new Error('Pathname is required for noSsr routes on build'); } runTask(async () => { - await generateDefaultHtml(htmlPath2pathname(pathname)); + await generateDefaultHtml(routePathToHtmlFilePath(routePath)); }); } } diff --git a/packages/waku/src/router/fs-router.ts b/packages/waku/src/router/fs-router.ts index fc93485503..cf0f3b0a85 100644 --- a/packages/waku/src/router/fs-router.ts +++ b/packages/waku/src/router/fs-router.ts @@ -86,7 +86,7 @@ export function fsRouter( ); } else if (pathItems.at(0) === options.apiDir) { // Strip the apiDir prefix from the path (e.g., _api/hello.txt -> hello.txt) - const apiPath = pathItems.slice(1).join('/'); + const apiPath = '/' + pathItems.slice(1).join('/'); if (config?.render === 'static') { if (Object.keys(mod).length !== 2 || !mod.GET) { console.warn( diff --git a/packages/waku/tests/create-pages.test.ts b/packages/waku/tests/create-pages.test.ts index 392306e491..32b4668c51 100644 --- a/packages/waku/tests/create-pages.test.ts +++ b/packages/waku/tests/create-pages.test.ts @@ -1473,6 +1473,23 @@ describe('createPages pages and layouts', () => { await expect(getConfigs).rejects.toThrowError('Duplicated path: /test'); }); + it('fails if canonical and trailing-slash paths override each other', async () => { + createPages(async ({ createPage }) => [ + createPage({ + render: 'dynamic', + path: '/test', + component: () => null, + }), + createPage({ + render: 'dynamic', + path: '/test/' as never, + component: () => null, + }), + ]); + const { getConfigs } = injectedFunctions(); + await expect(getConfigs).rejects.toThrowError('Duplicated path: /test/'); + }); + it('creates a complex router', async () => { const TestPage = vi.fn(); complexTestRouter(createPages, TestPage); diff --git a/packages/waku/tests/inferred-paths.test.ts b/packages/waku/tests/inferred-paths.test.ts index 642152d628..fe0e898ecc 100644 --- a/packages/waku/tests/inferred-paths.test.ts +++ b/packages/waku/tests/inferred-paths.test.ts @@ -3,11 +3,15 @@ import type { TypeEqual } from 'ts-expect'; import { describe, it } from 'vitest'; // Reproduce the AllowPathDecorators type from router/client.tsx to test it directly +type AllowTrailingSlash = Path extends '/' + ? Path + : Path | `${Path}/`; + type AllowPathDecorators = Path extends unknown ? - | Path - | `${Path}?${string}` - | `${Path}#${string}` + | AllowTrailingSlash + | `${AllowTrailingSlash}?${string}` + | `${AllowTrailingSlash}#${string}` | `?${string}` | `#${string}` : never; @@ -17,6 +21,7 @@ describe('AllowPathDecorators', () => { type Result = AllowPathDecorators<'/' | '/foo'>; expectType>(true); expectType>(true); + expectType>(true); }); it('allows paths with query strings', () => { @@ -27,6 +32,9 @@ describe('AllowPathDecorators', () => { expectType>( true, ); + expectType>( + true, + ); }); it('allows paths with hash fragments', () => { @@ -37,6 +45,9 @@ describe('AllowPathDecorators', () => { expectType>( true, ); + expectType>( + true, + ); }); it('allows bare query strings without a path prefix', () => { diff --git a/packages/waku/tests/path.test.ts b/packages/waku/tests/path.test.ts index 1779739dd8..29e244ae4c 100644 --- a/packages/waku/tests/path.test.ts +++ b/packages/waku/tests/path.test.ts @@ -52,12 +52,14 @@ describe('getPathMapping', () => { test('handles literal paths', () => { const pathSpec = parsePathWithSlug('/foo/bar'); expect(getPathMapping(pathSpec, '/foo/bar')).toEqual({}); + expect(getPathMapping(pathSpec, '/foo/bar/')).toEqual({}); expect(getPathMapping(pathSpec, '/foo/baz')).toBe(null); }); test('handles paths with groups', () => { const pathSpec = parsePathWithSlug('/foo/[id]'); expect(getPathMapping(pathSpec, '/foo/123')).toEqual({ id: '123' }); + expect(getPathMapping(pathSpec, '/foo/123/')).toEqual({ id: '123' }); expect(getPathMapping(pathSpec, '/foo/bar')).toEqual({ id: 'bar' }); expect(getPathMapping(pathSpec, '/foo')).toBe(null); }); diff --git a/packages/waku/tests/router-client.test.tsx b/packages/waku/tests/router-client.test.tsx index 06e110d7d5..748be839db 100644 --- a/packages/waku/tests/router-client.test.tsx +++ b/packages/waku/tests/router-client.test.tsx @@ -260,7 +260,7 @@ afterEach(() => { }); describe('router/client utilities', () => { - test('parses route path/query/hash and normalizes trailing suffixes', () => { + test('parses route path/query/hash and canonicalizes path from pathname', () => { const route = unstable_parseRoute( new URL('http://localhost/foo/index.html?count=2#hash'), ); @@ -276,6 +276,13 @@ describe('router/client utilities', () => { query: 'q=1', hash: '', }); + + const route3 = unstable_parseRoute(new URL('http://localhost/baz/?q=1')); + expect(route3).toEqual({ + path: '/baz', + query: 'q=1', + hash: '', + }); }); test('reads httpstatus meta content', () => { @@ -405,7 +412,7 @@ describe('useRouter + Link with context', () => { expect(changeRoute).toHaveBeenNthCalledWith( 3, { path: '/start', query: '', hash: '' }, - { shouldScroll: true }, + { shouldScroll: true, refetch: true }, ); const firstUrl = ( (changeRoute.mock.calls[0] as unknown[] | undefined)?.[1] as diff --git a/packages/waku/tests/rscPath.test.ts b/packages/waku/tests/rscPath.test.ts index 555d17cea3..01b251979f 100644 --- a/packages/waku/tests/rscPath.test.ts +++ b/packages/waku/tests/rscPath.test.ts @@ -5,7 +5,11 @@ import { encodeFuncId, encodeRscPath, } from '../src/lib/utils/rsc-path.js'; -import { decodeRoutePath, encodeRoutePath } from '../src/router/common.js'; +import { + decodeRoutePath, + encodeRoutePath, + pathnameToRoutePath, +} from '../src/router/common.js'; describe('encodeRscPath', () => { test('encodes rscPath', () => { @@ -79,6 +83,20 @@ describe('decodeFuncId', () => { }); }); +describe('pathnameToRoutePath', () => { + test('canonicalizes trailing slash and index.html', () => { + expect(pathnameToRoutePath('/')).toBe('/'); + expect(pathnameToRoutePath('/foo')).toBe('/foo'); + expect(pathnameToRoutePath('/foo/')).toBe('/foo'); + expect(pathnameToRoutePath('/index.html')).toBe('/'); + expect(pathnameToRoutePath('/foo/index.html')).toBe('/foo'); + }); + + test('throws on invalid pathname', () => { + expect(() => pathnameToRoutePath('foo')).toThrow(); + }); +}); + describe('encodeRoutePath', () => { test('encodes routePath', () => { expect(encodeRoutePath('/')).toBe('R/_root'); @@ -89,6 +107,8 @@ describe('encodeRoutePath', () => { test('throws on invalid routePath', () => { expect(() => encodeRoutePath('foo')).toThrow(); expect(() => encodeRoutePath('/foo/')).toThrow(); + expect(() => encodeRoutePath('/index.html')).toThrow(); + expect(() => encodeRoutePath('/foo/index.html')).toThrow(); }); });