Nuxt universal + prod - SSR/service plugins broken after page refresh
See original GitHub issueSteps to reproduce
Setup a very basic nuxt + feathers-vuex example as per the docs. Nuxt mode should be universal. No auth code necessary, but add at least one service using the makeServicePlugin, and have it wired to a working feathers API with some dummy data.
Setup a basic page to test SSR - here, we’re going to call a feathers-vuex service method in fetch():
// pages/index.vue
<template>
<div>
<pre>{{ $store.state.conversations }}</pre>
</div>
</template>
<script>
import { models } from 'feathers-vuex';
export default {
data: () => ({}),
async fetch({ store, params }) {
console.log('start fetch() | store.state.conversations = ', store.state.conversations);
const { conversation } = models.api;
await conversation.get(1);
console.log('end fetch() | store.state.conversations = ', store.state.conversations);
}
};
</script>
- Start a dev server (e.g. yarn dev) - visit the page & refresh.
^ All good, store is clean and then populated on each request. SSR works.
- Now start a production server, e.g.
yarn build && yarn start
- Visit the test page - trying refreshing
Expected behavior
SSR works on every request.
Actual behavior
SSR breaks after first request (store state is only being correctly populated on the first request).
Debugging
If you add a mutation logger plugin to vuex, you’ll see that in dev mode we get the correct cycle of state being cleared and populated on each page request:
**First request**
begin fetch() | store.state.conversations = {
ids: [],
keyedById: {},
copiesById: {},
tempsById: {},
tempsByNewId: {},
pagination: { defaultLimit: null, defaultSkip: null },
isFindPending: false,
isGetPending: false,
isCreatePending: false,
isUpdatePending: false,
isPatchPending: false,
isRemovePending: false,
errorOnFind: null,
errorOnGet: null,
errorOnCreate: null,
errorOnUpdate: null,
errorOnPatch: null,
errorOnRemove: null,
modelName: 'conversation',
namespace: 'conversations',
servicePath: 'conversations',
autoRemove: false,
addOnUpsert: false,
enableEvents: false,
idField: 'id',
tempIdField: '__id',
debug: false,
keepCopiesInStore: false,
nameStyle: 'short',
paramsForServer: [],
preferUpdate: false,
replaceItems: false,
serverAlias: 'api',
skipRequestIfExists: false,
whitelist: []
}
conversations/setPending get
conversations/addItem Conversation {
id: 7,
title: null,
creatorId: 5,
recipientId: 1,
postId: 10011,
createdAt: '2020-04-23T22:54:26.062Z',
updatedAt: '2020-04-23T22:54:26.062Z'
}
conversations/addItem Conversation {
id: 7,
title: null,
creatorId: 5,
recipientId: 1,
postId: 10011,
createdAt: '2020-04-23T22:54:26.062Z',
updatedAt: '2020-04-23T22:54:26.062Z'
}
conversations/unsetPending get
end fetch() | store.state.conversations {
ids: [ 7 ],
keyedById: {
'7': Conversation {
id: 7,
title: null,
creatorId: 5,
recipientId: 1,
postId: 10011,
createdAt: '2020-04-23T22:54:26.062Z',
updatedAt: '2020-04-23T22:54:26.062Z'
}
},
copiesById: {},
tempsById: {},
tempsByNewId: {},
pagination: { defaultLimit: null, defaultSkip: null },
isFindPending: false,
isGetPending: false,
isCreatePending: false,
isUpdatePending: false,
isPatchPending: false,
isRemovePending: false,
errorOnFind: null,
errorOnGet: null,
errorOnCreate: null,
errorOnUpdate: null,
errorOnPatch: null,
errorOnRemove: null,
modelName: 'conversation',
namespace: 'conversations',
servicePath: 'conversations',
autoRemove: false,
addOnUpsert: false,
enableEvents: false,
idField: 'id',
tempIdField: '__id',
debug: false,
keepCopiesInStore: false,
nameStyle: 'short',
paramsForServer: [],
preferUpdate: false,
replaceItems: false,
serverAlias: 'api',
skipRequestIfExists: false,
whitelist: []
}
***Page Reload***
begin fetch() | store.state.conversations = {
ids: [],
keyedById: {},
copiesById: {},
tempsById: {},
tempsByNewId: {},
pagination: { defaultLimit: null, defaultSkip: null },
isFindPending: false,
isGetPending: false,
isCreatePending: false,
isUpdatePending: false,
isPatchPending: false,
isRemovePending: false,
errorOnFind: null,
errorOnGet: null,
errorOnCreate: null,
errorOnUpdate: null,
errorOnPatch: null,
errorOnRemove: null,
modelName: 'conversation',
namespace: 'conversations',
servicePath: 'conversations',
autoRemove: false,
addOnUpsert: false,
enableEvents: false,
idField: 'id',
tempIdField: '__id',
debug: false,
keepCopiesInStore: false,
nameStyle: 'short',
paramsForServer: [],
preferUpdate: false,
replaceItems: false,
serverAlias: 'api',
skipRequestIfExists: false,
whitelist: []
}
conversations/setPending get
conversations/addItem Conversation {
id: 7,
title: null,
creatorId: 5,
recipientId: 1,
postId: 10011,
createdAt: '2020-04-23T22:54:26.062Z',
updatedAt: '2020-04-23T22:54:26.062Z'
}
conversations/addItem Conversation {
id: 7,
title: null,
creatorId: 5,
recipientId: 1,
postId: 10011,
createdAt: '2020-04-23T22:54:26.062Z',
updatedAt: '2020-04-23T22:54:26.062Z'
}
conversations/unsetPending get
end fetch() | store.state.conversations {
ids: [ 7 ],
keyedById: {
'7': Conversation {
id: 7,
title: null,
creatorId: 5,
recipientId: 1,
postId: 10011,
createdAt: '2020-04-23T22:54:26.062Z',
updatedAt: '2020-04-23T22:54:26.062Z'
}
},
copiesById: {},
tempsById: {},
tempsByNewId: {},
pagination: { defaultLimit: null, defaultSkip: null },
isFindPending: false,
isGetPending: false,
isCreatePending: false,
isUpdatePending: false,
isPatchPending: false,
isRemovePending: false,
errorOnFind: null,
errorOnGet: null,
errorOnCreate: null,
errorOnUpdate: null,
errorOnPatch: null,
errorOnRemove: null,
modelName: 'conversation',
namespace: 'conversations',
servicePath: 'conversations',
autoRemove: false,
addOnUpsert: false,
enableEvents: false,
idField: 'id',
tempIdField: '__id',
debug: false,
keepCopiesInStore: false,
nameStyle: 'short',
paramsForServer: [],
preferUpdate: false,
replaceItems: false,
serverAlias: 'api',
skipRequestIfExists: false,
whitelist: []
}
For prod – only the first page request cycle is healthy:
**First request**
begin fetch() | store.state.conversations = {
ids: [],
keyedById: {},
copiesById: {},
tempsById: {},
tempsByNewId: {},
pagination: { defaultLimit: null, defaultSkip: null },
isFindPending: false,
isGetPending: false,
isCreatePending: false,
isUpdatePending: false,
isPatchPending: false,
isRemovePending: false,
errorOnFind: null,
errorOnGet: null,
errorOnCreate: null,
errorOnUpdate: null,
errorOnPatch: null,
errorOnRemove: null,
modelName: 'conversation',
namespace: 'conversations',
servicePath: 'conversations',
autoRemove: false,
addOnUpsert: false,
enableEvents: false,
idField: 'id',
tempIdField: '__id',
debug: false,
keepCopiesInStore: false,
nameStyle: 'short',
paramsForServer: [],
preferUpdate: false,
replaceItems: false,
serverAlias: 'api',
skipRequestIfExists: false,
whitelist: []
}
conversations/setPending get
conversations/addItem o {
id: 7,
title: null,
creatorId: 5,
recipientId: 1,
postId: 10011,
createdAt: '2020-04-23T22:54:26.062Z',
updatedAt: '2020-04-23T22:54:26.062Z'
}
conversations/addItem o {
id: 7,
title: null,
creatorId: 5,
recipientId: 1,
postId: 10011,
createdAt: '2020-04-23T22:54:26.062Z',
updatedAt: '2020-04-23T22:54:26.062Z'
}
conversations/unsetPending get
end fetch() | store.state.conversations {
ids: [ 7 ],
keyedById: {
'7': o {
id: 7,
title: null,
creatorId: 5,
recipientId: 1,
postId: 10011,
createdAt: '2020-04-23T22:54:26.062Z',
updatedAt: '2020-04-23T22:54:26.062Z'
}
},
copiesById: {},
tempsById: {},
tempsByNewId: {},
pagination: { defaultLimit: null, defaultSkip: null },
isFindPending: false,
isGetPending: false,
isCreatePending: false,
isUpdatePending: false,
isPatchPending: false,
isRemovePending: false,
errorOnFind: null,
errorOnGet: null,
errorOnCreate: null,
errorOnUpdate: null,
errorOnPatch: null,
errorOnRemove: null,
modelName: 'conversation',
namespace: 'conversations',
servicePath: 'conversations',
autoRemove: false,
addOnUpsert: false,
enableEvents: false,
idField: 'id',
tempIdField: '__id',
debug: false,
keepCopiesInStore: false,
nameStyle: 'short',
paramsForServer: [],
preferUpdate: false,
replaceItems: false,
serverAlias: 'api',
skipRequestIfExists: false,
whitelist: []
}
**Page Reload**
begin fetch() | store.state.conversations = {
ids: [],
keyedById: {},
copiesById: {},
tempsById: {},
tempsByNewId: {},
pagination: { defaultLimit: null, defaultSkip: null },
isFindPending: false,
isGetPending: false,
isCreatePending: false,
isUpdatePending: false,
isPatchPending: false,
isRemovePending: false,
errorOnFind: null,
errorOnGet: null,
errorOnCreate: null,
errorOnUpdate: null,
errorOnPatch: null,
errorOnRemove: null,
modelName: 'conversation',
namespace: 'conversations',
servicePath: 'conversations',
autoRemove: false,
addOnUpsert: false,
enableEvents: false,
idField: 'id',
tempIdField: '__id',
debug: false,
keepCopiesInStore: false,
nameStyle: 'short',
paramsForServer: [],
preferUpdate: false,
replaceItems: false,
serverAlias: 'api',
skipRequestIfExists: false,
whitelist: []
}
conversations/setPending get
conversations/mergeInstance {
id: 7,
title: null,
creatorId: 5,
recipientId: 1,
postId: 10011,
createdAt: '2020-04-23T22:54:26.062Z',
updatedAt: '2020-04-23T22:54:26.062Z'
}
conversations/updateItem o {
id: 7,
title: null,
creatorId: 5,
recipientId: 1,
postId: 10011,
createdAt: '2020-04-23T22:54:26.062Z',
updatedAt: '2020-04-23T22:54:26.062Z'
}
conversations/unsetPending get
end fetch() | store.state.conversations {
ids: [],
keyedById: {},
copiesById: {},
tempsById: {},
tempsByNewId: {},
pagination: { defaultLimit: null, defaultSkip: null },
isFindPending: false,
isGetPending: false,
isCreatePending: false,
isUpdatePending: false,
isPatchPending: false,
isRemovePending: false,
errorOnFind: null,
errorOnGet: null,
errorOnCreate: null,
errorOnUpdate: null,
errorOnPatch: null,
errorOnRemove: null,
modelName: 'conversation',
namespace: 'conversations',
servicePath: 'conversations',
autoRemove: false,
addOnUpsert: false,
enableEvents: false,
idField: 'id',
tempIdField: '__id',
debug: false,
keepCopiesInStore: false,
nameStyle: 'short',
paramsForServer: [],
preferUpdate: false,
replaceItems: false,
serverAlias: 'api',
skipRequestIfExists: false,
whitelist: []
}
You’ll notice in prod after the page refresh the store state is messed up. We get empty state both before and after fetch() completes and the service module attempts to update records instead of adding them.
I haven’t dug much deeper than this, but I noted the globalModels object is not clean on the second request (in prod - it’s fine in dev…?!). It has a leftover reference to the conversations service:
Overwriting Model: models[api][conversation] (global-models.js)
Annnnd, within service-module-actions.js, it’s using this dirty state. Within the page component, we’re getting something different.
Any help would be appreciated! And thanks for the awesome plugin, it’s been a big time saver.
System configuration
Tell us about the applicable parts of your setup.
Module versions (especially the part that’s not working):
NodeJS version:
12.16.1
"dependencies": {
"@feathersjs/rest-client": "^4.3.11",
"@nuxtjs/axios": "^5.6.0",
"@nuxtjs/dotenv": "^1.4.1",
"@vue/composition-api": "^0.5.0",
"consola": "^2.10.1",
"cross-env": "^5.2.0",
"express": "^4.16.4",
"feathers-client": "^2.4.0",
"feathers-hooks-common": "^4.20.7",
"nuxt-client-init-module": "^0.1.8",
"feathers-vuex": "^3.9.1",
"lodash": "^4.17.15",
"nuxt": "^2.11.0",
},
Operating System: OS X 10.14.6 Browser Version: Firefox/Chrome
Issue Analytics
- State:
- Created 3 years ago
- Reactions:1
- Comments:16 (8 by maintainers)
Top GitHub Comments
@marshallswain Pretty much. For SSR, that code gets executed on each request. SPA - typically only once, unless someone is re-initializing (I don’t see the use case for this on the client though?). Irrespective of the mode, it’s just important the Model state and vuex state are in sync. Right now, store.registerModule (in step 1^) is omitting the preserveState param, so it’s defaulting to clearing state - whereas in 2b^, state is preserved if it exists on the Model. The state difference is the source of the bug.
In theory, preserving state could be useful in some SSR scenarios, so this could be an option for the service. But unless that’s requested, a safer default option IMO is to clean state, since it prevents memory leaks for SSR.
And yes - it fixes my app in SPA/SSR (I use both for mobile/web builds). I’ve also tested memory leaks when spamming SSR routes and it seems fine!
@phatj One last thing I might add - I don’t know your setup or constraints, but another approach here is to use a serverless function for SSR instead. Since they are - by design - short lived processes, you don’t have to worry about state pollution (or other annoying SSR gotchas) between requests. Once the client has loaded the SSR bundle, it can then talk to your API server, and pretend like none of this SSR nonsense ever existed.
The other main benefit of serverless here is out of box scaling. Vercel supports SSR for Nuxt (though you can roll your own using AWS lambda + serverless framework). I am leaning towards this route instead, since it generally feels safer than trying to make every 3rd party library I want to use fully SSR compliant.