fix(notification): replace view-all anchor with button to prevent iframe navigation #552

Merged
marcel merged 5 commits from feat/issue-545-notification-dropdown-iframe-fix into main 2026-05-12 18:56:14 +02:00
3 changed files with 45 additions and 13 deletions

View File

@@ -79,7 +79,7 @@ The following `vi.mock(module, factory)` calls in browser specs are **acceptable
These modules are resolved at static import time (before any test runs). Their `vi.mock` factories are served by birpc synchronously during module graph resolution, not after worker teardown.
**Pattern note:** When an overlay or dropdown contains a navigation link (`<a href="…">`), use `e.preventDefault()` + `goto(path)` in the click handler instead of letting the browser follow the `href`. In a vitest-browser Playwright iframe there is no SvelteKit router, so a real navigation tears down the orchestrator iframe and crashes the test run. The `href` attribute should still be present for right-click / open-in-new-tab semantics.
**Pattern note:** When an overlay or dropdown triggers a navigation action, use `<button type="button">` with an `onclick` handler that calls `goto(path)` — do **not** use `<a href="…">` with `e.preventDefault()`. SvelteKit registers its link interceptor as a capture-phase `document` listener, so it fires before the component's bubble-phase `onclick`. By the time `e.preventDefault()` runs the router has already initiated navigation, which tears down the vitest-browser Playwright orchestrator iframe. A `<button>` carries no `href`, so the capture-phase interceptor never fires. See `NotificationDropdown.svelte` for the canonical example.
---

View File

@@ -13,9 +13,8 @@ type Props = {
let { notifications, onMarkRead, onMarkAllRead, onClose }: Props = $props();
function handleViewAll(e: MouseEvent) {
e.preventDefault();
onClose();
function handleViewAll() {
onClose(); // close first — avoids stale dropdown during navigation transition
goto('/aktivitaeten');
}
</script>
@@ -134,12 +133,12 @@ function handleViewAll(e: MouseEvent) {
{/if}
<div class="border-t border-line px-4 py-2">
<a
href="/aktivitaeten"
<button
type="button"
onclick={handleViewAll}
class="text-xs font-medium text-ink-2 transition-colors hover:text-ink"
class="min-h-[44px] px-1 text-xs font-medium text-ink-2 transition-colors hover:text-ink"
>
{m.chronik_view_all()}
</a>
</button>
</div>
</div>

View File

@@ -6,7 +6,10 @@ import NotificationDropdown from './NotificationDropdown.svelte';
vi.mock('$app/navigation', () => ({ goto: vi.fn() }));
afterEach(cleanup);
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
const makeNotification = (overrides: Record<string, unknown> = {}) => ({
id: 'n1',
@@ -156,7 +159,7 @@ describe('NotificationDropdown', () => {
expect(onMarkAllRead).toHaveBeenCalledOnce();
});
it('calls onClose when the view-all link is clicked', async () => {
it('calls onClose when the view-all button is clicked', async () => {
const onClose = vi.fn();
render(NotificationDropdown, {
props: {
@@ -167,14 +170,44 @@ describe('NotificationDropdown', () => {
}
});
const viewAllLink = page.getByRole('link', { name: /alle aktivitäten|view all/i });
await expect.element(viewAllLink).toHaveAttribute('href', '/aktivitaeten');
await viewAllLink.click();
await page.getByRole('button', { name: /alle aktivitäten|view all/i }).click();
expect(onClose).toHaveBeenCalledOnce();
});
it('navigates to /aktivitaeten when the view-all button is clicked', async () => {
render(NotificationDropdown, {
props: {
notifications: [],
onMarkRead: () => {},
onMarkAllRead: () => {},
onClose: () => {}
}
});
await page.getByRole('button', { name: /alle aktivitäten|view all/i }).click();
expect(goto).toHaveBeenCalledWith('/aktivitaeten');
});
it('calls onClose before navigating to /aktivitaeten', async () => {
const callOrder: string[] = [];
const onClose = vi.fn(() => callOrder.push('close'));
vi.mocked(goto).mockImplementation(() => callOrder.push('goto'));
render(NotificationDropdown, {
props: {
notifications: [],
onMarkRead: () => {},
onMarkAllRead: () => {},
onClose
}
});
await page.getByRole('button', { name: /alle aktivitäten|view all/i }).click();
expect(callOrder).toEqual(['close', 'goto']);
});
it('renders MENTION items with the mention verb text', async () => {
render(NotificationDropdown, {
props: {