Split NotificationBell.svelte (316 lines) — extract dropdown + SSE stream module
#200
Reference in New Issue
Block a user
Delete Branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Context
NotificationBell.svelteis 316 lines with three concerns: the bell button with badge, the dropdown panel (header + notification list + footer), and the SSE EventSource stream + fetch logic.Proposed Split
1.
useNotificationStream.svelte.ts(~70 lines)Extract SSE + fetch logic:
fetchNotifications,fetchUnreadCountparseNotificationEventmarkRead,markAllReadnotifications,unreadCount2.
NotificationDropdown.svelte(~130 lines)Extract the dropdown panel:
notifications,onMarkRead,onMarkAllRead,onClose,attachFirstFocusable3.
NotificationBell.sveltebecomes orchestrator (~80 lines)useNotificationStream+NotificationDropdownAcceptance Criteria
npm run checkpasses👨💻 Felix Brandt -- Senior Fullstack Developer
Good decomposition along visual boundaries. The three-piece split (stream module, dropdown panel, orchestrator bell) maps cleanly to the "one nameable region per component" rule. A few things I'd want clarified before implementation:
Component Splitting
The ~80-line orchestrator target for
NotificationBell.svelteis reasonable, but toggle logic + keyboard handling + click-outside + focus management can grow quickly. IfattachFirstFocusableinvolves a ref callback pattern, that alone can eat 10-15 lines. Is there a risk the orchestrator creeps past 100 lines once focus management is fully wired? Consider whether focus-trap logic belongs in a shared action or utility rather than inline.The issue mentions
attachFirstFocusableas a prop onNotificationDropdown. This looks like an imperative callback pattern -- how does this interact with Svelte 5's$bindableand rune-based reactivity? Ause:actiondirective on the first focusable element inside the dropdown might be cleaner than passing a callback prop.Reactive State in
useNotificationStream.svelte.tsThe module exposes
notificationsandunreadCountas state. Will these be$staterunes inside the.svelte.tsmodule? If so, the return type needs to be carefully designed -- returning raw$stateobjects from a module means consumers get reactive references, which is correct but worth documenting explicitly.markReadandmarkAllReadare commands that also update local state (decrement badge, update notification object). Are these optimistic updates, or do they await the server response before updating state? The acceptance criteria should specify this -- optimistic updates need rollback handling on failure.Testing Strategy
useNotificationStream.svelte.tsis pure logic with side effects (EventSource, fetch). This is highly testable in isolation with mockedEventSourceandfetch. The issue should call out that this module gets unit tests before the visual components are touched.The
{#each}block rendering notification rows inNotificationDropdown-- will these be keyed by notification ID? The acceptance criteria don't mention it, but it's required by project standards.Naming
useNotificationStreamfollows the Reactuse*convention. In Svelte 5, the emerging pattern for.svelte.tsmodules is eithercreateX()or just a plain export. ConsidercreateNotificationStream()or simply exporting the state and functions directly from the module without a wrapper function -- whichever matches the existing patterns in this codebase.🏗️ Markus Keller -- Senior Application Architect
The split is structurally sound. Extracting the SSE stream into a
.svelte.tsmodule is the right call -- it separates transport from presentation and makes the EventSource lifecycle independently testable. A few architectural considerations:Module Boundary Clarity
The issue lists
fetchNotifications,fetchUnreadCount,markRead,markAllRead, and EventSource setup all inuseNotificationStream.svelte.ts. That's both read (SSE stream + polling) and write (mark read) in one module. Is this intentional? An alternative is to keep the stream module purely reactive (receives events, exposes state) and have the orchestrator call the mark-read API directly. The question is: doesmarkAllReadneed to update local state atomically with the server call? If yes, co-locating makes sense. If it's just a POST followed by a refetch, the orchestrator can own it.What happens when the SSE connection drops? The issue mentions
EventSource setupbut not reconnection strategy.EventSourcehas built-in reconnection, but theLast-Event-IDheader and event deduplication need to be handled correctly on the backend. Does the backend SSE endpoint supportLast-Event-ID? If not, reconnection will replay or miss events. This is worth a note in the acceptance criteria.Transport Choice Validation
State Ownership
notificationsandunreadCountliving in the stream module means the module is both a data source and a state store. This is fine for a single consumer (the bell), but if the notification count is ever needed elsewhere (e.g., a page title badge, a mobile nav indicator), the state would need to be lifted or the module imported in multiple places. Is there a known second consumer, or is single-consumer sufficient for now? Don't over-engineer, but worth asking.Acceptance Criteria Gap
🧪 Sara Holt -- Senior QA Engineer
This is a pure refactor issue -- behavior must remain identical. That makes the test strategy straightforward but critical: existing behavior is the contract, and the tests must prove nothing changed.
Test Strategy for the Refactor
Before writing any code, the existing
NotificationBellshould have tests that cover every acceptance criterion listed. If those tests don't exist yet, they must be written first against the current 316-line component. The refactor then runs under a green test suite that catches any regression.Specifically, I'd want these tests to exist before the split:
markReadon a single notification updates its visual statemarkAllReadclears all unread indicatorsUnit Tests for
useNotificationStream.svelte.tsnotificationsandunreadCountparseNotificationEventhandles malformed events gracefully (doesn't throw, logs warning)markReadupdates local state (optimistic or after response -- whichever is chosen)markAllReadresetsunreadCountto 0Component Tests for
NotificationDropdown.svelteonMarkReadwith correct notification ID on clickonMarkAllReadwhen header action is clickedonClosewhen "view all" link is clicked (if applicable)Edge Cases to Cover
notificationsis empty and the user opens the dropdown? The issue mentions "empty state" but doesn't describe what it looks like. The test should assert specific empty-state text.Acceptance Criteria Suggestion
npm run testpasses with no new skipped or disabled tests" -- this ensures the refactor doesn't leave behind@Disabledor.skip()tests.🔒 Nora "NullX" Steiner -- Application Security Engineer
Refactors don't change attack surface -- in theory. In practice, moving SSE and fetch logic into a separate module is a good moment to audit how that logic handles untrusted input. A few things to verify:
SSE Event Parsing
parseNotificationEventis called out explicitly. How does it handle unexpected or malicious event data? If the SSE backend is compromised or a MITM injects crafted events, the parser must not blindly trust the payload. Specifically:JSON.parsewith a schema check, or does it spread raw parsed data into state?NotificationDropdown, that's a stored XSS vector via the SSE stream. Confirm all notification content is rendered as text, not{@html}.Authentication on SSE Endpoint
@RequirePermissionchecks as the REST notification endpoints?Mark-Read API Calls
markRead(notificationId)andmarkAllRead()presumably call REST endpoints. Confirm:CORS and CSP
Suggestion
useNotificationStream.svelte.ts, add a guard in the EventSourceonmessagehandler that validates the event data shape before applying it to state. A simple type guard function (3-5 lines) prevents malformed events from corrupting the UI state.🎨 Leonie Voss -- Senior UX Designer & Accessibility Strategist
The split itself is invisible to users -- good. But it's also an opportunity to audit the notification dropdown's accessibility and interaction design, since the components are being touched anyway.
Focus Management
role="menu"withrole="menuitem"children, or arole="listbox"? The correct ARIA role determines which keyboard interactions users expect (arrow keys for menu, different behavior for listbox).role="status"oraria-live="polite"region that announces new notifications to screen readers? The badge count changing is a visual-only cue. Screen reader users need an announcement like "3 new notifications" when the count changes via SSE.Touch Targets
Empty State
Dropdown Positioning
max-heightwith scroll and doesn't extend below the viewport fold.Notification Type Icons
aria-hidden="true"if there's an adjacent text label conveying the type⚙️ Tobias Wendt -- DevOps & Platform Engineer
Pure frontend refactor -- no infrastructure changes needed. But SSE has operational characteristics worth considering, especially since the stream logic is being extracted into a standalone module.
SSE Connection Lifecycle
EventSource connections are long-lived HTTP connections. Each open browser tab holds one connection to the backend. Does the Spring Boot backend use async/non-blocking SSE (e.g.,
SseEmitterwith a virtual thread or reactive stream)? If it uses a servlet thread per connection, each open tab consumes a thread from the Jetty thread pool. With the default Jetty pool size (~200), this limits concurrent users with the notification panel.After the refactor, confirm that
onDestroyin the stream module reliably callseventSource.close(). A leaked EventSource connection per page navigation would accumulate connections on the backend and eventually exhaust resources.Proxy Behavior
responseTypeand buffering settings.Monitoring
CI Impact
npm run checkpasses" is correct. Suggest also runningnpm run testin CI to catch regressions from the split. If there are existing E2E tests covering notification behavior, those are the highest-value regression signal here.Bundle Size
.svelte.tsmodule and a new.sveltecomponent shouldn't change the bundle size meaningfully, but worth a quick check withnpm run buildbefore and after to confirm no accidental dependency pulls.Comprehensive Response to Review Comments
I read the full source of
NotificationBell.svelte(316 lines),notifications.ts(42 lines),notifications.spec.ts(106 lines),NotificationController.java,SseEmitterRegistry.java,NotificationService.java, the notifications page, andvite.config.ts. Findings below, organized by persona.Felix Brandt -- Component Splitting & Rune Design
Orchestrator line count risk: Confirmed realistic. The current orchestrator-scope code (toggle, close, keydown,
attachBellButton,attachClickOutside, plus the bell button template) is ~75 lines.attachFirstFocusableis 6 lines (lines 106-111) -- it's a simple ref-capture attachment returning a cleanup function, not a complex ref callback. The 80-100 line target holds.attachFirstFocusablepattern: Currently implemented as an attachment function (lines 106-111) that captures a DOM ref. The dropdown then uses{@attach attachFirstFocusable}on the "mark all read" button (line 203). This is already the Svelte-idiomatic approach -- it's equivalent to ause:actiondirective. No callback prop is needed; the attachment lives in the orchestrator and is passed down via{@attach}. After the split,NotificationDropdownwould expose a slot or accept an attachment prop for its first focusable element. Ause:actionon the first focusable inside the dropdown is a clean alternative -- agreed.$staterunes in.svelte.tsmodule: This would be the first.svelte.tsmodule in the codebase (no existing ones found via glob). The return type should be explicit:{ notifications: NotificationItem[], unreadCount: number, markRead: ..., markAllRead: ... }where the state fields are reactive$statevalues. Consumers get reactive references automatically -- this is correct Svelte 5 behavior but worth a brief inline comment since it's the first such module.Optimistic updates: Both
markRead(line 61-76) andmarkAllRead(line 78-88) currently do await-then-update -- theyawait fetch(...)and only update local state after a successful response. No rollback needed because state isn't changed optimistically. Thecatchblocks log errors but don't update state. This is the safe choice. Acceptance criteria should note: "mark-read updates are applied after server confirmation, not optimistically."Naming: Agree that
useNotificationStreamfollows React convention. Since no.svelte.tsmodules exist yet, this sets the precedent.createNotificationStream()is more Svelte-idiomatic. Alternatively, a bare module export pattern (no wrapper function) works too.Keyed
{#each}: Already keyed by notification ID at line 236:{#each notifications as notification (notification.id)}. This carries over toNotificationDropdown.Markus Keller -- Architecture & SSE Lifecycle
Read+write co-location:
markReadandmarkAllReadboth mutate local state atomically with the server response (decrementunreadCount, setnotification.read = true). Splitting writes to the orchestrator would require the orchestrator to reach into the module's state to update it, breaking encapsulation. Co-location in the stream module is the correct choice here.SSE reconnection /
Last-Event-ID: The backendSseEmitterRegistry(line 18) creates emitters withnew SseEmitter(0L)(no timeout). It does not send event IDs --SseEmitter.event().name("notification").data(data)at line 30 has no.id()call. ThereforeLast-Event-IDis not supported.EventSourcewill auto-reconnect on disconnect, but missed events during the gap are lost. The frontend mitigates this partially:toggleDropdown()callsfetchNotifications()every time the dropdown opens (line 47), so the user gets a fresh list on interaction. However, the badge count (unreadCount) could drift if events are missed during a reconnect gap. Recommendation for acceptance criteria: Add "on EventSource reconnect, re-fetchunreadCountto resync badge" -- this can be done in theonopenhandler.Singleton vs shared EventSource:
SseEmitterRegistrystores one emitter per userId (line 19,ConcurrentHashMap<UUID, SseEmitter>). Opening a second tab replaces the previous emitter (line 19,emitters.putoverwrites). This means only the most recently opened tab receives SSE events. This is a pre-existing limitation, not introduced by this refactor, but worth noting. For this issue, single-consumer (the bell) is sufficient.Second consumer: The notifications page (
/notifications/+page.svelte) does NOT use SSE -- it loads via server-sidedataand client-sidefetchfor pagination. No second SSE consumer exists. Single-consumer design is fine.Sara Holt -- Test Coverage
Existing tests:
frontend/src/lib/utils/notifications.spec.tshas 106 lines covering:relativeTime: 8 tests (all time buckets, defaultnowparameter)parseNotificationEvent: 7 tests (valid payload, invalid JSON, missingid/documentId/actorName, unknown type, valid REPLY type)Missing pre-refactor tests: No component-level tests exist for
NotificationBell.svelte. The vitest config (vite.config.ts lines 56-69) has a browser-based test project for.svelte.test.tsfiles using Playwright, so component tests are supported. Before the refactor, these tests should be written:markReadcallsPATCH /api/notifications/{id}/readand updates visual statemarkAllReadcallsPOST /api/notifications/read-alland clears unread indicatorsEmpty state: The empty state renders
m.notification_empty()(line 232) with a bell icon. The Paraglide key exists. Test should assert this specific text appears when notifications array is empty.Live update while open: Currently, SSE events prepend to
notificationsarray (line 134) regardless of dropdown state. The list updates live if the dropdown is open. This should be tested.Acceptance criteria addition: Agree -- add "
npm run testpasses with no.skip()or disabled tests."Nora Steiner -- Security
parseNotificationEventvalidation: Solid. Lines 29-34 ofnotifications.tsvalidate:idis string,documentIdis string,actorNameis string,typeis one of['REPLY', 'MENTION']. Returnsnullon any validation failure or JSON parse error. Theonmessagehandler (line 132-133) checksif (!notification) returnbefore using the result. No blindly-spread raw data.{@html}usage: None. Grep confirms zero{@html}inNotificationBell.svelte. All notification content is rendered as text via{notification.actorName}and Paraglide message functions. No XSS vector.Session expiry mid-stream: The backend SSE endpoint (
/api/notifications/stream) relies on Spring Security session auth. If the session expires,EventSourceauto-reconnects and gets a 302 redirect to the login page (or a 401). The browser'sEventSourceimplementation will retry indefinitely on non-2xx responses. The current code (lines 128-141) has noonerrorhandler on the EventSource. This is a gap. The stream module should add anonerrorhandler that detects repeated failures (e.g., 3 consecutive errors) and callseventSource.close()to stop retrying. Alternatively, checkeventSource.readyStateand stop if the server consistently rejects.IDOR on mark-read: Protected.
NotificationService.markRead()(line 133-138 of NotificationService.java) checksnotification.getRecipient().getId().equals(userId)and throwsDomainException.forbidden()if the notification belongs to a different user. Notification IDs are UUIDs (not sequential).CORS/CSP: In development, the Vite proxy at
/api(vite.config.ts line 16-33) handles all API calls including SSE. The proxy injects theAuthorizationheader from theauth_tokencookie. SSE goes through the same proxy path, so no separate CORS issue. In production, frontend and backend are same-origin behind Caddy.Leonie Voss -- Accessibility
ARIA roles: The dropdown uses
role="dialog"witharia-modal="true"(line 191-193). The notification list usesrole="list"(line 235). This is acceptable butrole="dialog"witharia-modal="true"implies a focus trap, which is not implemented -- Tab can escape the dropdown. Two options: (a) implement a focus trap, or (b) change torole="menu"withrole="menuitem"children, which better matches the interaction pattern (list of actionable items). Recommend option (b) since the dropdown behaves like a menu.Arrow-key navigation: Not implemented. Users must Tab through items. With potentially 10 notification rows, this is slow. If switching to
role="menu", arrow-key navigation betweenmenuitemchildren is expected per WAI-ARIA. This should be added during the refactor since the dropdown component is being extracted anyway.aria-livefor badge: Present. Line 179:aria-live="polite"witharia-atomic="true"on the unread count badge. Screen readers will announce count changes. However, the badge is inside an{#if unreadCount > 0}block (line 177), so when the count goes from 0 to 1, the element is newly inserted into the DOM. Most screen readers will announce this viaaria-live, but some may not catch the initial insertion. Movingaria-liveto a persistent wrapper that always exists (even when count is 0, just visually hidden) would be more reliable.Touch targets: Each notification row is a full-width button (line 239-298) with
py-3padding. At default font size, rows are approximately 56px tall -- above the 44px minimum. There's no separate "mark read" button per row; clicking the row both marks read and navigates. Single touch target per row -- no overlap concern.Empty state: Renders
m.notification_empty()with an icon (aria-hidden="true"). The text is announced. Accessible.Dropdown positioning / mobile overflow: The dropdown is
w-80(320px) absolutely positionedright-0(line 194). On viewports narrower than 320px + bell button offset, it will overflow left. Nomax-heightwith scroll is set on the notification list -- with 10 items, the dropdown could be ~500px tall. Recommend addingmax-h-[24rem] overflow-y-autoto the<ul>or the dropdown body to prevent viewport overflow on mobile.Type icons: Both icons (lines 248-278) are wrapped in
<span aria-hidden="true">(line 245). The type is conveyed via text in the adjacent<p>element using Paraglide messages (notification_type_reply/notification_type_mention). Icons are decorative. Correct.Tobias Wendt -- DevOps & SSE Operations
SSE thread model:
SseEmitterRegistryuses Spring'sSseEmitterwhich is async -- it does NOT hold a servlet thread per connection. TheSseEmitteris returned from the controller method, freeing the Jetty thread. Events are sent asynchronously viaemitter.send()(line 30 of SseEmitterRegistry.java). The0Ltimeout (line 18) means no server-side timeout. This is safe for thread pool exhaustion. Each open tab holds one HTTP connection (kernel socket) but no Jetty thread.EventSource cleanup on navigation:
onDestroy(lines 139-141) callseventSource?.close(). In SvelteKit,onDestroyfires when the component is unmounted. SinceNotificationBelllives in the global layout, it's only destroyed on full page unload (not client-side navigation). This is correct -- the bell persists across navigations, so the SSE connection stays open. No leaked connections from SPA navigation.Vite proxy buffering: The Vite dev proxy config (vite.config.ts lines 16-33) uses
http-proxyunder the hood. By default,http-proxydoes NOT buffer chunked/streaming responses -- it streams them through. SSE works through the Vite proxy without additional config. Nows: trueor special streaming flags are needed for SSE (those are for WebSockets). If buffering issues arise, addingheaders: { 'X-Accel-Buffering': 'no' }to the proxy config would fix it, but this is unlikely needed.Monitoring: Agree that the extracted stream module is a good boundary for logging reconnection events. The
SseEmitterRegistryalready logs send failures at debug level (line 32). Client-side reconnection logging can be added in theonerrorhandler (which should be added anyway per Nora's session-expiry concern).CI impact:
npm run checkandnpm run testshould both be in CI for this refactor. No new services needed. Bundle size impact is negligible -- splitting a single component into three files doesn't add imports.Summary of Recommended Acceptance Criteria Additions
onerrorhandler on EventSource to detect auth failure / repeated errors and stop retryingunreadCounton EventSource reconnect (onopen) to resync badge after missed eventsmax-h-[24rem] overflow-y-autoto dropdown list for mobile viewport safetynpm run testpasses with no.skip()testsrole="menu"+ arrow-key navigation instead ofrole="dialog"(can be a follow-up issue)aria-liveregion to a persistent wrapper outside the{#if}block for reliable screen reader announcement