Last active
September 13, 2021 22:57
-
-
Save vezaynk/a4395fcc4e8d53d5be4dff4ef5228379 to your computer and use it in GitHub Desktop.
A useTitle hook for React
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/** | |
* @jest-environment jsdom | |
*/ | |
import React, { useState } from 'react'; | |
import { renderHook, cleanup } from '@testing-library/react-hooks'; | |
import { act, fireEvent, render } from '@testing-library/react'; | |
import useTitle from './useTitle'; | |
import * as LayoutWrappedElement from ':hyperloop/src/layout/getLayoutWrappedElement'; | |
describe('useTitle', () => { | |
let mockLayoutContext: { title?: string } | null = null; | |
beforeEach(() => { | |
document.title = 'a title'; | |
mockLayoutContext = {}; | |
}); | |
it('updates the document title', () => { | |
renderHook(() => useTitle('some title')); | |
expect(document.title).toBe('some title'); | |
}); | |
it('does not update null titles', () => { | |
renderHook(() => useTitle(null)); | |
expect(document.title).toBe("Fallback Title"); | |
}); | |
it('does not update empty titles', () => { | |
renderHook(() => useTitle('')); | |
expect(document.title).toBe("Fallback Title"); | |
}); | |
it('applies the cleanup title on cleanup', async () => { | |
renderHook(() => useTitle('some title', 'some other title')); | |
expect(document.title).toBe('some title'); | |
await cleanup(); | |
expect(document.title).toBe('some other title'); | |
}); | |
it('does not apply cleanup on re-render', async () => { | |
const Component = () => { | |
const [state, setState] = useState(false); | |
useTitle('some title', 'cleanup title'); | |
return ( | |
<button type="button" onClick={() => setState(!state)}> | |
{state} | |
</button> | |
); | |
}; | |
const { unmount, getByRole } = render(<Component />); | |
expect(document.title).toBe('some title'); | |
await act(async () => { | |
fireEvent.click(getByRole('button')); | |
}); | |
expect(document.title).toBe('some title'); | |
unmount(); | |
expect(document.title).toBe('cleanup title'); | |
}); | |
it('title in layout context should be updated in SSR', () => { | |
jest | |
.spyOn(LayoutWrappedElement, 'useLayoutContext') | |
.mockImplementation(() => mockLayoutContext); | |
renderHook(() => useTitle('some title')); | |
expect(document.title).toBe('some title'); | |
expect(mockLayoutContext?.title).toBe('some title'); | |
}); | |
it('title in layout context should be not updated in CSR', () => { | |
mockLayoutContext = null; | |
jest | |
.spyOn(LayoutWrappedElement, 'useLayoutContext') | |
.mockImplementation(() => mockLayoutContext); | |
renderHook(() => useTitle('some title')); | |
expect(document.title).toBe('some title'); | |
expect(mockLayoutContext).toBeNull(); | |
}); | |
it('nested useTitle overrides parent', async () => { | |
let unmountNested = () => {}; | |
let mountNested = () => {}; | |
const NestedComponent = () => { | |
useTitle('child title'); | |
return null; | |
}; | |
const TopTitleComponent = () => { | |
useTitle('parent title', 'cleanup title'); | |
const [showChildren, setShowChildren] = useState(true); | |
unmountNested = () => setShowChildren(false); | |
mountNested = () => setShowChildren(true); | |
return showChildren ? <NestedComponent /> : null; | |
}; | |
// Renders all | |
const { unmount } = render(<TopTitleComponent />); | |
await act( | |
() => new Promise<void>((resolve) => setTimeout(resolve, 0)), | |
); | |
expect(document.title).toBe('child title'); | |
// Unmount child | |
unmountNested(); | |
await act( | |
() => new Promise<void>((resolve) => setTimeout(resolve, 0)), | |
); | |
expect(document.title).toBe('parent title'); | |
// Mount child | |
mountNested(); | |
await act( | |
() => new Promise<void>((resolve) => setTimeout(resolve, 0)), | |
); | |
expect(document.title).toBe('child title'); | |
// Unmount all | |
unmount(); | |
await act( | |
() => new Promise<void>((resolve) => setTimeout(resolve, 0)), | |
); | |
expect(document.title).toBe('cleanup title'); | |
}); | |
it('does not update title when overriden by child', async () => { | |
let changeTitle = () => {}; | |
const NestedComponent = () => { | |
useTitle('child title'); | |
return null; | |
}; | |
const TopTitleComponent = () => { | |
const [title, setTitle] = useState('original parent title'); | |
changeTitle = () => setTitle('new parent title'); | |
useTitle(title); | |
return <NestedComponent />; | |
}; | |
render(<TopTitleComponent />); | |
await act( | |
() => new Promise<void>((resolve) => setTimeout(resolve, 0)), | |
); | |
expect(document.title).toBe('child title'); | |
changeTitle(); | |
await act( | |
() => new Promise<void>((resolve) => setTimeout(resolve, 0)), | |
); | |
expect(document.title).toBe('child title'); | |
}); | |
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { useEffect, useRef } from 'react'; | |
import { useLayoutContext } from ':hyperloop/src/layout/getLayoutWrappedElement'; | |
interface TitleBox { | |
value: string | null; | |
} | |
// eslint-disable-next-line rulesdir/singleton-analysis | |
let titleStack: TitleBox[] = []; | |
// eslint-disable-next-line rulesdir/singleton-analysis | |
let finalCleanupTitle: string | null = null; | |
function setDocumentTitle(title?: string | null) { | |
if (!title || typeof document === 'undefined') { | |
return; | |
} | |
document.title = title; | |
} | |
/** | |
* Updates the document title. Useful for client side navigation. | |
* @param title The string to assign to `document.title`. | |
* @param cleanupTitle Fallback to this title when cleaning up the hook. Default to the generic | |
* Airbnb mainsite title. This is really important for client side navigation: If you do not | |
* clean up the title, an incorrect document title will follow the user to every page that does | |
* not explicitly set a title of its own. | |
* | |
* Allows override for non-mainsite usage, but should typically depend on the default. | |
*/ | |
export default function useTitle(title: string | null, cleanupTitle: string | null = null) { | |
const titleReference = useRef<TitleBox | null>(null); | |
function updateTitle() { | |
// On cleanup either use the parent's useTitle call, the top-most cleanup title or the default title | |
setDocumentTitle( | |
titleStack.filter((t) => t.value)[0]?.value || | |
finalCleanupTitle || | |
"Fallback Title", | |
); | |
} | |
// Use the top-most cleanup title, if cleanup is needed | |
useEffect(() => { | |
if (cleanupTitle) finalCleanupTitle = cleanupTitle; | |
}, [cleanupTitle]); | |
// Notes: layoutProps will be only available during SSR | |
// So this is only for SSR | |
const layoutProps = useLayoutContext(); | |
if (title && layoutProps) { | |
layoutProps.title = title; | |
} | |
// If the useTitle() is being invoked for the first time (most likely by a newly rendered child, add the title to the front) | |
if (!titleReference.current && title) { | |
titleStack.unshift({ value: title }); | |
[titleReference.current] = titleStack; | |
} | |
useEffect(() => { | |
if (!titleReference.current) { | |
const index = titleStack.push({ value: title }) - 1; | |
titleReference.current = titleStack[index]; | |
} else { | |
titleReference.current.value = title; | |
} | |
updateTitle(); | |
}, [title]); | |
// cleanup effect | |
useEffect( | |
() => () => { | |
// Remove from title stack | |
titleStack = titleStack.filter((t) => t !== titleReference.current); | |
updateTitle(); | |
}, | |
[], | |
); | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment