diff --git a/CLAUDE.md b/CLAUDE.md index 1625a6a..2d542fe 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -168,7 +168,7 @@ yarn check && yarn test Both must pass with no errors before committing. ### After Pushing -1. Create draft PR: `gh pr create --draft` +1. Create PR: `gh pr create` 2. Check CI status: `gh pr checks` 3. Fix any CI failures on the branch 4. Note: CI environment differs (locale, timezone, Node version) diff --git a/src/client/features/Page/pageSlice.ts b/src/client/features/Page/pageSlice.ts index bd282ca..845b9d9 100644 --- a/src/client/features/Page/pageSlice.ts +++ b/src/client/features/Page/pageSlice.ts @@ -1,4 +1,6 @@ import { createSlice } from '@reduxjs/toolkit'; +import { useLayoutEffect } from 'react'; +import { useDispatch } from '../../store'; export interface PageContext { /** identifier for the sidebar heading this page appears under */ @@ -27,3 +29,21 @@ export const pageSlice = createSlice({ export const { set } = pageSlice.actions; export default pageSlice.reducer; + +/** + * Sets the page context (title, back button, sidebar highlight). + * + * This hook centralizes the page context setting pattern, replacing individual + * useEffect calls in each page component. It uses useLayoutEffect to ensure + * the context is set synchronously before paint, avoiding visual flicker. + * + * The hook re-runs when any context value changes. + */ +export function usePageContext(context: PageContext): void { + const dispatch = useDispatch(); + const { title, back, under } = context; + + useLayoutEffect(() => { + dispatch(set({ title, back, under })); + }, [dispatch, title, back, under]); +} diff --git a/src/client/pages/About.tsx b/src/client/pages/About.tsx index 8236f14..a200e7a 100644 --- a/src/client/pages/About.tsx +++ b/src/client/pages/About.tsx @@ -1,11 +1,10 @@ import { Link } from '@mui/material'; import axios from 'axios'; import { Fragment, type ReactNode, useEffect, useState } from 'react'; -import { useDispatch } from 'react-redux'; import db from '../db'; import DebugPanel from '../features/Debug/DebugPanel'; -import { set as setContext } from '../features/Page/pageSlice'; +import { usePageContext } from '../features/Page/pageSlice'; import SyncPanel from '../features/Sync/SyncPanel'; import UpdatePanel from '../features/Update/UpdatePanel'; import { useSelector } from '../store'; @@ -19,7 +18,7 @@ function mapProps(parent: string, info: Record): InfoRow[] { } function About() { - const dispatch = useDispatch(); + usePageContext({ title: 'About', back: true, under: 'about' }); const loggedInUser = useSelector((state) => state.user.value); // eslint-disable-next-line no-unused-vars @@ -65,15 +64,6 @@ function About() { .catch(console.error); } }, []); - useEffect(() => { - dispatch( - setContext({ - title: 'About', - back: true, - under: 'about', - }), - ); - }, [dispatch]); const vars: InfoRow[] = [ [

SERVER DETAILS

], diff --git a/src/client/pages/History.tsx b/src/client/pages/History.tsx index 5d78763..ae1601c 100644 --- a/src/client/pages/History.tsx +++ b/src/client/pages/History.tsx @@ -2,14 +2,15 @@ import { List, Typography } from '@mui/material'; import { useEffect, useState } from 'react'; import type { TemplateDoc } from '../../shared/types'; import db from '../db'; -import { set as setContext } from '../features/Page/pageSlice'; +import { usePageContext } from '../features/Page/pageSlice'; import RepeatableListItem from '../features/Repeatable/RepeatableListItem'; -import { useDispatch, useSelector } from '../store'; +import { useSelector } from '../store'; import type { SortableRepeatableDoc } from './Home'; function History() { + usePageContext({ title: 'History', under: 'history' }); + const [repeatables, setRepeatables] = useState([] as SortableRepeatableDoc[]); - const dispatch = useDispatch(); const user = useSelector((state) => state.user.value); const handle = db(user); @@ -17,15 +18,6 @@ function History() { // we don't actually care about this value, we just use it to trigger list reloading const lastSynced = useSelector((state) => state.docs.lastSynced); - useEffect(() => { - dispatch( - setContext({ - title: 'History', - under: 'history', - }), - ); - }, [dispatch]); - // NB: this code also exists in Home.js using updated instead of completed // biome-ignore lint/correctness/useExhaustiveDependencies: we need the timestamp to trigger useEffect(() => { diff --git a/src/client/pages/Home.tsx b/src/client/pages/Home.tsx index 6d8b224..2e897c7 100644 --- a/src/client/pages/Home.tsx +++ b/src/client/pages/Home.tsx @@ -2,10 +2,10 @@ import { Divider, List, Typography } from '@mui/material'; import { Fragment, useEffect, useState } from 'react'; import type { RepeatableDoc, TemplateDoc } from '../../shared/types'; import db from '../db'; -import { set as setContext } from '../features/Page/pageSlice'; +import { usePageContext } from '../features/Page/pageSlice'; import RepeatableListItem from '../features/Repeatable/RepeatableListItem'; import TemplateListItem from '../features/Template/TemplateListItem'; -import { useDispatch, useSelector } from '../store'; +import { useSelector } from '../store'; export type SortableRepeatableDoc = Omit & { timestamp?: number; @@ -14,7 +14,7 @@ export type SortableRepeatableDoc = Omit & { }; function Home() { - const dispatch = useDispatch(); + usePageContext({ title: 'Repeatable Checklists', under: 'home' }); const user = useSelector((state) => state.user.value); const handle = db(user); @@ -25,15 +25,6 @@ function Home() { const [templates, setTemplates] = useState([] as TemplateDoc[]); const [repeatables, setRepeatables] = useState([] as SortableRepeatableDoc[]); - useEffect(() => { - dispatch( - setContext({ - title: 'Repeatable Checklists', - under: 'home', - }), - ); - }, [dispatch]); - // NB: this code also exists in History.js using completed instead of updated // biome-ignore lint/correctness/useExhaustiveDependencies: TODO confirm this is OK, I think we need lastSynced as an effective "try again" useEffect(() => { diff --git a/src/client/pages/Repeatable.tsx b/src/client/pages/Repeatable.tsx index c9507ea..6c7004b 100644 --- a/src/client/pages/Repeatable.tsx +++ b/src/client/pages/Repeatable.tsx @@ -7,7 +7,7 @@ import { v4 as uuid } from 'uuid'; import type { RepeatableDoc, TemplateDoc } from '../../shared/types'; import db from '../db'; -import { set as setContext } from '../features/Page/pageSlice'; +import { usePageContext } from '../features/Page/pageSlice'; import RepeatableRenderer from '../features/Repeatable/RepeatableRenderer'; import { debugClient } from '../globals'; import { clearRepeatable, clearTemplate, setRepeatable, setTemplate } from '../state/docsSlice'; @@ -79,13 +79,6 @@ function Repeatable() { debug('post template load'); // React 19+ automatically batches updates - no need for unstable_batchedUpdates - dispatch( - setContext({ - title: template.title, - back: true, - under: 'home', - }), - ); dispatch(setRepeatable(repeatable)); dispatch(setTemplate(template)); setInitiallyOpen(!repeatable.completed); @@ -99,6 +92,13 @@ function Repeatable() { }; }, [dispatch, handle, repeatableId, location, navigate]); + // Set page context based on loaded template + usePageContext({ + title: template?.title, + back: true, + under: 'home', + }); + async function deleteRepeatable() { const copy = Object.assign({}, repeatable); diff --git a/src/client/pages/Template.tsx b/src/client/pages/Template.tsx index 1930a82..602653e 100644 --- a/src/client/pages/Template.tsx +++ b/src/client/pages/Template.tsx @@ -13,7 +13,7 @@ import { v4 as uuid } from 'uuid'; import { SlugType, type TemplateDoc } from '../../shared/types'; import db from '../db'; -import { set as setContext } from '../features/Page/pageSlice'; +import { usePageContext } from '../features/Page/pageSlice'; import RepeatableRenderer from '../features/Repeatable/RepeatableRenderer'; import { clearTemplate, setTemplate } from '../state/docsSlice'; import { useDispatch, useSelector } from '../store'; @@ -60,15 +60,11 @@ function Template() { }; }, [handle, templateId, navigate, dispatch]); - useEffect(() => { - dispatch( - setContext({ - title: `${template?.title || 'New Template'} | edit`, - back: true, - under: 'home', - }), - ); - }, [dispatch, template?.title]); + usePageContext({ + title: `${template?.title || 'New Template'} | edit`, + back: true, + under: 'home', + }); async function handleDelete(event: MouseEvent) { event.preventDefault();