Closes #560. Composes the active inbox's unread count over the base favicon as an SVG badge, served as a percent-encoded data: URL, so new mail is visible on a tab that is not focused — including when the browser collapses tabs to icon-only, where a title-based count disappears entirely. The base icon is read from the rendered <link rel="icon"> rather than from config, so admin and per-domain branding overrides are inherited for free: the count is drawn on whatever logo the deployment actually serves. Keeping the badge in SVG rather than rasterising to a canvas also means the browser can rasterise it at whatever size it asks for, so a HiDPI tab is not served a 16px bitmap. Notes on the approach: - The badge link is an *additional* icon link that we append and mark as ours; we never remove or mutate a link we did not create. Next's metadata icons are rendered by React, which keeps a fiber pointing at that DOM node, so removing it would leave React holding a detached node and throw "Cannot read properties of null (reading 'removeChild')" on the next commit that deletes the fiber. Appending instead means the last-declared icon wins, and non-SVG fallback links survive with their type/sizes intact. (The usual recipe for this feature — assign canvas.toDataURL() to the existing link's href — does both of the things that break here.) - Every change of state is an *insertion* of a fresh link of ours, never a mutation or a removal, because that is the only signal a browser reliably re-reads the favicon on. Firefox ignores an in-place href change, and it equally ignores a removal — so clearing the badge by deleting our link left a stale count painted on the tab until a hard reload. Clearing it instead inserts a new link of ours carrying the original base href. - Holding last place has to be defended: on a client-side navigation React re-hoists its metadata icon link into <head>, landing after ours, and the base icon silently wins again. A MutationObserver on <head> moves our own link back to the end whenever a foreign icon link appears — moving only our node, never anyone else's. It no-ops once ours is last again, so a move cannot feed itself. - The badge is a full-width band across the foot of the icon, drawn to the metrics measured from Gmail's own 16px favicon: band height 0.625 of the icon, digit cap height 0.44, flush to the edges, corners rounded by about a pixel. Full width is what keeps a three-glyph label legible — rounded ends waste exactly the horizontal space it needs. Neutral white with black digits rather than the conventional red: faviconUrl is admin-overridable and Bulwark's own icon is rgb(219,45,84), so a red badge sat red-on-red. - The base SVG may be admin-uploaded, and the branding route deliberately serves it under a sandboxing CSP because SVG can carry script. Re-emitting it as a same-origin data: URL would un-fence that, so script, foreignObject and every on* handler are stripped before serialising. - Mounted in the root layout, not on the mail route: the badge belongs to the tab, so mounting it on the page would clear it on every hop to settings, calendar or contacts.
560 lines
22 KiB
TypeScript
560 lines
22 KiB
TypeScript
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
import { StrictMode } from 'react';
|
|
import { render, renderHook, waitFor } from '@testing-library/react';
|
|
import { useFaviconBadge } from '@/hooks/use-favicon-badge';
|
|
import { renderBadgedFavicon } from '@/lib/favicon-badge';
|
|
|
|
vi.mock('@/lib/favicon-badge', () => ({
|
|
renderBadgedFavicon: vi.fn((_source: string, count: number) =>
|
|
count > 0 ? `data:image/svg+xml,BADGED-${count}` : null,
|
|
),
|
|
}));
|
|
|
|
const renderBadgedFaviconMock = vi.mocked(renderBadgedFavicon);
|
|
|
|
const ORIGINAL_HREF = '/branding/Bulwark_Favicon.svg';
|
|
|
|
/** The link the hook owns: the only one it may ever touch. */
|
|
function badgeLink(): HTMLLinkElement | null {
|
|
return document.querySelector<HTMLLinkElement>('link[data-favicon-badge]');
|
|
}
|
|
|
|
/** The base icon link the page (or React) rendered: must survive untouched. */
|
|
function baseLinks(): HTMLLinkElement[] {
|
|
return Array.from(
|
|
document.querySelectorAll<HTMLLinkElement>('link[rel~="icon"]:not([data-favicon-badge])'),
|
|
);
|
|
}
|
|
|
|
/** Every icon link in <head>, in document order. The browser honours the last. */
|
|
function iconLinks(): HTMLLinkElement[] {
|
|
return Array.from(document.head.querySelectorAll<HTMLLinkElement>('link[rel~="icon"]'));
|
|
}
|
|
|
|
function lastIconLink(): HTMLLinkElement | null {
|
|
return iconLinks().at(-1) ?? null;
|
|
}
|
|
|
|
/**
|
|
* What Next/React does on a client-side navigation: it re-hoists its metadata
|
|
* icon link into <head>, appending a *fresh* node after everything already
|
|
* there — including our badge link.
|
|
*/
|
|
function rehoistBaseIcon(href = ORIGINAL_HREF): HTMLLinkElement {
|
|
const link = document.createElement('link');
|
|
link.rel = 'icon';
|
|
link.href = href;
|
|
document.head.appendChild(link);
|
|
return link;
|
|
}
|
|
|
|
/** Drains microtasks (MutationObserver callbacks) and one macrotask. */
|
|
async function settle(): Promise<void> {
|
|
for (let i = 0; i < 20; i++) await Promise.resolve();
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
}
|
|
|
|
function svgResponse(body = '<svg viewBox="0 0 1000 1000"/>') {
|
|
return new Response(body, { status: 200, headers: { 'content-type': 'image/svg+xml' } });
|
|
}
|
|
|
|
function Badger({ count }: { count: number }) {
|
|
useFaviconBadge(count);
|
|
return null;
|
|
}
|
|
|
|
beforeEach(() => {
|
|
document.head.innerHTML = `<link rel="icon" href="${ORIGINAL_HREF}">`;
|
|
vi.stubGlobal('fetch', vi.fn(async () => svgResponse()));
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.unstubAllGlobals();
|
|
vi.clearAllMocks();
|
|
// document.head outlives every test, so a spy on it that a failing assertion
|
|
// never got to restore would leak into the next test's counts.
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
describe('useFaviconBadge', () => {
|
|
it('appends its own badged icon link when the count is positive', async () => {
|
|
renderHook(() => useFaviconBadge(3));
|
|
await waitFor(() => {
|
|
expect(badgeLink()!.getAttribute('href')).toBe('data:image/svg+xml,BADGED-3');
|
|
});
|
|
expect(badgeLink()!.getAttribute('type')).toBe('image/svg+xml');
|
|
// Last-declared icon wins, so ours must be last in <head>.
|
|
expect(document.head.lastElementChild).toBe(badgeLink());
|
|
});
|
|
|
|
it('never removes or mutates an icon link it did not create', async () => {
|
|
const before = baseLinks()[0];
|
|
renderHook(() => useFaviconBadge(3));
|
|
await waitFor(() => expect(badgeLink()).not.toBeNull());
|
|
|
|
expect(before.isConnected).toBe(true);
|
|
expect(before.getAttribute('href')).toBe(ORIGINAL_HREF);
|
|
expect(baseLinks()).toHaveLength(1);
|
|
});
|
|
|
|
it('leaves every icon link it did not create intact, including non-SVG fallbacks', async () => {
|
|
document.head.innerHTML =
|
|
`<link rel="icon" type="image/svg+xml" href="/a.svg">` +
|
|
`<link rel="icon" type="image/png" sizes="32x32" href="/a.png">`;
|
|
|
|
renderHook(() => useFaviconBadge(3));
|
|
await waitFor(() => expect(badgeLink()).not.toBeNull());
|
|
|
|
const survivors = baseLinks();
|
|
expect(survivors).toHaveLength(2);
|
|
expect(survivors[0].getAttribute('href')).toBe('/a.svg');
|
|
expect(survivors[0].getAttribute('type')).toBe('image/svg+xml');
|
|
expect(survivors[1].getAttribute('href')).toBe('/a.png');
|
|
expect(survivors[1].getAttribute('type')).toBe('image/png');
|
|
expect(survivors[1].getAttribute('sizes')).toBe('32x32');
|
|
});
|
|
|
|
it('does not throw when React owns the icon link and its subtree is deleted', async () => {
|
|
// React 19 hoists <link rel="icon"> into <head> and keeps a fiber pointing at
|
|
// that DOM node. Removing it out from under React makes the commit phase throw
|
|
// "Cannot read properties of null (reading 'removeChild')" when the fiber is
|
|
// later deleted. The hook must therefore never touch a node it did not create.
|
|
document.head.innerHTML = '';
|
|
|
|
const { unmount } = render(
|
|
<>
|
|
<link rel="icon" type="image/svg+xml" href={ORIGINAL_HREF} />
|
|
<Badger count={3} />
|
|
</>,
|
|
);
|
|
|
|
await waitFor(() => expect(badgeLink()).not.toBeNull());
|
|
|
|
// The React-owned node is still there, untouched.
|
|
const reactOwned = baseLinks();
|
|
expect(reactOwned).toHaveLength(1);
|
|
expect(reactOwned[0].getAttribute('href')).toBe(ORIGINAL_HREF);
|
|
|
|
expect(() => unmount()).not.toThrow();
|
|
expect(badgeLink()).toBeNull();
|
|
});
|
|
|
|
it('replaces its own link rather than mutating its href', async () => {
|
|
const { rerender } = renderHook(({ n }) => useFaviconBadge(n), { initialProps: { n: 3 } });
|
|
await waitFor(() => {
|
|
expect(badgeLink()!.getAttribute('href')).toBe('data:image/svg+xml,BADGED-3');
|
|
});
|
|
const first = badgeLink();
|
|
|
|
rerender({ n: 4 });
|
|
await waitFor(() => {
|
|
expect(badgeLink()!.getAttribute('href')).toBe('data:image/svg+xml,BADGED-4');
|
|
});
|
|
|
|
// Firefox ignores an in-place href change on the favicon link.
|
|
expect(badgeLink()).not.toBe(first);
|
|
expect(first!.isConnected).toBe(false);
|
|
});
|
|
|
|
it('clears the badge by inserting a fresh link carrying the base href, not by removing its own', async () => {
|
|
// The field bug: Firefox does not re-evaluate the favicon when an icon link
|
|
// is *removed* — a removal is not an insertion, so it keeps painting the
|
|
// last icon it was handed and the stale "99+" badge sticks until a hard
|
|
// reload. Clearing must therefore be an insertion: our own link is replaced
|
|
// by a brand-new node carrying the original base href.
|
|
const { rerender } = renderHook(({ n }) => useFaviconBadge(n), { initialProps: { n: 3 } });
|
|
await waitFor(() => {
|
|
expect(badgeLink()!.getAttribute('href')).toBe('data:image/svg+xml,BADGED-3');
|
|
});
|
|
const badged = badgeLink()!;
|
|
|
|
rerender({ n: 0 });
|
|
await waitFor(() => expect(badgeLink()!.getAttribute('href')).toBe(ORIGINAL_HREF));
|
|
|
|
const restored = badgeLink()!;
|
|
expect(restored).not.toBe(badged); // a NEW node: an insertion, not an href swap
|
|
expect(badged.isConnected).toBe(false);
|
|
expect(restored.getAttribute('type')).toBe('image/svg+xml');
|
|
expect(lastIconLink()).toBe(restored);
|
|
|
|
// And the base link the page rendered is still untouched.
|
|
const survivors = baseLinks();
|
|
expect(survivors).toHaveLength(1);
|
|
expect(survivors[0].getAttribute('href')).toBe(ORIGINAL_HREF);
|
|
});
|
|
|
|
it('inserts the base-href link exactly once while the count stays at zero', async () => {
|
|
const { rerender } = renderHook(({ n }) => useFaviconBadge(n), { initialProps: { n: 3 } });
|
|
await waitFor(() => expect(badgeLink()).not.toBeNull());
|
|
|
|
const appendSpy = vi.spyOn(document.head, 'appendChild');
|
|
const ownAppends = () =>
|
|
appendSpy.mock.calls.filter(
|
|
([node]) => node instanceof Element && node.matches('link[data-favicon-badge]'),
|
|
).length;
|
|
|
|
rerender({ n: 0 });
|
|
await waitFor(() => expect(badgeLink()!.getAttribute('href')).toBe(ORIGINAL_HREF));
|
|
const restored = badgeLink()!;
|
|
expect(ownAppends()).toBe(1);
|
|
|
|
// Neither further renders at the same count nor unrelated <head> churn (an
|
|
// observer tick) may re-insert it: no remove/append thrash on every tick.
|
|
rerender({ n: 0 });
|
|
rerender({ n: 0 });
|
|
document.head.appendChild(document.createElement('meta'));
|
|
await settle();
|
|
|
|
expect(ownAppends()).toBe(1);
|
|
expect(badgeLink()).toBe(restored);
|
|
expect(document.querySelectorAll('link[data-favicon-badge]')).toHaveLength(1);
|
|
appendSpy.mockRestore();
|
|
});
|
|
|
|
it('keeps its own link last even while it is only carrying the base href', async () => {
|
|
const { rerender } = renderHook(({ n }) => useFaviconBadge(n), { initialProps: { n: 3 } });
|
|
await waitFor(() => expect(badgeLink()).not.toBeNull());
|
|
|
|
rerender({ n: 0 });
|
|
await waitFor(() => expect(badgeLink()!.getAttribute('href')).toBe(ORIGINAL_HREF));
|
|
const own = badgeLink()!;
|
|
|
|
rehoistBaseIcon();
|
|
await waitFor(() => expect(lastIconLink()).toBe(own));
|
|
expect(badgeLink()).toBe(own); // moved, not recreated
|
|
});
|
|
|
|
it('badges again with a fresh insertion when the count leaves zero', async () => {
|
|
const { rerender } = renderHook(({ n }) => useFaviconBadge(n), { initialProps: { n: 3 } });
|
|
await waitFor(() => expect(badgeLink()).not.toBeNull());
|
|
|
|
rerender({ n: 0 });
|
|
await waitFor(() => expect(badgeLink()!.getAttribute('href')).toBe(ORIGINAL_HREF));
|
|
const cleared = badgeLink()!;
|
|
|
|
rerender({ n: 7 });
|
|
await waitFor(() => {
|
|
expect(badgeLink()!.getAttribute('href')).toBe('data:image/svg+xml,BADGED-7');
|
|
});
|
|
expect(badgeLink()).not.toBe(cleared);
|
|
expect(cleared.isConnected).toBe(false);
|
|
expect(lastIconLink()).toBe(badgeLink());
|
|
expect(fetch).toHaveBeenCalledTimes(1); // the base is still fetched only once
|
|
expect(baseLinks()).toHaveLength(1);
|
|
});
|
|
|
|
it('removes only its own link on unmount', async () => {
|
|
const { unmount } = renderHook(() => useFaviconBadge(3));
|
|
await waitFor(() => expect(badgeLink()).not.toBeNull());
|
|
|
|
unmount();
|
|
expect(badgeLink()).toBeNull();
|
|
expect(baseLinks()).toHaveLength(1);
|
|
expect(baseLinks()[0].getAttribute('href')).toBe(ORIGINAL_HREF);
|
|
});
|
|
|
|
it('does not fetch, and adds no link, while the count is zero', async () => {
|
|
renderHook(() => useFaviconBadge(0));
|
|
await Promise.resolve();
|
|
await Promise.resolve();
|
|
|
|
expect(fetch).not.toHaveBeenCalled();
|
|
expect(renderBadgedFaviconMock).not.toHaveBeenCalled();
|
|
expect(badgeLink()).toBeNull();
|
|
expect(baseLinks()).toHaveLength(1);
|
|
});
|
|
|
|
it('leaves the icon alone when the base is not SVG', async () => {
|
|
vi.stubGlobal(
|
|
'fetch',
|
|
vi.fn(async () => new Response('binary', { headers: { 'content-type': 'image/png' } })),
|
|
);
|
|
renderHook(() => useFaviconBadge(3));
|
|
await waitFor(() => expect(fetch).toHaveBeenCalled());
|
|
|
|
// Assert on the observable end state, not merely on the href being
|
|
// unchanged: the href is also unchanged *before* the catch block runs,
|
|
// so a href-only assertion would pass even if the code went on to swap
|
|
// the icon a tick later. The renderer must never be reached.
|
|
await waitFor(() => expect(renderBadgedFaviconMock).not.toHaveBeenCalled());
|
|
expect(badgeLink()).toBeNull();
|
|
expect(baseLinks()[0].getAttribute('href')).toBe(ORIGINAL_HREF);
|
|
});
|
|
|
|
it('leaves the icon alone when the base fetch fails', async () => {
|
|
vi.stubGlobal('fetch', vi.fn(async () => new Response('', { status: 404 })));
|
|
renderHook(() => useFaviconBadge(3));
|
|
await waitFor(() => expect(fetch).toHaveBeenCalled());
|
|
expect(renderBadgedFaviconMock).not.toHaveBeenCalled();
|
|
expect(badgeLink()).toBeNull();
|
|
expect(baseLinks()[0].getAttribute('href')).toBe(ORIGINAL_HREF);
|
|
});
|
|
|
|
it('does nothing when there is no icon link to read', async () => {
|
|
document.head.innerHTML = '';
|
|
renderHook(() => useFaviconBadge(3));
|
|
await Promise.resolve(); // let any deferred async work start
|
|
expect(fetch).not.toHaveBeenCalled();
|
|
expect(renderBadgedFaviconMock).not.toHaveBeenCalled();
|
|
expect(badgeLink()).toBeNull();
|
|
});
|
|
|
|
it('fetches the base icon exactly once under StrictMode and rapid count changes', async () => {
|
|
// StrictMode double-invokes effects, and a count change while the fetch is in
|
|
// flight re-runs the effect: neither may issue a second request.
|
|
let resolveFetch: (response: Response) => void = () => {};
|
|
const inFlight = new Promise<Response>((resolve) => {
|
|
resolveFetch = resolve;
|
|
});
|
|
vi.stubGlobal(
|
|
'fetch',
|
|
vi.fn(() => inFlight),
|
|
);
|
|
|
|
const { rerender } = renderHook(({ n }) => useFaviconBadge(n), {
|
|
initialProps: { n: 1 },
|
|
wrapper: StrictMode,
|
|
});
|
|
|
|
rerender({ n: 2 });
|
|
rerender({ n: 5 });
|
|
expect(fetch).toHaveBeenCalledTimes(1);
|
|
|
|
resolveFetch(svgResponse());
|
|
await waitFor(() => {
|
|
// The badge lands on the latest count, not the one in flight at fetch time.
|
|
expect(badgeLink()!.getAttribute('href')).toBe('data:image/svg+xml,BADGED-5');
|
|
});
|
|
|
|
rerender({ n: 6 });
|
|
await waitFor(() => {
|
|
expect(badgeLink()!.getAttribute('href')).toBe('data:image/svg+xml,BADGED-6');
|
|
});
|
|
expect(fetch).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('does not re-render the badge when the count is unchanged', async () => {
|
|
const { rerender } = renderHook(({ n }) => useFaviconBadge(n), {
|
|
initialProps: { n: 3 },
|
|
});
|
|
await waitFor(() => {
|
|
expect(badgeLink()!.getAttribute('href')).toBe('data:image/svg+xml,BADGED-3');
|
|
});
|
|
|
|
const settled = badgeLink();
|
|
renderBadgedFaviconMock.mockClear();
|
|
rerender({ n: 3 });
|
|
|
|
// The element identity alone is not evidence: `count` is the effect's only
|
|
// dependency, so React would skip the effect regardless. Assert the
|
|
// renderer was not invoked again — that is the behaviour under test.
|
|
expect(renderBadgedFaviconMock).not.toHaveBeenCalled();
|
|
expect(badgeLink()).toBe(settled);
|
|
});
|
|
|
|
describe('when the setting is off', () => {
|
|
it('adds no link and does not fetch, however high the count', async () => {
|
|
renderHook(() => useFaviconBadge(3, false));
|
|
await settle();
|
|
|
|
expect(fetch).not.toHaveBeenCalled();
|
|
expect(renderBadgedFaviconMock).not.toHaveBeenCalled();
|
|
expect(badgeLink()).toBeNull();
|
|
expect(baseLinks()).toHaveLength(1);
|
|
expect(baseLinks()[0].getAttribute('href')).toBe(ORIGINAL_HREF);
|
|
});
|
|
|
|
it('clears a showing badge by inserting a fresh link carrying the base href', async () => {
|
|
// Same guarantee as the count-back-to-zero clear, and for the same reason:
|
|
// Firefox re-evaluates the favicon on an *insertion* and on nothing else.
|
|
// Turning the setting off by *removing* our link would leave the stale
|
|
// badge painted on the tab until a hard reload.
|
|
const { rerender } = renderHook(({ on }) => useFaviconBadge(3, on), {
|
|
initialProps: { on: true },
|
|
});
|
|
await waitFor(() => {
|
|
expect(badgeLink()!.getAttribute('href')).toBe('data:image/svg+xml,BADGED-3');
|
|
});
|
|
const badged = badgeLink()!;
|
|
|
|
rerender({ on: false });
|
|
await waitFor(() => expect(badgeLink()!.getAttribute('href')).toBe(ORIGINAL_HREF));
|
|
|
|
const restored = badgeLink()!;
|
|
expect(restored).not.toBe(badged); // a NEW node: an insertion, not an href swap
|
|
expect(badged.isConnected).toBe(false);
|
|
expect(restored.getAttribute('type')).toBe('image/svg+xml');
|
|
expect(lastIconLink()).toBe(restored);
|
|
|
|
// And the base link the page rendered is still untouched.
|
|
const survivors = baseLinks();
|
|
expect(survivors).toHaveLength(1);
|
|
expect(survivors[0].getAttribute('href')).toBe(ORIGINAL_HREF);
|
|
});
|
|
|
|
it('re-badges with a fresh insertion when the setting is turned back on', async () => {
|
|
const { rerender } = renderHook(({ on }) => useFaviconBadge(3, on), {
|
|
initialProps: { on: true },
|
|
});
|
|
await waitFor(() => expect(badgeLink()).not.toBeNull());
|
|
|
|
rerender({ on: false });
|
|
await waitFor(() => expect(badgeLink()!.getAttribute('href')).toBe(ORIGINAL_HREF));
|
|
const cleared = badgeLink()!;
|
|
|
|
rerender({ on: true });
|
|
await waitFor(() => {
|
|
expect(badgeLink()!.getAttribute('href')).toBe('data:image/svg+xml,BADGED-3');
|
|
});
|
|
expect(badgeLink()).not.toBe(cleared);
|
|
expect(cleared.isConnected).toBe(false);
|
|
expect(lastIconLink()).toBe(badgeLink());
|
|
expect(fetch).toHaveBeenCalledTimes(1); // the base is still fetched only once
|
|
expect(baseLinks()).toHaveLength(1);
|
|
});
|
|
|
|
it('keeps its base-href link last when React re-hoists its icon', async () => {
|
|
const { rerender } = renderHook(({ on }) => useFaviconBadge(3, on), {
|
|
initialProps: { on: true },
|
|
});
|
|
await waitFor(() => expect(badgeLink()).not.toBeNull());
|
|
|
|
rerender({ on: false });
|
|
await waitFor(() => expect(badgeLink()!.getAttribute('href')).toBe(ORIGINAL_HREF));
|
|
const own = badgeLink()!;
|
|
|
|
rehoistBaseIcon();
|
|
await waitFor(() => expect(lastIconLink()).toBe(own));
|
|
expect(badgeLink()).toBe(own); // moved, not recreated
|
|
});
|
|
});
|
|
|
|
describe('when React re-hoists its icon link on a client-side navigation', () => {
|
|
it('moves its own link back to the end so the badge keeps winning', async () => {
|
|
// The field bug: Inbox badges the tab, a hop to /calendar makes React
|
|
// re-insert its metadata <link rel="icon"> *after* ours, the base icon
|
|
// wins again and the badge vanishes.
|
|
renderHook(() => useFaviconBadge(3));
|
|
await waitFor(() => expect(badgeLink()).not.toBeNull());
|
|
expect(lastIconLink()).toBe(badgeLink());
|
|
|
|
const own = badgeLink()!;
|
|
const rehoisted = rehoistBaseIcon();
|
|
expect(lastIconLink()).toBe(rehoisted); // the badge is now outranked
|
|
|
|
await waitFor(() => expect(lastIconLink()).toBe(own));
|
|
expect(document.head.lastElementChild).toBe(own);
|
|
expect(badgeLink()).toBe(own); // moved, not recreated
|
|
expect(rehoisted.isConnected).toBe(true); // and React's node is untouched
|
|
});
|
|
|
|
it('restores the badge without the count changing', async () => {
|
|
// The "never comes back" half of the bug. Navigating back to the inbox
|
|
// does not change the unread count, so nothing re-runs the count effect:
|
|
// the observer alone must put the badge back on top.
|
|
const { rerender } = renderHook(({ n }) => useFaviconBadge(n), { initialProps: { n: 3 } });
|
|
await waitFor(() => {
|
|
expect(badgeLink()!.getAttribute('href')).toBe('data:image/svg+xml,BADGED-3');
|
|
});
|
|
|
|
renderBadgedFaviconMock.mockClear();
|
|
rehoistBaseIcon();
|
|
await waitFor(() => expect(lastIconLink()).toBe(badgeLink()));
|
|
|
|
rerender({ n: 3 }); // same count: no effect re-run to lean on
|
|
await settle();
|
|
|
|
expect(lastIconLink()).toBe(badgeLink());
|
|
expect(badgeLink()!.getAttribute('href')).toBe('data:image/svg+xml,BADGED-3');
|
|
expect(renderBadgedFaviconMock).not.toHaveBeenCalled();
|
|
expect(fetch).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('re-applies the badge if its own link is removed entirely', async () => {
|
|
renderHook(() => useFaviconBadge(3));
|
|
await waitFor(() => expect(badgeLink()).not.toBeNull());
|
|
|
|
badgeLink()!.remove(); // React blows away part of <head>
|
|
|
|
await waitFor(() => {
|
|
expect(badgeLink()!.getAttribute('href')).toBe('data:image/svg+xml,BADGED-3');
|
|
});
|
|
expect(lastIconLink()).toBe(badgeLink());
|
|
expect(fetch).toHaveBeenCalledTimes(1); // the base is still fetched only once
|
|
});
|
|
|
|
it('settles: re-appending its own link does not feed the observer a loop', async () => {
|
|
// Moving our link fires the observer again. If the move is not guarded by
|
|
// "am I already last?", that second run moves it again, for ever. Count
|
|
// the appends of *our* node: exactly one, and it must stop growing.
|
|
renderHook(() => useFaviconBadge(3));
|
|
await waitFor(() => expect(badgeLink()).not.toBeNull());
|
|
const own = badgeLink()!;
|
|
|
|
const appendSpy = vi.spyOn(document.head, 'appendChild');
|
|
const ownAppends = () => appendSpy.mock.calls.filter(([node]) => node === own).length;
|
|
|
|
rehoistBaseIcon();
|
|
await waitFor(() => expect(lastIconLink()).toBe(own));
|
|
expect(ownAppends()).toBe(1);
|
|
|
|
await settle();
|
|
expect(ownAppends()).toBe(1); // the observer's own mutation is a no-op
|
|
expect(lastIconLink()).toBe(own);
|
|
// Base + re-hoisted base + exactly one badge: nothing was duplicated.
|
|
expect(iconLinks()).toHaveLength(3);
|
|
expect(document.querySelectorAll('link[data-favicon-badge]')).toHaveLength(1);
|
|
appendSpy.mockRestore();
|
|
});
|
|
|
|
it('still never removes or mutates a link it did not create', async () => {
|
|
document.head.innerHTML =
|
|
`<link rel="icon" type="image/svg+xml" href="/a.svg">` +
|
|
`<link rel="icon" type="image/png" sizes="32x32" href="/a.png">`;
|
|
|
|
const { unmount } = render(
|
|
<>
|
|
<link rel="icon" type="image/svg+xml" href={ORIGINAL_HREF} />
|
|
<Badger count={3} />
|
|
</>,
|
|
);
|
|
await waitFor(() => expect(badgeLink()).not.toBeNull());
|
|
|
|
const rehoisted = rehoistBaseIcon();
|
|
await waitFor(() => expect(lastIconLink()).toBe(badgeLink()));
|
|
|
|
const survivors = baseLinks();
|
|
expect(survivors).toHaveLength(4);
|
|
expect(survivors.map((l) => l.getAttribute('href'))).toEqual([
|
|
'/a.svg',
|
|
'/a.png',
|
|
ORIGINAL_HREF,
|
|
ORIGINAL_HREF,
|
|
]);
|
|
expect(survivors[1].getAttribute('type')).toBe('image/png');
|
|
expect(survivors[1].getAttribute('sizes')).toBe('32x32');
|
|
expect(rehoisted.isConnected).toBe(true);
|
|
|
|
// The React-owned node is still React's to delete.
|
|
expect(() => unmount()).not.toThrow();
|
|
});
|
|
|
|
it('disconnects the observer on unmount and leaves nothing of its own behind', async () => {
|
|
const disconnect = vi.spyOn(MutationObserver.prototype, 'disconnect');
|
|
const { unmount } = renderHook(() => useFaviconBadge(3));
|
|
await waitFor(() => expect(badgeLink()).not.toBeNull());
|
|
|
|
unmount();
|
|
expect(disconnect).toHaveBeenCalled();
|
|
expect(badgeLink()).toBeNull();
|
|
|
|
// A post-unmount re-hoist must not resurrect the badge.
|
|
rehoistBaseIcon();
|
|
await settle();
|
|
expect(badgeLink()).toBeNull();
|
|
expect(baseLinks()).toHaveLength(2);
|
|
disconnect.mockRestore();
|
|
});
|
|
});
|
|
});
|