feathersjs-ecosystem / feathers-vuex

Integration of FeathersJS, Vue, and Nuxt for the artisan developer
https://vuex.feathersjs.com
MIT License
445 stars 108 forks source link

Nuxt SSR + AsyncData retrieved from FeathersVuex on page reload is not reactive #480

Open tylermmorton opened 4 years ago

tylermmorton commented 4 years ago

Steps to reproduce

/pages/post/_id.vue

  async asyncData({ app, route }) {
    const { Post } = models.api;
    const post = await Post.get(route.params.id);
    return { post };
  },

Expected behavior

When Nuxt serves a page to the client on first load/page reload, objects received from FeathersVuex in asyncData should be model instances. Note that any subsequent loads through the Vue/Nuxt router in Universal or SPA mode are working as intended.

Actual behavior

The data loaded during asyncData is merged with the component's data field but loses its ties to the FeathersVuex store. Without those ties, functions like clone() or save() cannot be called. This also breaks components like FeathersVuexFormWrapper.

My findings & workaround

I've made a temporary solution to get me moving forward again with my project. On the client side only, whenever the component is created, I will individually hydrate each object using a function I exported from my feathers plugin.

The hydrateObject function will just return the corresponding record in the Vuex store based on the object's id and api passed in. I assume the object is already in the store (thanks to calls to hydrateApi on nuxtClientInit), so no redundant calls to the server will happen here. This seems to work OK at replacing the normal Object with a model instance but can be cumbersome when there's a lot of objects to hydrate:

/plugins/feathers.js

async function hydrateObject(obj, api) {
  if (obj.id != undefined && api != undefined) {
    const record = await api.get(obj.id);
    return record;
  }
}
...
export { hydrateObject, ... }

/pages/post/_id.vue

import { models, hydrateObject } from "~/plugins/feathers";

  async created() {
    if (process.client) {
      const { Post } = models.api;
      this.post = await hydrateObject(this.post, Post);
      // this.post is now a model instance so it has functions like clone()
      this.form = this.post.clone();
    }
  },

Theres a couple of caveats I've run into with this solution, the main one being that if you are following the form binding "clone/commit" pattern or using the FeathersVuexFormWrapper, your data is not turned into a model instance until created() is called, causing any components that rely on the model instance functions to fail.

Right now the only solution I can think of to this is to roll my own version of FeathersVuexFormWrapper that can take either a normal object or a model instance on page load, and hydrates the object if needed when created() on the client is called.

System configuration

I've set up my project as close to the FeathersVuex + Nuxt documentation as I can. When setting up the store, I've made sure to include the call to hydrateApi() provided by FeathersVuex, which seems to be hydrating the store data properly.

export const actions = {
  nuxtServerInit({ commit, dispatch, state }, { req }) {
    return initAuth({
      commit,
      dispatch,
      req,
      moduleName: "auth",
      cookieName: "feathers-jwt"
    }).then(async () => {
      if (state.auth.accessToken) {
        // auth the current user from the JWT token obtained above
        await dispatch("auth/authenticate", {
          accessToken: state.auth.accessToken,
          strategy: "jwt"
        });
      }
    });
  },
  nuxtClientInit({ state, dispatch, commit }, context) {
    if (models) {
      hydrateApi({ api: models.api });
    }
    if (state.auth.accessToken) {
      return dispatch("auth/onInitAuth", state.auth.payload);
    }
  }
};

Module versions

  "dependencies": {
    "@feathersjs/authentication": "^4.5.3",
    "@feathersjs/authentication-client": "^4.5.4",
    "@feathersjs/authentication-local": "^4.5.4",
    "@feathersjs/authentication-oauth": "^4.5.4",
    "@feathersjs/configuration": "^4.5.3",
    "@feathersjs/errors": "^4.5.3",
    "@feathersjs/express": "^4.5.4",
    "@feathersjs/feathers": "^4.5.3",
    "@feathersjs/socketio": "^4.5.4",
    "@feathersjs/socketio-client": "^4.5.4",
    "@feathersjs/transport-commons": "^4.5.3",
    "@vue/composition-api": "^0.5.0",
    "compression": "^1.7.4",
    "cookie-storage": "^6.0.0",
    "cors": "^2.8.5",
    "feathers-hooks-common": "^5.0.3",
    "feathers-sequelize": "^6.1.0",
    "feathers-vuex": "^3.9.2",
    "helmet": "^3.22.0",
    "nuxt": "^2.12.2",
    "nuxt-client-init-module": "^0.1.8",
    "pg": "^8.0.3",
    "sequelize": "^5.21.7",
    "serve-favicon": "^2.5.0",
    "socket.io-client": "^2.3.0",
    "tiptap": "^1.27.1",
    "tiptap-extensions": "^1.29.1",
    "winston": "^3.2.1"
  },

NodeJS version: v10.7.0

Operating System: MacOS Mojave Version 10.14.5 (18F132)

Browser Version: Chrome Version 81.0.4044.129 (Official Build) (64-bit)

-- This may just be an issue with Nuxt, but I'm interested to see what others think of this.

Thanks in advance Tyler

marshallswain commented 4 years ago

Thanks for the well-documented issue. We will probably want to include a utility like the one you made in a future release.

Maybe we can make FeathersVuexFormWrapper check for instances in the store.

tylermmorton commented 4 years ago

Thanks Marshall! I've glanced at FeathersVuexFormWrapper to see if I could maybe submit a PR. I was thinking right in setup() before the clone operation we could add a check for the clone function.

if(typeof this.item.clone !== undefined)

but I'm not sure how to determine the model type without passing it as a prop. Maybe you'd have a better idea on how to do this

marshallswain commented 4 years ago

@tylermmorton good point, we will definitely need to pass the model as a prop.

tylermmorton commented 4 years ago

I haven't forgotten about this issue. I'm still playing around in my personal project and trying different solutions. If anyone has any ideas on this I'd be happy to try them out.

I haven't figured out yet how to automatically hydrate the objects when Nuxt serves the page on a reload. There just isn't a good hook to use for this. If Nuxt had a fetched() hook that gets called after a fetch() query completes, that would be a great place for this. I've tried adding a watcher on the Nuxt $fetchState but that wasn't working for me.

I've instead adapted the clone/commit strategy with an "edit" toggle for the form or editor you're trying to use this clone/commit pattern with. I've taken the FeathersVuexFormWrapper and modified it. I apologize in advance if some of this is unclear. Its rough ideas for now

The utility function I wrote earlier:

import { hydrateObject } from "~/plugins/feathers";
// equates to
export const hydrateObject = function hydrateObject(obj, model) {
  if (typeof model == 'string') {
    model = models.api[model]
  }
  // return the feathers-vuex record from the obj's id.
  if (obj.id != undefined && model != undefined) {
    return model.getFromStore(obj.id)
  }
}

additional props:

    item: {
      type: Object,
      required: true
    },
    model: {
      type: String || Object,
      required: false
    },

additional data:

  data: () => ({
    clone: null,
    isEditing: false,
    isHydrated: false
  }),

additional computed props:

    form() {
      if (this.isHydrated) {
        return this.clone;
      } else {
        return this.item;
      }
    },

and finally a new method to be exported. This is the most important because it turns the normal JS object into a model reference whenever the "edit" button is clicked.

    edit() {
      if (process.client) {
        let modelInstance = hydrateObject(this.item, this.model);
        if (modelInstance != undefined) {
          this.clone = modelInstance.clone();
          this.isHydrated = true;
          this.isEditing = true;
        }
      }
    },

and we export everything

  render() {
    const { form, edit, save, reset, remove, isDirty, isNew, isEditing } = this;
    return this.$scopedSlots.default({
      form,
      edit,
      save,
      reset,
      remove,
      isDirty,
      isNew,
      isEditing
    });
  }

and usage would look like this:

    <SSRFormWrapper :item="post" model="Post">
      <template v-slot="{ form, save, reset, edit, isEditing }">
        <div>
          <v-editor v-model="form.content" :editable="isEditing"/>

          <div v-if="isEditing">
            <v-btn @click="save">Save</v-btn>
            <v-btn @click="reset">Cancel</v-btn>
          </div>
          <div v-else>
            <v-btn @click="edit">Edit</v-btn>
          </div>
        </div>
      </template>
    </SSRFormWrapper>
njbarrett commented 4 years ago

Hi all, I have expanded upon @tylermmorton 's workaround function a little with the following: 1) Handles already hydrated objects 2) Errors if an invalid modelName is passed. (Assumes always passing a string modelName)

export const hydrateObject = (obj, modelName) => {
  // Already hydrated.
  if (obj && obj.constructor && obj.constructor.name === modelName) {
    return obj;
  }

  if (typeof modelName !== 'string' || !models.api[modelName]) {
    throw new Error('Please pass a valid model name to hydrateObject');
  }

  const Model = models.api[modelName];

  // Return the feathers-vuex record from the obj's id.
  if (typeof obj.id !== 'undefined' && typeof Model !== 'undefined') {
    return Model.getFromStore(obj.id);
  }

  return obj;
};