Search code examples
serializationnuxt.jsserver-side-renderingnuxt3.js

Nuxt 3 rich JSON payload serialisation


The Nuxt team introduced support for sending class instances from the server to the client in Nuxt 3.4.0 as an experimental feature & made it enabled by default in Nuxt 3.5.0

However, in my opinion, the documentation has been quite falling behind... As someone who hasn't had much experience with custom serializers, I am not so sure how to implement my own based on the example they provided in the release notes:

// example of a custom payload plugin from the Nuxt team
export default definePayloadPlugin(() => {
  definePayloadReducer('BlinkingText', data => data === '<original-blink>' && '_')
  definePayloadReviver('BlinkingText', () => '<revivified-blink>')
})

I have so many questions like:

  • Are all of the payload plugins run every time a payload is sent from the server to the client and does only the one that returns a truthy value in the payloadReducer callback get used on the specific type?
  • Does the name of the payloadReducer and the payloadReviver matter? Is it tied to something? Or can it be anything as long as the reviver & reducer have the same name?
  • Will the reviver automatically run on the client during hydration?

My solution

After some experimentation, I came up with this plugin for my ApiResponse class:

export default definePayloadPlugin(() => {
    definePayloadReducer('ApiResponse', data => data instanceof ApiResponse && data.getReducedData())
    definePayloadReviver('ApiResponse', data => new ApiResponse(...data))
})

I defined a method in the class, which returns the constructor parameters in an array:

getReducedData() {
    return [this.data, this.model, this.responseMeta]
}

And I am using the following transform method in my useFetch wrapper:

transform: (data) => new ApiResponse(data, this.model, responseMeta),

This, however, results in the following error: 500 Cannot stringify a function

at flatten (./node_modules/devalue/src/stringify.js:102:15)
at flatten (./node_modules/devalue/src/stringify.js:60:39)
at flatten (./node_modules/devalue/src/stringify.js:166:43)
at flatten (./node_modules/devalue/src/stringify.js:166:43)
at flatten (./node_modules/devalue/src/stringify.js:60:39)
at stringify (./node_modules/devalue/src/stringify.js:178:16)
at renderPayloadJsonScript (./.nuxt/dev/index.mjs:12654:32)
at ./.nuxt/dev/index.mjs:12578:29
at process.processTicksAndRejections (node:internal/process/task_queues:95:5)

What am I doing wrong?


If I do the transform in the following way, it kinda works:

// in useFetch options:
transform: this.transformResponse


// the transformResponse function:
transformResponse(response, responseMeta = null) {
    return new ApiResponse(response, this.model, responseMeta)
}

The data returned from useFetch is indeed instanceof ApiResponse on the client in this case, however, the this context in the callback is different so I have no way to access the model & as there is no way for me to pass the responseMeta into the constructor. I don't understand why the payloadPlugin seems to work in this second example and not in the first one... There is no mention of the definePayloadPlugin, definePayloadReducer & definePayloadReviver functions in the documentation.


Solution

  • I figured it out. Let's go through the points one by one:

    Are all of the payload plugins run every time a payload is sent from the server to the client and does only the one that returns a truthy value in the payloadReducer callback get used on the specific type?

    Yes.

    Does the name of the payloadReducer and the payloadReviver matter? Is it tied to something? Or can it be anything as long as the reviver & reducer have the same name?

    As far as I'm aware, the name has nothing to do with the type - so it can be anything as long as the reducer & the reviver match.

    Will the reviver automatically run on the client during hydration?

    Yes.

    Basically, I'm pretty sure that every payload plugin runs for every payload on the client during hydration.


    Now let's discuss why my first solution didn't work:

    In the ApiResponse class, I instantiate a new ApiModel subclass based on the model reference provided in the constructor. THIS ApiModel reference is the "function that could not be stringified".

    The version where I only provided a reference to the response transform function (transform: this.transformResponse) worked because this.model was undefined in that context.

    So it appears I can't pass classes in any form, whether they're instantiated or not... which does make sense, I suppose...


    How did I solve it?

    In the getReducedData method, instead of passing the model reference, I return the model's name as a string.

    getReducedData() {
        return [this.data, createModelKey(this.model), this.responseMeta]
    }
    

    I then use that name in a map to instantiate the appropriate model on the client in the reviver, like this:

    const classes = {
        [createModelKey(ProductModel)]: ProductModel,
    }
    
    export default definePayloadPlugin(() => {
        definePayloadReducer('ApiResponse', data => data instanceof ApiResponse && data.getReducedData())
        definePayloadReviver('ApiResponse', (data) => {
            const model = data[1] ? classes[data[1]] : null
            return new ApiResponse(data[0], model, data[2])
        })
    })
    

    Please let me know if there's anything I'm doing wrong or if there are better ways to approach this. 🙏 If possible, I would prefer not to add the model classes to the map manually...

    I hope this helps someone!