fix: portal page reactivity issue with Svelte 5 runes (#303)

after PR #302, portal page hung indefinitely showing "loading tracks..."
despite data loading successfully. console logs showed all API calls
completed and state variables updated, but UI never reflected changes.

root cause: portal page uses Svelte 5 runes mode, but component-local
state was declared with plain `let` instead of `let x = $state(...)`.
in Svelte 5 runes mode, plain let variables have no reactivity - they're
just regular JavaScript variables, so assignments don't trigger UI updates.

the bug was introduced when new state variables (`hasUnresolvedFeaturesInput`)
were added with `$state()` while existing variables remained as plain `let`,
creating an inconsistent mix that masked the issue.

fix:
- convert all reactive state in portal +page.svelte to use $state()
- add comprehensive Svelte 5 runes documentation to state-management.md
- add prominent gotcha to frontend/CLAUDE.md about $state() requirement
- improve logfire.md with deployment_environment column for debugging

this prevents future reactivity bugs and provides clear debugging guidance
when symptoms match (variables update but UI doesn't).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude <noreply@anthropic.com>

authored by zzstoatzz.io Claude and committed by GitHub 4c385a52 024f54f0

Changed files
+130 -27
docs
frontend
src
routes
portal
+103 -2
docs/frontend/state-management.md
··· 1 - # global state management 1 + # state management 2 + 3 + ## svelte 5 runes mode 4 + 5 + plyr.fm uses svelte 5 runes mode throughout the frontend. **all reactive state must use the `$state()` rune**. 6 + 7 + ### component-local state 8 + 9 + **critical**: in svelte 5 runes mode, plain `let` variables are NOT reactive. you must explicitly opt into reactivity. 10 + 11 + ```typescript 12 + // ❌ WRONG - no reactivity, UI won't update 13 + let loading = true; 14 + let tracks = []; 15 + let selectedId = null; 16 + 17 + // assignments won't trigger UI updates 18 + loading = false; // template still shows "loading..." 19 + tracks = newTracks; // template won't re-render 20 + ``` 21 + 22 + ```typescript 23 + // ✅ CORRECT - reactive state 24 + let loading = $state(true); 25 + let tracks = $state<Track[]>([]); 26 + let selectedId = $state<number | null>(null); 27 + 28 + // assignments trigger UI updates 29 + loading = false; // template updates immediately 30 + tracks = newTracks; // template re-renders with new data 31 + ``` 32 + 33 + ### when to use `$state()` 34 + 35 + use `$state()` for any variable that: 36 + - is used in the template (`{#if loading}`, `{#each tracks}`, etc.) 37 + - needs to trigger UI updates when changed 38 + - is bound to form inputs (`bind:value={title}`) 39 + - is checked in reactive blocks (`$effect(() => { ... })`) 40 + 41 + use plain `let` for: 42 + - constants that never change 43 + - variables only used in functions/callbacks (not template) 44 + - intermediate calculations that don't need reactivity 45 + 46 + ### common mistakes 47 + 48 + **1. mixing reactive and non-reactive state** 49 + 50 + ```typescript 51 + // ❌ creates confusing bugs - some state updates, some doesn't 52 + let loading = true; // non-reactive 53 + let tracks = $state<Track[]>([]); // reactive 54 + let selectedId = $state<number | null>(null); // reactive 55 + ``` 56 + 57 + **2. forgetting `$state()` after copy-pasting** 58 + 59 + ```typescript 60 + // ❌ copied from svelte 4 code 61 + let editing = false; 62 + let editValue = ''; 2 63 3 - ## overview 64 + // ✅ updated for svelte 5 65 + let editing = $state(false); 66 + let editValue = $state(''); 67 + ``` 68 + 69 + **3. assuming reactivity from svelte 4 habits** 70 + 71 + svelte 4 made all component `let` variables reactive by default. svelte 5 requires explicit `$state()` opt-in for finer control and better performance. 72 + 73 + ### debugging reactivity issues 74 + 75 + **symptom**: template shows stale data even though console.log shows variable updated 76 + 77 + ```typescript 78 + async function loadData() { 79 + loading = true; // variable updates... 80 + const data = await fetch(...); 81 + loading = false; // variable updates... 82 + console.log('loading:', loading); // logs "false" 83 + } 84 + // but UI still shows "loading..." spinner 85 + ``` 86 + 87 + **diagnosis**: missing `$state()` wrapper 88 + 89 + ```typescript 90 + // check variable declaration 91 + let loading = true; // ❌ missing $state() 92 + 93 + // fix 94 + let loading = $state(true); // ✅ now reactive 95 + ``` 96 + 97 + **verification**: after adding `$state()`, check: 98 + 1. variable assignments trigger template updates 99 + 2. no console errors about "Cannot access X before initialization" 100 + 3. UI reflects current variable value 101 + 102 + ## global state management 103 + 104 + ### overview 4 105 5 106 plyr.fm uses global state managers following the Svelte 5 runes pattern for cross-component reactive state. 6 107
+1
docs/tools/logfire.md
··· 6 6 7 7 ## Key Fields 8 8 9 + - `deployment_environment` - environment name (local/staging/production) - **ALWAYS filter by this when investigating issues** 9 10 - `is_exception` - boolean field to filter spans that contain exceptions 10 11 - `kind` - either 'span' or 'event' 11 12 - `trace_id` - unique identifier for a trace (group of related spans)
+1
frontend/CLAUDE.md
··· 8 8 - **routes**: pages in `routes/` with `+page.svelte` and `+page.ts` for data loading 9 9 10 10 gotchas: 11 + - **svelte 5 runes mode**: component-local state MUST use `$state()` - plain `let` has no reactivity (see `docs/frontend/state-management.md`) 11 12 - toast positioning: bottom-left above player footer (not top-right) 12 13 - queue sync: uses BroadcastChannel for cross-tab, not SSE 13 14 - preferences: managed in SettingsMenu component, not dedicated state file
+25 -25
frontend/src/routes/portal/+page.svelte
··· 26 26 return ACCEPTED_AUDIO_EXTENSIONS.includes(ext); 27 27 } 28 28 29 - let loading = true; 30 - let error = ''; 31 - let tracks: Track[] = []; 32 - let loadingTracks = false; 29 + let loading = $state(true); 30 + let error = $state(''); 31 + let tracks = $state<Track[]>([]); 32 + let loadingTracks = $state(false); 33 33 34 34 // upload form fields 35 - let title = ''; 36 - let albumTitle = ''; 37 - let file: File | null = null; 38 - let imageFile: File | null = null; 39 - let featuredArtists: FeaturedArtist[] = []; 35 + let title = $state(''); 36 + let albumTitle = $state(''); 37 + let file = $state<File | null>(null); 38 + let imageFile = $state<File | null>(null); 39 + let featuredArtists = $state<FeaturedArtist[]>([]); 40 40 let hasUnresolvedFeaturesInput = $state(false); 41 41 42 42 // track editing state 43 - let editingTrackId: number | null = null; 44 - let editTitle = ''; 45 - let editAlbum = ''; 46 - let editFeaturedArtists: FeaturedArtist[] = []; 47 - let editImageFile: File | null = null; 43 + let editingTrackId = $state<number | null>(null); 44 + let editTitle = $state(''); 45 + let editAlbum = $state(''); 46 + let editFeaturedArtists = $state<FeaturedArtist[]>([]); 47 + let editImageFile = $state<File | null>(null); 48 48 let hasUnresolvedEditFeaturesInput = $state(false); 49 49 50 50 // profile editing state 51 - let displayName = ''; 52 - let bio = ''; 53 - let avatarUrl = ''; 54 - let savingProfile = false; 55 - let profileSuccess = ''; 56 - let profileError = ''; 51 + let displayName = $state(''); 52 + let bio = $state(''); 53 + let avatarUrl = $state(''); 54 + let savingProfile = $state(false); 55 + let profileSuccess = $state(''); 56 + let profileError = $state(''); 57 57 58 58 // album management state 59 - let albums: AlbumSummary[] = []; 60 - let loadingAlbums = false; 61 - let editingAlbumId: string | null = null; 62 - let editAlbumCoverFile: File | null = null; 59 + let albums = $state<AlbumSummary[]>([]); 60 + let loadingAlbums = $state(false); 61 + let editingAlbumId = $state<string | null>(null); 62 + let editAlbumCoverFile = $state<File | null>(null); 63 63 64 64 // export state 65 - let exportingMedia = false; 65 + let exportingMedia = $state(false); 66 66 67 67 onMount(async () => { 68 68 // check if exchange_token is in URL (from OAuth callback)