algolia / vue-instantsearch

👀 Algolia components for building search UIs with Vue.js
https://www.algolia.com/doc/guides/building-search-ui/what-is-instantsearch/vue
MIT License
853 stars 157 forks source link

[Algolia 4 + Nuxt static] How to provide search parameters to findResultsState on version 4? #1085

Closed nachoadjust closed 1 year ago

nachoadjust commented 3 years ago

Issue

We are trying to migrate our Algolia implementation from v2 to v4.

We use Nuxt and we have implemented the exampled explained here which "works".

However we are not being able to find out how to provide the search parameters to filter the data for SSR in v4.

This results in our page loading the wrong information on SSR and updating it on the client:

How we are currently doing it (using v2):

In v2 we do not have the issue described as the correct data already comes included in the page. We use the asyncData hook to request our data from the CMS, and to create the Algolia state.

<script>
import api from "~/plugins/api-service.js"
import { createInstantSearchInstance } from "~/plugins/instant-search.js"

const { instantsearch, rootMixin } = createInstantSearchInstance("RESOURCES")

export default {
  mixins: [rootMixin],  
  beforeMount () {
    instantsearch.hydrate(this.instantSearchState)
  },
  async asyncData (context) {
    try {
      const story = await api.getStoryFromCMS(context)

      const searchParameters = {
        hitsPerPage: 12,
        filters: `(type:Ebook) AND locale:${context.app.i18n.locale} AND NOT hideInSearch:true`,
        disjunctiveFacets: ["topics"]
      }

      let instantSearchState = await instantsearch.findResultsState(searchParameters)
      instantSearchState = instantsearch.getState()

      return {
        story,
        instantSearchState,
        searchParameters,
        refinementParameters: searchParameters.disjunctiveFacets,
        query: "",
      }
    } catch (error) {
      return api.handleErrors(context, error)
    }
  }
}
</script>

How we are trying to implement it using v4:

<script>
import api from "~/plugins/api-service.js"
import algoliasearch from "algoliasearch/lite"
import _renderToString from "vue-server-renderer/basic"
import { createServerRootMixin } from 'vue-instantsearch'

const searchClient = algoliasearch(
  process.env.algoliaAppId,
  process.env.algoliaSearch
)

const renderToString = (app) => {
  return new Promise((resolve, reject) => {
    _renderToString(app, (err, res) => {
      if (err) {
        reject(err)
      }
      resolve(res)
    })
  })
}

export default {
  mixins: [
    createServerRootMixin({
      searchClient,
      indexName: "RESOURCES"
    })
  ],  
  beforeMount () {
     const algoliaStateResults = (this.$nuxt.context && this.$nuxt.context.nuxtState.algoliaState) || window.__NUXT__.algoliaState

    if (algoliaStateResults) {
      this.instantsearch.hydrate(algoliaStateResults)
      delete this.$nuxt.context.nuxtState.algoliaState
      delete window.__NUXT__.algoliaState
    }
  },
  serverPrefetch () {
    return this.instantsearch.findResultsState({
      component: this,
      renderToString
    })
    .then(algoliaState => {
      this.$ssrContext.nuxt.algoliaState = algoliaState
    })
  },
  async asyncData (context) {
    try {
      const story = await api.getStoryFromCMS(context)

      const searchParameters = {
        hitsPerPage: 12,
        filters: `(type:Ebook) AND locale:${context.app.i18n.locale} AND NOT hideInSearch:true`,
        disjunctiveFacets: ["topics"]
      }

      return {
        story,
        searchParameters,
        refinementParameters: searchParameters.disjunctiveFacets
      }
    } catch (error) {
      return api.handleErrors(context, error)
    }
  }
}
</script>

The template part looks like this in both cases:

<template>
  <ais-instant-search-ssr>
    <div class="sidebar">
      <ais-search-box v-model="query"/>

      <template v-if="refinementParameters">
        <ais-panel
          v-for="parameter in refinementParameters"
          :key="`rp_${parameter}`"
        >
          <ais-refinement-list :attribute="parameter" />
        </ais-panel>
      </template>
    </div>

    <div class="body">
      <!-- eslint-disable -->
      <ais-configure
        v-bind="searchParameters"
        highlightPreTag="__highlight__"
        highlightPostTag="__/highlight__"
      />

      <ais-state-results>
        <template slot-scope="{ hits }">
          <ais-hits 
            v-if="hits.length > 0"
            :escapeHTML="false"
          >
            <div slot-scope="{ items }">
              <div
                v-for="(item, index) in items"
                :key="`ri_${index}`"
              >
                {{ item.name }}
              </div>
            </div>
          </ais-hits>
        </template>
      </ais-state-results>

    </div>
  </ais-instant-search-ssr>
</template>

In v2 we can easily pass the parameters to define the state, and provide the result to the client for the hydration:

const searchParameters = {
  hitsPerPage: 12,
   ...
}

let instantSearchState = await instantsearch.findResultsState(searchParameters)
instantSearchState = instantsearch.getState()

How can the same be achieved in v4?

Haroenv commented 3 years ago

When you are relying on custom parameters for the backend search, you'll need to somehow also apply those to the frontend. To avoid this mismatch we've set it so that ui state is the source of truth. This indeed means that asynchronously setting an initial state wasn't what we had in mind.

Do you have a live sample of your previous implementation somewhere? I can understand the individual parts of the code, but not fully sure how it all works together, especially both using asyncData & serverPrefetch

nachoadjust commented 3 years ago

Hello @Haroenv, thank you very much for jumping in!

Here is the sandbox you requested.

Test Context:

The page should load some resources of our site (ebooks).

It should do it for the English version only. But when the page loads you will probably see a "glitch", where:

  1. it initially shows ALL results for ALL locales,
  2. and soon after that it reloads the list, and shows the correct results (just English)

(If the error doesn't trigger initially try reloading codesanbox's browser).

I believe this is because the searchParameters are not being passed to the Algolia instance in this new version of vue-instantsearch (v4)

in v2 we would just do this passing inside asyncData:

async asyncData (context) {
    try {
      const story = await api.getStoryFromCMS(context)

      const searchParameters = {
        hitsPerPage: 12,
        filters: `(type:Ebook) AND locale:${context.app.i18n.locale} AND NOT hideInSearch:true`,
        disjunctiveFacets: ["topics"]
      }

      let instantSearchState = await instantsearch.findResultsState(searchParameters)
      instantSearchState = instantsearch.getState()

      return {
        story,
        instantSearchState,
        searchParameters,
        refinementParameters: searchParameters.disjunctiveFacets,
        query: "",
      }
    } catch (error) {
      return api.handleErrors(context, error)
    }
  }
Haroenv commented 2 years ago

What you could do is add a virtual refinement list for locale, and use context.app.i18n.locale in the stateMapping to enable the right refinement, so it's not required to use filters directly

nachoadjust commented 2 years ago

Hello @Haroenv. Could you provide an example of how to apply what you described that fits the code I provided? Because I am having trouble understanding how to apply what you mentioned and I have not found any docs for virtualRefinementList for Vue, only for React.

I see that there seems to be a mapping between connectRefinementList to ais-refinements-list (see link) I assume this means that ais-refinements-list is algolia-Vue's connectRefinementList?

Thank you in advance!

Side Note: I was checking the codebase for algolia v2 specifically for findResultsState to try and understand more about the issue:

search.findResultsState = params => {
    search.helper = algoliaHelper(
      searchClient,
      indexName,
      _objectSpread({}, params, { // <<<<<<< HERE
        // parameters set by default
        highlightPreTag: '__ais-highlight__',
        highlightPostTag: '__/ais-highlight__',
      })
    );

    return search.helper.searchOnce().then(({ content: lastResults }) => {
      // The search instance needs to act as if this was a regular `search`
      // but return a promise, since that is the interface of `asyncData`
      search.helper.lastResults = lastResults;
    });
  }

This approach allowed us to request+filter the data to Algolia and get the response we needed already on Server side.

I see the approach of passing searchParameters has been removed since v3, but I cannot find any info explaining how get the same result as in v2 using the ways of v3. Are there any docs that might help me ? Thanks again!

Haroenv commented 2 years ago

Sorry, there's no docs as we considered this v2 behaviour rather a bug (state isn't in sync with UI) than a feature. I'm not fully sure how to create your use case though, so would have to consider how to pass state correctly. A virtual refinementList is indeed not documented for vue, as you can use <ais-configure /> or <ais-refinement-list hidden />

nachoadjust commented 2 years ago

@Haroenv thanks for the quick reply.

I was not aware of this information you shared. Our implementation of Algolia was done by someone else before me and I lack documentation.

All approaches I have tried so far have failed to get the correct initial data response on the server side. It only works on client side.

This is noticeable in the sandbox I provided. There you can see that when the page loads it renders some cards, and 3-4 seconds later the data refreshes and the page loads the correct cards.

This is also an issue for the static generation of our site, as the .html files get created with the wrong data in them.

I will keep on reading to see if I find a fix, but unless I can provide filters when I make the SSR data request to algolia, I doubt I can get this to work.

If you can provide any useful documentation to point me in the right direction that will be greatly appreciated.

Haroenv commented 2 years ago

Either something simple was wrong with my example, or something else was happening, but in theory this should work: https://codesandbox.io/s/test-algolia4-forked-xmbj5?file=/pages/index.vue (moving the parameters inside the rendered component, so that the parameters can be taken in account

nachoadjust commented 2 years ago

@Haroenv thank you for the code example, however I see the issue still happening. It does take the filters, but only on client side, but on server side the data the server sees is the one without the filters. I have recorded a video to try to explain the issue completely, so that I don't bombard you so much with messages. Maybe it helps? Thanks again for the effort!

Haroenv commented 2 years ago

Sorry for being unclear, but I indeed also see the issue, but not what the solution is

nachoadjust commented 2 years ago

From my understanding the only way to achieve this would be to allow findResultsState() to accept search parameters. (But I do not know what disadvantages that might have on other parts of algolia's code.)

Haroenv commented 2 years ago

That wouldn't be possible, it doesn't deal directly with search parameters, and also there isn't really a use case for sending a different request on the server than on the client. What I think is likely going wrong in the example I made is that on the server the props/parameters aren't correctly forwarded (maybe related to cloneComponent?)

nachoadjust commented 2 years ago

What I think is likely going wrong in the example I made is that on the server the props/parameters aren't correctly forwarded (maybe related to cloneComponent?) Yes, that is correct. I thought findResultsState was in charged of receiving them, that's why I suggested it.

I am not aware of cloneComponent and how to use. Is it part of Algolia's implementation?

Haroenv commented 2 years ago

$cloneComponent is a function passed to createServerRootMixin, which copies the "component" to be able to render it and get all child components. The default implementation is

https://github.com/algolia/vue-instantsearch/blob/f2ba52eced3eb2450e65efa1926d0d7284c203e8/src/util/createServerRootMixin.js#L34-L77

I haven't tested this case extensively yet, so I'm not sure, but it's possible that one of the ways nuxt is injecting the locale isn't copied by the cloneComponent

Haroenv commented 1 year ago

I don't think we'll make any changes to how this functions to enable the previous design, as it wasn't designed for that purpose. Best would be to use the earlier suggested initial ui state or routing.