nuxt / nuxt/ui

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

Open
#6,674 1 comment 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

triage
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 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:

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:

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

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. 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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.