import { describe, it, expect, afterEach } from "vitest";
import { render, screen, act, cleanup } from "@testing-library/react";
import { StrictMode, useEffect, useLayoutEffect } from "react";
import { useState as useTapState } from "../../react-hooks/useState";
import { useEffect as useTapEffect } from "../../react-hooks/useEffect";
import { useTapHost } from "../../index";
const countOf = (log: string[], entry: string) =>
log.filter((e) => e === entry).length;
describe("useTapHost", () => {
afterEach(() => {
cleanup();
});
it("returns the resource value and re-renders on dispatch", () => {
let api!: { count: number; setCount: (n: number) => void };
function App() {
const { value } = useTapHost(function TapHost() {
const [count, setCount] = useTapState(0);
return { count, setCount };
});
api = value;
return
{value.count}
;
}
render();
expect(screen.getByTestId("count").textContent).toBe("0");
act(() => api.setCount(3));
expect(screen.getByTestId("count").textContent).toBe("3");
});
it("commits in the passive phase, before the effects of a consumer that mounts effects", () => {
const log: string[] = [];
function Child({ effects }: { effects: () => void }) {
useEffect(effects);
useLayoutEffect(() => {
log.push("child layout");
}, []);
useEffect(() => {
log.push("child effect");
}, []);
return null;
}
function App() {
const { effects } = useTapHost(function TapHost() {
useTapEffect(() => {
log.push("tap effect");
});
return null;
});
useEffect(() => {
log.push("host effect");
}, []);
return ;
}
render();
// "child layout" first proves the commit is passive (does not block
// paint); "tap effect" before "child effect" proves the consumer's
// instance won the commit over the host's own fallback instance.
expect(log).toEqual([
"child layout",
"tap effect",
"child effect",
"host effect",
]);
});
it("falls back to the host's own instance when no consumer mounts effects", () => {
const log: string[] = [];
function Child() {
useEffect(() => {
log.push("child effect");
}, []);
return null;
}
function App() {
useTapHost(function TapHost() {
useTapEffect(() => {
log.push("tap effect");
});
return null;
});
return ;
}
render();
expect(log).toEqual(["child effect", "tap effect"]);
});
it("commits exactly once per pass with multiple consumers", () => {
const log: string[] = [];
let api!: { bump: () => void };
function Child({ effects }: { effects: () => void }) {
useEffect(effects);
return null;
}
function App() {
const { value, effects } = useTapHost(function TapHost() {
const [count, setCount] = useTapState(0);
useTapEffect(() => {
log.push("tap effect");
});
return { count, bump: () => setCount(count + 1) };
});
api = value;
return (
<>
>
);
}
render();
expect(countOf(log, "tap effect")).toBe(1);
act(() => api.bump());
expect(countOf(log, "tap effect")).toBe(2);
});
it("hands responsibility to the next instance when the winner unmounts", () => {
const log: string[] = [];
let api!: { count: number; bump: () => void };
function Child({ name, effects }: { name: string; effects: () => void }) {
useEffect(effects);
useEffect(() => {
log.push(`${name} effect`);
});
return null;
}
function App({ showA }: { showA: boolean }) {
const { value, effects } = useTapHost(function TapHost() {
const [count, setCount] = useTapState(0);
useTapEffect(() => {
log.push(`tap effect ${count}`);
return () => {
log.push("tap cleanup");
};
});
return { count, bump: () => setCount(count + 1) };
});
api = value;
return (
<>
{showA && }
>
);
}
const { rerender } = render();
expect(log).toEqual(["tap effect 0", "a effect", "b effect"]);
log.length = 0;
rerender();
// The winner's unmount removes only its instance, not the resource: the
// no-deps tap effect re-fires as a cleanup/setup pair, not a final
// cleanup, and the commit still lands before b's own effect.
expect(log).toEqual(["tap cleanup", "tap effect 0", "b effect"]);
log.length = 0;
act(() => api.bump());
expect(api.count).toBe(1);
// Next in line (b) now commits, still ahead of its own effects.
expect(log).toEqual(["tap cleanup", "tap effect 1", "b effect"]);
});
it("unmounting the host cleans up exactly once", () => {
// effects consumers must be descendants of the host component, so
// they unmount with it and no flush can fire after the deletion.
const log: string[] = [];
function Child({ effects }: { effects: () => void }) {
useEffect(effects);
return null;
}
function HostComp() {
const { effects } = useTapHost(function TapHost() {
useTapEffect(() => {
log.push("tap effect");
return () => {
log.push("tap cleanup");
};
}, []);
return null;
});
return ;
}
function App({ showHost }: { showHost: boolean }) {
return showHost ? : null;
}
const { rerender } = render();
expect(log).toEqual(["tap effect"]);
rerender();
expect(log).toEqual(["tap effect", "tap cleanup"]);
});
it("remounts through a StrictMode effect cycle", () => {
const log: string[] = [];
let api!: { count: number; setCount: (n: number) => void };
function App() {
const { value } = useTapHost(function TapHost() {
const [count, setCount] = useTapState(0);
useTapEffect(() => {
log.push("tap mount");
return () => {
log.push("tap unmount");
};
}, []);
return { count, setCount };
});
api = value;
return {value.count}
;
}
render(
,
);
// setup, simulated unmount, setup: same as inlined hooks in StrictMode.
expect(countOf(log, "tap mount")).toBe(2);
expect(countOf(log, "tap unmount")).toBe(1);
act(() => api.setCount(5));
expect(screen.getByTestId("count").textContent).toBe("5");
expect(countOf(log, "tap mount")).toBe(2);
expect(countOf(log, "tap unmount")).toBe(1);
});
});