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

Merge remote-tracking branch 'origin/master' into support/migrate-to-pino

Yuki Takei 1 месяц назад
Родитель
Сommit
e47118e389
51 измененных файлов с 2611 добавлено и 849 удалено
  1. 466 0
      .kiro/specs/auto-scroll/design.md
  2. 91 0
      .kiro/specs/auto-scroll/requirements.md
  3. 246 0
      .kiro/specs/auto-scroll/research.md
  4. 6 4
      .kiro/specs/auto-scroll/spec.json
  5. 154 0
      .kiro/specs/auto-scroll/tasks.md
  6. 0 99
      .kiro/specs/suggest-path/tasks.md
  7. 0 262
      .kiro/specs/upgrade-fixed-packages/design.md
  8. 0 75
      .kiro/specs/upgrade-fixed-packages/requirements.md
  9. 0 183
      .kiro/specs/upgrade-fixed-packages/research.md
  10. 0 89
      .kiro/specs/upgrade-fixed-packages/tasks.md
  11. 4 0
      apps/app/bin/openapi/definition-apiv3.js
  12. 1 0
      apps/app/bin/openapi/generate-spec-apiv3.sh
  13. 20 2
      apps/app/src/client/components/PageCreateModal.tsx
  14. 6 0
      apps/app/src/client/services/renderer/renderer.tsx
  15. 201 0
      apps/app/src/client/util/watch-rendering-and-rescroll.spec.tsx
  16. 84 0
      apps/app/src/client/util/watch-rendering-and-rescroll.ts
  17. 4 45
      apps/app/src/components/PageView/PageView.tsx
  18. 329 0
      apps/app/src/components/PageView/use-hash-auto-scroll.spec.tsx
  19. 106 0
      apps/app/src/components/PageView/use-hash-auto-scroll.ts
  20. 78 0
      apps/app/src/features/ai-tools/suggest-path/server/routes/apiv3/index.ts
  21. 133 0
      apps/app/src/features/mermaid/components/MermaidViewer.spec.tsx
  22. 23 1
      apps/app/src/features/mermaid/components/MermaidViewer.tsx
  23. 3 1
      apps/app/src/features/mermaid/services/mermaid.ts
  24. 53 0
      apps/app/src/features/plantuml/components/PlantUmlViewer.spec.tsx
  25. 31 0
      apps/app/src/features/plantuml/components/PlantUmlViewer.tsx
  26. 1 0
      apps/app/src/features/plantuml/components/index.ts
  27. 1 0
      apps/app/src/features/plantuml/index.ts
  28. 1 1
      apps/app/src/features/plantuml/services/index.ts
  29. 43 1
      apps/app/src/features/plantuml/services/plantuml.ts
  30. 126 0
      apps/app/src/features/search/client/components/SearchPage/SearchResultContent.spec.tsx
  31. 8 50
      apps/app/src/features/search/client/components/SearchPage/SearchResultContent.tsx
  32. 182 0
      apps/app/src/features/search/client/components/SearchPage/use-keyword-rescroll.spec.tsx
  33. 76 0
      apps/app/src/features/search/client/components/SearchPage/use-keyword-rescroll.ts
  34. 5 1
      biome.json
  35. 1 1
      package.json
  36. 6 0
      packages/core/CHANGELOG.md
  37. 1 1
      packages/core/package.json
  38. 1 0
      packages/core/src/consts/index.ts
  39. 15 0
      packages/core/src/consts/renderer.ts
  40. 7 0
      packages/pluginkit/CHANGELOG.md
  41. 1 1
      packages/pluginkit/package.json
  42. 4 1
      packages/remark-attachment-refs/src/@types/declaration.d.ts
  43. 3 0
      packages/remark-drawio/package.json
  44. 5 0
      packages/remark-drawio/src/@types/declaration.d.ts
  45. 17 3
      packages/remark-drawio/src/components/DrawioViewer.tsx
  46. 9 1
      packages/remark-drawio/src/services/renderer/remark-drawio.ts
  47. 24 0
      packages/remark-drawio/turbo.json
  48. 4 1
      packages/remark-lsx/src/@types/declaration.d.ts
  49. 5 1
      packages/remark-lsx/src/client/components/Lsx.tsx
  50. 2 0
      packages/remark-lsx/src/client/services/renderer/lsx.ts
  51. 24 25
      pnpm-lock.yaml

+ 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

+ 6 - 4
.kiro/specs/upgrade-fixed-packages/spec.json → .kiro/specs/auto-scroll/spec.json

@@ -1,7 +1,8 @@
 {
-  "feature_name": "upgrade-fixed-packages",
-  "created_at": "2026-03-23T00:00:00Z",
-  "updated_at": "2026-03-23T00:00:00Z",
+  "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": {
@@ -15,7 +16,8 @@
     },
     "tasks": {
       "generated": true,
-      "approved": 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_

+ 0 - 99
.kiro/specs/suggest-path/tasks.md

@@ -4,100 +4,19 @@
 
 - [x] 1. Phase 1 MVP — Shared types, memo path suggestion, and endpoint registration
 - [x] 1.1 Define suggestion types and implement memo path generation
-  - Define the suggestion response types used across both phases: suggestion type discriminator, individual suggestion structure with type/path/label/description/grant fields, and the response wrapper
-  - Implement memo path generation: when user pages are enabled (default), generate path under the user's home directory with owner-only grant; when user pages are disabled, generate path under an alternative namespace with hardcoded owner-only grant (actual parent grant resolution deferred to Phase 2 task 2)
-  - Enforce directory path format with trailing slash for all generated paths
-  - Generate fixed descriptive text for memo suggestions
-  - Include unit tests covering both user-pages-enabled and user-pages-disabled paths, verifying correct path format, grant value, and description
-  - _Requirements: 1.2, 1.3, 2.1, 2.2, 2.3, 2.4, 2.5, 6.1, 6.2_
-
 - [x] 1.2 Register route endpoint with authentication and validation
-  - Create the route under a new namespace separate from the page API, following the existing handler factory pattern
-  - Apply the standard middleware chain: access token parsing, strict login requirement, AI service gating, request body validation
-  - Implement the handler to invoke memo suggestion generation for the authenticated user and return the suggestions array using the standard API response format
-  - Return appropriate error responses for authentication failures, validation failures, and AI-disabled states without exposing internal system details
-  - Register the new namespace route in the central API router
-  - _Requirements: 1.1, 1.4, 8.1, 8.2, 8.3, 9.1, 9.2_
-
 - [x] 1.3 Phase 1 integration verification
-  - Verify the complete request-response cycle for the memo suggestion endpoint with valid authentication
-  - Verify authentication enforcement: unauthenticated requests receive appropriate error responses
-  - Verify input validation: requests with missing or empty body field receive validation errors
-  - Verify AI service gating: requests when AI is disabled receive appropriate error responses
-  - Verify response structure: correct fields, trailing slash on path, correct grant value
-  - _Requirements: 1.1, 1.2, 1.3, 1.4, 2.1, 8.1, 8.2, 9.1, 9.2_
 
 ## Phase 2 — Revised
 
 - [x] 2. (P) Enhance grant resolver for ancestor path traversal
-  - Enhance the existing grant resolution to support paths that may not yet exist in GROWI, as required by the sibling pattern where new directory names are generated
-  - When the direct parent page exists, return its grant value as the upper bound for child page permissions
-  - When the direct parent page is not found, traverse upward through ancestor paths to find the nearest existing page's grant
-  - When no ancestor page is found at any level, return owner-only grant as a safe default
-  - Include unit tests for: direct parent found, ancestor found at various depths, no ancestor found (safe default), root-level paths, paths with trailing slashes
-  - _Requirements: 7.1, 7.2_
-
 - [x] 3. (P) Content analysis via GROWI AI (1st AI call)
-  - Implement content analysis that delegates to GROWI AI for a single AI call performing both keyword extraction and flow/stock information type classification
-  - Extract 1-5 keywords from the content, prioritizing proper nouns and technical terms over generic words
-  - Classify the content as either flow information (time-bound: meeting notes, diaries, reports) or stock information (reference: documentation, knowledge base articles)
-  - Reference the existing flow/stock classification guidance as a prompt reference, without treating it as the sole classification criterion
-  - On analysis failure or inability to produce usable keywords, throw an error so the caller can handle fallback logic
-  - Include unit tests for: successful keyword extraction with quality verification, correct flow/stock classification for representative content samples, edge cases (very short content, ambiguous content), and failure propagation
-  - _Requirements: 5.1, 5.2, 5.4_
-
 - [x] 4. (P) Search candidate retrieval with score threshold filtering
-  - Implement search candidate retrieval that searches for related pages using extracted keywords via the existing search service
-  - Use extracted keywords (not raw content body) for search operations
-  - Filter search results using an Elasticsearch score threshold to retain only sufficiently relevant candidates
-  - Return an array of candidates with page path, snippet, and score for downstream AI evaluation
-  - Return an empty array if no results pass the threshold, allowing the caller to omit search-based suggestions
-  - The score threshold value is configurable and will be tuned with real data during implementation
-  - Include unit tests for: multi-result retrieval, threshold filtering (candidates above/below/at threshold), empty result handling, and correct candidate structure
-  - _Requirements: 3.1, 3.2, 3.5, 5.3_
-
 - [x] 5. (P) AI-based candidate evaluation and path proposal (2nd AI call)
-  - Implement candidate evaluation that delegates to GROWI AI for a single AI call evaluating search candidates for content-destination fit
-  - Evaluate each candidate's suitability by passing the content body, the content analysis results (keywords and informationType from the 1st AI call), and each candidate's path and search snippet
-  - For each suitable candidate, propose a save location using one of three structural patterns relative to the matching page: (a) parent directory, (b) subdirectory under the matching page, (c) sibling directory alongside the matching page
-  - When the sibling pattern is selected, generate an appropriate new directory name based on the content being saved; the generated path must be at the same hierarchy level as the matching search candidate page
-  - Generate a description for each suggestion explaining why the location is suitable, considering content relevance and flow/stock alignment
-  - Rank suggestions by content-destination fit, using flow/stock information type alignment as a ranking factor rather than a hard filter
-  - Pass candidate paths and ES snippets to the AI context, not full page bodies, to manage AI context budget
-  - On evaluation failure, throw an error so the caller can handle fallback logic
-  - Include unit tests for: path pattern selection across all three patterns, sibling path generation at correct hierarchy level, AI-generated description quality, ranking order, flow/stock alignment consideration, and failure propagation
-  - _Requirements: 3.3, 6.3, 10.1, 10.2, 10.3, 10.4, 11.1, 11.2, 11.3, 12.1, 12.2, 12.3, 12.4_
-
 - [x] 6. (P) Category-based path suggestion (under review — prior implementation retained)
-  - This component has an existing implementation from the prior Phase 2 design; it is retained as-is pending reviewer discussion on whether to keep, merge, or remove
-  - Search for matching pages scoped to top-level directories using extracted keywords
-  - Extract the top-level path segment from the most relevant result as the suggested category directory
-  - Generate a description from the top-level segment name using mechanical text, not AI
-  - Resolve the parent page's grant value via grant resolution
-  - Return null when no matching top-level pages are found, so this suggestion type is omitted from the response
-  - Include unit tests for: top-level segment extraction, description generation, grant resolution, and empty result handling
-  - _Requirements: 4.1, 4.2, 4.3, 4.4_
-
 - [x] 7. Phase 2 revised orchestration and integration
 - [x] 7.1 Rewrite orchestration for revised Phase 2 pipeline
-  - Rewrite the orchestration function to implement the revised Phase 2 pipeline: always generate memo suggestion first as guaranteed fallback, then invoke content analysis (1st AI call), pass keywords to search candidate retrieval, pass candidates to candidate evaluation (2nd AI call), and run category generation in parallel with the search-evaluate pipeline
-  - After candidate evaluation returns, resolve grant for each proposed path via grant resolver
-  - Map the informationType from content analysis onto each search-type suggestion in the final response, and add informationType as an optional field on the suggestion type
-  - Ensure the response includes both structured metadata (informationType, type, grant) and natural language context (description) for client LLM independence
-  - Ensure all reasoning-intensive operations (keyword extraction, flow/stock classification, candidate evaluation, path proposal, description generation) are performed server-side
-  - Handle graceful degradation at each failure point: content analysis failure skips the entire search pipeline (memo-only), candidate evaluation failure falls back to memo + category (if available), category failure is independent and does not affect the search pipeline
-  - Ensure the response always contains at least one suggestion (memo type)
-  - Update the route handler to use the revised orchestration function with injected dependencies
-  - Include unit tests for: full pipeline success with all suggestion types, partial failures at each stage with correct degradation, informationType mapping to PathSuggestion, dependency injection, and parallel execution of category vs search-evaluate pipeline
-  - _Requirements: 1.1, 1.2, 1.3, 3.3, 3.4, 5.3, 5.5, 8.3, 9.2, 11.4, 13.1, 13.2, 13.3_
-
 - [x] 7.2 Phase 2 integration verification
-  - Verify the complete revised flow end-to-end: content body → content analysis (keywords + informationType) → search candidate retrieval (with score threshold) → candidate evaluation (path proposals + descriptions) → grant resolution → unified response with all suggestion types
-  - Verify informationType field is present in search-based suggestions and absent in memo and category suggestions
-  - Verify path proposal patterns work correctly: parent directory, subdirectory, and sibling with generated new paths at the correct hierarchy level
-  - Verify graceful degradation at each failure point: content analysis failure → memo-only, search returns empty → search suggestions omitted, candidate evaluation failure → memo + category, category failure → memo + search, all Phase 2 failures → memo-only
-  - Verify response structure across all suggestion types: correct fields, AI-generated descriptions for search type, fixed description for memo, mechanical description for category, valid grant values, and trailing slashes on all paths
-  - _Requirements: 3.1, 3.2, 3.3, 3.4, 3.5, 5.1, 5.4, 5.5, 6.1, 6.3, 10.1, 11.1, 11.4, 12.1, 13.1, 13.2_
 
 ## Post-Implementation Refactoring (from code review)
 
@@ -105,27 +24,9 @@ See `gap-analysis.md` for detailed rationale.
 
 - [x] 8. Simplify service layer abstractions
 - [x] 8.1 Remove `GenerateSuggestionsDeps` DI pattern from `generate-suggestions.ts`
-  - Remove the `GenerateSuggestionsDeps` type and `deps` parameter from `generateSuggestions()`
-  - Import `analyzeContent`, `evaluateCandidates`, `generateCategorySuggestion`, `resolveParentGrant` directly
-  - Accept `searchService` as a direct argument (the only true external dependency that cannot be imported)
-  - Rewrite `generate-suggestions.spec.ts` to use `vi.mock()` instead of injected mock deps
-  - Simplify the route handler in `routes/apiv3/index.ts` to pass `searchService` directly instead of wiring 5 callbacks
-
 - [x] 8.2 Remove `RetrieveSearchCandidatesOptions` from `retrieve-search-candidates.ts`
-  - Replace `options: RetrieveSearchCandidatesOptions` with a direct `searchService: SearchService` parameter
-  - Keep `scoreThreshold` as a module-level constant (no caller overrides it)
-  - Update `retrieve-search-candidates.spec.ts` accordingly
-  - Update the call site in `generate-suggestions.ts` (no more lambda wrapper needed)
-
 - [x] 8.3 Add JSDoc to `call-llm-for-json.ts`
-  - Add a brief JSDoc comment explaining this utility's purpose: shared LLM client initialization, JSON parsing, and response validation
-  - Document that it is consumed by `analyzeContent` and `evaluateCandidates`
-
 - [x] 8.4 Narrow `userGroups: unknown` to `ObjectIdLike[]`
-  - Update `SearchService` interface in `suggest-path-types.ts`: change `userGroups: unknown` to `userGroups: ObjectIdLike[]`
-  - Propagate the type change to `retrieveSearchCandidates` and `generateSuggestions` signatures
-  - Import `ObjectIdLike` from `@growi/core` (or the appropriate subpath)
-  - Update test files to use correctly typed mock values
 
 ## Requirements Coverage
 

+ 0 - 262
.kiro/specs/upgrade-fixed-packages/design.md

@@ -1,262 +0,0 @@
-# Design Document: upgrade-fixed-packages
-
-## Overview
-
-**Purpose**: This feature audits and upgrades version-pinned packages in `apps/app/package.json` that were frozen due to upstream bugs, ESM-only migrations, or licensing constraints. The build environment has shifted from webpack to Turbopack, and the runtime now targets Node.js 24 with stable `require(esm)` support, invalidating several original pinning reasons.
-
-**Users**: Maintainers and developers benefit from up-to-date dependencies with bug fixes, security patches, and reduced technical debt.
-
-**Impact**: Modifies `apps/app/package.json` dependency versions and comment blocks; touches source files where `escape-string-regexp` is replaced by native `RegExp.escape()`.
-
-### Goals
-- Verify each pinning reason against current upstream status
-- Upgrade packages where the original constraint no longer applies
-- Replace `escape-string-regexp` with native `RegExp.escape()` (Node.js 24)
-- Update or remove comment blocks to reflect current state
-- Produce audit documentation for future reference
-
-### Non-Goals
-- Replacing handsontable with an alternative library (license constraint remains; replacement is a separate initiative)
-- Upgrading `@keycloak/keycloak-admin-client` to v19+ (significant API breaking changes; deferred to separate task)
-- Major version upgrades of unrelated packages
-- Modifying the build pipeline or Turbopack configuration
-
-## Architecture
-
-This is a dependency maintenance task, not a feature implementation. No new components or architectural changes are introduced.
-
-### Existing Architecture Analysis
-
-The pinned packages fall into distinct categories by their usage context:
-
-| Category | Packages | Build Context |
-|----------|----------|---------------|
-| Server-only (tsc → CJS) | `escape-string-regexp`, `@aws-sdk/*`, `@keycloak/*` | Express server compiled by tsc |
-| Client-only (Turbopack) | `string-width` (via @growi/editor), `bootstrap` | Bundled by Turbopack/Vite |
-| Client + SSR | `next-themes` | Turbopack + SSR rendering |
-| License-pinned | `handsontable`, `@handsontable/react` | Client-only |
-
-Key enabler: Node.js ^24 provides stable `require(esm)` support, removing the fundamental CJS/ESM incompatibility that caused several pins.
-
-### Technology Stack
-
-| Layer | Choice / Version | Role in Feature | Notes |
-|-------|------------------|-----------------|-------|
-| Runtime | Node.js ^24 | Enables `require(esm)` and `RegExp.escape()` | ES2026 Stage 4 features available |
-| Build (client) | Turbopack (Next.js 16) | Bundles ESM-only packages without issues | No changes needed |
-| Build (server) | tsc (CommonJS output) | `require(esm)` handles ESM-only imports | Node.js 24 native support |
-| Package manager | pnpm v10 | Manages dependency resolution | No changes needed |
-
-## System Flows
-
-### Upgrade Verification Flow
-
-```mermaid
-flowchart TD
-    Start[Select package to upgrade] --> Update[Update version in package.json]
-    Update --> Install[pnpm install]
-    Install --> Build{turbo run build}
-    Build -->|Pass| Lint{turbo run lint}
-    Build -->|Fail| Revert[Revert package change]
-    Lint -->|Pass| Test{turbo run test}
-    Lint -->|Fail| Revert
-    Test -->|Pass| Verify[Verify .next/node_modules symlinks]
-    Test -->|Fail| Revert
-    Verify --> Next[Proceed to next package]
-    Revert --> Document[Document failure reason]
-    Document --> Next
-```
-
-Each package is upgraded and verified independently. Failures are isolated and reverted without affecting other upgrades.
-
-## Requirements Traceability
-
-| Requirement | Summary | Components | Action |
-|-------------|---------|------------|--------|
-| 1.1 | Bootstrap bug investigation | PackageAudit | Verify #39798 fixed in v5.3.4 |
-| 1.2 | next-themes issue investigation | PackageAudit | Verify #122 resolved; check v0.4.x compatibility |
-| 1.3 | @aws-sdk constraint verification | PackageAudit | Confirm mongodb constraint is on different package |
-| 1.4 | Document investigation results | AuditReport | Summary table in research.md |
-| 2.1 | ESM compatibility per package | PackageAudit | Assess escape-string-regexp, string-width, @keycloak |
-| 2.2 | Server build ESM support | PackageAudit | Verify Node.js 24 require(esm) for server context |
-| 2.3 | Client build ESM support | PackageAudit | Confirm Turbopack handles ESM-only packages |
-| 2.4 | Compatibility matrix | AuditReport | Table in research.md |
-| 3.1 | Handsontable license check | PackageAudit | Confirm v7+ still non-MIT |
-| 3.2 | Document pinning requirement | AuditReport | Note in audit summary |
-| 4.1 | Update package.json versions and comments | UpgradeExecution | Modify versions and comment blocks |
-| 4.2 | Build verification | UpgradeExecution | `turbo run build --filter @growi/app` |
-| 4.3 | Lint verification | UpgradeExecution | `turbo run lint --filter @growi/app` |
-| 4.4 | Test verification | UpgradeExecution | `turbo run test --filter @growi/app` |
-| 4.5 | Revert on failure | UpgradeExecution | Git revert per package |
-| 4.6 | Update comment blocks | UpgradeExecution | Remove or update comments |
-| 5.1 | Audit summary table | AuditReport | Final summary with decisions |
-| 5.2 | Document continued pinning | AuditReport | Reasons for remaining pins |
-| 5.3 | Document upgrade rationale | AuditReport | What changed upstream |
-
-## Components and Interfaces
-
-| Component | Domain | Intent | Req Coverage | Key Dependencies |
-|-----------|--------|--------|--------------|------------------|
-| PackageAudit | Investigation | Research upstream status for each pinned package | 1.1–1.4, 2.1–2.4, 3.1–3.2 | GitHub issues, npm registry |
-| UpgradeExecution | Implementation | Apply version changes and verify build | 4.1–4.6 | pnpm, turbo, tsc |
-| SourceMigration | Implementation | Replace escape-string-regexp with RegExp.escape() | 4.1 | 9 source files |
-| AuditReport | Documentation | Produce summary of all decisions | 5.1–5.3 | research.md |
-
-### Investigation Layer
-
-#### PackageAudit
-
-| Field | Detail |
-|-------|--------|
-| Intent | Investigate upstream status of each pinned package and determine upgrade feasibility |
-| Requirements | 1.1, 1.2, 1.3, 1.4, 2.1, 2.2, 2.3, 2.4, 3.1, 3.2 |
-
-**Responsibilities & Constraints**
-- Check upstream issue trackers for bug fix status
-- Verify ESM compatibility against Node.js 24 `require(esm)` and Turbopack
-- Confirm license status for handsontable
-- Produce actionable recommendation per package
-
-**Audit Decision Matrix**
-
-| Package | Current | Action | Target | Risk | Rationale |
-|---------|---------|--------|--------|------|-----------|
-| `bootstrap` | `=5.3.2` | Upgrade | `^5.3.4` | Low | Bug #39798 fixed in v5.3.4 |
-| `next-themes` | `^0.2.1` | Upgrade | `^0.4.4` | Medium | Original issue was misattributed; v0.4.x works with Pages Router |
-| `escape-string-regexp` | `^4.0.0` | Replace | Remove dep | Low | Native `RegExp.escape()` in Node.js 24 |
-| `string-width` | `=4.2.2` | Upgrade | `^7.0.0` | Low | Used only in ESM context (@growi/editor) |
-| `@aws-sdk/client-s3` | `3.454.0` | Relax | `^3.454.0` | Low | Pinning comment was misleading |
-| `@aws-sdk/s3-request-presigner` | `3.454.0` | Relax | `^3.454.0` | Low | Same as above |
-| `@keycloak/keycloak-admin-client` | `^18.0.0` | Defer | No change | N/A | API breaking changes; separate task |
-| `handsontable` | `=6.2.2` | Keep | No change | N/A | License constraint (non-MIT since v7) |
-| `@handsontable/react` | `=2.1.0` | Keep | No change | N/A | Requires handsontable >= 7 |
-
-### Implementation Layer
-
-#### UpgradeExecution
-
-| Field | Detail |
-|-------|--------|
-| Intent | Apply version changes incrementally with build verification |
-| Requirements | 4.1, 4.2, 4.3, 4.4, 4.5, 4.6 |
-
-**Responsibilities & Constraints**
-- Upgrade one package at a time to isolate failures
-- Run full verification suite (build, lint, test) after each change
-- Revert and document any package that causes failures
-- Update `// comments for dependencies` block to reflect new state
-
-**Upgrade Order** (lowest risk first):
-1. `@aws-sdk/*` — relax version range (no code changes)
-2. `string-width` — upgrade in @growi/editor (isolated ESM package)
-3. `bootstrap` — upgrade to ^5.3.4 (verify SCSS compilation)
-4. `escape-string-regexp` → `RegExp.escape()` — source code changes across 9 files
-5. `next-themes` — upgrade to ^0.4.x (review API changes across 12 files)
-
-**Implementation Notes**
-- After each upgrade, verify `.next/node_modules/` symlinks for Turbopack externalisation compliance (per `package-dependencies` rule)
-- For bootstrap: run `pnpm run pre:styles-commons` and `pnpm run pre:styles-components` to verify SCSS compilation
-- For next-themes: review v0.3.0 and v0.4.0 changelogs for breaking API changes before modifying code
-
-#### SourceMigration
-
-| Field | Detail |
-|-------|--------|
-| Intent | Replace all `escape-string-regexp` usage with native `RegExp.escape()` |
-| Requirements | 4.1 |
-
-**Files to Modify**:
-
-`apps/app/src/` (6 files):
-- `server/models/page.ts`
-- `server/service/page/index.ts`
-- `server/service/page-grant.ts`
-- `server/routes/apiv3/users.js`
-- `server/models/obsolete-page.js`
-- `features/openai/server/services/openai.ts`
-
-`packages/` (3 files):
-- `packages/core/src/utils/page-path-utils/` (2 files)
-- `packages/remark-lsx/src/server/routes/list-pages/index.ts`
-
-**Migration Pattern**:
-```typescript
-// Before
-import escapeStringRegexp from 'escape-string-regexp';
-const pattern = new RegExp(escapeStringRegexp(input));
-
-// After
-const pattern = new RegExp(RegExp.escape(input));
-```
-
-**Implementation Notes**
-- Remove `escape-string-regexp` from `apps/app/package.json` dependencies after migration
-- Remove from `packages/core/package.json` and `packages/remark-lsx/package.json` if listed
-- Verify `RegExp.escape()` TypeScript types are available (may need `@types/node` update or lib config)
-
-### Documentation Layer
-
-#### AuditReport
-
-| Field | Detail |
-|-------|--------|
-| Intent | Document all audit decisions for future maintainers |
-| Requirements | 5.1, 5.2, 5.3 |
-
-**Deliverables**:
-- Updated `// comments for dependencies` in package.json (only retained pins with current reasons)
-- Updated `// comments for defDependencies` (handsontable entries unchanged)
-- Summary in research.md with final decision per package
-
-**Updated Comment Blocks** (target state):
-
-```json
-{
-  "// comments for dependencies": {
-    "@keycloak/keycloak-admin-client": "19.0.0 or above exports only ESM. API breaking changes require separate migration effort.",
-    "next-themes": "(if upgrade fails) Document specific failure reason here"
-  },
-  "// comments for defDependencies": {
-    "@handsontable/react": "v3 requires handsontable >= 7.0.0.",
-    "handsontable": "v7.0.0 or above is no longer MIT license."
-  }
-}
-```
-
-Note: The exact final state depends on which upgrades succeed. If all planned upgrades pass, only `@keycloak` and `handsontable` entries remain.
-
-## Testing Strategy
-
-### Build Verification (per package)
-- `turbo run build --filter @growi/app` — Turbopack client build + tsc server build
-- `ls apps/app/.next/node_modules/ | grep <package>` — Externalisation check
-- `pnpm run pre:styles-commons` — SCSS compilation (bootstrap only)
-
-### Lint Verification (per package)
-- `turbo run lint --filter @growi/app` — TypeScript type check + Biome
-
-### Unit/Integration Tests (per package)
-- `turbo run test --filter @growi/app` — Full test suite
-- For `RegExp.escape()` migration: run tests for page model, page service, page-grant service specifically
-
-### Regression Verification (final)
-- Full build + lint + test after all upgrades applied together
-- Verify `.next/node_modules/` symlink integrity via `check-next-symlinks.sh` (if available locally)
-
-## Migration Strategy
-
-```mermaid
-flowchart LR
-    Phase1[Phase 1: Low Risk] --> Phase2[Phase 2: Medium Risk]
-    Phase1 --> P1a[aws-sdk relax range]
-    Phase1 --> P1b[string-width upgrade]
-    Phase2 --> P2a[bootstrap upgrade]
-    Phase2 --> P2b[escape-string-regexp replace]
-    Phase2 --> P2c[next-themes upgrade]
-```
-
-- **Phase 1** (low risk): @aws-sdk range relaxation, string-width upgrade — minimal code changes
-- **Phase 2** (medium risk): bootstrap, escape-string-regexp replacement, next-themes — requires code review and/or source changes
-- Each upgrade is independently revertible
-- Deferred: @keycloak (high risk, separate task)
-- No change: handsontable (license constraint)

+ 0 - 75
.kiro/specs/upgrade-fixed-packages/requirements.md

@@ -1,75 +0,0 @@
-# Requirements Document
-
-## Introduction
-
-The `apps/app/package.json` file contains several packages whose versions are intentionally pinned due to ESM-only upgrades, upstream bugs, or licensing concerns. These pinning reasons were documented in `// comments for dependencies` and `// comments for defDependencies` comment blocks. Since the build environment has significantly changed (webpack → Turbopack), and upstream issues may have been resolved, a systematic audit is needed to determine which packages can now be safely upgraded.
-
-### Pinned Packages Inventory
-
-| # | Package | Current Version | Pinning Reason |
-|---|---------|----------------|----------------|
-| 1 | `@aws-sdk/client-s3`, `@aws-sdk/s3-request-presigner` | `3.454.0` | Fix version above 3.186.0 required by mongodb@4.16.0 |
-| 2 | `@keycloak/keycloak-admin-client` | `^18.0.0` | 19.0.0+ exports only ESM |
-| 3 | `bootstrap` | `=5.3.2` | v5.3.3 has a bug (twbs/bootstrap#39798) |
-| 4 | `escape-string-regexp` | `^4.0.0` | 5.0.0+ exports only ESM |
-| 5 | `next-themes` | `^0.2.1` | 0.3.0 causes type error (pacocoursey/next-themes#122) |
-| 6 | `string-width` | `=4.2.2` | 5.0.0+ exports only ESM |
-| 7 | `@handsontable/react` | `=2.1.0` | v3 requires handsontable >= 7.0.0 |
-| 8 | `handsontable` | `=6.2.2` | v7.0.0+ is no longer MIT license |
-
-## Requirements
-
-### Requirement 1: Upstream Bug and Issue Investigation
-
-**Objective:** As a maintainer, I want to verify whether upstream bugs and issues that originally caused version pinning have been resolved, so that I can make informed upgrade decisions.
-
-#### Acceptance Criteria
-
-1. When investigating the bootstrap pinning, the audit process shall check the current status of https://github.com/twbs/bootstrap/issues/39798 and determine whether v5.3.3+ has fixed the reported bug.
-2. When investigating the next-themes pinning, the audit process shall check the current status of https://github.com/pacocoursey/next-themes/issues/122 and determine whether v0.3.0+ has resolved the type error.
-3. When investigating the @aws-sdk pinning, the audit process shall verify whether the mongodb version used in GROWI still requires the `>=3.186.0` constraint and whether the latest @aws-sdk versions are compatible.
-4. The audit process shall document the investigation result for each package, including: current upstream status, whether the original issue is resolved, and the recommended action (upgrade/keep/replace).
-
-### Requirement 2: ESM-Only Package Compatibility Assessment
-
-**Objective:** As a maintainer, I want to assess whether ESM-only versions of pinned packages are now compatible with the current Turbopack-based build environment, so that outdated CJS-only constraints can be removed.
-
-#### Acceptance Criteria
-
-1. When assessing ESM compatibility, the audit process shall evaluate each ESM-pinned package (`escape-string-regexp`, `string-width`, `@keycloak/keycloak-admin-client`) against the current build pipeline (Turbopack for client, tsc for server).
-2. When a package is used in server-side code (transpiled via tsc with `tsconfig.build.server.json`), the audit process shall verify whether the server build output format (CJS or ESM) supports importing ESM-only packages.
-3. When a package is used only in client-side code (bundled via Turbopack), the audit process shall confirm that Turbopack can resolve ESM-only packages without issues.
-4. The audit process shall produce a compatibility matrix showing each ESM-pinned package, its usage context (server/client/both), and whether upgrading to the ESM-only version is feasible.
-
-### Requirement 3: License Compliance Verification
-
-**Objective:** As a maintainer, I want to confirm that the handsontable/`@handsontable/react` licensing situation has not changed, so that I can determine whether these packages must remain pinned or can be replaced.
-
-#### Acceptance Criteria
-
-1. When evaluating handsontable, the audit process shall verify the current license of handsontable v7.0.0+ and confirm whether it remains non-MIT.
-2. If handsontable v7.0.0+ is still non-MIT, the audit process shall document that `handsontable` (`=6.2.2`) and `@handsontable/react` (`=2.1.0`) must remain pinned or an alternative library must be identified.
-3. If a MIT-licensed alternative to handsontable exists, the audit process shall note it as a potential replacement candidate (out of scope for this spec but documented for future work).
-
-### Requirement 4: Safe Upgrade Execution
-
-**Objective:** As a maintainer, I want to upgrade packages that are confirmed safe to update, so that the project benefits from bug fixes, security patches, and new features.
-
-#### Acceptance Criteria
-
-1. When upgrading a pinned package, the upgrade process shall update the version specifier in `apps/app/package.json` and remove or update the corresponding entry in the `// comments for dependencies` or `// comments for defDependencies` block.
-2. When a package is upgraded, the upgrade process shall verify that `turbo run build --filter @growi/app` completes successfully.
-3. When a package is upgraded, the upgrade process shall verify that `turbo run lint --filter @growi/app` completes without new errors.
-4. When a package is upgraded, the upgrade process shall verify that `turbo run test --filter @growi/app` passes without new failures.
-5. If a package upgrade causes build, lint, or test failures, the upgrade process shall revert that specific package change and document the failure reason.
-6. When all upgrades are complete, the `// comments for dependencies` and `// comments for defDependencies` blocks shall accurately reflect only the packages that remain pinned, with updated reasons if applicable.
-
-### Requirement 5: Audit Documentation
-
-**Objective:** As a maintainer, I want a clear record of the audit results, so that future maintainers understand which packages were evaluated and why decisions were made.
-
-#### Acceptance Criteria
-
-1. The audit process shall produce a summary table documenting each pinned package with: package name, previous version, new version (or "unchanged"), and rationale for the decision.
-2. When a package remains pinned, the documentation shall include the verified reason for continued pinning.
-3. When a package is upgraded, the documentation shall note what changed upstream that made the upgrade possible.

+ 0 - 183
.kiro/specs/upgrade-fixed-packages/research.md

@@ -1,183 +0,0 @@
-# Research & Design Decisions
-
----
-**Purpose**: Capture discovery findings for the pinned package audit and upgrade initiative.
-**Usage**: Inform design.md decisions; provide evidence for future maintainers.
----
-
-## Summary
-- **Feature**: `upgrade-fixed-packages`
-- **Discovery Scope**: Extension (auditing existing dependency constraints)
-- **Key Findings**:
-  - Bootstrap bug (#39798) fixed in v5.3.4 — safe to upgrade to latest 5.3.x
-  - next-themes original issue (#122) was resolved long ago; upgrade to v0.4.x feasible but has Next.js 16 `cacheComponents` caveat
-  - Node.js ^24 enables stable `require(esm)`, unlocking ESM-only package upgrades for server code
-  - `escape-string-regexp` can be replaced entirely by native `RegExp.escape()` (ES2026, Node.js 24)
-  - handsontable license situation unchanged — must remain pinned at 6.2.2
-  - @aws-sdk pinning comment is misleading; packages can be freely upgraded
-
-## Research Log
-
-### Bootstrap v5.3.3 Bug (#39798)
-- **Context**: bootstrap pinned at `=5.3.2` due to modal header regression in v5.3.3
-- **Sources Consulted**: https://github.com/twbs/bootstrap/issues/39798, https://github.com/twbs/bootstrap/pull/41336
-- **Findings**:
-  - Issue CLOSED on 2025-04-03
-  - Fixed in v5.3.4 via PR #41336 (Fix modal and offcanvas header collapse)
-  - Bug: `.modal-header` lost `justify-content: space-between`, causing content collapse
-  - Latest stable: v5.3.8 (August 2025)
-- **Implications**: Safe to upgrade from `=5.3.2` to `^5.3.4`. Skip v5.3.3 entirely. Recommend `^5.3.4` or pin to latest `=5.3.8`.
-
-### next-themes Type Error (#122)
-- **Context**: next-themes pinned at `^0.2.1` due to reported type error in v0.3.0
-- **Sources Consulted**: https://github.com/pacocoursey/next-themes/issues/122, https://github.com/pacocoursey/next-themes/issues/375
-- **Findings**:
-  - Issue #122 CLOSED on 2022-06-02 — was specific to an old beta version (v0.0.13-beta.3), not v0.3.0
-  - The pinning reason was based on incomplete information; v0.2.0+ already had the fix
-  - Latest: v0.4.6 (March 2025). Peers: `react ^16.8 || ^17 || ^18 || ^19`
-  - **Caveat**: Issue #375 reports a bug with Next.js 16's `cacheComponents` feature — stale theme values when cached components reactivate
-  - PR #377 in progress to fix via `useSyncExternalStore`
-  - Without `cacheComponents`, v0.4.6 works fine with Next.js 16
-- **Implications**: Upgrade to v0.4.x is feasible. GROWI uses Pages Router (not App Router), so `cacheComponents` is likely not relevant. Breaking API changes between v0.2 → v0.4 need review. Used in 12 files across apps/app.
-
-### ESM-only Package Compatibility (escape-string-regexp, string-width, @keycloak)
-- **Context**: Three packages pinned to CJS-compatible versions because newer versions are ESM-only
-- **Sources Consulted**: Node.js v22.12.0 release notes (require(esm) enabled by default), TC39 RegExp.escape Stage 4, sindresorhus ESM guidance, npm package pages
-- **Findings**:
-
-  **escape-string-regexp** (^4.0.0):
-  - Used in 6 server-side files + 3 shared package files (all server context)
-  - Node.js 24 has stable `require(esm)` — ESM-only v5 would work
-  - **Better**: `RegExp.escape()` is ES2026 Stage 4, natively available in Node.js 24 (V8 support)
-  - Can eliminate the dependency entirely
-
-  **string-width** (=4.2.2):
-  - Used only in `packages/editor/src/models/markdown-table.js`
-  - `@growi/editor` has `"type": "module"` and builds with Vite (ESM context)
-  - No server-side value imports (only type imports in `sync-ydoc.ts`, erased at compile)
-  - Safe to upgrade to v7.x
-
-  **@keycloak/keycloak-admin-client** (^18.0.0):
-  - Used in 1 server-side file: `features/external-user-group/server/service/keycloak-user-group-sync.ts`
-  - Latest: v26.5.5 (February 2026)
-  - `require(esm)` in Node.js 24 should handle it, but API has significant breaking changes (v18 → v26)
-  - Sub-path exports need verification
-  - Higher risk upgrade — API surface changes expected
-
-- **Implications**: string-width is the easiest upgrade. escape-string-regexp should be replaced by native `RegExp.escape()`. @keycloak requires careful API migration and is higher risk.
-
-### @aws-sdk Pinning Analysis
-- **Context**: @aws-sdk/client-s3 and @aws-sdk/s3-request-presigner pinned at 3.454.0
-- **Sources Consulted**: mongodb package.json, npm registry, GROWI source code
-- **Findings**:
-  - Pinning comment says "required by mongodb@4.16.0" but is misleading
-  - mongodb@4.17.2 has `@aws-sdk/credential-providers: ^3.186.0` as **optional** dependency — a different package
-  - The S3 client packages are used directly by GROWI for file upload (server/service/file-uploader/aws/)
-  - Latest: @aws-sdk/client-s3@3.1014.0 (March 2026) — over 500 versions behind
-  - AWS SDK v3 follows semver; any 3.x should be compatible
-- **Implications**: Remove the misleading comment. Change from exact `3.454.0` to `^3.454.0` or update to latest. Low risk.
-
-### Handsontable License Status
-- **Context**: handsontable pinned at =6.2.2 (last MIT version), @handsontable/react at =2.1.0
-- **Sources Consulted**: handsontable.com/docs/software-license, npm, Hacker News discussion
-- **Findings**:
-  - v7.0.0+ (March 2019) switched from MIT to proprietary license — unchanged as of 2026
-  - Free "Hobby" license exists but restricted to non-commercial personal use
-  - Commercial use requires paid subscription
-  - MIT alternatives: AG Grid Community (most mature), Jspreadsheet CE, Univer (Apache 2.0)
-- **Implications**: Must remain pinned. No action possible without license purchase or library replacement. Library replacement is out of scope for this spec.
-
-## Design Decisions
-
-### Decision: Replace escape-string-regexp with native RegExp.escape()
-- **Context**: escape-string-regexp v5 is ESM-only; used in 9 files across server code
-- **Alternatives Considered**:
-  1. Upgrade to v5 with require(esm) support — works but adds unnecessary dependency
-  2. Replace with native `RegExp.escape()` — zero dependencies, future-proof
-- **Selected Approach**: Replace with `RegExp.escape()`
-- **Rationale**: Node.js 24 supports `RegExp.escape()` natively (ES2026 Stage 4). Eliminates a dependency entirely.
-- **Trade-offs**: Requires touching 9 files, but changes are mechanical (find-and-replace)
-- **Follow-up**: Verify `RegExp.escape()` is available in the project's Node.js 24 target
-
-### Decision: Upgrade string-width directly to v7.x
-- **Context**: Used only in @growi/editor (ESM package, Vite-bundled, client-only)
-- **Selected Approach**: Direct upgrade to latest v7.x
-- **Rationale**: Consumer is already ESM; zero CJS concern
-- **Trade-offs**: None significant; API is stable
-
-### Decision: Upgrade bootstrap to ^5.3.4
-- **Context**: Bug fixed in v5.3.4; latest is 5.3.8
-- **Selected Approach**: Change from `=5.3.2` to `^5.3.4`
-- **Rationale**: Original bug resolved; skip v5.3.3
-- **Trade-offs**: Need to verify GROWI's custom SCSS and modal usage against 5.3.4+ changes
-
-### Decision: Upgrade next-themes to latest 0.4.x
-- **Context**: Original issue was a misunderstanding; latest is v0.4.6
-- **Selected Approach**: Upgrade to `^0.4.4` (or latest)
-- **Rationale**: Issue #122 was specific to old beta, not v0.3.0. GROWI uses Pages Router, so cacheComponents bug is not relevant.
-- **Trade-offs**: Breaking API changes between v0.2 → v0.4 need review. 12 files import from next-themes.
-- **Follow-up**: Review v0.3.0 and v0.4.0 changelogs for breaking changes
-
-### Decision: Relax @aws-sdk version to caret range
-- **Context**: Pinning was based on misleading comment; packages are independent of mongodb constraint
-- **Selected Approach**: Change from `3.454.0` to `^3.454.0`
-- **Rationale**: AWS SDK v3 follows semver; the comment conflated credential-providers with S3 client
-- **Trade-offs**: Low risk. Conservative approach keeps minimum at 3.454.0.
-
-### Decision: Defer @keycloak upgrade (high risk)
-- **Context**: v18 → v26 has significant API breaking changes; only 1 file affected
-- **Selected Approach**: Document as upgradeable but defer to a separate task
-- **Rationale**: API migration requires Keycloak server compatibility testing; out of proportion for a batch upgrade task
-- **Trade-offs**: Remains on old version longer, but isolated to one feature
-
-### Decision: Keep handsontable pinned (license constraint)
-- **Context**: v7+ is proprietary; no free alternative that's drop-in
-- **Selected Approach**: No change. Document for future reference.
-- **Rationale**: License constraint is permanent unless library is replaced entirely
-- **Trade-offs**: None — this is a business/legal decision, not technical
-
-## Risks & Mitigations
-- **Bootstrap SCSS breakage**: v5.3.4+ may have SCSS variable changes → Run `pre:styles-commons` and `pre:styles-components` builds to verify
-- **next-themes API changes**: v0.2 → v0.4 has breaking changes → Review changelog; test all 12 consuming files
-- **RegExp.escape() availability**: Ensure Node.js 24 V8 includes it → Verify with simple runtime test
-- **@aws-sdk transitive dependency changes**: Newer AWS SDK may pull different transitive deps → Monitor bundle size
-- **Build regression**: Any upgrade could break Turbopack build → Follow incremental upgrade strategy with build verification per package
-
-## Future Considerations (Out of Scope)
-
-### transpilePackages cleanup in next.config.ts
-- **Context**: `next.config.ts` defines `getTranspilePackages()` listing 60+ ESM-only packages to force Turbopack to bundle them instead of externalising. The original comment says: "listing ESM packages until experimental.esmExternals works correctly to avoid ERR_REQUIRE_ESM".
-- **Relationship to require(esm)**: `transpilePackages` and `require(esm)` solve different problems. `transpilePackages` prevents Turbopack from externalising packages during SSR; `require(esm)` allows Node.js to load ESM packages via `require()` at runtime. With Node.js 24's stable `require(esm)`, externalised ESM packages *should* load correctly in SSR, meaning some `transpilePackages` entries may become unnecessary.
-- **Why not now**: (1) Turbopack's `esmExternals` handling is still `experimental`; (2) removing entries shifts packages from bundled to externalised, which means they appear in `.next/node_modules/` and must be classified as `dependencies` per the `package-dependencies` rule; (3) 60+ packages need individual verification. This is a separate investigation with a large blast radius.
-- **Recommendation**: Track as a separate task. Test by removing a few low-risk entries (e.g., `bail`, `ccount`, `zwitch`) and checking whether SSR still works with Turbopack externalisation + Node.js 24 `require(esm)`.
-
-## References
-- [Bootstrap issue #39798](https://github.com/twbs/bootstrap/issues/39798) — modal header regression, fixed in v5.3.4
-- [next-themes issue #122](https://github.com/pacocoursey/next-themes/issues/122) — type error, resolved in v0.2.0
-- [next-themes issue #375](https://github.com/pacocoursey/next-themes/issues/375) — Next.js 16 cacheComponents bug
-- [TC39 RegExp.escape() Stage 4](https://socket.dev/blog/tc39-advances-3-proposals-to-stage-4-regexp-escaping-float16array-and-redeclarable-global-eval) — ES2026
-- [Node.js require(esm) stability](https://joyeecheung.github.io/blog/2025/12/30/require-esm-in-node-js-from-experiment-to-stability/) — stable since Node.js 22.12.0
-- [Handsontable license change](https://handsontable.com/docs/javascript-data-grid/software-license/) — proprietary since v7.0.0
-
-## Final Audit Summary (2026-03-23)
-
-| Package | Previous Version | New Version | Action | Rationale |
-|---------|-----------------|-------------|--------|-----------|
-| `@aws-sdk/client-s3` | `3.454.0` | `^3.1014.0` | Upgraded | Pinning comment was misleading; S3 client is independent of mongodb constraint |
-| `@aws-sdk/s3-request-presigner` | `3.454.0` | `^3.1014.0` | Upgraded | Same as above |
-| `bootstrap` | `=5.3.2` | `^5.3.8` | Upgraded | Bug #39798 fixed in v5.3.4; SCSS compilation verified |
-| `escape-string-regexp` | `^4.0.0` | Removed | Replaced | Native `RegExp.escape()` (ES2026, Node.js 24) eliminates the dependency |
-| `string-width` | `=4.2.2` | `^7.0.0` | Upgraded | Used only in @growi/editor (ESM context, Vite-bundled) |
-| `next-themes` | `^0.2.1` | `^0.4.6` | Upgraded | Original issue #122 was misattributed; only change needed: type import path |
-| `@keycloak/keycloak-admin-client` | `^18.0.0` | Unchanged | Deferred | API breaking changes (v18→v26) require separate migration effort |
-| `handsontable` | `=6.2.2` | Unchanged | Kept | v7.0.0+ is proprietary (non-MIT license) |
-| `@handsontable/react` | `=2.1.0` | Unchanged | Kept | Requires handsontable >= 7.0.0 |
-
-### Additional Changes
-
-- Added `RegExp.escape()` TypeScript type declarations in `apps/app/src/@types/`, `packages/core/src/@types/`, and `packages/remark-lsx/src/@types/` (awaiting TypeScript built-in support)
-- Updated `tsconfig.build.client.json` to include `src/@types/**/*.d.ts` for Next.js build compatibility
-- Updated `generate-children-regexp.spec.ts` test expectations for `RegExp.escape()` output (escapes spaces as `\x20`)
-- Removed `escape-string-regexp` from `transpilePackages` in `next.config.ts`
-- Updated `bootstrap` version across 5 packages: apps/app, packages/editor, packages/core-styles, packages/preset-themes, apps/slackbot-proxy
-- Updated `// comments for dependencies` to retain only `@keycloak` entry with updated reason

+ 0 - 89
.kiro/specs/upgrade-fixed-packages/tasks.md

@@ -1,89 +0,0 @@
-# Implementation Plan
-
-- [x] 1. Pre-implementation verification
-- [x] 1.1 Verify RegExp.escape() availability and TypeScript support
-  - Confirm `RegExp.escape()` is available at runtime in the project's Node.js 24 target
-  - Check whether TypeScript recognizes `RegExp.escape()` — may need `lib` config update or `@types/node` update
-  - If unavailable, fall back to upgrading `escape-string-regexp` to v5 with `require(esm)` instead
-  - _Requirements: 2.2_
-
-- [x] 1.2 Review next-themes v0.3.0 and v0.4.0 breaking API changes
-  - Read changelogs for v0.3.0 and v0.4.0 releases to identify breaking changes
-  - Map breaking changes to the 12 consuming files in apps/app
-  - Determine migration effort and document required code changes
-  - Confirm GROWI's Pages Router usage is unaffected by the cacheComponents bug (issue #375)
-  - _Requirements: 1.2_
-
-- [x] 2. Low-risk package upgrades
-- [x] 2.1 (P) Relax @aws-sdk version range
-  - Change `@aws-sdk/client-s3` from `3.454.0` to `^3.1014.0` in apps/app/package.json
-  - Change `@aws-sdk/s3-request-presigner` from `3.454.0` to `^3.1014.0`
-  - Update the misleading `"@aws-skd/*"` comment to reflect the actual reason or remove it
-  - Run `pnpm install` and verify build with `turbo run build --filter @growi/app`
-  - Run `turbo run test --filter @growi/app` to confirm no regressions
-  - _Requirements: 1.3, 4.1, 4.2, 4.4_
-
-- [x] 2.2 (P) Upgrade string-width in @growi/editor
-  - Update `string-width` from `=4.2.2` to `^7.0.0` in packages/editor/package.json
-  - Verify @growi/editor builds successfully (Vite, ESM context)
-  - Run `turbo run build --filter @growi/app` to confirm downstream build passes
-  - Run `turbo run test --filter @growi/app` to confirm no regressions
-  - Remove the `string-width` comment from apps/app/package.json `// comments for dependencies`
-  - _Requirements: 2.1, 2.3, 4.1, 4.2, 4.4_
-
-- [x] 3. Upgrade bootstrap to ^5.3.8
-  - Change `bootstrap` from `=5.3.2` to `^5.3.8` in apps/app/package.json and all other packages
-  - Run `pnpm install` to resolve the new version
-  - Run `pnpm run pre:styles-commons` and `pnpm run pre:styles-components` to verify SCSS compilation
-  - Run `turbo run build --filter @growi/app` to confirm Turbopack build passes
-  - Run `turbo run lint --filter @growi/app` to check for type or lint errors
-  - Run `turbo run test --filter @growi/app` to confirm no regressions
-  - Visually inspect modal headers if a dev server is available (original bug was modal header layout)
-  - Remove the `bootstrap` comment from `// comments for dependencies`
-  - If build or SCSS fails, revert and document the failure reason
-  - _Requirements: 1.1, 4.1, 4.2, 4.3, 4.4, 4.5_
-
-- [x] 4. Replace escape-string-regexp with native RegExp.escape()
-- [x] 4.1 Migrate all source files from escape-string-regexp to RegExp.escape()
-  - Replace `import escapeStringRegexp from 'escape-string-regexp'` and corresponding calls with `RegExp.escape()` in each file
-  - Files in apps/app/src: page.ts, page/index.ts, page-grant.ts, users.js, obsolete-page.js, openai.ts (6 files)
-  - Files in packages: core/src/utils/page-path-utils (2 files), remark-lsx/src/server/routes/list-pages/index.ts (1 file)
-  - Ensure each replacement preserves the exact same escaping behavior
-  - _Requirements: 4.1_
-
-- [x] 4.2 Remove escape-string-regexp dependency and verify
-  - Remove `escape-string-regexp` from apps/app/package.json dependencies
-  - Remove from packages/core and packages/remark-lsx package.json if listed
-  - Remove the `escape-string-regexp` comment from `// comments for dependencies`
-  - Remove `escape-string-regexp` entry from `transpilePackages` in next.config.ts
-  - Run `pnpm install` to update lockfile
-  - Run `turbo run build --filter @growi/app` to verify build
-  - Run `turbo run lint --filter @growi/app` to verify no type errors
-  - Run `turbo run test --filter @growi/app` to verify no regressions
-  - If RegExp.escape() has TypeScript issues, add type declaration or adjust lib config
-  - _Requirements: 2.1, 2.2, 4.1, 4.2, 4.3, 4.4, 4.5_
-
-- [x] 5. Upgrade next-themes to ^0.4.x
-- [x] 5.1 Update next-themes and adapt consuming code
-  - Change `next-themes` from `^0.2.1` to `^0.4.6` in apps/app/package.json
-  - Apply required API migration changes across the 12 consuming files identified in design
-  - Pay attention to any renamed exports, changed hook signatures, or provider prop changes
-  - Ensure `useTheme()` and `ThemeProvider` usage is compatible with v0.4.x API
-  - _Requirements: 1.2, 4.1_
-
-- [x] 5.2 Verify next-themes upgrade
-  - Run `turbo run build --filter @growi/app` to confirm build passes
-  - Run `turbo run lint --filter @growi/app` to check for type errors (original pinning was about types)
-  - Run `turbo run test --filter @growi/app` to confirm no regressions
-  - Remove the `next-themes` comment from `// comments for dependencies`
-  - If build or type errors occur, investigate whether the issue is the same as #122 or a new problem
-  - If upgrade fails, revert and document the reason; keep the pin with an updated comment
-  - _Requirements: 4.2, 4.3, 4.4, 4.5, 4.6_
-
-- [x] 6. Finalize audit documentation and comment blocks
-  - Verify `// comments for dependencies` block contains only packages that remain pinned (@keycloak if unchanged)
-  - Verify `// comments for defDependencies` block is accurate (handsontable entries unchanged)
-  - Update comment text to reflect current reasons where applicable
-  - Produce a final summary table in research.md documenting: package name, previous version, new version or "unchanged", and rationale
-  - Confirm all requirements are satisfied by reviewing the checklist against actual changes made
-  - _Requirements: 3.1, 3.2, 4.6, 5.1, 5.2, 5.3_

+ 4 - 0
apps/app/bin/openapi/definition-apiv3.js

@@ -116,6 +116,10 @@ module.exports = {
         'Install',
       ],
     },
+    {
+      name: 'AI API',
+      tags: ['AI Tools'],
+    },
     {
       name: 'Public API',
       tags: ['Healthcheck', 'Statistics', '', '', '', '', '', ''],

+ 1 - 0
apps/app/bin/openapi/generate-spec-apiv3.sh

@@ -12,6 +12,7 @@ swagger-jsdoc \
   -d "${APP_PATH}/bin/openapi/definition-apiv3.js" \
   "${APP_PATH}/src/features/external-user-group/server/routes/apiv3/*.ts" \
   "${APP_PATH}/src/features/templates/server/routes/apiv3/*.ts" \
+  "${APP_PATH}/src/features/ai-tools/**/server/routes/apiv3/*.ts" \
   "${APP_PATH}/src/features/growi-plugin/server/routes/apiv3/**/*.ts" \
   "${APP_PATH}/src/server/routes/apiv3/**/*.{js,ts}" \
   "${APP_PATH}/src/server/routes/login.js" \

+ 20 - 2
apps/app/src/client/components/PageCreateModal.tsx

@@ -21,7 +21,7 @@ import { debounce } from 'throttle-debounce';
 import { useCreateTemplatePage } from '~/client/services/create-page';
 import { useCreatePage } from '~/client/services/create-page/use-create-page';
 import { useToastrOnError } from '~/client/services/use-toastr-on-error';
-import { useCurrentUser } from '~/states/global';
+import { useCurrentUser, useGrowiCloudUri } from '~/states/global';
 import { isSearchServiceReachableAtom } from '~/states/server-configurations';
 import {
   usePageCreateModalActions,
@@ -35,9 +35,10 @@ import styles from './PageCreateModal.module.scss';
 const { isCreatablePage, isUsersHomepage } = pagePathUtils;
 
 const PageCreateModal: React.FC = () => {
-  const { t } = useTranslation();
+  const { t, i18n } = useTranslation();
 
   const currentUser = useCurrentUser();
+  const growiCloudUri = useGrowiCloudUri();
 
   const { isOpened, path: pathname = '' } = usePageCreateModalStatus();
   const { close: closeCreateModal } = usePageCreateModalActions();
@@ -71,6 +72,12 @@ const PageCreateModal: React.FC = () => {
     [userHomepagePath, t, now],
   );
 
+  const templateHelpLang = i18n.language === 'ja' ? 'ja' : 'en';
+  const templateHelpUrl =
+    growiCloudUri != null
+      ? `https://growi.cloud/help/${templateHelpLang}/guide/features/template.html`
+      : `https://docs.growi.org/${templateHelpLang}/guide/features/template.html`;
+
   const [todayInput, setTodayInput] = useState('');
   const [pageNameInput, setPageNameInput] = useState(pageNameInputInitialValue);
   const [template, setTemplate] = useState(null);
@@ -295,6 +302,16 @@ const PageCreateModal: React.FC = () => {
         <fieldset className="col-12">
           <h3 className="pb-2">
             {t('template.modal_label.Create template under')}
+            <a
+              href={templateHelpUrl}
+              target="_blank"
+              rel="noopener noreferrer"
+              className="ms-1"
+            >
+              <span className="material-symbols-outlined fs-6 text-secondary">
+                help
+              </span>
+            </a>
             <br />
             <code className="h6" data-testid="grw-page-create-modal-path-name">
               {pathname}
@@ -353,6 +370,7 @@ const PageCreateModal: React.FC = () => {
     isOpened,
     pathname,
     template,
+    templateHelpUrl,
     onChangeTemplateHandler,
     createTemplateWithToastr,
     t,

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

@@ -95,6 +95,7 @@ export const generateViewOptions = (
             presentation.sanitizeOption,
             drawio.sanitizeOption,
             mermaidSanitizeOption,
+            plantuml.sanitizeOption,
             callout.sanitizeOption,
             attachment.sanitizeOption,
             lsxGrowiDirective.sanitizeOption,
@@ -132,6 +133,7 @@ export const generateViewOptions = (
     components.refsimg = refsGrowiDirective.RefsImg;
     components.gallery = refsGrowiDirective.Gallery;
     components.drawio = DrawioViewerWithEditButton;
+    components.plantuml = plantuml.PlantUmlViewer;
     components.table = TableWithEditButton;
     components.mermaid = MermaidViewer;
     components.callout = callout.CalloutViewer;
@@ -220,6 +222,7 @@ export const generateSimpleViewOptions = (
             presentation.sanitizeOption,
             drawio.sanitizeOption,
             mermaidSanitizeOption,
+            plantuml.sanitizeOption,
             callout.sanitizeOption,
             attachment.sanitizeOption,
             lsxGrowiDirective.sanitizeOption,
@@ -250,6 +253,7 @@ export const generateSimpleViewOptions = (
     components.refsimg = refsGrowiDirective.RefsImgImmutable;
     components.gallery = refsGrowiDirective.GalleryImmutable;
     components.drawio = drawio.DrawioViewer;
+    components.plantuml = plantuml.PlantUmlViewer;
     components.mermaid = MermaidViewer;
     components.callout = callout.CalloutViewer;
     components.attachment = RichAttachment;
@@ -321,6 +325,7 @@ export const generatePreviewOptions = (
             getCommonSanitizeOption(config),
             drawio.sanitizeOption,
             mermaidSanitizeOption,
+            plantuml.sanitizeOption,
             callout.sanitizeOption,
             attachment.sanitizeOption,
             lsxGrowiDirective.sanitizeOption,
@@ -352,6 +357,7 @@ export const generatePreviewOptions = (
     components.refsimg = refsGrowiDirective.RefsImgImmutable;
     components.gallery = refsGrowiDirective.GalleryImmutable;
     components.drawio = drawio.DrawioViewer;
+    components.plantuml = plantuml.PlantUmlViewer;
     components.mermaid = MermaidViewer;
     components.callout = callout.CalloutViewer;
     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 { isDeepEquals } from '@growi/core/dist/utils/is-deep-equals';
 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 { PageContentFooter } from './PageContentFooter';
 import { PageViewLayout } from './PageViewLayout';
+import { useHashAutoScroll } from './use-hash-auto-scroll';
 
 // biome-ignore-start lint/style/noRestrictedImports: no-problem dynamic import
 const NotCreatablePage = dynamic(
@@ -129,42 +122,8 @@ const PageViewComponent = (props: Props): JSX.Element => {
     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(() => {
     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]);
+};

+ 78 - 0
apps/app/src/features/ai-tools/suggest-path/server/routes/apiv3/index.ts

@@ -20,6 +20,50 @@ import { generateSuggestions } from '../../services/generate-suggestions';
 
 const logger = loggerFactory('growi:features:suggest-path:routes');
 
+/**
+ * @swagger
+ *
+ * components:
+ *   schemas:
+ *     PathSuggestion:
+ *       type: object
+ *       required:
+ *         - type
+ *         - path
+ *         - label
+ *         - description
+ *         - grant
+ *       properties:
+ *         type:
+ *           type: string
+ *           enum: [memo, search, category]
+ *           description: The type of suggestion
+ *         path:
+ *           type: string
+ *           description: Suggested page path
+ *           example: "/user/alice/2026/04/01/meeting-notes"
+ *         label:
+ *           type: string
+ *           description: Human-readable label for the suggestion
+ *         description:
+ *           type: string
+ *           description: Explanation of why this path is suggested
+ *         grant:
+ *           type: integer
+ *           description: Page grant (1=public, 4=owner_only, 5=user_group)
+ *         informationType:
+ *           type: string
+ *           enum: [flow, stock]
+ *           description: Whether the content is flow (time-based) or stock (reference)
+ *     SuggestPathResponse:
+ *       type: object
+ *       properties:
+ *         suggestions:
+ *           type: array
+ *           items:
+ *             $ref: '#/components/schemas/PathSuggestion'
+ */
+
 type ReqBody = {
   body: string;
 };
@@ -44,6 +88,40 @@ const validator = [
     .withMessage(`body must not exceed ${MAX_BODY_LENGTH} characters`),
 ];
 
+/**
+ * @swagger
+ *
+ * /ai-tools/suggest-path:
+ *   post:
+ *     summary: Suggest page paths based on content
+ *     description: Analyzes the given content and suggests appropriate page paths using keyword extraction, search, and AI evaluation.
+ *     tags: [AI Tools]
+ *     security:
+ *       - bearer: []
+ *       - accessTokenInQuery: []
+ *     requestBody:
+ *       required: true
+ *       content:
+ *         application/json:
+ *           schema:
+ *             type: object
+ *             required:
+ *               - body
+ *             properties:
+ *               body:
+ *                 type: string
+ *                 description: The page content to analyze for path suggestions
+ *                 maxLength: 50000
+ *     responses:
+ *       200:
+ *         description: Path suggestions generated successfully
+ *         content:
+ *           application/json:
+ *             schema:
+ *               $ref: '#/components/schemas/SuggestPathResponse'
+ *       500:
+ *         description: Failed to generate path suggestions
+ */
 export const suggestPathHandlersFactory = (crowi: Crowi): RequestHandler[] => {
   const loginRequiredStrictly = loginRequiredFactory(crowi);
 

+ 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 { GROWI_IS_CONTENT_RENDERING_ATTR } from '@growi/core/dist/consts';
 import mermaid from 'mermaid';
 import { v7 as uuidV7 } from 'uuid';
 
@@ -20,6 +21,8 @@ export const MermaidViewer = React.memo(
     const ref = useRef<HTMLDivElement>(null);
 
     useEffect(() => {
+      let rafId: number | undefined;
+
       (async () => {
         if (ref.current != null && value != null) {
           mermaid.initialize({
@@ -34,15 +37,34 @@ export const MermaidViewer = React.memo(
             const id = `mermaid-${uuidV7()}`;
             const { svg } = await mermaid.render(id, value, ref.current);
             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) {
             logger.error(err);
+            ref.current?.setAttribute(GROWI_IS_CONTENT_RENDERING_ATTR, 'false');
           }
         }
       })();
+
+      return () => {
+        if (rafId != null) {
+          cancelAnimationFrame(rafId);
+        }
+      };
     }, [isDarkMode, value]);
 
     return value ? (
-      <div ref={ref} key={value}>
+      <div
+        ref={ref}
+        key={value}
+        {...{ [GROWI_IS_CONTENT_RENDERING_ATTR]: 'true' }}
+      >
         {value}
       </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 { Code } from 'mdast';
 import type { Plugin } from 'unified';
@@ -12,6 +13,7 @@ function rewriteNode(node: Code) {
   data.hName = 'mermaid';
   data.hProperties = {
     value: node.value,
+    [GROWI_IS_CONTENT_RENDERING_ATTR]: 'true',
   };
 }
 
@@ -26,6 +28,6 @@ export const remarkPlugin: Plugin = () => (tree) => {
 export const sanitizeOption: SanitizeOption = {
   tagNames: ['mermaid'],
   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';

+ 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 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 { visit } from 'unist-util-visit';
 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);
+
+    // 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 { useCallback, useEffect, useRef } from 'react';
+import { useCallback, useRef } from 'react';
 import dynamic from 'next/dynamic';
 import type { IPageToDeleteWithMeta, IPageToRenameWithMeta } from '@growi/core';
 import { getIdStringForRef } from '@growi/core';
 import { useTranslation } from 'next-i18next';
 import { DropdownItem } from 'reactstrap';
-import { debounce } from 'throttle-debounce';
 
 import type {
   AdditionalMenuItemsRendererProps,
@@ -13,7 +12,6 @@ import type {
 } from '~/client/components/Common/Dropdown/PageItemControl';
 import type { RevisionLoaderProps } from '~/client/components/Page/RevisionLoader';
 import { exportAsMarkdown } from '~/client/services/page-operation';
-import { scrollWithinContainer } from '~/client/util/smooth-scroll';
 import { toastSuccess } from '~/client/util/toastr';
 import { PagePathNav } from '~/components/Common/PagePathNav';
 import type { IPageWithSearchMeta } from '~/interfaces/search';
@@ -35,6 +33,8 @@ import {
 import { useSearchResultOptions } from '~/stores/renderer';
 import { mutateSearching } from '~/stores/search';
 
+import { useKeywordRescroll } from './use-keyword-rescroll';
+
 import styles from './SearchResultContent.module.scss';
 
 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 = {
   pageWithMeta: IPageWithSearchMeta;
   highlightKeywords?: string[];
@@ -99,57 +96,18 @@ type Props = {
   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) => {
   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 page = pageWithMeta.data;
   const { open: openDuplicateModal } = usePageDuplicateModalActions();
   const { open: openRenameModal } = usePageRenameModalActions();
   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": [
     {
-      "includes": ["**/vite*.config.ts", "vitest*.config.ts"],
+      "includes": [
+        "**/vite*.config.ts",
+        "vitest*.config.ts",
+        "**/declaration.d.ts"
+      ],
       "linter": {
         "rules": {
           "style": {

+ 1 - 1
package.json

@@ -81,7 +81,7 @@
     "turbo": "^2.1.3",
     "typescript": "^5.9.3",
     "typescript-transform-paths": "^3.5.6",
-    "vite": "^6.4.1",
+    "vite": "^6.4.2",
     "vite-plugin-dts": "^4.5.4",
     "vite-tsconfig-paths": "^5.1.4",
     "vitest": "^3.2.4",

+ 6 - 0
packages/core/CHANGELOG.md

@@ -1,5 +1,11 @@
 # @growi/core
 
+## 2.3.0
+
+### Minor Changes
+
+- [#10853](https://github.com/growilabs/growi/pull/10853) [`3c50530`](https://github.com/growilabs/growi/commit/3c50530a105d85058076f31f1800c6304850f5d5) Thanks [@miya](https://github.com/miya)! - Add const for content rendering
+
 ## 2.2.0
 
 ### Minor Changes

+ 1 - 1
packages/core/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/core",
-  "version": "2.2.0",
+  "version": "2.3.0",
   "description": "GROWI Core Libraries",
   "license": "MIT",
   "repository": {

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

@@ -1,4 +1,5 @@
 export * from './accepted-upload-file-type';
 export * from './growi-plugin';
+export * from './renderer';
 export * from './system';
 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;

+ 7 - 0
packages/pluginkit/CHANGELOG.md

@@ -1,5 +1,12 @@
 # @growi/pluginkit
 
+## 1.2.3
+
+### Patch Changes
+
+- Updated dependencies [[`3c50530`](https://github.com/growilabs/growi/commit/3c50530a105d85058076f31f1800c6304850f5d5)]:
+  - @growi/core@2.3.0
+
 ## 1.2.2
 
 ### Patch Changes

+ 1 - 1
packages/pluginkit/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/pluginkit",
-  "version": "1.2.2",
+  "version": "1.2.3",
   "description": "Plugin kit for GROWI",
   "license": "MIT",
   "repository": {

+ 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.
-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": "run-p lint:*"
   },
+  "dependencies": {
+    "@growi/core": "workspace:^"
+  },
   "devDependencies": {
     "@types/hast": "^3.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,
   useState,
 } from 'react';
+import { GROWI_IS_CONTENT_RENDERING_ATTR } from '@growi/core/dist/consts';
 import { debounce } from 'throttle-debounce';
 
 import type { IGraphViewerGlobal } from '..';
@@ -127,6 +128,11 @@ export const DrawioViewer = memo((props: DrawioViewerProps): JSX.Element => {
   useEffect(() => {
     if (error != 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]);
 
@@ -141,8 +147,11 @@ export const DrawioViewer = memo((props: DrawioViewerProps): JSX.Element => {
 
         const mxgraphData = target.dataset.mxgraph;
         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) => {
       for (const _entry of entries) {
-        // setElementWidth(entry.contentRect.width);
         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();
       }
     });
@@ -182,6 +195,7 @@ export const DrawioViewer = memo((props: DrawioViewerProps): JSX.Element => {
       className={`drawio-viewer ${styles['drawio-viewer']} p-2`}
       data-begin-line-number-of-markdown={bol}
       data-end-line-number-of-markdown={eol}
+      {...{ [GROWI_IS_CONTENT_RENDERING_ATTR]: 'true' }}
     >
       {/* show error */}
       {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 { Schema as SanitizeOption } from 'hast-util-sanitize';
 import type { Code, Node, Paragraph } from 'mdast';
 import type { Plugin } from 'unified';
 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 {
   hName?: string;
@@ -34,6 +41,7 @@ function rewriteNode(node: Node, index: number, isDarkMode?: boolean) {
     eol: node.position?.end.line,
     isDarkMode: isDarkMode ? 'true' : 'false',
     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.
-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 { GROWI_IS_CONTENT_RENDERING_ATTR } from '@growi/core/dist/consts';
 import { LoadingSpinner } from '@growi/ui/dist/components';
 
 import { useSWRxLsx } from '../stores/lsx';
@@ -149,7 +150,10 @@ const LsxSubstance = React.memo(
     }, [data, setSize]);
 
     return (
-      <div className={`lsx ${styles.lsx}`}>
+      <div
+        className={`lsx ${styles.lsx}`}
+        {...{ [GROWI_IS_CONTENT_RENDERING_ATTR]: isLoading ? 'true' : 'false' }}
+      >
         <ErrorMessage />
         <Loading />
         {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 {
   addTrailingSlash,
   hasHeadingSlash,
@@ -25,6 +26,7 @@ const SUPPORTED_ATTRIBUTES = [
   'filter',
   'except',
   'isSharedPage',
+  GROWI_IS_CONTENT_RENDERING_ATTR,
 ];
 
 type DirectiveAttributes = Record<string, string>;

+ 24 - 25
pnpm-lock.yaml

@@ -68,7 +68,7 @@ importers:
         version: 7.0.0-dev.20260114.1
       '@vitejs/plugin-react':
         specifier: ^4.7.0
-        version: 4.7.0(vite@6.4.1(@types/node@24.12.0)(jiti@1.21.6)(sass@1.77.6)(terser@5.46.1)(yaml@2.4.5))
+        version: 4.7.0(vite@6.4.2(@types/node@24.12.0)(jiti@1.21.6)(sass@1.77.6)(terser@5.46.1)(yaml@2.4.5))
       '@vitest/coverage-v8':
         specifier: ^3.2.4
         version: 3.2.4(vitest@3.2.4)
@@ -139,14 +139,14 @@ importers:
         specifier: ^3.5.6
         version: 3.5.6(typescript@5.9.3)
       vite:
-        specifier: ^6.4.1
-        version: 6.4.1(@types/node@24.12.0)(jiti@1.21.6)(sass@1.77.6)(terser@5.46.1)(yaml@2.4.5)
+        specifier: ^6.4.2
+        version: 6.4.2(@types/node@24.12.0)(jiti@1.21.6)(sass@1.77.6)(terser@5.46.1)(yaml@2.4.5)
       vite-plugin-dts:
         specifier: ^4.5.4
-        version: 4.5.4(@types/node@24.12.0)(rollup@4.39.0)(typescript@5.9.3)(vite@6.4.1(@types/node@24.12.0)(jiti@1.21.6)(sass@1.77.6)(terser@5.46.1)(yaml@2.4.5))
+        version: 4.5.4(@types/node@24.12.0)(rollup@4.39.0)(typescript@5.9.3)(vite@6.4.2(@types/node@24.12.0)(jiti@1.21.6)(sass@1.77.6)(terser@5.46.1)(yaml@2.4.5))
       vite-tsconfig-paths:
         specifier: ^5.1.4
-        version: 5.1.4(typescript@5.9.3)(vite@6.4.1(@types/node@24.12.0)(jiti@1.21.6)(sass@1.77.6)(terser@5.46.1)(yaml@2.4.5))
+        version: 5.1.4(typescript@5.9.3)(vite@6.4.2(@types/node@24.12.0)(jiti@1.21.6)(sass@1.77.6)(terser@5.46.1)(yaml@2.4.5))
       vitest:
         specifier: ^3.2.4
         version: 3.2.4(@types/node@24.12.0)(@vitest/ui@3.2.4)(happy-dom@15.7.4)(jiti@1.21.6)(jsdom@26.1.0)(sass@1.77.6)(terser@5.46.1)(yaml@2.4.5)
@@ -1602,6 +1602,9 @@ importers:
 
   packages/remark-drawio:
     dependencies:
+      '@growi/core':
+        specifier: workspace:^
+        version: link:../core
       react:
         specifier: ^18.2.0
         version: 18.2.0
@@ -13709,8 +13712,8 @@ packages:
       vite:
         optional: true
 
-  vite@6.4.1:
-    resolution: {integrity: sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==}
+  vite@6.4.2:
+    resolution: {integrity: sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==}
     engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0}
     hasBin: true
     peerDependencies:
@@ -20009,7 +20012,7 @@ snapshots:
 
   '@ungap/structured-clone@1.2.0': {}
 
-  '@vitejs/plugin-react@4.7.0(vite@6.4.1(@types/node@24.12.0)(jiti@1.21.6)(sass@1.77.6)(terser@5.46.1)(yaml@2.4.5))':
+  '@vitejs/plugin-react@4.7.0(vite@6.4.2(@types/node@24.12.0)(jiti@1.21.6)(sass@1.77.6)(terser@5.46.1)(yaml@2.4.5))':
     dependencies:
       '@babel/core': 7.29.0
       '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.29.0)
@@ -20017,7 +20020,7 @@ snapshots:
       '@rolldown/pluginutils': 1.0.0-beta.27
       '@types/babel__core': 7.20.5
       react-refresh: 0.17.0
-      vite: 6.4.1(@types/node@24.12.0)(jiti@1.21.6)(sass@1.77.6)(terser@5.46.1)(yaml@2.4.5)
+      vite: 6.4.2(@types/node@24.12.0)(jiti@1.21.6)(sass@1.77.6)(terser@5.46.1)(yaml@2.4.5)
     transitivePeerDependencies:
       - supports-color
 
@@ -20048,13 +20051,13 @@ snapshots:
       chai: 5.3.3
       tinyrainbow: 2.0.0
 
-  '@vitest/mocker@3.2.4(vite@6.4.1(@types/node@24.12.0)(jiti@1.21.6)(sass@1.77.6)(terser@5.46.1)(yaml@2.4.5))':
+  '@vitest/mocker@3.2.4(vite@6.4.2(@types/node@24.12.0)(jiti@1.21.6)(sass@1.77.6)(terser@5.46.1)(yaml@2.4.5))':
     dependencies:
       '@vitest/spy': 3.2.4
       estree-walker: 3.0.3
       magic-string: 0.30.21
     optionalDependencies:
-      vite: 6.4.1(@types/node@24.12.0)(jiti@1.21.6)(sass@1.77.6)(terser@5.46.1)(yaml@2.4.5)
+      vite: 6.4.2(@types/node@24.12.0)(jiti@1.21.6)(sass@1.77.6)(terser@5.46.1)(yaml@2.4.5)
 
   '@vitest/pretty-format@3.2.4':
     dependencies:
@@ -22539,10 +22542,6 @@ snapshots:
     dependencies:
       pend: 1.2.0
 
-  fdir@6.5.0(picomatch@4.0.2):
-    optionalDependencies:
-      picomatch: 4.0.2
-
   fdir@6.5.0(picomatch@4.0.4):
     optionalDependencies:
       picomatch: 4.0.4
@@ -28858,7 +28857,7 @@ snapshots:
       debug: 4.4.3(supports-color@10.0.0)
       es-module-lexer: 1.7.0
       pathe: 2.0.3
-      vite: 6.4.1(@types/node@24.12.0)(jiti@1.21.6)(sass@1.77.6)(terser@5.46.1)(yaml@2.4.5)
+      vite: 6.4.2(@types/node@24.12.0)(jiti@1.21.6)(sass@1.77.6)(terser@5.46.1)(yaml@2.4.5)
     transitivePeerDependencies:
       - '@types/node'
       - jiti
@@ -28873,7 +28872,7 @@ snapshots:
       - tsx
       - yaml
 
-  vite-plugin-dts@4.5.4(@types/node@24.12.0)(rollup@4.39.0)(typescript@5.9.3)(vite@6.4.1(@types/node@24.12.0)(jiti@1.21.6)(sass@1.77.6)(terser@5.46.1)(yaml@2.4.5)):
+  vite-plugin-dts@4.5.4(@types/node@24.12.0)(rollup@4.39.0)(typescript@5.9.3)(vite@6.4.2(@types/node@24.12.0)(jiti@1.21.6)(sass@1.77.6)(terser@5.46.1)(yaml@2.4.5)):
     dependencies:
       '@microsoft/api-extractor': 7.58.0(@types/node@24.12.0)
       '@rollup/pluginutils': 5.2.0(rollup@4.39.0)
@@ -28886,28 +28885,28 @@ snapshots:
       magic-string: 0.30.21
       typescript: 5.9.3
     optionalDependencies:
-      vite: 6.4.1(@types/node@24.12.0)(jiti@1.21.6)(sass@1.77.6)(terser@5.46.1)(yaml@2.4.5)
+      vite: 6.4.2(@types/node@24.12.0)(jiti@1.21.6)(sass@1.77.6)(terser@5.46.1)(yaml@2.4.5)
     transitivePeerDependencies:
       - '@types/node'
       - rollup
       - supports-color
 
-  vite-tsconfig-paths@5.1.4(typescript@5.9.3)(vite@6.4.1(@types/node@24.12.0)(jiti@1.21.6)(sass@1.77.6)(terser@5.46.1)(yaml@2.4.5)):
+  vite-tsconfig-paths@5.1.4(typescript@5.9.3)(vite@6.4.2(@types/node@24.12.0)(jiti@1.21.6)(sass@1.77.6)(terser@5.46.1)(yaml@2.4.5)):
     dependencies:
       debug: 4.4.3(supports-color@10.0.0)
       globrex: 0.1.2
       tsconfck: 3.0.3(typescript@5.9.3)
     optionalDependencies:
-      vite: 6.4.1(@types/node@24.12.0)(jiti@1.21.6)(sass@1.77.6)(terser@5.46.1)(yaml@2.4.5)
+      vite: 6.4.2(@types/node@24.12.0)(jiti@1.21.6)(sass@1.77.6)(terser@5.46.1)(yaml@2.4.5)
     transitivePeerDependencies:
       - supports-color
       - typescript
 
-  vite@6.4.1(@types/node@24.12.0)(jiti@1.21.6)(sass@1.77.6)(terser@5.46.1)(yaml@2.4.5):
+  vite@6.4.2(@types/node@24.12.0)(jiti@1.21.6)(sass@1.77.6)(terser@5.46.1)(yaml@2.4.5):
     dependencies:
       esbuild: 0.25.12
-      fdir: 6.5.0(picomatch@4.0.2)
-      picomatch: 4.0.2
+      fdir: 6.5.0(picomatch@4.0.4)
+      picomatch: 4.0.4
       postcss: 8.5.6
       rollup: 4.39.0
       tinyglobby: 0.2.15
@@ -28929,7 +28928,7 @@ snapshots:
     dependencies:
       '@types/chai': 5.2.3
       '@vitest/expect': 3.2.4
-      '@vitest/mocker': 3.2.4(vite@6.4.1(@types/node@24.12.0)(jiti@1.21.6)(sass@1.77.6)(terser@5.46.1)(yaml@2.4.5))
+      '@vitest/mocker': 3.2.4(vite@6.4.2(@types/node@24.12.0)(jiti@1.21.6)(sass@1.77.6)(terser@5.46.1)(yaml@2.4.5))
       '@vitest/pretty-format': 3.2.4
       '@vitest/runner': 3.2.4
       '@vitest/snapshot': 3.2.4
@@ -28947,7 +28946,7 @@ snapshots:
       tinyglobby: 0.2.15
       tinypool: 1.1.1
       tinyrainbow: 2.0.0
-      vite: 6.4.1(@types/node@24.12.0)(jiti@1.21.6)(sass@1.77.6)(terser@5.46.1)(yaml@2.4.5)
+      vite: 6.4.2(@types/node@24.12.0)(jiti@1.21.6)(sass@1.77.6)(terser@5.46.1)(yaml@2.4.5)
       vite-node: 3.2.4(@types/node@24.12.0)(jiti@1.21.6)(sass@1.77.6)(terser@5.46.1)(yaml@2.4.5)
       why-is-node-running: 2.3.0
     optionalDependencies: