Skip to content

Instantly share code, notes, and snippets.

@vezaynk
Last active September 13, 2021 22:57
Show Gist options
  • Save vezaynk/a4395fcc4e8d53d5be4dff4ef5228379 to your computer and use it in GitHub Desktop.
Save vezaynk/a4395fcc4e8d53d5be4dff4ef5228379 to your computer and use it in GitHub Desktop.
A useTitle hook for React
/**
* @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');
});
});
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