From 216bdc1c33251a59818919ca3966af3ed6f89f86 Mon Sep 17 00:00:00 2001 From: Haroen Viaene Date: Fri, 29 May 2026 16:46:17 +0200 Subject: [PATCH 1/2] fix(react-instantsearch-nextjs): refresh results on client-side navigation (#7060) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #6995 stopped the routing effect from calling `onUpdate` on its first run to avoid wiping the URL with a nested `` (#6980). But on a client-side navigation the App Router remounts `InstantSearchNext`, so that mount also looks like a first run and `onUpdate` was skipped — no fresh search ran and the page kept whatever `window[InstantSearchInitialResults]` held (empty or stale) until a full reload. Tell a genuine initial hydration apart from a client-side navigation using the Navigation Timing API: `performance.getEntriesByType('navigation')[0].name` holds the URL the document was hard-loaded with and is unaffected by SPA navigations. When the current path differs from it, the mount is a client-side navigation and we run `onUpdate` to refresh the results; when it matches, it is the initial hydration and we skip `onUpdate`, preserving #6980. This also covers the case where the navigation lands on the first InstantSearch page of the session (e.g. coming from a page without InstantSearch). The comparison uses `window.location.pathname` so it stays consistent when a `basePath` is set (`usePathname()` strips it). A `window` fallback handles environments without Navigation Timing. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../next-app-router/app/landing/page.tsx | 17 +++ .../__tests__/e2e/softNavigation.test.ts | 21 +++ .../useInstantSearchRouting.test.tsx | 131 ++++++++++++++++++ .../src/useInstantSearchRouting.ts | 58 +++++++- 4 files changed, 223 insertions(+), 4 deletions(-) create mode 100644 examples/react/next-app-router/app/landing/page.tsx create mode 100644 packages/react-instantsearch-nextjs/__tests__/e2e/softNavigation.test.ts diff --git a/examples/react/next-app-router/app/landing/page.tsx b/examples/react/next-app-router/app/landing/page.tsx new file mode 100644 index 00000000000..4221e44445a --- /dev/null +++ b/examples/react/next-app-router/app/landing/page.tsx @@ -0,0 +1,17 @@ +import Link from 'next/link'; +import React from 'react'; + +// A plain page with no InstantSearch, mirroring the reproduction in #7060 +// where the first soft-navigation lands on an InstantSearch page. +export const dynamic = 'force-dynamic'; + +export default function Landing() { + return ( +
+

Landing

+ + Go to Appliances + +
+ ); +} diff --git a/packages/react-instantsearch-nextjs/__tests__/e2e/softNavigation.test.ts b/packages/react-instantsearch-nextjs/__tests__/e2e/softNavigation.test.ts new file mode 100644 index 00000000000..3c13e3de9ec --- /dev/null +++ b/packages/react-instantsearch-nextjs/__tests__/e2e/softNavigation.test.ts @@ -0,0 +1,21 @@ +import { test, expect } from '@playwright/test'; + +// Reproduces https://github.com/algolia/instantsearch/issues/7060: +// on a client-side (soft) navigation that lands on the first InstantSearch page +// of the session (here, coming from a page without InstantSearch), the +// server-rendered results stopped being reflected and the page showed empty +// hits until a full reload. The `/landing` page has no InstantSearch, so this +// exercises exactly that case. Verified to fail before the fix (0 hits). +test.describe('client-side navigation refreshes results', () => { + test('renders results when navigating from a page without InstantSearch', async ({ + page, + }) => { + await page.goto('/landing'); + + await page.locator('#to-appliances').click(); + await expect(page).toHaveURL('http://localhost:3000/Appliances'); + + await expect(page.locator('.ais-Hits-item').first()).toBeVisible(); + await expect(page.locator('.ais-Hits-item')).not.toHaveCount(0); + }); +}); diff --git a/packages/react-instantsearch-nextjs/src/__tests__/useInstantSearchRouting.test.tsx b/packages/react-instantsearch-nextjs/src/__tests__/useInstantSearchRouting.test.tsx index 95438212e77..1daaa9c4a32 100644 --- a/packages/react-instantsearch-nextjs/src/__tests__/useInstantSearchRouting.test.tsx +++ b/packages/react-instantsearch-nextjs/src/__tests__/useInstantSearchRouting.test.tsx @@ -22,11 +22,49 @@ jest.mock('next/navigation', () => ({ }, })); +// Count how many times the router's `onUpdate` callback is invoked. The hook +// captures `onUpdate` through the history router's `start` option, so we wrap +// `start` to increment a counter whenever the stored callback fires. +let onUpdateCalls = 0; +jest.mock('instantsearch.js/es/lib/routers/history', () => { + const actual = jest.requireActual('instantsearch.js/es/lib/routers/history'); + return { + __esModule: true, + ...actual, + default: (options: { start?: (onUpdate: () => void) => void }) => { + const originalStart = options.start; + if (typeof originalStart === 'function') { + options.start = (onUpdate: () => void) => + originalStart(() => { + onUpdateCalls += 1; + return onUpdate(); + }); + } + return actual.default(options); + }, + }; +}); + describe('routing', () => { beforeEach(() => { mockPathname.mockReturnValue('/search'); mockSearchParams.mockReturnValue(new URLSearchParams()); window.history.replaceState({}, '', '/search'); + onUpdateCalls = 0; + // The hook reads the document's initial path from the Navigation Timing + // API to tell hydration apart from client-side navigation. jsdom doesn't + // implement it, so we define it to simulate a document hard-loaded on + // `/search` (this also exercises the production path rather than the + // fallback). + Object.defineProperty(performance, 'getEntriesByType', { + configurable: true, + value: () => [{ name: 'http://localhost/search' }], + }); + }); + + afterEach(() => { + delete (performance as unknown as Record) + .getEntriesByType; }); // Reproduces https://github.com/algolia/instantsearch/issues/6980: @@ -170,6 +208,99 @@ describe('routing', () => { expect(window.location.search).toBe('?indexName%5Bquery%5D=iphone'); }); + + // Reproduces https://github.com/algolia/instantsearch/issues/7060: + // on client-side navigation the App Router remounts `InstantSearchNext`, so + // the routing effect runs as a "first run" again. It must still call + // `onUpdate` to refresh the results, otherwise the new page shows stale or + // empty hits until a full reload. + it('runs onUpdate when remounting on a new route after navigation', async () => { + const indexName = 'indexName'; + const routing = { router: { writeDelay: 0 } }; + + // Initial hydration on `/search`. + const { unmount } = render( + + + + ); + + await act(async () => { + await wait(0); + }); + + // The initial render must not re-run `onUpdate` (it would wipe the URL with + // a nested ``, see #6980). + expect(onUpdateCalls).toBe(0); + + unmount(); + + // Simulate a client-side navigation to a different route, then mount a fresh + // instance for it (as the App Router does on a soft navigation). + mockPathname.mockReturnValue('/category'); + mockSearchParams.mockReturnValue(new URLSearchParams()); + window.history.pushState({}, '', '/category'); + + render( + + + + ); + + await act(async () => { + await wait(0); + }); + + // The navigation-induced mount must run `onUpdate` so the results refresh. + expect(onUpdateCalls).toBe(1); + }); + + it('does not run onUpdate when another instance mounts on the same route', async () => { + const indexName = 'indexName'; + const routing = { router: { writeDelay: 0 } }; + + const { unmount } = render( + + + + ); + + await act(async () => { + await wait(0); + }); + + unmount(); + + // Same route, no navigation (e.g. a second `InstantSearchNext` on the + // initial page): a freshly mounted instance must not re-run `onUpdate`. + render( + + + + ); + + await act(async () => { + await wait(0); + }); + + expect(onUpdateCalls).toBe(0); + }); }); afterAll(() => { diff --git a/packages/react-instantsearch-nextjs/src/useInstantSearchRouting.ts b/packages/react-instantsearch-nextjs/src/useInstantSearchRouting.ts index 9bde4fb2274..bdf5ada0f17 100644 --- a/packages/react-instantsearch-nextjs/src/useInstantSearchRouting.ts +++ b/packages/react-instantsearch-nextjs/src/useInstantSearchRouting.ts @@ -9,6 +9,44 @@ import type { UiState } from 'instantsearch.js'; import type { BrowserHistoryArgs } from 'instantsearch.js/es/lib/routers/history'; import type { InstantSearchProps } from 'react-instantsearch-core'; +// Fallback store for the first path an `InstantSearchNext` instance rendered +// with, used only in environments without the Navigation Timing API (e.g. +// jsdom in tests). See `getInitialPath` below. +const InstantSearchDocumentPath = Symbol.for('InstantSearchDocumentPath'); +declare global { + interface Window { + [InstantSearchDocumentPath]?: string; + } +} + +/** + * The pathname the document was initially loaded with. + * + * It comes from the Navigation Timing API, which reflects the hard page load + * and is *not* affected by client-side (SPA) navigations, so it lets us tell a + * genuine initial hydration apart from a client-side navigation — even when the + * navigation lands on the very first `InstantSearchNext` page of the session + * (e.g. coming from a page without InstantSearch, see #7060). + * + * Falls back to the first path any instance rendered with (stored on `window`) + * when Navigation Timing is unavailable. + */ +function getInitialPath(currentPath: string): string { + try { + const [entry] = performance.getEntriesByType('navigation'); + if (entry && entry.name) { + return new URL(entry.name).pathname; + } + } catch (e) { + // Navigation Timing not available; fall through to the `window` fallback. + } + + if (window[InstantSearchDocumentPath] === undefined) { + window[InstantSearchDocumentPath] = currentPath; + } + return window[InstantSearchDocumentPath]; +} + export function useInstantSearchRouting< TUiState extends UiState = UiState, TRouteState = TUiState @@ -23,9 +61,6 @@ export function useInstantSearchRouting< useRef['routing']>(null); const onUpdateRef = useRef<() => void>(null); const isUnmounting = useRef(false); - // Skip the on-mount fire of the effect below: `subscribe()` already merges - // the URL into `_initialUiState`, and a redundant `setUiState` can wipe the - // URL with a nested `` (see #6980). const previousRouteRef = useRef(null); useEffect(() => { @@ -39,7 +74,22 @@ export function useInstantSearchRouting< previousRouteRef.current = currentRoute; if (isFirstRun) { - return; + // First run of a freshly mounted instance. On the genuine initial + // hydration we must skip `onUpdate`: `subscribe()` already merged the URL + // into `_initialUiState`, and a redundant `setUiState` can wipe the URL + // with a nested `` (see #6980/#6995). But when the document was + // initially loaded on a different path, this mount is the result of a + // client-side navigation and we must run `onUpdate` to refresh the + // results, otherwise the new page shows stale or empty hits (see #7060). + // We compare `window.location.pathname` (not `usePathname()`) so the + // comparison is consistent with the Navigation Timing URL when a + // `basePath` is configured (`usePathname()` strips it, the others don't). + const currentPath = window.location.pathname; + const isClientNavigation = getInitialPath(currentPath) !== currentPath; + + if (!isClientNavigation) { + return; + } } if (onUpdateRef.current) { From f72964f5f5667f96653ef4717cfdc186ce3385c4 Mon Sep 17 00:00:00 2001 From: Haroen Viaene Date: Fri, 29 May 2026 17:22:33 +0200 Subject: [PATCH 2/2] refactor(react-instantsearch-nextjs): refresh on navigation instead of re-running onUpdate Address review feedback on the soft-navigation fix. The previous approach re-ran the router's `onUpdate` on a client-side navigation's first render. As pointed out in review, that re-introduced the #6980 failure mode on soft navigation: `onUpdate` writes the URL, and on a nested `` page whose children only register on a second render pass it serialized incomplete state and wiped the URL. It also only compared the pathname, missing same-path/different-search navigations. Instead, leave `useInstantSearchRouting` exactly as #6995 left it and add a small client-only `RefreshOnClientNavigation` component that calls `search.refresh()` when it detects a client-side navigation. `refresh()` re-fetches with the already-correct UI state and never touches the URL, so it fixes #7060 without any #6980 risk. Navigation detection uses the Navigation Timing API (`pathname` + `search`, hash ignored), with a `window` fallback for environments without it. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/InstantSearchNext.tsx | 2 + .../src/RefreshOnClientNavigation.ts | 82 +++++++++ .../RefreshOnClientNavigation.test.tsx | 165 ++++++++++++++++++ .../useInstantSearchRouting.test.tsx | 131 -------------- .../src/useInstantSearchRouting.ts | 58 +----- 5 files changed, 253 insertions(+), 185 deletions(-) create mode 100644 packages/react-instantsearch-nextjs/src/RefreshOnClientNavigation.ts create mode 100644 packages/react-instantsearch-nextjs/src/__tests__/RefreshOnClientNavigation.test.tsx diff --git a/packages/react-instantsearch-nextjs/src/InstantSearchNext.tsx b/packages/react-instantsearch-nextjs/src/InstantSearchNext.tsx index c32939ac5ec..8ccc6ab5762 100644 --- a/packages/react-instantsearch-nextjs/src/InstantSearchNext.tsx +++ b/packages/react-instantsearch-nextjs/src/InstantSearchNext.tsx @@ -7,6 +7,7 @@ import { } from 'react-instantsearch-core'; import { InitializePromise } from './InitializePromise'; +import { RefreshOnClientNavigation } from './RefreshOnClientNavigation'; import { TriggerSearch } from './TriggerSearch'; import { useDynamicRouteWarning } from './useDynamicRouteWarning'; import { useInstantSearchRouting } from './useInstantSearchRouting'; @@ -85,6 +86,7 @@ export function InstantSearchNext< {isServer && } {children} {isServer && } + {!isServer && } ); diff --git a/packages/react-instantsearch-nextjs/src/RefreshOnClientNavigation.ts b/packages/react-instantsearch-nextjs/src/RefreshOnClientNavigation.ts new file mode 100644 index 00000000000..de724308ef6 --- /dev/null +++ b/packages/react-instantsearch-nextjs/src/RefreshOnClientNavigation.ts @@ -0,0 +1,82 @@ +import { useEffect, useRef } from 'react'; +import { useInstantSearchContext } from 'react-instantsearch-core'; + +// Fallback store for the first location an `InstantSearchNext` instance +// rendered with, used only in environments without the Navigation Timing API +// (e.g. jsdom in tests). See `getInitialLocation` below. +const InstantSearchDocumentLocation = Symbol.for( + 'InstantSearchDocumentLocation' +); +declare global { + interface Window { + [InstantSearchDocumentLocation]?: string; + } +} + +/** + * The `pathname` + `search` the document was initially loaded with. + * + * It comes from the Navigation Timing API, which reflects the hard page load + * and is *not* affected by client-side (SPA) navigations. The hash is ignored + * as it isn't part of the routing state. Falls back to the first location any + * instance rendered with (stored on `window`) when Navigation Timing is + * unavailable. + */ +function getInitialLocation(currentLocation: string): string { + try { + const [entry] = performance.getEntriesByType('navigation'); + if (entry && entry.name) { + const url = new URL(entry.name); + return url.pathname + url.search; + } + } catch (e) { + // Navigation Timing not available; fall through to the `window` fallback. + } + + if (window[InstantSearchDocumentLocation] === undefined) { + window[InstantSearchDocumentLocation] = currentLocation; + } + return window[InstantSearchDocumentLocation]; +} + +/** + * Whether the current location differs from the one the document was initially + * loaded with — i.e. whether we reached this render through a client-side (SPA) + * navigation rather than the initial hydration. + */ +export function isClientNavigation(): boolean { + const currentLocation = window.location.pathname + window.location.search; + return getInitialLocation(currentLocation) !== currentLocation; +} + +/** + * Refreshes the search after a client-side navigation. + * + * On a client-side navigation the App Router remounts `InstantSearchNext` with + * the server-streamed `initialResults`, which can be empty (e.g. coming from a + * page without InstantSearch) or stale, and the instance doesn't search again + * on its own — leaving the new page with no or outdated hits until a full + * reload (#7060). When we detect such a navigation we refresh the search so it + * re-fetches with the (already correct) current UI state. + * + * Unlike re-running the router's `onUpdate`, `refresh()` doesn't touch the URL, + * so it can't wipe the URL of a nested `` whose children only register + * on a second render pass (#6980). + */ +export function RefreshOnClientNavigation() { + const search = useInstantSearchContext(); + const hasRefreshed = useRef(false); + + useEffect(() => { + if (hasRefreshed.current) { + return; + } + hasRefreshed.current = true; + + if (search.started && isClientNavigation()) { + search.refresh(); + } + }, [search]); + + return null; +} diff --git a/packages/react-instantsearch-nextjs/src/__tests__/RefreshOnClientNavigation.test.tsx b/packages/react-instantsearch-nextjs/src/__tests__/RefreshOnClientNavigation.test.tsx new file mode 100644 index 00000000000..f9b2f9681cf --- /dev/null +++ b/packages/react-instantsearch-nextjs/src/__tests__/RefreshOnClientNavigation.test.tsx @@ -0,0 +1,165 @@ +/** + * @jest-environment @instantsearch/testutils/jest-environment-jsdom.ts + */ + +import { createSearchClient } from '@instantsearch/mocks'; +import { wait } from '@instantsearch/testutils'; +import { act, render } from '@testing-library/react'; +import React from 'react'; +import { Index, SearchBox } from 'react-instantsearch'; +import { useInstantSearchContext } from 'react-instantsearch-core'; + +import { InstantSearchNext } from '../InstantSearchNext'; +import { isClientNavigation } from '../RefreshOnClientNavigation'; + +import type { InstantSearch } from 'instantsearch.js'; + +const mockPathname = jest.fn(); +const mockSearchParams = jest.fn(); +jest.mock('next/navigation', () => ({ + ...jest.requireActual('next/navigation'), + usePathname() { + return mockPathname(); + }, + useSearchParams() { + return mockSearchParams(); + }, +})); + +// The component reads the document's initial URL from the Navigation Timing +// API to tell hydration apart from a client-side navigation. jsdom doesn't +// implement it, so we define it (which also exercises the production path +// rather than the `window` fallback). `navigationInitialHref` models reality: +// it stays fixed at the hard-loaded URL while client-side navigations change +// `window.location`. `null` means "no navigation yet" (hydration), so the entry +// reflects the current location. +let navigationInitialHref: string | null = null; + +// Captures the InstantSearch instance and spies on `refresh` during render, so +// the spy is installed before the `RefreshOnClientNavigation` effect runs. +let capturedSearch: InstantSearch | null = null; +function CaptureInstance() { + const search = useInstantSearchContext(); + if (!jest.isMockFunction(search.refresh)) { + jest.spyOn(search, 'refresh'); + } + capturedSearch = search; + return null; +} + +beforeEach(() => { + mockPathname.mockReturnValue('/search'); + mockSearchParams.mockReturnValue(new URLSearchParams()); + window.history.replaceState({}, '', '/search'); + navigationInitialHref = null; + capturedSearch = null; + Object.defineProperty(performance, 'getEntriesByType', { + configurable: true, + value: () => [{ name: navigationInitialHref ?? window.location.href }], + }); +}); + +afterEach(() => { + delete (performance as unknown as Record).getEntriesByType; +}); + +describe('isClientNavigation', () => { + test('is false when the current location matches the initial document', () => { + window.history.replaceState({}, '', '/search?q=iphone'); + expect(isClientNavigation()).toBe(false); + }); + + test('is true when the pathname differs from the initial document', () => { + navigationInitialHref = 'http://localhost/other'; + window.history.pushState({}, '', '/search'); + expect(isClientNavigation()).toBe(true); + }); + + test('is true when only the search params differ from the initial document', () => { + navigationInitialHref = 'http://localhost/search?q=old'; + window.history.pushState({}, '', '/search?q=new'); + expect(isClientNavigation()).toBe(true); + }); +}); + +describe('RefreshOnClientNavigation', () => { + test('refreshes the search on a client-side navigation', async () => { + // Document loaded elsewhere, then client-navigated to `/search`. + navigationInitialHref = 'http://localhost/other'; + window.history.pushState({}, '', '/search'); + + await act(async () => { + render( + + + + + ); + }); + await act(async () => { + await wait(0); + }); + + expect(capturedSearch!.refresh).toHaveBeenCalledTimes(1); + }); + + test('does not refresh on the initial hydration', async () => { + await act(async () => { + render( + + + + + ); + }); + await act(async () => { + await wait(0); + }); + + expect(capturedSearch!.refresh).not.toHaveBeenCalled(); + }); + + // The fix must not reintroduce the #6980 URL wipe on a soft navigation: when + // landing on a nested- page via client-side navigation, refreshing the + // results must not touch the URL (refresh() doesn't, unlike re-running the + // router's onUpdate before the nested children register). + test('preserves the URL on a soft navigation to a nested ', async () => { + const indexName = 'instant_search'; + const indexId = `${indexName}_web`; + + navigationInitialHref = 'http://localhost/other'; + mockSearchParams.mockReturnValue(new URLSearchParams('q=iphone')); + window.history.pushState({}, '', '/search?q=iphone'); + + await act(async () => { + render( + + + + + + + ); + }); + await act(async () => { + await wait(0); + }); + + expect(capturedSearch!.refresh).toHaveBeenCalledTimes(1); + expect(window.location.search).toBe('?q=iphone'); + }); +}); diff --git a/packages/react-instantsearch-nextjs/src/__tests__/useInstantSearchRouting.test.tsx b/packages/react-instantsearch-nextjs/src/__tests__/useInstantSearchRouting.test.tsx index 1daaa9c4a32..95438212e77 100644 --- a/packages/react-instantsearch-nextjs/src/__tests__/useInstantSearchRouting.test.tsx +++ b/packages/react-instantsearch-nextjs/src/__tests__/useInstantSearchRouting.test.tsx @@ -22,49 +22,11 @@ jest.mock('next/navigation', () => ({ }, })); -// Count how many times the router's `onUpdate` callback is invoked. The hook -// captures `onUpdate` through the history router's `start` option, so we wrap -// `start` to increment a counter whenever the stored callback fires. -let onUpdateCalls = 0; -jest.mock('instantsearch.js/es/lib/routers/history', () => { - const actual = jest.requireActual('instantsearch.js/es/lib/routers/history'); - return { - __esModule: true, - ...actual, - default: (options: { start?: (onUpdate: () => void) => void }) => { - const originalStart = options.start; - if (typeof originalStart === 'function') { - options.start = (onUpdate: () => void) => - originalStart(() => { - onUpdateCalls += 1; - return onUpdate(); - }); - } - return actual.default(options); - }, - }; -}); - describe('routing', () => { beforeEach(() => { mockPathname.mockReturnValue('/search'); mockSearchParams.mockReturnValue(new URLSearchParams()); window.history.replaceState({}, '', '/search'); - onUpdateCalls = 0; - // The hook reads the document's initial path from the Navigation Timing - // API to tell hydration apart from client-side navigation. jsdom doesn't - // implement it, so we define it to simulate a document hard-loaded on - // `/search` (this also exercises the production path rather than the - // fallback). - Object.defineProperty(performance, 'getEntriesByType', { - configurable: true, - value: () => [{ name: 'http://localhost/search' }], - }); - }); - - afterEach(() => { - delete (performance as unknown as Record) - .getEntriesByType; }); // Reproduces https://github.com/algolia/instantsearch/issues/6980: @@ -208,99 +170,6 @@ describe('routing', () => { expect(window.location.search).toBe('?indexName%5Bquery%5D=iphone'); }); - - // Reproduces https://github.com/algolia/instantsearch/issues/7060: - // on client-side navigation the App Router remounts `InstantSearchNext`, so - // the routing effect runs as a "first run" again. It must still call - // `onUpdate` to refresh the results, otherwise the new page shows stale or - // empty hits until a full reload. - it('runs onUpdate when remounting on a new route after navigation', async () => { - const indexName = 'indexName'; - const routing = { router: { writeDelay: 0 } }; - - // Initial hydration on `/search`. - const { unmount } = render( - - - - ); - - await act(async () => { - await wait(0); - }); - - // The initial render must not re-run `onUpdate` (it would wipe the URL with - // a nested ``, see #6980). - expect(onUpdateCalls).toBe(0); - - unmount(); - - // Simulate a client-side navigation to a different route, then mount a fresh - // instance for it (as the App Router does on a soft navigation). - mockPathname.mockReturnValue('/category'); - mockSearchParams.mockReturnValue(new URLSearchParams()); - window.history.pushState({}, '', '/category'); - - render( - - - - ); - - await act(async () => { - await wait(0); - }); - - // The navigation-induced mount must run `onUpdate` so the results refresh. - expect(onUpdateCalls).toBe(1); - }); - - it('does not run onUpdate when another instance mounts on the same route', async () => { - const indexName = 'indexName'; - const routing = { router: { writeDelay: 0 } }; - - const { unmount } = render( - - - - ); - - await act(async () => { - await wait(0); - }); - - unmount(); - - // Same route, no navigation (e.g. a second `InstantSearchNext` on the - // initial page): a freshly mounted instance must not re-run `onUpdate`. - render( - - - - ); - - await act(async () => { - await wait(0); - }); - - expect(onUpdateCalls).toBe(0); - }); }); afterAll(() => { diff --git a/packages/react-instantsearch-nextjs/src/useInstantSearchRouting.ts b/packages/react-instantsearch-nextjs/src/useInstantSearchRouting.ts index bdf5ada0f17..9bde4fb2274 100644 --- a/packages/react-instantsearch-nextjs/src/useInstantSearchRouting.ts +++ b/packages/react-instantsearch-nextjs/src/useInstantSearchRouting.ts @@ -9,44 +9,6 @@ import type { UiState } from 'instantsearch.js'; import type { BrowserHistoryArgs } from 'instantsearch.js/es/lib/routers/history'; import type { InstantSearchProps } from 'react-instantsearch-core'; -// Fallback store for the first path an `InstantSearchNext` instance rendered -// with, used only in environments without the Navigation Timing API (e.g. -// jsdom in tests). See `getInitialPath` below. -const InstantSearchDocumentPath = Symbol.for('InstantSearchDocumentPath'); -declare global { - interface Window { - [InstantSearchDocumentPath]?: string; - } -} - -/** - * The pathname the document was initially loaded with. - * - * It comes from the Navigation Timing API, which reflects the hard page load - * and is *not* affected by client-side (SPA) navigations, so it lets us tell a - * genuine initial hydration apart from a client-side navigation — even when the - * navigation lands on the very first `InstantSearchNext` page of the session - * (e.g. coming from a page without InstantSearch, see #7060). - * - * Falls back to the first path any instance rendered with (stored on `window`) - * when Navigation Timing is unavailable. - */ -function getInitialPath(currentPath: string): string { - try { - const [entry] = performance.getEntriesByType('navigation'); - if (entry && entry.name) { - return new URL(entry.name).pathname; - } - } catch (e) { - // Navigation Timing not available; fall through to the `window` fallback. - } - - if (window[InstantSearchDocumentPath] === undefined) { - window[InstantSearchDocumentPath] = currentPath; - } - return window[InstantSearchDocumentPath]; -} - export function useInstantSearchRouting< TUiState extends UiState = UiState, TRouteState = TUiState @@ -61,6 +23,9 @@ export function useInstantSearchRouting< useRef['routing']>(null); const onUpdateRef = useRef<() => void>(null); const isUnmounting = useRef(false); + // Skip the on-mount fire of the effect below: `subscribe()` already merges + // the URL into `_initialUiState`, and a redundant `setUiState` can wipe the + // URL with a nested `` (see #6980). const previousRouteRef = useRef(null); useEffect(() => { @@ -74,22 +39,7 @@ export function useInstantSearchRouting< previousRouteRef.current = currentRoute; if (isFirstRun) { - // First run of a freshly mounted instance. On the genuine initial - // hydration we must skip `onUpdate`: `subscribe()` already merged the URL - // into `_initialUiState`, and a redundant `setUiState` can wipe the URL - // with a nested `` (see #6980/#6995). But when the document was - // initially loaded on a different path, this mount is the result of a - // client-side navigation and we must run `onUpdate` to refresh the - // results, otherwise the new page shows stale or empty hits (see #7060). - // We compare `window.location.pathname` (not `usePathname()`) so the - // comparison is consistent with the Navigation Timing URL when a - // `basePath` is configured (`usePathname()` strips it, the others don't). - const currentPath = window.location.pathname; - const isClientNavigation = getInitialPath(currentPath) !== currentPath; - - if (!isClientNavigation) { - return; - } + return; } if (onUpdateRef.current) {