Skip to content

Commit fc4aede

Browse files
committed
fix(desktop): distinguish update toast dismissal from eviction
1 parent d74b855 commit fc4aede

6 files changed

Lines changed: 53 additions & 8 deletions

File tree

‎.github/workflows/desktop-e2e.yml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ on:
1111
- '.github/workflows/desktop-release.yml'
1212
- 'apps/desktop/**'
1313
- 'apps/sim/app/_shell/desktop-update-*.tsx'
14+
- 'apps/sim/app/layout.tsx'
1415
- 'apps/sim/hooks/use-desktop-update-state.ts'
1516
- 'apps/sim/lib/desktop/**'
1617
- 'apps/sim/app/workspace/**/browser-session/**'

‎apps/desktop/e2e/fixtures/update-notification.tsx‎

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { type ComponentType, StrictMode, useState } from 'react'
2-
import { Chip, ChipInput, ToastProvider } from '@sim/emcn'
2+
import { Chip, ChipInput, ToastProvider, toast } from '@sim/emcn'
33
import { PathnameContext } from 'next/dist/shared/lib/hooks-client-context.shared-runtime'
44
import { createRoot } from 'react-dom/client'
55

@@ -16,6 +16,16 @@ function UpdateNotificationFixture({ Notification }: UpdateNotificationFixturePr
1616
<main className='flex flex-col gap-[16px] p-[48px]'>
1717
<ChipInput aria-label='Work in progress' placeholder='Keep working' />
1818
<Chip onClick={() => setPathname('/workspace/second')}>Switch workspace</Chip>
19+
<Chip
20+
onClick={() => {
21+
for (let index = 0; index < 3; index++) {
22+
toast({ message: `Background task ${index + 1}`, duration: 0 })
23+
}
24+
}}
25+
>
26+
Fill notification stack
27+
</Chip>
28+
<Chip onClick={() => toast.dismissAll()}>Clear notifications</Chip>
1929
<output aria-label='Current route'>{pathname}</output>
2030
</main>
2131
</ToastProvider>

‎apps/desktop/e2e/password-autofill.spec.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -311,6 +311,9 @@ test.describe('saved password autofill', () => {
311311
await expect
312312
.poll(() => pageScript('document.getElementById("user").value'))
313313
.toBe('account9@example.test')
314+
await expect
315+
.poll(() => app.windows().some((page) => page.url().includes('credential-picker.html')))
316+
.toBe(false)
314317
await clickField()
315318
await picker()
316319
await pickerKey('Escape')

‎apps/desktop/e2e/update-notification.spec.ts‎

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -194,20 +194,40 @@ mountUpdateNotificationFixture(DesktopUpdateNotification);`,
194194
}
195195
)
196196

197+
await check('stack eviction does not count as dismissing the update offer', async () => {
198+
await publish({ status: 'checking', version: '2.4.0' })
199+
await expect(restart).toHaveCount(0)
200+
await publish({ status: 'ready', version: '2.4.0' })
201+
await expect(restart).toHaveCount(1)
202+
await page.getByRole('button', { name: 'Fill notification stack' }).click()
203+
await expect(restart).toHaveCount(0)
204+
await page.getByRole('button', { name: 'Clear notifications' }).click()
205+
await expect(notice).toHaveCount(0)
206+
await publish({ status: 'checking', version: '2.4.0' })
207+
await page.evaluate(
208+
() =>
209+
new Promise<void>((resolve) =>
210+
requestAnimationFrame(() => requestAnimationFrame(() => resolve()))
211+
)
212+
)
213+
await publish({ status: 'ready', version: '2.4.0' })
214+
await expect(restart).toBeVisible()
215+
})
216+
197217
await check('actions reach the real preload IPC with the correct operation', async () => {
198218
for (const { state, action, channel } of [
199219
{
200-
state: { status: 'ready', version: '2.1.0' },
220+
state: { status: 'ready', version: '2.4.0' },
201221
action: restart,
202222
channel: 'desktop:updates:install',
203223
},
204224
{
205-
state: { status: 'available', version: '2.2.0' },
225+
state: { status: 'available', version: '2.5.0' },
206226
action: download,
207227
channel: 'desktop:updates:check',
208228
},
209229
{
210-
state: { status: 'available', version: '2.3.0', manual: true },
230+
state: { status: 'available', version: '2.6.0', manual: true },
211231
action: download,
212232
channel: 'desktop:updates:install',
213233
},

‎apps/sim/app/_shell/desktop-update-notification.tsx‎

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,12 @@ import { getDesktopShellVersion, getDesktopUpdates } from '@/lib/desktop'
66
import { isShellOutdated } from '@/lib/desktop/min-version'
77
import { useDesktopUpdateState } from '@/hooks/use-desktop-update-state'
88

9+
interface DesktopUpdateNotificationProps {
10+
[key: string]: never
11+
}
12+
913
/** Keeps optional desktop updates actionable across routes for the current window session. */
10-
export function DesktopUpdateNotification() {
14+
export function DesktopUpdateNotification(_props: DesktopUpdateNotificationProps) {
1115
const { status, version, manual } = useDesktopUpdateState()
1216
const { toast, dismiss } = useToast()
1317
const dismissedOffer = useRef<string | null>(null)
@@ -38,13 +42,13 @@ export function DesktopUpdateNotification() {
3842
},
3943
},
4044
persistAcrossRoutes: true,
41-
onDismiss: () => {
45+
onUserDismiss: () => {
4246
if (active) dismissedOffer.current = offer
4347
},
4448
})
4549

4650
return () => {
47-
// State changes and StrictMode cleanup withdraw the toast without dismissing the offer.
51+
// Ignore clicks while a withdrawn toast finishes its exit animation.
4852
active = false
4953
dismiss(id)
5054
}

‎packages/emcn/src/components/toast/toast.tsx‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,7 @@ interface ToastData {
100100
duration: number
101101
persistAcrossRoutes: boolean
102102
onDismiss?: () => void
103+
onUserDismiss?: () => void
103104
}
104105

105106
interface ToastRemoval {
@@ -174,6 +175,8 @@ type ToastInput = {
174175
action?: ToastAction
175176
/** Called once when the toast leaves the stack, regardless of how it was dismissed. */
176177
onDismiss?: () => void
178+
/** Called directly when the user closes the toast or selects its action, before removal. */
179+
onUserDismiss?: () => void
177180
duration?: number
178181
/**
179182
* Keep the toast across navigation. The stack is otherwise cleared on every
@@ -374,7 +377,10 @@ function ToastItem({ toast: t, geometry, reduceMotion, onDismiss, onMeasure }: T
374377
return () => observer.disconnect()
375378
}, [t.id, onMeasure])
376379

377-
const dismiss = useCallback(() => onDismiss(t.id), [onDismiss, t.id])
380+
const dismiss = () => {
381+
t.onUserDismiss?.()
382+
onDismiss(t.id)
383+
}
378384

379385
const { y, scale, height, zIndex } = geometry
380386
const cornerRadius = height <= COMPACT_CARD_HEIGHT_PX ? COMPACT_RADIUS_PX : CONCENTRIC_RADIUS_PX
@@ -539,6 +545,7 @@ export function ToastProvider({ children }: { children?: ReactNode }) {
539545
duration: input.duration ?? (input.action ? 0 : AUTO_DISMISS_MS),
540546
persistAcrossRoutes: input.persistAcrossRoutes ?? false,
541547
onDismiss: input.onDismiss,
548+
onUserDismiss: input.onUserDismiss,
542549
}
543550
dispatch({ type: 'add', toast: data })
544551
return id

0 commit comments

Comments
 (0)