question-mark
Stuck on an issue?

Lightrun Answers was designed to reduce the constant googling that comes with debugging 3rd party libraries. It collects links to all the places you might be looking at while hunting down a tough bug.

And, if you’re still stuck at the end, we’re happy to hop on a call to see how we can help out.

Rendering ignores Pinia data in `initialState` when the store state is set in `beforeEnter` route guards

See original GitHub issue

In my application I’d like to initialise state on a per-route basis rather than all in one go up-front. This is nice because all pages in the application load their dependent data in the same way. So regardless of how you got there, initial page load (SSG or client-side) or a client-side navigation to the route, we have just one place that loads the data. To achieve this I’m using the beforeEnter guard to initialise my Pinia store before the component mounts and the route is resolved.

The issue I’m having is that although the route guard is executed and the data is indeed fetched and set at build time, the page does not get rendered properly.

When I added some crude logging to see what was going on, I realised that there were multiple instances of the store. The instance which was initialised and loaded the data was somehow not the same instance present when the page was rendered out. This is not true if the page is the last route in the list - in that case the store is the same and it renders correctly.

Reproduction

First I create the basic store. Note how each instance of the store has a unique randomInit value which I can use to keep track of what’s going on - it’ll make sense in a minute!

import { defineStore } from 'pinia'

interface Ship {
	id: string
	name: string
}

interface State {
	randomInit: string
	hasLoaded: boolean
	ships: Ship[],
}

export const useShipsStore = defineStore('ships', {
	state: (): State => ({
		randomInit: `${Date.now()}_${(Math.round(Math.random() * 1000))}`,
		hasLoaded: false,
		ships: [],
	}),
	actions: {
		async initialise() {
			if (this.hasLoaded)
				return

			console.log(`loading fresh ships! state ID: ${this.randomInit}`)

			const res = await fetch('https://swapi.dev/api/starships/')
			const body = await res.json()

			this.ships= body.results
			this.hasLoaded= true
		},
	},
})

Here’s the main.ts file. Note how I log the store’s randomInit when the SSG data is being set.

import 'isomorphic-unfetch'
import devalue from '@nuxt/devalue'
import { ViteSSG } from 'vite-ssg'
import { createPinia } from 'pinia'
import { useStarshipStore } from '@/stores/ships'

import App from '@/App.vue'
import Account from '@/pages/Account.vue'
import Ships from '@/pages/Ships.vue'
import Home from '@/pages/Home.vue'

const routes = [
	{
		path: '/',
		component: Home,
	},
	{
		path: '/starships',
		component: Starships,
		beforeEnter: async (to, from) => {
			const starshipStore = useStarshipStore()

			await starshipStore.initialise()
		},
	},
	{
		path: '/account',
		component: Account,
	},
]

async function bootstrapPage(context) {
	const { app, initialState, router, routePath, isClient } = context

	const pinia = createPinia()
	app.use(pinia)

	const starshipStore = useStarshipStore()

	if (isClient) {
		// on the client side, we restore the state
		pinia.state.value = initialState?.pinia || {}
	} else {
		// at build time set the hydration data to whatever is in the store
		// this will be stringified and set to window.__INITIAL_STATE__
		initialState.pinia = pinia.state.value

		console.log(`setting SSG data: ${routePath} @ ${shipsStore.randomInit}`)
	}
}

function transformState(state) {
	return import.meta.env.SSR ? devalue(state) : state
}

export const createApp = ViteSSG(App, { routes }, bootstrapPage, { transformState })

Expected behaviour

  • starships.html file in the dist directory has fully rendered the template and has the starship data in the window.__INITIAL_STATE__ object
  • output log:
[vite-ssg] Build for server...
vite v2.8.4 building SSR bundle for production...
✓ 22 modules transformed.
.vite-ssg-temp/main.mjs                    26.08 KiB
setting SSG data: undefined @ 1646180624980_753

[vite-ssg] Rendering Pages... (5)
setting SSG data: /starships @ 1646180624988_22
loading fresh ships! state ID: 1646180624988_22
setting SSG data: / @ 1646180625005_628
setting SSG data: /account @ 1646180625007_552
dist/index.html         0.96 KiB
dist/account.html       0.93 KiB
dist/ships.html         1.43 KiB

Actual behaviour

  • starships.html in the dist directory has an empty template and the initial store state in the window.__INITIAL_STATE__ object
  • output log:
[vite-ssg] Build for server...
vite v2.8.4 building SSR bundle for production...
✓ 22 modules transformed.
.vite-ssg-temp/main.mjs                    26.03 KiB
setting SSG data: undefined @ 1646181850722_607

[vite-ssg] Rendering Pages... (5)
setting SSG data: / @ 1646181850726_738
setting SSG data: /ships @ 1646181850727_588
setting SSG data: /account @ 1646181850729_24
loading fresh ships! state ID: 1646181850729_24
dist/index.html         0.96 KiB
dist/account.html       0.93 KiB
dist/ships.html         8.07 KiB

Note how the randomInit values are the same for the account page iteration but also somehow the initialisation of the store, which happens in a different route!

This strikes me as some kind of race condition or concurrency issue where the store is not being tracked properly on each iteration of the generation process.

So I took a look…

The fix

I’m not really sure why or how this works, but there is a way to reliably fix it.

Option one (first thing I tried): disable parallel rendering. In src/node/build.ts:

  await Promise.all(
    routesPaths.map(async(route) => {
      try {
        const appCtx = await createApp(false, route) as ViteSSGContext<true>

Changing the loop code to a simple for (const route of routePaths) { does solve it. But, this is not satisfactory for me - it would be far too slow! So I had another idea…

Option two (major hax): I found that doing any async operation before the call to createApp would fix it.

  await Promise.all(
    routesPaths.map(async(route) => {
      await new Promise(resolve => setImmediate(resolve));
      try {
        const appCtx = await createApp(false, route) as ViteSSGContext<true>

Yay! This is brilliant. Now each page in my app can use the route guard to get all its dependent data, whether you’re navigating on the client, or it’s the initial page load, or it’s the server building the page during SSG! Much nicer than doing everything in the vite-ssg callback function!

Reflection

Doing some aysnc work forces the next part (creating the app instance, registering the store) to the next tick of the event loop. Maybe this fixes something in Pinia so that the store instance is consistent during the render.

But I’m not quite sure why it’s an issue at all, or why this “fix” works. Surely there is a less hacky way to get this behaviour.

Issue Analytics

  • State:open
  • Created 2 years ago
  • Comments:11 (5 by maintainers)

github_iconTop GitHub Comments

1reaction
gryphonmyerscommented, Mar 5, 2022

To me this sounds like an issue with pinia, and possibly unsupported use of it (the docs only describe using stores inside setup functions, not route guards). It is a bit odd that your hack resolves it. But yeah I think doing data fetching inside setup yields a better result anyway so I’d just stick with doing it there.

0reactions
J-Sekcommented, Mar 14, 2022

I’ve set up Pinia as JAM Stack website cache (Vue3 + SSG). I could not get the state loaded with initialState.pinia = pinia.state.value, so I called it as .load() and made the store initialize its own values.

Hopefully it helps someone.

export function bootstrapStore(app: App<Element>, initialState: Record<string, any>, isClient: boolean) {
  const pinia = createPinia();
  app.use(pinia);
  const cache = useCacheStore();
  if (isClient) {
    cache.load(initialState?.pinia?.cache ?? {});
  } else {
    initialState.pinia = pinia.state.value;
  }
}

export const useCacheStore = defineStore("cache", {
  state: (): State => ({
    entries: {},
  }),
  actions: {
    set(key: string, value: any) {
      this.entries[key] = value;
    },
    load(state: any) {
      this.entries = {...state?.entries};
    },
  },
});
Read more comments on GitHub >

github_iconTop Results From Across the Web

Can't access Pinia store in beforeEnter in vue 2 app
I am using Pinia in a vue 2 app, and am trying to access some state in the beforeEach route guard, but receive...
Read more >
Using a store outside of a component - Pinia
When dealing with Server Side Rendering, you will have to pass the pinia instance to useStore() . This prevents pinia from sharing global...
Read more >
Vue 3 + Pinia - JWT Authentication Tutorial & Example
Tutorial on how to build a simple login application with Vue 3 and Pinia that uses JWT authentication.
Read more >
Getting more out of your Pinia Stores — Vue Amsterdam ...
state.value. The first time you call the useSomeStore() in your application, it's going to put the initial state into the store using the...
Read more >
Complex Vue 3 state management made easy with Pinia
Configuring the router; Inspecting the Pinia stores in Vue Devtools ... The state is defined as a function returning the initial state ......
Read more >

github_iconTop Related Medium Post

No results found

github_iconTop Related StackOverflow Question

No results found

github_iconTroubleshoot Live Code

Lightrun enables developers to add logs, metrics and snapshots to live code - no restarts or redeploys required.
Start Free

github_iconTop Related Reddit Thread

No results found

github_iconTop Related Hackernoon Post

No results found

github_iconTop Related Tweet

No results found

github_iconTop Related Dev.to Post

No results found

github_iconTop Related Hashnode Post

No results found