diff --git a/.changeset/large-rockets-push.md b/.changeset/large-rockets-push.md new file mode 100644 index 0000000000..99f158c890 --- /dev/null +++ b/.changeset/large-rockets-push.md @@ -0,0 +1,5 @@ +--- +'@tanstack/react-router': patch +--- + +Avoid hydration-triggered rerenders for links that do not compare URL hashes while preserving hash-sensitive active state and ClientOnly behavior. diff --git a/packages/react-router/src/ClientOnly.tsx b/packages/react-router/src/ClientOnly.tsx index c139b05d0a..010a6d3117 100644 --- a/packages/react-router/src/ClientOnly.tsx +++ b/packages/react-router/src/ClientOnly.tsx @@ -56,8 +56,20 @@ export function ClientOnly({ children, fallback = null }: ClientOnlyProps) { * ``` * @returns True if the JS has been hydrated already, false otherwise. */ -export function useHydrated(): boolean { - return React.useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot) +export function useHydrated(): boolean +/** + * @internal + * callers whose output does not depend on hydration can keep the + * same snapshot on both sides, avoiding an unnecessary post-hydration render. + */ +export function useHydrated(enabled: boolean): boolean +/** @internal */ +export function useHydrated(enabled = true): boolean { + return React.useSyncExternalStore( + subscribe, + getSnapshot, + enabled ? getServerSnapshot : getSnapshot, + ) } function subscribe() { diff --git a/packages/react-router/src/link.tsx b/packages/react-router/src/link.tsx index 26ec05a848..94bf0f5ad1 100644 --- a/packages/react-router/src/link.tsx +++ b/packages/react-router/src/link.tsx @@ -257,7 +257,7 @@ export function useLinkProps< } = options as typeof options & { to?: string } // eslint-disable-next-line react-hooks/rules-of-hooks - const isHydrated = useHydrated() + const isHydrated = useHydrated(!!activeOptions?.includeHash) // eslint-disable-next-line react-hooks/rules-of-hooks const [stableSearch, stableParams, stableActiveOptions] = useStableValues( diff --git a/packages/react-router/tests/link-hydration.test.tsx b/packages/react-router/tests/link-hydration.test.tsx new file mode 100644 index 0000000000..497464b3f8 --- /dev/null +++ b/packages/react-router/tests/link-hydration.test.tsx @@ -0,0 +1,203 @@ +import React from 'react' +import { renderToString } from 'react-dom/server' +import { hydrateRoot } from 'react-dom/client' +import { act } from '@testing-library/react' +import { expect, test, vi } from 'vitest' +import { + Link, + RouterContextProvider, + createMemoryHistory, + createRootRoute, + createRoute, + createRouter, +} from '../src' + +test('only hash-sensitive links need a second hydration render', async () => { + const rootRoute = createRootRoute() + const router = createRouter({ + routeTree: rootRoute.addChildren([ + createRoute({ getParentRoute: () => rootRoute, path: '/target' }), + ]), + history: createMemoryHistory({ initialEntries: ['/target#section'] }), + }) + await router.load() + const serverRouter = createRouter({ + routeTree: router.routeTree, + history: createMemoryHistory({ initialEntries: ['/target'] }), + isServer: true, + }) + await serverRouter.load() + const ordinary = vi.fn(({ isActive }: { isActive: boolean }) => + String(isActive), + ) + const hashSensitive = vi.fn(({ isActive }: { isActive: boolean }) => + String(isActive), + ) + const tree = (includeHash = false, contextRouter = router) => ( + + + {ordinary} + + + {hashSensitive} + + + ) + const container = document.createElement('div') + document.body.append(container) + container.innerHTML = renderToString(tree(false, serverRouter)) + expect(container.querySelector('[data-testid="ordinary"]')?.textContent).toBe( + 'true', + ) + expect(container.querySelector('[data-testid="hash"]')?.textContent).toBe( + 'false', + ) + ordinary.mockClear() + hashSensitive.mockClear() + const onRecoverableError = vi.fn() + const diagnostics = vi.spyOn(console, 'error').mockImplementation(() => {}) + const serverAnchor = container.querySelector('[data-testid="ordinary"]') + let root: ReturnType | undefined + try { + await act(() => { + root = hydrateRoot(container, tree(), { onRecoverableError }) + }) + expect(ordinary).toHaveBeenCalledTimes(1) + expect(container.querySelector('[data-testid="ordinary"]')).toBe( + serverAnchor, + ) + expect(hashSensitive.mock.calls.map(([state]) => state.isActive)).toEqual([ + false, + true, + ]) + expect(container.querySelector('[data-testid="hash"]')).toHaveAttribute( + 'aria-current', + 'page', + ) + expect(container.querySelector('[data-testid="hash"]')).toHaveAttribute( + 'title', + 'active', + ) + await act(() => root!.render(tree(true))) + expect( + container.querySelector('[data-testid="ordinary"]'), + ).not.toHaveAttribute('aria-current') + expect(container.querySelector('[data-testid="ordinary"]')).toHaveAttribute( + 'title', + 'inactive', + ) + await act(() => root!.render(tree(false))) + expect(container.querySelector('[data-testid="ordinary"]')).toHaveAttribute( + 'aria-current', + 'page', + ) + expect(onRecoverableError).not.toHaveBeenCalled() + expect(diagnostics).not.toHaveBeenCalled() + } finally { + await act(() => root?.unmount()) + router.history.destroy() + serverRouter.history.destroy() + diagnostics.mockRestore() + container.remove() + } +}) + +test('hash-sensitive links retain the server snapshot in a delayed hydration boundary', async () => { + const rootRoute = createRootRoute() + const routeTree = rootRoute.addChildren([ + createRoute({ getParentRoute: () => rootRoute, path: '/target' }), + ]) + const serverRouter = createRouter({ + routeTree, + history: createMemoryHistory({ initialEntries: ['/target'] }), + isServer: true, + }) + const clientRouter = createRouter({ + routeTree, + history: createMemoryHistory({ initialEntries: ['/target#section'] }), + }) + await Promise.all([serverRouter.load(), clientRouter.load()]) + let ready = true + let resolve!: () => void + const pending = new Promise((done) => { + resolve = done + }) + const ordinary = vi.fn(() => 'ordinary') + const hashSensitive = vi.fn(({ isActive }: { isActive: boolean }) => + String(isActive), + ) + function DeferredLinks() { + if (!ready) { + throw pending + } + return ( + <> + {ordinary} + + {hashSensitive} + + + ) + } + const tree = (router: typeof clientRouter) => ( + + Loading

}> + +
+
+ ) + const container = document.createElement('div') + document.body.append(container) + container.innerHTML = renderToString(tree(serverRouter)) + const serverAnchor = container.querySelector('a') + ordinary.mockClear() + hashSensitive.mockClear() + ready = false + const onRecoverableError = vi.fn() + const diagnostics = vi.spyOn(console, 'error').mockImplementation(() => {}) + let root: ReturnType | undefined + try { + await act(() => { + root = hydrateRoot(container, tree(clientRouter), { onRecoverableError }) + }) + expect(ordinary).not.toHaveBeenCalled() + expect(hashSensitive).not.toHaveBeenCalled() + await act(async () => { + ready = true + resolve() + await pending + }) + expect(ordinary).toHaveBeenCalledTimes(1) + expect(hashSensitive.mock.calls.map(([state]) => state.isActive)).toEqual([ + false, + true, + ]) + expect(container.querySelector('a')).toBe(serverAnchor) + expect(container.querySelectorAll('a')[1]).toHaveAttribute( + 'aria-current', + 'page', + ) + expect(onRecoverableError).not.toHaveBeenCalled() + expect(diagnostics).not.toHaveBeenCalled() + } finally { + await act(() => root?.unmount()) + serverRouter.history.destroy() + clientRouter.history.destroy() + diagnostics.mockRestore() + container.remove() + } +})