Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions examples/react/next-app-router/app/landing/page.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div>
<h1>Landing</h1>
<Link href="/Appliances" id="to-appliances">
Go to Appliances
</Link>
</div>
);
}
Original file line number Diff line number Diff line change
@@ -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);
});
});
2 changes: 2 additions & 0 deletions packages/react-instantsearch-nextjs/src/InstantSearchNext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -85,6 +86,7 @@ export function InstantSearchNext<
{isServer && <InitializePromise nonce={nonce} />}
{children}
{isServer && <TriggerSearch nonce={nonce} />}
{!isServer && <RefreshOnClientNavigation />}
</InstantSearch>
</ServerOrHydrationProvider>
);
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Comment on lines +47 to +49
}

/**
* 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 `<Index>` 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;
}
Original file line number Diff line number Diff line change
@@ -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<string, unknown>).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(
<InstantSearchNext searchClient={createSearchClient()} indexName="i">
<CaptureInstance />
<SearchBox />
</InstantSearchNext>
);
});
await act(async () => {
await wait(0);
});

expect(capturedSearch!.refresh).toHaveBeenCalledTimes(1);
});

test('does not refresh on the initial hydration', async () => {
await act(async () => {
render(
<InstantSearchNext searchClient={createSearchClient()} indexName="i">
<CaptureInstance />
<SearchBox />
</InstantSearchNext>
);
});
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-<Index> 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 <Index> children register).
test('preserves the URL on a soft navigation to a nested <Index>', 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(
<InstantSearchNext
searchClient={createSearchClient()}
routing={{
router: { writeDelay: 0 },
stateMapping: {
stateToRoute(uiState) {
const query = uiState[indexId]?.query;
return query ? { q: query } : {};
},
routeToState(routeState: { q?: string } = {}) {
return { [indexId]: { query: routeState.q } };
},
},
}}
>
<CaptureInstance />
<Index indexName={indexName} indexId={indexId}>
<SearchBox />
</Index>
</InstantSearchNext>
);
});
await act(async () => {
await wait(0);
});

expect(capturedSearch!.refresh).toHaveBeenCalledTimes(1);
expect(window.location.search).toBe('?q=iphone');
});
});
Loading