From 1186ad600962f6f2d9abf5f2158bff07a6278e23 Mon Sep 17 00:00:00 2001 From: Gab Date: Thu, 30 Jul 2026 22:05:40 -0300 Subject: [PATCH] fix(webkit): carry closable through the toaster's store projection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `toast.vue` resolves the close control with `item.entry.closable ?? closable`, so a per-toast option is supposed to win over the Toaster's prop. It could not: the watched projection in `toaster.vue` IS the entry the region renders, not just a change-detection key, and it did not copy `closable` — every toast fell back to the Toaster. That breaks the async pattern hardest: a `loading` toast raised under a `:closable="false"` Toaster and updated in place into an error had no way out. `onClose` stays out of the projection on purpose — the store owns invoking it. Both new tests fail against the previous projection and pass with it. --- .../components/feedback/toast/toast.test.ts | 34 +++++++++++++++++++ .../src/components/feedback/toast/toaster.vue | 8 ++++- 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/packages/webkit/src/components/feedback/toast/toast.test.ts b/packages/webkit/src/components/feedback/toast/toast.test.ts index 7d68534f1..89a78e481 100644 --- a/packages/webkit/src/components/feedback/toast/toast.test.ts +++ b/packages/webkit/src/components/feedback/toast/toast.test.ts @@ -219,6 +219,40 @@ describe('Toast (composition + imperative store)', () => { expect(within(region!).queryByTestId('feedback-toast__close')).toBeNull() }) + + it('honours a per-toast closable over a Toaster that closes nothing', async () => { + // Source: `item.entry.closable ?? closable` — the per-toast option wins. + // It only can if the region's projection of the store entry carries + // `closable`; when it did not, this option was silently inert. + await mountToaster({ position: 'bottom-right', duration: 0, closable: false }) + + toast.error('Deployment failed', { closable: true }) + const region = await regionFor('bottom-right') + const scope = within(region!) + + expect(scope.getByText('Deployment failed')).toBeTruthy() + expect(scope.getByTestId('feedback-toast__close')).toBeTruthy() + }) + + it('keeps a per-toast closable through an in-place update', async () => { + // The async pattern: a `loading` toast is raised first and only becomes + // closable when it settles into its error state under the SAME id. + await mountToaster({ position: 'bottom-right', duration: 0, closable: false }) + + const id = toast.loading('Deploying…') + let region = await regionFor('bottom-right') + expect(within(region!).queryByTestId('feedback-toast__close')).toBeNull() + + useToastStore().update(id, { + type: 'error', + message: 'Deployment failed', + duration: 0, + closable: true + }) + region = await regionFor('bottom-right') + + expect(within(region!).getByTestId('feedback-toast__close')).toBeTruthy() + }) }) describe('per-toast position overrides the Toaster default', () => { diff --git a/packages/webkit/src/components/feedback/toast/toaster.vue b/packages/webkit/src/components/feedback/toast/toaster.vue index 47bfaf834..86b7e77a2 100644 --- a/packages/webkit/src/components/feedback/toast/toaster.vue +++ b/packages/webkit/src/components/feedback/toast/toaster.vue @@ -118,6 +118,11 @@ if (changed) heights.value = nextHeights } + // This projection IS the entry the region renders (`item.entry` below), not + // just a change-detection key — so every field the template or the default + // slot reads has to be carried here. `closable` in particular: without it the + // per-toast override is dropped and every toast falls back to the Toaster's + // prop. (`onClose` is deliberately absent: the store owns invoking it.) watch( () => store.toasts.map((t) => ({ @@ -127,7 +132,8 @@ description: t.description, action: t.action, duration: t.duration, - position: t.position + position: t.position, + closable: t.closable })), (next) => { const nextIds = new Set(next.map((t) => t.id))