From 03218d999ca9ea95268a013b469be6d4908b7fc9 Mon Sep 17 00:00:00 2001 From: Astro Date: Tue, 26 May 2026 18:21:51 +1000 Subject: [PATCH 1/5] fix(useUrlState): preserve unknown URL params on write useSyncUrlState previously rebuilt the URL from scratch using only the keys in the state object it was given, silently stripping any param it did not own. Now it starts from the current querystring and only mutates the managed keys (set or delete), leaving unknown params intact. Found while planning issue 011: useMode writing mode=feed would have been clobbered by the next filter change. Fix is generic, not feed-mode specific. Regression test added covering managed keys, skipped values, preserved unknown params, and removal of previously-present managed keys when their value becomes skipped. Co-Authored-By: Claude Opus 4.7 --- frontend/src/hooks/useUrlState.js | 16 ++++++-- frontend/src/hooks/useUrlState.test.js | 57 ++++++++++++++++++++++++++ memory.md | 4 ++ 3 files changed, 73 insertions(+), 4 deletions(-) create mode 100644 frontend/src/hooks/useUrlState.test.js diff --git a/frontend/src/hooks/useUrlState.js b/frontend/src/hooks/useUrlState.js index d38986b..14c30df 100644 --- a/frontend/src/hooks/useUrlState.js +++ b/frontend/src/hooks/useUrlState.js @@ -8,6 +8,12 @@ function readParams() { return out; } +function isSkippedValue(v) { + if (v === undefined || v === null || v === '' || v === false) return true; + if (typeof v === 'number' && v === 0) return true; + return false; +} + export function getInitialUrlState() { return readParams(); } @@ -16,11 +22,13 @@ export function useSyncUrlState(state) { const lastWritten = useRef(''); useEffect(() => { - const params = new URLSearchParams(); + const params = new URLSearchParams(window.location.search); for (const [k, v] of Object.entries(state)) { - if (v === undefined || v === null || v === '' || v === false) continue; - if (typeof v === 'number' && v === 0) continue; - params.set(k, String(v)); + if (isSkippedValue(v)) { + params.delete(k); + } else { + params.set(k, String(v)); + } } const search = params.toString(); if (search === lastWritten.current) return; diff --git a/frontend/src/hooks/useUrlState.test.js b/frontend/src/hooks/useUrlState.test.js new file mode 100644 index 0000000..17ce00e --- /dev/null +++ b/frontend/src/hooks/useUrlState.test.js @@ -0,0 +1,57 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { renderHook } from '@testing-library/react'; +import { useSyncUrlState } from '@/hooks/useUrlState'; + +function setUrl(search) { + const qs = search ? `?${search}` : ''; + window.history.replaceState(null, '', `/${qs}`); +} + +describe('useSyncUrlState', () => { + beforeEach(() => { + setUrl(''); + }); + + it('writes managed state keys to the URL', () => { + renderHook(() => useSyncUrlState({ r: 'nsfw', sort: 'hot' })); + const params = new URLSearchParams(window.location.search); + expect(params.get('r')).toBe('nsfw'); + expect(params.get('sort')).toBe('hot'); + }); + + it('does not write skipped values (empty string, null, undefined, false, zero)', () => { + renderHook(() => + useSyncUrlState({ + r: 'nsfw', + empty: '', + nullish: null, + undef: undefined, + falsy: false, + zero: 0 + }) + ); + const params = new URLSearchParams(window.location.search); + expect(params.get('r')).toBe('nsfw'); + expect(params.has('empty')).toBe(false); + expect(params.has('nullish')).toBe(false); + expect(params.has('undef')).toBe(false); + expect(params.has('falsy')).toBe(false); + expect(params.has('zero')).toBe(false); + }); + + it('preserves pre-existing unknown params on write (regression for issue 011)', () => { + setUrl('mode=feed'); + renderHook(() => useSyncUrlState({ r: 'nsfw' })); + const params = new URLSearchParams(window.location.search); + expect(params.get('mode')).toBe('feed'); + expect(params.get('r')).toBe('nsfw'); + }); + + it('removes managed keys with skipped values from the URL if previously present', () => { + setUrl('r=nsfw&sort=hot'); + renderHook(() => useSyncUrlState({ r: 'nsfw', sort: '' })); + const params = new URLSearchParams(window.location.search); + expect(params.get('r')).toBe('nsfw'); + expect(params.has('sort')).toBe(false); + }); +}); diff --git a/memory.md b/memory.md index afb56c3..7877967 100644 --- a/memory.md +++ b/memory.md @@ -42,6 +42,10 @@ This file is for the human + the AI assistant. Keep entries dated. Newest at top Append-only. Newest at top. +### 2026-05-26: `useUrlState` made composable (issue 011 prep) +- **`useSyncUrlState` now preserves unknown URL params.** Previously it rebuilt the URL from scratch using only the keys in the state object it was given, silently stripping any param it didn't own. Discovered while planning feed-mode entry: `useMode` writing `?mode=feed` would have been clobbered by the next filter change. Fix is generic, not feed-mode-specific, and lives in its own commit ahead of the rest of issue 011. +- **Regression test added** (`frontend/src/hooks/useUrlState.test.js`) covering: managed keys written, skipped values not written, pre-existing unknown params preserved, managed keys with skipped values removed if previously present. + ### 2026-05-26: Tooling scaffold (issue 010) - **Warn-baseline for ESLint on legacy code.** Rules existing code violates are downgraded to `warn` at the config level rather than refactored. Tightening to `error` belongs in a dedicated cleanup task, not 010. Specific downgrades: `no-empty` (allowEmptyCatch), `react-hooks/set-state-in-effect`, `react-hooks/immutability`, `react/no-unescaped-entities`, `no-constant-binary-expression`. - **`no-dupe-keys` stays strict.** The one duplicate `width` key in `VideoPlayer.jsx` was deleted by exception (one line, behavior-neutral, user-approved). Future dupe-key bugs will still be caught. From 4b2c4c778078fec51b5dae0b390f05f0e54779ed Mon Sep 17 00:00:00 2001 From: Astro Date: Tue, 26 May 2026 18:40:34 +1000 Subject: [PATCH 2/5] feat(011): feed-mode shell with mode toggle, exit gesture, HTTPS dev Implements the minimum end-to-end vertical slice from issue 011: enter feed mode from the grid, swipe between items full-screen using native scroll-snap, exit back to the grid with scroll position restored. useMode hook (frontend/src/hooks/useMode.js) - Owns the 'grid' | 'feed' state. - Reads/writes URL param mode=feed and localStorage key nightfeed:mode. URL takes precedence over localStorage on init. - Captures window.scrollY when entering feed; restores via requestAnimationFrame when returning to grid. - Cold-load history bootstrap: when starting in feed mode directly (URL or localStorage) and the current history entry was not pushed by us, synthesises a grid history entry below the current one so browser back exits feed instead of leaving the app. FeedMode + FeedItem (frontend/src/components/) - FeedMode is a scroll-snap container that maps items to FeedItem. - FeedItem reuses getModalItems() from utils/media.js to discriminate item kind, then renders VideoPlayer for kind='video', plain img for kind='image' or gallery items, null for audio/embed (v1 out of scope). - Both components are well under the 200-line guardrail. FeedExitGesture (frontend/src/components/) - Behaviour-only Fragment wrapper, attaches listeners to window so the leftmost-25px edge-swipe-right gesture is captured regardless of which child receives the touch. - Esc, edge-swipe, and browser-back all call history.back(); the popstate listener detects mode=feed leaving the URL and calls onExit (which is setMode('grid')). One convergence point for all three exits. feed.css (frontend/src/styles/) - scroll-snap-type: y mandatory, touch-action: pan-y, overscroll- behavior: contain, 100dvh per item. - Imported from FeedMode.jsx via Vite; styles.css stays untouched. App.jsx - Surgical changes: three new imports, one useMode call, a single early-return branch for feed mode, and one onEnterFeed prop on TopBar. No new useState added. TopBar.jsx - Optional onEnterFeed prop renders the Enter Feed icon button in the top-right cluster. Button is absent in feed mode because TopBar itself does not render there. vite-plugin-mkcert - Installed into the frontend workspace and wired into vite.config.js behind the VITE_HTTPS=true env flag. Default dev stays HTTP. - README's "Mobile development" section already documented this (added in 010 governance), so no README changes needed. memory.md - Decisions log entry for issue 011: exit-path convergence, cold-load history bootstrap, getModalItems reuse, VideoPlayer untouched, and a followup flag for cross-sibling autoplay-with-sound. Self-check - npm run lint at root: 0 errors, 39 warnings (all pre-existing legacy from the 010 warn-baseline; no new warnings introduced). - npm test: backend 6/6 passing, frontend 13/13 passing. - npm run build: clean. - File sizes: useMode 91, FeedMode 14, FeedItem 48, FeedExitGesture 71, feed.css 40. App.jsx grew by 14 lines, TopBar by 9. Co-Authored-By: Claude Opus 4.7 --- frontend/package.json | 1 + frontend/src/App.jsx | 14 ++++ frontend/src/components/FeedExitGesture.jsx | 71 ++++++++++++++++ frontend/src/components/FeedItem.jsx | 48 +++++++++++ frontend/src/components/FeedMode.jsx | 14 ++++ frontend/src/components/TopBar.jsx | 11 ++- frontend/src/hooks/useMode.js | 91 +++++++++++++++++++++ frontend/src/hooks/useMode.test.js | 54 ++++++++++++ frontend/src/styles/feed.css | 40 +++++++++ frontend/vite.config.js | 6 +- memory.md | 9 ++ package-lock.json | 42 ++++++++++ 12 files changed, 399 insertions(+), 2 deletions(-) create mode 100644 frontend/src/components/FeedExitGesture.jsx create mode 100644 frontend/src/components/FeedItem.jsx create mode 100644 frontend/src/components/FeedMode.jsx create mode 100644 frontend/src/hooks/useMode.js create mode 100644 frontend/src/hooks/useMode.test.js create mode 100644 frontend/src/styles/feed.css diff --git a/frontend/package.json b/frontend/package.json index 00255f3..795b088 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -33,6 +33,7 @@ "jsdom": "^29.1.1", "prettier": "^3.8.3", "vite": "^5.4.10", + "vite-plugin-mkcert": "^2.0.0", "vitest": "^4.1.7" } } diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 563609b..af76593 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -14,12 +14,15 @@ import RelatedSubsRow from './components/RelatedSubsRow'; import SourceToggle from './components/SourceToggle'; import AdvancedSearch, { DEFAULT_ADVANCED, QUALITY_THRESHOLDS, countActiveFilters } from './components/AdvancedSearch'; import ActiveFiltersStrip from './components/ActiveFiltersStrip'; +import FeedMode from './components/FeedMode'; +import FeedExitGesture from './components/FeedExitGesture'; import { ToastProvider, useToast } from './components/Toast'; import { useFavorites } from './hooks/useFavorites'; import { useSavedSubreddits } from './hooks/useSavedSubreddits'; import { useHiddenAuthors } from './hooks/useBlockList'; import { usePreferences } from './hooks/usePreferences'; import { getInitialUrlState, useSyncUrlState } from './hooks/useUrlState'; +import { useMode } from './hooks/useMode'; import { fetchRedditUserMedia, fetchSubredditMedia, @@ -275,6 +278,8 @@ function AppShell() { useSyncUrlState(urlState); + const { mode, setMode } = useMode(); + const currentSnapshot = useMemo( () => createFeedSnapshot({ subreddit, authorView, sort, includeNsfw, mediaFilter, order, redditFilters }), [subreddit, authorView, sort, includeNsfw, mediaFilter, order, redditFilters] @@ -786,6 +791,14 @@ function AppShell() { return { videos, images, audio, all: items.length }; }, [items]); + if (mode === 'feed') { + return ( + setMode('grid')}> + + + ); + } + return (
setAdvancedOpen(true)} advancedFilterCount={countActiveFilters(advanced)} + onEnterFeed={() => setMode('feed')} /> {source === 'reddit' && ( diff --git a/frontend/src/components/FeedExitGesture.jsx b/frontend/src/components/FeedExitGesture.jsx new file mode 100644 index 0000000..466dd07 --- /dev/null +++ b/frontend/src/components/FeedExitGesture.jsx @@ -0,0 +1,71 @@ +import { useEffect } from 'react'; + +const EDGE_SWIPE_START_THRESHOLD_PX = 25; +const EDGE_SWIPE_DISTANCE_THRESHOLD_PX = 60; +const EDGE_SWIPE_VERTICAL_TOLERANCE_PX = 40; + +function FeedExitGesture({ onExit, children }) { + useEffect(() => { + let touchStart = null; + + function handleKeyDown(e) { + if (e.key === 'Escape') { + e.preventDefault(); + window.history.back(); + } + } + + function handlePopState() { + const urlMode = new URLSearchParams(window.location.search).get('mode'); + if (urlMode !== 'feed') { + onExit(); + } + } + + function handleTouchStart(e) { + const t = e.touches[0]; + if (!t) return; + if (t.clientX <= EDGE_SWIPE_START_THRESHOLD_PX) { + touchStart = { x: t.clientX, y: t.clientY }; + } else { + touchStart = null; + } + } + + function handleTouchMove(e) { + if (!touchStart) return; + const t = e.touches[0]; + if (!t) return; + const dx = t.clientX - touchStart.x; + const dy = Math.abs(t.clientY - touchStart.y); + if ( + dx > EDGE_SWIPE_DISTANCE_THRESHOLD_PX && + dy < EDGE_SWIPE_VERTICAL_TOLERANCE_PX + ) { + touchStart = null; + window.history.back(); + } + } + + function handleTouchEnd() { + touchStart = null; + } + + window.addEventListener('keydown', handleKeyDown); + window.addEventListener('popstate', handlePopState); + window.addEventListener('touchstart', handleTouchStart, { passive: true }); + window.addEventListener('touchmove', handleTouchMove, { passive: true }); + window.addEventListener('touchend', handleTouchEnd, { passive: true }); + return () => { + window.removeEventListener('keydown', handleKeyDown); + window.removeEventListener('popstate', handlePopState); + window.removeEventListener('touchstart', handleTouchStart); + window.removeEventListener('touchmove', handleTouchMove); + window.removeEventListener('touchend', handleTouchEnd); + }; + }, [onExit]); + + return <>{children}; +} + +export default FeedExitGesture; diff --git a/frontend/src/components/FeedItem.jsx b/frontend/src/components/FeedItem.jsx new file mode 100644 index 0000000..ca21fbe --- /dev/null +++ b/frontend/src/components/FeedItem.jsx @@ -0,0 +1,48 @@ +import VideoPlayer from './VideoPlayer'; +import { getModalItems } from '../utils/media'; + +function FeedItem({ item }) { + const modalItems = getModalItems(item); + const first = modalItems[0]; + if (!first) return null; + + if (first.kind === 'video') { + return ( +
+ +
+ ); + } + + if (first.kind === 'image') { + return ( +
+ +
+ ); + } + + if (first.kind === 'audio' || first.kind === 'embed') { + return null; + } + + if (first.url) { + return ( +
+ +
+ ); + } + + return null; +} + +export default FeedItem; diff --git a/frontend/src/components/FeedMode.jsx b/frontend/src/components/FeedMode.jsx new file mode 100644 index 0000000..3197202 --- /dev/null +++ b/frontend/src/components/FeedMode.jsx @@ -0,0 +1,14 @@ +import FeedItem from './FeedItem'; +import '../styles/feed.css'; + +function FeedMode({ items }) { + return ( +
+ {items.map((item) => ( + + ))} +
+ ); +} + +export default FeedMode; diff --git a/frontend/src/components/TopBar.jsx b/frontend/src/components/TopBar.jsx index 32ff88b..8ca27b2 100644 --- a/frontend/src/components/TopBar.jsx +++ b/frontend/src/components/TopBar.jsx @@ -93,7 +93,8 @@ function TopBar({ blueskySort, onBlueskySortChange, onOpenAdvanced, - advancedFilterCount = 0 + advancedFilterCount = 0, + onEnterFeed }) { const isEporner = source === 'eporner'; const isBooru = source === 'booru'; @@ -189,6 +190,14 @@ function TopBar({ )} + {onEnterFeed && ( + + )}
); diff --git a/frontend/src/hooks/useMode.js b/frontend/src/hooks/useMode.js new file mode 100644 index 0000000..1ccb7fb --- /dev/null +++ b/frontend/src/hooks/useMode.js @@ -0,0 +1,91 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; + +const MODE_KEY = 'nightfeed:mode'; +const VALID_MODES = new Set(['grid', 'feed']); + +function readInitialMode() { + if (typeof window === 'undefined') return 'grid'; + const url = new URLSearchParams(window.location.search).get('mode'); + if (VALID_MODES.has(url)) return url; + try { + const stored = localStorage.getItem(MODE_KEY); + if (VALID_MODES.has(stored)) return stored; + } catch { + // localStorage may be unavailable; fall through to default. + } + return 'grid'; +} + +function buildUrl(params) { + const search = params.toString(); + return `${window.location.pathname}${search ? '?' + search : ''}${window.location.hash}`; +} + +export function useMode() { + const [mode, setModeState] = useState(readInitialMode); + const gridScrollYRef = useRef(0); + + useEffect(() => { + try { + localStorage.setItem(MODE_KEY, mode); + } catch { + // ignore + } + }, [mode]); + + // Cold-load history bootstrap: if we landed in feed mode directly (URL or + // localStorage) and the current history entry was not pushed by us, insert + // a grid entry below the current one so browser-back exits feed instead of + // leaving the app. + useEffect(() => { + if (mode !== 'feed') return; + if (window.history.state?.nightfeedMode === 'feed') return; + const params = new URLSearchParams(window.location.search); + params.delete('mode'); + const gridUrl = buildUrl(params); + params.set('mode', 'feed'); + const feedUrl = buildUrl(params); + window.history.replaceState( + { ...window.history.state, nightfeedMode: 'grid' }, + '', + gridUrl + ); + window.history.pushState({ nightfeedMode: 'feed' }, '', feedUrl); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + // Restore scroll when returning to grid from feed. + useEffect(() => { + if (mode !== 'grid') return; + const y = gridScrollYRef.current; + if (y <= 0) return; + requestAnimationFrame(() => window.scrollTo(0, y)); + }, [mode]); + + const setMode = useCallback((next) => { + if (!VALID_MODES.has(next)) return; + setModeState((prev) => { + if (prev === next) return prev; + const params = new URLSearchParams(window.location.search); + if (next === 'feed') { + gridScrollYRef.current = window.scrollY; + params.set('mode', 'feed'); + window.history.pushState( + { nightfeedMode: 'feed' }, + '', + buildUrl(params) + ); + } else { + params.delete('mode'); + window.history.replaceState( + { ...window.history.state, nightfeedMode: 'grid' }, + '', + buildUrl(params) + ); + } + return next; + }); + }, []); + + return { mode, setMode }; +} diff --git a/frontend/src/hooks/useMode.test.js b/frontend/src/hooks/useMode.test.js new file mode 100644 index 0000000..bf5c30a --- /dev/null +++ b/frontend/src/hooks/useMode.test.js @@ -0,0 +1,54 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { act, renderHook } from '@testing-library/react'; +import { useMode } from '@/hooks/useMode'; + +const MODE_KEY = 'nightfeed:mode'; + +function reset() { + localStorage.clear(); + window.history.replaceState(null, '', '/'); +} + +describe('useMode', () => { + beforeEach(reset); + + it('initial state from URL: ?mode=feed lands in feed mode', () => { + window.history.replaceState(null, '', '/?mode=feed'); + const { result } = renderHook(() => useMode()); + expect(result.current.mode).toBe('feed'); + }); + + it('initial state from localStorage when URL is silent', () => { + localStorage.setItem(MODE_KEY, 'feed'); + const { result } = renderHook(() => useMode()); + expect(result.current.mode).toBe('feed'); + }); + + it('defaults to grid when neither URL nor localStorage is set', () => { + const { result } = renderHook(() => useMode()); + expect(result.current.mode).toBe('grid'); + }); + + it('URL takes precedence over localStorage on init', () => { + localStorage.setItem(MODE_KEY, 'feed'); + window.history.replaceState(null, '', '/?mode=grid'); + const { result } = renderHook(() => useMode()); + expect(result.current.mode).toBe('grid'); + }); + + it('setMode persists to both URL and localStorage', () => { + const { result } = renderHook(() => useMode()); + + act(() => result.current.setMode('feed')); + expect(result.current.mode).toBe('feed'); + expect(new URLSearchParams(window.location.search).get('mode')).toBe( + 'feed' + ); + expect(localStorage.getItem(MODE_KEY)).toBe('feed'); + + act(() => result.current.setMode('grid')); + expect(result.current.mode).toBe('grid'); + expect(new URLSearchParams(window.location.search).get('mode')).toBeNull(); + expect(localStorage.getItem(MODE_KEY)).toBe('grid'); + }); +}); diff --git a/frontend/src/styles/feed.css b/frontend/src/styles/feed.css new file mode 100644 index 0000000..ce21e07 --- /dev/null +++ b/frontend/src/styles/feed.css @@ -0,0 +1,40 @@ +.feed-mode { + position: fixed; + inset: 0; + background: #000; + overflow-y: auto; + overflow-x: hidden; + scroll-snap-type: y mandatory; + touch-action: pan-y; + overscroll-behavior: contain; + z-index: 1000; + -webkit-overflow-scrolling: touch; +} + +.feed-item { + width: 100%; + height: 100vh; + height: 100dvh; + scroll-snap-align: start; + scroll-snap-stop: always; + display: flex; + align-items: center; + justify-content: center; + background: #000; + overflow: hidden; + position: relative; +} + +.feed-item__media { + max-width: 100%; + max-height: 100%; + width: 100%; + height: 100%; + object-fit: contain; + display: block; +} + +.feed-item__video { + width: 100%; + height: 100%; +} diff --git a/frontend/vite.config.js b/frontend/vite.config.js index 9555ba1..da85a77 100644 --- a/frontend/vite.config.js +++ b/frontend/vite.config.js @@ -1,9 +1,13 @@ import { defineConfig } from 'vite'; import react from '@vitejs/plugin-react'; +import mkcert from 'vite-plugin-mkcert'; import { fileURLToPath, URL } from 'node:url'; +import process from 'node:process'; + +const useHttps = process.env.VITE_HTTPS === 'true'; export default defineConfig({ - plugins: [react()], + plugins: [react(), useHttps && mkcert()].filter(Boolean), resolve: { alias: { '@': fileURLToPath(new URL('./src', import.meta.url)) diff --git a/memory.md b/memory.md index 7877967..2a54ec6 100644 --- a/memory.md +++ b/memory.md @@ -42,6 +42,15 @@ This file is for the human + the AI assistant. Keep entries dated. Newest at top Append-only. Newest at top. +### 2026-05-26: Feed-mode shell shipped (issue 011) +- **Single exit code path.** Esc keydown, leftmost-25px edge-swipe-right (dx > 60px, dy < 40px), and browser back all call `history.back()`. The popstate listener in `FeedExitGesture` detects the URL no longer has `mode=feed` and calls `setMode('grid')`. One convergence point; no double-handling. +- **Cold-load history bootstrap.** When `useMode` initialises into feed mode directly from URL or localStorage (the current history entry was not pushed by us), the hook synthesises a grid history entry below the current one via `replaceState` + `pushState`. Guarantees browser back exits feed mode unconditionally. +- **`FeedExitGesture` has no DOM.** Listeners attach to `window` so the edge-swipe registers regardless of which child receives the touch. The component is a behaviour-only Fragment wrapper. +- **Item-kind discriminator is `getModalItems()` in `utils/media.js`.** Already exists for the lightbox; feed mode reuses it directly. Not duplicated, no new utility introduced. +- **`VideoPlayer` untouched.** `FeedItem` passes `mp4Url/hlsUrl/dashUrl/hasAudio/sourceKind/posterUrl/className`. The existing `autoPlay=true`, `loop=true` defaults match feed-mode semantics. +- **`feed.css` imported from `FeedMode.jsx` via Vite**, not from `styles.css`. `styles.css` stays off-limits. +- **Followup flagged, not addressed:** sound autoplay-after-user-gesture across feed scrolls. The Enter-Feed button click is the unlock gesture per the initial plan; whether `VideoPlayer` needs adjustment to propagate that across siblings is a future investigation, likely under issue 014 or its own slice. + ### 2026-05-26: `useUrlState` made composable (issue 011 prep) - **`useSyncUrlState` now preserves unknown URL params.** Previously it rebuilt the URL from scratch using only the keys in the state object it was given, silently stripping any param it didn't own. Discovered while planning feed-mode entry: `useMode` writing `?mode=feed` would have been clobbered by the next filter change. Fix is generic, not feed-mode-specific, and lives in its own commit ahead of the rest of issue 011. - **Regression test added** (`frontend/src/hooks/useUrlState.test.js`) covering: managed keys written, skipped values not written, pre-existing unknown params preserved, managed keys with skipped values removed if previously present. diff --git a/package-lock.json b/package-lock.json index 8fa7a01..a4ccf2d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -59,6 +59,7 @@ "jsdom": "^29.1.1", "prettier": "^3.8.3", "vite": "^5.4.10", + "vite-plugin-mkcert": "^2.0.0", "vitest": "^4.1.7" } }, @@ -7855,6 +7856,47 @@ } } }, + "node_modules/vite-plugin-mkcert": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/vite-plugin-mkcert/-/vite-plugin-mkcert-2.0.0.tgz", + "integrity": "sha512-+5roXeOT91WRO3NKFRcDZKEHhze/1uahSSKOIq3vz2w19mJojBcyOo0JyLRHbME31Ym5qPRNJ8CwKO0mu4RJ2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "supports-color": "^10.2.2", + "undici": "^8.0.2" + }, + "engines": { + "node": ">=22.19.0" + }, + "peerDependencies": { + "vite": ">=3" + } + }, + "node_modules/vite-plugin-mkcert/node_modules/supports-color": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz", + "integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/vite-plugin-mkcert/node_modules/undici": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.3.0.tgz", + "integrity": "sha512-TkUDgb6tl7KOGZ+7e8E3d2FYgUQgF6z5YypqjWmixVQSQERFcVrVg0ySADm2LVLRh5ljAaHTCR5Fmz3Q34rB7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=22.19.0" + } + }, "node_modules/vitest": { "version": "4.1.7", "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.7.tgz", From 2b4766b2abeccf78e3d0be044460fd720ad72fd8 Mon Sep 17 00:00:00 2001 From: Astro Date: Tue, 26 May 2026 19:05:57 +1000 Subject: [PATCH 3/5] fix(useMode): call history.pushState synchronously to survive Strict Mode setMode previously called history.pushState (and replaceState on exit) from inside the setState updater function. React 18 Strict Mode double-invokes state updater functions in development to verify purity, and side effects inside an updater are an anti-pattern: the push can fire twice, fire on a discarded render, or be mistimed against the commit. The practical symptom on the chore/011 PR was that clicking Enter Feed left the URL unchanged even though feed mode rendered correctly. Refactor: - setMode now reads current mode from a modeRef for synchronous dedup. - pushState / replaceState run synchronously inside setMode, BEFORE setModeState is called. - modeRef is updated synchronously alongside the React state update. - A defensive useEffect keeps modeRef aligned with mode in case state ever changes through a path other than setMode. Regression test added: asserts window.location.search contains mode=feed inside the act() callback, capturing the URL before React flushes the state update queue. With the previous code (pushState inside the updater), the URL inside the act block had not yet been mutated; with the fix, it has. Co-Authored-By: Claude Opus 4.7 --- frontend/src/hooks/useMode.js | 50 +++++++++++++++++------------- frontend/src/hooks/useMode.test.js | 17 ++++++++++ 2 files changed, 46 insertions(+), 21 deletions(-) diff --git a/frontend/src/hooks/useMode.js b/frontend/src/hooks/useMode.js index 1ccb7fb..bacff1b 100644 --- a/frontend/src/hooks/useMode.js +++ b/frontend/src/hooks/useMode.js @@ -23,8 +23,16 @@ function buildUrl(params) { export function useMode() { const [mode, setModeState] = useState(readInitialMode); + const modeRef = useRef(mode); const gridScrollYRef = useRef(0); + // Defensive: keep the ref aligned with state in case state ever changes + // through a path other than setMode (it shouldn't, but the ref is the + // synchronous source of truth and we don't want it to drift). + useEffect(() => { + modeRef.current = mode; + }, [mode]); + useEffect(() => { try { localStorage.setItem(MODE_KEY, mode); @@ -64,27 +72,27 @@ export function useMode() { const setMode = useCallback((next) => { if (!VALID_MODES.has(next)) return; - setModeState((prev) => { - if (prev === next) return prev; - const params = new URLSearchParams(window.location.search); - if (next === 'feed') { - gridScrollYRef.current = window.scrollY; - params.set('mode', 'feed'); - window.history.pushState( - { nightfeedMode: 'feed' }, - '', - buildUrl(params) - ); - } else { - params.delete('mode'); - window.history.replaceState( - { ...window.history.state, nightfeedMode: 'grid' }, - '', - buildUrl(params) - ); - } - return next; - }); + if (modeRef.current === next) return; + + const params = new URLSearchParams(window.location.search); + if (next === 'feed') { + gridScrollYRef.current = window.scrollY; + params.set('mode', 'feed'); + window.history.pushState( + { nightfeedMode: 'feed' }, + '', + buildUrl(params) + ); + } else { + params.delete('mode'); + window.history.replaceState( + { ...window.history.state, nightfeedMode: 'grid' }, + '', + buildUrl(params) + ); + } + modeRef.current = next; + setModeState(next); }, []); return { mode, setMode }; diff --git a/frontend/src/hooks/useMode.test.js b/frontend/src/hooks/useMode.test.js index bf5c30a..b41364d 100644 --- a/frontend/src/hooks/useMode.test.js +++ b/frontend/src/hooks/useMode.test.js @@ -51,4 +51,21 @@ describe('useMode', () => { expect(new URLSearchParams(window.location.search).get('mode')).toBeNull(); expect(localStorage.getItem(MODE_KEY)).toBe('grid'); }); + + it('writes URL synchronously inside setMode, not deferred to React commit', () => { + // Regression for React 18 Strict Mode bug: when history.pushState lived + // inside the setState updater function, it was a side effect that React + // would invoke twice and could mistime against the commit. The URL must + // change the moment setMode is called, before the React state update + // queue flushes. + const { result } = renderHook(() => useMode()); + + let urlInsideAct; + act(() => { + result.current.setMode('feed'); + urlInsideAct = window.location.search; + }); + + expect(urlInsideAct).toContain('mode=feed'); + }); }); From bf80f8af3eea26e5d76c42123a53e641c356f04c Mon Sep 17 00:00:00 2001 From: Astro Date: Tue, 26 May 2026 19:06:41 +1000 Subject: [PATCH 4/5] feat(VideoPlayer): add controls prop to support chromeless playback in feed mode VideoPlayer previously hardcoded the native HTML5 controls attribute on its video element. Feed mode (issue 011) needs chromeless playback with no native controls visible. Change is exactly two lines in VideoPlayer.jsx: - controls = true added to the destructured props with a true default - controls attribute on the video element now reads from the prop Default remains true so every existing call site (lightbox, prebuffer helper, etc) behaves identically. FeedItem opts out by passing controls={false}. This is the structurally correct fix versus hiding controls with ::-webkit-media-controls CSS, which would couple feed-mode styling to Chromium's shadow DOM internals. The off-limits rule on VideoPlayer.jsx was suspended narrowly for this two-line change only. See memory.md decisions log. Co-Authored-By: Claude Opus 4.7 --- frontend/src/components/FeedItem.jsx | 1 + frontend/src/components/VideoPlayer.jsx | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/frontend/src/components/FeedItem.jsx b/frontend/src/components/FeedItem.jsx index ca21fbe..dbb8461 100644 --- a/frontend/src/components/FeedItem.jsx +++ b/frontend/src/components/FeedItem.jsx @@ -17,6 +17,7 @@ function FeedItem({ item }) { sourceKind={first.sourceKind} posterUrl={item?.thumbnail || ''} className="feed-item__video" + controls={false} /> ); diff --git a/frontend/src/components/VideoPlayer.jsx b/frontend/src/components/VideoPlayer.jsx index 2afa624..d54f00c 100644 --- a/frontend/src/components/VideoPlayer.jsx +++ b/frontend/src/components/VideoPlayer.jsx @@ -32,6 +32,7 @@ const VideoPlayer = forwardRef(function VideoPlayer( className, autoPlay = true, loop = true, + controls = true, prebufferOnly = false, allowUnmutedAutoplay = false, preferredMuted = null, @@ -564,7 +565,7 @@ const VideoPlayer = forwardRef(function VideoPlayer(