From 0650cab5db613f60c5a3d9e80a971fc12d0886ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=B0=EC=83=81=EB=B9=88?= Date: Sun, 16 Aug 2026 20:37:20 +0900 Subject: [PATCH 1/2] =?UTF-8?q?perf(toc):=20=EC=97=AD=EC=88=9C=20=ED=83=90?= =?UTF-8?q?=EC=83=89=20=EC=A1=B0=EA=B8=B0=20=EC=A2=85=EB=A3=8C=20=EB=B0=8F?= =?UTF-8?q?=20=EB=B6=88=ED=95=84=EC=9A=94=ED=95=9C=20getBoundingClientRect?= =?UTF-8?q?=20=ED=98=B8=EC=B6=9C=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - useActiveHeading에서 헤딩 목록을 역순(Bottom-up)으로 탐색하여 활성선 기준점 이하의 첫 헤딩 발견 시 즉시 조기 종료(Early-break) 적용 - 긴 포스트 스크롤 시 상단 헤딩들의 불필요한 DOM 측정(Reflow) 방지 - 동일 ID 상태 변경 방지 가드 추가 및 조기 종료 동작 검증 단위 테스트 추가 --- src/features/post-toc/use-active-heading.ts | 24 ++++++++++++++++----- tests/use-active-heading.test.tsx | 18 ++++++++++++++++ 2 files changed, 37 insertions(+), 5 deletions(-) diff --git a/src/features/post-toc/use-active-heading.ts b/src/features/post-toc/use-active-heading.ts index 5851381..5d8308a 100644 --- a/src/features/post-toc/use-active-heading.ts +++ b/src/features/post-toc/use-active-heading.ts @@ -3,8 +3,16 @@ import { useEffect, useState } from "react" import type { TableOfContentsItem } from "@/lib/markdown" -const ACTIVE_LINE_VIEWPORT_RATIO = 0.35 - +export const ACTIVE_LINE_VIEWPORT_RATIO = 0.35 + +/** + * 목차(TOC)에서 현재 스크롤 위치에 해당하는 활성 헤딩 ID를 추적합니다. + * 뷰포트 높이의 35% 지점(ACTIVE_LINE_VIEWPORT_RATIO)을 지나는 가장 최근 헤딩을 식별합니다. + * + * [성능 최적화]: + * 1. requestAnimationFrame(rAF) 스로틀링을 적용하여 스크롤 중 프레임당 최대 1회만 계산. + * 2. 역순(Bottom-up) 탐색 및 조기 종료(Early-break)를 통해 활성선 이전 헤딩들의 불필요한 getBoundingClientRect() 호출 방지. + */ export function useActiveHeading(items: readonly TableOfContentsItem[]): string | undefined { const [activeHeadingId, setActiveHeadingId] = useState() @@ -30,13 +38,19 @@ export function useActiveHeading(items: readonly TableOfContentsItem[]): string const activeLine = window.innerHeight * ACTIVE_LINE_VIEWPORT_RATIO let nextActiveHeadingId: string | undefined - for (const heading of headings) { - if (heading.getBoundingClientRect().top <= activeLine) { + // 아래쪽(최신) 헤딩부터 역순으로 검사하여 활성선(activeLine) 이하에 도달한 첫 번째 요소를 찾으면 즉시 루프 종료 + for (let i = headings.length - 1; i >= 0; i -= 1) { + const heading = headings[i] + + if (heading !== undefined && heading.getBoundingClientRect().top <= activeLine) { nextActiveHeadingId = heading.id + break } } - setActiveHeadingId(nextActiveHeadingId) + setActiveHeadingId((current) => + current === nextActiveHeadingId ? current : nextActiveHeadingId, + ) } const requestActiveHeadingUpdate = () => { diff --git a/tests/use-active-heading.test.tsx b/tests/use-active-heading.test.tsx index ac2b688..9a794f6 100644 --- a/tests/use-active-heading.test.tsx +++ b/tests/use-active-heading.test.tsx @@ -41,6 +41,24 @@ describe("active heading tracking", () => { expect(result.current).toBe("body") }) + it("Given multiple headings When bottom-up search finds active heading Then it breaks early without measuring earlier headings", () => { + const first = placeHeading("first", -300) + const second = placeHeading("second", 150) + const third = placeHeading("third", 500) + + const firstMeasure = vi.spyOn(first, "getBoundingClientRect") + const secondMeasure = vi.spyOn(second, "getBoundingClientRect") + const thirdMeasure = vi.spyOn(third, "getBoundingClientRect") + + const { result } = renderHook(() => useActiveHeading(tocItems("first", "second", "third"))) + + expect(result.current).toBe("second") + expect(thirdMeasure).toHaveBeenCalled() + expect(secondMeasure).toHaveBeenCalled() + // second가 activeLine 이하이므로 루프가 조기 종료되어 first는 측정되지 않아야 한다. + expect(firstMeasure).not.toHaveBeenCalled() + }) + it("Given every heading below the active line When tracking Then reports no active heading", () => { placeHeading("intro", ACTIVE_LINE + 1) From 48b560fbaa0ca3a19bcd4adf227b4fa786f9ee84 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=B0=EC=83=81=EB=B9=88?= Date: Sun, 16 Aug 2026 20:38:40 +0900 Subject: [PATCH 2/2] fix(tests): declare window.happyDOM global type for IDE type checking --- tests/use-active-heading.test.tsx | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/tests/use-active-heading.test.tsx b/tests/use-active-heading.test.tsx index 9a794f6..41ff2c8 100644 --- a/tests/use-active-heading.test.tsx +++ b/tests/use-active-heading.test.tsx @@ -4,6 +4,14 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" import { useActiveHeading } from "../src/features/post-toc/use-active-heading" import type { TableOfContentsItem } from "../src/lib/markdown" +declare global { + interface Window { + happyDOM: { + setViewport: (options: { width?: number; height?: number }) => void + } + } +} + // 활성 판정선은 뷰포트 높이의 35% 지점이다. 800px 뷰포트에서는 280px. const VIEWPORT_HEIGHT = 800 const ACTIVE_LINE = 280