|
|
@@ -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();
|