Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix(server-renderer): Fix call to serverPrefetch in server renderer with an async setup #10893

Conversation

deleteme
Copy link
Contributor

@deleteme deleteme commented May 8, 2024

This fixes a bug where the serverPrefetch option would not be called with an async setup method.

About the fix

A prefetches reference was created while instance.sp was null. However, with an async setup, instance.sp has the serverPrefetch methods only after setup resolves.

The fix is achieved by waiting to for setup to resolve before trying to access the the serverPrefetch options from instance.sp.

I added a unit test that reproduces the problem, by adding an async setup to the component definition. FWIW, I considered and experimented with adding more async setup variations of the existing tests, but backed out after they did not reproduce the problem and decided they weren't critical.

Impact of the bug

The bug can break SSR because the server renderer will not wait long enough to produce serializable state.

Specifically, this bug exists when using:

  1. defineNuxtComponent from Nuxt 3 (because it creates an async setup method)
  2. with the vue-apollo package options API (because it defines a serverPrefetch method that resolves when all apollo promises resolve)

The server will make a request, but the renderer doesn't wait for it to finish. Nuxt dispatches app:rendered, serializes whatever state it has (which excludes the in-flight apollo graphql requests). Then the page loads in a blank state, then the client in the browser will issue the now redundant graphql requests, and finally render the complete page.

This problem affects Nuxt 2 apollo apps that are migrating to Nuxt 3 and is a regression in the ecosystem.

… option w/async setup

This fixes a bug where the serverPrefetch option would not be called
with an async setup method.

A `prefetches` reference was created while `instance.sp` was `null`.
However, with an async setup, `instance.sp` has the serverPrefetch
methods only after setup resolves.

The fix is achieved by waiting to for setup to resolve before trying to
access the the serverPrefetch options from `instance.sp`.
Copy link

github-actions bot commented May 15, 2024

Size Report

Bundles

File Size Gzip Brotli
runtime-dom.global.prod.js 99.1 kB (-920 B) 37.5 kB (-149 B) 33.8 kB (-152 B)
vue.global.prod.js 157 kB (-1.85 kB) 57.3 kB (-214 B) 51 kB (-158 B)

Usages

Name Size Gzip Brotli
createApp 54.5 kB (-614 B) 21.1 kB (-74 B) 19.3 kB (-62 B)
createSSRApp 58.5 kB (-596 B) 22.8 kB (-82 B) 20.7 kB (-69 B)
defineCustomElement 59.1 kB (-695 B) 22.6 kB (-101 B) 20.6 kB (-71 B)
overall 68.1 kB (-722 B) 26.2 kB (-100 B) 23.8 kB (-82 B)

@deleteme
Copy link
Contributor Author

Hi @sodatea, is there anything else you need from me for this PR to merge?

const setupAndPrefetched = setupResolved
.then(() => {
// instance.sp may be null until an async setup resolves, so evaluate it here
const prefetches = getPrefetches()
Copy link
Member

@edison1105 edison1105 Aug 16, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It seems getPrefetches will be called twice, if hasAsyncSetup equals false. I think this should be avoided.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reading instance.sp only once and caching that value to be used after set up resolves is what caused the bug.

What is the concern about accessing instance.sp twice?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@edison1105 I pushed a commit which:

  • Makes renderComponentVNode async. It now always returns a promise.
  • Uses await directly on the setupComponent call. No need to inspect it to see if it is a promise.
  • Simplifies the implementation to remove branching logic.
  • Reads instance.sp once, after setup.

Please let me know if this is what you had in mind. This extra commit is more involved.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure if it's reasonable to change renderComponentVNode to async. see #11340 (comment)
Let's wait for reviews from other team members

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@edison1105 Ok, I see that there is deliberate synchronous fast paths in place. In that case, I'm happy to revert the last commit or do whatever is needed here.

Copy link
Member

@edison1105 edison1105 Aug 20, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe we could change it like below, what do you think?

  let prefetches = instance.sp /* LifecycleHooks.SERVER_PREFETCH */
  if (hasAsyncSetup || prefetches) {
    let p: Promise<unknown> = (
      hasAsyncSetup
        // instance.sp may be null until an async setup resolves, so evaluate it here
        ? (res as Promise<void>).then(() => (prefetches = instance.sp))
        : Promise.resolve()
    )
      .then(() => {
        if (prefetches) {
          return Promise.all(
            prefetches.map(prefetch => prefetch.call(instance.proxy)),
          )
        }
      })
      // Note: error display is already done by the wrapped lifecycle hook function.
      .catch(NOOP)
    return p.then(() => renderComponentSubTree(instance, slotScopeId))
  } else {
    return renderComponentSubTree(instance, slotScopeId)
  }

Copy link
Contributor Author

@deleteme deleteme Aug 20, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@edison1105 I pushed up that change in 7af4f77, and then I went a little further and optimized away two promises one promise in e159b16.

  1. Removes the unnecessary hasAsyncSetup ternary conditional by using Promise.resolve directly around the res object because it returns the exact same promise if it is given one.

  2. Removes a new promise by removing a .then callback

  3. Removes another new promise by removing a .catch callback in favor of assigning the noop as the second argument Edit: I was mistaken about this change; It isn't pushed, nor needed here.

So maybe this will be a little faster or at least produce a little less garbage.

Copy link

netlify bot commented Aug 16, 2024

Deploy Preview for vue-next-template-explorer failed.

Name Link
🔨 Latest commit 7bf6732
🔍 Latest deploy log https://app.netlify.com/sites/vue-next-template-explorer/deploys/66c4075bd86c70000894e7f5

Copy link

netlify bot commented Aug 16, 2024

Deploy Preview for vue-sfc-playground failed.

Name Link
🔨 Latest commit 7bf6732
🔍 Latest deploy log https://app.netlify.com/sites/vue-sfc-playground/deploys/66c4075bd596850008c07783

Copy link

pkg-pr-new bot commented Aug 19, 2024

Open in Stackblitz

@vue/compiler-core

pnpm add https://pkg.pr.new/@vue/compiler-core@10893

@vue/compiler-sfc

pnpm add https://pkg.pr.new/@vue/compiler-sfc@10893

@vue/compiler-dom

pnpm add https://pkg.pr.new/@vue/compiler-dom@10893

@vue/compiler-ssr

pnpm add https://pkg.pr.new/@vue/compiler-ssr@10893

@vue/reactivity

pnpm add https://pkg.pr.new/@vue/reactivity@10893

@vue/runtime-dom

pnpm add https://pkg.pr.new/@vue/runtime-dom@10893

@vue/runtime-core

pnpm add https://pkg.pr.new/@vue/runtime-core@10893

@vue/server-renderer

pnpm add https://pkg.pr.new/@vue/server-renderer@10893

@vue/shared

pnpm add https://pkg.pr.new/@vue/shared@10893

@vue/compat

pnpm add https://pkg.pr.new/@vue/compat@10893

vue

pnpm add https://pkg.pr.new/vue@10893

commit: 7bf6732

const p: Promise<unknown> = Promise.resolve(res as Promise<void>)
.then(() => {
// instance.sp may be null until an async setup resolves, so evaluate it here
prefetches = instance.sp
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
prefetches = instance.sp
if(hasAsyncSetup) prefetches = instance.sp

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I still believe here should check hasAsyncSetup

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please see commit 7bf6732

const p: Promise<unknown> = Promise.resolve(res as Promise<void>)
.then(() => {
// instance.sp may be null until an async setup resolves, so evaluate it here
prefetches = instance.sp
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I still believe here should check hasAsyncSetup

@edison1105 edison1105 added the ready to merge The PR is ready to be merged. label Aug 20, 2024
@yyx990803 yyx990803 merged commit 6039e25 into vuejs:main Sep 3, 2024
8 of 13 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
ready to merge The PR is ready to be merged. scope: ssr
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants