refactor(notifications): convert per-component stream hook to module-level singleton
Replaces the per-component createNotificationStream() factory with a shared $lib/stores/notifications.svelte.ts singleton. Ref-counted init()/destroy() ensures one EventSource per tab no matter how many consumers mount simultaneously. Motivation: the /chronik "Für dich" box (#285) needs the same live-arrival stream that NotificationBell already consumes. Two factories would open two SSE connections per tab — this refactor avoids the silent regression before it ships. - New: src/lib/stores/notifications.svelte.ts (module state, refcount) - New: src/lib/stores/notifications.svelte.spec.ts (proves single EventSource across multiple consumers + ref-counted teardown) - Deleted: src/lib/hooks/useNotificationStream.svelte.ts (factory) - Deleted: src/lib/hooks/__tests__/useNotificationStream.svelte.test.ts - NotificationBell now imports the singleton Part of #285. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -3,13 +3,13 @@ import { onMount, onDestroy } from 'svelte';
|
|||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { m } from '$lib/paraglide/messages.js';
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
import { clickOutside } from '$lib/actions/clickOutside';
|
import { clickOutside } from '$lib/actions/clickOutside';
|
||||||
import { createNotificationStream } from '$lib/hooks/useNotificationStream.svelte';
|
import { notificationStore } from '$lib/stores/notifications.svelte';
|
||||||
import NotificationDropdown from './NotificationDropdown.svelte';
|
import NotificationDropdown from './NotificationDropdown.svelte';
|
||||||
|
|
||||||
let open = $state(false);
|
let open = $state(false);
|
||||||
let bellButtonEl: HTMLButtonElement | null = null;
|
let bellButtonEl: HTMLButtonElement | null = null;
|
||||||
|
|
||||||
const stream = createNotificationStream();
|
const stream = notificationStore;
|
||||||
|
|
||||||
async function toggleDropdown() {
|
async function toggleDropdown() {
|
||||||
open = !open;
|
open = !open;
|
||||||
|
|||||||
@@ -1,142 +0,0 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
||||||
import type { NotificationItem } from '../useNotificationStream.svelte';
|
|
||||||
|
|
||||||
// Track the last created EventSource instance
|
|
||||||
let lastEventSource: {
|
|
||||||
close: ReturnType<typeof vi.fn>;
|
|
||||||
onopen: (() => void) | null;
|
|
||||||
onerror: (() => void) | null;
|
|
||||||
simulate: (type: string, data: string) => void;
|
|
||||||
} | null = null;
|
|
||||||
|
|
||||||
class MockEventSource {
|
|
||||||
onopen: (() => void) | null = null;
|
|
||||||
onerror: (() => void) | null = null;
|
|
||||||
close = vi.fn();
|
|
||||||
private listeners: Record<string, ((e: MessageEvent) => void)[]> = {};
|
|
||||||
|
|
||||||
constructor() {
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-this-alias
|
|
||||||
lastEventSource = this;
|
|
||||||
}
|
|
||||||
|
|
||||||
addEventListener(type: string, fn: (e: MessageEvent) => void) {
|
|
||||||
if (!this.listeners[type]) this.listeners[type] = [];
|
|
||||||
this.listeners[type].push(fn);
|
|
||||||
}
|
|
||||||
|
|
||||||
simulate(type: string, data: string) {
|
|
||||||
const event = new MessageEvent(type, { data });
|
|
||||||
for (const fn of this.listeners[type] ?? []) {
|
|
||||||
fn(event);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
vi.stubGlobal('EventSource', MockEventSource);
|
|
||||||
|
|
||||||
const mockFetch = vi.fn();
|
|
||||||
vi.stubGlobal('fetch', mockFetch);
|
|
||||||
|
|
||||||
// Import after stubs are set up
|
|
||||||
const { createNotificationStream } = await import('../useNotificationStream.svelte');
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
mockFetch.mockReset();
|
|
||||||
lastEventSource = null;
|
|
||||||
});
|
|
||||||
|
|
||||||
function makeNotification(overrides: Partial<NotificationItem> = {}): NotificationItem {
|
|
||||||
return {
|
|
||||||
id: 'n1',
|
|
||||||
type: 'REPLY',
|
|
||||||
actorName: 'Hans',
|
|
||||||
documentId: 'doc-1',
|
|
||||||
referenceId: 'ref-1',
|
|
||||||
annotationId: null,
|
|
||||||
read: false,
|
|
||||||
createdAt: new Date().toISOString(),
|
|
||||||
...overrides
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('createNotificationStream', () => {
|
|
||||||
it('starts with empty notifications and zero unreadCount', () => {
|
|
||||||
const stream = createNotificationStream();
|
|
||||||
expect(stream.notifications).toHaveLength(0);
|
|
||||||
expect(stream.unreadCount).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('fetchUnreadCount updates unreadCount from API', async () => {
|
|
||||||
mockFetch.mockResolvedValueOnce(new Response(JSON.stringify({ count: 3 }), { status: 200 }));
|
|
||||||
const stream = createNotificationStream();
|
|
||||||
await stream.fetchUnreadCount();
|
|
||||||
expect(stream.unreadCount).toBe(3);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('fetchNotifications populates notifications from API', async () => {
|
|
||||||
const items = [makeNotification()];
|
|
||||||
mockFetch.mockResolvedValueOnce(
|
|
||||||
new Response(JSON.stringify({ content: items }), { status: 200 })
|
|
||||||
);
|
|
||||||
const stream = createNotificationStream();
|
|
||||||
await stream.fetchNotifications();
|
|
||||||
expect(stream.notifications).toHaveLength(1);
|
|
||||||
expect(stream.notifications[0].id).toBe('n1');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('markRead marks notification as read and decrements unreadCount', async () => {
|
|
||||||
mockFetch
|
|
||||||
.mockResolvedValueOnce(new Response(JSON.stringify({ count: 2 }), { status: 200 }))
|
|
||||||
.mockResolvedValueOnce(new Response(null, { status: 200 }));
|
|
||||||
const stream = createNotificationStream();
|
|
||||||
await stream.fetchUnreadCount();
|
|
||||||
|
|
||||||
const notification = makeNotification({ read: false });
|
|
||||||
await stream.markRead(notification);
|
|
||||||
expect(notification.read).toBe(true);
|
|
||||||
expect(stream.unreadCount).toBe(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('markAllRead calls the API and resets unreadCount', async () => {
|
|
||||||
mockFetch.mockResolvedValueOnce(new Response(null, { status: 200 }));
|
|
||||||
const stream = createNotificationStream();
|
|
||||||
await stream.markAllRead();
|
|
||||||
expect(mockFetch).toHaveBeenCalledWith('/api/notifications/read-all', { method: 'POST' });
|
|
||||||
expect(stream.unreadCount).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('destroy closes the EventSource', async () => {
|
|
||||||
mockFetch.mockResolvedValue(new Response(JSON.stringify({ count: 0 }), { status: 200 }));
|
|
||||||
const stream = createNotificationStream();
|
|
||||||
stream.init();
|
|
||||||
expect(lastEventSource).not.toBeNull();
|
|
||||||
stream.destroy();
|
|
||||||
expect(lastEventSource!.close).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('SSE notification event prepends notification and increments unreadCount', async () => {
|
|
||||||
mockFetch.mockResolvedValue(new Response(JSON.stringify({ count: 0 }), { status: 200 }));
|
|
||||||
const stream = createNotificationStream();
|
|
||||||
stream.init();
|
|
||||||
|
|
||||||
const notification = makeNotification({ id: 'sse-1', read: false });
|
|
||||||
lastEventSource!.simulate('notification', JSON.stringify(notification));
|
|
||||||
|
|
||||||
expect(stream.notifications).toHaveLength(1);
|
|
||||||
expect(stream.notifications[0].id).toBe('sse-1');
|
|
||||||
expect(stream.unreadCount).toBe(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('SSE notification event with read:true does not increment unreadCount', async () => {
|
|
||||||
mockFetch.mockResolvedValue(new Response(JSON.stringify({ count: 0 }), { status: 200 }));
|
|
||||||
const stream = createNotificationStream();
|
|
||||||
stream.init();
|
|
||||||
|
|
||||||
const notification = makeNotification({ id: 'sse-2', read: true });
|
|
||||||
lastEventSource!.simulate('notification', JSON.stringify(notification));
|
|
||||||
|
|
||||||
expect(stream.notifications).toHaveLength(1);
|
|
||||||
expect(stream.unreadCount).toBe(0);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,95 +0,0 @@
|
|||||||
import { type NotificationItem, parseNotificationEvent } from '$lib/utils/notifications';
|
|
||||||
|
|
||||||
export type { NotificationItem };
|
|
||||||
|
|
||||||
export function createNotificationStream() {
|
|
||||||
let notifications = $state<NotificationItem[]>([]);
|
|
||||||
let unreadCount = $state(0);
|
|
||||||
let eventSource: EventSource | null = null;
|
|
||||||
|
|
||||||
async function fetchNotifications(): Promise<void> {
|
|
||||||
try {
|
|
||||||
const res = await fetch('/api/notifications?size=10');
|
|
||||||
if (res.ok) {
|
|
||||||
const data = await res.json();
|
|
||||||
notifications = data.content ?? [];
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Failed to fetch notifications', e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchUnreadCount(): Promise<void> {
|
|
||||||
try {
|
|
||||||
const res = await fetch('/api/notifications/unread-count');
|
|
||||||
if (res.ok) {
|
|
||||||
const data = await res.json();
|
|
||||||
unreadCount = data.count;
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Failed to fetch unread count', e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function markRead(notification: NotificationItem): Promise<void> {
|
|
||||||
if (!notification.read) {
|
|
||||||
try {
|
|
||||||
await fetch(`/api/notifications/${notification.id}/read`, { method: 'PATCH' });
|
|
||||||
notification.read = true;
|
|
||||||
unreadCount = Math.max(0, unreadCount - 1);
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Failed to mark notification as read', e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function markAllRead(): Promise<void> {
|
|
||||||
try {
|
|
||||||
await fetch('/api/notifications/read-all', { method: 'POST' });
|
|
||||||
for (const n of notifications) {
|
|
||||||
n.read = true;
|
|
||||||
}
|
|
||||||
unreadCount = 0;
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Failed to mark all notifications as read', e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function init(): void {
|
|
||||||
fetchUnreadCount();
|
|
||||||
eventSource = new EventSource('/api/notifications/stream');
|
|
||||||
eventSource.addEventListener('notification', (e) => {
|
|
||||||
const notification = parseNotificationEvent(e.data);
|
|
||||||
if (!notification) return;
|
|
||||||
notifications = [notification, ...notifications];
|
|
||||||
if (!notification.read) unreadCount += 1;
|
|
||||||
});
|
|
||||||
eventSource.onopen = () => {
|
|
||||||
fetchUnreadCount();
|
|
||||||
};
|
|
||||||
eventSource.onerror = () => {
|
|
||||||
// Close on error to avoid repeated reconnect noise
|
|
||||||
eventSource?.close();
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function destroy(): void {
|
|
||||||
eventSource?.close();
|
|
||||||
eventSource = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
get notifications() {
|
|
||||||
return notifications;
|
|
||||||
},
|
|
||||||
get unreadCount() {
|
|
||||||
return unreadCount;
|
|
||||||
},
|
|
||||||
fetchNotifications,
|
|
||||||
fetchUnreadCount,
|
|
||||||
markRead,
|
|
||||||
markAllRead,
|
|
||||||
init,
|
|
||||||
destroy
|
|
||||||
};
|
|
||||||
}
|
|
||||||
108
frontend/src/lib/stores/notifications.svelte.spec.ts
Normal file
108
frontend/src/lib/stores/notifications.svelte.spec.ts
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import type { NotificationItem } from '$lib/utils/notifications';
|
||||||
|
|
||||||
|
let lastEventSource: MockEventSource | null = null;
|
||||||
|
let eventSourceCount = 0;
|
||||||
|
|
||||||
|
class MockEventSource {
|
||||||
|
onopen: (() => void) | null = null;
|
||||||
|
onerror: (() => void) | null = null;
|
||||||
|
close = vi.fn();
|
||||||
|
private listeners: Record<string, ((e: MessageEvent) => void)[]> = {};
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
eventSourceCount += 1;
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-this-alias
|
||||||
|
lastEventSource = this;
|
||||||
|
}
|
||||||
|
|
||||||
|
addEventListener(type: string, fn: (e: MessageEvent) => void) {
|
||||||
|
if (!this.listeners[type]) this.listeners[type] = [];
|
||||||
|
this.listeners[type].push(fn);
|
||||||
|
}
|
||||||
|
|
||||||
|
simulate(type: string, data: string) {
|
||||||
|
const event = new MessageEvent(type, { data });
|
||||||
|
for (const fn of this.listeners[type] ?? []) {
|
||||||
|
fn(event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
vi.stubGlobal('EventSource', MockEventSource);
|
||||||
|
|
||||||
|
const mockFetch = vi.fn();
|
||||||
|
vi.stubGlobal('fetch', mockFetch);
|
||||||
|
|
||||||
|
const { notificationStore, __resetForTest } = await import('./notifications.svelte');
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockFetch.mockReset();
|
||||||
|
mockFetch.mockResolvedValue(new Response(JSON.stringify({ count: 0 }), { status: 200 }));
|
||||||
|
lastEventSource = null;
|
||||||
|
eventSourceCount = 0;
|
||||||
|
__resetForTest();
|
||||||
|
});
|
||||||
|
|
||||||
|
function makeNotification(overrides: Partial<NotificationItem> = {}): NotificationItem {
|
||||||
|
return {
|
||||||
|
id: 'n1',
|
||||||
|
type: 'REPLY',
|
||||||
|
actorName: 'Hans',
|
||||||
|
documentId: 'doc-1',
|
||||||
|
documentTitle: null,
|
||||||
|
referenceId: 'ref-1',
|
||||||
|
annotationId: null,
|
||||||
|
read: false,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
...overrides
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('notificationStore (singleton)', () => {
|
||||||
|
it('opens a single EventSource across multiple init() calls', () => {
|
||||||
|
notificationStore.init();
|
||||||
|
notificationStore.init();
|
||||||
|
notificationStore.init();
|
||||||
|
|
||||||
|
expect(eventSourceCount).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('closes the EventSource only after every init() is matched with destroy()', () => {
|
||||||
|
notificationStore.init();
|
||||||
|
notificationStore.init();
|
||||||
|
const es = lastEventSource!;
|
||||||
|
|
||||||
|
notificationStore.destroy();
|
||||||
|
expect(es.close).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
notificationStore.destroy();
|
||||||
|
expect(es.close).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reopens a fresh EventSource after full teardown', () => {
|
||||||
|
notificationStore.init();
|
||||||
|
notificationStore.destroy();
|
||||||
|
notificationStore.init();
|
||||||
|
|
||||||
|
expect(eventSourceCount).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('SSE notification event prepends notification and increments unreadCount', () => {
|
||||||
|
notificationStore.init();
|
||||||
|
|
||||||
|
const notification = makeNotification({ id: 'sse-1', read: false });
|
||||||
|
lastEventSource!.simulate('notification', JSON.stringify(notification));
|
||||||
|
|
||||||
|
expect(notificationStore.notifications[0].id).toBe('sse-1');
|
||||||
|
expect(notificationStore.unreadCount).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('markAllRead resets unreadCount', async () => {
|
||||||
|
mockFetch.mockResolvedValue(new Response(null, { status: 200 }));
|
||||||
|
await notificationStore.markAllRead();
|
||||||
|
|
||||||
|
expect(mockFetch).toHaveBeenCalledWith('/api/notifications/read-all', { method: 'POST' });
|
||||||
|
expect(notificationStore.unreadCount).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
108
frontend/src/lib/stores/notifications.svelte.ts
Normal file
108
frontend/src/lib/stores/notifications.svelte.ts
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
import { type NotificationItem, parseNotificationEvent } from '$lib/utils/notifications';
|
||||||
|
|
||||||
|
export type { NotificationItem };
|
||||||
|
|
||||||
|
let notifications = $state<NotificationItem[]>([]);
|
||||||
|
let unreadCount = $state(0);
|
||||||
|
let eventSource: EventSource | null = null;
|
||||||
|
let refCount = 0;
|
||||||
|
|
||||||
|
async function fetchNotifications(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/notifications?size=10');
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
notifications = data.content ?? [];
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to fetch notifications', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchUnreadCount(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/notifications/unread-count');
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
unreadCount = data.count;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to fetch unread count', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function markRead(notification: NotificationItem): Promise<void> {
|
||||||
|
if (!notification.read) {
|
||||||
|
try {
|
||||||
|
await fetch(`/api/notifications/${notification.id}/read`, { method: 'PATCH' });
|
||||||
|
notification.read = true;
|
||||||
|
unreadCount = Math.max(0, unreadCount - 1);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to mark notification as read', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function markAllRead(): Promise<void> {
|
||||||
|
try {
|
||||||
|
await fetch('/api/notifications/read-all', { method: 'POST' });
|
||||||
|
for (const n of notifications) {
|
||||||
|
n.read = true;
|
||||||
|
}
|
||||||
|
unreadCount = 0;
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to mark all notifications as read', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function init(): void {
|
||||||
|
refCount += 1;
|
||||||
|
if (refCount > 1) return;
|
||||||
|
|
||||||
|
fetchUnreadCount();
|
||||||
|
eventSource = new EventSource('/api/notifications/stream');
|
||||||
|
eventSource.addEventListener('notification', (e) => {
|
||||||
|
const notification = parseNotificationEvent((e as MessageEvent).data);
|
||||||
|
if (!notification) return;
|
||||||
|
notifications = [notification, ...notifications];
|
||||||
|
if (!notification.read) unreadCount += 1;
|
||||||
|
});
|
||||||
|
eventSource.onopen = () => {
|
||||||
|
fetchUnreadCount();
|
||||||
|
};
|
||||||
|
eventSource.onerror = () => {
|
||||||
|
eventSource?.close();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function destroy(): void {
|
||||||
|
if (refCount === 0) return;
|
||||||
|
refCount -= 1;
|
||||||
|
if (refCount === 0) {
|
||||||
|
eventSource?.close();
|
||||||
|
eventSource = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function __resetForTest(): void {
|
||||||
|
eventSource?.close();
|
||||||
|
eventSource = null;
|
||||||
|
refCount = 0;
|
||||||
|
notifications = [];
|
||||||
|
unreadCount = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const notificationStore = {
|
||||||
|
get notifications() {
|
||||||
|
return notifications;
|
||||||
|
},
|
||||||
|
get unreadCount() {
|
||||||
|
return unreadCount;
|
||||||
|
},
|
||||||
|
fetchNotifications,
|
||||||
|
fetchUnreadCount,
|
||||||
|
markRead,
|
||||||
|
markAllRead,
|
||||||
|
init,
|
||||||
|
destroy
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user