Yuki Takei 1 день назад
Родитель
Сommit
7e4683feda
3 измененных файлов с 128 добавлено и 102 удалено
  1. 121 95
      .kiro/specs/auto-scroll/design.md
  2. 2 2
      .kiro/specs/auto-scroll/requirements.md
  3. 5 5
      .kiro/specs/auto-scroll/spec.json

+ 121 - 95
.kiro/specs/auto-scroll/design.md

@@ -17,6 +17,7 @@
 
 
 ### Non-Goals
 ### 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
 - 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
 - Supporting non-browser environments (SSR) — this is a client-only hook
 
 
 ## Architecture
 ## Architecture
@@ -34,19 +35,25 @@ The rendering attribute `data-growi-rendering` is defined in `@growi/core` and c
 
 
 ### Architecture Pattern & Boundary Map
 ### 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
 ```mermaid
 graph TB
 graph TB
     subgraph growi_core[growi core]
     subgraph growi_core[growi core]
         CONST[Rendering Status Constants]
         CONST[Rendering Status Constants]
     end
     end
 
 
-    subgraph apps_app[apps app - client hooks]
-        HOOK[useContentAutoScroll]
+    subgraph shared_util[src/client/util]
         WATCH[watchRenderingAndReScroll]
         WATCH[watchRenderingAndReScroll]
     end
     end
 
 
-    subgraph page_views[Content Views]
+    subgraph page_view[src/components/PageView]
+        UHAS[useHashAutoScroll]
         PV[PageView]
         PV[PageView]
+    end
+
+    subgraph search[features/search/.../SearchPage]
+        UKR[useKeywordRescroll]
         SRC[SearchResultContent]
         SRC[SearchResultContent]
     end
     end
 
 
@@ -57,9 +64,10 @@ graph TB
         LSX[Lsx]
         LSX[Lsx]
     end
     end
 
 
-    PV -->|calls, default scrollTo| HOOK
-    SRC -->|calls, custom scrollTo| HOOK
-    HOOK -->|delegates| WATCH
+    PV -->|calls| UHAS
+    UHAS -->|imports| WATCH
+    SRC -->|calls| UKR
+    UKR -->|imports| WATCH
     WATCH -->|queries| CONST
     WATCH -->|queries| CONST
     DV -->|sets/toggles| CONST
     DV -->|sets/toggles| CONST
     MV -->|sets/toggles| CONST
     MV -->|sets/toggles| CONST
@@ -68,10 +76,9 @@ graph TB
 ```
 ```
 
 
 **Architecture Integration**:
 **Architecture Integration**:
-- Selected pattern: Custom hook with options object — idiomatic React, testable, extensible
-- Domain boundaries: Hook logic in `src/hooks/`, constants in `@growi/core`, attribute lifecycle in each renderer package
+- 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
 - Existing patterns preserved: MutationObserver + polling hybrid, timeout-based safety bounds
-- New components rationale: `src/hooks/` directory needed for cross-feature hooks not tied to a specific feature module
 - Steering compliance: Named exports, immutable patterns, co-located tests
 - Steering compliance: Named exports, immutable patterns, co-located tests
 
 
 ### Technology Stack
 ### Technology Stack
@@ -90,12 +97,12 @@ No new external dependencies are introduced.
 
 
 ```mermaid
 ```mermaid
 sequenceDiagram
 sequenceDiagram
-    participant Caller as Content View
-    participant Hook as useContentAutoScroll
+    participant Caller as Content View (PageView)
+    participant Hook as useHashAutoScroll
     participant DOM as DOM
     participant DOM as DOM
     participant Watch as watchRenderingAndReScroll
     participant Watch as watchRenderingAndReScroll
 
 
-    Caller->>Hook: useContentAutoScroll options
+    Caller->>Hook: useHashAutoScroll options
     Hook->>Hook: Guard checks key, hash, container
     Hook->>Hook: Guard checks key, hash, container
 
 
     alt Target exists in DOM
     alt Target exists in DOM
@@ -131,55 +138,54 @@ Key decisions:
 
 
 | Requirement | Summary | Components | Interfaces | Flows |
 | Requirement | Summary | Components | Interfaces | Flows |
 |-------------|---------|------------|------------|-------|
 |-------------|---------|------------|------------|-------|
-| 1.1, 1.2 | Immediate scroll to hash target | useContentAutoScroll | UseContentAutoScrollOptions.resolveTarget | Auto-Scroll Lifecycle |
-| 1.3, 1.4, 1.5 | Guard conditions | useContentAutoScroll | UseContentAutoScrollOptions.key, contentContainerId | — |
-| 2.1, 2.2, 2.3 | Deferred scroll for lazy targets | useContentAutoScroll (target observer) | — | Auto-Scroll Lifecycle |
-| 3.1–3.6 | Re-scroll after rendering | watchRenderingAndReScroll | scrollTo callback | Auto-Scroll Lifecycle |
+| 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.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 | — |
 | 4.8 | ResizeObserver re-render cycle | DrawioViewer | GROWI_IS_CONTENT_RENDERING_ATTR | — |
-| 5.1–5.5 | Page-type agnostic design | useContentAutoScroll, SearchResultContent | UseContentAutoScrollOptions | — |
-| 5.6, 5.7, 6.1–6.3 | Cleanup and safety | useContentAutoScroll, watchRenderingAndReScroll | cleanup functions | — |
+| 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
 ## Components and Interfaces
 
 
 | Component | Domain/Layer | Intent | Req Coverage | Key Dependencies | Contracts |
 | Component | Domain/Layer | Intent | Req Coverage | Key Dependencies | Contracts |
 |-----------|--------------|--------|--------------|------------------|-----------|
 |-----------|--------------|--------|--------------|------------------|-----------|
-| useContentAutoScroll | Client Hooks | Reusable auto-scroll hook with configurable target resolution and scroll behavior | 1, 2, 5, 6 | watchRenderingAndReScroll (P0), Rendering Status Constants (P1) | Service |
-| watchRenderingAndReScroll | Client Hooks (internal) | Polls for rendering-status attributes and re-scrolls until complete or timeout | 3, 6 | Rendering Status Constants (P0) | Service |
+| 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 |
 | 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 |
 | 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 |
 | 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 |
 | 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 |
 | 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 |
-| SearchResultContent (modification) | features/search | Integrate useContentAutoScroll with container-relative scrollTo; suppress keyword scroll when hash is present | 5.1, 5.2, 5.3, 5.5 | useContentAutoScroll (P0), scrollWithinContainer (P0) | State |
 
 
 ### Client Hooks
 ### Client Hooks
 
 
-#### useContentAutoScroll
+#### useHashAutoScroll
 
 
 | Field | Detail |
 | Field | Detail |
 |-------|--------|
 |-------|--------|
-| Intent | Reusable hook that scrolls to a target element identified by URL hash, with support for lazy-rendered content and customizable scroll behavior |
+| 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 |
 | Requirements | 1.1–1.5, 2.1–2.3, 5.1–5.7, 6.1–6.3 |
 
 
 **Responsibilities & Constraints**
 **Responsibilities & Constraints**
-- Orchestrates the full auto-scroll lifecycle: guard → resolve target → scroll → watch rendering
+- 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
 - 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
-- Must not import page-specific or feature-specific modules
+- Co-located with `PageView.tsx` — this hook is hash-navigation–specific (`window.location.hash`)
 
 
 **Dependencies**
 **Dependencies**
-- Outbound: `watchRenderingAndReScroll` — rendering watch delegation (P0)
+- Outbound: `watchRenderingAndReScroll` from `~/client/util/watch-rendering-and-rescroll` (P0)
 
 
 **Contracts**: Service [x]
 **Contracts**: Service [x]
 
 
 ##### Service Interface
 ##### Service Interface
 
 
 ```typescript
 ```typescript
-/** Configuration for the auto-scroll hook */
-interface UseContentAutoScrollOptions {
+/** Configuration for the hash-based auto-scroll hook */
+interface UseHashAutoScrollOptions {
   /**
   /**
    * Unique key that triggers re-execution when changed.
    * Unique key that triggers re-execution when changed.
-   * Typically a page ID, search query ID, or other view identifier.
    * When null/undefined, all scroll processing is skipped.
    * When null/undefined, all scroll processing is skipped.
    */
    */
   key: string | undefined | null;
   key: string | undefined | null;
@@ -202,7 +208,7 @@ interface UseContentAutoScrollOptions {
 }
 }
 
 
 /** Hook signature */
 /** Hook signature */
-function useContentAutoScroll(options: UseContentAutoScrollOptions): void;
+function useHashAutoScroll(options: UseHashAutoScrollOptions): void;
 ```
 ```
 
 
 - Preconditions: Called within a React component; browser environment with `window.location.hash` available
 - Preconditions: Called within a React component; browser environment with `window.location.hash` available
@@ -210,10 +216,51 @@ function useContentAutoScroll(options: UseContentAutoScrollOptions): void;
 - Invariants: At most one target observer and one rendering watch active per hook instance
 - Invariants: At most one target observer and one rendering watch active per hook instance
 
 
 **Implementation Notes**
 **Implementation Notes**
-- File location: `apps/app/src/client/hooks/use-content-auto-scroll/use-content-auto-scroll.ts`
-- Test file: `apps/app/src/client/hooks/use-content-auto-scroll/use-content-auto-scroll.spec.tsx`
+- 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
 - The `resolveTarget` and `scrollTo` callbacks should be wrapped in `useRef` to avoid re-triggering the effect when callback identity changes
-- Export both `useContentAutoScroll` and `watchRenderingAndReScroll` as named exports for independent testability
+
+---
+
+#### 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
 
 
 ---
 ---
 
 
@@ -221,7 +268,7 @@ function useContentAutoScroll(options: UseContentAutoScrollOptions): void;
 
 
 | Field | Detail |
 | Field | Detail |
 |-------|--------|
 |-------|--------|
-| Intent | Pure function (not a hook) that monitors rendering-status attributes and periodically re-scrolls until rendering completes or timeout |
+| 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 |
 | Requirements | 3.1–3.6, 6.1–6.3 |
 
 
 **Responsibilities & Constraints**
 **Responsibilities & Constraints**
@@ -254,6 +301,8 @@ function watchRenderingAndReScroll(
 - Invariants: At most one poll timer active at any time; stopped flag prevents post-cleanup execution
 - Invariants: At most one poll timer active at any time; stopped flag prevents post-cleanup execution
 
 
 **Implementation Notes**
 **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
 - 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
 - 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
 - The MutationObserver watches `childList`, `subtree`, and `attributes` (filtered to the rendering-status attribute) — the `childList` + `subtree` combination is what detects late-mounting async renderers
@@ -376,66 +425,24 @@ const GROWI_IS_CONTENT_RENDERING_SELECTOR =
 
 
 | Field | Detail |
 | Field | Detail |
 |-------|--------|
 |-------|--------|
-| Intent | Integrate `useContentAutoScroll` for hash-based navigation within the search result content pane; coordinate with the existing keyword-highlight scroll to prevent position conflicts |
-| Requirements | 5.1, 5.2, 5.3, 5.5 |
-
-**Background**: `SearchResultContent` renders page content inside a div with `overflow-y-scroll` (`#search-result-content-body-container`). It already has a separate keyword-highlight scroll mechanism — a `useEffect` with no dependency array that uses `MutationObserver` to scroll to the first `.highlighted-keyword` element using `scrollWithinContainer`. These two scroll mechanisms must coexist without overriding each other.
+| 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 |
 
 
-**Container-Relative Scroll Problem**
+**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.
 
 
-`element.scrollIntoView()` (the hook's default `scrollTo`) scrolls the viewport, not the scrolling container. Since `#search-result-content-body-container` is the scrolling unit, a custom `scrollTo` is required:
-
-```
-scrollTo(target):
-  distance = target.getBoundingClientRect().top
-            - container.getBoundingClientRect().top
-            - SCROLL_OFFSET_TOP
-  scrollWithinContainer(container, distance)
-```
+**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.
 
 
-The `container` reference is obtained via `scrollElementRef.current` (the existing React ref already present in the component). The `SCROLL_OFFSET_TOP = 30` constant is reused from the keyword scroll for visual consistency.
-
-**Scroll Conflict Resolution**
-
-When a URL hash is present, both mechanisms would fire:
-1. `useContentAutoScroll` → scroll to the hash target element
-2. Keyword MutationObserver → scroll to the first `.highlighted-keyword` element (500ms debounced)
-
-This creates a race condition where the keyword scroll overrides the hash scroll. Resolution strategy:
-
-> **When `window.location.hash` is non-empty, the keyword-highlight `useEffect` returns early.** Hash-based scroll takes priority.
-
-Concretely, the existing keyword-scroll `useEffect` gains a guard at the top:
-
-```
-if (window.location.hash.length > 0) return;
-```
-
-When no hash is present, keyword scroll proceeds exactly as before — no behavior change for the common case.
+**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**
 **Hook Call Site**
 
 
 ```typescript
 ```typescript
-const scrollTo = useCallback((target: HTMLElement) => {
-  const container = scrollElementRef.current;
-  if (container == null) return;
-  const distance =
-    target.getBoundingClientRect().top -
-    container.getBoundingClientRect().top -
-    SCROLL_OFFSET_TOP;
-  scrollWithinContainer(container, distance);
-}, []);
-
-useContentAutoScroll({
-  key: page._id,
-  contentContainerId: 'search-result-content-body-container',
-  scrollTo,
-});
+useKeywordRescroll({ scrollElementRef, key: page._id });
 ```
 ```
 
 
-- `resolveTarget` defaults to `document.getElementById` — heading elements have `id` attributes set by the remark processing pipeline, so the default resolver works without customization.
-- `scrollTo` uses the existing `scrollElementRef` directly to avoid redundant `getElementById` lookup.
-- `useCallback` with empty deps array ensures callback identity is stable across renders (the hook wraps it in a ref internally, but stable identity avoids any risk of spurious re-renders).
+- `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`
 **File**: `apps/app/src/features/search/client/components/SearchPage/SearchResultContent.tsx`
 
 
@@ -570,17 +577,21 @@ This feature operates entirely in the browser DOM layer with no server interacti
 
 
 ## Testing Strategy
 ## Testing Strategy
 
 
-### Unit Tests (co-located in `src/client/hooks/use-content-auto-scroll/`)
+### useHashAutoScroll Tests (co-located in `src/components/PageView/`)
 
 
 1. **Guard conditions**: Verify no-op when key is null, hash is empty, or container not found (1.3–1.5)
 1. **Guard conditions**: Verify no-op when key is null, hash is empty, or container not found (1.3–1.5)
 2. **Immediate scroll**: Target exists in DOM → `scrollTo` called once (1.1)
 2. **Immediate scroll**: Target exists in DOM → `scrollTo` called once (1.1)
 3. **Encoded hash**: URI-encoded hash decoded and resolved correctly (1.2)
 3. **Encoded hash**: URI-encoded hash decoded and resolved correctly (1.2)
 4. **Custom resolveTarget**: Provided closure is called instead of default `getElementById` (5.2)
 4. **Custom resolveTarget**: Provided closure is called instead of default `getElementById` (5.2)
 5. **Custom scrollTo**: Provided scroll function is called instead of default `scrollIntoView` (5.3)
 5. **Custom scrollTo**: Provided scroll function is called instead of default `scrollIntoView` (5.3)
-6. **Late-mounting renderers**: Rendering elements that appear after the initial scroll are detected and trigger a re-scroll (key scenario for Mermaid/PlantUML)
-7. **No spurious re-scroll when no renderers**: When no rendering elements ever appear, the watch times out without calling `scrollTo` again (validates always-start trade-off)
+6. **Late-mounting renderers**: Rendering elements that appear after the initial scroll are detected and trigger a re-scroll
+7. **No spurious re-scroll when no renderers**: When no rendering elements ever appear, the watch times out without calling `scrollTo` again
+8. **Deferred scroll**: Target appears after initial render via MutationObserver (2.1, 2.2)
+9. **Target observation timeout**: 10s timeout when target never appears (2.3)
+10. **Key change cleanup**: Observers and timers from previous run are released (5.6)
+11. **Unmount cleanup**: All resources released (5.7, 6.1)
 
 
-### Integration Tests (watchRenderingAndReScroll)
+### watchRenderingAndReScroll Tests (co-located in `src/client/util/`)
 
 
 1. **Rendering elements present**: Poll timer fires at 5s, re-scroll executes (3.1)
 1. **Rendering elements present**: Poll timer fires at 5s, re-scroll executes (3.1)
 2. **No rendering elements**: No timer scheduled (3.3)
 2. **No rendering elements**: No timer scheduled (3.3)
@@ -590,14 +601,29 @@ This feature operates entirely in the browser DOM layer with no server interacti
 6. **Watch timeout**: All resources cleaned up after 10s (3.6, 6.2)
 6. **Watch timeout**: All resources cleaned up after 10s (3.6, 6.2)
 7. **Cleanup prevents post-cleanup execution**: Stopped flag prevents race (6.1)
 7. **Cleanup prevents post-cleanup execution**: Stopped flag prevents race (6.1)
 8. **Rendering completes before first timer**: Immediate re-scroll fires via wasRendering path, no extra scroll after that
 8. **Rendering completes before first timer**: Immediate re-scroll fires via wasRendering path, no extra scroll after that
+9. **Active timer cancelled when rendering elements removed**: Avoids redundant re-scroll
+
+### useKeywordRescroll Tests (co-located in `features/search/.../SearchPage/`)
+
+1. **watchRenderingAndReScroll called with scroll container**: Correct container element passed
+2. **scrollToKeyword scrolls to first .highlighted-keyword**: Container-relative scroll calculation verified
+3. **scrollToKeyword returns false when no keyword found**: No scroll attempted
+4. **MutationObserver set up on container**: Correct observe config verified
+5. **Cleanup on unmount**: MO disconnected, rendering watch cleanup called, debounce cancelled
+6. **Key change re-runs effect**: New watch started for new key
+7. **Null container guard**: No-op when scrollElementRef.current is null
+
+### SearchResultContent Tests (co-located with component)
+
+1. **Hook integration**: `useKeywordRescroll` called with correct key and scroll container ref
+2. **Key change**: Hook re-called with new key on page change
 
 
 ### MermaidViewer Tests
 ### MermaidViewer Tests
 
 
-1. **rAF cleanup on unmount**: When component unmounts during the async render, the pending `requestAnimationFrame` is cancelled — no `setAttribute` call after unmount
-2. **isDarkMode change re-renders correctly**: Attribute resets to `"true"` on re-render and transitions to `"false"` via rAF after the new render completes
+1. **rAF cleanup on unmount**: Pending `requestAnimationFrame` cancelled on unmount
+2. **Rendering attribute lifecycle**: "true" initially → "false" via rAF after render → "false" immediately on error
 
 
-### Hook Lifecycle Tests
+### PlantUmlViewer Tests
 
 
-1. **Key change**: Cleanup runs, new scroll cycle starts (5.6)
-2. **Unmount**: All observers and timers cleaned up (5.7, 6.1)
-3. **Re-render with same key**: Effect does not re-trigger (stability)
+1. **Rendering attribute lifecycle**: "true" initially → "false" on img load → "false" on img error
+2. **img src**: Correct src attribute rendered

+ 2 - 2
.kiro/specs/auto-scroll/requirements.md

@@ -63,7 +63,7 @@ The following reviewer feedback is incorporated into these requirements:
 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).
 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.
 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.
 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, and lsx (Lsx). Other async renderers (PlantUML, attachment-refs, RichAttachment) are deferred to follow-up work.
+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.
 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
 ### Requirement 5: Page-Type Agnostic Design
@@ -76,7 +76,7 @@ The following reviewer feedback is incorporated into these requirements:
 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.
 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.
 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).
 4. The hook shall not import or depend on any page-specific state (Jotai atoms, SWR hooks, or page models).
-5. The hook shall be located in a shared directory (e.g., `src/client/hooks/`) and named to reflect its general-purpose nature — not tied to a specific page component.
+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.
 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.
 7. When the component using the hook unmounts, the hook shall clean up all MutationObservers, timers, and rendering watch resources.
 
 

+ 5 - 5
.kiro/specs/auto-scroll/spec.json

@@ -1,9 +1,9 @@
 {
 {
   "feature_name": "auto-scroll",
   "feature_name": "auto-scroll",
   "created_at": "2026-04-02T00:00:00.000Z",
   "created_at": "2026-04-02T00:00:00.000Z",
-  "updated_at": "2026-04-06T00:00:00.000Z",
+  "updated_at": "2026-04-07T00:00:00.000Z",
   "language": "en",
   "language": "en",
-  "phase": "tasks-generated",
+  "phase": "implementation-complete",
   "approvals": {
   "approvals": {
     "requirements": {
     "requirements": {
       "generated": true,
       "generated": true,
@@ -15,9 +15,9 @@
     },
     },
     "tasks": {
     "tasks": {
       "generated": true,
       "generated": true,
-      "approved": false,
-      "notes": "Task 8 redesigned as module reorganization (5 subtasks). Tasks 1–7 complete."
+      "approved": true,
+      "notes": "All tasks (1–8) complete. Design updated to reflect final architecture after module reorganization."
     }
     }
   },
   },
-  "ready_for_implementation": false
+  "ready_for_implementation": true
 }
 }