algolia / instantsearch

⚡️ Libraries for building performant and instant search and recommend experiences with Algolia. Compatible with JavaScript, TypeScript, React and Vue.
https://www.algolia.com/doc/guides/building-search-ui/what-is-instantsearch/js/
MIT License
3.67k stars 515 forks source link

Instantsearch hooks: router not getting along with Next.JS #5241

Closed dsbrianwebster closed 1 year ago

dsbrianwebster commented 2 years ago

We are trying out a server rendered search implementation with NextJS. All seems okay until we try to add a routing object to InstantSearch. We are using routing to have urls for our search state, and to make them SEO friendly (q=potato&type=tuber). There are all sorts of quirks though, from additional re-renders on load to the rest of the application routing breaking.

1. Re-Render Example: If we try starting at url /search?q=potato we immediately see a re-render to /search.

2. Routing Example: When we click on a hit/result <Link href={/post/${hit.slug}>🥔 hit</Link> we are taken to our expected /post/${hit.slug} url, but then from there our routing in general seems to be broken. Clicking back moves to /search?q=potato, but only the url changes. Page content is not updated.

StepBrothers_3lg copy

Haroenv commented 2 years ago

Just to double check, is that with using the getLocation call, like in the hooks-next example? https://codesandbox.io/s/github/algolia/react-instantsearch/tree/master/examples/hooks-next

It's definitely not a simple problem keeping the state and routers in sync, so if there's a specific edge case missed I'd love to fix that

francoischalifour commented 2 years ago

Hey @dsbrianwebster – this is likely because Next.js is using Strict Mode, which isn't yet supported in the library with React 18.

Our <InstantSearch> component reads the URL query params to set the initial state, and write the browser URL on unmount to reset it. This is a first-class behavior of the underlying InstantSearch.js library. The problem is that during the second Strict Mode render, the URL has been reset, and therefore the second render is unable to recover the initial state. This breaks URL synchronization.

We're working on React 18 Strict Mode support this week so you can expect a fix in the coming days.

In the mean time, could you disable Strict Mode on the <InstantSearch> component?

Edit: Strict Mode support was introduced in v6.28.0.

dsbrianwebster commented 2 years ago

Hi @francoischalifour. Thanks for the response! Yeah we have been seeing a bunch of "additional render' issues with other packages after upgrading to React 18 and pretty much roll with strictMode off by default on all projects for the time being.

I can confirm our next config is...

Screen Shot 2022-06-07 at 2 07 01 PM

and we still experience these issues.

dsbrianwebster commented 2 years ago

Hi @Haroenv thanks for the response. And yes, no doubt, def not a simple problem keeping the state and router in sync. Its feels so close 🏁 ! I've forked your sandbox to demonstrate item 2 (Routing example above)...

https://codesandbox.io/s/hooks-next-bug-3506-item-2-6ekug6?file=/pages/index.tsx

To reproduce...

  1. perform a search. either a query or refinement -- anything that triggers a URL change.
  2. Then click on one of the results where you will be routed to '/test'
  3. The click back, the url will change but you will be stuck on the test page

Perhaps issue is something to do with how the InstantSearch component / router is unmounted.

dsbrianwebster commented 2 years ago

@francoischalifour + @Haroenv we've dug a little deeper on Item 1 (The Re-Render Example) and seem to have isolated the issue we're experience there as having something to do with our effort to implement an SEO-friendly URL structure. We're still investigating and will either circle back with an answer there or a sandbox example.

The sandbox provided in the previous comment is still very relevant for @Haroenv as it pertains to item algolia/react-instantsearch#2 edge case.

joshgeller commented 2 years ago

I'm also experiencing the second issue (broken back button).

  1. Refine hits via <RefinementList>
  2. <InstantSearch> appends stringified refinements to the URL
  3. Click a <Link> to go to hit content page
  4. Click back, the URL changes but the page content does not

Possible clue for narrowing down the issue:

  1. Refine hits with <RefinementList>
  2. <InstantSearch> appends stringified refinements to the URL
  3. Refresh the page (so that state is initialized from URL)
  4. Click <Link> to go to hit content page
  5. Click back and everything works correctly
klaasman commented 2 years ago

I'm running into issue no. 2 here as well, the broken back button. I think it's caused by the instantsearch.js history implementation performing it's own history mutations, over here:

https://github.com/algolia/instantsearch.js/blob/0a517609de103eef4f8edfefe6e28a1d79a14209/src/lib/routers/history.ts#L142

e.g., try the following on any app rendered by next.js (in this example I'll use https://nextjs.org/)

  1. visit https://nextjs.org/
  2. open dev tools console
  3. run command history.pushState({}, '', '/test-foo-bar') and see the url get updated to the new pathname. (this mimics an updated url due to filtering)
  4. click the nav item "Showcase" (this mimics clicking a search result)
  5. hit the back button
  6. the pathname goes from /showcase to /test-foo-bar, in the UI nothing happens, you'll stay on the "showcase" page.

~The algolia hook history API needs something like custom routing method in order to fix this.~

~e.g. calling context:~

import { InstantSearch } from "react-instantsearch-hooks-web";
import { history } from 'instantsearch.js/es/lib/routers';
import { useRouter } from "next/router";

const router = useRouter();

<InstantSearch
  routing={{
    router: history({
      // please note that `onUpdate` is not an existing part of the api
      onUpdate: (url) => router.push(url),
    })
  }}
/>

~When an onUpdate is provided the history instance should obviously no more do it's own history mutations.~

~This also alllows us to perform a history.replace() instead of history.push() which makes more sense in some filtering contexts.~

Edit: the above is already possible with a custom history handler as suggested below.

Haroenv commented 2 years ago

You can provide a custom router, you don't need to use history, the methods are described here https://www.algolia.com/doc/api-reference/widgets/instantsearch/js/#widget-param-routing

I'd advise looking at the implementation of the history router, because there's some edge cases we already cover there as a base

joshgeller commented 2 years ago
import { InstantSearch } from "react-instantsearch-hooks-web";
import { history } from 'instantsearch.js/es/lib/routers';
import { useRouter } from "next/router";

const router = useRouter();

<InstantSearch
  routing={{
    router: history({
      onUpdate: (url) => router.push(url),
    })
  }}
/>

Edit: the above is already possible with a custom history handler as suggested below.

@klaasman Do you actually have this working? Because it doesn't look like the history router accepts an onUpdate function. onUpdate is used internally, but I don't see a way to pass a custom function. Same goes for several other options mentioned in @Haroenv's link above.

It would be nice if history allowed the user to pass custom functions for onUpdate, read, write, etc. I'd rather not fork and maintain this router if I can avoid it.

Please let me know if I'm misunderstanding any of this.

klaasman commented 2 years ago

@joshgeller Sorry for the lack of clarity, what I meant is that it would be nice to make that onUpdate part of the api.

FYI: I've copied the original history.ts into my repo and made some adjustments in order to use the nextjs router instead - it certainly hasn't been cleaned up but feel free to take a look here: https://gist.github.com/klaasman/b0e06e8f63dbae70829424232285570c

DB-Alex commented 2 years ago

Hey @dsbrianwebster – this is likely because Next.js is using Strict Mode, which isn't yet supported in the library with React 18.

Our <InstantSearch> component reads the URL query params to set the initial state, and write the browser URL on unmount to reset it. This is a first-class behavior of the underlying InstantSearch.js library. The problem is that during the second Strict Mode render, the URL has been reset, and therefore the second render is unable to recover the initial state. This breaks URL synchronization.

We're working on React 18 Strict Mode support this week so you can expect a fix in the coming days.

In the mean time, could you disable Strict Mode on the <InstantSearch> component?

Can you maybe tell us the status of the Strict Mode support and good routing in next?

francoischalifour commented 2 years ago

@DB-Alex Strict mode support was released in v6.28.0.

Do you have other routing issues than the ones mentioned in this thread?

saybou commented 2 years ago

Hi @francoischalifour , i'm still experiencing the 2 issues mentioned by @dsbrianwebster (i'm using nextjs 12 & react 17), especially the broken back button

DB-Alex commented 2 years ago

I also have this warning:

dev: warn  - ../../node_modules/react-instantsearch-hooks-server/dist/es/getServerState.js
dev: Critical dependency: the request of a dependency is an expression

And when I switch pages in Next like this:

Its crashing in the instantsearch clean up:

TypeError: Cannot read properties of null (reading 'state')

Call Stack
eval
../../node_modules/instantsearch.js/es/widgets/index/index.js (530:0)
Array.forEach
<anonymous>
Object.dispose
../../node_modules/instantsearch.js/es/widgets/index/index.js (520:0)
eval
../../node_modules/instantsearch.js/es/widgets/index/index.js (254:0)
Array.reduce
<anonymous>
Object.removeWidgets
../../node_modules/instantsearch.js/es/widgets/index/index.js (252:0)
InstantSearch.removeWidgets
../../node_modules/instantsearch.js/es/lib/InstantSearch.js (357:0)
InstantSearch.dispose
../../node_modules/instantsearch.js/es/lib/InstantSearch.js (494:0)
eval
../../node_modules/react-instantsearch-hooks/dist/es/lib/useInstantSearchApi.js (40:0)
safelyCallDestroy
../../node_modules/react-dom/cjs/react-dom.development.js (22932:0)
commitHookEffectListUnmount
../../node_modules/react-dom/cjs/react-dom.development.js (23100:0)
commitPassiveUnmountInsideDeletedTreeOnFiber
../../node_modules/react-dom/cjs/react-dom.development.js (25098:0)
commitPassiveUnmountEffectsInsideOfDeletedTree_begin
../../node_modules/react-dom/cjs/react-dom.development.js (25048:0)
commitPassiveUnmountEffects_begin
../../node_modules/react-dom/cjs/react-dom.development.js (24956:0)
commitPassiveUnmountEffects
../../node_modules/react-dom/cjs/react-dom.development.js (24941:0)
flushPassiveEffectsImpl
../../node_modules/react-dom/cjs/react-dom.development.js (27038:0)
flushPassiveEffects
../../node_modules/react-dom/cjs/react-dom.development.js (26984:0)
eval
../../node_modules/react-dom/cjs/react-dom.development.js (26769:0)
workLoop
../../node_modules/scheduler/cjs/scheduler.development.js (266:0)
flushWork
../../node_modules/scheduler/cjs/scheduler.development.js (239:0)
MessagePort.performWorkUntilDeadline
../../node_modules/scheduler/cjs/scheduler.development.js (533:0)

Here is a snippet of line 530

localWidgets.forEach(function (widget) {
        if (widget.dispose) {
          // The dispose function is always called once the instance is started
          // (it's an effect of `removeWidgets`). The index is initialized and
          // the Helper is available. We don't care about the return value of
          // `dispose` because the index is removed. We can't call `removeWidgets`
          // because we want to keep the widgets on the instance, to allow idempotent
          // operations on `add` & `remove`.
          widget.dispose({
            helper: helper,
            state: helper.state, <----- 530
            parent: _this5
          });
        }
      });
nico-mautone commented 2 years ago

Hi, I'm also still experiencing issue number 2, mentioned by @dsbrianwebster (i'm using nextjs 12 & react 18). Has anyone come up to a solution? The custom router implementation maybe? Thanks

RudiPersson commented 2 years ago

@dhayab posted a workaround using Next useRouter and useEffect in a discussion. Worked for me :)

https://github.com/algolia/react-instantsearch/discussions/3376#discussioncomment-2297218

nico-mautone commented 2 years ago

Thanks, but I couldn't make it work with that approach as I have a whole application with tons of search state, that is not attached to a single React useState as it is on the sandbox example. Because of that, I wasn't able to apply URL changes to the algolia UiState, because I don't have access to a function such as "setIndexUiState" provided by useInstantSearch, because hooks can't be called outside the InstatSearch Wrapper. (in the sandbox it uses directly useState setter).

nico-mautone commented 2 years ago

Thanks @dhmacs, I used that workaround for the moment, triggering a page reload once the popstate occurs over the search page. Something simple like this for the moment, until issue is fixed:

useEffect(() => {
    const handleRouteChange = () => {
      if (window.location.href.includes('${searchPage}')) window.location.reload();
    };
    window.addEventListener('popstate', handleRouteChange);
    return () => {
      window.removeEventListener('popstate', handleRouteChange);
    };
  }, []);
leeran7 commented 2 years ago

I'm experiencing the same back button routing bug as @dsbrianwebster. I recreated it and you can use this vercel link and this source code to check it out.

JulesVerdijk commented 2 years ago

I ran into this problem as well and created a, pretty hacky, fix that seems to work (not used in production yet, so fingers crossed). What I did was taking full control of the routing, not using the router provided by Instantsearch.

Requirement for this solution is a helper that can convert an UiState to a URL (like the createURL function in the algolia router) and back again (like the parseURL function).

In basis the solution works like this:

const AlgoliaCollection: React.FC<any> = ({ serverState }) => {
    const nextRouter = useRouter();

    const onStateChange: InstantSearchProps['onStateChange'] = ({ uiState }) => {
        setUiStateFromAlgolia(uiState);
        const urlForUiState = uiStateToUrl(uiState, INDEX_NAME);

        nextRouter.push(urlForUiState, undefined, { shallow: true });

    return (
        <InstantSearchSSRProvider {...serverState}>
            <InstantSearch searchClient={algoliaClient} indexName={INDEX_NAME} onStateChange={onStateChange}>
                <RefinementList />
                <Hits />
            </InstantSearch>
        </InstantSearchSSRProvider>
    );
}

export default AlgoliaCollection;

So you now control the updating of the UiState yourself, and use this to update the NextRouter accordingly. However this is not enough, it works one way only. On onStateChange the route changes, but if you route through Next (e.g. press the back button) the UiState does not update accordingly. To be able to do this you need to be able to setUiState on a routing event, but InstantSearch does not propegate a setUiState function outside of the onStateChange prop (can we please get that Algolia?). However, this function ís available in child-components in the useInstantSearch hook. I abused this to set the UiState based on routing changes, something like this:

const UpdateStateBasedOnRoute: React.FC<{ uiStateFromAlgolia: UiState, indexName: string }> = ({ uiStateFromAlgolia, indexName }) => {
    const { setUiState } = useInstantSearch();
    const nextRouter = useRouter();

    // This fires on every uiStateFromAlgoliaChange
    useEffect(() => {
        // Create a URL based on the uiStateFromAlgolia (which is set in `AlgoliaCollection` based on the onStateChange event
        const urlFromUiState = uiStateToUrl(uiStateFromAlgolia, indexName);

        // Create a URL based on the actual route
        const urlFromRouter = nextRouter.asPath.includes('?') ? `?${nextRouter.asPath.split('?')[1]}` : ``;

        // These URLs should be identical, if not, routing has probably changed and thus the UiState should be updated accordingly
        //
        if (urlFromUiState !== urlFromRouter) {
            const newUiState = urlToUiState(`${process.env.NEXT_PUBLIC_WEB_URL}${nextRouter.asPath}`, indexName);
            setUiState(newUiState);
        } else {
            setUiState(uiStateFromAlgolia);
        }
    }, [uiStateFromAlgolia, nextRouter]);

    return null;
};

// uiStateFromServer is a URL to UiState done on the server, so the `uiStateFromAlgolia` is correct on first load
const AlgoliaCollection: React.FC<any> = ({ serverState, uiStateFromServer }) => {
    const nextRouter = useRouter();

    const [uiStateFromAlgolia, setUiStateFromAlgolia] = useState(uiStateFromServer);

    const onStateChange: InstantSearchProps['onStateChange'] = ({ uiState }) => {
        setUiStateFromAlgolia(uiState);
        const urlForUiState = uiStateToUrl(uiState, INDEX_NAME);

        nextRouter.push(urlForUiState, undefined, { shallow: true }).then(() => {});
    };

    return (
        <InstantSearchSSRProvider {...serverState}>
            <InstantSearch searchClient={algoliaClient} indexName={INDEX_NAME} onStateChange={onStateChange}>
                <UpdateStateBasedOnRoute uiStateFromAlgolia={uiStateFromAlgolia} indexName={INDEX_NAME} />
                <RefinementList />
                <Hits />
            </InstantSearch>
        </InstantSearchSSRProvider>
    );
}

export default AlgoliaCollection;

Note that I pass uiStateFromAlgolia as a prop to UpdateStateBasedOnRoute , but it is also available from useInstantSearch however I found passing it made it update more smoothly (it is a hacky solution!). With again a disclaimer that this works in a development environment but I haven’t used it in production yet.

This would work a lot more smoothly if we could actually get a setUiState prop directly on InstantSearch so this can all be controlled within the main component instead of in an empty child component.

tadinski commented 2 years ago

Experiencing the same routing problem as described by @dsbrianwebster in n.2 issue.

AryanJ-NYC commented 2 years ago

@JulesVerdijk your solution mostly worked but broke our infinite scrolling.

We've decided to go back to the old instantsearch package which is a shame.

@dhayab, is there an ETA on a fix?

tadinski commented 2 years ago

@dhayab posted a workaround using Next useRouter and useEffect in a discussion. Worked for me :)

#3376 (comment)

This workaround works for me.

dhayab commented 2 years ago

Hi, here's a sandbox with another workaround that shares some similarities with @JulesVerdijk : https://codesandbox.io/s/rish-next-router-handler-is22ev?file=/pages/%5Bcategory%5D.tsx.

It consists of a useNextRouterHandler() Hook that returns:

It required an explicit definition of the route/state mapping, and supports Next.js dynamic routes (by specifying the route query to inject).

I tested it on some sandbox shared in the issues, and it works well. Feel free to share below if there are edge cases that are not handled by this workaround.

AryanJ-NYC commented 2 years ago

Hi, here's a sandbox with another workaround that shares some similarities with @JulesVerdijk : https://codesandbox.io/s/rish-next-router-handler-is22ev?file=/pages/%5Bcategory%5D.tsx.

It consists of a useNextRouterHandler() Hook that returns:

  • an initialUiState that needs to be passed to <InstantSearch> (for SSR)
  • a <NextRouterHandler /> empty component that should be mounted in <InstantSearch> (to be able to use Hooks from React InstantSearch Hooks)

It required an explicit definition of the route/state mapping, and supports Next.js dynamic routes (by specifying the route query to inject).

I tested it on some sandbox shared in the issues, and it works well. Feel free to share below if there are edge cases that are not handled by this workaround.

Is this workaround permanent or will react-instantsearch-hooks natively support next.js in the future?

dhayab commented 2 years ago

It's a temporary solution until we finish implementation and ship native support for third-party router later this year.

tremby commented 2 years ago

@dhayab, in my implementation it's not practical to enable the server-side rendering. I tried simply not wrapping with InstantSearchSSRProvider but while everything appears to be working, I get some Error: Loading initial props cancelled messages in the console, which concern me.

Am I right to be concerned? If so, what can I do? If not, do you know how can I suppress these errors?

Thanks so much.

dhayab commented 2 years ago

Hi @tremby, could you share a code sandbox where you can reproduce your issue? You can use https://codesandbox.io/s/rish-next-router-handler-is22ev?file=/pages/%5Bcategory%5D.tsx as a starting point if that helps.

jacobsfletch commented 2 years ago

I was able to partially fix this by syncing history changes to next/router in the background from within the createURL method, like this:

import Router from 'next/router';
import { InstantSearch } from 'react-instantsearch-hooks-web';

<InstantSearch
  routing={{
    createURL() {
      const href = 'YOUR_HREF'
      Router.push(href, undefined, { shallow: true });
      return href;
    }
  }}
/>

For more context, my project is server rendered but does not server render this component. My implementation continues to use history from the instant search router, but reports changes to next/router as they happen. Here is more of my component:

import Router from 'next/router';
import { InstantSearch } from 'react-instantsearch-hooks-web';
import algoliasearch, { SearchClient } from 'algoliasearch/lite';
import { history } from 'instantsearch.js/es/lib/routers';

const searchClient: SearchClient = algoliasearch(YOUR_APP_ID, YOUR_API_KEY);
const algoliaIndex = YOUR_ALGOLIA_INDEX;

...

<InstantSearch
    searchClient={searchClient}
    indexName={algoliaIndex}
    initialUiState={{
        [algoliaIndex]: {
            configure: {
                hitsPerPage: 12,
                filters: allFilters,
                facetingAfterDistinct: true,
            },
            ...initialURLState
        }
    }}
    routing={{
        router: history({
            parseURL({ qsModule, location }) {
                const search = location.search.slice(1);
                const parsedSearch = qsModule.parse(search) as UiState;
                return parsedSearch;
            },
            createURL({ qsModule, location, routeState }) {
                const { origin, pathname, hash } = location;
                const queryString = qsModule.stringify(routeState, {
                    encode: true,
                    arrayFormat: 'repeat',
                    addQueryPrefix: true
                });

                const encodedString = queryString // try and keep a human-friendly url, decode brackets
                    .replace(/%5B/g, "[")
                    .replace(/%5D/g, "]");

                const href = `${origin}${pathname}${encodedString}${hash}`;

                Router.push(href, undefined, { shallow: true });

                return href;
            },
        }),
        stateMapping: {
            // @ts-ignore
            stateToRoute(uiState: UiState): IndexUiState {
                const indexUiState = uiState[algoliaIndex];
                delete indexUiState.configure;
                return indexUiState;
            },
            // @ts-ignore
            routeToState(routeState: IndexUiState): IndexUiState {
                return routeState
            },
        },
    }}
>
    ...
</InstantSearch>
jacobsfletch commented 2 years ago

^ To add to my comment above, this is considered a "partial fix" because it only fixes full-page transitions, not shallow ones. Here's a breakdown:

Works:

  1. Filter the search
  2. Click a result
  3. Once there, click "back" using native browser navigation
  4. Returns to previous page with the previous filters correctly applied

Does not work:

  1. Filter the search
  2. Click "back" using native browser navigation
  3. Changes the URL correctly but does not apply the search filter
JanKups commented 1 year ago

Following the Docs implementation of SEO-friendly URLs also causes the issue mentioned above.

Interesting to note is that it seems that "createURL" and "stateToRoute" are called on the page of the Hit/result.

@dhayab Your fix including NextRouterHandler works great. I do have an edge case: using subdomains for a multi-tenant setup (https://github.com/vercel/platforms). The url send to useNextRouterHandler becomes undefined . Do you see any red flags using a subdomain?

ddaoxuan commented 1 year ago

@dhayab do we have an ETA for the native support solution? by that I mean 3rd party routing going along with Algolia InstantSearch

Maybe to have like an example working with NextJS, including SSG, SSR and CSR. The user flow would be like that land on a category (SSG) => facet click to filtered category(SSR) => and for example change sorting (CSR)

bramski commented 1 year ago

Need this. I'm pretty frustrated after 2 years of developing nextJS applications with algolia that this hasn't been addressed.

haras-unicorn commented 1 year ago

I also needed this and figured out how to do this until there's official support. It's a bit hacky but it works for my use case.

Here's the router code (I suggest you put it in a .js file):

import { history as InstantSearchHistory } from 'instantsearch.js/es/lib/routers';
import router from 'next/router';

export function history(url) {
  const newHistory = InstantSearchHistory({
    getLocation() {
      if (typeof window === 'undefined') {
        return new URL(url);
      }

      return window.location;
    },
  });

  newHistory.write = function (routeState) {
    if (typeof window === 'undefined') {
      return;
    }

    const url = this.createURL(routeState);
    const title = this.windowTitle && this.windowTitle(routeState);
    if (this.writeTimer) {
      clearTimeout(this.writeTimer);
    }
    this.writeTimer = setTimeout(() => {
      if (title) {
        window.document.title = title;
      }
      const shouldWrite = this.shouldWrite(url);
      if (shouldWrite) {
        router.push(url, url);
        this.pushed = true;
        this.latestAcknowledgedHistory = window.history.length;
      }
      this.inPopState = false;
      this.externalChange = false;
      this.writeTimer = undefined;
    }, this.writeDelay);
  }.bind(newHistory);

  newHistory.onUpdate = function (callback) {
    if (typeof window === 'undefined') {
      return;
    }

    this._onPopState = (url) => {
      if (this.writeTimer) {
        clearTimeout(this.writeTimer);
        this.writeTimer = undefined;
      }

      this.inPopState = true;

      const newRoute = this.read();
      callback(newRoute);
    };

    this._onRouteChangeComplete = (url) => {
      if (this.writeTimer) {
        clearTimeout(this.writeTimer);
        this.writeTimer = undefined;
      }

      if (this.pushed) {
        this.pushed = false;
        return;
      }

      this.externalChange = true;

      const newRoute = this.read();
      callback(newRoute);
    };

    router.events.on('routeChangeComplete', this._onRouteChangeComplete);
    router.beforePopState((event) => this._onPopState(event.url));
  }.bind(newHistory);

  newHistory.dispose = function () {
    this.isDisposed = true;

    if (this.writeTimer) {
      clearTimeout(this.writeTimer);
    }

    this.write({});
  }.bind(newHistory);

  newHistory.shouldWrite = function (url) {
    if (typeof window === 'undefined') {
      return false;
    }

    const lastPushWasByISAfterDispose = !(this.isDisposed && this.latestAcknowledgedHistory !== window.history.length);

    return !this.inPopState && !this.externalChange && lastPushWasByISAfterDispose && url !== window.location.href;
  }.bind(newHistory);

  return newHistory;
}

Use it like so:

import { history } from '<where you put the router file>';
import { InstantSearch } from 'react-instantsearch-hooks-web';
import { useMemo } from 'react';

export const Component = ({
  url,
  // ...
}) => {
  // ...

  return (
    <InstantSearch
      {/* ... */}
      routing={useMemo(
        () =>
          url
            ? {
                router: history(url),
              }
            : false,
        [url]
      )}
    >
      {/* ... */}
    </InstantSearch>
  );
};

The url comes from getServerSideProps and is used for SSR like so:

import { Component } from '<where you put your component>';
import { getServerState } from 'react-instantsearch-hooks-server';
import { InstantSearchSSRProvider } from 'react-instantsearch-hooks-web';

const Page = ({ url, serverState, /* ... */ }) => {
  // ...

  return (
    <InstantSearchSSRProvider {...serverState}>
      <Component url={url}>
        {/* ... */}
      </Component>
    </InstantSearchSSRProvider>
  );
};

export async function getServerSideProps(ctx) {
  // maybe there's a better way to get the url but this works
  const { req } = ctx;
  const protocol = req.headers.referer?.split('://')[0] || 'https';
  const serverUrl = `${protocol}://${req.headers.host}${req.url}`;
  const url = serverUrl;

  const serverState = await getServerState(<App Component={Page} pageProps={{ url, /* ... */ }} />);

  return { props: { url, serverState, /* ... */ } };
}
bline108 commented 1 year ago

Any progress on a native solution? Thanks!

henry9207 commented 1 year ago

Algolia, when will the solution be released? No one is responsible? Hey, it is something critical........

stephenandekian commented 1 year ago

Algolia team, just want to chime in here in the event anyone is listening to this thread. Native support for this issue really critical. Can you please provide an update on where this fix is on the roadmap and when it can be expected, if ever?

hussainb commented 1 year ago

Is it possible to disable InstantSearch's URL change and handle it manually? I mean, when a search is fired there should be no change in the URL.

aymeric-giraudet commented 1 year ago

Hi all, we are currently working on a solution, and we came up with the idea of a package that lets you create a custom router that is compatible with Next.js and fixes the back button issue as well as the dynamic routing issue.

It needs to be its own package because it has next as a peerDependency so we can use its router.

Before moving forward we'd appreciate getting your feedback. For this we released this experimental package instantsearch-router-next-experimental Please note that as its name implies, this one should only be used for testing. If the solution is validated a new packaged with a definitive name and API will get published.

To use it, install it :

yarn add instantsearch-router-next-experimental
# or if you're using npm
npm install instantsearch-router-next-experimental

Then you can instantiate it and pass it in the routing prop of the <InstantSearch> component :

import { createInstantSearchNextRouter } from 'instantsearch-router-next-experimental';

export default function Page({ serverState, url }) {
  return (
    <InstantSearchSSRProvider {...serverState}>
      <InstantSearch
        searchClient={searchClient}
        indexName="instant_search"
        routing={{ router: createInstantSearchNextRouter({ serverUrl: url }) }}
      >
        {/* ... */}
      </InstantSearch>
    </InstantSearchSSRProvider>
  );
}

If you are not using SSR therefore not having a url prop, you can simply call createInstantSearchNextRouter without any argument. And if you have custom routing methods such as createUrl or parseUrl you may pass them in the object argument as well. You can find more info about usage on the npm page

You may find the source code in the package itself or on this branch.

We're looking forward to hearing how it works for you. It can be a reaction to this comment if it works nicely for you, or a comment detailing which version of next and react-instantsearch-hooks you are using if it does not work !

Thanks 😁

Raesta commented 1 year ago

Hi @aymeric-giraudet,

thank you for your solution, but I have a problem actually.

I use :

My problem is on the loading of InstantSearch image

The understanding of this problem is probably the old version of next but currently we can not migrate.

Thank you in advance for your help.

aymeric-giraudet commented 1 year ago

Hi @Raesta,

Thanks for the feedback ! It is very helpful for the future, looks like we relied on a pretty new method.

Can you try setting the doNotOverrideBeforePopState option to true ? Your routing would look like this :

{
  router: createInstantSearchNextRouter({ 
    serverUrl: url,
    doNotOverrideBeforePopState: true
  }) 
}

Normally it should not call beforePopState with this, please let me know if it works :)

In the meantime I'll iterate and check if the method exists before using it and publish a newer version.

Edit: scrap that, seems like a problem with the router singleton, I'll investigate more ! Edit2: it's a problem with webpack, swc handles it fine though, which is why it works on 12+ only. Seems like using cjs instead of esm with webpack works though

aymeric-giraudet commented 1 year ago

Hi @Raesta,

Sorry for the double post but it should be fixed by now, with version 0.0.5 What I did is I added this line to package.json in instantsearch-router-next-experimental :

"exports": {
  "webpack": "./dist/index.cjs"
}

To instruct webpack to use cjs even if mjs is available, and it seems to fix things.

You can let me know if this fixes things on your end too, thanks ! :)

Raesta commented 1 year ago

Hi @aymeric-giraudet work perfectly, thanks a lot for your work.

When do you think the final package will be published?

colis commented 1 year ago

Hi @aymeric-giraudet, and thanks for the update.

I'm using next 13.0.6 and react-instantsearch-hooks 6.38.3 (CSR-only), unfortunately I'm still seeing the re-render issue mentioned in the first post.

Haroenv commented 1 year ago

@colis, please create a reproduction for that issue? I believe you may possibly not be rendering a SearchBox, which is required for the parameters to stay in the URL.

colis commented 1 year ago

hi @Haroenv, I do use the SearchBox, along with various RefinementList components

aymeric-giraudet commented 1 year ago

@Raesta : we want to gather as much feedback as possible to make sure it covers most issues and versions, so it should come out in around 1 month from now

@colis : I can't seem to reproduce the issue on my end, there might be some specific routing ? A reproduction of the issue on codesandbox would help greatly

avremel commented 1 year ago

@aymeric-giraudet Thank you for the package! Very helpful to inspect the route as a drop in replacement for uiState which IME is unreliable (see here).

I've noticed that windowTitle doesn't seem to work. It flashes the new title for a second and then reverts back to the old one.

I've provided custom routing functions:

 routing: {
      router: createInstantSearchNextRouter({
        serverUrl,
        routerOptions: {
          windowTitle: (routeState) => windowTitle(routeState, facetConfig),
          createURL,
          parseURL,
        },
      }),
      stateMapping: {
        routeToState: (params) => routeToState(params, facetConfig),
        stateToRoute,
      },
    }
aymeric-giraudet commented 1 year ago

Hi @avremel,

Are you using the <Head> component from next/head ? This overrides the title when rerendered, maybe that could be it ?

Otherwise I did some debugging on my side, I see that windowTitle runs twice but with the same routeState, maybe something in facetConfig changes in between ?

avremel commented 1 year ago

@aymeric-giraudet

We are using <Head> but the problem persists even if I remove <title> from Head. Maybe I need to remove Head entirely?

facetConfig doesn't change between renders. It is an server side API call response.