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 => {
// 1. Initial hydration only — so children read correct values on first render
const tuples = [
createAtomTuple(currentPathnameAtom, commonEachProps.currentPathname),
createAtomTuple(currentUserAtom, commonEachProps.currentUser),
createAtomTuple(isMaintenanceModeAtom, commonEachProps.isMaintenanceMode),
];
useHydrateAtoms(tuples); // force NOT enabled
// 2. Subsequent sync (route transitions) — run after commit to avoid render-time setState
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.
1---2name: page-transition-and-rendering-flow3description: 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`).4---56# Page Transition and Rendering Flow78## Problem910The 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:1112- Forcing global atom updates during render causes "setState during render of a different component" warnings.13- Moving hydration into `useEffect` without care causes flashes of stale values or hydration mismatches.14- Confusing `router.asPath` vs `props.currentPathname` vs `currentPathnameAtom` leads to inconsistent reads across the transition.1516This skill documents the intended flow so that edits preserve invariants.1718## Key Actors19201. **`pages/[[...path]]/index.page.tsx`** — Dynamic route component. Runs on server and client. Hydrates page-data atoms (`currentPageDataAtom`, etc.) from `getServerSideProps` output.212. **`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`.223. **`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.234. **`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.245. **`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.256. **`pages/_app.page.tsx` / `states/global/hydrate.ts`** — Hydrates global atoms (`currentPathnameAtom`, `currentUserAtom`, `isMaintenanceModeAtom`) via `useHydrateGlobalEachAtoms`.2627## Flow 1: Server-Side Rendering (first load / reload)28291. **Request received**: server receives request (e.g. `/user/username/memo`).302. **`getServerSideProps` runs**:31 - `getServerSidePropsForInitial` executes.32 - `retrievePageData` normalizes the path and fetches page data from the API.33 - Returns page data and normalized `currentPathname` as props.343. **Component renders, atoms initialized**:35 - `[[...path]]/index.page.tsx` receives props and initializes page-data atoms (`currentPageDataAtom`, etc.).36 - `PageView` and children render on the server.374. **Client-side hydration + URL alignment**:38 - Browser receives HTML; React hydrates.39 - `useShallowRouting` compares browser URL (`/user/username/memo`) against `props.currentPathname` (`/user/username/memo/`).40 - On mismatch, `router.replace(..., { shallow: true })` silently rewrites the browser URL to the server-normalized path.4142## Flow 2: Client-Side Navigation (`<Link>` click)43441. **Navigation start**: user clicks `<Link href="/new/page">`. `useRouter` detects URL change and `[[...path]]/index.page.tsx` re-evaluates.452. **`useSameRouteNavigation` triggers fetch**:46 - Its `useEffect` detects `router.asPath` change (`/new/page`).47 - Calls `fetchCurrentPage({ path: '/new/page' })`. This hook always attempts the call.483. **`useFetchCurrentPage` decides and executes**:49 - **3a. Path preprocessing**: decodes the path; detects permalink format (e.g. `/65d4...`).50 - **3b. Dedup guard**: compares preprocessed path / extracted page ID against current Jotai state. If equal, returns without hitting the API.51 - **3c. Loading flag**: sets `pageLoadingAtom = true`.52 - **3d. API call**: `apiv3Get('/page', ...)` with path / pageId / revisionId.534. **Atomic state update**:54 - **Success**: all relevant atoms (`currentPageDataAtom`, `currentPageEntityIdAtom`, `currentPageEmptyIdAtom`, `pageNotFoundAtom`, `pageLoadingAtom`, …) are updated together, avoiding intermediate states where `pageId` is temporarily undefined.55 - **Error (e.g. 404)**: `pageErrorAtom` set, `pageNotFoundAtom = true`, `pageLoadingAtom = false` last.565. **`PageView` re-renders** with the new data.576. **Side effects**: after `fetchCurrentPage` completes, `useSameRouteNavigation` calls `mutateEditingMarkdown` to refresh editor state.5859## Critical Rule: Global Atom Hydration vs Subsequent Sync6061**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.6263### Why6465- `useHydrateAtoms` runs during render. With `dangerouslyForceHydrate: true`, it re-writes atom values on *every* render — including navigations when props change.66- 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:67 > Warning: Cannot update a component (`PageViewComponent`) while rendering a different component (`GrowiAppSubstance`).6869### Correct pattern7071Split the two concerns:7273```ts74export const useHydrateGlobalEachAtoms = (commonEachProps: CommonEachProps): void => {75 // 1. Initial hydration only — so children read correct values on first render76 const tuples = [77 createAtomTuple(currentPathnameAtom, commonEachProps.currentPathname),78 createAtomTuple(currentUserAtom, commonEachProps.currentUser),79 createAtomTuple(isMaintenanceModeAtom, commonEachProps.isMaintenanceMode),80 ];81 useHydrateAtoms(tuples); // force NOT enabled8283 // 2. Subsequent sync (route transitions) — run after commit to avoid render-time setState84 const setCurrentPathname = useSetAtom(currentPathnameAtom);85 const setCurrentUser = useSetAtom(currentUserAtom);86 const setIsMaintenanceMode = useSetAtom(isMaintenanceModeAtom);8788 useEffect(() => {89 setCurrentPathname(commonEachProps.currentPathname);90 }, [commonEachProps.currentPathname, setCurrentPathname]);9192 useEffect(() => {93 setCurrentUser(commonEachProps.currentUser);94 }, [commonEachProps.currentUser, setCurrentUser]);9596 useEffect(() => {97 setIsMaintenanceMode(commonEachProps.isMaintenanceMode);98 }, [commonEachProps.isMaintenanceMode, setIsMaintenanceMode]);99};100```101102### Trade-off accepted by this pattern103104On a route transition, `currentPathnameAtom` is **one render behind** before the effect commits. This is safe because:105106- Data fetching (`useSameRouteNavigation`, `useFetchCurrentPage`, `useShallowRouting`) reads `router.asPath` or `props.currentPathname` directly — not `currentPathnameAtom`.107- `useCurrentPagePath` uses `currentPagePathAtom` (page data) as primary and falls back to `currentPathname` only when the page data is absent.108- 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.109110## Source Reference Map111112| Concern | File |113|---|---|114| Dynamic route entry | `apps/app/src/pages/[[...path]]/index.page.tsx` |115| SSR props | `apps/app/src/pages/[[...path]]/server-side-props.ts` |116| Route-change trigger | `apps/app/src/pages/[[...path]]/use-same-route-navigation.ts` |117| URL normalization | `apps/app/src/pages/[[...path]]/use-shallow-routing.ts` |118| Page fetch / atom updates | `apps/app/src/states/page/use-fetch-current-page.ts` |119| Page path selector | `apps/app/src/states/page/hooks.ts` (`useCurrentPagePath`) |120| Global atom hydration | `apps/app/src/states/global/hydrate.ts` |121| Global atom definitions | `apps/app/src/states/global/global.ts` |122| App shell | `apps/app/src/pages/_app.page.tsx` (`GrowiAppSubstance`) |123124## When to Apply125126- Editing any hook under `states/global/` that hydrates from `commonEachProps` / `commonInitialProps`.127- Modifying `useSameRouteNavigation`, `useFetchCurrentPage`, or `useShallowRouting`.128- Adding new global atoms that must stay aligned with server-side props across navigations.129- Touching `_app.page.tsx` render order or provider composition.130- Debugging "setState during render of a different component" warnings originating from `_app.page.tsx` or `GrowiAppSubstance`.131132## Common Pitfalls1331341. **`dangerouslyForceHydrate: true` for route-sync purposes** — breaks the render model. Use `useEffect` + `useSetAtom` instead.1352. **Moving the initial hydration into `useEffect`** — children reading the atom on first render would see the default (empty) value, causing flashes / hydration mismatches.1363. **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.1374. **Updating page-data atoms one-by-one during a fetch** — always update atomically (success block) to avoid intermediate states visible to `PageView`.1385. **Adding a guard like `if (new !== old) set(new)` for atoms** — unnecessary; Jotai already dedupes on `Object.is`.