From 00aff64a1d69e4b3868d05fab75f9a6bbcbd4c5f Mon Sep 17 00:00:00 2001 From: Dhaya <154633+dhayab@users.noreply.github.com> Date: Mon, 9 Dec 2024 09:55:06 +0100 Subject: [PATCH] fix(next-app-router): prevent client-side search when rerendering (#6452) * fix(next-app-router): prevent client-side search when rerendering * ensure search is performed when remounted on client-side route change --- .../src/InstantSearchNext.tsx | 22 +++++- .../src/__tests__/InstantSearchNext.test.tsx | 73 +++++++++++++++++++ 2 files changed, 93 insertions(+), 2 deletions(-) create mode 100644 packages/react-instantsearch-nextjs/src/__tests__/InstantSearchNext.test.tsx diff --git a/packages/react-instantsearch-nextjs/src/InstantSearchNext.tsx b/packages/react-instantsearch-nextjs/src/InstantSearchNext.tsx index d540bdc968..63d46d3096 100644 --- a/packages/react-instantsearch-nextjs/src/InstantSearchNext.tsx +++ b/packages/react-instantsearch-nextjs/src/InstantSearchNext.tsx @@ -1,5 +1,6 @@ import { safelyRunOnBrowser } from 'instantsearch.js/es/lib/utils'; import { headers } from 'next/headers'; +import { usePathname } from 'next/navigation'; import React, { useEffect, useRef } from 'react'; import { InstantSearch, @@ -20,9 +21,11 @@ import type { } from 'react-instantsearch-core'; const InstantSearchInitialResults = Symbol.for('InstantSearchInitialResults'); +const InstantSearchLastPath = Symbol.for('InstantSearchLastPath'); declare global { interface Window { [InstantSearchInitialResults]?: InitialResults; + [InstantSearchLastPath]?: string; } } @@ -47,6 +50,17 @@ export function InstantSearchNext< ...instantSearchProps }: InstantSearchNextProps) { const isMounting = useRef(true); + const isServer = typeof window === 'undefined'; + const pathname = usePathname(); + const hasRouteChanged = + !isServer && + window[InstantSearchLastPath] && + window[InstantSearchLastPath] !== pathname; + + // We only want to trigger a search from a server environment + // or if a Next.js route change has happened on the client + const shouldTriggerSearch = isServer || hasRouteChanged; + useEffect(() => { isMounting.current = false; return () => { @@ -55,6 +69,10 @@ export function InstantSearchNext< }; }, []); + useEffect(() => { + window[InstantSearchLastPath] = pathname; + }, [pathname]); + const nonce = safelyRunOnBrowser(() => undefined, { fallback: () => headers().get('x-nonce') || undefined, }); @@ -77,9 +95,9 @@ This message will only be displayed in development mode.` - {!initialResults && } + {shouldTriggerSearch && } {children} - {!initialResults && } + {shouldTriggerSearch && } diff --git a/packages/react-instantsearch-nextjs/src/__tests__/InstantSearchNext.test.tsx b/packages/react-instantsearch-nextjs/src/__tests__/InstantSearchNext.test.tsx new file mode 100644 index 0000000000..684c853e4b --- /dev/null +++ b/packages/react-instantsearch-nextjs/src/__tests__/InstantSearchNext.test.tsx @@ -0,0 +1,73 @@ +/** + * @jest-environment jsdom + */ + +import { createSearchClient } from '@instantsearch/mocks'; +import { wait } from '@instantsearch/testutils'; +import { act, render } from '@testing-library/react'; +import React from 'react'; +import { SearchBox } from 'react-instantsearch'; + +import { InstantSearchNext } from '../InstantSearchNext'; + +const mockPathname = jest.fn(); +jest.mock('next/navigation', () => ({ + ...jest.requireActual('next/navigation'), + usePathname() { + return mockPathname(); + }, +})); + +describe('rerendering', () => { + const client = createSearchClient(); + + function Component() { + return ( + + + + ); + } + + beforeEach(() => { + (client.search as jest.Mock).mockClear(); + }); + + it('does not trigger a client-side search by default', async () => { + const { rerender } = render(); + + await act(async () => { + await wait(0); + }); + + rerender(); + + await act(async () => { + await wait(0); + }); + + expect(client.search).toHaveBeenCalledTimes(0); + }); + + it('triggers a client-side search on route change', async () => { + mockPathname.mockImplementation(() => '/a'); + const { rerender } = render(); + + await act(async () => { + await wait(0); + }); + + mockPathname.mockImplementation(() => '/b'); + rerender(); + + await act(async () => { + await wait(0); + }); + + expect(client.search).not.toHaveBeenCalledTimes(0); + }); +}); + +afterAll(() => { + jest.resetAllMocks(); +});