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.
SearchResultContent as a second consumer with container-relative scroll strategydata-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-upThe current implementation lives in apps/app/src/components/PageView/use-hash-auto-scroll.tsx, tightly coupled to PageView via:
document.getElementById(targetId) for target resolutionelement.scrollIntoView() for scroll executionpageId implying page-specific usageThe 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)Note: This diagram reflects the final architecture after Task 8 module reorganization. See "Task 8 Design" section below for the migration details.
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:
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 packageCo-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.
| 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.
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,<br/>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:
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.| 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 | — |
| 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 <img> 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 |
| 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
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 laterPageView.tsx — this hook is hash-navigation–specific (window.location.hash)Dependencies
watchRenderingAndReScroll from ~/client/util/watch-rendering-and-rescroll (P0)Contracts: Service [x]
/** 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;
window.location.hash availableImplementation Notes
apps/app/src/components/PageView/use-hash-auto-scroll.tsapps/app/src/components/PageView/use-hash-auto-scroll.spec.tsxresolveTarget and scrollTo callbacks should be wrapped in useRef to avoid re-triggering the effect when callback identity changes| 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
watchRenderingAndReScroll integration for async renderer layout shift compensationSearchResultContent.tsxDependencies
watchRenderingAndReScroll from ~/client/util/watch-rendering-and-rescroll (P0)scrollWithinContainer from ~/client/util/smooth-scroll (P0)Contracts: Service [x]
interface UseKeywordRescrollOptions {
/** Ref to the scrollable container element */
scrollElementRef: RefObject<HTMLElement | null>;
/** Unique key that triggers re-execution (typically page._id) */
key: string;
}
function useKeywordRescroll(options: UseKeywordRescrollOptions): void;
scrollElementRef.current is a mounted scroll containerImplementation Notes
apps/app/src/features/search/client/components/SearchPage/use-keyword-rescroll.tsapps/app/src/features/search/client/components/SearchPage/use-keyword-rescroll.spec.tsxscrollToKeyword, scrollToTargetWithinContainer) are defined in the hook file since only this hook uses them| 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
Dependencies
@growi/core rendering status constants — attribute selector (P0)Contracts: Service [x]
/**
* 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;
contentContainer is a mounted DOM elementImplementation Notes
apps/app/src/client/util/watch-rendering-and-rescroll.ts (co-located with smooth-scroll.ts)apps/app/src/client/util/watch-rendering-and-rescroll.spec.tsxstopped boolean flag checked inside timer callbacks to prevent race conditions between cleanup and queued timer executioncheckAndSchedule 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 completedchildList, subtree, and attributes (filtered to the rendering-status attribute) — the childList + subtree combination is what detects late-mounting async rendererscheckAndSchedule() 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.| 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]
/** 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;
packages/core/src/consts/renderer.ts (replaces existing 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| Field | Detail |
|---|---|
| Intent | Adopt declarative attribute value toggling instead of imperative add/remove |
| Requirements | 4.3, 4.4, 4.8 |
Implementation Notes
removeAttribute(GROWI_RENDERING_ATTR) calls with setAttribute(GROWI_IS_CONTENT_RENDERING_ATTR, 'false'){[GROWI_IS_CONTENT_RENDERING_ATTR]: 'true'} (unchanged pattern, new constant name)SUPPORTED_ATTRIBUTES in remark-drawio.ts to use new constant namedrawioContainerRef.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.| Field | Detail |
|---|---|
| Intent | Add rendering-status attribute lifecycle to async mermaid.render() SVG rendering |
| Requirements | 4.3, 4.4, 4.7 |
Implementation Notes
data-growi-is-content-rendering="true" on the container element at initial render (via JSX spread before mermaid.render() is called)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."false" immediately (without rAF) in the error/catch path, since no layout shift is expected on errorapps/app/src/features/mermaid/components/MermaidViewer.tsx| 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 <img> 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
apps/app/src/features/plantuml/components/PlantUmlViewer.tsx<img> in a <div> container with data-growi-is-content-rendering="true" initially"false" via onLoad and onError handlers on the <img> elementplantuml.ts) is updated to output a custom <plantuml src="..."> HAST element instead of a plain <img>. 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)| Field | Detail |
|---|---|
| Intent | Add rendering-status attribute lifecycle to async SWR page list fetching |
| Requirements | 4.3, 4.4, 4.7 |
Implementation Notes
data-growi-is-content-rendering="true" on the outermost container element while isLoading === true (SWR fetch in progress)"false" when data arrives — whether success, error, or empty resultisLoading state (no imperative DOM manipulation needed)packages/remark-lsx/src/client/components/Lsx.tsx@growi/core must be added as a dependency of remark-lsx (same pattern as remark-drawio)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.| 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
useKeywordRescroll({ scrollElementRef, key: page._id });
scrollElementRef is the existing React ref pointing to the scroll containerkey: page._id triggers re-execution when the selected page changesFile: apps/app/src/features/search/client/components/SearchPage/SearchResultContent.tsx
This feature operates entirely in the browser DOM layer with no server interaction. Errors are limited to DOM state mismatches.
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.