diff --git a/.github/workflows/lighthouse-report.yml b/.github/workflows/lighthouse-report.yml index 7711bba545f7..be3610facbb9 100644 --- a/.github/workflows/lighthouse-report.yml +++ b/.github/workflows/lighthouse-report.yml @@ -68,6 +68,7 @@ jobs: - name: Add Lighthouse stats as comment id: comment_to_pr + if: github.event.pull_request.head.repo.full_name == github.repository uses: marocchino/sticky-pull-request-comment@5770ad5eb8f42dd2c4f34da00c94c5381e49af88 # 3.0.5 with: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/lint-autofix.yml b/.github/workflows/lint-autofix.yml index 3bada614c695..55635072b0a3 100644 --- a/.github/workflows/lint-autofix.yml +++ b/.github/workflows/lint-autofix.yml @@ -55,5 +55,6 @@ jobs: run: git diff - uses: stefanzweifel/git-auto-commit-action@4a55954c782fc1ea30b9056cd3e7a2b40ca8887d # v7.2.0 + if: github.event.pull_request.head.repo.full_name == github.repository with: commit_message: 'refactor: apply lint autofix' diff --git a/packages/docusaurus-bundler/src/minifyHtml.ts b/packages/docusaurus-bundler/src/minifyHtml.ts index 226fb08f2ba2..f76f4bb48848 100644 --- a/packages/docusaurus-bundler/src/minifyHtml.ts +++ b/packages/docusaurus-bundler/src/minifyHtml.ts @@ -88,6 +88,11 @@ async function getSwcMinifier(): Promise { // See https://github.com/swc-project/swc/issues/10994 tagOmission: 'keep-head-and-body', + // Keep attribute quotes. WhatsApp and some RDFa parsers ignore + // og:image when the minifier emits property=og:image without quotes. + // See https://github.com/facebook/docusaurus/issues/12368 + quotes: true, + // Sorting these attributes (class) can lead to React hydration errors sortSpaceSeparatedAttributeValues: false, sortAttributes: false, diff --git a/packages/docusaurus-theme-translations/locales/az/plugin-ideal-image.json b/packages/docusaurus-theme-translations/locales/az/plugin-ideal-image.json new file mode 100644 index 000000000000..accd2e5b50e7 --- /dev/null +++ b/packages/docusaurus-theme-translations/locales/az/plugin-ideal-image.json @@ -0,0 +1,7 @@ +{ + "theme.IdealImageMessage.404error": "404. Təsvir tapılmadı", + "theme.IdealImageMessage.error": "Xəta. Yeniləmək üçün klik edin.", + "theme.IdealImageMessage.load": "Yükləmək üçün klik edin{sizeMessage}", + "theme.IdealImageMessage.loading": "Yüklənir...", + "theme.IdealImageMessage.offline": "Brauzer oflayndır. Təsvir yüklənmədi" +} diff --git a/packages/docusaurus-theme-translations/locales/az/plugin-pwa.json b/packages/docusaurus-theme-translations/locales/az/plugin-pwa.json new file mode 100644 index 000000000000..4a96350db073 --- /dev/null +++ b/packages/docusaurus-theme-translations/locales/az/plugin-pwa.json @@ -0,0 +1,5 @@ +{ + "theme.PwaReloadPopup.closeButtonAriaLabel": "Bağla", + "theme.PwaReloadPopup.info": "Yeni versiya mövcuddur", + "theme.PwaReloadPopup.refreshButtonText": "Yenilə" +} diff --git a/packages/docusaurus-theme-translations/locales/az/theme-common.json b/packages/docusaurus-theme-translations/locales/az/theme-common.json new file mode 100644 index 000000000000..1cc16d46e99d --- /dev/null +++ b/packages/docusaurus-theme-translations/locales/az/theme-common.json @@ -0,0 +1,84 @@ +{ + "theme.AnnouncementBar.closeButtonAriaLabel": "Bağla", + "theme.BackToTopButton.buttonAriaLabel": "Səhifənin başına qayıt", + "theme.CodeBlock.copied": "Kopyalandı", + "theme.CodeBlock.copy": "Kopyala", + "theme.CodeBlock.copyButtonAriaLabel": "Kodu kopyala", + "theme.CodeBlock.wordWrapToggle": "Söz sürüşdürməni aç/bağla", + "theme.DocSidebarItem.collapseCategoryAriaLabel": "'{label}' yan panel kateqoriyasını yığ", + "theme.DocSidebarItem.expandCategoryAriaLabel": "'{label}' yan panel kateqoriyasını aç", + "theme.ErrorPageContent.title": "Bu səhifə çökdü.", + "theme.ErrorPageContent.tryAgain": "Yenidən cəhd edin", + "theme.IconExternalLink.ariaLabel": "(yeni tab-da açılır)", + "theme.NavBar.navAriaLabel": "Əsas", + "theme.NotFound.p1": "Axtardığınızı tapa bilmədik.", + "theme.NotFound.p2": "Zəhmət olmasa, sizi bu ünvana yönləndirən saytın sahibi ilə əlaqə saxlayın və linkin işləmədiyini bildirin.", + "theme.NotFound.title": "Səhifə tapılmadı", + "theme.TOCCollapsible.toggleButtonLabel": "Bu səhifədə", + "theme.admonition.caution": "diqqət", + "theme.admonition.danger": "təhlükə", + "theme.admonition.info": "məlumat", + "theme.admonition.note": "qeyd", + "theme.admonition.tip": "məsləhət", + "theme.admonition.warning": "xəbərdarlıq", + "theme.blog.archive.description": "Arxiv", + "theme.blog.archive.title": "Arxiv", + "theme.blog.author.noPosts": "Bu müəllif hələ heç bir yazı yazmayıb.", + "theme.blog.author.pageTitle": "{authorName} - {nPosts}", + "theme.blog.authorsList.pageTitle": "Müəlliflər", + "theme.blog.authorsList.viewAll": "Bütün müəlliflərə bax", + "theme.blog.paginator.navAriaLabel": "Bloq siyahısı səhifə naviqasiyası", + "theme.blog.paginator.newerEntries": "Daha yeni yazılar", + "theme.blog.paginator.olderEntries": "Daha köhnə yazılar", + "theme.blog.post.paginator.navAriaLabel": "Bloq yazısı səhifə naviqasiyası", + "theme.blog.post.paginator.newerPost": "Daha yeni yazı", + "theme.blog.post.paginator.olderPost": "Daha köhnə yazı", + "theme.blog.post.plurals": "Bir yazı|{count} yazı", + "theme.blog.post.readMore": "Ətraflı oxu", + "theme.blog.post.readMoreLabel": "{title} haqqında ətraflı oxu", + "theme.blog.post.readingTime.plurals": "Bir dəqiqəlik oxu|{readingTime} dəqiqəlik oxu", + "theme.blog.sidebar.navAriaLabel": "Bloqun son yazıları üzrə naviqasiya", + "theme.blog.tagTitle": "\"{tagName}\" etiketi ilə {nPosts}", + "theme.colorToggle.ariaLabel": "Tünd və işıqlı rejim arasında keçid et (hazırda {mode})", + "theme.colorToggle.ariaLabel.mode.dark": "tünd rejim", + "theme.colorToggle.ariaLabel.mode.light": "işıqlı rejim", + "theme.colorToggle.ariaLabel.mode.system": "sistem rejimi", + "theme.common.editThisPage": "Səhifəni redaktə et", + "theme.common.headingLinkTitle": "{heading} başlığına birbaşa keçid", + "theme.common.skipToMainContent": "Əsas məzmuna keç", + "theme.contentVisibility.draftBanner.message": "Bu səhifə qaralamadır. O, yalnız dev rejimində görünəcək və istismar mühitinə daxil edilməyəcək.", + "theme.contentVisibility.draftBanner.title": "Qaralama səhifə", + "theme.contentVisibility.unlistedBanner.message": "Bu səhifə siyahıya alınmayıb. Axtarış sistemləri onu indeksləməyəcək və ona yalnız birbaşa linki olan istifadəçilər daxil ola biləcək.", + "theme.contentVisibility.unlistedBanner.title": "Siyahıya alınmamış səhifə", + "theme.docs.DocCard.categoryDescription.plurals": "1 element|{count} element", + "theme.docs.breadcrumbs.home": "Ana səhifə", + "theme.docs.breadcrumbs.navAriaLabel": "Naviqasiya yolu", + "theme.docs.paginator.navAriaLabel": "Sənəd səhifələri", + "theme.docs.paginator.next": "Növbəti", + "theme.docs.paginator.previous": "Əvvəlki", + "theme.docs.sidebar.closeSidebarButtonAriaLabel": "Naviqasiya panelini bağla", + "theme.docs.sidebar.collapseButtonAriaLabel": "Yan paneli yığ", + "theme.docs.sidebar.collapseButtonTitle": "Yan paneli yığ", + "theme.docs.sidebar.expandButtonAriaLabel": "Yan paneli aç", + "theme.docs.sidebar.expandButtonTitle": "Yan paneli aç", + "theme.docs.sidebar.navAriaLabel": "Sənədlər yan paneli", + "theme.docs.sidebar.toggleSidebarButtonAriaLabel": "Yan paneli aç/bağla", + "theme.docs.tagDocListPageTitle": "\"{tagName}\" etiketi ilə {nDocsTagged}", + "theme.docs.tagDocListPageTitle.nDocsTagged": "Bir sənəd etiketlənib|{count} sənəd etiketlənib", + "theme.docs.versionBadge.label": "Versiya: {versionLabel}", + "theme.docs.versions.latestVersionLinkLabel": "Ən son versiya", + "theme.docs.versions.latestVersionSuggestionLabel": "Ən son sənədlər üçün {latestVersionLink} ({versionLabel}) səhifəsinə baxın.", + "theme.docs.versions.unmaintainedVersionLabel": "Bu, {siteTitle} {versionLabel} üçün sənədlərdir və artıq aktiv şəkildə dəstəklənmir.", + "theme.docs.versions.unreleasedVersionLabel": "Bu, {siteTitle} {versionLabel} versiyası üçün hələ buraxılmamış sənədlərdir", + "theme.lastUpdated.atDate": " {date} tarixində", + "theme.lastUpdated.byUser": " {user} tərəfindən", + "theme.lastUpdated.lastUpdatedAtBy": "Son yenilənmə{atDate}{byUser}", + "theme.navbar.mobileDropdown.collapseButton.collapseAriaLabel": "Siyahını yığ", + "theme.navbar.mobileDropdown.collapseButton.expandAriaLabel": "Siyahını genişləndir", + "theme.navbar.mobileLanguageDropdown.label": "Dillər", + "theme.navbar.mobileSidebarSecondaryMenu.backButtonLabel": "← Əsas menyuya qayıt", + "theme.navbar.mobileVersionsDropdown.label": "Versiyalar", + "theme.tags.tagsListLabel": "Etiketlər:", + "theme.tags.tagsPageLink": "Bütün etiketlərə bax", + "theme.tags.tagsPageTitle": "Etiketlər" +} diff --git a/packages/docusaurus-theme-translations/locales/az/theme-live-codeblock.json b/packages/docusaurus-theme-translations/locales/az/theme-live-codeblock.json new file mode 100644 index 000000000000..17b268f8073f --- /dev/null +++ b/packages/docusaurus-theme-translations/locales/az/theme-live-codeblock.json @@ -0,0 +1,5 @@ +{ + "theme.Playground.buttons.reset": "Sıfırla", + "theme.Playground.liveEditor": "Canlı redaktor", + "theme.Playground.result": "Nəticə" +} diff --git a/packages/docusaurus-theme-translations/locales/az/theme-search-algolia.json b/packages/docusaurus-theme-translations/locales/az/theme-search-algolia.json new file mode 100644 index 000000000000..a862e9ac2092 --- /dev/null +++ b/packages/docusaurus-theme-translations/locales/az/theme-search-algolia.json @@ -0,0 +1,60 @@ +{ + "theme.SearchBar.label": "Axtar", + "theme.SearchBar.seeAll": "{count} nəticənin hamısına bax", + "theme.SearchModal.askAiScreen.afterToolCallText": "Axtarıldı:", + "theme.SearchModal.askAiScreen.copyButtonCopiedText": "Kopyalandı!", + "theme.SearchModal.askAiScreen.copyButtonText": "Kopyala", + "theme.SearchModal.askAiScreen.copyButtonTitle": "Kopyala", + "theme.SearchModal.askAiScreen.disclaimerText": "Cavablar süni intellekt tərəfindən yaradılır və səhv ola bilər. Cavabları yoxlayın.", + "theme.SearchModal.askAiScreen.dislikeButtonTitle": "Bəyənmədim", + "theme.SearchModal.askAiScreen.duringToolCallText": "Axtarılır: ", + "theme.SearchModal.askAiScreen.likeButtonTitle": "Bəyəndim", + "theme.SearchModal.askAiScreen.preToolCallText": "Axtarılır...", + "theme.SearchModal.askAiScreen.relatedSourcesText": "Əlaqəli mənbələr", + "theme.SearchModal.askAiScreen.thanksForFeedbackText": "Rəyiniz üçün təşəkkür edirik!", + "theme.SearchModal.askAiScreen.thinkingText": "Düşünür...", + "theme.SearchModal.errorScreen.helpText": "İnternet bağlantınızı yoxlamağınız faydalı ola bilər.", + "theme.SearchModal.errorScreen.titleText": "Nəticələri əldə etmək mümkün olmadı", + "theme.SearchModal.footer.backToSearchText": "Axtarışa qayıt", + "theme.SearchModal.footer.closeKeyAriaLabel": "Escape düyməsi", + "theme.SearchModal.footer.closeText": "Bağla", + "theme.SearchModal.footer.navigateDownKeyAriaLabel": "Aşağı ox", + "theme.SearchModal.footer.navigateText": "Naviqasiya", + "theme.SearchModal.footer.navigateUpKeyAriaLabel": "Yuxarı ox", + "theme.SearchModal.footer.searchByText": "Dəstəkləyir: ", + "theme.SearchModal.footer.selectKeyAriaLabel": "Enter düyməsi", + "theme.SearchModal.footer.selectText": "Seç", + "theme.SearchModal.footer.submitQuestionText": "Sualı göndər", + "theme.SearchModal.noResultsScreen.noResultsText": "Nəticə tapılmadı:", + "theme.SearchModal.noResultsScreen.reportMissingResultsLinkText": "Bizə bildirin.", + "theme.SearchModal.noResultsScreen.reportMissingResultsText": "Bu sorğunun nəticə qaytarmalı olduğunu düşünürsünüz?", + "theme.SearchModal.noResultsScreen.suggestedQueryText": "Bunu axtarmağa cəhd edin:", + "theme.SearchModal.placeholder": "Sənədlərdə axtar", + "theme.SearchModal.resultsScreen.askAiPlaceholder": "Süni intellektdən soruş: ", + "theme.SearchModal.searchBox.backToKeywordSearchButtonAriaLabel": "Açar söz axtarışına qayıt", + "theme.SearchModal.searchBox.backToKeywordSearchButtonText": "Açar söz axtarışına qayıt", + "theme.SearchModal.searchBox.cancelButtonText": "Ləğv et", + "theme.SearchModal.searchBox.enterKeyHint": "axtar", + "theme.SearchModal.searchBox.enterKeyHintAskAi": "enter", + "theme.SearchModal.searchBox.placeholderText": "Sənədlərdə axtar", + "theme.SearchModal.searchBox.placeholderTextAskAi": "Başqa sual verin...", + "theme.SearchModal.searchBox.placeholderTextAskAiStreaming": "Cavablandırılır...", + "theme.SearchModal.searchBox.resetButtonTitle": "Sorğunu təmizlə", + "theme.SearchModal.searchBox.searchInputLabel": "Axtar", + "theme.SearchModal.startScreen.favoriteSearchesTitle": "Seçilmiş", + "theme.SearchModal.startScreen.noRecentSearchesText": "Son axtarış yoxdur", + "theme.SearchModal.startScreen.recentConversationsTitle": "Son söhbətlər", + "theme.SearchModal.startScreen.recentSearchesTitle": "Son", + "theme.SearchModal.startScreen.removeFavoriteSearchButtonTitle": "Bu axtarışı seçilmişlərdən sil", + "theme.SearchModal.startScreen.removeRecentConversationButtonTitle": "Bu söhbəti tarixçədən sil", + "theme.SearchModal.startScreen.removeRecentSearchButtonTitle": "Bu axtarışı tarixçədən sil", + "theme.SearchModal.startScreen.saveRecentSearchButtonTitle": "Axtarışı yadda saxla", + "theme.SearchPage.algoliaLabel": "Powered by Algolia", + "theme.SearchPage.documentsFound.plurals": "Bir sənəd tapıldı|{count} sənəd tapıldı", + "theme.SearchPage.emptyResultsTitle": "Sənədlərdə axtar", + "theme.SearchPage.existingResultsTitle": "\"{query}\" üçün axtarış nəticələri", + "theme.SearchPage.fetchingNewResults": "Yeni nəticələr yüklənir...", + "theme.SearchPage.inputLabel": "Axtar", + "theme.SearchPage.inputPlaceholder": "Axtarış məntini buraya yazın", + "theme.SearchPage.noResultsText": "Heç bir nəticə tapılmadı" +} diff --git a/packages/docusaurus-utils/src/__tests__/markdownUtils.test.ts b/packages/docusaurus-utils/src/__tests__/markdownUtils.test.ts index a81e8cfff103..21bb9d45b758 100644 --- a/packages/docusaurus-utils/src/__tests__/markdownUtils.test.ts +++ b/packages/docusaurus-utils/src/__tests__/markdownUtils.test.ts @@ -47,6 +47,47 @@ describe('createExcerpt', () => { ); }); + it('creates excerpt for h1 heading containing hash in text (e.g. C#)', () => { + expect( + createExcerpt(dedent` + + # C# Programming Guide + + This paragraph should become the description. + `), + ).toBe('This paragraph should become the description.'); + }); + + it('creates excerpt for h1 heading containing hash in text (e.g. F#)', () => { + expect( + createExcerpt(dedent` + + # F# Programming Guide + + This paragraph should become the description. + `), + ).toBe('This paragraph should become the description.'); + }); + + it('creates excerpt for h1 heading containing hash with trailing ATX closing marker', () => { + expect( + createExcerpt(dedent` + + # C# Programming Guide # + + This paragraph should become the description. + `), + ).toBe('This paragraph should become the description.'); + }); + + it('creates excerpt for h2 heading containing hash in text (e.g. C#)', () => { + expect( + createExcerpt(dedent` + ## C# Setup Guide + `), + ).toBe('C# Setup Guide'); + }); + it('creates excerpt for regular content with alternate title', () => { expect( createExcerpt(dedent` @@ -572,6 +613,26 @@ Lorem Ipsum }); }); + it('parses markdown h1 title placed after export declarations', () => { + const markdown = dedent` + export function Author() { + return ( + Author + ); + } + + # Markdown Title + + Lorem Ipsum + + `; + + expect(parseMarkdownContentTitle(markdown)).toEqual({ + content: markdown, + contentTitle: 'Markdown Title', + }); + }); + it('parses markdown h1 title placed after multiple import declarations', () => { const markdown = dedent` import Component1 from '@site/src/components/Component1'; @@ -602,23 +663,21 @@ Lorem Ipsum }); }); - it('parses markdown h1 title placed after multiple import declarations and remove it', () => { + it('parses markdown h1 title placed after multiple import/export declarations and remove it', () => { const markdown = dedent` import Component1 from '@site/src/components/Component1'; import Component2 from '@site/src/components/Component2'; - import Component3 from '@site/src/components/Component3'; - import Component4 from '@site/src/components/Component4'; + export function Component3() { + return Component3 + } + + export function Component4() { + return Component4 + } import Component5 from '@site/src/components/Component5'; - import Component6 from '@site/src/components/Component6'; - import Component7 from '@site/src/components/Component7'; - import Component8 from '@site/src/components/Component8'; - import Component9 from '@site/src/components/Component9'; - import Component10 from '@site/src/components/Component10'; - import Component11 from '@site/src/components/Component11'; - import Component12 from '@site/src/components/Component12'; - import Component13 from '@site/src/components/Component13'; - import Component14 from '@site/src/components/Component14'; - import Component15 from '@site/src/components/Component15'; + export function Component6() { + return Component6 + } # Markdown Title @@ -633,6 +692,33 @@ Lorem Ipsum contentTitle: 'Markdown Title', }); }); + + it('parses markdown h1 title placed after multiple import/export declarations', () => { + const markdown = dedent` + import Component1 from '@site/src/components/Component1'; + import Component2 from '@site/src/components/Component2'; + export function Component3() { + return Component3 + } + + export function Component4() { + return Component4 + } + import Component5 from '@site/src/components/Component5'; + export function Component6() { + return Component6 + } + + # Markdown Title + + Lorem Ipsum + `; + + expect(parseMarkdownContentTitle(markdown)).toEqual({ + content: markdown, + contentTitle: 'Markdown Title', + }); + }); }); describe('parseFileContentFrontMatter', () => { diff --git a/packages/docusaurus-utils/src/markdownUtils.ts b/packages/docusaurus-utils/src/markdownUtils.ts index 216d0122e7ad..f1ad5fdc009f 100644 --- a/packages/docusaurus-utils/src/markdownUtils.ts +++ b/packages/docusaurus-utils/src/markdownUtils.ts @@ -146,7 +146,7 @@ export function createExcerpt(fileString: string): string | undefined { // Remove HTML tags. .replace(/<[^>]*>/g, '') // Remove Title headers - .replace(/^#[^#]+#?/gm, '') + .replace(/^#(?!#).*/gm, '') // Remove Markdown + ATX-style headers .replace(/^#{1,6}\s*(?[^#]*?)\s*#{0,6}/gm, '$1') // Remove emphasis. @@ -255,18 +255,18 @@ export function parseMarkdownContentTitle( const removeContentTitleOption = options?.removeContentTitle ?? false; const content = contentUntrimmed.trim(); - // We only need to detect import statements that will be parsed by MDX as - // `import` nodes, as broken syntax can't render anyways. That means any block - // that has `import` at the very beginning and surrounded by empty lines. - const contentWithoutImport = content - .replace(/^(?:import\s(?:.|\r?\n(?!\r?\n))*(?:\r?\n){2,})*/, '') + // We only need to detect import/export statements that will be parsed by MDX as + // `import` or `export` nodes, as broken syntax can't render anyways. That means any block + // that has `import` or `export` at the very beginning and surrounded by empty lines. + const contentWithoutImportExport = content + .replace(/^(?:(?:import|export)\s(?:.|\r?\n(?!\r?\n))*(?:\r?\n){2,})*/, '') .trim(); const regularTitleMatch = /^#[ \t]+(?[^ \t].*)(?:\r?\n|$)/.exec( - contentWithoutImport, + contentWithoutImportExport, ); const alternateTitleMatch = /^(?<title>.*)\r?\n=+(?:\r?\n|$)/.exec( - contentWithoutImport, + contentWithoutImportExport, ); const titleMatch = regularTitleMatch ?? alternateTitleMatch; 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<IntersectionObserver>(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<Options> = {}) { } describe('<Link>', () => { + 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<IntersectionObserver>({ + 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 ( + <MemoryRouter initialEntries={[defaultOptions.currentLocation]}> + <Context.Provider value={context}>{children}</Context.Provider> + </MemoryRouter> + ); + } + + try { + const {rerender, unmount} = renderRTL(<Link to="/docs/intro" />, { + wrapper: Wrapper, + }); + expect(createIntersectionObserver).toHaveBeenCalledTimes(1); + + rerender(<Link to="/docs/intro" />); + expect(createIntersectionObserver).toHaveBeenCalledTimes(1); + expect(observers[0]!.disconnect).not.toHaveBeenCalled(); + + rerender(<Link to="/docs/api" />); + 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'});