Yuki Takei 23 saat önce
ebeveyn
işleme
1e5dc31140

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

@@ -122,32 +122,32 @@
   - 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)
 
-- [ ] 8. Reorganize auto-scroll modules by co-locating hooks with their consumers
-- [ ] 8.1 Move the rendering watch utility to the shared utility directory
+- [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_
-- [ ] 8.2 Rename and move the hash-based auto-scroll hook next to PageView
+- [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_
-- [ ] 8.3 Extract the keyword-scroll effect from SearchResultContent into a co-located hook
+- [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_
-- [ ] 8.4 (P) Write tests for the extracted keyword-rescroll hook
+- [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_
-- [ ] 8.5 (P) Remove the old shared hooks directory and verify no stale imports
+- [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

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

@@ -1 +0,0 @@
-export * from './use-content-auto-scroll';

+ 0 - 0
apps/app/src/client/hooks/use-content-auto-scroll/watch-rendering-and-rescroll.spec.tsx → apps/app/src/client/util/watch-rendering-and-rescroll.spec.tsx


+ 0 - 0
apps/app/src/client/hooks/use-content-auto-scroll/watch-rendering-and-rescroll.ts → apps/app/src/client/util/watch-rendering-and-rescroll.ts


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

@@ -4,8 +4,6 @@ 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';
@@ -23,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(
@@ -124,7 +123,7 @@ const PageViewComponent = (props: Props): JSX.Element => {
   );
 
   // Auto-scroll to URL hash target, handling lazy-rendered content
-  useContentAutoScroll({ key: currentPageId, contentContainerId });
+  useHashAutoScroll({ key: currentPageId, contentContainerId });
 
   const specialContents = useMemo(() => {
     if (isIdenticalPathPage) {

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

@@ -2,9 +2,9 @@ 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 { useContentAutoScroll } from './use-content-auto-scroll';
+import { useHashAutoScroll } from './use-hash-auto-scroll';
 
-describe('useContentAutoScroll', () => {
+describe('useHashAutoScroll', () => {
   const containerId = 'test-content-container';
   let container: HTMLDivElement;
 
@@ -29,7 +29,7 @@ describe('useContentAutoScroll', () => {
     container.appendChild(target);
 
     renderHook(() =>
-      useContentAutoScroll({ key: null, contentContainerId: containerId }),
+      useHashAutoScroll({ key: null, contentContainerId: containerId }),
     );
 
     expect(target.scrollIntoView).not.toHaveBeenCalled();
@@ -43,7 +43,7 @@ describe('useContentAutoScroll', () => {
     container.appendChild(target);
 
     renderHook(() =>
-      useContentAutoScroll({ key: undefined, contentContainerId: containerId }),
+      useHashAutoScroll({ key: undefined, contentContainerId: containerId }),
     );
 
     expect(target.scrollIntoView).not.toHaveBeenCalled();
@@ -57,7 +57,7 @@ describe('useContentAutoScroll', () => {
     container.appendChild(target);
 
     renderHook(() =>
-      useContentAutoScroll({ key: 'page-id', contentContainerId: containerId }),
+      useHashAutoScroll({ key: 'page-id', contentContainerId: containerId }),
     );
 
     expect(target.scrollIntoView).not.toHaveBeenCalled();
@@ -71,7 +71,7 @@ describe('useContentAutoScroll', () => {
     container.appendChild(target);
 
     renderHook(() =>
-      useContentAutoScroll({
+      useHashAutoScroll({
         key: 'page-id',
         contentContainerId: 'nonexistent-id',
       }),
@@ -88,7 +88,7 @@ describe('useContentAutoScroll', () => {
     container.appendChild(target);
 
     const { unmount } = renderHook(() =>
-      useContentAutoScroll({ key: 'page-id', contentContainerId: containerId }),
+      useHashAutoScroll({ key: 'page-id', contentContainerId: containerId }),
     );
 
     expect(target.scrollIntoView).toHaveBeenCalledTimes(1);
@@ -105,7 +105,7 @@ describe('useContentAutoScroll', () => {
     container.appendChild(target);
 
     const { unmount } = renderHook(() =>
-      useContentAutoScroll({ key: 'page-id', contentContainerId: containerId }),
+      useHashAutoScroll({ key: 'page-id', contentContainerId: containerId }),
     );
 
     expect(target.scrollIntoView).toHaveBeenCalledTimes(1);
@@ -120,7 +120,7 @@ describe('useContentAutoScroll', () => {
     const resolveTarget = vi.fn(() => target);
 
     const { unmount } = renderHook(() =>
-      useContentAutoScroll({
+      useHashAutoScroll({
         key: 'page-id',
         contentContainerId: containerId,
         resolveTarget,
@@ -142,7 +142,7 @@ describe('useContentAutoScroll', () => {
     const customScrollTo = vi.fn();
 
     const { unmount } = renderHook(() =>
-      useContentAutoScroll({
+      useHashAutoScroll({
         key: 'page-id',
         contentContainerId: containerId,
         scrollTo: customScrollTo,
@@ -167,7 +167,7 @@ describe('useContentAutoScroll', () => {
     container.appendChild(renderingEl);
 
     const { unmount } = renderHook(() =>
-      useContentAutoScroll({ key: 'page-id', contentContainerId: containerId }),
+      useHashAutoScroll({ key: 'page-id', contentContainerId: containerId }),
     );
 
     expect(target.scrollIntoView).toHaveBeenCalledTimes(1);
@@ -194,7 +194,7 @@ describe('useContentAutoScroll', () => {
 
     // No rendering elements at scroll time
     const { unmount } = renderHook(() =>
-      useContentAutoScroll({ key: 'page-id', contentContainerId: containerId }),
+      useHashAutoScroll({ key: 'page-id', contentContainerId: containerId }),
     );
 
     expect(target.scrollIntoView).toHaveBeenCalledTimes(1);
@@ -220,7 +220,7 @@ describe('useContentAutoScroll', () => {
     container.appendChild(target);
 
     const { unmount } = renderHook(() =>
-      useContentAutoScroll({ key: 'page-id', contentContainerId: containerId }),
+      useHashAutoScroll({ key: 'page-id', contentContainerId: containerId }),
     );
 
     expect(target.scrollIntoView).toHaveBeenCalledTimes(1);
@@ -240,7 +240,7 @@ describe('useContentAutoScroll', () => {
     window.location.hash = '#deferred';
 
     const { unmount } = renderHook(() =>
-      useContentAutoScroll({ key: 'page-id', contentContainerId: containerId }),
+      useHashAutoScroll({ key: 'page-id', contentContainerId: containerId }),
     );
 
     const target = document.createElement('div');
@@ -259,7 +259,7 @@ describe('useContentAutoScroll', () => {
     window.location.hash = '#never-appears';
 
     const { unmount } = renderHook(() =>
-      useContentAutoScroll({ key: 'page-id', contentContainerId: containerId }),
+      useHashAutoScroll({ key: 'page-id', contentContainerId: containerId }),
     );
 
     // Advance past the timeout
@@ -290,7 +290,7 @@ describe('useContentAutoScroll', () => {
     container.appendChild(renderingEl);
 
     const { unmount } = renderHook(() =>
-      useContentAutoScroll({ key: 'page-id', contentContainerId: containerId }),
+      useHashAutoScroll({ key: 'page-id', contentContainerId: containerId }),
     );
 
     expect(target.scrollIntoView).toHaveBeenCalledTimes(1);
@@ -310,8 +310,7 @@ describe('useContentAutoScroll', () => {
     container.appendChild(target);
 
     const { rerender, unmount } = renderHook(
-      ({ key }) =>
-        useContentAutoScroll({ key, contentContainerId: containerId }),
+      ({ key }) => useHashAutoScroll({ key, contentContainerId: containerId }),
       { initialProps: { key: 'page-1' as string | null } },
     );
 

+ 5 - 6
apps/app/src/client/hooks/use-content-auto-scroll/use-content-auto-scroll.ts → apps/app/src/components/PageView/use-hash-auto-scroll.ts

@@ -3,10 +3,11 @@ import { useEffect, useRef } from 'react';
 import {
   WATCH_TIMEOUT_MS,
   watchRenderingAndReScroll,
-} from './watch-rendering-and-rescroll';
+  // biome-ignore lint/style/noRestrictedImports: client-only hook used in client-only component
+} from '~/client/util/watch-rendering-and-rescroll';
 
-/** Configuration for the auto-scroll hook */
-export interface UseContentAutoScrollOptions {
+/** 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.
@@ -35,9 +36,7 @@ export interface UseContentAutoScrollOptions {
  * Handles lazy-rendered content by polling for rendering-status
  * attributes and re-scrolling after they finish.
  */
-export const useContentAutoScroll = (
-  options: UseContentAutoScrollOptions,
-): void => {
+export const useHashAutoScroll = (options: UseHashAutoScrollOptions): void => {
   const { key, contentContainerId } = options;
   const resolveTargetRef = useRef(options.resolveTarget);
   resolveTargetRef.current = options.resolveTarget;

+ 31 - 106
apps/app/src/features/search/client/components/SearchPage/SearchResultContent.spec.tsx

@@ -4,17 +4,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
 
 import type { IPageWithSearchMeta } from '~/interfaces/search';
 
-// Mock watchRenderingAndReScroll
-vi.mock(
-  '~/client/hooks/use-content-auto-scroll/watch-rendering-and-rescroll',
-  () => ({
-    watchRenderingAndReScroll: vi.fn(() => vi.fn()), // returns a cleanup fn
-  }),
-);
-
-// Mock scrollWithinContainer
-vi.mock('~/client/util/smooth-scroll', () => ({
-  scrollWithinContainer: vi.fn(),
+// Mock useKeywordRescroll
+vi.mock('./use-keyword-rescroll', () => ({
+  useKeywordRescroll: vi.fn(),
 }));
 
 // Mock next/dynamic
@@ -60,13 +52,10 @@ vi.mock('~/components/Common/PagePathNav', () => ({
   PagePathNav: () => null,
 }));
 
-import { watchRenderingAndReScroll } from '~/client/hooks/use-content-auto-scroll/watch-rendering-and-rescroll';
-import { scrollWithinContainer } from '~/client/util/smooth-scroll';
-
 import { SearchResultContent } from './SearchResultContent';
+import { useKeywordRescroll } from './use-keyword-rescroll';
 
-const mockWatchRenderingAndReScroll = vi.mocked(watchRenderingAndReScroll);
-const mockScrollWithinContainer = vi.mocked(scrollWithinContainer);
+const mockUseKeywordRescroll = vi.mocked(useKeywordRescroll);
 
 const createMockPage = (overrides: Partial<IPageHasId> = {}): IPageHasId =>
   ({
@@ -82,9 +71,7 @@ const createMockPageWithMeta = (page: IPageHasId = createMockPage()) =>
 
 describe('SearchResultContent', () => {
   beforeEach(() => {
-    mockWatchRenderingAndReScroll.mockReset();
-    mockWatchRenderingAndReScroll.mockReturnValue(vi.fn());
-    mockScrollWithinContainer.mockReset();
+    mockUseKeywordRescroll.mockReset();
     window.location.hash = '';
   });
 
@@ -92,110 +79,48 @@ describe('SearchResultContent', () => {
     window.location.hash = '';
   });
 
-  describe('watchRenderingAndReScroll integration', () => {
-    it('should call watchRenderingAndReScroll with the scroll container element', () => {
+  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(mockWatchRenderingAndReScroll).toHaveBeenCalledTimes(1);
-      const firstCall = mockWatchRenderingAndReScroll.mock.calls[0];
-      expect(firstCall).toBeDefined();
-      const containerArg = firstCall?.[0];
-      expect(containerArg).toBeInstanceOf(HTMLElement);
-      expect((containerArg as HTMLElement).id).toBe(
-        'search-result-content-body-container',
-      );
-    });
-
-    it('should pass a scrollToKeyword function as the second argument', () => {
-      const page = createMockPage();
-      const pageWithMeta = createMockPageWithMeta(page);
-
-      render(<SearchResultContent pageWithMeta={pageWithMeta} />);
-
-      const scrollToKeyword = mockWatchRenderingAndReScroll.mock.calls[0]?.[1];
-      expect(typeof scrollToKeyword).toBe('function');
+      expect(mockUseKeywordRescroll).toHaveBeenCalledTimes(1);
+      const callArgs = mockUseKeywordRescroll.mock.calls[0]?.[0];
+      expect(callArgs?.key).toBe('page-123');
     });
 
-    it('scrollToKeyword should scroll to .highlighted-keyword within container', () => {
-      const page = createMockPage();
-      const pageWithMeta = createMockPageWithMeta(page);
-
-      render(<SearchResultContent pageWithMeta={pageWithMeta} />);
-
-      const firstCall = mockWatchRenderingAndReScroll.mock.calls[0];
-      expect(firstCall).toBeDefined();
-      const container = firstCall?.[0] as HTMLElement;
-      const scrollToKeyword = firstCall?.[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);
-
-      container.removeChild(keyword);
-    });
-
-    it('scrollToKeyword should return false when no .highlighted-keyword element exists', () => {
-      const page = createMockPage();
+    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 scrollToKeyword = mockWatchRenderingAndReScroll.mock.calls[0]?.[1];
-      expect(scrollToKeyword).toBeDefined();
-
-      const result = scrollToKeyword?.();
-
-      expect(result).toBe(false);
-      expect(mockScrollWithinContainer).not.toHaveBeenCalled();
+      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 call watchRenderingAndReScroll cleanup when component unmounts', () => {
-      const mockCleanup = vi.fn();
-      mockWatchRenderingAndReScroll.mockReturnValue(mockCleanup);
+    it('should re-call useKeywordRescroll with new key when page changes', () => {
+      const page1 = createMockPage({ _id: 'page-1' });
+      const pageWithMeta1 = createMockPageWithMeta(page1);
 
-      const page = createMockPage();
-      const pageWithMeta = createMockPageWithMeta(page);
-
-      const { unmount } = render(
-        <SearchResultContent pageWithMeta={pageWithMeta} />,
+      const { rerender } = render(
+        <SearchResultContent pageWithMeta={pageWithMeta1} />,
       );
 
-      unmount();
+      const page2 = createMockPage({ _id: 'page-2' });
+      const pageWithMeta2 = createMockPageWithMeta(page2);
+
+      rerender(<SearchResultContent pageWithMeta={pageWithMeta2} />);
 
-      expect(mockCleanup).toHaveBeenCalledTimes(1);
+      // useKeywordRescroll should be called with new key on rerender
+      const lastCall = mockUseKeywordRescroll.mock.calls.at(-1)?.[0];
+      expect(lastCall?.key).toBe('page-2');
     });
   });
 });

+ 4 - 60
apps/app/src/features/search/client/components/SearchPage/SearchResultContent.tsx

@@ -1,20 +1,17 @@
 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,
   ForceHideMenuItems,
 } from '~/client/components/Common/Dropdown/PageItemControl';
 import type { RevisionLoaderProps } from '~/client/components/Page/RevisionLoader';
-import { watchRenderingAndReScroll } from '~/client/hooks/use-content-auto-scroll/watch-rendering-and-rescroll';
 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';
@@ -36,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'];
@@ -90,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[];
@@ -100,65 +96,13 @@ type Props = {
   forceHideMenuItems?: ForceHideMenuItems;
 };
 
-const scrollToTargetWithinContainer = (
-  target: HTMLElement,
-  container: HTMLElement,
-): void => {
-  const distance =
-    target.getBoundingClientRect().top -
-    container.getBoundingClientRect().top -
-    SCROLL_OFFSET_TOP;
-  scrollWithinContainer(container, distance);
-};
-
-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;
-  scrollToTargetWithinContainer(toElem, scrollElement);
-};
-const scrollToFirstHighlightedKeywordDebounced = debounce(
-  500,
-  scrollToFirstHighlightedKeyword,
-);
-
 export const SearchResultContent: FC<Props> = (props: Props) => {
   const scrollElementRef = useRef<HTMLDivElement | null>(null);
 
   const { pageWithMeta } = props;
   const page = pageWithMeta.data;
 
-  // ***************************  Keyword Scroll  ***************************
-  // biome-ignore lint/correctness/useExhaustiveDependencies: page._id is a trigger dep: re-run this effect when the selected page changes
-  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;
-    };
-
-    const observer = new MutationObserver(() => {
-      scrollToFirstHighlightedKeywordDebounced(scrollElement);
-    });
-    observer.observe(scrollElement, MUTATION_OBSERVER_CONFIG);
-
-    // Re-scroll to keyword after async renderers (drawio/mermaid) cause layout shifts
-    const cleanupWatch = watchRenderingAndReScroll(
-      scrollElement,
-      scrollToKeyword,
-    );
-    return cleanupWatch;
-  }, [page._id]);
-  // *******************************  end  *******************************
+  useKeywordRescroll({ scrollElementRef, key: page._id });
 
   const { highlightKeywords, showPageControlDropdown, forceHideMenuItems } =
     props;

+ 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();
+  });
+});

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

@@ -0,0 +1,81 @@
+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);
+};
+
+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;
+  scrollToTargetWithinContainer(toElem, scrollElement);
+};
+
+const scrollToFirstHighlightedKeywordDebounced = debounce(
+  500,
+  scrollToFirstHighlightedKeyword,
+);
+
+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 scrollToKeyword = (): boolean => {
+      const toElem = scrollElement.querySelector(
+        '.highlighted-keyword',
+      ) as HTMLElement | null;
+      if (toElem == null) return false;
+      scrollToTargetWithinContainer(toElem, scrollElement);
+      return true;
+    };
+
+    const observer = new MutationObserver(() => {
+      scrollToFirstHighlightedKeywordDebounced(scrollElement);
+    });
+    observer.observe(scrollElement, MUTATION_OBSERVER_CONFIG);
+
+    // Re-scroll to keyword after async renderers (drawio/mermaid) cause layout shifts
+    const cleanupWatch = watchRenderingAndReScroll(
+      scrollElement,
+      scrollToKeyword,
+    );
+
+    return () => {
+      observer.disconnect();
+      cleanupWatch();
+    };
+  }, [key]);
+};