| name | page-transition-and-rendering-flow |
| description | Auto-invoked when modifying page transition logic, global atom hydration, or the `[[...path]]` dynamic route. Explains the data flow from SSR/client navigation to page rendering, and the hydration-vs-subsequent-sync rule for global atoms (`currentPathnameAtom`, `currentUserAtom`, `isMaintenanceModeAtom`). |
Page Transition and Rendering Flow
Problem
The page transition path in GROWI spans SSR, client-side navigation, URL normalization, Jotai atom hydration, and asynchronous data fetching. Changes in any one of these layers can cause subtle regressions in other layers:
- Forcing global atom updates during render causes "setState during render of a different component" warnings.
- Moving hydration into
useEffect without care causes flashes of stale values or hydration mismatches.
- Confusing
router.asPath vs props.currentPathname vs currentPathnameAtom leads to inconsistent reads across the transition.
This skill documents the intended flow so that edits preserve invariants.
Key Actors
pages/[[...path]]/index.page.tsx — Dynamic route component. Runs on server and client. Hydrates page-data atoms (currentPageDataAtom, etc.) from getServerSideProps output.
pages/[[...path]]/use-same-route-navigation.ts — Detects router.asPath changes on the client and triggers fetchCurrentPage. Always attempts fetch; actual skip is decided inside useFetchCurrentPage.
states/page/use-fetch-current-page.ts — Single source of truth for page data fetching. Decides whether a fetch is actually needed (guards against duplicate fetches by comparing decoded path / permalink ID with current atom state). Updates page-data atoms atomically on success to avoid intermediate states.
pages/[[...path]]/use-shallow-routing.ts — After hydration, compares the browser URL with props.currentPathname (the server-normalized path) and issues a router.replace(..., { shallow: true }) to align them.
pages/[[...path]]/server-side-props.ts — getServerSidePropsForInitial calls retrievePageData, performs path normalization (e.g. /user/username → /user/username/), and returns data + normalized currentPathname as props.
pages/_app.page.tsx / states/global/hydrate.ts — Hydrates global atoms (currentPathnameAtom, currentUserAtom, isMaintenanceModeAtom) via useHydrateGlobalEachAtoms.
Flow 1: Server-Side Rendering (first load / reload)
- Request received: server receives request (e.g.
/user/username/memo).
getServerSideProps runs:
getServerSidePropsForInitial executes.
retrievePageData normalizes the path and fetches page data from the API.
- Returns page data and normalized
currentPathname as props.
- Component renders, atoms initialized:
[[...path]]/index.page.tsx receives props and initializes page-data atoms (currentPageDataAtom, etc.).
PageView and children render on the server.
- Client-side hydration + URL alignment:
- Browser receives HTML; React hydrates.
useShallowRouting compares browser URL (/user/username/memo) against props.currentPathname (/user/username/memo/).
- On mismatch,
router.replace(..., { shallow: true }) silently rewrites the browser URL to the server-normalized path.
Flow 2: Client-Side Navigation (<Link> click)
- Navigation start: user clicks
<Link href="/new/page">. useRouter detects URL change and [[...path]]/index.page.tsx re-evaluates.
useSameRouteNavigation triggers fetch:
- Its
useEffect detects router.asPath change (/new/page).
- Calls
fetchCurrentPage({ path: '/new/page' }). This hook always attempts the call.
useFetchCurrentPage decides and executes:
- 3a. Path preprocessing: decodes the path; detects permalink format (e.g.
/65d4...).
- 3b. Dedup guard: compares preprocessed path / extracted page ID against current Jotai state. If equal, returns without hitting the API.
- 3c. Loading flag: sets
pageLoadingAtom = true.
- 3d. API call:
apiv3Get('/page', ...) with path / pageId / revisionId.
- Atomic state update:
- Success: all relevant atoms (
currentPageDataAtom, currentPageEntityIdAtom, currentPageEmptyIdAtom, pageNotFoundAtom, pageLoadingAtom, …) are updated together, avoiding intermediate states where pageId is temporarily undefined.
- Error (e.g. 404):
pageErrorAtom set, pageNotFoundAtom = true, pageLoadingAtom = false last.
PageView re-renders with the new data.
- Side effects: after
fetchCurrentPage completes, useSameRouteNavigation calls mutateEditingMarkdown to refresh editor state.
Critical Rule: Global Atom Hydration vs Subsequent Sync
Rule: In useHydrateGlobalEachAtoms (and similar hooks that run inside _app.page.tsx), do not use useHydrateAtoms(tuples, { dangerouslyForceHydrate: true }) to keep atoms aligned with commonEachProps across navigations.
Why
useHydrateAtoms runs during render. With dangerouslyForceHydrate: true, it re-writes atom values on every render — including navigations when props change.
- Those atoms are subscribed by already-mounted components (e.g.
PageViewComponent). Writing to them mid-render triggers setState on sibling components during the parent's render, producing:
Warning: Cannot update a component (PageViewComponent) while rendering a different component (GrowiAppSubstance).
Correct pattern
Split the two concerns:
export const useHydrateGlobalEachAtoms = (commonEachProps: CommonEachProps): void => {
const tuples = [
createAtomTuple(currentPathnameAtom, commonEachProps.currentPathname),
createAtomTuple(currentUserAtom, commonEachProps.currentUser),
createAtomTuple(isMaintenanceModeAtom, commonEachProps.isMaintenanceMode),
];
useHydrateAtoms(tuples);
const setCurrentPathname = useSetAtom(currentPathnameAtom);
const setCurrentUser = useSetAtom(currentUserAtom);
const setIsMaintenanceMode = useSetAtom(isMaintenanceModeAtom);
useEffect(() => {
setCurrentPathname(commonEachProps.currentPathname);
}, [commonEachProps.currentPathname, setCurrentPathname]);
useEffect(() => {
setCurrentUser(commonEachProps.currentUser);
}, [commonEachProps.currentUser, setCurrentUser]);
useEffect(() => {
setIsMaintenanceMode(commonEachProps.isMaintenanceMode);
}, [commonEachProps.isMaintenanceMode, setIsMaintenanceMode]);
};
Trade-off accepted by this pattern
On a route transition, currentPathnameAtom is one render behind before the effect commits. This is safe because:
- Data fetching (
useSameRouteNavigation, useFetchCurrentPage, useShallowRouting) reads router.asPath or props.currentPathname directly — not currentPathnameAtom.
useCurrentPagePath uses currentPagePathAtom (page data) as primary and falls back to currentPathname only when the page data is absent.
- Jotai's
Object.is comparison means the effect is a no-op when the value hasn't actually changed, so setters don't need manual guards.
Source Reference Map
| Concern | File |
|---|
| Dynamic route entry | apps/app/src/pages/[[...path]]/index.page.tsx |
| SSR props | apps/app/src/pages/[[...path]]/server-side-props.ts |
| Route-change trigger | apps/app/src/pages/[[...path]]/use-same-route-navigation.ts |
| URL normalization | apps/app/src/pages/[[...path]]/use-shallow-routing.ts |
| Page fetch / atom updates | apps/app/src/states/page/use-fetch-current-page.ts |
| Page path selector | apps/app/src/states/page/hooks.ts (useCurrentPagePath) |
| Global atom hydration | apps/app/src/states/global/hydrate.ts |
| Global atom definitions | apps/app/src/states/global/global.ts |
| App shell | apps/app/src/pages/_app.page.tsx (GrowiAppSubstance) |
When to Apply
- Editing any hook under
states/global/ that hydrates from commonEachProps / commonInitialProps.
- Modifying
useSameRouteNavigation, useFetchCurrentPage, or useShallowRouting.
- Adding new global atoms that must stay aligned with server-side props across navigations.
- Touching
_app.page.tsx render order or provider composition.
- Debugging "setState during render of a different component" warnings originating from
_app.page.tsx or GrowiAppSubstance.
Common Pitfalls
dangerouslyForceHydrate: true for route-sync purposes — breaks the render model. Use useEffect + useSetAtom instead.
- Moving the initial hydration into
useEffect — children reading the atom on first render would see the default (empty) value, causing flashes / hydration mismatches.
- Using
currentPathnameAtom as the trigger for data fetching — the trigger is router.asPath, and the normalized authority is props.currentPathname. The atom is for downstream UI consumers only.
- Updating page-data atoms one-by-one during a fetch — always update atomically (success block) to avoid intermediate states visible to
PageView.
- Adding a guard like
if (new !== old) set(new) for atoms — unnecessary; Jotai already dedupes on Object.is.