Toaster's own toasts state desyncs from useToast() writes when root app.vue has a top-level await (Suspense-wrapped)
Nobody has claimed this yet.
- Dominant language
- TypeScript
- Stars
- 6.9k
- Forks
- 1.1k
- Avg merge
- 1d 7h
- Merged PRs (30d)
- 57
Description
Environment
- Nuxt: 4.4.8
- Nuxt UI: 4.9.0
- Vue: 3.5.39
- Nitro: 2.13.4
ssr: true- Reproduced in both
nuxt devand anitro-cloudflare-modulepreset build served viawrangler dev(so it isn't a dev-only artifact of Vite's SSR pipeline).
Description
useToast().add() pushes an item into the shared toasts state correctly (confirmed by reading toasts.value back from the same useToast() call site, including after nextTick() and after a 300ms setTimeout), but the actually-mounted <Toaster> (rendered via <UApp>) never renders it — its own internal toasts binding stays [] forever.
I confirmed this precisely by walking the live Vue component instance tree from the rendered toast viewport element:
const ol = document.querySelector('ol[data-slot="viewport"]');
let cur = ol.__vueParentComponent;
while (cur) {
if (cur.setupState?.toasts !== undefined) {
console.log(cur.type?.name, cur.setupState.toasts.value ?? cur.setupState.toasts);
break;
}
cur = cur.parent;
}
// → "Toaster" []
...while a separate useToast() call in the same page (in onMounted, guaranteed no timing/inject warnings) shows the pushed item present in toasts.value the whole time. Two useToast() call sites that should share one useState('toasts', ...) instance are desynced — the write succeeds, the read (inside Toaster's own setup) never sees it.
Suspected trigger: our root app.vue has a top-level await before <UApp> mounts (fetching page data needed for SEO/branding), making app.vue itself an async component that Nuxt wraps in a <Suspense> boundary. I strongly suspect this is what desyncs Toaster's useState('toasts') binding from the rest of the app's calls — this matches a known class of Vue/Nuxt bug where nested async-setup() components under <Suspense> can end up with inconsistent getCurrentInstance()/injection context after resolution, but I did not have time to build a minimal isolated repro removing our app's other complexity to nail down whether the top-level await is necessary and sufficient.
Separately (and already fixed on our end, not what this issue is about): a component that calls useToast() (via a wrapper composable) after its own top-level await reliably logs [Vue warn]: inject() can only be used inside setup() or functional components — consistent with the known async-setup/lifecycle-timing footgun, and probably a contributing factor but not the sole cause, since the empty-Toaster-state repro above happens even with a useToast() call site that has zero Vue warnings (top of a fresh page's onMounted).
Related issues I found that circle similar territory but don't exactly match this (no top-level-await/Suspense angle, no resolution reached in either):
- https://github.com/nuxt/ui/issues/3564 ("Toast not showing",
UAppcorrectly wrapping, closed with no resolution) - https://github.com/nuxt/ui/issues/1477 (
useToast()beforenavigateTo(), different mechanism — SSR redirect racing the toast, not applicable here since our repro has no navigation at all)
Reproduction
Not yet reduced to a minimal Stackblitz — will follow up if useful, but wanted to file while the diagnostic detail (the live component-tree walk above) is fresh, since it's the strongest lead: I can confirm toasts.value differs between two useToast() call sites within the exact same page render, one of which is <Toaster>'s own.
Rough shape of our app that reproduces it 100% of the time:
<!-- app.vue -->
<template>
<UApp>
<NuxtPage />
</UApp>
</template>
<script setup lang="ts">
// Top-level await BEFORE <UApp> mounts — suspected trigger.
const record = await useAsyncData('site', () => $fetch('/api/site')).then(r => r.data.value);
</script>
<!-- pages/index.vue, or anywhere -->
<script setup lang="ts">
onMounted(() => {
const t = useToast();
t.add({ title: 'test' });
// t.toasts.value DOES contain the item after nextTick — but it never renders.
});
</script>
Additional context
Happy to build the minimal Stackblitz repro if that'd help move this forward — flagging now with the concrete evidence in hand rather than sitting on it.
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start with the reproduced app.vue and pages/index.vue setup using a top-level await before UApp and a mounted useToast() call. Verify whether the Suspense-wrapped app causes the Toaster and page call sites to bind to different state, then reduce the case to isolate the trigger. Done means identifying the cause and providing a reliable fix or minimal reproduction that demonstrates it.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- nuxt, typescript
- Domain
- frontend
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Quiet
- Clarity
- Mostly clear
- Newbie friendliness
- 38/100