Просмотр исходного кода

Merge pull request #10853 from growilabs/fix/179942-auto-scroll-with-resize-observer

fix: re-scroll to hash target after lazy-rendered content completes
mergify[bot] 20 часов назад
Родитель
Сommit
8a86649666
38 измененных файлов с 2494 добавлено и 107 удалено
  1. 5 0
      .changeset/cyan-pants-hear.md
  2. 466 0
      .kiro/specs/auto-scroll/design.md
  3. 91 0
      .kiro/specs/auto-scroll/requirements.md
  4. 246 0
      .kiro/specs/auto-scroll/research.md
  5. 24 0
      .kiro/specs/auto-scroll/spec.json
  6. 154 0
      .kiro/specs/auto-scroll/tasks.md
  7. 6 0
      apps/app/src/client/services/renderer/renderer.tsx
  8. 201 0
      apps/app/src/client/util/watch-rendering-and-rescroll.spec.tsx
  9. 84 0
      apps/app/src/client/util/watch-rendering-and-rescroll.ts
  10. 4 45
      apps/app/src/components/PageView/PageView.tsx
  11. 329 0
      apps/app/src/components/PageView/use-hash-auto-scroll.spec.tsx
  12. 106 0
      apps/app/src/components/PageView/use-hash-auto-scroll.ts
  13. 133 0
      apps/app/src/features/mermaid/components/MermaidViewer.spec.tsx
  14. 23 1
      apps/app/src/features/mermaid/components/MermaidViewer.tsx
  15. 3 1
      apps/app/src/features/mermaid/services/mermaid.ts
  16. 53 0
      apps/app/src/features/plantuml/components/PlantUmlViewer.spec.tsx
  17. 31 0
      apps/app/src/features/plantuml/components/PlantUmlViewer.tsx
  18. 1 0
      apps/app/src/features/plantuml/components/index.ts
  19. 1 0
      apps/app/src/features/plantuml/index.ts
  20. 1 1
      apps/app/src/features/plantuml/services/index.ts
  21. 43 1
      apps/app/src/features/plantuml/services/plantuml.ts
  22. 126 0
      apps/app/src/features/search/client/components/SearchPage/SearchResultContent.spec.tsx
  23. 8 50
      apps/app/src/features/search/client/components/SearchPage/SearchResultContent.tsx
  24. 182 0
      apps/app/src/features/search/client/components/SearchPage/use-keyword-rescroll.spec.tsx
  25. 76 0
      apps/app/src/features/search/client/components/SearchPage/use-keyword-rescroll.ts
  26. 5 1
      biome.json
  27. 1 0
      packages/core/src/consts/index.ts
  28. 15 0
      packages/core/src/consts/renderer.ts
  29. 4 1
      packages/remark-attachment-refs/src/@types/declaration.d.ts
  30. 3 0
      packages/remark-drawio/package.json
  31. 5 0
      packages/remark-drawio/src/@types/declaration.d.ts
  32. 17 3
      packages/remark-drawio/src/components/DrawioViewer.tsx
  33. 9 1
      packages/remark-drawio/src/services/renderer/remark-drawio.ts
  34. 24 0
      packages/remark-drawio/turbo.json
  35. 4 1
      packages/remark-lsx/src/@types/declaration.d.ts
  36. 5 1
      packages/remark-lsx/src/client/components/Lsx.tsx
  37. 2 0
      packages/remark-lsx/src/client/services/renderer/lsx.ts
  38. 3 0
      pnpm-lock.yaml

+ 5 - 0
.changeset/cyan-pants-hear.md

@@ -0,0 +1,5 @@
+---
+'@growi/core': minor
+---
+
+Add const for content rendering

+ 466 - 0
.kiro/specs/auto-scroll/design.md

@@ -0,0 +1,466 @@
+# 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,<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:
+- 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 `<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 |
+
+### 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<HTMLElement | null>;
+  /** 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 `<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**
+- New component at `apps/app/src/features/plantuml/components/PlantUmlViewer.tsx`
+- Wraps `<img>` in a `<div>` container with `data-growi-is-content-rendering="true"` initially
+- Sets attribute to `"false"` via `onLoad` and `onError` handlers on the `<img>` element
+- The plantuml remark plugin (`plantuml.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`)
+
+---
+
+### 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.
+

+ 91 - 0
.kiro/specs/auto-scroll/requirements.md

@@ -0,0 +1,91 @@
+# Requirements Document
+
+## Introduction
+
+This specification defines the behavior of the **hash-based auto-scroll** mechanism used across GROWI's content pages. When a user navigates to a URL containing a fragment hash (e.g., `#section-title`), the system scrolls to the corresponding element in the rendered content. Because GROWI pages contain lazily-rendered elements (Drawio diagrams, Mermaid charts, etc.) that cause layout shifts after initial paint, the system must detect in-progress renders and re-scroll to compensate.
+
+This hook is designed to be **page-type agnostic** — it must work in any view that renders Markdown content with a hash-addressable container (PageView, search result previews, etc.).
+
+## Review Feedback (from yuki-takei, PR #10853)
+
+The following reviewer feedback is incorporated into these requirements:
+
+1. **Rendering attribute value**: Use declarative `true`/`false` toggling instead of `setAttribute`/`removeAttribute` — the attribute should always be present with a boolean-like value, not added/removed.
+2. **Attribute naming**: The attribute name should more clearly convey "rendering in progress" status. The name will be finalized in the design phase but must be more descriptive than `data-growi-rendering`.
+3. **Hook generalization**: Move to `src/client/hooks/` for shared use; accept a target-resolving closure instead of hardcoded `getElementById`; support customizable scroll behavior (e.g., `scrollIntoView` for PageView vs. a different method for SearchResultContent); rename the hook accordingly.
+
+## Requirements
+
+### Requirement 1: Immediate Scroll to Hash Target
+
+**Objective:** As a user, I want to be scrolled to the section referenced by the URL hash when I open a page, so that I can directly access the content I was linked to.
+
+#### Acceptance Criteria
+
+1. When the page loads with a URL hash and the target element already exists in the DOM, the hook shall scroll the target element into view immediately.
+2. When the page loads with a URL hash containing encoded characters (e.g., `%E6%97%A5%E6%9C%AC%E8%AA%9E`), the hook shall decode the hash and locate the corresponding element by its `id` attribute.
+3. If the key parameter is null or undefined, the hook shall skip all scroll processing.
+4. If the URL hash is empty, the hook shall skip all scroll processing.
+5. If the content container element is not found in the DOM, the hook shall skip all scroll processing.
+
+### Requirement 2: Deferred Scroll for Lazy-Rendered Targets
+
+**Objective:** As a user, I want the page to scroll to my target section even when the content is rendered after initial page load, so that dynamically rendered headings are still reachable via URL hash.
+
+#### Acceptance Criteria
+
+1. When the page loads with a URL hash and the target element does not yet exist in the DOM, the hook shall observe the content container for DOM mutations until the target appears.
+2. When the target element appears in the DOM during observation, the hook shall immediately scroll it into view.
+3. If the target element does not appear within the watch timeout period (default: 10 seconds), the hook shall stop observing and give up without error.
+
+### Requirement 3: Re-Scroll After Rendering Completion
+
+**Objective:** As a user, I want the view to re-adjust after lazy-rendered content (e.g., Drawio diagrams) finishes rendering, so that layout shifts do not push my target section out of view.
+
+#### Acceptance Criteria
+
+1. When an initial scroll completes and elements whose rendering-status attribute indicates "in progress" exist in the content container, the hook shall schedule a re-scroll after a poll interval (default: 5 seconds).
+2. While elements with in-progress rendering status remain in the container after a re-scroll, the hook shall repeat the poll-and-re-scroll cycle.
+3. When no elements with in-progress rendering status remain after a re-scroll check, the hook shall stop re-scrolling.
+4. When new elements with in-progress rendering status appear in the container (detected via MutationObserver), the hook shall schedule a re-scroll if one is not already pending.
+5. The hook shall not reset a running poll timer when additional DOM mutations occur — only schedule a new timer when no timer is active.
+6. The rendering watch shall automatically terminate after the watch timeout period (default: 10 seconds) regardless of remaining rendering elements.
+
+### Requirement 4: Rendering Status Attribute Protocol
+
+**Objective:** As a developer, I want a standardized attribute for components to signal their rendering status declaratively, so that the auto-scroll system can detect layout-shifting content generically.
+
+#### Acceptance Criteria
+
+1. The attribute name and its CSS selector for the "in progress" state shall be defined as shared constants in `@growi/core`.
+2. The attribute name shall clearly convey that rendering is in progress (e.g., more descriptive than a generic `data-growi-rendering`). The final name will be determined in the design phase.
+3. When a component begins rendering content that will change its dimensions (e.g., Drawio diagram initialization), the component shall set the attribute value to indicate "in progress" (e.g., `"true"`).
+4. When the component finishes rendering or encounters an error, the component shall set the attribute value to indicate "completed" (e.g., `"false"`) rather than removing the attribute entirely — the attribute lifecycle shall be declarative (value toggle), not imperative (add/remove).
+5. The attribute shall be included in the component's HTML sanitization allowlist so that it survives remark/rehype processing.
+6. The CSS selector used by the auto-scroll system shall match only the "in progress" state (e.g., `[attr="true"]`), not the completed state.
+7. The following async-rendering components shall adopt the attribute protocol in this scope: DrawioViewer, MermaidViewer, PlantUmlViewer (new wrapper component), and lsx (Lsx). Other async renderers (attachment-refs, RichAttachment) are deferred to follow-up work.
+8. When a component triggers a secondary re-render that will cause a layout shift (e.g., via ResizeObserver detecting container size changes after initial render), the component shall reset the attribute value to `"true"` before the re-render begins and allow the existing completion path to set it back to `"false"` when done. This ensures the auto-scroll system tracks all layout-shifting render cycles, not only the initial one.
+
+### Requirement 5: Page-Type Agnostic Design
+
+**Objective:** As a developer, I want the auto-scroll hook to be reusable across different page types (wiki pages, search results, etc.), so that hash-based scrolling behaves consistently throughout the application.
+
+#### Acceptance Criteria
+
+1. The hook shall accept a generic key parameter (not limited to page IDs) and a content container element ID as its inputs.
+2. The hook shall accept an optional target-resolving function (closure) that returns the target `HTMLElement | null`. When not provided, the hook shall default to resolving the target via `document.getElementById` using the decoded hash.
+3. The hook shall accept an optional scroll function that defines how to scroll to the target element. When not provided, the hook shall default to `element.scrollIntoView()`. This allows callers (e.g., SearchResultContent) to supply a custom scroll strategy.
+4. The hook shall not import or depend on any page-specific state (Jotai atoms, SWR hooks, or page models).
+5. The shared rendering-watch utility (`watchRenderingAndReScroll`) shall be located in a shared directory (e.g., `src/client/util/`). Each consumer-specific hook shall be co-located with its consumer component and named to reflect its purpose (e.g., hash-based scroll for PageView, keyword-based re-scroll for SearchResultContent).
+6. When the key parameter changes, the hook shall clean up any active observers and timers from the previous run and re-execute the scroll logic.
+7. When the component using the hook unmounts, the hook shall clean up all MutationObservers, timers, and rendering watch resources.
+
+### Requirement 6: Resource Cleanup and Safety
+
+**Objective:** As a developer, I want the hook to be safe against memory leaks and runaway timers, so that it can be used in any component lifecycle without side effects.
+
+#### Acceptance Criteria
+
+1. When the hook's effect cleanup runs, the hook shall disconnect all MutationObservers, clear all pending timers, and invoke any rendering watch cleanup functions.
+2. The hook shall enforce a maximum watch duration (default: 10 seconds) for both target observation and rendering watch, preventing indefinite resource consumption.
+3. While multiple elements with the rendering-status attribute (in-progress state) exist simultaneously, the hook shall execute only one re-scroll (not one per element).

+ 246 - 0
.kiro/specs/auto-scroll/research.md

@@ -0,0 +1,246 @@
+# Research & Design Decisions
+
+## Summary
+- **Feature**: `auto-scroll`
+- **Discovery Scope**: Extension (refactoring existing hook for reusability)
+- **Key Findings**:
+  - `src/client/hooks/` does not exist; hooks are collocated with features — a new shared hooks directory is needed
+  - SearchResultContent has independent scroll-to-highlighted-keyword logic using MutationObserver; coordination needed
+  - MermaidViewer does not implement the rendering attribute protocol; DrawioViewer is the only adopter
+
+## Research Log
+
+### Hook Location and Existing Patterns
+- **Context**: Requirement 5.5 specifies placing the hook in `src/client/hooks/`
+- **Findings**:
+  - `apps/app/src/client/hooks/` does not exist
+  - Existing hooks are collocated: `features/page-tree/hooks/`, `features/openai/client/components/.../hooks/`
+  - No precedent for a top-level shared hooks directory in `src/client/`
+- **Implications**: Creating `src/client/hooks/` establishes a new pattern for cross-feature hooks
+
+### SearchResultContent Scroll Behavior
+- **Context**: Requirement 5 mandates reusability for search result pages
+- **Sources**: `apps/app/src/features/search/client/components/SearchPage/SearchResultContent.tsx`
+- **Findings**:
+  - Container ID: `search-result-content-body-container`
+  - Container has `overflow-y-scroll` — is the scroll unit, not the viewport
+  - Uses MutationObserver to find `.highlighted-keyword` elements and scroll to the first one using `scrollWithinContainer`
+  - Debounced at 500ms; `SCROLL_OFFSET_TOP = 30`
+  - Does NOT use URL hash — scrolls to highlighted search terms
+  - `useEffect` has no dependency array (fires on every render); no cleanup (intentional per inline comment)
+- **Implications (updated)**:
+  - `scrollIntoView()` default is inappropriate; custom `scrollTo` using `scrollWithinContainer` is required
+  - When `window.location.hash` is non-empty, the keyword scroll overrides hash scroll after 500ms debounce — must be suppressed via early return guard
+  - The `resolveTarget` default (`document.getElementById`) works correctly; heading `id` attributes are set by the remark pipeline
+
+### DrawioViewer Rendering Attribute Pattern
+- **Context**: Requirement 4.4 mandates declarative true/false toggling
+- **Sources**: `packages/remark-drawio/src/components/DrawioViewer.tsx`
+- **Findings**:
+  - Initial render: `{[GROWI_RENDERING_ATTR]: 'true'}` in JSX spread (line 188)
+  - On error: `removeAttribute(GROWI_RENDERING_ATTR)` (line 131)
+  - On complete: `removeAttribute(GROWI_RENDERING_ATTR)` (line 148)
+  - This is imperative add/remove, not declarative value toggle
+- **Implications**: Needs refactoring to `setAttribute(attr, 'false')` on completion/error instead of `removeAttribute`
+
+### MermaidViewer Status
+- **Context**: Could benefit from rendering attribute protocol
+- **Sources**: `apps/app/src/features/mermaid/components/MermaidViewer.tsx`
+- **Findings**:
+  - Does NOT use `GROWI_RENDERING_ATTR`
+  - Uses `mermaid.render()` async with direct `innerHTML` assignment
+  - Mermaid sanitize options only allow `value` attribute
+- **Implications**: Adding Mermaid support is a separate task, not in scope for this spec, but the design should be compatible
+
+### Rendering Attribute Naming
+- **Context**: Reviewer feedback requests a more descriptive name
+- **Findings**:
+  - Current: `data-growi-rendering` — ambiguous (rendering what?)
+  - Candidates considered:
+    - `data-growi-is-rendering-in-progress` — explicit but verbose
+    - `data-growi-rendering-status` — implies multiple states
+    - `data-growi-content-rendering` — slightly more specific
+  - With declarative true/false, a boolean-style name like `data-growi-is-content-rendering` works well
+- **Implications**: Selected `data-growi-is-content-rendering` — clearly a boolean predicate, reads naturally as `is-content-rendering="true"/"false"`
+
+## Architecture Pattern Evaluation
+
+| Option | Description | Strengths | Risks / Limitations | Notes |
+|--------|-------------|-----------|---------------------|-------|
+| Custom hook with options object | Single hook with configurable resolveTarget and scrollTo callbacks | Clean API, single import, testable | Options object may grow over time | Selected approach |
+| Separate hooks per page type | usePageHashScroll, useSearchScroll | Type-specific optimization | Duplicated watch/cleanup logic | Rejected — violates DRY |
+| HOC wrapper | Higher-order component wrapping scroll behavior | Framework-agnostic | Harder to compose, less idiomatic React | Rejected — hooks are idiomatic |
+
+## Design Decisions
+
+### Decision: Hook API Shape
+- **Context**: Hook must support PageView (hash-based) and SearchResultContent (keyword-based) with different scroll strategies
+- **Alternatives Considered**:
+  1. Positional parameters — `useAutoScroll(key, containerId, resolveTarget?, scrollFn?)`
+  2. Options object — `useAutoScroll(options)`
+- **Selected Approach**: Options object with required `key` and `contentContainerId`, optional `resolveTarget` and `scrollTo`
+- **Rationale**: Options object is extensible without breaking existing call sites and self-documents parameter intent
+- **Trade-offs**: Slightly more verbose at call site; mitigated by clear defaults
+
+### Decision: Attribute Name
+- **Context**: Reviewer feedback: name should clearly convey "rendering in progress"
+- **Selected Approach**: `data-growi-is-content-rendering` with values `"true"` / `"false"`
+- **Rationale**: Boolean predicate naming (`is-*`) is natural for a two-state attribute; `content-rendering` disambiguates from other rendering concepts
+- **Follow-up**: Update `@growi/core` constant and all consumers
+
+### Decision: CSS Selector for In-Progress State
+- **Context**: Requirement 4.6 — selector must match only in-progress state
+- **Selected Approach**: `[data-growi-is-content-rendering="true"]` instead of bare attribute selector
+- **Rationale**: With declarative true/false toggling, bare `[attr]` matches both states; value selector is required
+
+## Risks & Mitigations
+- **Risk**: SearchResultContent's existing keyword-highlight scroll may conflict with hash-based scroll — **Mitigation**: Guard the keyword-scroll `useEffect` with `if (window.location.hash.length > 0) return;` so hash scroll takes priority when a hash is present; keyword scroll proceeds unchanged otherwise
+- **Risk**: `scrollIntoView()` default scrolls the viewport when SearchResultContent's container has `overflow-y-scroll` — **Mitigation**: Provide a custom `scrollTo` closure using `scrollWithinContainer` with offset from the container's bounding rect
+- **Risk**: Renaming the attribute requires coordinated changes across `@growi/core`, `remark-drawio`, and consuming apps — **Mitigation**: Constants are centralized; single constant rename propagates via imports
+- **Risk**: MutationObserver on `subtree: true` may be expensive on large pages — **Mitigation**: Retained 10s maximum watch timeout from current implementation
+
+## Post-Implementation Finding: SearchResultContent Integration Misalignment
+
+**Discovered after task 6 implementation** during code review conversation.
+
+### Problem
+
+The task 6 implementation added `useContentAutoScroll` to `SearchResultContent`, but this was architecturally incorrect. `useContentAutoScroll` is URL-hash–driven (`if (hash.length === 0) return`) and will never activate in the search results context — the search page URL (`/search?q=foo`) carries no fragment identifier.
+
+### Actual Requirement
+
+The real requirement for SearchResultContent is:
+1. **Keyword scroll** (already working): scroll to the first `.highlighted-keyword` element when content loads, via MutationObserver + 500ms debounce
+2. **Re-scroll after rendering** (missing): when drawio / mermaid diagrams render asynchronously after the initial keyword scroll, the layout shifts and the keyword moves out of view — `watchRenderingAndReScroll` should re-scroll to the keyword once rendering settles
+
+### Current Code State (as of this writing)
+
+`apps/app/src/features/search/client/components/SearchPage/SearchResultContent.tsx` contains:
+- `useContentAutoScroll(...)` call — **should be removed**
+- keyword scroll `useEffect` with hash guard (`if (window.location.hash.length > 0) return`) — the guard may also be removable depending on how the hook is refactored
+- `scrollToTargetWithinContainer` local helper (shared distance calculation) — **keep**
+
+### Proposed Refactoring Direction
+
+Two-phase refactor, designed for the next session:
+
+**Phase 1 — Immediate fix (SearchResultContent)**
+
+Wire `watchRenderingAndReScroll` directly into the keyword scroll `useEffect`:
+
+```typescript
+useEffect(() => {
+  const scrollElement = scrollElementRef.current;
+  if (scrollElement == null) return;
+
+  const scrollToKeyword = (): boolean => {
+    const toElem = scrollElement.querySelector('.highlighted-keyword') as HTMLElement | null;
+    if (toElem == null) return false;
+    scrollToTargetWithinContainer(toElem, scrollElement);
+    return true;
+  };
+
+  // MutationObserver for incremental content loading (debounced)
+  const observer = new MutationObserver(() => {
+    scrollToFirstHighlightedKeywordDebounced(scrollElement);
+  });
+  observer.observe(scrollElement, MUTATION_OBSERVER_CONFIG);
+
+  // Rendering watch: re-scroll after drawio/mermaid layout shifts
+  const cleanupWatch = watchRenderingAndReScroll(scrollElement, scrollToKeyword);
+  return cleanupWatch;
+}, [page._id]);
+```
+
+Remove the `useContentAutoScroll` import and call entirely.
+
+**Phase 2 — Architecture improvement (shared hook)**
+
+Reorganize the relationship between `useContentAutoScroll` and `watchRenderingAndReScroll`:
+
+- `watchRenderingAndReScroll` (pure function) is the core shared primitive — **promote it to a named export** so callers other than `useContentAutoScroll` can use it directly
+- Consider introducing a thin React wrapper hook `useRenderingRescroll(scrollToTarget, deps)` that manages the `useEffect` lifecycle for `watchRenderingAndReScroll`, making it composable
+- `useContentAutoScroll` becomes the **hash-navigation–specific** hook: hash guard → target resolution → initial scroll → delegates to `useRenderingRescroll`
+- `SearchResultContent` keyword scroll becomes: MO-debounce → initial scroll → delegates to `useRenderingRescroll`
+- PageView-specific logic (default `scrollIntoView`, `getElementById` resolver) stays in PageView or in `useContentAutoScroll`
+
+Resulting dependency graph:
+
+```
+useContentAutoScroll  ─┐
+                        ├── useRenderingRescroll ── watchRenderingAndReScroll
+SearchResultContent   ─┘
+```
+
+### Key Questions for Next Session Design
+
+1. Should `useRenderingRescroll` be a hook (managing `useEffect` internally) or should callers be responsible for calling it inside their own effect? A hook is more ergonomic; a plain function is more flexible.
+2. The current keyword-scroll `useEffect` has no dependency array (fires every render) and no cleanup — intentional per inline comment. Adding `[page._id]` deps and a cleanup changes this behavior. Is that safe?
+3. Should the hash guard on the keyword-scroll `useEffect` be removed once `useContentAutoScroll` is also removed from `SearchResultContent`?
+
+## Task 8 Analysis: useRenderingRescroll Hook Extraction
+
+### Investigation (2026-04-06)
+
+**Objective**: Determine whether extracting a shared `useRenderingRescroll` hook is architecturally beneficial after tasks 1–7 completion.
+
+**Method**: Code review of current implementations — `useContentAutoScroll` (108 lines), `watchRenderingAndReScroll` (85 lines), `SearchResultContent` keyword-scroll effect (lines 133–161).
+
+### Findings
+
+**1. Hook extraction is architecturally infeasible for `useContentAutoScroll`**
+
+`useContentAutoScroll` calls `watchRenderingAndReScroll` conditionally inside its `useEffect`:
+- On the immediate path: only after `scrollToTarget()` returns true (line 77)
+- On the deferred path: only after the MutationObserver detects the target element (line 91)
+
+React hooks cannot be called conditionally or inside callbacks. A `useRenderingRescroll` hook would need an "enabled" flag pattern, adding complexity without simplification.
+
+**2. Co-located cleanup in SearchResultContent prevents separation**
+
+The keyword-scroll `useEffect` in `SearchResultContent` (lines 135–160) combines:
+- MutationObserver for keyword highlight detection
+- `watchRenderingAndReScroll` for async renderer compensation
+- Single cleanup return that handles both
+
+Extracting the watch into a separate hook would split cleanup across two effects, making the lifecycle harder to reason about.
+
+**3. All three design questions from the original research are resolved**
+
+| Question | Resolution | How |
+|----------|------------|-----|
+| Hook vs. function | Plain function | Conditional call inside effect prevents hook usage |
+| `[page._id]` deps + cleanup safe? | Yes, safe | Implemented in task 7.1, working correctly |
+| Hash guard removal | Already done | Removed in task 7.1 alongside `useContentAutoScroll` removal |
+
+**4. Current architecture is already optimal**
+
+`watchRenderingAndReScroll` as a plain function returning a cleanup closure is the correct abstraction level:
+- Composable into any `useEffect` (conditional or unconditional)
+- No React runtime coupling (testable without `renderHook`)
+- Clean dependency graph with two independent consumers
+
+### Initial Recommendation (superseded)
+
+Initially recommended closing Task 8 without code changes. However, after discussion the scope was revised from "hook extraction" to "module reorganization" — see below.
+
+### Revised Direction: Module Reorganization (2026-04-06)
+
+**Context**: The user observed that while a shared `useRenderingRescroll` hook adds no value (confirmed by analysis above), the current file layout is inconsistent:
+
+1. `useContentAutoScroll` lives in `src/client/hooks/` (shared) but is PageView-specific (hash-dependent)
+2. `watchRenderingAndReScroll` lives next to that hook as if internal, but is the actual shared primitive
+3. SearchResultContent's scroll logic is inlined rather than extracted
+
+**Revised approach**:
+- Move `watchRenderingAndReScroll` to `src/client/util/` — co-located with `smooth-scroll.ts` (both are DOM scroll utilities)
+- Rename `useContentAutoScroll` → `useHashAutoScroll` and move next to `PageView.tsx`
+- Extract keyword-scroll effect from `SearchResultContent` into co-located `useKeywordRescroll` hook
+- Delete `src/client/hooks/use-content-auto-scroll/` directory
+
+**Rationale**: Module co-location over shared directory. Each hook lives next to its only consumer. Only the truly shared primitive (`watchRenderingAndReScroll`) stays in a shared directory — and it moves from `hooks/` to `util/` since it's a plain function, not a hook.
+
+## References
+- [MutationObserver API](https://developer.mozilla.org/en-US/docs/Web/API/MutationObserver) — core browser API used for DOM observation
+- [Element.scrollIntoView()](https://developer.mozilla.org/en-US/docs/Web/API/Element/scrollIntoView) — default scroll behavior
+- PR #10853 reviewer feedback from yuki-takei — driving force for this refactoring

+ 24 - 0
.kiro/specs/auto-scroll/spec.json

@@ -0,0 +1,24 @@
+{
+  "feature_name": "auto-scroll",
+  "created_at": "2026-04-02T00:00:00.000Z",
+  "updated_at": "2026-04-07T12:00:00.000Z",
+  "cleanup_completed": true,
+  "language": "en",
+  "phase": "implementation-complete",
+  "approvals": {
+    "requirements": {
+      "generated": true,
+      "approved": true
+    },
+    "design": {
+      "generated": true,
+      "approved": true
+    },
+    "tasks": {
+      "generated": true,
+      "approved": true,
+      "notes": "All tasks (1–8) complete. Design updated to reflect final architecture after module reorganization."
+    }
+  },
+  "ready_for_implementation": true
+}

+ 154 - 0
.kiro/specs/auto-scroll/tasks.md

@@ -0,0 +1,154 @@
+# Implementation Plan
+
+- [x] 1. Update rendering status constants in @growi/core
+  - Rename the attribute constant from the current name to `data-growi-is-content-rendering` to clearly convey boolean rendering-in-progress semantics
+  - Update the CSS selector constant to match only the in-progress state (`="true"`) rather than bare attribute presence
+  - Remove the old constants — no backward-compatibility aliases since all consumers are updated in the same change
+  - _Requirements: 4.1, 4.2, 4.6_
+
+- [x] 2. Update remark-drawio for declarative rendering attribute protocol
+- [x] 2.1 (P) Adopt declarative value toggling in DrawioViewer component
+  - Change rendering-complete and error paths to set the attribute value to `"false"` instead of removing the attribute entirely
+  - Update the initial JSX spread to use the renamed constant while keeping `"true"` as the initial value
+  - Verify that the wrapper component (DrawioViewerWithEditButton) continues to function without changes
+  - In the ResizeObserver handler, set `attr="true"` before `renderDrawioWithDebounce()` to signal re-render cycles to the auto-scroll system (req 4.8)
+  - _Requirements: 4.3, 4.4, 4.8_
+- [x] 2.2 (P) Update remark-drawio plugin sanitization and node rewriting
+  - Replace the old constant in the supported-attributes array with the new constant name
+  - Update node rewriting to set the new attribute name with `"true"` value on drawio nodes
+  - Confirm the sanitize export still passes the new attribute through HTML sanitization
+  - _Requirements: 4.5_
+
+- [x] 3. Add rendering attribute to MermaidViewer and Lsx
+- [x] 3.1 (P) Add rendering-status attribute lifecycle to MermaidViewer
+  - Set the rendering-status attribute to `"true"` on the container element at initial render before the async SVG render starts
+  - Set the attribute to `"false"` after `mermaid.render()` completes and the SVG is injected into the DOM
+  - Set the attribute to `"false"` in the error/catch path as well
+  - Update the mermaid remark plugin sanitize options to include the new attribute name in the allowlist
+  - _Requirements: 4.3, 4.4, 4.5, 4.7_
+- [x] 3.2 (P) Add rendering-status attribute lifecycle to Lsx component
+  - Set the rendering-status attribute to `"true"` on the outermost container while the SWR page list fetch is loading
+  - Set the attribute to `"false"` when data arrives — success, error, or empty result — using declarative binding from the existing `isLoading` state
+  - Update the lsx remark plugin sanitize options to include the new attribute name in the allowlist
+  - Add `@growi/core` as a dependency of `remark-lsx` (same pattern as `remark-drawio`)
+  - _Requirements: 4.3, 4.4, 4.5, 4.7_
+
+- [x] 4. Implement shared auto-scroll hook
+- [x] 4.1 Implement rendering watch function with safety improvements
+  - Create the `watchRenderingAndReScroll` function in the new shared hooks directory using the updated rendering-status selector
+  - Add a `stopped` boolean flag checked inside timer callbacks to prevent execution after cleanup (race condition fix from PR review)
+  - Maintain the existing non-resetting timer pattern: skip scheduling when a timer is already active
+  - When `checkAndSchedule` detects no rendering elements remain while a timer is still active, cancel the active timer immediately to avoid a redundant re-scroll after rendering has completed
+  - Enforce the 10-second hard timeout that cleans up observer and all timers regardless of remaining rendering elements
+  - _Requirements: 3.1, 3.2, 3.3, 3.4, 3.5, 3.6, 6.1, 6.2, 6.3_
+  - _Contracts: watchRenderingAndReScroll service interface_
+- [x] 4.2 Implement useContentAutoScroll hook with options object API
+  - Create the hook accepting an options object with `key`, `contentContainerId`, optional `resolveTarget`, and optional `scrollTo`
+  - Implement guard logic: skip processing when key is null/undefined, hash is empty, or container element not found
+  - Implement immediate scroll path: resolve target via provided closure (default: `getElementById`), scroll via provided function (default: `scrollIntoView`), then check for rendering elements before delegating to rendering watch — skip watch entirely if no rendering elements exist
+  - Implement deferred scroll path: MutationObserver on container until target appears, then scroll and conditionally delegate to rendering watch (same check), with 10-second timeout
+  - Store `resolveTarget` and `scrollTo` callbacks in refs to avoid re-triggering the effect on callback identity changes
+  - Wire cleanup to disconnect all observers, clear all timers, and invoke rendering watch cleanup
+  - _Requirements: 1.1, 1.2, 1.3, 1.4, 1.5, 2.1, 2.2, 2.3, 5.1, 5.2, 5.3, 5.4, 5.6, 5.7, 6.1, 6.2_
+  - _Contracts: UseContentAutoScrollOptions, useContentAutoScroll service interface_
+- [x] 4.3 (P) Write tests for watchRenderingAndReScroll
+  - Test that no timer is scheduled when no rendering elements exist
+  - Test that a re-scroll fires after the 5-second poll interval when rendering elements are present
+  - Test that the timer is not reset by intermediate DOM mutations
+  - Test that late-appearing rendering elements are detected by the observer and trigger a timer
+  - Test that only one re-scroll executes per cycle even with multiple rendering elements
+  - Test that the 10-second watch timeout cleans up all resources
+  - Test that the stopped flag prevents timer callbacks from executing after cleanup
+  - Test that an active timer is cancelled when rendering elements are removed before the timer fires
+  - _Requirements: 3.1, 3.2, 3.3, 3.4, 3.5, 3.6, 6.1, 6.2, 6.3_
+- [x] 4.4 (P) Write tests for useContentAutoScroll
+  - Test guard conditions: no-op when key is null, hash is empty, or container not found
+  - Test immediate scroll when target already exists in DOM
+  - Test deferred scroll when target appears after initial render via MutationObserver
+  - Test that encoded hash values are decoded correctly before target resolution
+  - Test that a custom `resolveTarget` closure is called instead of the default
+  - Test that a custom `scrollTo` function is called instead of the default
+  - Test cleanup on key change: observers and timers from previous run are released
+  - Test cleanup on unmount: all resources are released
+  - Test rendering watch integration: re-scroll fires when rendering elements exist after initial scroll
+  - Test that rendering watch is skipped when no rendering elements exist after initial scroll
+  - Test 10-second timeout for target observation when target never appears
+  - _Requirements: 1.1, 1.2, 1.3, 1.4, 1.5, 2.1, 2.2, 2.3, 5.1, 5.2, 5.3, 5.6, 5.7, 6.1, 6.2_
+
+- [x] 5. Integrate hook into PageView and remove old implementation
+  - Replace the import of the old hook with the new shared hook in PageView
+  - Update the call site to use the options object API with `key: currentPageId` and `contentContainerId` — no custom `resolveTarget` or `scrollTo` needed (defaults match PageView's behavior)
+  - Delete the old hook file and its test file from the PageView directory
+  - Verify that PageView auto-scroll behavior is preserved with manual testing or existing test coverage
+  - _Requirements: 5.1, 5.4, 5.5_
+
+- [x] 6. Integrate useContentAutoScroll into SearchResultContent
+- [x] 6.1 (P) Add hash-based auto-scroll with container-relative scroll strategy
+  - Call `useContentAutoScroll` with `key: page._id` and `contentContainerId: 'search-result-content-body-container'`
+  - Provide a custom `scrollTo` closure that calculates the target element's offset relative to the container's bounding rect and calls `scrollWithinContainer` with the same `SCROLL_OFFSET_TOP` constant already used for keyword scroll
+  - Capture the container via the existing `scrollElementRef` in the closure to avoid a redundant `getElementById` lookup
+  - Do not provide a custom `resolveTarget` — heading elements have `id` attributes set by the remark pipeline, so the default `getElementById` resolver works correctly
+  - _Requirements: 5.1, 5.2, 5.3, 5.5_
+
+- [x] 6.2 (P) Suppress keyword-highlight scroll when a URL hash is present
+  - Add an early return guard at the top of the existing keyword-scroll `useEffect`: if `window.location.hash` is non-empty, return immediately so hash-based scroll is not overridden by the debounced keyword scroll
+  - Preserve the existing keyword-scroll behavior fully when no hash is present — the MutationObserver, debounce interval, and `scrollWithinContainer` call remain unchanged
+  - _Requirements: 5.1, 5.5_
+
+- [x] 6.3 Write tests for SearchResultContent auto-scroll integration
+  - Test that `useContentAutoScroll` is called with the correct `key` and `contentContainerId` when the component mounts
+  - Test that the custom `scrollTo` scrolls within the container (not the viewport) by verifying `scrollWithinContainer` is called with the correct distance
+  - Test that the keyword-scroll `useEffect` skips observation when `window.location.hash` is non-empty
+  - Test that the keyword-scroll `useEffect` sets up the MutationObserver normally when no hash is present
+  - _Requirements: 5.1, 5.2, 5.3, 5.5_
+
+---
+
+## Phase 2: Module Reorganization
+
+> **Context**: Tasks 1–7 delivered all functional requirements. Task 8 reorganizes modules for co-location: each hook moves next to its consumer, and the shared rendering watch utility moves to `src/client/util/`. No behavior changes — pure structural improvement.
+
+- [x] 7. Fix SearchResultContent: replace `useContentAutoScroll` with `watchRenderingAndReScroll`
+- [x] 7.1 Wire `watchRenderingAndReScroll` into keyword-scroll effect
+  - Remove `useContentAutoScroll` import and call from `SearchResultContent.tsx`
+  - Import `watchRenderingAndReScroll` (already exported from `watch-rendering-and-rescroll.ts`)
+  - Inside the keyword-scroll `useEffect`, after setting up the MutationObserver, call `watchRenderingAndReScroll(scrollElement, scrollToKeyword)` where `scrollToKeyword` calls `scrollToTargetWithinContainer` on the first `.highlighted-keyword` element
+  - Add `[page._id]` to the dependency array (currently has no deps) and return the watch cleanup function
+  - Remove the hash guard (`if (window.location.hash.length > 0) return`) — no longer needed once `useContentAutoScroll` is removed
+  - _See research.md for proposed code sketch_
+
+- [x] 7.2 Update SearchResultContent tests
+  - Remove tests that assert `useContentAutoScroll` is called
+  - Add tests that `watchRenderingAndReScroll` re-scrolls to `.highlighted-keyword` after a rendering element settles
+  - Update MutationObserver suppression test: remove the hash-guard test (guard will be gone)
+
+- [x] 8. Reorganize auto-scroll modules by co-locating hooks with their consumers
+- [x] 8.1 Move the rendering watch utility to the shared utility directory
+  - Move the rendering watch function and its test file from the shared hooks directory to the client utility directory, alongside the existing smooth-scroll utility
+  - Update the import path in the hash-based auto-scroll hook to reference the new location
+  - Update the import path in SearchResultContent to reference the new location
+  - Run existing tests to verify no regressions from the path change
+  - _Requirements: 5.4, 5.5_
+- [x] 8.2 Rename and move the hash-based auto-scroll hook next to PageView
+  - Rename the hook and its options type to reflect its hash-navigation–specific purpose (not a generic "content auto-scroll")
+  - Move the hook file and its test file to the PageView component directory
+  - Update PageView's import to use the co-located hook with the new name
+  - Update the hook's internal import of the rendering watch utility to use the path established in 8.1
+  - Run existing tests to verify the rename and move introduce no regressions
+  - _Requirements: 5.4, 5.5_
+- [x] 8.3 Extract the keyword-scroll effect from SearchResultContent into a co-located hook
+  - Create a new hook that encapsulates the MutationObserver-based keyword detection, debounced scroll, and rendering watch integration currently inlined in the component
+  - Accept a ref to the scrollable container and a trigger key as inputs
+  - Move the scroll helper functions (container-relative scroll calculation, first-highlighted-keyword lookup) into the hook file if they are used only by this logic
+  - Replace the inline useEffect in SearchResultContent with a single call to the new hook
+  - _Requirements: 5.4, 5.5, 6.1_
+- [x] 8.4 (P) Write tests for the extracted keyword-rescroll hook
+  - Migrate rendering watch assertions from SearchResultContent tests into the new hook's test file
+  - Add tests for keyword scroll behavior: MutationObserver setup, debounced scroll to the first highlighted keyword, cleanup on key change and unmount
+  - Simplify SearchResultContent tests to verify the hook is called with the correct container ref and key, without re-testing internal scroll behavior
+  - _Requirements: 6.1, 6.2_
+- [x] 8.5 (P) Remove the old shared hooks directory and verify no stale imports
+  - Delete the now-empty auto-scroll hooks directory
+  - Search the codebase for any remaining references to the old directory path and fix them
+  - Run the full test suite and type check to confirm the reorganization is complete
+  - _Requirements: 5.5_

+ 6 - 0
apps/app/src/client/services/renderer/renderer.tsx

@@ -95,6 +95,7 @@ export const generateViewOptions = (
             presentation.sanitizeOption,
             presentation.sanitizeOption,
             drawio.sanitizeOption,
             drawio.sanitizeOption,
             mermaidSanitizeOption,
             mermaidSanitizeOption,
+            plantuml.sanitizeOption,
             callout.sanitizeOption,
             callout.sanitizeOption,
             attachment.sanitizeOption,
             attachment.sanitizeOption,
             lsxGrowiDirective.sanitizeOption,
             lsxGrowiDirective.sanitizeOption,
@@ -132,6 +133,7 @@ export const generateViewOptions = (
     components.refsimg = refsGrowiDirective.RefsImg;
     components.refsimg = refsGrowiDirective.RefsImg;
     components.gallery = refsGrowiDirective.Gallery;
     components.gallery = refsGrowiDirective.Gallery;
     components.drawio = DrawioViewerWithEditButton;
     components.drawio = DrawioViewerWithEditButton;
+    components.plantuml = plantuml.PlantUmlViewer;
     components.table = TableWithEditButton;
     components.table = TableWithEditButton;
     components.mermaid = MermaidViewer;
     components.mermaid = MermaidViewer;
     components.callout = callout.CalloutViewer;
     components.callout = callout.CalloutViewer;
@@ -220,6 +222,7 @@ export const generateSimpleViewOptions = (
             presentation.sanitizeOption,
             presentation.sanitizeOption,
             drawio.sanitizeOption,
             drawio.sanitizeOption,
             mermaidSanitizeOption,
             mermaidSanitizeOption,
+            plantuml.sanitizeOption,
             callout.sanitizeOption,
             callout.sanitizeOption,
             attachment.sanitizeOption,
             attachment.sanitizeOption,
             lsxGrowiDirective.sanitizeOption,
             lsxGrowiDirective.sanitizeOption,
@@ -250,6 +253,7 @@ export const generateSimpleViewOptions = (
     components.refsimg = refsGrowiDirective.RefsImgImmutable;
     components.refsimg = refsGrowiDirective.RefsImgImmutable;
     components.gallery = refsGrowiDirective.GalleryImmutable;
     components.gallery = refsGrowiDirective.GalleryImmutable;
     components.drawio = drawio.DrawioViewer;
     components.drawio = drawio.DrawioViewer;
+    components.plantuml = plantuml.PlantUmlViewer;
     components.mermaid = MermaidViewer;
     components.mermaid = MermaidViewer;
     components.callout = callout.CalloutViewer;
     components.callout = callout.CalloutViewer;
     components.attachment = RichAttachment;
     components.attachment = RichAttachment;
@@ -321,6 +325,7 @@ export const generatePreviewOptions = (
             getCommonSanitizeOption(config),
             getCommonSanitizeOption(config),
             drawio.sanitizeOption,
             drawio.sanitizeOption,
             mermaidSanitizeOption,
             mermaidSanitizeOption,
+            plantuml.sanitizeOption,
             callout.sanitizeOption,
             callout.sanitizeOption,
             attachment.sanitizeOption,
             attachment.sanitizeOption,
             lsxGrowiDirective.sanitizeOption,
             lsxGrowiDirective.sanitizeOption,
@@ -352,6 +357,7 @@ export const generatePreviewOptions = (
     components.refsimg = refsGrowiDirective.RefsImgImmutable;
     components.refsimg = refsGrowiDirective.RefsImgImmutable;
     components.gallery = refsGrowiDirective.GalleryImmutable;
     components.gallery = refsGrowiDirective.GalleryImmutable;
     components.drawio = drawio.DrawioViewer;
     components.drawio = drawio.DrawioViewer;
+    components.plantuml = plantuml.PlantUmlViewer;
     components.mermaid = MermaidViewer;
     components.mermaid = MermaidViewer;
     components.callout = callout.CalloutViewer;
     components.callout = callout.CalloutViewer;
     components.attachment = RichAttachment;
     components.attachment = RichAttachment;

+ 201 - 0
apps/app/src/client/util/watch-rendering-and-rescroll.spec.tsx

@@ -0,0 +1,201 @@
+import { GROWI_IS_CONTENT_RENDERING_ATTR } from '@growi/core/dist/consts';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+import { watchRenderingAndReScroll } from './watch-rendering-and-rescroll';
+
+describe('watchRenderingAndReScroll', () => {
+  let container: HTMLDivElement;
+  let scrollToTarget: ReturnType<typeof vi.fn>;
+
+  beforeEach(() => {
+    vi.useFakeTimers();
+    container = document.createElement('div');
+    document.body.appendChild(container);
+    scrollToTarget = vi.fn(() => true);
+  });
+
+  afterEach(() => {
+    vi.useRealTimers();
+    document.body.innerHTML = '';
+  });
+
+  it('should not schedule a timer when no rendering elements exist', () => {
+    const cleanup = watchRenderingAndReScroll(container, scrollToTarget);
+
+    vi.advanceTimersByTime(5000);
+    expect(scrollToTarget).not.toHaveBeenCalled();
+
+    cleanup();
+  });
+
+  it('should schedule a scroll after 5s when rendering elements exist', () => {
+    const renderingEl = document.createElement('div');
+    renderingEl.setAttribute(GROWI_IS_CONTENT_RENDERING_ATTR, 'true');
+    container.appendChild(renderingEl);
+
+    const cleanup = watchRenderingAndReScroll(container, scrollToTarget);
+
+    expect(scrollToTarget).not.toHaveBeenCalled();
+
+    vi.advanceTimersByTime(5000);
+    expect(scrollToTarget).toHaveBeenCalledTimes(1);
+
+    cleanup();
+  });
+
+  it('should not reset timer on intermediate DOM mutations', async () => {
+    const renderingEl = document.createElement('div');
+    renderingEl.setAttribute(GROWI_IS_CONTENT_RENDERING_ATTR, 'true');
+    container.appendChild(renderingEl);
+
+    const cleanup = watchRenderingAndReScroll(container, scrollToTarget);
+
+    vi.advanceTimersByTime(3000);
+    expect(scrollToTarget).not.toHaveBeenCalled();
+
+    // Trigger a DOM mutation mid-timer
+    const child = document.createElement('span');
+    container.appendChild(child);
+    await vi.advanceTimersByTimeAsync(0);
+
+    // The timer should NOT have been reset — 2 more seconds should fire it
+    vi.advanceTimersByTime(2000);
+    expect(scrollToTarget).toHaveBeenCalledTimes(1);
+
+    cleanup();
+  });
+
+  it('should detect rendering elements added after initial check via observer', async () => {
+    const cleanup = watchRenderingAndReScroll(container, scrollToTarget);
+
+    vi.advanceTimersByTime(3000);
+    expect(scrollToTarget).not.toHaveBeenCalled();
+
+    // Add a rendering element later (within 10s timeout)
+    const renderingEl = document.createElement('div');
+    renderingEl.setAttribute(GROWI_IS_CONTENT_RENDERING_ATTR, 'true');
+    container.appendChild(renderingEl);
+
+    // Flush microtasks so MutationObserver callback fires
+    await vi.advanceTimersByTimeAsync(0);
+
+    // Timer should be scheduled — fires after 5s
+    await vi.advanceTimersByTimeAsync(5000);
+    expect(scrollToTarget).toHaveBeenCalledTimes(1);
+
+    cleanup();
+  });
+
+  it('should scroll once when multiple rendering elements exist simultaneously', () => {
+    const renderingEl1 = document.createElement('div');
+    renderingEl1.setAttribute(GROWI_IS_CONTENT_RENDERING_ATTR, 'true');
+    container.appendChild(renderingEl1);
+
+    const renderingEl2 = document.createElement('div');
+    renderingEl2.setAttribute(GROWI_IS_CONTENT_RENDERING_ATTR, 'true');
+    container.appendChild(renderingEl2);
+
+    const cleanup = watchRenderingAndReScroll(container, scrollToTarget);
+
+    vi.advanceTimersByTime(5000);
+    expect(scrollToTarget).toHaveBeenCalledTimes(1);
+
+    cleanup();
+  });
+
+  it('should stop watching after 10s timeout', () => {
+    const renderingEl = document.createElement('div');
+    renderingEl.setAttribute(GROWI_IS_CONTENT_RENDERING_ATTR, 'true');
+    container.appendChild(renderingEl);
+
+    const cleanup = watchRenderingAndReScroll(container, scrollToTarget);
+
+    // First scroll at 5s
+    vi.advanceTimersByTime(5000);
+    expect(scrollToTarget).toHaveBeenCalledTimes(1);
+
+    // At 10s both the scroll timer and the watch timeout fire.
+    vi.advanceTimersByTime(5000);
+    const callsAfter10s = scrollToTarget.mock.calls.length;
+
+    // After 10s, no further scrolls should occur regardless
+    vi.advanceTimersByTime(10000);
+    expect(scrollToTarget).toHaveBeenCalledTimes(callsAfter10s);
+
+    cleanup();
+  });
+
+  it('should clean up timer and observer on cleanup call', () => {
+    const renderingEl = document.createElement('div');
+    renderingEl.setAttribute(GROWI_IS_CONTENT_RENDERING_ATTR, 'true');
+    container.appendChild(renderingEl);
+
+    const cleanup = watchRenderingAndReScroll(container, scrollToTarget);
+
+    cleanup();
+
+    vi.advanceTimersByTime(10000);
+    expect(scrollToTarget).not.toHaveBeenCalled();
+  });
+
+  it('should prevent timer callbacks from executing after cleanup (stopped flag)', () => {
+    const renderingEl = document.createElement('div');
+    renderingEl.setAttribute(GROWI_IS_CONTENT_RENDERING_ATTR, 'true');
+    container.appendChild(renderingEl);
+
+    const cleanup = watchRenderingAndReScroll(container, scrollToTarget);
+
+    // Advance partway, then cleanup
+    vi.advanceTimersByTime(3000);
+    cleanup();
+
+    // Timer would have fired at 5s, but cleanup was called
+    vi.advanceTimersByTime(5000);
+    expect(scrollToTarget).not.toHaveBeenCalled();
+  });
+
+  it('should not schedule further re-scrolls after rendering elements complete', async () => {
+    const renderingEl = document.createElement('div');
+    renderingEl.setAttribute(GROWI_IS_CONTENT_RENDERING_ATTR, 'true');
+    container.appendChild(renderingEl);
+
+    const cleanup = watchRenderingAndReScroll(container, scrollToTarget);
+
+    // First timer fires at 5s — re-scroll executes
+    vi.advanceTimersByTime(5000);
+    expect(scrollToTarget).toHaveBeenCalledTimes(1);
+
+    // Rendering completes — attribute toggled to false
+    renderingEl.setAttribute(GROWI_IS_CONTENT_RENDERING_ATTR, 'false');
+    await vi.advanceTimersByTimeAsync(0);
+
+    // No further re-scrolls should be scheduled
+    vi.advanceTimersByTime(10000);
+    expect(scrollToTarget).toHaveBeenCalledTimes(1);
+
+    cleanup();
+  });
+
+  it('should scroll exactly once when rendering completes before the first timer fires', () => {
+    const renderingEl = document.createElement('div');
+    renderingEl.setAttribute(GROWI_IS_CONTENT_RENDERING_ATTR, 'true');
+    container.appendChild(renderingEl);
+
+    const cleanup = watchRenderingAndReScroll(container, scrollToTarget);
+
+    // Rendering completes before the first poll timer fires
+    renderingEl.setAttribute(GROWI_IS_CONTENT_RENDERING_ATTR, 'false');
+
+    // Poll timer fires at 5s — detects no rendering elements.
+    // wasRendering is reset in the timer callback BEFORE scrollToTarget so that
+    // the subsequent checkAndSchedule call does not trigger a redundant extra scroll.
+    vi.advanceTimersByTime(5000);
+    expect(scrollToTarget).toHaveBeenCalledTimes(1);
+
+    // No further scrolls after rendering is confirmed done
+    vi.advanceTimersByTime(5000);
+    expect(scrollToTarget).toHaveBeenCalledTimes(1);
+
+    cleanup();
+  });
+});

+ 84 - 0
apps/app/src/client/util/watch-rendering-and-rescroll.ts

@@ -0,0 +1,84 @@
+import {
+  GROWI_IS_CONTENT_RENDERING_ATTR,
+  GROWI_IS_CONTENT_RENDERING_SELECTOR,
+} from '@growi/core/dist/consts';
+
+const RENDERING_POLL_INTERVAL_MS = 5000;
+export const WATCH_TIMEOUT_MS = 10000;
+
+/**
+ * Watch 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.
+ */
+export const watchRenderingAndReScroll = (
+  contentContainer: HTMLElement,
+  scrollToTarget: () => boolean,
+): (() => void) => {
+  let timerId: number | undefined;
+  let stopped = false;
+  let wasRendering = false;
+
+  const cleanup = () => {
+    stopped = true;
+    observer.disconnect();
+    if (timerId != null) {
+      window.clearTimeout(timerId);
+      timerId = undefined;
+    }
+    window.clearTimeout(watchTimeoutId);
+  };
+
+  const checkAndSchedule = () => {
+    if (stopped) return;
+
+    const hasRendering =
+      contentContainer.querySelector(GROWI_IS_CONTENT_RENDERING_SELECTOR) !=
+      null;
+
+    if (!hasRendering) {
+      if (timerId != null) {
+        window.clearTimeout(timerId);
+        timerId = undefined;
+      }
+      // Final re-scroll to compensate for the layout shift from the last completed render
+      if (wasRendering) {
+        wasRendering = false;
+        scrollToTarget();
+      }
+      return;
+    }
+
+    wasRendering = true;
+
+    // If a timer is already ticking, let it fire — don't reset
+    if (timerId != null) return;
+
+    timerId = window.setTimeout(() => {
+      if (stopped) return;
+      timerId = undefined;
+      // Reset before checkAndSchedule so the wasRendering guard does not
+      // trigger an extra re-scroll if rendering is already done by now.
+      wasRendering = false;
+      scrollToTarget();
+      checkAndSchedule();
+    }, RENDERING_POLL_INTERVAL_MS);
+  };
+
+  const observer = new MutationObserver(checkAndSchedule);
+
+  observer.observe(contentContainer, {
+    childList: true,
+    subtree: true,
+    attributes: true,
+    attributeFilter: [GROWI_IS_CONTENT_RENDERING_ATTR],
+  });
+
+  // Initial check
+  checkAndSchedule();
+
+  // Stop watching after timeout regardless of rendering state
+  const watchTimeoutId = window.setTimeout(cleanup, WATCH_TIMEOUT_MS);
+
+  return cleanup;
+};

+ 4 - 45
apps/app/src/components/PageView/PageView.tsx

@@ -1,12 +1,4 @@
-import {
-  type JSX,
-  memo,
-  useCallback,
-  useEffect,
-  useId,
-  useMemo,
-  useRef,
-} from 'react';
+import { type JSX, memo, useCallback, useId, useMemo, useRef } from 'react';
 import dynamic from 'next/dynamic';
 import dynamic from 'next/dynamic';
 import { isDeepEquals } from '@growi/core/dist/utils/is-deep-equals';
 import { isDeepEquals } from '@growi/core/dist/utils/is-deep-equals';
 import { isUsersHomepage } from '@growi/core/dist/utils/page-path-utils';
 import { isUsersHomepage } from '@growi/core/dist/utils/page-path-utils';
@@ -29,6 +21,7 @@ import { UserInfo } from '../User/UserInfo';
 import { PageAlerts } from './PageAlerts/PageAlerts';
 import { PageAlerts } from './PageAlerts/PageAlerts';
 import { PageContentFooter } from './PageContentFooter';
 import { PageContentFooter } from './PageContentFooter';
 import { PageViewLayout } from './PageViewLayout';
 import { PageViewLayout } from './PageViewLayout';
+import { useHashAutoScroll } from './use-hash-auto-scroll';
 
 
 // biome-ignore-start lint/style/noRestrictedImports: no-problem dynamic import
 // biome-ignore-start lint/style/noRestrictedImports: no-problem dynamic import
 const NotCreatablePage = dynamic(
 const NotCreatablePage = dynamic(
@@ -129,42 +122,8 @@ const PageViewComponent = (props: Props): JSX.Element => {
     rendererConfig.isEnabledMarp,
     rendererConfig.isEnabledMarp,
   );
   );
 
 
-  // ***************************  Auto Scroll  ***************************
-  useEffect(() => {
-    if (currentPageId == null) {
-      return;
-    }
-
-    // do nothing if hash is empty
-    const { hash } = window.location;
-    if (hash.length === 0) {
-      return;
-    }
-
-    const contentContainer = document.getElementById(contentContainerId);
-    if (contentContainer == null) return;
-
-    const targetId = decodeURIComponent(hash.slice(1));
-    const target = document.getElementById(targetId);
-    if (target != null) {
-      target.scrollIntoView();
-      return;
-    }
-
-    const observer = new MutationObserver(() => {
-      const target = document.getElementById(targetId);
-      if (target != null) {
-        target.scrollIntoView();
-        observer.disconnect();
-      }
-    });
-
-    observer.observe(contentContainer, { childList: true, subtree: true });
-
-    return () => observer.disconnect();
-  }, [currentPageId, contentContainerId]);
-
-  // *******************************  end  *******************************
+  // Auto-scroll to URL hash target, handling lazy-rendered content
+  useHashAutoScroll({ key: currentPageId, contentContainerId });
 
 
   const specialContents = useMemo(() => {
   const specialContents = useMemo(() => {
     if (isIdenticalPathPage) {
     if (isIdenticalPathPage) {

+ 329 - 0
apps/app/src/components/PageView/use-hash-auto-scroll.spec.tsx

@@ -0,0 +1,329 @@
+import { GROWI_IS_CONTENT_RENDERING_ATTR } from '@growi/core/dist/consts';
+import { renderHook } from '@testing-library/react';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+import { useHashAutoScroll } from './use-hash-auto-scroll';
+
+describe('useHashAutoScroll', () => {
+  const containerId = 'test-content-container';
+  let container: HTMLDivElement;
+
+  beforeEach(() => {
+    vi.useFakeTimers();
+    container = document.createElement('div');
+    container.id = containerId;
+    document.body.appendChild(container);
+  });
+
+  afterEach(() => {
+    vi.useRealTimers();
+    document.body.innerHTML = '';
+    window.location.hash = '';
+  });
+
+  it('should not scroll when key is null', () => {
+    window.location.hash = '#heading';
+    const target = document.createElement('div');
+    target.id = 'heading';
+    target.scrollIntoView = vi.fn();
+    container.appendChild(target);
+
+    renderHook(() =>
+      useHashAutoScroll({ key: null, contentContainerId: containerId }),
+    );
+
+    expect(target.scrollIntoView).not.toHaveBeenCalled();
+  });
+
+  it('should not scroll when key is undefined', () => {
+    window.location.hash = '#heading';
+    const target = document.createElement('div');
+    target.id = 'heading';
+    target.scrollIntoView = vi.fn();
+    container.appendChild(target);
+
+    renderHook(() =>
+      useHashAutoScroll({ key: undefined, contentContainerId: containerId }),
+    );
+
+    expect(target.scrollIntoView).not.toHaveBeenCalled();
+  });
+
+  it('should not scroll when hash is empty', () => {
+    window.location.hash = '';
+    const target = document.createElement('div');
+    target.id = 'heading';
+    target.scrollIntoView = vi.fn();
+    container.appendChild(target);
+
+    renderHook(() =>
+      useHashAutoScroll({ key: 'page-id', contentContainerId: containerId }),
+    );
+
+    expect(target.scrollIntoView).not.toHaveBeenCalled();
+  });
+
+  it('should not scroll when container is not found', () => {
+    window.location.hash = '#heading';
+    const target = document.createElement('div');
+    target.id = 'heading';
+    target.scrollIntoView = vi.fn();
+    container.appendChild(target);
+
+    renderHook(() =>
+      useHashAutoScroll({
+        key: 'page-id',
+        contentContainerId: 'nonexistent-id',
+      }),
+    );
+
+    expect(target.scrollIntoView).not.toHaveBeenCalled();
+  });
+
+  it('should scroll to target when it already exists in DOM', () => {
+    window.location.hash = '#heading';
+    const target = document.createElement('div');
+    target.id = 'heading';
+    target.scrollIntoView = vi.fn();
+    container.appendChild(target);
+
+    const { unmount } = renderHook(() =>
+      useHashAutoScroll({ key: 'page-id', contentContainerId: containerId }),
+    );
+
+    expect(target.scrollIntoView).toHaveBeenCalledTimes(1);
+
+    unmount();
+  });
+
+  it('should decode encoded hash values before target resolution', () => {
+    // Japanese characters encoded
+    window.location.hash = '#%E6%97%A5%E6%9C%AC%E8%AA%9E';
+    const target = document.createElement('div');
+    target.id = '日本語';
+    target.scrollIntoView = vi.fn();
+    container.appendChild(target);
+
+    const { unmount } = renderHook(() =>
+      useHashAutoScroll({ key: 'page-id', contentContainerId: containerId }),
+    );
+
+    expect(target.scrollIntoView).toHaveBeenCalledTimes(1);
+
+    unmount();
+  });
+
+  it('should use custom resolveTarget when provided', () => {
+    window.location.hash = '#heading';
+    const target = document.createElement('div');
+    target.scrollIntoView = vi.fn();
+    const resolveTarget = vi.fn(() => target);
+
+    const { unmount } = renderHook(() =>
+      useHashAutoScroll({
+        key: 'page-id',
+        contentContainerId: containerId,
+        resolveTarget,
+      }),
+    );
+
+    expect(resolveTarget).toHaveBeenCalledWith('heading');
+    expect(target.scrollIntoView).toHaveBeenCalledTimes(1);
+
+    unmount();
+  });
+
+  it('should use custom scrollTo when provided', () => {
+    window.location.hash = '#heading';
+    const target = document.createElement('div');
+    target.id = 'heading';
+    container.appendChild(target);
+
+    const customScrollTo = vi.fn();
+
+    const { unmount } = renderHook(() =>
+      useHashAutoScroll({
+        key: 'page-id',
+        contentContainerId: containerId,
+        scrollTo: customScrollTo,
+      }),
+    );
+
+    expect(customScrollTo).toHaveBeenCalledWith(target);
+
+    unmount();
+  });
+
+  it('should start rendering watch after scrolling to target', () => {
+    window.location.hash = '#heading';
+
+    const target = document.createElement('div');
+    target.id = 'heading';
+    target.scrollIntoView = vi.fn();
+    container.appendChild(target);
+
+    const renderingEl = document.createElement('div');
+    renderingEl.setAttribute(GROWI_IS_CONTENT_RENDERING_ATTR, 'true');
+    container.appendChild(renderingEl);
+
+    const { unmount } = renderHook(() =>
+      useHashAutoScroll({ key: 'page-id', contentContainerId: containerId }),
+    );
+
+    expect(target.scrollIntoView).toHaveBeenCalledTimes(1);
+
+    // Re-scroll after 5s due to rendering watch
+    vi.advanceTimersByTime(5000);
+    expect(target.scrollIntoView).toHaveBeenCalledTimes(2);
+
+    unmount();
+  });
+
+  // Poll interval is 5s, so this test needs more than 5s — extend timeout to 10s.
+  // happy-dom's MutationObserver does not fire reliably with fake timers when a
+  // setTimeout is pending in the same scope. Use real timers for this test only.
+  it('should re-scroll when rendering elements appear after initial scroll (late-mounting async renderers)', async () => {
+    vi.useRealTimers();
+
+    window.location.hash = '#heading';
+
+    const target = document.createElement('div');
+    target.id = 'heading';
+    target.scrollIntoView = vi.fn();
+    container.appendChild(target);
+
+    // No rendering elements at scroll time
+    const { unmount } = renderHook(() =>
+      useHashAutoScroll({ key: 'page-id', contentContainerId: containerId }),
+    );
+
+    expect(target.scrollIntoView).toHaveBeenCalledTimes(1);
+
+    // Async renderer mounts after the initial scroll (simulates Mermaid/PlantUML loading)
+    const renderingEl = document.createElement('div');
+    renderingEl.setAttribute(GROWI_IS_CONTENT_RENDERING_ATTR, 'true');
+    container.appendChild(renderingEl);
+
+    // Wait for MO to fire and the 5s poll timer to elapse
+    await new Promise<void>((resolve) => setTimeout(resolve, 5100));
+    expect(target.scrollIntoView).toHaveBeenCalledTimes(2);
+
+    unmount();
+  }, 10000);
+
+  it('should not re-scroll when no rendering elements exist after initial scroll', () => {
+    window.location.hash = '#heading';
+
+    const target = document.createElement('div');
+    target.id = 'heading';
+    target.scrollIntoView = vi.fn();
+    container.appendChild(target);
+
+    const { unmount } = renderHook(() =>
+      useHashAutoScroll({ key: 'page-id', contentContainerId: containerId }),
+    );
+
+    expect(target.scrollIntoView).toHaveBeenCalledTimes(1);
+
+    // No re-scroll since no rendering elements are present
+    vi.advanceTimersByTime(5000);
+    expect(target.scrollIntoView).toHaveBeenCalledTimes(1);
+
+    unmount();
+  });
+
+  it('should wait for target via MutationObserver when not yet in DOM', async () => {
+    // happy-dom's MutationObserver does not fire when a fake-timer setTimeout is
+    // pending in the same effect. Use real timers for this test only.
+    vi.useRealTimers();
+
+    window.location.hash = '#deferred';
+
+    const { unmount } = renderHook(() =>
+      useHashAutoScroll({ key: 'page-id', contentContainerId: containerId }),
+    );
+
+    const target = document.createElement('div');
+    target.id = 'deferred';
+    target.scrollIntoView = vi.fn();
+    container.appendChild(target);
+
+    await new Promise<void>((resolve) => setTimeout(resolve, 50));
+
+    expect(target.scrollIntoView).toHaveBeenCalledTimes(1);
+
+    unmount();
+  });
+
+  it('should stop target observer after 10s timeout when target never appears', async () => {
+    window.location.hash = '#never-appears';
+
+    const { unmount } = renderHook(() =>
+      useHashAutoScroll({ key: 'page-id', contentContainerId: containerId }),
+    );
+
+    // Advance past the timeout
+    vi.advanceTimersByTime(11000);
+
+    // Target appears after timeout — should NOT trigger scroll
+    const target = document.createElement('div');
+    target.id = 'never-appears';
+    target.scrollIntoView = vi.fn();
+    container.appendChild(target);
+
+    await vi.advanceTimersByTimeAsync(0);
+
+    expect(target.scrollIntoView).not.toHaveBeenCalled();
+
+    unmount();
+  });
+
+  it('should clean up all observers and timers on unmount', () => {
+    window.location.hash = '#heading';
+    const target = document.createElement('div');
+    target.id = 'heading';
+    target.scrollIntoView = vi.fn();
+    container.appendChild(target);
+
+    const renderingEl = document.createElement('div');
+    renderingEl.setAttribute(GROWI_IS_CONTENT_RENDERING_ATTR, 'true');
+    container.appendChild(renderingEl);
+
+    const { unmount } = renderHook(() =>
+      useHashAutoScroll({ key: 'page-id', contentContainerId: containerId }),
+    );
+
+    expect(target.scrollIntoView).toHaveBeenCalledTimes(1);
+
+    unmount();
+
+    // No further scrolls after unmount
+    vi.advanceTimersByTime(20000);
+    expect(target.scrollIntoView).toHaveBeenCalledTimes(1);
+  });
+
+  it('should re-run effect when key changes', () => {
+    window.location.hash = '#heading';
+    const target = document.createElement('div');
+    target.id = 'heading';
+    target.scrollIntoView = vi.fn();
+    container.appendChild(target);
+
+    const { rerender, unmount } = renderHook(
+      ({ key }) => useHashAutoScroll({ key, contentContainerId: containerId }),
+      { initialProps: { key: 'page-1' as string | null } },
+    );
+
+    expect(target.scrollIntoView).toHaveBeenCalledTimes(1);
+
+    // Change key — effect re-runs
+    rerender({ key: 'page-2' });
+    expect(target.scrollIntoView).toHaveBeenCalledTimes(2);
+
+    // Set to null — no additional scroll
+    rerender({ key: null });
+    expect(target.scrollIntoView).toHaveBeenCalledTimes(2);
+
+    unmount();
+  });
+});

+ 106 - 0
apps/app/src/components/PageView/use-hash-auto-scroll.ts

@@ -0,0 +1,106 @@
+import { useEffect, useRef } from 'react';
+
+import {
+  WATCH_TIMEOUT_MS,
+  watchRenderingAndReScroll,
+  // biome-ignore lint/style/noRestrictedImports: client-only hook used in client-only component
+} from '~/client/util/watch-rendering-and-rescroll';
+
+/** Configuration for the hash-based auto-scroll hook */
+export 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;
+}
+
+/**
+ * Auto-scroll to the URL hash target when a content view loads.
+ * Handles lazy-rendered content by polling for rendering-status
+ * attributes and re-scrolling after they finish.
+ */
+export const useHashAutoScroll = (options: UseHashAutoScrollOptions): void => {
+  const { key, contentContainerId } = options;
+  const resolveTargetRef = useRef(options.resolveTarget);
+  resolveTargetRef.current = options.resolveTarget;
+  const scrollToRef = useRef(options.scrollTo);
+  scrollToRef.current = options.scrollTo;
+
+  useEffect(() => {
+    if (key == null) return;
+
+    const { hash } = window.location;
+    if (hash.length === 0) return;
+
+    const contentContainer = document.getElementById(contentContainerId);
+    if (contentContainer == null) return;
+
+    const targetId = decodeURIComponent(hash.slice(1));
+
+    const scrollToTarget = (): boolean => {
+      const resolve =
+        resolveTargetRef.current ??
+        ((id: string) => document.getElementById(id));
+      const target = resolve(targetId);
+      if (target == null) return false;
+      const scroll =
+        scrollToRef.current ?? ((el: HTMLElement) => el.scrollIntoView());
+      scroll(target);
+      return true;
+    };
+
+    const startRenderingWatch = (): (() => void) => {
+      // Always start regardless of current rendering elements — async renderers
+      // (Mermaid via dynamic import, PlantUML images) may mount after the initial scroll.
+      return watchRenderingAndReScroll(contentContainer, scrollToTarget);
+    };
+
+    // Target already in DOM — scroll and optionally watch rendering
+    if (scrollToTarget()) {
+      const renderingCleanup = startRenderingWatch();
+      return () => {
+        renderingCleanup?.();
+      };
+    }
+
+    // Target not in DOM yet — wait for it, then optionally watch rendering
+    let renderingCleanup: (() => void) | undefined;
+
+    const observer = new MutationObserver(() => {
+      if (scrollToTarget()) {
+        observer.disconnect();
+        window.clearTimeout(timeoutId);
+        renderingCleanup = startRenderingWatch();
+      }
+    });
+
+    observer.observe(contentContainer, { childList: true, subtree: true });
+    const timeoutId = window.setTimeout(
+      () => observer.disconnect(),
+      WATCH_TIMEOUT_MS,
+    );
+
+    return () => {
+      observer.disconnect();
+      window.clearTimeout(timeoutId);
+      renderingCleanup?.();
+    };
+  }, [key, contentContainerId]);
+};

+ 133 - 0
apps/app/src/features/mermaid/components/MermaidViewer.spec.tsx

@@ -0,0 +1,133 @@
+import { GROWI_IS_CONTENT_RENDERING_ATTR } from '@growi/core/dist/consts';
+import { act, render } from '@testing-library/react';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+// Mock mermaid to control rendering behavior in tests
+vi.mock('mermaid', () => ({
+  default: {
+    initialize: vi.fn(),
+    render: vi.fn(),
+  },
+}));
+
+// Mock useNextThemes to provide a stable isDarkMode value
+vi.mock('~/stores-universal/use-next-themes', () => ({
+  useNextThemes: vi.fn(() => ({ isDarkMode: false })),
+}));
+
+// uuid mock: return predictable IDs
+vi.mock('uuid', () => ({
+  v7: vi.fn(() => 'test-uuid'),
+}));
+
+import mermaid from 'mermaid';
+
+import { MermaidViewer } from './MermaidViewer';
+
+const mockRender = vi.mocked(mermaid.render);
+
+describe('MermaidViewer', () => {
+  beforeEach(() => {
+    vi.useFakeTimers();
+  });
+
+  afterEach(() => {
+    vi.useRealTimers();
+    vi.clearAllMocks();
+    document.body.innerHTML = '';
+  });
+
+  it('should render container with rendering-status attribute set to "true" initially', () => {
+    mockRender.mockReturnValue(new Promise(() => {})); // pending — never resolves
+
+    const { container } = render(<MermaidViewer value="graph TD; A-->B" />);
+    const div = container.firstElementChild as HTMLElement;
+    expect(div.getAttribute(GROWI_IS_CONTENT_RENDERING_ATTR)).toBe('true');
+  });
+
+  it('should set rendering-status attribute to "false" via requestAnimationFrame after successful render', async () => {
+    mockRender.mockResolvedValue({
+      svg: '<svg>test</svg>',
+      bindFunctions: undefined as never,
+      diagramType: 'flowchart',
+    });
+
+    const { container } = render(<MermaidViewer value="graph TD; A-->B" />);
+    const div = container.firstElementChild as HTMLElement;
+
+    expect(div.getAttribute(GROWI_IS_CONTENT_RENDERING_ATTR)).toBe('true');
+
+    // Flush the async mermaid.render() call
+    await act(async () => {
+      await Promise.resolve();
+    });
+
+    // Attribute should still be "true" — waiting for rAF
+    expect(div.getAttribute(GROWI_IS_CONTENT_RENDERING_ATTR)).toBe('true');
+
+    // Flush the requestAnimationFrame
+    await act(() => {
+      vi.runAllTimers();
+    });
+
+    expect(div.getAttribute(GROWI_IS_CONTENT_RENDERING_ATTR)).toBe('false');
+  });
+
+  it('should set rendering-status attribute to "false" immediately on error (no rAF delay)', async () => {
+    mockRender.mockRejectedValue(new Error('Mermaid render failed'));
+
+    const { container } = render(
+      <MermaidViewer value="invalid mermaid syntax ###" />,
+    );
+    const div = container.firstElementChild as HTMLElement;
+
+    expect(div.getAttribute(GROWI_IS_CONTENT_RENDERING_ATTR)).toBe('true');
+
+    // Flush the rejected promise
+    await act(async () => {
+      await Promise.resolve();
+    });
+
+    expect(div.getAttribute(GROWI_IS_CONTENT_RENDERING_ATTR)).toBe('false');
+  });
+
+  it('should cancel pending requestAnimationFrame when component unmounts during render', async () => {
+    let resolveRender!: (value: {
+      svg: string;
+      bindFunctions: never;
+      diagramType: string;
+    }) => void;
+    mockRender.mockReturnValue(
+      new Promise<{ svg: string; bindFunctions: never; diagramType: string }>(
+        (resolve) => {
+          resolveRender = resolve;
+        },
+      ),
+    );
+
+    const cancelAnimationFrameSpy = vi.spyOn(window, 'cancelAnimationFrame');
+
+    const { unmount } = render(<MermaidViewer value="graph TD; A-->B" />);
+
+    // Resolve render but don't flush rAF yet
+    await act(async () => {
+      resolveRender({
+        svg: '<svg>test</svg>',
+        bindFunctions: undefined as never,
+        diagramType: 'flowchart',
+      });
+      await Promise.resolve();
+    });
+
+    // Unmount before rAF fires
+    unmount();
+
+    // Verify cancelAnimationFrame was called
+    expect(cancelAnimationFrameSpy).toHaveBeenCalled();
+
+    // Advancing timers should not cause errors (no DOM update on unmounted component)
+    await act(() => {
+      vi.runAllTimers();
+    });
+  });
+});

+ 23 - 1
apps/app/src/features/mermaid/components/MermaidViewer.tsx

@@ -1,4 +1,5 @@
 import React, { type JSX, useEffect, useRef } from 'react';
 import React, { type JSX, useEffect, useRef } from 'react';
+import { GROWI_IS_CONTENT_RENDERING_ATTR } from '@growi/core/dist/consts';
 import mermaid from 'mermaid';
 import mermaid from 'mermaid';
 import { v7 as uuidV7 } from 'uuid';
 import { v7 as uuidV7 } from 'uuid';
 
 
@@ -20,6 +21,8 @@ export const MermaidViewer = React.memo(
     const ref = useRef<HTMLDivElement>(null);
     const ref = useRef<HTMLDivElement>(null);
 
 
     useEffect(() => {
     useEffect(() => {
+      let rafId: number | undefined;
+
       (async () => {
       (async () => {
         if (ref.current != null && value != null) {
         if (ref.current != null && value != null) {
           mermaid.initialize({
           mermaid.initialize({
@@ -34,15 +37,34 @@ export const MermaidViewer = React.memo(
             const id = `mermaid-${uuidV7()}`;
             const id = `mermaid-${uuidV7()}`;
             const { svg } = await mermaid.render(id, value, ref.current);
             const { svg } = await mermaid.render(id, value, ref.current);
             ref.current.innerHTML = svg;
             ref.current.innerHTML = svg;
+            // Delay the "done" signal to the next animation frame so the browser has a chance
+            // to compute the SVG layout before the auto-scroll system re-scrolls.
+            rafId = requestAnimationFrame(() => {
+              ref.current?.setAttribute(
+                GROWI_IS_CONTENT_RENDERING_ATTR,
+                'false',
+              );
+            });
           } catch (err) {
           } catch (err) {
             logger.error(err);
             logger.error(err);
+            ref.current?.setAttribute(GROWI_IS_CONTENT_RENDERING_ATTR, 'false');
           }
           }
         }
         }
       })();
       })();
+
+      return () => {
+        if (rafId != null) {
+          cancelAnimationFrame(rafId);
+        }
+      };
     }, [isDarkMode, value]);
     }, [isDarkMode, value]);
 
 
     return value ? (
     return value ? (
-      <div ref={ref} key={value}>
+      <div
+        ref={ref}
+        key={value}
+        {...{ [GROWI_IS_CONTENT_RENDERING_ATTR]: 'true' }}
+      >
         {value}
         {value}
       </div>
       </div>
     ) : (
     ) : (

+ 3 - 1
apps/app/src/features/mermaid/services/mermaid.ts

@@ -1,3 +1,4 @@
+import { GROWI_IS_CONTENT_RENDERING_ATTR } from '@growi/core/dist/consts';
 import type { Schema as SanitizeOption } from 'hast-util-sanitize';
 import type { Schema as SanitizeOption } from 'hast-util-sanitize';
 import type { Code } from 'mdast';
 import type { Code } from 'mdast';
 import type { Plugin } from 'unified';
 import type { Plugin } from 'unified';
@@ -12,6 +13,7 @@ function rewriteNode(node: Code) {
   data.hName = 'mermaid';
   data.hName = 'mermaid';
   data.hProperties = {
   data.hProperties = {
     value: node.value,
     value: node.value,
+    [GROWI_IS_CONTENT_RENDERING_ATTR]: 'true',
   };
   };
 }
 }
 
 
@@ -26,6 +28,6 @@ export const remarkPlugin: Plugin = () => (tree) => {
 export const sanitizeOption: SanitizeOption = {
 export const sanitizeOption: SanitizeOption = {
   tagNames: ['mermaid'],
   tagNames: ['mermaid'],
   attributes: {
   attributes: {
-    mermaid: ['value'],
+    mermaid: ['value', GROWI_IS_CONTENT_RENDERING_ATTR],
   },
   },
 };
 };

+ 53 - 0
apps/app/src/features/plantuml/components/PlantUmlViewer.spec.tsx

@@ -0,0 +1,53 @@
+import { GROWI_IS_CONTENT_RENDERING_ATTR } from '@growi/core/dist/consts';
+import { fireEvent, render } from '@testing-library/react';
+import { afterEach, describe, expect, it } from 'vitest';
+
+import { PlantUmlViewer } from './PlantUmlViewer';
+
+describe('PlantUmlViewer', () => {
+  afterEach(() => {
+    document.body.innerHTML = '';
+  });
+
+  it('should render with rendering-status attribute set to "true" initially', () => {
+    const { container } = render(
+      <PlantUmlViewer src="http://example.com/plantuml.png" />,
+    );
+    const div = container.firstElementChild as HTMLElement;
+    expect(div.getAttribute(GROWI_IS_CONTENT_RENDERING_ATTR)).toBe('true');
+  });
+
+  it('should set rendering-status attribute to "false" when image loads', () => {
+    const { container } = render(
+      <PlantUmlViewer src="http://example.com/plantuml.png" />,
+    );
+    const div = container.firstElementChild as HTMLElement;
+    const img = div.querySelector('img') as HTMLImageElement;
+
+    expect(div.getAttribute(GROWI_IS_CONTENT_RENDERING_ATTR)).toBe('true');
+
+    fireEvent.load(img);
+
+    expect(div.getAttribute(GROWI_IS_CONTENT_RENDERING_ATTR)).toBe('false');
+  });
+
+  it('should set rendering-status attribute to "false" when image fails to load', () => {
+    const { container } = render(
+      <PlantUmlViewer src="http://example.com/nonexistent.png" />,
+    );
+    const div = container.firstElementChild as HTMLElement;
+    const img = div.querySelector('img') as HTMLImageElement;
+
+    expect(div.getAttribute(GROWI_IS_CONTENT_RENDERING_ATTR)).toBe('true');
+
+    fireEvent.error(img);
+
+    expect(div.getAttribute(GROWI_IS_CONTENT_RENDERING_ATTR)).toBe('false');
+  });
+
+  it('should render an img element with the provided src', () => {
+    render(<PlantUmlViewer src="http://example.com/diagram.png" />);
+    const img = document.querySelector('img') as HTMLImageElement;
+    expect(img.src).toBe('http://example.com/diagram.png');
+  });
+});

+ 31 - 0
apps/app/src/features/plantuml/components/PlantUmlViewer.tsx

@@ -0,0 +1,31 @@
+import React, { type JSX, useCallback, useRef } from 'react';
+import { GROWI_IS_CONTENT_RENDERING_ATTR } from '@growi/core/dist/consts';
+
+type PlantUmlViewerProps = {
+  src: string;
+};
+
+export const PlantUmlViewer = React.memo(
+  ({ src }: PlantUmlViewerProps): JSX.Element => {
+    const containerRef = useRef<HTMLDivElement>(null);
+
+    const handleLoaded = useCallback(() => {
+      containerRef.current?.setAttribute(
+        GROWI_IS_CONTENT_RENDERING_ATTR,
+        'false',
+      );
+    }, []);
+
+    return (
+      <div
+        ref={containerRef}
+        {...{ [GROWI_IS_CONTENT_RENDERING_ATTR]: 'true' }}
+      >
+        {/* biome-ignore lint/a11y/useAltText: PlantUML diagrams are purely visual */}
+        <img src={src} onLoad={handleLoaded} onError={handleLoaded} />
+      </div>
+    );
+  },
+);
+
+PlantUmlViewer.displayName = 'PlantUmlViewer';

+ 1 - 0
apps/app/src/features/plantuml/components/index.ts

@@ -0,0 +1 @@
+export { PlantUmlViewer } from './PlantUmlViewer';

+ 1 - 0
apps/app/src/features/plantuml/index.ts

@@ -1 +1,2 @@
+export * from './components';
 export * from './services';
 export * from './services';

+ 1 - 1
apps/app/src/features/plantuml/services/index.ts

@@ -1 +1 @@
-export { remarkPlugin } from './plantuml';
+export { remarkPlugin, sanitizeOption } from './plantuml';

+ 43 - 1
apps/app/src/features/plantuml/services/plantuml.ts

@@ -1,5 +1,7 @@
 import plantuml from '@akebifiky/remark-simple-plantuml';
 import plantuml from '@akebifiky/remark-simple-plantuml';
-import type { Code } from 'mdast';
+import { GROWI_IS_CONTENT_RENDERING_ATTR } from '@growi/core/dist/consts';
+import type { Schema as SanitizeOption } from 'hast-util-sanitize';
+import type { Code, Image, Parent } from 'mdast';
 import type { Plugin } from 'unified';
 import type { Plugin } from 'unified';
 import { visit } from 'unist-util-visit';
 import { visit } from 'unist-util-visit';
 import urljoin from 'url-join';
 import urljoin from 'url-join';
@@ -28,6 +30,46 @@ export const remarkPlugin: Plugin<[PlantUMLPluginParams]> = (options) => {
       }
       }
     });
     });
 
 
+    // Let remark-simple-plantuml convert plantuml code blocks to image nodes
     simplePlantumlPlugin(tree, file);
     simplePlantumlPlugin(tree, file);
+
+    // Transform plantuml image nodes into custom <plantuml> elements that carry
+    // the rendering-status attribute, allowing the auto-scroll system to detect
+    // and compensate for the layout shift caused by async image loading.
+    visit(
+      tree,
+      'image',
+      (node: Image, index: number | undefined, parent: Parent | undefined) => {
+        if (plantumlUri.length === 0 || !node.url.startsWith(baseUrl)) {
+          return;
+        }
+        if (index == null || parent == null) {
+          return;
+        }
+
+        const src = node.url;
+
+        // Replace the image node with a custom paragraph-like element.
+        // hName overrides the HTML tag; hProperties set element attributes.
+        parent.children[index] = {
+          type: 'paragraph',
+          children: [],
+          data: {
+            hName: 'plantuml',
+            hProperties: {
+              src,
+              [GROWI_IS_CONTENT_RENDERING_ATTR]: 'true',
+            },
+          },
+        };
+      },
+    );
   };
   };
 };
 };
+
+export const sanitizeOption: SanitizeOption = {
+  tagNames: ['plantuml'],
+  attributes: {
+    plantuml: ['src', GROWI_IS_CONTENT_RENDERING_ATTR],
+  },
+};

+ 126 - 0
apps/app/src/features/search/client/components/SearchPage/SearchResultContent.spec.tsx

@@ -0,0 +1,126 @@
+import type { IPageHasId } from '@growi/core';
+import { render } from '@testing-library/react';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+import type { IPageWithSearchMeta } from '~/interfaces/search';
+
+// Mock useKeywordRescroll
+vi.mock('./use-keyword-rescroll', () => ({
+  useKeywordRescroll: vi.fn(),
+}));
+
+// Mock next/dynamic
+vi.mock('next/dynamic', () => ({
+  default: () => {
+    return function MockDynamic() {
+      return null;
+    };
+  },
+}));
+
+// Mock various hooks and stores used by SearchResultContent
+vi.mock('~/services/layout/use-should-expand-content', () => ({
+  useShouldExpandContent: vi.fn(() => false),
+}));
+vi.mock('~/states/global', () => ({
+  useCurrentUser: vi.fn(() => null),
+}));
+vi.mock('~/states/ui/modal/page-delete', () => ({
+  usePageDeleteModalActions: vi.fn(() => ({ open: vi.fn() })),
+}));
+vi.mock('~/states/ui/modal/page-duplicate', () => ({
+  usePageDuplicateModalActions: vi.fn(() => ({ open: vi.fn() })),
+}));
+vi.mock('~/states/ui/modal/page-rename', () => ({
+  usePageRenameModalActions: vi.fn(() => ({ open: vi.fn() })),
+}));
+vi.mock('~/stores/page-listing', () => ({
+  mutatePageList: vi.fn(),
+  mutatePageTree: vi.fn(),
+  mutateRecentlyUpdated: vi.fn(),
+}));
+vi.mock('~/stores/renderer', () => ({
+  useSearchResultOptions: vi.fn(() => ({ data: null })),
+}));
+vi.mock('~/stores/search', () => ({
+  mutateSearching: vi.fn(),
+}));
+vi.mock('next-i18next', () => ({
+  useTranslation: vi.fn(() => ({ t: (key: string) => key })),
+}));
+vi.mock('~/components/Common/PagePathNav', () => ({
+  PagePathNav: () => null,
+}));
+
+import { SearchResultContent } from './SearchResultContent';
+import { useKeywordRescroll } from './use-keyword-rescroll';
+
+const mockUseKeywordRescroll = vi.mocked(useKeywordRescroll);
+
+const createMockPage = (overrides: Partial<IPageHasId> = {}): IPageHasId =>
+  ({
+    _id: 'page-123',
+    path: '/test/page',
+    revision: 'rev-456',
+    wip: false,
+    ...overrides,
+  }) as unknown as IPageHasId;
+
+const createMockPageWithMeta = (page: IPageHasId = createMockPage()) =>
+  ({ data: page }) as unknown as IPageWithSearchMeta;
+
+describe('SearchResultContent', () => {
+  beforeEach(() => {
+    mockUseKeywordRescroll.mockReset();
+    window.location.hash = '';
+  });
+
+  afterEach(() => {
+    window.location.hash = '';
+  });
+
+  describe('useKeywordRescroll integration', () => {
+    it('should call useKeywordRescroll with the correct key', () => {
+      const page = createMockPage({ _id: 'page-123' });
+      const pageWithMeta = createMockPageWithMeta(page);
+
+      render(<SearchResultContent pageWithMeta={pageWithMeta} />);
+
+      expect(mockUseKeywordRescroll).toHaveBeenCalledTimes(1);
+      const callArgs = mockUseKeywordRescroll.mock.calls[0]?.[0];
+      expect(callArgs?.key).toBe('page-123');
+    });
+
+    it('should call useKeywordRescroll with a ref to the scroll container', () => {
+      const page = createMockPage({ _id: 'page-123' });
+      const pageWithMeta = createMockPageWithMeta(page);
+
+      render(<SearchResultContent pageWithMeta={pageWithMeta} />);
+
+      const callArgs = mockUseKeywordRescroll.mock.calls[0]?.[0];
+      expect(callArgs?.scrollElementRef).toBeDefined();
+      expect(callArgs?.scrollElementRef.current).toBeInstanceOf(HTMLElement);
+      expect((callArgs?.scrollElementRef.current as HTMLElement).id).toBe(
+        'search-result-content-body-container',
+      );
+    });
+
+    it('should re-call useKeywordRescroll with new key when page changes', () => {
+      const page1 = createMockPage({ _id: 'page-1' });
+      const pageWithMeta1 = createMockPageWithMeta(page1);
+
+      const { rerender } = render(
+        <SearchResultContent pageWithMeta={pageWithMeta1} />,
+      );
+
+      const page2 = createMockPage({ _id: 'page-2' });
+      const pageWithMeta2 = createMockPageWithMeta(page2);
+
+      rerender(<SearchResultContent pageWithMeta={pageWithMeta2} />);
+
+      // useKeywordRescroll should be called with new key on rerender
+      const lastCall = mockUseKeywordRescroll.mock.calls.at(-1)?.[0];
+      expect(lastCall?.key).toBe('page-2');
+    });
+  });
+});

+ 8 - 50
apps/app/src/features/search/client/components/SearchPage/SearchResultContent.tsx

@@ -1,11 +1,10 @@
 import type { FC, JSX } from 'react';
 import type { FC, JSX } from 'react';
-import { useCallback, useEffect, useRef } from 'react';
+import { useCallback, useRef } from 'react';
 import dynamic from 'next/dynamic';
 import dynamic from 'next/dynamic';
 import type { IPageToDeleteWithMeta, IPageToRenameWithMeta } from '@growi/core';
 import type { IPageToDeleteWithMeta, IPageToRenameWithMeta } from '@growi/core';
 import { getIdStringForRef } from '@growi/core';
 import { getIdStringForRef } from '@growi/core';
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
 import { DropdownItem } from 'reactstrap';
 import { DropdownItem } from 'reactstrap';
-import { debounce } from 'throttle-debounce';
 
 
 import type {
 import type {
   AdditionalMenuItemsRendererProps,
   AdditionalMenuItemsRendererProps,
@@ -13,7 +12,6 @@ import type {
 } from '~/client/components/Common/Dropdown/PageItemControl';
 } from '~/client/components/Common/Dropdown/PageItemControl';
 import type { RevisionLoaderProps } from '~/client/components/Page/RevisionLoader';
 import type { RevisionLoaderProps } from '~/client/components/Page/RevisionLoader';
 import { exportAsMarkdown } from '~/client/services/page-operation';
 import { exportAsMarkdown } from '~/client/services/page-operation';
-import { scrollWithinContainer } from '~/client/util/smooth-scroll';
 import { toastSuccess } from '~/client/util/toastr';
 import { toastSuccess } from '~/client/util/toastr';
 import { PagePathNav } from '~/components/Common/PagePathNav';
 import { PagePathNav } from '~/components/Common/PagePathNav';
 import type { IPageWithSearchMeta } from '~/interfaces/search';
 import type { IPageWithSearchMeta } from '~/interfaces/search';
@@ -35,6 +33,8 @@ import {
 import { useSearchResultOptions } from '~/stores/renderer';
 import { useSearchResultOptions } from '~/stores/renderer';
 import { mutateSearching } from '~/stores/search';
 import { mutateSearching } from '~/stores/search';
 
 
+import { useKeywordRescroll } from './use-keyword-rescroll';
+
 import styles from './SearchResultContent.module.scss';
 import styles from './SearchResultContent.module.scss';
 
 
 const moduleClass = styles['search-result-content'];
 const moduleClass = styles['search-result-content'];
@@ -89,9 +89,6 @@ const AdditionalMenuItems = (props: AdditionalMenuItemsProps): JSX.Element => {
   );
   );
 };
 };
 
 
-const SCROLL_OFFSET_TOP = 30;
-const MUTATION_OBSERVER_CONFIG = { childList: true, subtree: true }; // omit 'subtree: true'
-
 type Props = {
 type Props = {
   pageWithMeta: IPageWithSearchMeta;
   pageWithMeta: IPageWithSearchMeta;
   highlightKeywords?: string[];
   highlightKeywords?: string[];
@@ -99,57 +96,18 @@ type Props = {
   forceHideMenuItems?: ForceHideMenuItems;
   forceHideMenuItems?: ForceHideMenuItems;
 };
 };
 
 
-const scrollToFirstHighlightedKeyword = (scrollElement: HTMLElement): void => {
-  // use querySelector to intentionally get the first element found
-  const toElem = scrollElement.querySelector(
-    '.highlighted-keyword',
-  ) as HTMLElement | null;
-  if (toElem == null) {
-    return;
-  }
-
-  const distance =
-    toElem.getBoundingClientRect().top -
-    scrollElement.getBoundingClientRect().top -
-    SCROLL_OFFSET_TOP;
-  scrollWithinContainer(scrollElement, distance);
-};
-const scrollToFirstHighlightedKeywordDebounced = debounce(
-  500,
-  scrollToFirstHighlightedKeyword,
-);
-
 export const SearchResultContent: FC<Props> = (props: Props) => {
 export const SearchResultContent: FC<Props> = (props: Props) => {
   const scrollElementRef = useRef<HTMLDivElement | null>(null);
   const scrollElementRef = useRef<HTMLDivElement | null>(null);
 
 
-  // ***************************  Auto Scroll  ***************************
-  useEffect(() => {
-    const scrollElement = scrollElementRef.current;
-
-    if (scrollElement == null) return;
+  const { pageWithMeta } = props;
+  const page = pageWithMeta.data;
 
 
-    const observer = new MutationObserver(() => {
-      scrollToFirstHighlightedKeywordDebounced(scrollElement);
-    });
-    observer.observe(scrollElement, MUTATION_OBSERVER_CONFIG);
+  useKeywordRescroll({ scrollElementRef, key: page._id });
 
 
-    // no cleanup function -- 2023.07.31 Yuki Takei
-    // see: https://developer.mozilla.org/en-US/docs/Web/API/MutationObserver/observe
-    // > You can call observe() multiple times on the same MutationObserver
-    // > to watch for changes to different parts of the DOM tree and/or different types of changes.
-  });
-  // *******************************  end  *******************************
-
-  const {
-    pageWithMeta,
-    highlightKeywords,
-    showPageControlDropdown,
-    forceHideMenuItems,
-  } = props;
+  const { highlightKeywords, showPageControlDropdown, forceHideMenuItems } =
+    props;
 
 
   const { t } = useTranslation();
   const { t } = useTranslation();
-
-  const page = pageWithMeta.data;
   const { open: openDuplicateModal } = usePageDuplicateModalActions();
   const { open: openDuplicateModal } = usePageDuplicateModalActions();
   const { open: openRenameModal } = usePageRenameModalActions();
   const { open: openRenameModal } = usePageRenameModalActions();
   const { open: openDeleteModal } = usePageDeleteModalActions();
   const { open: openDeleteModal } = usePageDeleteModalActions();

+ 182 - 0
apps/app/src/features/search/client/components/SearchPage/use-keyword-rescroll.spec.tsx

@@ -0,0 +1,182 @@
+import { renderHook } from '@testing-library/react';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+// Mock watchRenderingAndReScroll
+vi.mock('~/client/util/watch-rendering-and-rescroll', () => ({
+  watchRenderingAndReScroll: vi.fn(() => vi.fn()), // returns a cleanup fn
+}));
+
+// Mock scrollWithinContainer
+vi.mock('~/client/util/smooth-scroll', () => ({
+  scrollWithinContainer: vi.fn(),
+}));
+
+import { scrollWithinContainer } from '~/client/util/smooth-scroll';
+import { watchRenderingAndReScroll } from '~/client/util/watch-rendering-and-rescroll';
+
+import { useKeywordRescroll } from './use-keyword-rescroll';
+
+const mockWatchRenderingAndReScroll = vi.mocked(watchRenderingAndReScroll);
+const mockScrollWithinContainer = vi.mocked(scrollWithinContainer);
+
+describe('useKeywordRescroll', () => {
+  let container: HTMLDivElement;
+
+  beforeEach(() => {
+    vi.useFakeTimers();
+    mockWatchRenderingAndReScroll.mockReset();
+    mockWatchRenderingAndReScroll.mockReturnValue(vi.fn());
+    mockScrollWithinContainer.mockReset();
+
+    container = document.createElement('div');
+    container.id = 'test-container';
+    document.body.appendChild(container);
+  });
+
+  afterEach(() => {
+    vi.useRealTimers();
+    document.body.innerHTML = '';
+    window.location.hash = '';
+  });
+
+  it('should call watchRenderingAndReScroll with the scroll container element', () => {
+    const scrollElementRef = { current: container };
+
+    renderHook(() => useKeywordRescroll({ scrollElementRef, key: 'page-123' }));
+
+    expect(mockWatchRenderingAndReScroll).toHaveBeenCalledTimes(1);
+    const containerArg = mockWatchRenderingAndReScroll.mock.calls[0]?.[0];
+    expect(containerArg).toBe(container);
+  });
+
+  it('should pass a scrollToKeyword function as the second argument', () => {
+    const scrollElementRef = { current: container };
+
+    renderHook(() => useKeywordRescroll({ scrollElementRef, key: 'page-123' }));
+
+    const scrollToKeyword = mockWatchRenderingAndReScroll.mock.calls[0]?.[1];
+    expect(typeof scrollToKeyword).toBe('function');
+  });
+
+  it('scrollToKeyword should scroll to .highlighted-keyword within container', () => {
+    const scrollElementRef = { current: container };
+
+    renderHook(() => useKeywordRescroll({ scrollElementRef, key: 'page-123' }));
+
+    const scrollToKeyword = mockWatchRenderingAndReScroll.mock.calls[0]?.[1];
+
+    // Inject a highlighted-keyword element into the container
+    const keyword = document.createElement('span');
+    keyword.className = 'highlighted-keyword';
+    container.appendChild(keyword);
+
+    vi.spyOn(keyword, 'getBoundingClientRect').mockReturnValue({
+      top: 250,
+      bottom: 270,
+      left: 0,
+      right: 100,
+      width: 100,
+      height: 20,
+      x: 0,
+      y: 250,
+      toJSON: () => ({}),
+    });
+    vi.spyOn(container, 'getBoundingClientRect').mockReturnValue({
+      top: 100,
+      bottom: 600,
+      left: 0,
+      right: 100,
+      width: 100,
+      height: 500,
+      x: 0,
+      y: 100,
+      toJSON: () => ({}),
+    });
+
+    const result = scrollToKeyword?.();
+
+    // distance = 250 - 100 - 30 = 120
+    expect(mockScrollWithinContainer).toHaveBeenCalledWith(container, 120);
+    expect(result).toBe(true);
+  });
+
+  it('scrollToKeyword should return false when no .highlighted-keyword element exists', () => {
+    const scrollElementRef = { current: container };
+
+    renderHook(() => useKeywordRescroll({ scrollElementRef, key: 'page-123' }));
+
+    const scrollToKeyword = mockWatchRenderingAndReScroll.mock.calls[0]?.[1];
+    const result = scrollToKeyword?.();
+
+    expect(result).toBe(false);
+    expect(mockScrollWithinContainer).not.toHaveBeenCalled();
+  });
+
+  it('should set up a MutationObserver on the container', () => {
+    const scrollElementRef = { current: container };
+    const observeSpy = vi.spyOn(MutationObserver.prototype, 'observe');
+
+    renderHook(() => useKeywordRescroll({ scrollElementRef, key: 'page-123' }));
+
+    expect(observeSpy).toHaveBeenCalledWith(container, {
+      childList: true,
+      subtree: true,
+    });
+
+    observeSpy.mockRestore();
+  });
+
+  it('should call watchRenderingAndReScroll cleanup when hook unmounts', () => {
+    const mockCleanup = vi.fn();
+    mockWatchRenderingAndReScroll.mockReturnValue(mockCleanup);
+
+    const scrollElementRef = { current: container };
+
+    const { unmount } = renderHook(() =>
+      useKeywordRescroll({ scrollElementRef, key: 'page-123' }),
+    );
+
+    unmount();
+
+    expect(mockCleanup).toHaveBeenCalledTimes(1);
+  });
+
+  it('should disconnect MutationObserver when hook unmounts', () => {
+    const disconnectSpy = vi.spyOn(MutationObserver.prototype, 'disconnect');
+
+    const scrollElementRef = { current: container };
+
+    const { unmount } = renderHook(() =>
+      useKeywordRescroll({ scrollElementRef, key: 'page-123' }),
+    );
+
+    unmount();
+
+    expect(disconnectSpy).toHaveBeenCalled();
+
+    disconnectSpy.mockRestore();
+  });
+
+  it('should re-run effect when key changes', () => {
+    const scrollElementRef = { current: container };
+
+    const { rerender } = renderHook(
+      ({ key }) => useKeywordRescroll({ scrollElementRef, key }),
+      { initialProps: { key: 'page-1' } },
+    );
+
+    expect(mockWatchRenderingAndReScroll).toHaveBeenCalledTimes(1);
+
+    rerender({ key: 'page-2' });
+
+    expect(mockWatchRenderingAndReScroll).toHaveBeenCalledTimes(2);
+  });
+
+  it('should do nothing when scrollElementRef.current is null', () => {
+    const scrollElementRef = { current: null as HTMLElement | null };
+
+    renderHook(() => useKeywordRescroll({ scrollElementRef, key: 'page-123' }));
+
+    expect(mockWatchRenderingAndReScroll).not.toHaveBeenCalled();
+  });
+});

+ 76 - 0
apps/app/src/features/search/client/components/SearchPage/use-keyword-rescroll.ts

@@ -0,0 +1,76 @@
+import { type RefObject, useEffect } from 'react';
+import { debounce } from 'throttle-debounce';
+
+import { scrollWithinContainer } from '~/client/util/smooth-scroll';
+import { watchRenderingAndReScroll } from '~/client/util/watch-rendering-and-rescroll';
+
+const SCROLL_OFFSET_TOP = 30;
+const MUTATION_OBSERVER_CONFIG = { childList: true, subtree: true };
+
+const scrollToTargetWithinContainer = (
+  target: HTMLElement,
+  container: HTMLElement,
+): void => {
+  const distance =
+    target.getBoundingClientRect().top -
+    container.getBoundingClientRect().top -
+    SCROLL_OFFSET_TOP;
+  scrollWithinContainer(container, distance);
+};
+
+/**
+ * Scroll to the first `.highlighted-keyword` element inside the given container.
+ * @returns true if an element was found and scrolled to, false otherwise.
+ */
+const scrollToKeyword = (scrollElement: HTMLElement): boolean => {
+  // use querySelector to intentionally get the first element found
+  const toElem = scrollElement.querySelector(
+    '.highlighted-keyword',
+  ) as HTMLElement | null;
+  if (toElem == null) return false;
+  scrollToTargetWithinContainer(toElem, scrollElement);
+  return true;
+};
+
+export interface UseKeywordRescrollOptions {
+  /** Ref to the scrollable container element */
+  scrollElementRef: RefObject<HTMLElement | null>;
+  /** Unique key that triggers re-execution (typically page._id) */
+  key: string;
+}
+
+/**
+ * Watches for keyword highlights in the scroll container and scrolls to the first one.
+ * Also integrates with the rendering watch to re-scroll after async renderer layout shifts.
+ */
+export const useKeywordRescroll = ({
+  scrollElementRef,
+  key,
+}: UseKeywordRescrollOptions): void => {
+  // biome-ignore lint/correctness/useExhaustiveDependencies: key is a trigger dep — re-run this effect when the selected page changes
+  useEffect(() => {
+    const scrollElement = scrollElementRef.current;
+
+    if (scrollElement == null) return;
+
+    const scrollToKeywordDebounced = debounce(500, () => {
+      scrollToKeyword(scrollElement);
+    });
+
+    const observer = new MutationObserver(() => {
+      scrollToKeywordDebounced();
+    });
+    observer.observe(scrollElement, MUTATION_OBSERVER_CONFIG);
+
+    // Re-scroll to keyword after async renderers (drawio/mermaid) cause layout shifts
+    const cleanupWatch = watchRenderingAndReScroll(scrollElement, () =>
+      scrollToKeyword(scrollElement),
+    );
+
+    return () => {
+      observer.disconnect();
+      scrollToKeywordDebounced.cancel();
+      cleanupWatch();
+    };
+  }, [key]);
+};

+ 5 - 1
biome.json

@@ -100,7 +100,11 @@
   },
   },
   "overrides": [
   "overrides": [
     {
     {
-      "includes": ["**/vite*.config.ts", "vitest*.config.ts"],
+      "includes": [
+        "**/vite*.config.ts",
+        "vitest*.config.ts",
+        "**/declaration.d.ts"
+      ],
       "linter": {
       "linter": {
         "rules": {
         "rules": {
           "style": {
           "style": {

+ 1 - 0
packages/core/src/consts/index.ts

@@ -1,4 +1,5 @@
 export * from './accepted-upload-file-type';
 export * from './accepted-upload-file-type';
 export * from './growi-plugin';
 export * from './growi-plugin';
+export * from './renderer';
 export * from './system';
 export * from './system';
 export * from './ydoc-status';
 export * from './ydoc-status';

+ 15 - 0
packages/core/src/consts/renderer.ts

@@ -0,0 +1,15 @@
+/**
+ * HTML attribute name applied to elements that are currently being rendered
+ * (e.g. Drawio, Mermaid diagrams). Set to "true" while rendering is in progress,
+ * toggled to "false" once rendering is complete.
+ * Used by the auto-scroll system to detect in-progress renders before re-scrolling.
+ */
+export const GROWI_IS_CONTENT_RENDERING_ATTR =
+  'data-growi-is-content-rendering' as const;
+
+/**
+ * CSS selector matching elements currently rendering (value="true" only).
+ * Does not match completed elements (value="false").
+ */
+export const GROWI_IS_CONTENT_RENDERING_SELECTOR =
+  `[${GROWI_IS_CONTENT_RENDERING_ATTR}="true"]` as const;

+ 4 - 1
packages/remark-attachment-refs/src/@types/declaration.d.ts

@@ -1,2 +1,5 @@
 // prevent TS2307: Cannot find module './xxx.module.scss' or its corresponding type declarations.
 // prevent TS2307: Cannot find module './xxx.module.scss' or its corresponding type declarations.
-declare module '*.scss';
+declare module '*.module.scss' {
+  const classes: Record<string, string>;
+  export default classes;
+}

+ 3 - 0
packages/remark-drawio/package.json

@@ -30,6 +30,9 @@
     "lint:typecheck": "tsgo --noEmit",
     "lint:typecheck": "tsgo --noEmit",
     "lint": "run-p lint:*"
     "lint": "run-p lint:*"
   },
   },
+  "dependencies": {
+    "@growi/core": "workspace:^"
+  },
   "devDependencies": {
   "devDependencies": {
     "@types/hast": "^3.0.4",
     "@types/hast": "^3.0.4",
     "@types/mdast": "^4.0.4",
     "@types/mdast": "^4.0.4",

+ 5 - 0
packages/remark-drawio/src/@types/declaration.d.ts

@@ -0,0 +1,5 @@
+// prevent TS2307: Cannot find module './xxx.module.scss' or its corresponding type declarations.
+declare module '*.module.scss' {
+  const classes: Record<string, string>;
+  export default classes;
+}

+ 17 - 3
packages/remark-drawio/src/components/DrawioViewer.tsx

@@ -8,6 +8,7 @@ import {
   useRef,
   useRef,
   useState,
   useState,
 } from 'react';
 } from 'react';
+import { GROWI_IS_CONTENT_RENDERING_ATTR } from '@growi/core/dist/consts';
 import { debounce } from 'throttle-debounce';
 import { debounce } from 'throttle-debounce';
 
 
 import type { IGraphViewerGlobal } from '..';
 import type { IGraphViewerGlobal } from '..';
@@ -127,6 +128,11 @@ export const DrawioViewer = memo((props: DrawioViewerProps): JSX.Element => {
   useEffect(() => {
   useEffect(() => {
     if (error != null) {
     if (error != null) {
       onRenderingUpdated?.(null);
       onRenderingUpdated?.(null);
+      // finish rendering to allow auto-scroll system to detect the upcoming layout shift
+      drawioContainerRef.current?.setAttribute(
+        GROWI_IS_CONTENT_RENDERING_ATTR,
+        'false',
+      );
     }
     }
   }, [error, onRenderingUpdated]);
   }, [error, onRenderingUpdated]);
 
 
@@ -141,8 +147,11 @@ export const DrawioViewer = memo((props: DrawioViewerProps): JSX.Element => {
 
 
         const mxgraphData = target.dataset.mxgraph;
         const mxgraphData = target.dataset.mxgraph;
         if (mxgraphData != null) {
         if (mxgraphData != null) {
-          const mxgraph = JSON.parse(mxgraphData);
-          onRenderingUpdated?.(mxgraph.xml);
+          onRenderingUpdated?.(JSON.parse(mxgraphData).xml);
+          drawioContainerRef.current?.setAttribute(
+            GROWI_IS_CONTENT_RENDERING_ATTR,
+            'false',
+          );
         }
         }
       }
       }
     };
     };
@@ -163,8 +172,12 @@ export const DrawioViewer = memo((props: DrawioViewerProps): JSX.Element => {
 
 
     const observer = new ResizeObserver((entries) => {
     const observer = new ResizeObserver((entries) => {
       for (const _entry of entries) {
       for (const _entry of entries) {
-        // setElementWidth(entry.contentRect.width);
         onRenderingStart?.();
         onRenderingStart?.();
+        // Signal re-rendering in progress so the auto-scroll system can detect the upcoming layout shift
+        drawioContainerRef.current?.setAttribute(
+          GROWI_IS_CONTENT_RENDERING_ATTR,
+          'true',
+        );
         renderDrawioWithDebounce();
         renderDrawioWithDebounce();
       }
       }
     });
     });
@@ -182,6 +195,7 @@ export const DrawioViewer = memo((props: DrawioViewerProps): JSX.Element => {
       className={`drawio-viewer ${styles['drawio-viewer']} p-2`}
       className={`drawio-viewer ${styles['drawio-viewer']} p-2`}
       data-begin-line-number-of-markdown={bol}
       data-begin-line-number-of-markdown={bol}
       data-end-line-number-of-markdown={eol}
       data-end-line-number-of-markdown={eol}
+      {...{ [GROWI_IS_CONTENT_RENDERING_ATTR]: 'true' }}
     >
     >
       {/* show error */}
       {/* show error */}
       {error != null && (
       {error != null && (

+ 9 - 1
packages/remark-drawio/src/services/renderer/remark-drawio.ts

@@ -1,10 +1,17 @@
+import { GROWI_IS_CONTENT_RENDERING_ATTR } from '@growi/core/dist/consts';
 import type { Properties } from 'hast';
 import type { Properties } from 'hast';
 import type { Schema as SanitizeOption } from 'hast-util-sanitize';
 import type { Schema as SanitizeOption } from 'hast-util-sanitize';
 import type { Code, Node, Paragraph } from 'mdast';
 import type { Code, Node, Paragraph } from 'mdast';
 import type { Plugin } from 'unified';
 import type { Plugin } from 'unified';
 import { visit } from 'unist-util-visit';
 import { visit } from 'unist-util-visit';
 
 
-const SUPPORTED_ATTRIBUTES = ['diagramIndex', 'bol', 'eol', 'isDarkMode'];
+const SUPPORTED_ATTRIBUTES = [
+  'diagramIndex',
+  'bol',
+  'eol',
+  'isDarkMode',
+  GROWI_IS_CONTENT_RENDERING_ATTR,
+];
 
 
 interface Data {
 interface Data {
   hName?: string;
   hName?: string;
@@ -34,6 +41,7 @@ function rewriteNode(node: Node, index: number, isDarkMode?: boolean) {
     eol: node.position?.end.line,
     eol: node.position?.end.line,
     isDarkMode: isDarkMode ? 'true' : 'false',
     isDarkMode: isDarkMode ? 'true' : 'false',
     key: `drawio-${index}`,
     key: `drawio-${index}`,
+    [GROWI_IS_CONTENT_RENDERING_ATTR]: 'true',
   };
   };
 }
 }
 
 

+ 24 - 0
packages/remark-drawio/turbo.json

@@ -0,0 +1,24 @@
+{
+  "$schema": "https://turbo.build/schema.json",
+  "extends": ["//"],
+  "tasks": {
+    "build": {
+      "dependsOn": ["@growi/core#build"],
+      "outputs": ["dist/**"],
+      "outputLogs": "new-only"
+    },
+    "dev": {
+      "dependsOn": ["@growi/core#dev"],
+      "outputs": ["dist/**"],
+      "outputLogs": "new-only"
+    },
+    "watch": {
+      "dependsOn": ["@growi/core#dev"],
+      "outputs": ["dist/**"],
+      "outputLogs": "new-only"
+    },
+    "lint": {
+      "dependsOn": ["@growi/core#dev"]
+    }
+  }
+}

+ 4 - 1
packages/remark-lsx/src/@types/declaration.d.ts

@@ -1,2 +1,5 @@
 // prevent TS2307: Cannot find module './xxx.module.scss' or its corresponding type declarations.
 // prevent TS2307: Cannot find module './xxx.module.scss' or its corresponding type declarations.
-declare module '*.scss';
+declare module '*.module.scss' {
+  const classes: Record<string, string>;
+  export default classes;
+}

+ 5 - 1
packages/remark-lsx/src/client/components/Lsx.tsx

@@ -1,4 +1,5 @@
 import React, { type JSX, useCallback, useMemo } from 'react';
 import React, { type JSX, useCallback, useMemo } from 'react';
+import { GROWI_IS_CONTENT_RENDERING_ATTR } from '@growi/core/dist/consts';
 import { LoadingSpinner } from '@growi/ui/dist/components';
 import { LoadingSpinner } from '@growi/ui/dist/components';
 
 
 import { useSWRxLsx } from '../stores/lsx';
 import { useSWRxLsx } from '../stores/lsx';
@@ -149,7 +150,10 @@ const LsxSubstance = React.memo(
     }, [data, setSize]);
     }, [data, setSize]);
 
 
     return (
     return (
-      <div className={`lsx ${styles.lsx}`}>
+      <div
+        className={`lsx ${styles.lsx}`}
+        {...{ [GROWI_IS_CONTENT_RENDERING_ATTR]: isLoading ? 'true' : 'false' }}
+      >
         <ErrorMessage />
         <ErrorMessage />
         <Loading />
         <Loading />
         {contents}
         {contents}

+ 2 - 0
packages/remark-lsx/src/client/services/renderer/lsx.ts

@@ -1,3 +1,4 @@
+import { GROWI_IS_CONTENT_RENDERING_ATTR } from '@growi/core/dist/consts';
 import {
 import {
   addTrailingSlash,
   addTrailingSlash,
   hasHeadingSlash,
   hasHeadingSlash,
@@ -25,6 +26,7 @@ const SUPPORTED_ATTRIBUTES = [
   'filter',
   'filter',
   'except',
   'except',
   'isSharedPage',
   'isSharedPage',
+  GROWI_IS_CONTENT_RENDERING_ATTR,
 ];
 ];
 
 
 type DirectiveAttributes = Record<string, string>;
 type DirectiveAttributes = Record<string, string>;

+ 3 - 0
pnpm-lock.yaml

@@ -1625,6 +1625,9 @@ importers:
 
 
   packages/remark-drawio:
   packages/remark-drawio:
     dependencies:
     dependencies:
+      '@growi/core':
+        specifier: workspace:^
+        version: link:../core
       react:
       react:
         specifier: ^18.2.0
         specifier: ^18.2.0
         version: 18.2.0
         version: 18.2.0