#6674·ui

Toaster's own toasts state desyncs from useToast() writes when root app.vue has a top-level await (Suspense-wrapped)

Author: niklasfjeldbergCreated Jul 4, 2026Updated Sep 23, 2026
Labelsneeds reproductiontriage

Environment

  • Nuxt: 4.4.8
  • Nuxt UI: 4.9.0
  • Vue: 3.5.39
  • Nitro: 2.13.4
  • ssr: true
  • Reproduced in both nuxt dev and a nitro-cloudflare-module preset build served via wrangler 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:

javascript
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):

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:

xml
<!-- 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>
xml
<!-- 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.