From d375e5c9f162cbd84a6e6462977db56519d09f75 Mon Sep 17 00:00:00 2001 From: Armand Philippot Date: Thu, 7 Dec 2023 18:48:53 +0100 Subject: refactor(pages): refine Project pages * refactor ProjectOverview component to let consumers handle the value * extract project overview depending on Github to avoid fetching Github API if the project is not on Github * wrap dynamic import in a useMemo hook to avoid infinite rerender * fix table of contents by adding a useMutationObserver hook to refresh headings tree (without it useHeadingsTree is not retriggered once the dynamic import is done) * add Cypress tests --- src/utils/constants.ts | 2 - src/utils/helpers/strings.ts | 6 ++- src/utils/hooks/index.ts | 1 + .../use-headings-tree/use-headings-tree.test.ts | 54 ++++++++++++---------- .../hooks/use-headings-tree/use-headings-tree.ts | 50 +++++++++++++++----- src/utils/hooks/use-mutation-observer/index.ts | 1 + .../use-mutation-observer.test.ts | 42 +++++++++++++++++ .../use-mutation-observer/use-mutation-observer.ts | 35 ++++++++++++++ 8 files changed, 152 insertions(+), 39 deletions(-) create mode 100644 src/utils/hooks/use-mutation-observer/index.ts create mode 100644 src/utils/hooks/use-mutation-observer/use-mutation-observer.test.ts create mode 100644 src/utils/hooks/use-mutation-observer/use-mutation-observer.ts (limited to 'src/utils') diff --git a/src/utils/constants.ts b/src/utils/constants.ts index f9d6216..9733b15 100644 --- a/src/utils/constants.ts +++ b/src/utils/constants.ts @@ -1,7 +1,5 @@ export const GITHUB_API = 'https://api.github.com/graphql'; -export const GITHUB_PSEUDO = 'ArmandPhilippot'; - export const PERSONAL_LINKS = { GITHUB: 'https://github.com/ArmandPhilippot', GITLAB: 'https://gitlab.com/ArmandPhilippot', diff --git a/src/utils/helpers/strings.ts b/src/utils/helpers/strings.ts index b8af61d..1f40b8f 100644 --- a/src/utils/helpers/strings.ts +++ b/src/utils/helpers/strings.ts @@ -23,8 +23,10 @@ export const slugify = (text: string): string => * @param {string} text - A text to capitalize. * @returns {string} The capitalized text. */ -export const capitalize = (text: string): string => - text.replace(/^\w/, (firstLetter) => firstLetter.toUpperCase()); +export const capitalize = (text: T): Capitalize => + text.replace(/^\w/, (firstLetter) => + firstLetter.toUpperCase() + ) as Capitalize; /** * Convert a text from kebab case (foo-bar) to camel case (fooBar). diff --git a/src/utils/hooks/index.ts b/src/utils/hooks/index.ts index f4d1583..95cb717 100644 --- a/src/utils/hooks/index.ts +++ b/src/utils/hooks/index.ts @@ -9,6 +9,7 @@ export * from './use-github-repo-meta'; export * from './use-headings-tree'; export * from './use-local-storage'; export * from './use-match-media'; +export * from './use-mutation-observer'; export * from './use-on-click-outside'; export * from './use-on-route-change'; export * from './use-pagination'; diff --git a/src/utils/hooks/use-headings-tree/use-headings-tree.test.ts b/src/utils/hooks/use-headings-tree/use-headings-tree.test.ts index 2c8ff2d..ffc5dbe 100644 --- a/src/utils/hooks/use-headings-tree/use-headings-tree.test.ts +++ b/src/utils/hooks/use-headings-tree/use-headings-tree.test.ts @@ -1,5 +1,5 @@ -import { describe, expect, it } from '@jest/globals'; -import { act, renderHook } from '@testing-library/react'; +import { beforeEach, describe, expect, it } from '@jest/globals'; +import { act, renderHook, waitFor } from '@testing-library/react'; import { useHeadingsTree } from './use-headings-tree'; const labels = { @@ -9,16 +9,23 @@ const labels = { }; describe('useHeadingsTree', () => { - it('returns a ref callback and the headings tree', () => { - const wrapper = document.createElement('div'); - - wrapper.innerHTML = ` + const wrapper = document.createElement('div'); + wrapper.innerHTML = `

${labels.h1}

${labels.firstH2}

Expedita et necessitatibus qui numquam sunt et ut et. Earum nostrum esse nemo nisi qui. Ab in iure qui repellat voluptatibus nostrum odit aut qui. Architecto eum fugit quod excepturi numquam qui maxime accusantium. Fugit ipsam harum tempora.

${labels.secondH2}

Totam cumque aut ipsum. Necessitatibus magnam necessitatibus. Qui illo nulla non ab. Accusamus voluptatem ab fugiat voluptas aspernatur velit dolore reprehenderit. Voluptatem quod minima asperiores voluptatum distinctio cumque quo.

`; + beforeEach(() => { + document.body.appendChild(wrapper); + }); + + afterEach(() => { + document.body.removeChild(wrapper); + }); + + it('returns a ref callback and the headings tree', () => { const { result } = renderHook(() => useHeadingsTree()); act(() => result.current.ref(wrapper)); @@ -29,15 +36,6 @@ describe('useHeadingsTree', () => { }); it('can return a headings tree starting at the specified level', () => { - const wrapper = document.createElement('div'); - - wrapper.innerHTML = ` -

${labels.h1}

-

${labels.firstH2}

-

Expedita et necessitatibus qui numquam sunt et ut et. Earum nostrum esse nemo nisi qui. Ab in iure qui repellat voluptatibus nostrum odit aut qui. Architecto eum fugit quod excepturi numquam qui maxime accusantium. Fugit ipsam harum tempora.

-

${labels.secondH2}

-

Totam cumque aut ipsum. Necessitatibus magnam necessitatibus. Qui illo nulla non ab. Accusamus voluptatem ab fugiat voluptas aspernatur velit dolore reprehenderit. Voluptatem quod minima asperiores voluptatum distinctio cumque quo.

`; - const { result } = renderHook(() => useHeadingsTree({ fromLevel: 2 })); act(() => result.current.ref(wrapper)); @@ -48,15 +46,6 @@ describe('useHeadingsTree', () => { }); it('can return a headings tree stopping at the specified level', () => { - const wrapper = document.createElement('div'); - - wrapper.innerHTML = ` -

${labels.h1}

-

${labels.firstH2}

-

Expedita et necessitatibus qui numquam sunt et ut et. Earum nostrum esse nemo nisi qui. Ab in iure qui repellat voluptatibus nostrum odit aut qui. Architecto eum fugit quod excepturi numquam qui maxime accusantium. Fugit ipsam harum tempora.

-

${labels.secondH2}

-

Totam cumque aut ipsum. Necessitatibus magnam necessitatibus. Qui illo nulla non ab. Accusamus voluptatem ab fugiat voluptas aspernatur velit dolore reprehenderit. Voluptatem quod minima asperiores voluptatum distinctio cumque quo.

`; - const { result } = renderHook(() => useHeadingsTree({ toLevel: 1 })); act(() => result.current.ref(wrapper)); @@ -66,6 +55,23 @@ describe('useHeadingsTree', () => { expect(result.current.tree[0].children).toStrictEqual([]); }); + it('uses a mutation observer to watch for DOM changes', async () => { + const newH2 = document.createElement('h2'); + newH2.innerHTML = 'ut molestiae exercitationem'; + const { result } = renderHook(() => useHeadingsTree({ fromLevel: 2 })); + + act(() => result.current.ref(wrapper)); + + expect(result.current.tree.length).toBe(2); + + act(() => wrapper.appendChild(newH2)); + + await waitFor(() => { + // eslint-disable-next-line @typescript-eslint/no-magic-numbers + expect(result.current.tree.length).toBe(3); + }); + }); + it('throws an error if the options are invalid', () => { expect(() => useHeadingsTree({ fromLevel: 2, toLevel: 1 })).toThrowError( 'Invalid options: `fromLevel` must be lower or equal to `toLevel`.' diff --git a/src/utils/hooks/use-headings-tree/use-headings-tree.ts b/src/utils/hooks/use-headings-tree/use-headings-tree.ts index 68bdde8..802d843 100644 --- a/src/utils/hooks/use-headings-tree/use-headings-tree.ts +++ b/src/utils/hooks/use-headings-tree/use-headings-tree.ts @@ -1,5 +1,7 @@ -import { useState, useCallback, type RefCallback } from 'react'; +import { useState, useCallback, type RefCallback, useEffect } from 'react'; import type { HeadingLevel } from '../../../components'; +import type { Nullable } from '../../../types'; +import { useMutationObserver } from '../use-mutation-observer'; export type HeadingsTreeNode = { /** @@ -140,17 +142,43 @@ export const useHeadingsTree = ( 'Invalid options: `fromLevel` must be lower or equal to `toLevel`.' ); - const [tree, setTree] = useState([]); + const [headings, setHeadings] = useState>(); const requestedHeadingTags = getHeadingTagsList(options); const query = requestedHeadingTags.join(', '); - const ref: RefCallback = useCallback( - (el) => { - const headingNodes = el?.querySelectorAll(query); - if (headingNodes) setTree(buildHeadingsTreeFrom(headingNodes)); - }, - [query] - ); - - return { ref, tree }; + /* + * With a mutable ref, the headings are not always updated because of loading + * states. So we need to use a RefCallback to detect when the component is + * effectively rendered. However, to be able to compare the mutation records, + * we need to keep track of the current ref so we also need to use useState... + */ + const [wrapper, setWrapper] = useState>(); + const ref: RefCallback = useCallback((el) => { + setWrapper(el); + }, []); + + const updateHeadings = useCallback(() => { + const headingNodes = wrapper?.querySelectorAll(query); + + if (headingNodes) setHeadings(headingNodes); + }, [query, wrapper]); + + useEffect(() => { + if (wrapper) updateHeadings(); + }, [updateHeadings, wrapper]); + + useMutationObserver({ + callback: useCallback( + (records) => { + for (const record of records) { + if (record.target === wrapper) updateHeadings(); + } + }, + [updateHeadings, wrapper] + ), + options: { childList: true, subtree: true }, + ref: { current: typeof window === 'undefined' ? null : document.body }, + }); + + return { ref, tree: headings ? buildHeadingsTreeFrom(headings) : [] }; }; diff --git a/src/utils/hooks/use-mutation-observer/index.ts b/src/utils/hooks/use-mutation-observer/index.ts new file mode 100644 index 0000000..16780fe --- /dev/null +++ b/src/utils/hooks/use-mutation-observer/index.ts @@ -0,0 +1 @@ +export * from './use-mutation-observer'; diff --git a/src/utils/hooks/use-mutation-observer/use-mutation-observer.test.ts b/src/utils/hooks/use-mutation-observer/use-mutation-observer.test.ts new file mode 100644 index 0000000..62ed559 --- /dev/null +++ b/src/utils/hooks/use-mutation-observer/use-mutation-observer.test.ts @@ -0,0 +1,42 @@ +import { beforeEach, describe, expect, it, jest } from '@jest/globals'; +import { renderHook } from '@testing-library/react'; +import { useMutationObserver } from './use-mutation-observer'; + +describe('useMutationObserver', () => { + beforeEach(() => { + jest.restoreAllMocks(); + }); + + it('can create a new observer', () => { + const callback = jest.fn(); + const observerSpy = jest.spyOn(MutationObserver.prototype, 'observe'); + const wrapper = document.createElement('div'); + const options: MutationObserverInit = { childList: true }; + + renderHook(() => + useMutationObserver({ + callback, + options, + ref: { current: wrapper }, + }) + ); + + expect(observerSpy).toHaveBeenCalledTimes(1); + expect(observerSpy).toHaveBeenCalledWith(wrapper, options); + }); + + it('does not create a new observer when ref is null', () => { + const callback = jest.fn(); + const observerSpy = jest.spyOn(MutationObserver.prototype, 'observe'); + + renderHook(() => + useMutationObserver({ + callback, + options: { childList: true }, + ref: { current: null }, + }) + ); + + expect(observerSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/src/utils/hooks/use-mutation-observer/use-mutation-observer.ts b/src/utils/hooks/use-mutation-observer/use-mutation-observer.ts new file mode 100644 index 0000000..6043055 --- /dev/null +++ b/src/utils/hooks/use-mutation-observer/use-mutation-observer.ts @@ -0,0 +1,35 @@ +import { type RefObject, useEffect } from 'react'; +import type { Nullable } from '../../../types'; + +type UseMutationObserverProps> = { + /** + * A callback to execute when mutations are observed. + */ + callback: MutationCallback; + /** + * The options passed to mutation observer. + */ + options: MutationObserverInit; + /** + * A reference to the DOM node to observe. + */ + ref: RefObject; +}; + +export const useMutationObserver = >({ + callback, + options, + ref, +}: UseMutationObserverProps) => { + useEffect(() => { + if (!ref.current) return undefined; + + const observer = new MutationObserver(callback); + + observer.observe(ref.current, options); + + return () => { + observer.disconnect(); + }; + }, [callback, options, ref]); +}; -- cgit v1.2.3