refactor: overhaul RecentSongs store architecture to fix hydration and pagination (fixes #87)#88
refactor: overhaul RecentSongs store architecture to fix hydration and pagination (fixes #87)#88tomast1337 wants to merge 2 commits intodevelopfrom
Conversation
…nd MinIO services, and update npm scripts for improved Docker management.
… pagination - Migrate singleton Zustand store to React Context to allow synchronous SSR hydration and prevent initial render flashes. - Fix pagination desynchronization by incrementing the page state only after a successful network response. - Remove redundant ID-based concurrency checks, relying strictly on AbortController for deterministic network cancellation. - Delete implicit `useRecentSongsCategoriesLoader` side-effect hook in favor of explicit component-level data fetching. Fixes #87
There was a problem hiding this comment.
Pull request overview
This PR refactors the frontend RecentSongs state management to use a per-tree Context-backed Zustand store, aiming to fix hydration flashes and pagination desync/race issues (issue #87), and also updates the local Docker Compose workflow to support compose up --wait with MinIO initialization.
Changes:
- Replaced the global
RecentSongsZustand store with a Context-per-tree store, adding explicit actions for pagination, category selection, and category fetching. - Updated the browse home page components to consume the new
recentItemsfeed model (song/ad/loading) and to explicitly trigger category loading. - Added Docker Compose healthchecks and new root scripts to support
docker compose up --waitplus a one-shot MinIO bucket/CORS initializer profile.
Reviewed changes
Copilot reviewed 7 out of 7 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| package.json | Adds Docker helper scripts for compose up/down/reset and MinIO init. |
| docker-compose.yml | Adds healthchecks and introduces a profile-based one-shot mc init service. |
| apps/frontend/src/modules/browse/components/client/context/RecentSongs.context.tsx | Replaces singleton store with Context store; introduces feed item model + abort-based concurrency. |
| apps/frontend/src/modules/browse/components/client/context/HomePage.context.tsx | Simplifies provider composition; removes legacy combined hook. |
| apps/frontend/src/modules/browse/components/client/CategoryButton.tsx | Switches to selector-based store access and explicitly fetches categories. |
| apps/frontend/src/modules/browse/components/HomePageComponent.tsx | Renders the new recentItems feed model and removes implicit loader hooks. |
| apps/frontend/src/app/(content)/page.tsx | Adjusts initial recent-song fetch limit. |
Comments suppressed due to low confidence (1)
docker-compose.yml:85
- The
mcservice definesentrypoint: ['/bin/sh','-c']but thecommandstring also starts with-c '...'. That results in the container running/bin/sh -c "-c '...'", which typically tries to execute a command literally named-cand will fail, preventing MinIO bucket/CORS initialization. Remove the extra leading-cfromcommand(or change the entrypoint to just/bin/sh) so the init script actually runs.
entrypoint: ['/bin/sh', '-c']
depends_on:
minio:
condition: service_healthy
environment:
- MINIO_ROOT_USER=minioadmin
- MINIO_ROOT_PASSWORD=minioadmin
command: >
-c '
while ! mc alias set minio http://minio:9000 minioadmin minioadmin; do
echo "Waiting for MinIO to be available..."
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| const adCount = 1; | ||
| const pageSize = 12; | ||
| const fetchCount = pageSize - adCount; | ||
| const fetchCount = pageSize; |
There was a problem hiding this comment.
pageSize is used to size the loading placeholders, but fetchCount is set to pageSize while injectAdSlots() always adds an extra {type:'ad'} item. That makes each fetch produce pageSize + 1 rendered items (e.g. 13 when pageSize is 12), which can break pagination assumptions and cause layout shifts between loading and loaded states. Consider either (a) setting fetchCount back to pageSize - adCount (and matching the server-side initial fetch limit), or (b) splitting the concepts into songsPerPage vs feedItemsPerPage and generating placeholders accordingly.
| const fetchCount = pageSize; | |
| const fetchCount = Math.max(pageSize - adCount, 0); |
| if (adCount <= 0) { | ||
| return songItems; | ||
| } | ||
| return [...songItems, { type: 'ad' }]; |
There was a problem hiding this comment.
adCount is no longer honored as a count: injectAdSlots() appends exactly one ad regardless of adCount (it only acts as an enable/disable flag). Either remove adCount and use a boolean like includeAd, or update injectAdSlots() to insert adCount ads so the constant matches behavior.
| return [...songItems, { type: 'ad' }]; | |
| const adItems: FeedItem[] = Array.from({ length: adCount }, () => ({ | |
| type: 'ad', | |
| })); | |
| return [...songItems, ...adItems]; |
| "docker:minio-init": "docker compose --profile minio-init run --rm mc", | ||
| "docker:up": "docker compose up -d --wait && bun run docker:minio-init", | ||
| "docker:down": "docker compose down", | ||
| "docker:reset": "docker compose down && docker compose up -d --wait && bun run docker:minio-init", | ||
| "docker:reset:fresh": "docker compose down -v && docker compose up -d --wait && bun run docker:minio-init", |
There was a problem hiding this comment.
The PR description/title focus on the RecentSongs store refactor, but this change set also adds new Docker Compose scripts and healthcheck/profile behavior for MongoDB/Maildev/MinIO. Please update the PR description to mention the Docker/dev-environment changes (or split them into a separate PR) so reviewers and release notes accurately reflect what’s being modified.
This PR restructures the
RecentSongsstate management to prioritize predictable data flow and resolve structural bugs related to React hydration and concurrent network requests. Instead of just managing the symptoms of race conditions and initial render flashes, this update organizes the complexity at the root by moving away from a global singleton store to a Context-per-tree architecture.Closes #87.
create()store with aRecentSongsContext. The store is now instantiated per-tree and hydrated synchronously withinitialRecentSongs. This eliminates theuseEffectinitialization flash and hydration mismatches.pagecounter would eagerly increment before the fetch completed. The page state is now treated strictly: it only updates upon a successful network response. If a fetch fails or is aborted, the page does not advance.latestRequestIdtracking. Concurrency and race conditions are now strictly handled byAbortController, seamlessly short-circuiting stale requests.useRecentSongsCategoriesLoaderauto-loader hook. Category fetching is now an explicit action invoked by the consuming components, removing hidden side effects and preventing redundant requests.