From 038e216857321d1709914794eff662a974e2b609 Mon Sep 17 00:00:00 2001 From: Simon Siefke Date: Wed, 29 Jul 2026 19:42:12 +0000 Subject: [PATCH] fix(core): prevent Link observer leaks (AI-assisted) --- .../docusaurus/src/client/exports/Link.tsx | 61 ++++++++++--------- .../client/exports/__tests__/Link.test.tsx | 52 +++++++++++++++- 2 files changed, 83 insertions(+), 30 deletions(-) diff --git a/packages/docusaurus/src/client/exports/Link.tsx b/packages/docusaurus/src/client/exports/Link.tsx index 1fab775967ee..7352508c14dc 100644 --- a/packages/docusaurus/src/client/exports/Link.tsx +++ b/packages/docusaurus/src/client/exports/Link.tsx @@ -6,6 +6,7 @@ */ import React, { + useCallback, useEffect, useImperativeHandle, useRef, @@ -99,30 +100,39 @@ function Link({ const ioRef = useRef(undefined); - const handleRef = (el: HTMLAnchorElement | null) => { - innerRef.current = el; - - if (IOSupported && el && isInternal) { - // If IO supported and element reference found, set up Observer. - ioRef.current = new window.IntersectionObserver((entries) => { - entries.forEach((entry) => { - if (el === entry.target) { - // If element is in viewport, stop observing and run callback. - // https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API - if (entry.isIntersecting || entry.intersectionRatio > 0) { - ioRef.current!.unobserve(el); - ioRef.current!.disconnect(); - if (targetLink != null) { - window.docusaurus.prefetch(targetLink); + const handleRef = useCallback( + (el: HTMLAnchorElement | null) => { + innerRef.current = el; + ioRef.current?.disconnect(); + ioRef.current = undefined; + + if (IOSupported && el && isInternal) { + // If IO supported and element reference found, set up Observer. + const observer = new window.IntersectionObserver((entries) => { + entries.forEach((entry) => { + if (el === entry.target) { + // If element is in viewport, stop observing and run callback. + // https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API + if (entry.isIntersecting || entry.intersectionRatio > 0) { + observer.unobserve(el); + observer.disconnect(); + if (ioRef.current === observer) { + ioRef.current = undefined; + } + if (targetLink != null) { + window.docusaurus.prefetch(targetLink); + } } } - } + }); }); - }); - // Add element to the observer. - ioRef.current.observe(el); - } - }; + // Add element to the observer. + ioRef.current = observer; + observer.observe(el); + } + }, + [IOSupported, isInternal, targetLink], + ); const onInteractionEnter = () => { if (!preloaded.current && targetLink != null) { @@ -138,14 +148,7 @@ function Link({ window.docusaurus.prefetch(targetLink); } } - - // When unmounting, stop intersection observer from watching. - return () => { - if (IOSupported && ioRef.current) { - ioRef.current.disconnect(); - } - }; - }, [ioRef, targetLink, IOSupported, isInternal]); + }, [targetLink, IOSupported, isInternal]); // It is simple local anchor link targeting current page? const isAnchorLink = targetLink?.startsWith('#') ?? false; diff --git a/packages/docusaurus/src/client/exports/__tests__/Link.test.tsx b/packages/docusaurus/src/client/exports/__tests__/Link.test.tsx index fce2c970b6a8..f236b144162c 100644 --- a/packages/docusaurus/src/client/exports/__tests__/Link.test.tsx +++ b/packages/docusaurus/src/client/exports/__tests__/Link.test.tsx @@ -11,8 +11,9 @@ import React, {type ReactNode} from 'react'; import {render as renderRTL} from '@testing-library/react'; import '@testing-library/jest-dom/vitest'; import {fromPartial} from '@total-typescript/shoehorn'; -import {StaticRouter} from 'react-router-dom'; +import {MemoryRouter, StaticRouter} from 'react-router-dom'; import Link from '../Link'; +import ExecutionEnvironment from '../ExecutionEnvironment'; import {Context} from '../../docusaurusContext'; import type {DocusaurusContext} from '@docusaurus/types'; @@ -67,6 +68,55 @@ function createLinkRenderer(defaultRendererOptions: Partial = {}) { } describe('', () => { + it('reuses and disposes its intersection observer across renders', () => { + const canUseIntersectionObserver = + ExecutionEnvironment.canUseIntersectionObserver; + const observers: IntersectionObserver[] = []; + const createIntersectionObserver = vi.fn(function IntersectionObserver() { + const observer = fromPartial({ + disconnect: vi.fn(), + observe: vi.fn(), + unobserve: vi.fn(), + }); + observers.push(observer); + return observer; + }); + vi.stubGlobal('IntersectionObserver', createIntersectionObserver); + ExecutionEnvironment.canUseIntersectionObserver = true; + + const context = createDocusaurusContext(defaultOptions); + function Wrapper({children}: {children: ReactNode}) { + return ( + + {children} + + ); + } + + try { + const {rerender, unmount} = renderRTL(, { + wrapper: Wrapper, + }); + expect(createIntersectionObserver).toHaveBeenCalledTimes(1); + + rerender(); + expect(createIntersectionObserver).toHaveBeenCalledTimes(1); + expect(observers[0]!.disconnect).not.toHaveBeenCalled(); + + rerender(); + expect(createIntersectionObserver).toHaveBeenCalledTimes(2); + expect(observers[0]!.disconnect).toHaveBeenCalledTimes(1); + expect(observers[1]!.disconnect).not.toHaveBeenCalled(); + + unmount(); + expect(observers[1]!.disconnect).toHaveBeenCalledTimes(1); + } finally { + ExecutionEnvironment.canUseIntersectionObserver = + canUseIntersectionObserver; + vi.unstubAllGlobals(); + } + }); + describe('using "browser" router', () => { const render = createLinkRenderer({router: 'browser'});