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

Vue is not catching errors on server side in SSR when using async child setup #12575

Open
Gwynerva opened this issue Dec 18, 2024 · 31 comments · May be fixed by #12601
Open

Vue is not catching errors on server side in SSR when using async child setup #12575

Gwynerva opened this issue Dec 18, 2024 · 31 comments · May be fixed by #12601
Labels
🔨 p3-minor-bug Priority 3: this fixes a bug, but is an edge case that only affects very specific usage. scope: ssr

Comments

@Gwynerva
Copy link

Gwynerva commented Dec 18, 2024

Vue version

3.5.13

Link to minimal reproduction

Vue SFC Playground

Steps to reproduce

Just open a link and you will see errors when SSR is ON.
When SSR is OFF everything works as expected.

Also, commeting the line with top-level await (making Comp.vue a sync component) will also work fine both with and without SSR.

What is expected?

In SSR mode the page is expected to be blank white for 1 second and then display error message from App.vue.
This works as expected with SSR turned off.

Basically onErrorCaptured should be triggered, child setup and <template> render immediately stopped and error message from parent component shown.

What is actually happening?

In SSR mode the page is blank for 1 second but then it tries to render the <template> of Comp.vue using the data that might not even exist (because it appears in setup after throwing error).

And even if it manages to render <template> correctly, it still does not trigger onErrorCaptured in App.vue and do not switch to show error message.

Any additional comments?

I am building a custom rendering package built on top of Vue and it heavily uses structures like these:

 // Wrappers are supposed to catch ALL errors and throws in child <Block /> and show nice error message

<BlockWrapper>
    <Block ... />
</BlockWrapper>

<BlockWrapper>
    <Block ... />
</BlockWrapper>

<BlockWrapper>
    <Block ... />
</BlockWrapper>

...

Because of this bug OR my misunderstanding of how to handle async errors in Vue 3, even when thrown it still tries to render child block's <template> even with missing data (because setup is stopped executing), throws on reading properties of non-existing objects and the whole Nuxt page breaks.

Please, don't tell me this is a no-fix. It would be a disaster... 😭

@Gwynerva
Copy link
Author

Gwynerva commented Dec 18, 2024

I did some research and it turns out onErrorCaptured is not related to this bug.
There is something wrong happening with Vue on server side.

Calling setup directly in wrapper component does not help too.
Here is an example where I create a deep copy of child component and try {} catch {} it's setup directly in wrapper and it still tries to render it's <template> on server side and fails to get bar.val from setup because bar is not existing!

If we remove connections to setup function in child component we can see that Vue on server side actually detects an error but for some reason still just renders the child's <template> which is: I am just a Paragraph. Bar: REMOVED. And then on client side it catches the error again and this time show actual error message (and log hydration mismatches)...

image

Also in the picture you can see that when compiling on server-side Vue calls the child async setup and when it is done it stops compilation. Error is also detected, but after module is built!

So it seems Vue on server side can't catch an error and renders the child's template (if it can do it). And only on client side it actually can catch an error, stop child component from executing and display correct error message. But this causes hydration mismatch...

@Gwynerva Gwynerva changed the title onErrorCaptured is not triggered in SSR when async child throws Vue is not catching erros on server side in SSR when using async child setup Dec 18, 2024
@Gwynerva Gwynerva changed the title Vue is not catching erros on server side in SSR when using async child setup Vue is not catching errors on server side in SSR when using async child setup Dec 18, 2024
@noootwo
Copy link
Contributor

noootwo commented Dec 19, 2024

The error threw in setup is correctly be caught:

// return the promise so server-renderer can wait on it
return setupResult
.then((resolvedResult: unknown) => {
handleSetupResult(instance, resolvedResult, isSSR)
})
.catch(e => {
handleError(e, instance, ErrorCodes.SETUP_FUNCTION)
})

Therefore, regardless of whether the setup is executed successfully or not (whether there is a correct setupState), the component will be rendered:
// Note: error display is already done by the wrapped lifecycle hook function.
.catch(NOOP)
return p.then(() => renderComponentSubTree(instance, slotScopeId))

If the setup executed failed, means will render component with an empty setupState.Is this the expected behavior?

@Gwynerva
Copy link
Author

Gwynerva commented Dec 19, 2024

Well this behaviour is really confusing and right now I don't have even an idea of how to catch async child errors in parent wrapper... I tried onErrorCaptured, deep copying child setup, passing error ref to child as a prop and etc.

All these solutions just fail because Vue on server-side tries to render template with broken setup state first, which 99% will cause more unhandled errors inside onErrorCaptured or catch and these errors propagate all the way up to the App root breaking the whole page (in case of Nuxt).

So I desperately need either a fix or workaround 🥲
In my package I heavily use components like <Render ...> recursively and because of this behaviour Vue even when error is thrown still tries to render child's <Render> with broken or non-existing setup variables, which is likely to call it's own <Render> and this hell goes on...

The only ugly workaround-like is to write child <template> checking every (!) setup property on undefined...
But this is kind of horrible and feels fundamentally wrong.

@noootwo
Copy link
Contributor

noootwo commented Dec 19, 2024

The example without Suspense and SSR, catch the error in setup and render.
The example with Suspense and without SSR, only catch the error in setup, and skip render the component which execute setup failed.

It seems like there are different behaviors in different situations, which one is expected? @edison1105

@Gwynerva
Copy link
Author

Gwynerva commented Dec 19, 2024

@noootwo Can you please give me an example with Suspense (which is turned on by default in Nuxt on root level) and SSR enabled? I tried to create a workaround yersterday for hours and failed. I am rather new in Vue.

@noootwo
Copy link
Contributor

noootwo commented Dec 19, 2024

@noootwo Can you please give me an example with Suspense (which is turned on by default in Nuxt on root level) and SSR enabled? I tried to create a workaround yersterday for hours and failed. I am rather new in Vue.

@Gwynerva Can you use try/catch to handle the error in sub-component's setup? Like this, I'm not sure if this is what you need.

@Gwynerva
Copy link
Author

Gwynerva commented Dec 19, 2024

Unfortunately, the whole point in my library is that wrapper handles the error and adds some handy UI, while child component can be almost anything: KaTeX formula, image, paragraph and etc. Child components are external, often provided by other libraries. And if something goes wrong it is a wrapper that has to catch any errors and display nice error message.

image

Handling errors inside child component eliminates one of main purposes of the wrapper — providing same error design no matter what wrapper is rendering inside. I tried to pass error ref as prop to child and use onErrorCaptured inside child but it fails anyways.

In my situation handling errors in child is a bad practice, because children are supposed "by design" to throw if something goes wrong so wrapper could nicely tell user about a problem in unified way.

Observable block system is a good example of what I am trying to do with Vue.

@noootwo
Copy link
Contributor

noootwo commented Dec 20, 2024

Unfortunately, the whole point in my library is that wrapper handles the error and adds some handy UI, while child component can be almost anything: KaTeX formula, image, paragraph and etc. Child components are external, often provided by other libraries. And if something goes wrong it is a wrapper that has to catch any errors and display nice error message.

image

Handling errors inside child component eliminates one of main purposes of the wrapper — providing same error design no matter what wrapper is rendering inside. I tried to pass error ref as prop to child and use onErrorCaptured inside child but it fails anyways.

In my situation handling errors in child is a bad practice, because children are supposed "by design" to throw if something goes wrong so wrapper could nicely tell user about a problem in unified way.

Observable block system is a good example of what I am trying to do with Vue.

Sounds you can do like this, but this is just temporary measures. After this issue is resolved, there should be more humanized approaches available.

@Gwynerva
Copy link
Author

Gwynerva commented Dec 20, 2024

Still not applicable because it requires handling the error inside the component where is was thrown. And I plan to have like a few dozens of such "child" components, and all of them can pontentially throw... I really need to handle errors in wrapper, and this wrapper is a "physically" different component, outside of child component. The only possible connections would be possible with props, but still onErrorCaptured would be a perfect solution here.

<Wrapper> // Part of base library, provides info about <Child> and also catches its errors
    <Child /> // Can be an external, can throw, can do anything...
</Wrapper>

<Wrapper>
    <Child />
</Wrapper>

<Wrapper>
    <Child />
</Wrapper>

Anyways, thank you, I think your example will do for testing purposes for now to prevent the whole page crashing on every error. But this is unfortunately not a workaround :(

This seems like a rather serious SSR issue to me because right now onErrorCaptured simply does not do what it must do.
It does not stop Vue from rendering child <template> even if child <script setup> failed.

@edison1105

@edison1105
Copy link
Member

The example without Suspense and SSR, catch the error in setup and render. The example with Suspense and without SSR, only catch the error in setup, and skip render the component which execute setup failed.

It seems like there are different behaviors in different situations, which one is expected? @edison1105

These two examples capture the same error messages. Below are the modified versions, please observe the console.log
without Suspense and SSR
with Suspense and without SSR

When wrapped inside Suspense, the error message is not displayed because of error.value = e triggers an update of Suspense, causing the Comp not to resolve. I don't think this can be considered a bug.

@edison1105
Copy link
Member

The original issue arises because we catch exceptions during client-side rendering execution at

However, on the server-side, we don't catch exceptions at I think this should be fixed.

@edison1105 edison1105 added scope: ssr 🔨 p3-minor-bug Priority 3: this fixes a bug, but is an edge case that only affects very specific usage. labels Dec 23, 2024
@Gwynerva
Copy link
Author

Gwynerva commented Dec 23, 2024

I would argue about "minor" because in my opinion using standart onErrorCaptured on async components is not something very specific, and intent to catch async errors is rather common thing, not an edge case. In fact, I am surprised to be the first one to report it...

Either way, it's nice to know it's not a problem on my end. Very much looking forward to the fix, as there is no way to workaround it and the bug is extremely annoying.

@edison1105 edison1105 linked a pull request Dec 23, 2024 that will close this issue
@edison1105
Copy link
Member

@Gwynerva
If possible, you can throw the exception in onServePrefetch, it will be correctly handed.

@Gwynerva
Copy link
Author

Gwynerva commented Dec 23, 2024

@edison1105 Does not seem to work or I am doing something wrong?

Also, is there a way to use your PR instead of main version of Vue?
I tried "vue": "vuejs/core#edison/fix/12575" but that did not work.

@edison1105
Copy link
Member

edison1105 commented Dec 23, 2024

@Gwynerva

@edison1105 Does not seem to work or I am doing something wrong?

Also, is there a way to use your PR instead of main version of Vue? I tried "vue": "vuejs/core#edison/fix/12575" but that did not work.

Oh, I made a mistake onServePrefetch will not work on this scenario because there is no patch on SSR. You can try the preview package of #12601 via:

npm i https://pkg.pr.new/vue@12601

@Gwynerva
Copy link
Author

Gwynerva commented Dec 23, 2024

@edison1105 This does not work with bun, but I installed it in different folder and just copy/pasted the new files inside my project.

Now, it does not crash the whole page (which is super cool) but still tries to render template causing error and hydration mismatches.

image

As you can see the first error is the error I throw. But then it errors again trying to access non-existing properties of failed setup.

@edison1105
Copy link
Member

@Gwynerva
Could you please provide a reproduction that includes these error details? It can be a github repository, preferably simplified by removing unnecessary deps. I will take a closer look at it.

@Gwynerva
Copy link
Author

Gwynerva commented Dec 23, 2024

@edison1105 Sadly, I can't use your PR on Vue SFC Playground. But I made a StackBlitz demo with your PR and reproduced the same example as in first post.

Right now it does not even catching errors in sync Comp setup (with Promise line commented). And still not working with async Comp setup (when Promise line is uncommented).

Expected behaviour: both sync and async Comp throw calls must result in showing div with "Error: Back to App.vue" text and NOT even try to render Comp because it's <template> references non-existing setup properties.

@edison1105
Copy link
Member

@Gwynerva
Copy link
Author

Gwynerva commented Dec 23, 2024

@edison1105 Now it is working with async Comp and not working with sync Comp (try commenting and uncommenting await ... Promise line in Comp.vue). And this bug works even when SSR is turned off, also in both (async and sync) cases it warns in console 🤣

To be more precise, it actually "kind of" works, but instead of my own error message "Back to App.vue" it shows an error from render when accessing unexisting properties, meaning it for some reasons still tries to render Comp.vue template even when it's setup failed.

@edison1105
Copy link
Member

edison1105 commented Dec 24, 2024

@edison1105 Now it is working with async Comp and not working with sync Comp (try commenting and uncommenting await ... Promise line in Comp.vue). And this bug works even when SSR is turned off, also in both (async and sync) cases it warns in console 🤣

To be more precise, it actually "kind of" works, but instead of my own error message "Back to App.vue" it shows an error from render when accessing unexisting properties, meaning it for some reasons still tries to render Comp.vue template even when it's setup failed.

I couldn't see where the sync component fails to work. It works fine in both SSR and non-SSR. I am doing something wrong?
Suspense + Sync Component + SSR + PR 12601

The error message changed is expected:

<Suspense>
    <div v-if="error">{{ error }}</div>
    <Comp v-else :foo="'Foo Is Here!'" />
</Suspense>
  • if Comp.vue is sync, both the setup and render of Comp will be called, so you will see two error messages, observe the console.log
  • if Comp.vue is async, when an expectation is thrown during its setup, Suspense will patch and Comp.vue will be unmounted, Comp.vue never resolved, so its render function will not be called.

@Gwynerva
Copy link
Author

Gwynerva commented Dec 24, 2024

@edison1105 Why sync Comp.vue calling both setup and render? Isn't sync version is supposed to trigger parent onErrorCaptured which causes a component replacement just as it happens in async version? Or why async version is not calling both setup and render?

I want to show only actual error, the error in setup. Am I supposed to show only first error in onErrorCaptured and silently supress all other errors? Because both sync and async children can appear in my error handling wrapper.

Looks totally not safe for me to supress errors...

@edison1105
Copy link
Member

edison1105 commented Dec 24, 2024

@edison1105 Why sync Comp.vue calling both setup and render? Isn't sync version is supposed to trigger parent onErrorCaptured which causes a component replacement just as it happens in async version? Or why async version is not calling both setup and render?

I want to show only actual error, the error in setup. Am I supposed to show only first error in onErrorCaptured and silently supress all other errors? Because both sync and async children can appear in my error handling wrapper.

Looks totally not safe for me to supress errors...

When calling the setup function of a component

  • if the return value is not a Promise, the component's render function will continue to execute in the same tick.
  • if the return value is a Promise, it will wait for the Promise to resolve before executing render. In this case, the setup and the Promise resolution do not occur in the same tick. The entire process is as follows:
  1. setup is executed and throws an exception.
  2. error.value is assigned, triggering an update and placing the update function in the queue.
  3. The event loop processes the asynchronous callbacks in the task queue.
  4. update is executed.
  5. The Comp.vue component is unmounted.
  6. After 1s, the Promise resolves, preparing to execute the render function of Comp.vue, but since Comp.vue has already been unmounted, it is no longer processed.

Regardless of whether the component is synchronous or asynchronous, the exception thrown in the setup function always comes first, followed by the exception in the render function.

@Gwynerva
Copy link
Author

Gwynerva commented Dec 24, 2024

Ok, I see now. So I guess my solution here is to display div with the first error only and supress everything after.
So the only problem left are hydration mismatches in my project, but I guess they might be because of this bug we discovered as I heavily use h-created VNodes in my library.

@edison1105
Copy link
Member

@Gwynerva
This hydration mismatch is not necessarily the same as yours. This bug is actually an edge case; if the tag is not p, there will be no issue.

if the tag is not a p, it will work as expected.see

@Gwynerva
Copy link
Author

Gwynerva commented Dec 24, 2024

I will investigate this and let you know. Thank you!
With your PR this issue can be considered solved I guess.

@Gwynerva
Copy link
Author

Gwynerva commented Dec 24, 2024

@edison1105 Sadly, still get hydration mismatches when rendering nodes deep inside <Suspense> (in Nuxt suspense is on root level and it can not be used again somewhere deep inside).

Playground Example

Also, can't check the direct SSR output in Vue Playground but on my Nuxt setup it simply does not render the error message, just putting blank <!----> comment where the error text is supposed to be, also not adding error class to wrapper and in general, I suppose just not "return back" in wrapper to rerender the errored component...

image

This is how I render block in my library:

<script lang="ts" setup>
// ...

onErrorCaptured(e => {
    if (error.value)
        return false;

    console.error(`Error in block product "${props.node.name}"!`);
    console.error(e);
    error.value = e;
    return false;
});
</script>

<template>
    <div :class="cls.blockContainer">

        <BlockFloat v-bind="{ ...props, ...{ position: 'above' } }" />

        <template v-if="error">
            <div :class="[cls.block, cls.error]">
                <BlockAside v-bind="props" />
                <div :class="cls.blockMain">{{ error }}</div>
            </div>
        </template>
        <template v-else>
            <ProductComponent v-if="render.customLayout" />
            <div v-else :class="cls.block">
                <BlockAside v-bind="props" />
                <div :class="cls.blockMain">
                    <ProductComponent />
                </div>
            </div>
        </template>

        <BlockFloat v-bind="{ ...props, ...{ position: 'below' } }" />

    </div>
</template>

My guess is that Vue SSR or Nuxt don't see an error and try to render v-else template first. Then, inside ProductComponent they encounter error but does not return up to parent and not trigger v-if="error" template...

That is how I get <!----> instead of error message and have missing cls.error class.

@edison1105
Copy link
Member

@Gwynerva
This is the expected behavior. The SSR output content is three empty divs since SSR has no patch and Comp.vue renders nothing. for this scenarios, you should use data-allow-mismatch = "children" to ignore the mismatch warning.

see Playground

@Gwynerva
Copy link
Author

Gwynerva commented Dec 25, 2024

@edison1105 Does this mean there are no ways to do component replacement in parent when async child throws with Suspense + SSR? I tried to use nested <Suspense> inside Wrapper with supensible attribute but it changes nothing unfortunately.

for this scenarios, you should use data-allow-mismatch = "children" to ignore the mismatch warning.

But this supression will allso ignore problems with badly written child components, not allowing to detect possible SSR errors in them. So I guess this attribute should be added only when building site for production.

@edison1105
Copy link
Member

Does this mean there are no ways to do component replacement in parent when async child throws with Suspense + SSR?

I think so.

@Gwynerva
Copy link
Author

Gwynerva commented Dec 25, 2024

Such a long road to such a painful failure, lol.

Well, I think my only option here is to redesing some core functionality.
Maybe require every SSR compatible child to have sync setup, which receives precalculated async data through props, and that async data is calculated in parent component, where I can catch async errors and change state to error within the boundary of parent setup.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
🔨 p3-minor-bug Priority 3: this fixes a bug, but is an edge case that only affects very specific usage. scope: ssr
Projects
None yet
Development

Successfully merging a pull request may close this issue.

3 participants