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/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'); + }); +});