Yuki Takei 1 month ago
parent
commit
3c50530a10

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

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

+ 5 - 5
.kiro/specs/auto-scroll/design.md

@@ -68,9 +68,9 @@ graph TB
 
 **Architecture Integration**:
 - Selected pattern: Custom hook with options object — idiomatic React, testable, extensible
-- Domain boundaries: Hook logic in `src/client/hooks/`, constants in `@growi/core`, attribute lifecycle in each renderer package
+- Domain boundaries: Hook logic in `src/hooks/`, constants in `@growi/core`, attribute lifecycle in each renderer package
 - Existing patterns preserved: MutationObserver + polling hybrid, timeout-based safety bounds
-- New components rationale: `src/client/hooks/` directory needed for cross-feature hooks not tied to a specific feature module
+- New components rationale: `src/hooks/` directory needed for cross-feature hooks not tied to a specific feature module
 - Steering compliance: Named exports, immutable patterns, co-located tests
 
 ### Technology Stack
@@ -203,8 +203,8 @@ function useContentAutoScroll(options: UseContentAutoScrollOptions): void;
 - Invariants: At most one target observer and one rendering watch active per hook instance
 
 **Implementation Notes**
-- File location: `apps/app/src/client/hooks/use-content-auto-scroll.ts` (no JSX — `.ts` extension)
-- Test file: `apps/app/src/client/hooks/use-content-auto-scroll.spec.ts`
+- File location: `apps/app/src/hooks/use-content-auto-scroll.ts` (no JSX — `.ts` extension)
+- Test file: `apps/app/src/hooks/use-content-auto-scroll.spec.ts`
 - The `resolveTarget` and `scrollTo` callbacks should be wrapped in `useRef` or called from a ref to avoid re-triggering the effect when callback identity changes
 - Export both `useContentAutoScroll` and `watchRenderingAndReScroll` as named exports for independent testability
 
@@ -351,7 +351,7 @@ This feature operates entirely in the browser DOM layer with no server interacti
 
 ## Testing Strategy
 
-### Unit Tests (co-located in `src/client/hooks/`)
+### Unit Tests (co-located in `src/hooks/`)
 
 1. **Guard conditions**: Verify no-op when key is null, hash is empty, or container not found (1.3–1.5)
 2. **Immediate scroll**: Target exists in DOM → `scrollTo` called once (1.1)

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

@@ -3,7 +3,7 @@
   "created_at": "2026-04-02T00:00:00.000Z",
   "updated_at": "2026-04-02T00:00:00.000Z",
   "language": "en",
-  "phase": "tasks-generated",
+  "phase": "implementation-complete",
   "approvals": {
     "requirements": {
       "generated": true,
@@ -15,8 +15,8 @@
     },
     "tasks": {
       "generated": true,
-      "approved": false
+      "approved": true
     }
   },
-  "ready_for_implementation": false
+  "ready_for_implementation": true
 }

+ 13 - 13
.kiro/specs/auto-scroll/tasks.md

@@ -1,39 +1,39 @@
 # Implementation Plan
 
-- [ ] 1. Update rendering status constants in @growi/core
+- [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_
 
-- [ ] 2. Update remark-drawio for declarative rendering attribute protocol
-- [ ] 2.1 (P) Adopt declarative value toggling in DrawioViewer component
+- [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
   - _Requirements: 4.3, 4.4_
-- [ ] 2.2 (P) Update remark-drawio plugin sanitization and node rewriting
+- [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_
 
-- [ ] 3. Add rendering attribute to MermaidViewer and Lsx
-- [ ] 3.1 (P) Add rendering-status attribute lifecycle to MermaidViewer
+- [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_
-- [ ] 3.2 (P) Add rendering-status attribute lifecycle to Lsx component
+- [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_
 
-- [ ] 4. Implement shared auto-scroll hook
-- [ ] 4.1 Implement rendering watch function with safety improvements
+- [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
@@ -41,7 +41,7 @@
   - 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_
-- [ ] 4.2 Implement useContentAutoScroll hook with options object API
+- [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
@@ -50,7 +50,7 @@
   - 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_
-- [ ] 4.3 (P) Write tests for watchRenderingAndReScroll
+- [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
@@ -60,7 +60,7 @@
   - 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_
-- [ ] 4.4 (P) Write tests for useContentAutoScroll
+- [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
@@ -74,7 +74,7 @@
   - 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_
 
-- [ ] 5. Integrate hook into PageView and remove old implementation
+- [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

+ 226 - 49
apps/app/src/components/PageView/use-hash-auto-scroll.spec.tsx → apps/app/src/client/hooks/use-content-auto-scroll.spec.tsx

@@ -1,11 +1,11 @@
-import { GROWI_RENDERING_ATTR } from '@growi/core/dist/consts';
+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,
+  useContentAutoScroll,
   watchRenderingAndReScroll,
-} from './use-hash-auto-scroll';
+} from './use-content-auto-scroll';
 
 describe('watchRenderingAndReScroll', () => {
   let container: HTMLDivElement;
@@ -34,7 +34,7 @@ describe('watchRenderingAndReScroll', () => {
 
   it('should schedule a scroll after 5s when rendering elements exist', () => {
     const renderingEl = document.createElement('div');
-    renderingEl.setAttribute(GROWI_RENDERING_ATTR, 'true');
+    renderingEl.setAttribute(GROWI_IS_CONTENT_RENDERING_ATTR, 'true');
     container.appendChild(renderingEl);
 
     const cleanup = watchRenderingAndReScroll(container, scrollToTarget);
@@ -49,12 +49,11 @@ describe('watchRenderingAndReScroll', () => {
 
   it('should not reset timer on intermediate DOM mutations', async () => {
     const renderingEl = document.createElement('div');
-    renderingEl.setAttribute(GROWI_RENDERING_ATTR, 'true');
+    renderingEl.setAttribute(GROWI_IS_CONTENT_RENDERING_ATTR, 'true');
     container.appendChild(renderingEl);
 
     const cleanup = watchRenderingAndReScroll(container, scrollToTarget);
 
-    // Advance 3 seconds
     vi.advanceTimersByTime(3000);
     expect(scrollToTarget).not.toHaveBeenCalled();
 
@@ -78,45 +77,30 @@ describe('watchRenderingAndReScroll', () => {
 
     // Add a rendering element later (within 10s timeout)
     const renderingEl = document.createElement('div');
-    renderingEl.setAttribute(GROWI_RENDERING_ATTR, 'true');
+    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
-    vi.advanceTimersByTime(5000);
+    await vi.advanceTimersByTimeAsync(5000);
     expect(scrollToTarget).toHaveBeenCalledTimes(1);
 
     cleanup();
   });
 
-  it('should clean up timer and observer on cleanup call', () => {
-    const renderingEl = document.createElement('div');
-    renderingEl.setAttribute(GROWI_RENDERING_ATTR, 'true');
-    container.appendChild(renderingEl);
-
-    const cleanup = watchRenderingAndReScroll(container, scrollToTarget);
-
-    cleanup();
-
-    vi.advanceTimersByTime(10000);
-    expect(scrollToTarget).not.toHaveBeenCalled();
-  });
-
   it('should scroll once when multiple rendering elements exist simultaneously', () => {
-    // Two rendering elements present from the start (e.g. two DrawIO diagrams)
     const renderingEl1 = document.createElement('div');
-    renderingEl1.setAttribute(GROWI_RENDERING_ATTR, 'true');
+    renderingEl1.setAttribute(GROWI_IS_CONTENT_RENDERING_ATTR, 'true');
     container.appendChild(renderingEl1);
 
     const renderingEl2 = document.createElement('div');
-    renderingEl2.setAttribute(GROWI_RENDERING_ATTR, 'true');
+    renderingEl2.setAttribute(GROWI_IS_CONTENT_RENDERING_ATTR, 'true');
     container.appendChild(renderingEl2);
 
     const cleanup = watchRenderingAndReScroll(container, scrollToTarget);
 
-    // Scroll fires once at 5s — not multiplied by element count
     vi.advanceTimersByTime(5000);
     expect(scrollToTarget).toHaveBeenCalledTimes(1);
 
@@ -125,7 +109,7 @@ describe('watchRenderingAndReScroll', () => {
 
   it('should stop watching after 10s timeout', () => {
     const renderingEl = document.createElement('div');
-    renderingEl.setAttribute(GROWI_RENDERING_ATTR, 'true');
+    renderingEl.setAttribute(GROWI_IS_CONTENT_RENDERING_ATTR, 'true');
     container.appendChild(renderingEl);
 
     const cleanup = watchRenderingAndReScroll(container, scrollToTarget);
@@ -135,7 +119,6 @@ describe('watchRenderingAndReScroll', () => {
     expect(scrollToTarget).toHaveBeenCalledTimes(1);
 
     // At 10s both the scroll timer and the watch timeout fire.
-    // The scroll may or may not execute depending on timer ordering.
     vi.advanceTimersByTime(5000);
     const callsAfter10s = scrollToTarget.mock.calls.length;
 
@@ -145,9 +128,83 @@ describe('watchRenderingAndReScroll', () => {
 
     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();
+  });
 });
 
-describe('useHashAutoScroll', () => {
+describe('useContentAutoScroll', () => {
   const containerId = 'test-content-container';
   let container: HTMLDivElement;
 
@@ -164,14 +221,30 @@ describe('useHashAutoScroll', () => {
     window.location.hash = '';
   });
 
-  it('should not scroll when pageId is null', () => {
+  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(() =>
+      useContentAutoScroll({ 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(null, containerId));
+    renderHook(() =>
+      useContentAutoScroll({ key: undefined, contentContainerId: containerId }),
+    );
 
     expect(target.scrollIntoView).not.toHaveBeenCalled();
   });
@@ -183,7 +256,9 @@ describe('useHashAutoScroll', () => {
     target.scrollIntoView = vi.fn();
     container.appendChild(target);
 
-    renderHook(() => useHashAutoScroll('page-id', containerId));
+    renderHook(() =>
+      useContentAutoScroll({ key: 'page-id', contentContainerId: containerId }),
+    );
 
     expect(target.scrollIntoView).not.toHaveBeenCalled();
   });
@@ -195,7 +270,12 @@ describe('useHashAutoScroll', () => {
     target.scrollIntoView = vi.fn();
     container.appendChild(target);
 
-    renderHook(() => useHashAutoScroll('page-id', 'nonexistent-id'));
+    renderHook(() =>
+      useContentAutoScroll({
+        key: 'page-id',
+        contentContainerId: 'nonexistent-id',
+      }),
+    );
 
     expect(target.scrollIntoView).not.toHaveBeenCalled();
   });
@@ -208,32 +288,88 @@ describe('useHashAutoScroll', () => {
     container.appendChild(target);
 
     const { unmount } = renderHook(() =>
-      useHashAutoScroll('page-id', containerId),
+      useContentAutoScroll({ 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(() =>
+      useContentAutoScroll({ 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(() =>
+      useContentAutoScroll({
+        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(() =>
+      useContentAutoScroll({
+        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';
 
-    // Target with a rendering element
     const target = document.createElement('div');
     target.id = 'heading';
     target.scrollIntoView = vi.fn();
     container.appendChild(target);
 
     const renderingEl = document.createElement('div');
-    renderingEl.setAttribute(GROWI_RENDERING_ATTR, 'true');
+    renderingEl.setAttribute(GROWI_IS_CONTENT_RENDERING_ATTR, 'true');
     container.appendChild(renderingEl);
 
     const { unmount } = renderHook(() =>
-      useHashAutoScroll('page-id', containerId),
+      useContentAutoScroll({ key: 'page-id', contentContainerId: containerId }),
     );
 
-    // Initial scroll
     expect(target.scrollIntoView).toHaveBeenCalledTimes(1);
 
     // Re-scroll after 5s due to rendering watch
@@ -243,11 +379,53 @@ describe('useHashAutoScroll', () => {
     unmount();
   });
 
-  it('should stop target observer after 10s timeout', async () => {
+  it('should skip rendering watch 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(() =>
+      useContentAutoScroll({ key: 'page-id', contentContainerId: containerId }),
+    );
+
+    expect(target.scrollIntoView).toHaveBeenCalledTimes(1);
+
+    // No re-scroll since no rendering elements
+    vi.advanceTimersByTime(5000);
+    expect(target.scrollIntoView).toHaveBeenCalledTimes(1);
+
+    unmount();
+  });
+
+  it('should wait for target via MutationObserver when not yet in DOM', async () => {
+    window.location.hash = '#deferred';
+
+    const { unmount } = renderHook(() =>
+      useContentAutoScroll({ key: 'page-id', contentContainerId: containerId }),
+    );
+
+    // Target appears after initial render
+    const target = document.createElement('div');
+    target.id = 'deferred';
+    target.scrollIntoView = vi.fn();
+    container.appendChild(target);
+
+    // Flush microtasks for MutationObserver
+    await vi.advanceTimersByTimeAsync(0);
+
+    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('page-id', containerId),
+      useContentAutoScroll({ key: 'page-id', contentContainerId: containerId }),
     );
 
     // Advance past the timeout
@@ -274,17 +452,15 @@ describe('useHashAutoScroll', () => {
     container.appendChild(target);
 
     const renderingEl = document.createElement('div');
-    renderingEl.setAttribute(GROWI_RENDERING_ATTR, 'true');
+    renderingEl.setAttribute(GROWI_IS_CONTENT_RENDERING_ATTR, 'true');
     container.appendChild(renderingEl);
 
     const { unmount } = renderHook(() =>
-      useHashAutoScroll('page-id', containerId),
+      useContentAutoScroll({ key: 'page-id', contentContainerId: containerId }),
     );
 
-    // Initial scroll
     expect(target.scrollIntoView).toHaveBeenCalledTimes(1);
 
-    // Unmount before poll timer fires
     unmount();
 
     // No further scrolls after unmount
@@ -292,7 +468,7 @@ describe('useHashAutoScroll', () => {
     expect(target.scrollIntoView).toHaveBeenCalledTimes(1);
   });
 
-  it('should re-run effect when pageId changes', () => {
+  it('should re-run effect when key changes', () => {
     window.location.hash = '#heading';
     const target = document.createElement('div');
     target.id = 'heading';
@@ -300,18 +476,19 @@ describe('useHashAutoScroll', () => {
     container.appendChild(target);
 
     const { rerender, unmount } = renderHook(
-      ({ pageId }) => useHashAutoScroll(pageId, containerId),
-      { initialProps: { pageId: 'page-1' as string | null } },
+      ({ key }) =>
+        useContentAutoScroll({ key, contentContainerId: containerId }),
+      { initialProps: { key: 'page-1' as string | null } },
     );
 
     expect(target.scrollIntoView).toHaveBeenCalledTimes(1);
 
-    // Change pageId — effect re-runs
-    rerender({ pageId: 'page-2' });
+    // Change key — effect re-runs
+    rerender({ key: 'page-2' });
     expect(target.scrollIntoView).toHaveBeenCalledTimes(2);
 
     // Set to null — no additional scroll
-    rerender({ pageId: null });
+    rerender({ key: null });
     expect(target.scrollIntoView).toHaveBeenCalledTimes(2);
 
     unmount();

+ 194 - 0
apps/app/src/client/hooks/use-content-auto-scroll.ts

@@ -0,0 +1,194 @@
+import { useEffect, useRef } from 'react';
+import {
+  GROWI_IS_CONTENT_RENDERING_ATTR,
+  GROWI_IS_CONTENT_RENDERING_SELECTOR,
+} from '@growi/core/dist/consts';
+
+const RENDERING_POLL_INTERVAL_MS = 5000;
+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;
+};
+
+/** Configuration for the auto-scroll hook */
+export interface UseContentAutoScrollOptions {
+  /**
+   * 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 useContentAutoScroll = (
+  options: UseContentAutoScrollOptions,
+): 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 hasRenderingElements = (): boolean => {
+      return (
+        contentContainer.querySelector(GROWI_IS_CONTENT_RENDERING_SELECTOR) !=
+        null
+      );
+    };
+
+    const startRenderingWatchIfNeeded = (): (() => void) | undefined => {
+      if (hasRenderingElements()) {
+        return watchRenderingAndReScroll(contentContainer, scrollToTarget);
+      }
+      return undefined;
+    };
+
+    // Target already in DOM — scroll and optionally watch rendering
+    if (scrollToTarget()) {
+      const renderingCleanup = startRenderingWatchIfNeeded();
+      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 = startRenderingWatchIfNeeded();
+      }
+    });
+
+    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]);
+};

+ 3 - 2
apps/app/src/components/PageView/PageView.tsx

@@ -4,6 +4,8 @@ import { isDeepEquals } from '@growi/core/dist/utils/is-deep-equals';
 import { isUsersHomepage } from '@growi/core/dist/utils/page-path-utils';
 import { useSlidesByFrontmatter } from '@growi/presentation/dist/services';
 
+// biome-ignore lint/style/noRestrictedImports: client-only hook used in client-only component
+import { useContentAutoScroll } from '~/client/hooks/use-content-auto-scroll';
 import { PagePathNavTitle } from '~/components/Common/PagePathNavTitle';
 import type { RendererConfig } from '~/interfaces/services/renderer';
 import { useShouldExpandContent } from '~/services/layout/use-should-expand-content';
@@ -21,7 +23,6 @@ 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(
@@ -123,7 +124,7 @@ const PageViewComponent = (props: Props): JSX.Element => {
   );
 
   // Auto-scroll to URL hash target, handling lazy-rendered content
-  useHashAutoScroll(currentPageId, contentContainerId);
+  useContentAutoScroll({ key: currentPageId, contentContainerId });
 
   const specialContents = useMemo(() => {
     if (isIdenticalPathPage) {

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

@@ -1,137 +0,0 @@
-import { useEffect } from 'react';
-import {
-  GROWI_RENDERING_ATTR,
-  GROWI_RENDERING_ATTR_SELECTOR,
-} from '@growi/core/dist/consts';
-
-const RENDERING_POLL_INTERVAL_MS = 5000;
-const WATCH_TIMEOUT_MS = 10000;
-
-/**
- * Watch for `data-growi-rendering` elements in the container.
- * While any exist, wait 5 seconds then re-scroll to the target.
- * Repeats until no rendering elements remain.
- * A MutationObserver re-triggers the check when new elements appear.
- * Returns a cleanup function.
- */
-export const watchRenderingAndReScroll = (
-  contentContainer: HTMLElement,
-  scrollToTarget: () => boolean,
-): (() => void) => {
-  let timerId: number | undefined;
-
-  const cleanup = () => {
-    observer.disconnect();
-    if (timerId != null) {
-      window.clearTimeout(timerId);
-    }
-    window.clearTimeout(watchTimeoutId);
-  };
-
-  const checkAndSchedule = () => {
-    // If a timer is already ticking, let it fire — don't reset.
-    // Resetting on every DOM mutation would prevent the scroll
-    // from ever executing when rendering completes quickly.
-    if (timerId != null) return;
-
-    const hasRendering =
-      contentContainer.querySelector(GROWI_RENDERING_ATTR_SELECTOR) != null;
-
-    if (hasRendering) {
-      timerId = window.setTimeout(() => {
-        timerId = undefined;
-        scrollToTarget();
-        checkAndSchedule();
-      }, RENDERING_POLL_INTERVAL_MS);
-    }
-  };
-
-  const observer = new MutationObserver(checkAndSchedule);
-
-  observer.observe(contentContainer, {
-    childList: true,
-    subtree: true,
-    attributes: true,
-    attributeFilter: [GROWI_RENDERING_ATTR],
-  });
-
-  // Initial check
-  checkAndSchedule();
-
-  // Stop watching after timeout regardless of rendering state
-  const watchTimeoutId = window.setTimeout(cleanup, WATCH_TIMEOUT_MS);
-
-  return cleanup;
-};
-
-/**
- * Auto-scroll to the URL hash target when the page loads.
- * Handles lazy-rendered content by polling for `data-growi-rendering`
- * elements and re-scrolling after they finish.
- *
- * Flow:
- *   1. Guard: skip if pageId is null, hash is empty, or container not found
- *   2. Decode the hash and define scrollToTarget()
- *   3. If the target element already exists in the DOM:
- *      a. scrollIntoView() immediately
- *      b. Start watchRenderingAndReScroll() to compensate for layout shifts
- *   4. If the target element does NOT exist yet:
- *      a. Set up a MutationObserver on the container to wait for it
- *      b. When the target appears → scrollIntoView() + start watchRenderingAndReScroll()
- *      c. Give up after WATCH_TIMEOUT_MS (10s)
- *   5. Cleanup: disconnect observers, clear timers, stop rendering watch
- */
-export const useHashAutoScroll = (
-  pageId: string | undefined | null,
-  contentContainerId: string,
-): void => {
-  useEffect(() => {
-    if (pageId == 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 target = document.getElementById(targetId);
-      if (target == null) return false;
-      target.scrollIntoView();
-      return true;
-    };
-
-    // Target already in DOM — scroll and watch rendering
-    if (scrollToTarget()) {
-      return watchRenderingAndReScroll(contentContainer, scrollToTarget);
-    }
-
-    // Target not in DOM yet — wait for it, then watch rendering
-    let renderingCleanup: (() => void) | undefined;
-
-    const observer = new MutationObserver(() => {
-      if (scrollToTarget()) {
-        observer.disconnect();
-        window.clearTimeout(timeoutId);
-        renderingCleanup = watchRenderingAndReScroll(
-          contentContainer,
-          scrollToTarget,
-        );
-      }
-    });
-
-    observer.observe(contentContainer, { childList: true, subtree: true });
-    const timeoutId = window.setTimeout(
-      () => observer.disconnect(),
-      WATCH_TIMEOUT_MS,
-    );
-
-    return () => {
-      observer.disconnect();
-      window.clearTimeout(timeoutId);
-      renderingCleanup?.();
-    };
-  }, [pageId, contentContainerId]);
-};

+ 8 - 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';
 
@@ -34,15 +35,21 @@ export const MermaidViewer = React.memo(
             const id = `mermaid-${uuidV7()}`;
             const { svg } = await mermaid.render(id, value, ref.current);
             ref.current.innerHTML = svg;
+            ref.current.setAttribute(GROWI_IS_CONTENT_RENDERING_ATTR, 'false');
           } catch (err) {
             logger.error(err);
+            ref.current?.setAttribute(GROWI_IS_CONTENT_RENDERING_ATTR, 'false');
           }
         }
       })();
     }, [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],
   },
 };

+ 11 - 6
packages/core/src/consts/renderer.ts

@@ -1,10 +1,15 @@
 /**
  * HTML attribute name applied to elements that are currently being rendered
- * (e.g. Drawio, Mermaid diagrams). Removed once rendering is complete.
- * Used by PageView to detect in-progress renders before auto-scrolling.
+ * (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_RENDERING_ATTR = 'data-growi-rendering' as const;
+export const GROWI_IS_CONTENT_RENDERING_ATTR =
+  'data-growi-is-content-rendering' as const;
 
-/** CSS attribute selector for elements with {@link GROWI_RENDERING_ATTR}. */
-export const GROWI_RENDERING_ATTR_SELECTOR =
-  `[${GROWI_RENDERING_ATTR}]` 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;

+ 10 - 4
packages/remark-drawio/src/components/DrawioViewer.tsx

@@ -8,7 +8,7 @@ import {
   useRef,
   useState,
 } from 'react';
-import { GROWI_RENDERING_ATTR } from '@growi/core/dist/consts';
+import { GROWI_IS_CONTENT_RENDERING_ATTR } from '@growi/core/dist/consts';
 import { debounce } from 'throttle-debounce';
 
 import type { IGraphViewerGlobal } from '..';
@@ -128,7 +128,10 @@ export const DrawioViewer = memo((props: DrawioViewerProps): JSX.Element => {
   useEffect(() => {
     if (error != null) {
       onRenderingUpdated?.(null);
-      drawioContainerRef.current?.removeAttribute(GROWI_RENDERING_ATTR);
+      drawioContainerRef.current?.setAttribute(
+        GROWI_IS_CONTENT_RENDERING_ATTR,
+        'false',
+      );
     }
   }, [error, onRenderingUpdated]);
 
@@ -145,7 +148,10 @@ export const DrawioViewer = memo((props: DrawioViewerProps): JSX.Element => {
         if (mxgraphData != null) {
           const mxgraph = JSON.parse(mxgraphData);
           onRenderingUpdated?.(mxgraph.xml);
-          drawioContainerRef.current?.removeAttribute(GROWI_RENDERING_ATTR);
+          drawioContainerRef.current?.setAttribute(
+            GROWI_IS_CONTENT_RENDERING_ATTR,
+            'false',
+          );
         }
       }
     };
@@ -185,7 +191,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_RENDERING_ATTR]: 'true' }}
+      {...{ [GROWI_IS_CONTENT_RENDERING_ATTR]: 'true' }}
     >
       {/* show error */}
       {error != null && (

+ 3 - 3
packages/remark-drawio/src/services/renderer/remark-drawio.ts

@@ -1,4 +1,4 @@
-import { GROWI_RENDERING_ATTR } from '@growi/core/dist/consts';
+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';
@@ -10,7 +10,7 @@ const SUPPORTED_ATTRIBUTES = [
   'bol',
   'eol',
   'isDarkMode',
-  GROWI_RENDERING_ATTR,
+  GROWI_IS_CONTENT_RENDERING_ATTR,
 ];
 
 interface Data {
@@ -41,7 +41,7 @@ function rewriteNode(node: Node, index: number, isDarkMode?: boolean) {
     eol: node.position?.end.line,
     isDarkMode: isDarkMode ? 'true' : 'false',
     key: `drawio-${index}`,
-    [GROWI_RENDERING_ATTR]: 'true',
+    [GROWI_IS_CONTENT_RENDERING_ATTR]: 'true',
   };
 }
 

+ 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>;