Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 9 additions & 11 deletions frontend/eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -25,17 +25,15 @@ const eslintConfig = [
"error",
{ argsIgnorePattern: "^_", varsIgnorePattern: "^_" },
],
// react-hooks v7 (bundled by eslint-config-next 16) adds the React
// Compiler-era rules as errors. The codebase predates them and carries
// ~27 hits of deliberate pre-compiler idioms (latest-ref, sync-state-in
// -effect, render-scoped subcomponents). Warn — visible in editors and
// lint output, not a CI gate — while they're burned down incrementally.
// rules-of-hooks / exhaustive-deps stay errors, as ever.
"react-hooks/refs": "warn",
"react-hooks/set-state-in-effect": "warn",
"react-hooks/static-components": "warn",
"react-hooks/immutability": "warn",
"react-hooks/preserve-manual-memoization": "warn",
// react-hooks v7 (eslint-config-next 16) React Compiler-era rules, held at
// error so a new pre-compiler idiom can't slip in (the codebase's original
// hits were burned down in #38). Explicit rather than inherited so the CI
// gate is unambiguous.
"react-hooks/refs": "error",
"react-hooks/set-state-in-effect": "error",
"react-hooks/static-components": "error",
"react-hooks/immutability": "error",
"react-hooks/preserve-manual-memoization": "error",
},
},

Expand Down
11 changes: 8 additions & 3 deletions frontend/src/app/(app)/admin/appearance/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -64,11 +64,16 @@ export default function AdminAppearancePage() {
}, [palette, accent]);

// On leaving without saving, restore what the viewer actually sees (their own
// palette override, if any, over the saved site default).
// palette override, if any, over the saved site default). The unmount cleanup
// needs the *latest* saved settings + override, but must run only on unmount —
// so mirror them into refs from an effect (refs are written outside render)
// and read those in the mount-only cleanup.
const savedRef = useRef(saved);
savedRef.current = saved;
const overrideRef = useRef(paletteOverride);
overrideRef.current = paletteOverride;
useEffect(() => {
savedRef.current = saved;
overrideRef.current = paletteOverride;
});
useEffect(() => {
return () => {
const s = savedRef.current;
Expand Down
30 changes: 17 additions & 13 deletions frontend/src/components/app/route-progress.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,15 @@ export function RouteProgress() {
}
}, []);

// Declared before `start` so it can be a real dependency there (rather than
// referenced before declaration behind a lint-disable).
const finish = React.useCallback(() => {
clearTimers();
// If the bar never showed (instant nav within the start delay), stay hidden.
setProgress((p) => (p <= 0 ? 0 : 100));
timers.current.push(setTimeout(() => setProgress(0), 240));
}, [clearTimers]);

const start = React.useCallback(() => {
clearTimers();
// Delay before showing so a fast navigation doesn't blink a bar.
Expand All @@ -39,15 +48,7 @@ export function RouteProgress() {
);
// Never trickle forever if a navigation is cancelled.
timers.current.push(setTimeout(() => finish(), 10_000));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [clearTimers]);

const finish = React.useCallback(() => {
clearTimers();
// If the bar never showed (instant nav within the start delay), stay hidden.
setProgress((p) => (p <= 0 ? 0 : 100));
timers.current.push(setTimeout(() => setProgress(0), 240));
}, [clearTimers]);
}, [clearTimers, finish]);

// Start on any left-click of an internal, same-origin link (Next <Link> renders
// a plain <a>). Modified clicks and new-tab links open elsewhere — ignore them.
Expand Down Expand Up @@ -81,11 +82,14 @@ export function RouteProgress() {
return () => document.removeEventListener("click", onClick, true);
}, [pathname, start]);

// A committed pathname change means the navigation finished.
// A committed pathname change means the navigation finished. Finish on the
// next frame rather than synchronously in the effect body: it's a genuine
// react-to-external-event (the router committed), and deferring keeps the
// 100%-then-fade paint from being coalesced into the route's own render.
React.useEffect(() => {
finish();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [pathname]);
const raf = requestAnimationFrame(() => finish());
return () => cancelAnimationFrame(raf);
}, [pathname, finish]);

React.useEffect(() => clearTimers, [clearTimers]);

Expand Down