From b37ac157197b4483bc5606254c672d69f8c8e152 Mon Sep 17 00:00:00 2001 From: dawn <90008@klbr.net> Date: Mon, 29 Jun 2026 18:44:41 +0300 Subject: [PATCH] refactor: modernize notificationStream, jetstream, and pulsingPostId to Svelte 5 state --- .beads/interactions.jsonl | 1 + .beads/issues.jsonl | 1 + AGENTS.md | 44 +++++++++++++++------------ src/components/BskyPost.svelte | 10 +++--- src/lib/state.svelte.ts | 13 +++++--- src/routes/[...catchall]/+page.svelte | 38 +++++++++++------------ 6 files changed, 60 insertions(+), 47 deletions(-) diff --git a/.beads/interactions.jsonl b/.beads/interactions.jsonl index 82b4b55..03d7216 100644 --- a/.beads/interactions.jsonl +++ b/.beads/interactions.jsonl @@ -6,3 +6,4 @@ {"id":"int-453baacc","kind":"field_change","created_at":"2026-06-29T14:33:25.416212006Z","actor":"dawn","issue_id":"nucleus-ehl","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"refactored GenericTimelineView.svelte to use TanStack Virtual with dynamic height"}} {"id":"int-ef0bb2b7","kind":"field_change","created_at":"2026-06-29T14:35:47.990869363Z","actor":"dawn","issue_id":"nucleus-9ot","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Removed old package and verified typescript / prettier formatting"}} {"id":"int-a4d53813","kind":"field_change","created_at":"2026-06-29T14:35:55.325922806Z","actor":"dawn","issue_id":"nucleus-bkx","extra":{"field":"status","new_value":"closed","old_value":"open","reason":"Epic complete: all list virtualizations rewritten to use TanStack Virtual."}} +{"id":"int-3ef022dd","kind":"field_change","created_at":"2026-06-29T15:44:24.7334854Z","actor":"dawn","issue_id":"nucleus-huf","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Converted global notificationStream, jetstream, and pulsingPostId stores to Svelte 5 state objects."}} diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index 79aecac..2c5ff32 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -1,3 +1,4 @@ +{"_type":"issue","id":"nucleus-huf","title":"Modernize state stores to Svelte 5 Runes","description":"Convert notificationStream, jetstream, and pulsingPostId from Svelte 4 writables to Svelte 5 state/derived runes, updating components and routes.","status":"closed","priority":2,"issue_type":"task","assignee":"dawn","owner":"90008@klbr.net","created_at":"2026-06-29T15:41:52Z","created_by":"dawn","updated_at":"2026-06-29T15:44:25Z","started_at":"2026-06-29T15:42:02Z","closed_at":"2026-06-29T15:44:25Z","close_reason":"Converted global notificationStream, jetstream, and pulsingPostId stores to Svelte 5 state objects.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"nucleus-9ot","title":"Clean up @tutorlatin/svelte-tiny-virtual-list and verify build/lints","description":"Uninstall unused dependency, run linter and build to make sure everything works perfectly","status":"closed","priority":2,"issue_type":"task","assignee":"dawn","owner":"90008@klbr.net","created_at":"2026-06-29T14:27:01Z","created_by":"dawn","updated_at":"2026-06-29T14:35:48Z","started_at":"2026-06-29T14:33:32Z","closed_at":"2026-06-29T14:35:48Z","close_reason":"Removed old package and verified typescript / prettier formatting","dependencies":[{"issue_id":"nucleus-9ot","depends_on_id":"nucleus-3w3","type":"blocks","created_at":"2026-06-29T17:28:10Z","created_by":"dawn","metadata":"{}"},{"issue_id":"nucleus-9ot","depends_on_id":"nucleus-ehl","type":"blocks","created_at":"2026-06-29T17:28:23Z","created_by":"dawn","metadata":"{}"},{"issue_id":"nucleus-9ot","depends_on_id":"nucleus-fcu","type":"blocks","created_at":"2026-06-29T17:28:15Z","created_by":"dawn","metadata":"{}"},{"issue_id":"nucleus-9ot","depends_on_id":"nucleus-ib8","type":"blocks","created_at":"2026-06-29T17:28:19Z","created_by":"dawn","metadata":"{}"},{"issue_id":"nucleus-9ot","depends_on_id":"nucleus-n16","type":"blocks","created_at":"2026-06-29T17:28:02Z","created_by":"dawn","metadata":"{}"},{"issue_id":"nucleus-9ot","depends_on_id":"nucleus-xnz","type":"blocks","created_at":"2026-06-29T17:28:06Z","created_by":"dawn","metadata":"{}"}],"dependency_count":6,"dependent_count":1,"comment_count":0} {"_type":"issue","id":"nucleus-ehl","title":"Refactor GenericTimelineView to use TanStack Virtual","description":"Replace VirtualList in GenericTimelineView.svelte with TanStack Virtual using dynamic height measurement (measureElement)","status":"closed","priority":2,"issue_type":"task","assignee":"dawn","owner":"90008@klbr.net","created_at":"2026-06-29T14:26:53Z","created_by":"dawn","updated_at":"2026-06-29T14:33:25Z","started_at":"2026-06-29T14:33:01Z","closed_at":"2026-06-29T14:33:25Z","close_reason":"refactored GenericTimelineView.svelte to use TanStack Virtual with dynamic height","dependencies":[{"issue_id":"nucleus-ehl","depends_on_id":"nucleus-n16","type":"blocks","created_at":"2026-06-29T17:27:57Z","created_by":"dawn","metadata":"{}"}],"dependency_count":1,"dependent_count":2,"comment_count":0} {"_type":"issue","id":"nucleus-ib8","title":"Refactor FeedTimelineView to use TanStack Virtual","description":"Replace VirtualList in FeedTimelineView.svelte with TanStack Virtual using dynamic height measurement (measureElement)","status":"closed","priority":2,"issue_type":"task","assignee":"dawn","owner":"90008@klbr.net","created_at":"2026-06-29T14:26:45Z","created_by":"dawn","updated_at":"2026-06-29T14:32:55Z","started_at":"2026-06-29T14:30:44Z","closed_at":"2026-06-29T14:32:55Z","close_reason":"refactored FeedTimelineView.svelte to use TanStack Virtual with dynamic height","dependencies":[{"issue_id":"nucleus-ib8","depends_on_id":"nucleus-n16","type":"blocks","created_at":"2026-06-29T17:27:53Z","created_by":"dawn","metadata":"{}"}],"dependency_count":1,"dependent_count":2,"comment_count":0} diff --git a/AGENTS.md b/AGENTS.md index d85c4d8..7c96f55 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -125,51 +125,57 @@ bd prime # Refresh Beads context # Project Guide & Tech Stack ## Tech Stack + - **Runtime**: Deno 2.x (use `deno install`, `deno task`, etc., NEVER use `npx` or `npm` directly unless necessary) - **Framework**: Svelte 5 (uses Runes like `$state`, `$derived`, `$effect`) - **Virtualization**: `@tanstack/svelte-virtual` (replaces old `@tutorlatin/svelte-tiny-virtual-list`) - **Styling**: Tailwind CSS v4 ## Virtualization Guidelines (TanStack Virtual) + When implementing list/timeline virtualization, follow these patterns to prevent bugs and Svelte 5 compilation warnings: 1. **Reactivity & Options Updates**: To avoid Svelte 5 "capturing local state" warnings, initialize the virtualizer with `count: 0` and update it reactively using `$effect` and `$virtualizer.setOptions(...)`: + ```typescript let parentRef = $state(null); - + const virtualizer = createVirtualizer({ - count: 0, - getScrollElement: () => parentRef, - estimateSize: () => 44, - overscan: 5 + count: 0, + getScrollElement: () => parentRef, + estimateSize: () => 44, + overscan: 5 }); $effect(() => { - $virtualizer.setOptions({ - count: items.length, - getScrollElement: () => parentRef, - estimateSize: () => 44, - overscan: 5 - }); + $virtualizer.setOptions({ + count: items.length, + getScrollElement: () => parentRef, + estimateSize: () => 44, + overscan: 5 + }); }); ``` 2. **Dynamic Height Measurement**: Always provide `getItemKey` using a unique identifier (like post/thread URI) to prevent cache collisions when the list changes. Use a Svelte action `use:measureElement` to measure DOM nodes dynamically: + ```typescript const measureElement = (node: HTMLElement) => { - $virtualizer.measureElement(node); + $virtualizer.measureElement(node); }; ``` + And in the markup, set `data-index` and use the action: + ```svelte
- +
``` @@ -177,8 +183,8 @@ When implementing list/timeline virtualization, follow these patterns to prevent To scroll to a target index reactively, use an `$effect`: ```typescript $effect(() => { - if (scrollToIndex !== undefined) { - $virtualizer.scrollToIndex(scrollToIndex); - } + if (scrollToIndex !== undefined) { + $virtualizer.scrollToIndex(scrollToIndex); + } }); ``` diff --git a/src/components/BskyPost.svelte b/src/components/BskyPost.svelte index 63f030c..84cf38c 100644 --- a/src/components/BskyPost.svelte +++ b/src/components/BskyPost.svelte @@ -28,7 +28,7 @@ } from '$lib/state.svelte'; import type { PostWithUri } from '$lib/at/fetch'; import { onMount, type Snippet } from 'svelte'; - import { derived } from 'svelte/store'; + import Dropdown from './Dropdown.svelte'; import { settings } from '$lib/settings'; import RichText from './RichText.svelte'; @@ -113,7 +113,7 @@ const postId = $derived( `timeline-post-${did.replace(/[^a-zA-Z0-9]/g, '_')}-${rkey}-${quoteDepth}` ); - const isPulsing = derived(pulsingPostId, (pulsingPostId) => pulsingPostId === postId); + const isPulsing = $derived(pulsingPostId.current === postId); const scrollToAndPulse = (targetUri: ResourceUri) => { const targetId = `timeline-post-${targetUri}-0`; @@ -127,8 +127,8 @@ '--nucleus-selected-post', generateColorForDid(expect(parseCanonicalResourceUri(targetUri)).repo) ); - pulsingPostId.set(targetId); - setTimeout(() => pulsingPostId.set(null), 1200); + pulsingPostId.current = targetId; + setTimeout(() => (pulsingPostId.current = null), 1200); }, 400); }; @@ -280,7 +280,7 @@ oncontextmenu={handleRightClick} class=" group rounded-sm border-2 p-2 shadow-lg backdrop-blur-sm transition-all - {$isPulsing ? 'animate-pulse-highlight' : ''} + {isPulsing ? 'animate-pulse-highlight' : ''} {isOnPostComposer ? 'backdrop-brightness-20' : ''} " style=" diff --git a/src/lib/state.svelte.ts b/src/lib/state.svelte.ts index d1828d8..53d6264 100644 --- a/src/lib/state.svelte.ts +++ b/src/lib/state.svelte.ts @@ -1,4 +1,3 @@ -import { get, writable } from 'svelte/store'; import { AtpClient, setRecordCache, @@ -34,8 +33,12 @@ import { Router } from './router.svelte'; import { accounts, type Account } from './accounts'; import { getPreferences, putPreferences, type Preferences } from './at/pocket'; -export const notificationStream = writable(null); -export const jetstream = writable(null); +export const notificationStream = { + current: $state(null) +}; +export const jetstream = { + current: $state(null) +}; export const profiles = new SvelteMap(); export const handles = new SvelteMap(); @@ -239,7 +242,9 @@ export const createPostBacklink = async ( }); }; -export const pulsingPostId = writable(null); +export const pulsingPostId = { + current: $state(null) +}; export const viewClient = new AtpClient(); export const clients = new SvelteMap(); diff --git a/src/routes/[...catchall]/+page.svelte b/src/routes/[...catchall]/+page.svelte index f9eb676..db05eee 100644 --- a/src/routes/[...catchall]/+page.svelte +++ b/src/routes/[...catchall]/+page.svelte @@ -25,7 +25,6 @@ loadAccountPreferences, resetFeed } from '$lib/state.svelte'; - import { get } from 'svelte/store'; import Icon from '@iconify/svelte'; import { sessions } from '$lib/at/oauth'; import type { AtprotoDid, Did } from '@atcute/lexicons/syntax'; @@ -111,26 +110,20 @@ window.addEventListener('scroll', handleScroll); accounts.subscribe((newAccounts) => { - get(notificationStream)?.stop(); + notificationStream.current?.stop(); // jetstream.set(null); if (newAccounts.length === 0) return; - notificationStream.set( - streamNotifications( - newAccounts.map((account) => account.did), - 'app.bsky.feed.post:reply.parent.uri', - 'app.bsky.feed.post:embed.record.record.uri', - 'app.bsky.feed.post:embed.record.uri', - 'app.bsky.feed.repost:subject.uri', - 'app.bsky.feed.like:subject.uri', - 'app.bsky.graph.follow:subject', - 'app.bsky.graph.block:subject' - ) + notificationStream.current = streamNotifications( + newAccounts.map((account) => account.did), + 'app.bsky.feed.post:reply.parent.uri', + 'app.bsky.feed.post:embed.record.record.uri', + 'app.bsky.feed.post:embed.record.uri', + 'app.bsky.feed.repost:subject.uri', + 'app.bsky.feed.like:subject.uri', + 'app.bsky.graph.follow:subject', + 'app.bsky.graph.block:subject' ); }); - notificationStream.subscribe((stream) => { - if (!stream) return; - stream.listen(handleNotification); - }); console.log(`creating jetstream subscription to ${$settings.endpoints.jetstream}`); const jetstreamSub = new JetstreamSubscription({ @@ -139,7 +132,7 @@ // this is here because if wantedDids is zero jetstream will send all events wantedDids: ['did:web:guestbook.gaze.systems'] }); - jetstream.set(jetstreamSub); + jetstream.current = jetstreamSub; (async () => { console.log('polling for jetstream...'); @@ -171,7 +164,14 @@ const accountDids = $accounts.values().map((account) => account.did); wantedDids.push(...followDids, ...accountDids); // console.log('updating jetstream options:', wantedDids); - $jetstream?.updateOptions({ wantedDids }); + jetstream.current?.updateOptions({ wantedDids }); + }); + + $effect(() => { + const stream = notificationStream.current; + if (stream) { + stream.listen(handleNotification); + } }); -- 2.51.2