# Design Document: auto-scroll ## Overview **Purpose**: This feature provides a reusable hash-based auto-scroll mechanism that handles lazy-rendered content across GROWI's Markdown views. It compensates for layout shifts caused by asynchronous component rendering (e.g., Drawio diagrams, Mermaid charts, PlantUML images) by detecting in-progress renders and re-scrolling to the target. **Users**: End users navigating to hash-linked sections benefit from reliable scroll positioning. Developers integrating the hook into new views (PageView, SearchResultContent, future views) benefit from a standardized, configurable API. **Impact**: Refactors the existing `useHashAutoScroll` hook from a PageView-specific implementation into a shared, configurable hook. Renames and updates the rendering status attribute protocol for clarity and declarative usage. Also integrates hash-based auto-scroll into `SearchResultContent`, where the content pane has an independent scroll container. ### Goals - Provide a single reusable hook for hash-based auto-scroll across all content views - Support customizable target resolution and scroll behavior per caller - Establish a clear, declarative rendering-status attribute protocol for async-rendering components - Maintain robust resource cleanup with timeout-based safety bounds - Integrate `SearchResultContent` as a second consumer with container-relative scroll strategy ### Non-Goals - Adding `data-growi-is-content-rendering` to attachment-refs (Ref/Refs/RefImg/RefsImg/Gallery), or RichAttachment — these also cause layout shifts but require more complex integration; deferred to follow-up - Replacing SearchResultContent's keyword-highlight scroll with hash-based scroll (search pages have no URL hash) - Supporting non-browser environments (SSR) — this is a client-only hook ## Architecture ### Existing Architecture Analysis The current implementation lives in `apps/app/src/components/PageView/use-hash-auto-scroll.tsx`, tightly coupled to PageView via: - Hardcoded `document.getElementById(targetId)` for target resolution - Hardcoded `element.scrollIntoView()` for scroll execution - First parameter named `pageId` implying page-specific usage The rendering attribute `data-growi-rendering` is defined in `@growi/core` and consumed by: - `remark-drawio` (sets attribute on render start, removes on completion) - `use-hash-auto-scroll` (observes attribute presence via MutationObserver) ### Architecture Pattern & Boundary Map > **Note**: This diagram reflects the final architecture after Task 8 module reorganization. See "Task 8 Design" section below for the migration details. ```mermaid graph TB subgraph growi_core[growi core] CONST[Rendering Status Constants] end subgraph shared_util[src/client/util] WATCH[watchRenderingAndReScroll] end subgraph page_view[src/components/PageView] UHAS[useHashAutoScroll] PV[PageView] end subgraph search[features/search/.../SearchPage] UKR[useKeywordRescroll] SRC[SearchResultContent] end subgraph renderers[Async Renderers] DV[DrawioViewer] MV[MermaidViewer] PUV[PlantUmlViewer] LSX[Lsx] end PV -->|calls| UHAS UHAS -->|imports| WATCH SRC -->|calls| UKR UKR -->|imports| WATCH WATCH -->|queries| CONST DV -->|sets/toggles| CONST MV -->|sets/toggles| CONST PUV -->|sets/toggles| CONST LSX -->|sets/toggles| CONST ``` **Architecture Integration**: - Selected pattern: Co-located hooks per consumer + shared utility function — idiomatic React, testable, minimal coupling - Domain boundaries: `watchRenderingAndReScroll` (shared pure function) in `src/client/util/`, consumer-specific hooks co-located with their components, constants in `@growi/core`, attribute lifecycle in each renderer package - Existing patterns preserved: MutationObserver + polling hybrid, timeout-based safety bounds - Steering compliance: Named exports, immutable patterns, co-located tests **Co-location rationale**: `watchRenderingAndReScroll` lives in `src/client/util/` (not `hooks/`) because it is a plain function, not a React hook — co-located with `smooth-scroll.ts` as both are DOM scroll utilities. `useHashAutoScroll` lives next to `PageView.tsx` because it is hash-navigation–specific (`window.location.hash`) and PageView is its only consumer. `useKeywordRescroll` lives next to `SearchResultContent.tsx` for the same reason. The old `src/client/hooks/use-content-auto-scroll/` shared directory was removed because the hook was never truly shared — only the underlying utility function was. ### Technology Stack | Layer | Choice / Version | Role in Feature | Notes | |-------|------------------|-----------------|-------| | Frontend | React 18 hooks (`useEffect`) | Hook lifecycle management | No new dependencies | | Browser API | MutationObserver, `setTimeout`, `requestAnimationFrame` | DOM observation, polling, and layout timing | Standard Web APIs | | Shared Constants | `@growi/core` | Rendering attribute definitions | Existing package | No new external dependencies are introduced. ## System Flows ### Auto-Scroll Lifecycle ```mermaid sequenceDiagram participant Caller as Content View (PageView) participant Hook as useHashAutoScroll participant DOM as DOM participant Watch as watchRenderingAndReScroll Caller->>Hook: useHashAutoScroll options Hook->>Hook: Guard checks key, hash, container alt Target exists in DOM Hook->>DOM: resolveTarget DOM-->>Hook: HTMLElement Hook->>DOM: scrollTo target Hook->>Watch: start rendering watch (always) else Target not yet in DOM Hook->>DOM: MutationObserver on container DOM-->>Hook: target appears Hook->>DOM: scrollTo target Hook->>Watch: start rendering watch (always) end Note over Watch: MutationObserver detects rendering elements,
including those that mount after the initial scroll loop While rendering elements exist and within timeout Watch->>DOM: query rendering-status attr DOM-->>Watch: elements found Watch-->>Watch: wait 5s Watch->>DOM: scrollTo target end Note over Watch: Auto-cleanup after 10s timeout ``` Key decisions: - The two-phase approach (target observation → rendering watch) runs sequentially. - The rendering watch uses a non-resetting timer to prevent starvation from rapid DOM mutations. - **The rendering watch always starts after the initial scroll**, regardless of whether rendering elements exist at that moment. This is necessary because async renderers (Mermaid loaded via `dynamic()`, PlantUML images) may mount into the DOM *after* the hook's effect runs. The MutationObserver inside `watchRenderingAndReScroll` (`childList: true, subtree: true`) detects these late-mounting elements. ## Requirements Traceability | Requirement | Summary | Components | Interfaces | Flows | |-------------|---------|------------|------------|-------| | 1.1, 1.2 | Immediate scroll to hash target | useHashAutoScroll | UseHashAutoScrollOptions.resolveTarget | Auto-Scroll Lifecycle | | 1.3, 1.4, 1.5 | Guard conditions | useHashAutoScroll | UseHashAutoScrollOptions.key, contentContainerId | — | | 2.1, 2.2, 2.3 | Deferred scroll for lazy targets | useHashAutoScroll (target observer) | — | Auto-Scroll Lifecycle | | 3.1–3.6 | Re-scroll after rendering | watchRenderingAndReScroll | scrollToTarget callback | Auto-Scroll Lifecycle | | 4.1–4.7 | Rendering attribute protocol | Rendering Status Constants, DrawioViewer, MermaidViewer, PlantUmlViewer, Lsx | GROWI_IS_CONTENT_RENDERING_ATTR | — | | 4.8 | ResizeObserver re-render cycle | DrawioViewer | GROWI_IS_CONTENT_RENDERING_ATTR | — | | 5.1–5.5 | Page-type agnostic design | watchRenderingAndReScroll (shared), useHashAutoScroll (PageView), useKeywordRescroll (Search) | — | — | | 5.6, 5.7, 6.1–6.3 | Cleanup and safety | useHashAutoScroll, useKeywordRescroll, watchRenderingAndReScroll | cleanup functions | — | ## Components and Interfaces | Component | Domain/Layer | Intent | Req Coverage | Key Dependencies | Contracts | |-----------|--------------|--------|--------------|------------------|-----------| | useHashAutoScroll | src/components/PageView | Hash-based auto-scroll hook for PageView with configurable target resolution and scroll behavior | 1, 2, 5, 6 | watchRenderingAndReScroll (P0), Rendering Status Constants (P1) | Service | | useKeywordRescroll | features/search/.../SearchPage | Keyword-highlight scroll hook with rendering watch integration for SearchResultContent | 5, 6 | watchRenderingAndReScroll (P0), scrollWithinContainer (P0) | Service | | watchRenderingAndReScroll | src/client/util | Shared utility: polls for rendering-status attributes and re-scrolls until complete or timeout | 3, 6 | Rendering Status Constants (P0) | Service | | Rendering Status Constants | @growi/core | Shared attribute name, value, and selector constants | 4 | None | State | | DrawioViewer (modification) | remark-drawio | Declarative rendering-status attribute toggle | 4.3, 4.4, 4.8 | Rendering Status Constants (P0) | State | | MermaidViewer (modification) | features/mermaid | Add rendering-status attribute lifecycle to async SVG render | 4.3, 4.4, 4.7 | Rendering Status Constants (P0) | State | | PlantUmlViewer (new) | features/plantuml | Wrap PlantUML `` to provide rendering-status attribute lifecycle | 4.3, 4.4, 4.7 | Rendering Status Constants (P0) | State | | Lsx (modification) | remark-lsx | Add rendering-status attribute lifecycle to async page list fetch | 4.3, 4.4, 4.7 | Rendering Status Constants (P0) | State | ### Client Hooks #### useHashAutoScroll | Field | Detail | |-------|--------| | Intent | Hash-based auto-scroll hook for PageView that scrolls to a target element identified by URL hash, with support for lazy-rendered content and customizable scroll behavior | | Requirements | 1.1–1.5, 2.1–2.3, 5.1–5.7, 6.1–6.3 | **Responsibilities & Constraints** - Orchestrates the full hash-based auto-scroll lifecycle: guard → resolve target → scroll → watch rendering - Always delegates to `watchRenderingAndReScroll` after the initial scroll — does **not** skip the watch even when no rendering elements are present at scroll time, because async renderers may mount later - Co-located with `PageView.tsx` — this hook is hash-navigation–specific (`window.location.hash`) **Dependencies** - Outbound: `watchRenderingAndReScroll` from `~/client/util/watch-rendering-and-rescroll` (P0) **Contracts**: Service [x] ##### Service Interface ```typescript /** Configuration for the hash-based auto-scroll hook */ interface UseHashAutoScrollOptions { /** * Unique key that triggers re-execution when changed. * When null/undefined, all scroll processing is skipped. */ key: string | undefined | null; /** DOM id of the content container element to observe */ contentContainerId: string; /** * Optional function to resolve the scroll target element. * Receives the decoded hash string (without '#'). * Defaults to: (hash) => document.getElementById(hash) */ resolveTarget?: (decodedHash: string) => HTMLElement | null; /** * Optional function to scroll to the target element. * Defaults to: (el) => el.scrollIntoView() */ scrollTo?: (target: HTMLElement) => void; } /** Hook signature */ function useHashAutoScroll(options: UseHashAutoScrollOptions): void; ``` - Preconditions: Called within a React component; browser environment with `window.location.hash` available - Postconditions: On unmount or key change, all observers and timers are cleaned up - Invariants: At most one target observer and one rendering watch active per hook instance **Implementation Notes** - File location: `apps/app/src/components/PageView/use-hash-auto-scroll.ts` - Test file: `apps/app/src/components/PageView/use-hash-auto-scroll.spec.tsx` - The `resolveTarget` and `scrollTo` callbacks should be wrapped in `useRef` to avoid re-triggering the effect when callback identity changes --- #### useKeywordRescroll | Field | Detail | |-------|--------| | Intent | Keyword-highlight scroll hook for SearchResultContent that scrolls to the first `.highlighted-keyword` element and re-scrolls after async renderers settle | | Requirements | 5.1–5.7, 6.1–6.3 | **Responsibilities & Constraints** - MutationObserver on container for keyword highlight detection (debounced 500ms) - `watchRenderingAndReScroll` integration for async renderer layout shift compensation - Cleanup of both MO and rendering watch on key change or unmount - Co-located with `SearchResultContent.tsx` **Dependencies** - Outbound: `watchRenderingAndReScroll` from `~/client/util/watch-rendering-and-rescroll` (P0) - Outbound: `scrollWithinContainer` from `~/client/util/smooth-scroll` (P0) **Contracts**: Service [x] ##### Service Interface ```typescript interface UseKeywordRescrollOptions { /** Ref to the scrollable container element */ scrollElementRef: RefObject; /** Unique key that triggers re-execution (typically page._id) */ key: string; } function useKeywordRescroll(options: UseKeywordRescrollOptions): void; ``` - Preconditions: `scrollElementRef.current` is a mounted scroll container - Postconditions: On unmount or key change, MO disconnected, rendering watch cleaned up, debounce cancelled **Implementation Notes** - File location: `apps/app/src/features/search/client/components/SearchPage/use-keyword-rescroll.ts` - Test file: `apps/app/src/features/search/client/components/SearchPage/use-keyword-rescroll.spec.tsx` - Helper functions (`scrollToKeyword`, `scrollToTargetWithinContainer`) are defined in the hook file since only this hook uses them --- #### watchRenderingAndReScroll | Field | Detail | |-------|--------| | Intent | Pure function (not a hook) that monitors rendering-status attributes and periodically re-scrolls until rendering completes or timeout. Shared utility consumed by both `useHashAutoScroll` and `useKeywordRescroll`. | | Requirements | 3.1–3.6, 6.1–6.3 | **Responsibilities & Constraints** - Sets up MutationObserver to detect rendering-status attribute changes **and** new rendering elements added to the DOM (childList + subtree) - Manages a non-resetting poll timer (5s interval) - Enforces a hard timeout (10s) to prevent unbounded observation - Returns a cleanup function **Dependencies** - External: `@growi/core` rendering status constants — attribute selector (P0) **Contracts**: Service [x] ##### Service Interface ```typescript /** * Watches for elements with in-progress rendering status in the container. * Periodically calls scrollToTarget while rendering elements remain. * Returns a cleanup function that stops observation and clears timers. */ function watchRenderingAndReScroll( contentContainer: HTMLElement, scrollToTarget: () => boolean, ): () => void; ``` - Preconditions: `contentContainer` is a mounted DOM element - Postconditions: Cleanup function disconnects observer, clears all timers - Invariants: At most one poll timer active at any time; stopped flag prevents post-cleanup execution **Implementation Notes** - File location: `apps/app/src/client/util/watch-rendering-and-rescroll.ts` (co-located with `smooth-scroll.ts`) - Test file: `apps/app/src/client/util/watch-rendering-and-rescroll.spec.tsx` - Add a `stopped` boolean flag checked inside timer callbacks to prevent race conditions between cleanup and queued timer execution - When `checkAndSchedule` detects that no rendering elements remain and a timer is currently active, cancel the active timer immediately — avoids a redundant re-scroll after rendering has already completed - The MutationObserver watches `childList`, `subtree`, and `attributes` (filtered to the rendering-status attribute) — the `childList` + `subtree` combination is what detects late-mounting async renderers - **Performance trade-off**: The function is always started regardless of whether rendering elements exist at call time. This means one MutationObserver + one 10s cleanup timeout run for every hash navigation, even on pages with no async renderers. The initial `checkAndSchedule()` call returns early if no rendering elements are present, so no poll timer is ever scheduled in that case — the only cost is the MO observation and the 10s cleanup timeout itself, which is acceptable. - **`querySelector` frequency**: The `checkAndSchedule` callback fires on every `childList` mutation (in addition to attribute changes). Each invocation runs `querySelector(GROWI_IS_CONTENT_RENDERING_SELECTOR)` on the container. This call is O(n) on the subtree but stops at the first match and is bounded by the 10s timeout, making it acceptable even for content-heavy pages. --- ### @growi/core Constants #### Rendering Status Constants | Field | Detail | |-------|--------| | Intent | Centralized constants for the rendering-status attribute name, values, and CSS selector | | Requirements | 4.1, 4.2, 4.6 | **Contracts**: State [x] ##### State Management ```typescript /** Attribute name applied to elements during async content rendering */ const GROWI_IS_CONTENT_RENDERING_ATTR = 'data-growi-is-content-rendering' as const; /** * CSS selector matching elements currently rendering. * Matches only the "true" state, not completed ("false"). */ const GROWI_IS_CONTENT_RENDERING_SELECTOR = `[${GROWI_IS_CONTENT_RENDERING_ATTR}="true"]` as const; ``` - File location: `packages/core/src/consts/renderer.ts` (replaces existing constants) - Old constants (`GROWI_RENDERING_ATTR`, `GROWI_RENDERING_ATTR_SELECTOR`) are removed and replaced — no backward compatibility shim needed since all consumers are updated in the same change --- ### remark-drawio Modifications #### DrawioViewer (modification) | Field | Detail | |-------|--------| | Intent | Adopt declarative attribute value toggling instead of imperative add/remove | | Requirements | 4.3, 4.4, 4.8 | **Implementation Notes** - Replace `removeAttribute(GROWI_RENDERING_ATTR)` calls with `setAttribute(GROWI_IS_CONTENT_RENDERING_ATTR, 'false')` - Initial JSX: `{[GROWI_IS_CONTENT_RENDERING_ATTR]: 'true'}` (unchanged pattern, new constant name) - Update `SUPPORTED_ATTRIBUTES` in `remark-drawio.ts` to use new constant name - Update sanitize option to allow the new attribute name - **ResizeObserver re-render cycle** (req 4.8): In the ResizeObserver handler, call `drawioContainerRef.current?.setAttribute(GROWI_IS_CONTENT_RENDERING_ATTR, 'true')` before `renderDrawioWithDebounce()`. The existing inner MutationObserver (childList) completion path already sets the attribute back to `"false"` after each render. --- ### MermaidViewer Modification #### MermaidViewer (modification) | Field | Detail | |-------|--------| | Intent | Add rendering-status attribute lifecycle to async `mermaid.render()` SVG rendering | | Requirements | 4.3, 4.4, 4.7 | **Implementation Notes** - Set `data-growi-is-content-rendering="true"` on the container element at initial render (via JSX spread before `mermaid.render()` is called) - After `mermaid.render()` completes and SVG is injected via `innerHTML`, delay the `"false"` signal using **`requestAnimationFrame`** so that the browser can compute the SVG layout before the auto-scroll system re-scrolls. Setting `"false"` synchronously after `innerHTML` assignment would signal completion before the browser has determined the element's final dimensions. - Set attribute to `"false"` immediately (without rAF) in the error/catch path, since no layout shift is expected on error - Cancel the pending rAF on effect cleanup to prevent state updates on unmounted components - File: `apps/app/src/features/mermaid/components/MermaidViewer.tsx` - The mermaid remark plugin sanitize options must be updated to include the new attribute name --- ### PlantUmlViewer (new component) #### PlantUmlViewer | Field | Detail | |-------|--------| | Intent | Wrap PlantUML image rendering in a component that signals rendering status, enabling the auto-scroll system to compensate for the layout shift when the external image loads | | Requirements | 4.3, 4.4, 4.7 | **Background**: PlantUML diagrams are rendered as `` tags pointing to an external PlantUML server. The image load is asynchronous and causes a layout shift. The previous implementation had no `data-growi-is-content-rendering` support, so layout shifts from PlantUML images were never compensated. **Implementation Notes** - New component at `apps/app/src/features/plantuml/components/PlantUmlViewer.tsx` - Wraps `` in a `
` container with `data-growi-is-content-rendering="true"` initially - Sets attribute to `"false"` via `onLoad` and `onError` handlers on the `` element - The plantuml remark plugin (`plantuml.ts`) is updated to output a custom `` HAST element instead of a plain ``. This allows the renderer to map the `plantuml` element to the `PlantUmlViewer` React component. - `sanitizeOption` is exported from the plantuml service and merged in `renderer.tsx` (same pattern as drawio and mermaid) - `PlantUmlViewer` is registered as `components.plantuml` in all view option generators (`generateViewOptions`, `generateSimpleViewOptions`, `generatePreviewOptions`) --- ### remark-lsx Modification #### Lsx (modification) | Field | Detail | |-------|--------| | Intent | Add rendering-status attribute lifecycle to async SWR page list fetching | | Requirements | 4.3, 4.4, 4.7 | **Implementation Notes** - Set `data-growi-is-content-rendering="true"` on the outermost container element while `isLoading === true` (SWR fetch in progress) - Set attribute to `"false"` when data arrives — whether success, error, or empty result - Use declarative attribute binding via the existing `isLoading` state (no imperative DOM manipulation needed) - File: `packages/remark-lsx/src/client/components/Lsx.tsx` - The lsx remark plugin sanitize options must be updated to include the new attribute name - `@growi/core` must be added as a dependency of `remark-lsx` (same pattern as `remark-drawio`) - **SWR cache hit behavior**: When SWR returns a cached result immediately (`isLoading=false` on first render), the attribute starts at `"false"` and no re-scroll is triggered. This is correct: a cached result means the list renders without a layout shift, so no compensation is needed. The re-scroll mechanism only activates when `isLoading` starts as `"true"` (no cache) and transitions to `"false"` after the fetch completes. --- ### SearchResultContent Integration #### SearchResultContent (modification) | Field | Detail | |-------|--------| | Intent | Integrate rendering-watch into SearchResultContent's keyword scroll so that layout shifts from async renderers are compensated | | Requirements | 5.1, 5.4, 5.5, 6.1 | **Background**: `SearchResultContent` renders page content inside a div with `overflow-y-scroll` (`#search-result-content-body-container`). The keyword-highlight scroll mechanism was originally inlined as a `useEffect` with no dependency array and no cleanup. **Post-Implementation Correction**: The initial design (tasks 6.1–6.3) attempted to integrate `useContentAutoScroll` (hash-based) into SearchResultContent. This was architecturally incorrect — search pages use `/search?q=foo` with no URL hash, so the hash-driven hook would never activate. See `research.md` "Post-Implementation Finding" for details. **Final Architecture**: The keyword scroll effect was extracted into a dedicated `useKeywordRescroll` hook (co-located with SearchResultContent), which directly integrates `watchRenderingAndReScroll` for rendering compensation. No hash-based scroll is used in SearchResultContent. **Hook Call Site** ```typescript useKeywordRescroll({ scrollElementRef, key: page._id }); ``` - `scrollElementRef` is the existing React ref pointing to the scroll container - `key: page._id` triggers re-execution when the selected page changes - The hook internally handles MutationObserver setup, debounced keyword scroll, rendering watch, and full cleanup **File**: `apps/app/src/features/search/client/components/SearchPage/SearchResultContent.tsx` --- ## Error Handling ### Error Strategy This feature operates entirely in the browser DOM layer with no server interaction. Errors are limited to DOM state mismatches. ### Error Categories and Responses **Target Not Found** (2.3): If the hash target never appears within 10s, the observer disconnects silently. No error is surfaced to the user — this matches browser-native behavior for invalid hash links. **Container Not Found** (1.5): If the container element ID does not resolve, the hook returns immediately with no side effects. **Rendering Watch Timeout** (3.6): After 10s, all observers and timers are cleaned up regardless of remaining rendering elements. This prevents resource leaks from components that fail to signal completion.