instantsearch icon indicating copy to clipboard operation
instantsearch copied to clipboard

Nuxt + Routing : stateToRoute rettrigering with an empty state when changing a filter

Open louislebrault opened this issue 4 years ago • 5 comments

Bug 🐞

What is the current behavior?

When i add or remove a filter, stateToRoute is triggered twice : the first time normally, then immediately a second times with an empty index. This empty state goes right into my router "write" function, which then remove all my filters and redirect me to my page without any filter.

Does it happens under certain circumstances ?

I dont know, i wonder if its not related to the SSR and/or routing. But i couldnt figure the origin of the problem.

What is the expected behavior?

stateToRoute does not trigger with an empty uiState when there is filters selected.

What is the version you are using?

3.8.1

I didnt make a codesandbox because i couldnt find a nuxt template that is working on codesandbox (all those i found was broke), but if this is crucial i can make one.

I this code example i added a workaround so write does not trigger a redirection when it receive the empty state. The undesired empty uiState look like this: { indexName: {} }, it has a key with my index name and the value is an empty object.

<template>
  <ais-instant-search-ssr>
    <ais-configure :hits-per-page.camel="50" />
    <ProductFilters>
      <ais-search-box />
      <ais-refinement-list attribute="product.categories.name" />
    </ProductFilters>
    <div class="list">
      <ais-stats>
        <div slot-scope="{ nbHits }">
          <ProductCount :count="nbHits" />
        </div>
      </ais-stats>
      <ais-infinite-hits
        :transform-items="formatHits"
      >
        <div slot-scope="{ items, refineNext, isLastPage }">
          <ProductList
            :products="items.map(item => item.formattedProduct)"
            :is-show-more-button-showing="Boolean(items.length) && !isLastPage"
            :on-show-more-click="refineNext"
          />
        </div>
      </ais-infinite-hits>
    </div>
  </ais-instant-search-ssr>
</template>

<script>
import {
  createRouteFromRouteState,
  createRouteStateFromRoute,
  createURLFromRouteState,
  formatProducts,
  routeToState,
  stateToRoute,
} from '~/helpers/algolia'
import algoliasearch from 'algoliasearch/lite'
import { createServerRootMixin } from 'vue-instantsearch'

const searchClient = algoliasearch(
  process.env.ALGOLIA_APP_ID,
  process.env.ALGOLIA_API_KEY
)

function nuxtRouter (vueRouter) {
  return {
    read () {
      return createRouteStateFromRoute(vueRouter.currentRoute)
    },
    write (routeState) {
      if (
        routeState.disableWriteBuggyState ||
        this.createURL(routeState) === this.createURL(this.read())
      ) {
        return
      }

      const fullPath = vueRouter.resolve({
        query: routeState,
      }).href
      const route = createRouteFromRouteState(fullPath, routeState)
      vueRouter.replace(route)
    },
    createURL (routeState) {
      const fullPath = vueRouter.resolve({
        query: routeState,
      }).href

      return createURLFromRouteState(fullPath, routeState)
    },
    // call this callback whenever the URL changed externally
    onUpdate (cb) {
      if (typeof window === 'undefined') return

      this._onPopState = (event) => {
        const routeState = event.state
        if (!routeState) {
          cb(this.read())
        } else {
          cb(routeState)
        }
      }
      window.addEventListener('popstate', this._onPopState)
    },
    // remove any listeners
    dispose () {
      if (typeof window === 'undefined') { return }

      window.removeEventListener('popstate', this._onPopState)
    },
  }
}

export default {
  provide () {
    return {
      $_ais_ssrInstantSearchInstance: this.instantsearch,
    }
  },
  data () {
    const mixin = createServerRootMixin({
      searchClient,
      indexName: process.env.ALGOLIA_PRODUCT_INDEX_NAME,
      routing: {
        router: nuxtRouter(this.$router),
        stateMapping: { stateToRoute, routeToState },
      },
    })

    return {
      ...mixin.data(),
    }
  },
  serverPrefetch () {
    return this.instantsearch.findResultsState(this).then((algoliaState) => {
      this.$ssrContext.nuxt.algoliaState = algoliaState
    })
  },
  beforeMount () {
    const results = (this.$nuxt.context && this.$nuxt.context.nuxtState.algoliaState) || window.__NUXT__.algoliaState

    this.instantsearch.hydrate(results)

    delete this.$nuxt.context.nuxtState.algoliaState
    delete window.__NUXT__.algoliaState
  },
  methods: {
    formatHits: formatProducts,
  },
}
</script>

<style scoped>
.list {
  grid-area: list;
}
</style>
// ~/helpers/algolia
import qs from 'qs'

const PRODUCT_CATEGORIES = 'product.categories.name'

const formatSizes = (sizes) => {
  if (!sizes) { return '' }

  return sizes
    .map(size => size.name)
    .reduce((acc, size, index) => {
      if (index === 0) { return acc + size }

      return acc + ',' + size
    }, '')
}

export const formatProducts = items => items.map((item) => {
  const id = item?.objectID ?? ''
  const name = item?.product?.content?.name ?? ''
  const image = item?.product?.images?.details[0] ?? ''
  const price = item?.product?.pricing?.current ?? -1
  const sizes = formatSizes(item?.product?.measures?.sizes)

  return {
    ...item,
    formattedProduct: {
      id,
      name,
      image,
      price,
      sizes,
    },
  }
})

export const getCategoriesFromPath = (path) => {
  const categories = /(\/products\/)(.*?)(?:[\/?]|$)/.exec(path)

  if (!categories) return []

  return categories[2]
    ? categories[2].split('+').map(capitalizeFirstLetter)
    : []
}

const capitalizeFirstLetter = string => string
  .charAt(0)
  .toUpperCase() +
  string.slice(1)

export const createRouteStateFromRoute = (route) => {
  const categories = getCategoriesFromPath(route.fullPath)

  return {
    categories,
    q: route.query.text,
    page: route.query.page,
  }
}

export const createRouteFromRouteState = (fullPath, routeState) => {
  const url = createURLFromRouteState(fullPath, routeState)

  return {
    query: {
      text: routeState.q,
      page: routeState.page,
    },
    fullPath: url,
    path: url.split('?')[0],
  }
}

export const createURLFromRouteState = (fullPath, routeState) => {
  const myParams = qs.stringify({ categories: routeState.categories, text: routeState.q, page: routeState.page }, {
    addQueryPrefix: true,
    arrayFormat: 'repeat',
    format: 'RFC3986',
  })

  const base = '/products/'

  if (!routeState?.categories?.length) {
    if (!myParams) {
      return base
    }

    return base + myParams
  }

  const result = base +
    routeState?.categories.map(c => c.toLowerCase()).join('+') +
    myParams

  return result
}

export const stateToRoute = (uiState) => {
  console.log('uiState', uiState)
  const indexUiState = uiState[process.env.ALGOLIA_PRODUCT_INDEX_NAME]

  if (Object.entries(indexUiState).length === 0) {
    return { disableWriteBuggyState: true }
  }

  const categories = indexUiState?.refinementList[PRODUCT_CATEGORIES] ?? []

  return {
    q: indexUiState?.query,
    categories,
    page: indexUiState?.page,
  }
}

export const routeToState = routeState => ({
  [process.env.ALGOLIA_PRODUCT_INDEX_NAME]: {
    query: routeState.q,
    refinementList: {
      [PRODUCT_CATEGORIES]: routeState.categories,
    },
    page: routeState.page,
  },
})

louislebrault avatar Jul 23 '21 08:07 louislebrault

Hi @louislebrault thanks for reaching out. Indeed it would be helpful to have a codesandbox, preferably simplified to narrow down the issue. It looks like the codesandbox we have on the docs is experiencing some issues, but here is a working version in the meantime: https://codesandbox.io/s/bold-tdd-hfb69

Would it be possible to adapt this example to help up pin point your issue?

tkrugg avatar Jul 23 '21 14:07 tkrugg

I found the origin of the problem, and it don't come from vue-instantsearch code.

It come from the behavior of vue-router :

write (routeState) {
      //...
      const fullPath = vueRouter.resolve({
        query: routeState,
      }).href
      const route = createRouteFromRouteState(fullPath, routeState)
      // route contains not only the query but also new path, shape look like this : { path, fullPath, query }
      vueRouter.replace(route)
    },

When i replace (or push) the new route, since the path change, vue-router destroy the current page and trigger a redirection. In my case obviously the component that matches the new route is the same, but its still destroyed and rendered again, that's what leaded to upcoming empty uiState : InstantSearch is removing all its widgets before my page is destroyed then send an empty uiState to all middlewares.

It leaded in tons of painful side-effects, filters that i tried to hide with v-if disappearing then reappearing, URL being set correctly then replaced with the root URL of the page, etc...

I follow the example from the documentation, but in the documentation case, only query parameters are changed inside the vueRouter.push call, so current component is not destroyed :

https://www.algolia.com/doc/guides/building-search-ui/going-further/routing-urls/vue/#combining-with-nuxtjs

 write(routeState) {
      // ...
      vueRouter.push({
        query: routeState,
      });
    },

I'm not sure about the workaround tho. i think i'll use history.replaceState to change the URL without triggering those undesired side-effects.

Maybe there should be some information about it in the documentation ? I struggled to debug this, that said now that i know where it came from, it seems pretty obvious...

louislebrault avatar Jul 28 '21 12:07 louislebrault

Wow, thanks for getting back to us with more documentation, I personally only used query together with vue router, so didn't realise that it was an issue. Using the default historyRouter implementation of InstantSearch.js would also avoid the issues, as long as all possible routes correctly redirect to the search page.

Where in the docs would you search for this note?

Haroenv avatar Jul 28 '21 12:07 Haroenv

Since its directly related to vue-router behaviors i would expect to find it here i think : https://www.algolia.com/doc/guides/building-search-ui/going-further/routing-urls/vue/#combining-with-vue-router

Happy to hear that my recent struggles will lead into a tiny improvement in the documentation :relieved:

Btw, in Nuxt app case, historyRouter (from instantsearch.js/es/lib/routers) wont work out of the box since it depends on global object window, which is only accessible from the client-side (nothing but importing it in a component is enough to make it crash during server-side render).

louislebrault avatar Jul 28 '21 12:07 louislebrault

Here's my fix :

function nuxtRouter (vueRouter) {
   // ...
    write (routeState) {
      // history exists only on client-side
      if (typeof history === 'undefined') return

      const fullPath = vueRouter.resolve({
        query: routeState,
      }).href

      const route = createRouteFromRouteState(fullPath, routeState)
      
      // route variable shape look like this : { path: String, fullPath: String, query: Object }
      // I think its not mandatory to pass this object to replaceState, the important parameter is the third one
      // https://developer.mozilla.org/en-US/docs/Web/API/History/pushState
      history.replaceState(route, '', route.fullPath)
    },
    // ...

Its the simplest fix i found based on the documentation example, but there's probably better ways to do it (maybe not using vueRouter instance at all, since its only used to get the base url ?).

louislebrault avatar Jul 29 '21 08:07 louislebrault