perf(movimento): sem tela vazia, passagens em CSS e vitrine leve

Projetos sobem sobre o palco do hero e o celular tem hero de uma tela sem mergulho; passagens com animation-timeline em CSS, sem GSAP nem motion; troca da vitrine sem texto duplicado; moldura de celular só com pôster e scrub reencodado em 960 px (9,4 para 5,2 MB); 3D monta na primeira interação e para quando parado; transição entre páginas do mesmo segmento; foco em anel duplo; texto de 15 px no celular; código morto removido.
This commit is contained in:
Evandro Fonseca Junior committed 2026-10-14 14:15:50 -03:00
1 parent 10f170abf4
commit 318a764190
77 files changed
+4293 -4951

No files matched your search

@@ -2,171 +2,41 @@ import { act, render } from "@testing-library/react";
import { useRef } from "react";
import {
anchorDuration,
ENTER_OFFSET,
enterScene,
exitScene,
anchorTop,
IDLE_SECONDS,
isPlainPrimaryClick,
JUMP_SCREENS,
MAX_ANCHOR_SECONDS,
PASSAGE_END,
samePageHashTarget,
useCinematicScroll,
} from "./useCinematicScroll";
/* ------------------------------------------------------------------------------------------------
* Passagem de verdade (GSAP real, sem ScrollTrigger): o que cada cena recebe no começo e no fim.
* ------------------------------------------------------------------------------------------------ */
/*
* A rolagem da home sem biblioteca de animação por quadro: as passagens entre cenas são CSS; este hook só
* liga o Lenis (mouse) e leva os links internos até o destino. Aqui: o hook montado (Lenis trocado por um
* dublê que registra o que recebe) e as funções puras que ele usa.
*/
// o resto do arquivo troca o GSAP por um fake: aqui vale o de verdade
const { gsap: realGsap } = jest.requireActual<typeof import("gsap")>("gsap");
// o GSAP e o ScrollTrigger saíram da página: se alguém voltar a importá-los, estes dublês quebram o teste
jest.mock(
"gsap",
() => {
throw new Error("gsap não deve ser carregado");
},
{ virtual: true },
);
jest.mock(
"gsap/ScrollTrigger",
() => {
throw new Error("gsap/ScrollTrigger não deve ser carregado");
},
{ virtual: true },
);
function scene() {
const element = document.createElement("section");
document.body.append(element);
return element;
}
function timeline() {
return realGsap.timeline({
paused: true,
defaults: { ease: "none", duration: 1 },
});
}
const translateY = (element: HTMLElement) =>
Number(
/translate(?:3d)?\(\s*[-\d.]+(?:px)?,\s*([-\d.]+)px/.exec(
element.style.transform,
)?.[1] ?? Number.NaN,
);
describe("the single passage between scenes", () => {
afterEach(() => {
document.body.innerHTML = "";
});
it("brings the next scene in with a fade and a short vertical offset, nothing else", () => {
expect(ENTER_OFFSET).toBeGreaterThanOrEqual(24);
expect(ENTER_OFFSET).toBeLessThanOrEqual(40);
const next = scene();
const tl = timeline();
enterScene(tl, next);
tl.progress(0.0001);
expect(Number(next.style.opacity)).toBeLessThan(0.01);
expect(translateY(next)).toBeCloseTo(ENTER_OFFSET, 0);
tl.progress(0.5);
expect(Number(next.style.opacity)).toBeGreaterThan(0.2);
expect(Number(next.style.opacity)).toBeLessThan(1);
expect(translateY(next)).toBeGreaterThan(0);
expect(translateY(next)).toBeLessThan(ENTER_OFFSET);
// sem recorte, escala, giro nem visibility em nenhum ponto
for (const progress of [0.0001, 0.3, 0.7]) {
tl.progress(progress);
expect(next.style.clipPath).toBe("");
expect(next.style.visibility).toBe("");
expect(next.style.transform).not.toMatch(/scale|rotate/);
}
});
it("leaves no inline style behind once the scene has arrived, and brings it back on the way up", () => {
const next = scene();
const tl = timeline();
enterScene(tl, next);
tl.progress(1);
expect(next.style.opacity).toBe("");
expect(next.style.transform).toBe("");
tl.progress(0.4);
expect(Number(next.style.opacity)).toBeLessThan(1);
expect(next.style.transform).not.toBe("");
});
it("fades the previous scene all the way to opacity 0 at the end of every passage", () => {
const previous = scene();
const tl = timeline();
exitScene(tl, previous);
tl.progress(0.5);
expect(Number(previous.style.opacity)).toBeGreaterThan(0);
expect(Number(previous.style.opacity)).toBeLessThan(1);
expect(previous.style.visibility).toBe("");
expect(previous.style.clipPath).toBe("");
tl.progress(1);
expect(previous.style.opacity).toBe("0");
// rolando de volta, a cena reaparece inteira
tl.progress(0.0001);
expect(Number(previous.style.opacity)).toBeGreaterThan(0.99);
});
it("a scene in the middle of the page arrives, stays, and leaves at 0 without fighting itself", () => {
const middle = scene();
const arrive = timeline();
const leave = timeline();
enterScene(arrive, middle);
exitScene(leave, middle);
arrive.progress(1);
expect(middle.style.opacity).toBe("");
leave.progress(1);
expect(middle.style.opacity).toBe("0");
leave.progress(0);
arrive.progress(0.2);
expect(Number(middle.style.opacity)).toBeLessThan(0.5);
});
});
describe("anchor travel", () => {
it("jumps straight to far anchors and animates near ones for 0.8 s at most", () => {
expect(MAX_ANCHOR_SECONDS).toBe(0.8);
const viewport = 800;
expect(anchorDuration(25000, viewport)).toBeNull();
expect(anchorDuration(-25000, viewport)).toBeNull();
expect(
anchorDuration(viewport * (JUMP_SCREENS + 0.5), viewport),
).toBeNull();
for (const distance of [0, 200, 800, 1600, viewport * JUMP_SCREENS]) {
const seconds = anchorDuration(distance, viewport);
expect(seconds).not.toBeNull();
expect(seconds as number).toBeGreaterThan(0);
expect(seconds as number).toBeLessThanOrEqual(MAX_ANCHOR_SECONDS);
}
});
});
/* ------------------------------------------------------------------------------------------------
* O hook: GSAP, ScrollTrigger e Lenis trocados por fakes que registram o que recebem.
* ------------------------------------------------------------------------------------------------ */
type Call = unknown[];
const mockTimelines: Array<{ vars: ScrollTriggerLike; calls: Call[] }> = [];
const mockTicker = {
add: jest.fn(),
remove: jest.fn(),
lagSmoothing: jest.fn(),
time: 0,
};
const mockLenis: MockLenis[] = [];
interface ScrollTriggerLike {
scrollTrigger: {
trigger: HTMLElement;
start: () => number;
end: () => number;
scrub: boolean;
onEnter?: () => void;
};
}
class MockLenis {
isScrolling: false | "smooth" = false;
on = jest.fn();
raf = jest.fn();
scrollTo = jest.fn();
destroy = jest.fn();
@@ -175,30 +45,6 @@ class MockLenis {
}
}
jest.mock("gsap", () => ({
gsap: {
registerPlugin: jest.fn(),
ticker: mockTicker,
context: (fn: () => void) => {
fn();
return { revert: jest.fn() };
},
timeline: (vars: ScrollTriggerLike) => {
const record = { vars, calls: [] as Call[] };
mockTimelines.push(record);
const chain = {
fromTo: (...args: Call) => {
record.calls.push(args);
return chain;
},
};
return chain;
},
},
}));
jest.mock("gsap/ScrollTrigger", () => ({
ScrollTrigger: { update: jest.fn(), refresh: jest.fn() },
}));
jest.mock("lenis", () => ({
__esModule: true,
get default() {
@@ -226,7 +72,6 @@ function Home() {
<section id="projects" data-top={TOPS.projects}>
<h2>Projetos</h2>
</section>
{/* uma cena mais curta que a tela: a saída dela não pode começar antes de a entrada terminar */}
<section id="short" data-top={TOPS.short}>
<h2>Curta</h2>
</section>
@@ -257,15 +102,20 @@ function mockMedia(active: string[]) {
async function mount() {
const view = render(<Home />);
// as importações dinâmicas do hook (gsap, ScrollTrigger, Lenis) resolvem em microtarefas
// a importação dinâmica do Lenis resolve em microtarefas
await act(async () => {
for (let i = 0; i < 10; i++) await Promise.resolve();
});
return view;
}
const scenes = () =>
Array.from(document.querySelectorAll<HTMLElement>("main > section"));
/** Quadros de animação pedidos pelo hook desde que o espião foi criado. */
let frames: jest.SpyInstance;
const advance = (ms: number) =>
act(() => {
jest.advanceTimersByTime(ms);
});
beforeAll(() => {
Object.defineProperty(HTMLElement.prototype, "offsetTop", {
@@ -286,12 +136,9 @@ afterAll(() => {
});
beforeEach(() => {
mockTimelines.length = 0;
jest.useFakeTimers();
frames = jest.spyOn(window, "requestAnimationFrame");
mockLenis.length = 0;
mockTicker.add.mockClear();
mockTicker.remove.mockClear();
mockTicker.lagSmoothing.mockClear();
mockTicker.time = 0;
mockMedia([]);
window.history.replaceState(null, "", "/");
jest.spyOn(window, "scrollTo").mockImplementation(() => {});
@@ -299,162 +146,286 @@ beforeEach(() => {
configurable: true,
value: 800,
});
Object.defineProperty(window, "scrollY", { configurable: true, value: 0 });
});
afterEach(() => {
window.matchMedia = originalMatchMedia;
jest.useRealTimers();
jest.restoreAllMocks();
});
describe("anchor travel", () => {
it("jumps straight to far anchors and animates near ones for 0.8 s at most", () => {
expect(MAX_ANCHOR_SECONDS).toBe(0.8);
const viewport = 800;
expect(anchorDuration(25000, viewport)).toBeNull();
expect(anchorDuration(-25000, viewport)).toBeNull();
expect(
anchorDuration(viewport * (JUMP_SCREENS + 0.5), viewport),
).toBeNull();
for (const distance of [0, 200, 800, 1600, viewport * JUMP_SCREENS]) {
const seconds = anchorDuration(distance, viewport);
expect(seconds).not.toBeNull();
expect(seconds as number).toBeGreaterThan(0);
expect(seconds as number).toBeLessThanOrEqual(MAX_ANCHOR_SECONDS);
}
});
it("takes longer for farther anchors, up to the cap, and the same time up or down", () => {
const near = anchorDuration(200, 800) as number;
const far = anchorDuration(1600, 800) as number;
expect(far).toBeGreaterThan(near);
expect(anchorDuration(-1600, 800)).toBe(far);
expect(anchorDuration(0, 0)).not.toBeNaN();
});
});
describe("isPlainPrimaryClick", () => {
const click = (init: MouseEventInit = {}) =>
new MouseEvent("click", { cancelable: true, button: 0, ...init });
it("accepts a plain click of the main button", () => {
expect(isPlainPrimaryClick(click())).toBe(true);
});
it.each(["metaKey", "ctrlKey", "shiftKey", "altKey"] as const)(
"refuses a click with %s held (new tab, selection)",
(modifier) => {
expect(isPlainPrimaryClick(click({ [modifier]: true }))).toBe(false);
},
);
it.each([1, 2])("refuses mouse button %i", (button) => {
expect(isPlainPrimaryClick(click({ button }))).toBe(false);
});
it("refuses a click somebody else already cancelled", () => {
const event = click();
event.preventDefault();
expect(isPlainPrimaryClick(event)).toBe(false);
});
});
describe("samePageHashTarget", () => {
afterEach(() => {
document.body.innerHTML = "";
});
function clickOn(markup: string, selector: string) {
document.body.innerHTML = markup;
const target = document.querySelector(selector) as Element;
const event = new MouseEvent("click", { bubbles: true });
Object.defineProperty(event, "target", { value: target });
return event;
}
it("finds the element a same-page #link points to", () => {
const event = clickOn(
`<a href="/#alvo">ir</a><section id="alvo"></section>`,
"a",
);
const hit = samePageHashTarget(event);
expect(hit?.hash).toBe("#alvo");
expect(hit?.target.id).toBe("alvo");
});
it("works from inside the link (the text, an icon)", () => {
const event = clickOn(
`<a href="/#alvo"><span id="inside">ir</span></a><section id="alvo"></section>`,
"#inside",
);
expect(samePageHashTarget(event)?.target.id).toBe("alvo");
});
it.each([
["another site", `<a href="https://example.com/#alvo">x</a>`],
["another page of this site", `<a href="/outra#alvo">x</a>`],
["no anchor", `<a href="/">x</a>`],
["an empty anchor", `<a href="/#">x</a>`],
["a new tab", `<a href="/#alvo" target="_blank">x</a>`],
["an id that does not exist", `<a href="/#nada">x</a>`],
["a malformed percent-encoding", `<a href="/#%E0%A4%A">x</a>`],
["no link at all", `<p>x</p>`],
])("returns null for %s", (_label, markup) => {
const event = clickOn(
`${markup}<section id="alvo"></section>`,
markup.startsWith("<a") ? "a" : "p",
);
expect(samePageHashTarget(event)).toBeNull();
});
it("handles target=_self like a plain link", () => {
const event = clickOn(
`<a href="/#alvo" target="_self">x</a><section id="alvo"></section>`,
"a",
);
expect(samePageHashTarget(event)?.target.id).toBe("alvo");
});
it("ignores a click whose target is not an element", () => {
const event = new MouseEvent("click", { bubbles: true });
Object.defineProperty(event, "target", { value: document });
expect(samePageHashTarget(event)).toBeNull();
});
});
describe("anchorTop", () => {
const scenes = () => {
document.body.innerHTML = `
<section id="a" data-top="1800"><h2 id="a-title" data-top="1900">t</h2></section>
<section id="b" data-top="2400"></section>
<p id="loose" data-top="9000">x</p>`;
return [...document.querySelectorAll<HTMLElement>("section")];
};
const byId = (id: string) => document.getElementById(id) as HTMLElement;
afterEach(() => {
document.body.innerHTML = "";
jest.restoreAllMocks();
});
function headerOffset(px: string) {
jest
.spyOn(window, "getComputedStyle")
.mockReturnValue({ scrollPaddingTop: px } as CSSStyleDeclaration);
}
it("is the scene top when the target is the whole scene (the header offset is ignored)", () => {
const all = scenes();
headerOffset("80px");
expect(anchorTop(byId("a"), all)).toBe(1800);
});
it("is the target minus the fixed header when it is inside a scene", () => {
const all = scenes();
headerOffset("80px");
expect(anchorTop(byId("a-title"), all)).toBe(1900 - 80);
});
it("never goes above the top of the scene", () => {
const all = scenes();
headerOffset("300px");
expect(anchorTop(byId("a-title"), all)).toBe(1800);
});
it("measures from the top of the page, minus the header, for a target outside every scene", () => {
const all = scenes();
headerOffset("100px");
expect(anchorTop(byId("loose"), all)).toBe(9000 - 100);
});
it("treats a missing header offset as zero", () => {
const all = scenes();
headerOffset("");
expect(anchorTop(byId("a-title"), all)).toBe(1900);
});
});
describe("useCinematicScroll", () => {
it("never hides a scene: no visibility, no sticky pin, nothing inline before the scroll runs", async () => {
it("never hides, pins or restyles a scene: the passages are CSS, nothing inline", async () => {
await mount();
for (const element of scenes()) {
expect(element.style.visibility).toBe("");
expect(element.style.position).toBe("");
expect(element.style.top).toBe("");
expect(element.style.opacity).toBe("");
expect(element.style.clipPath).toBe("");
for (const element of document.querySelectorAll<HTMLElement>(
"main > section",
)) {
expect(element.getAttribute("style")).toBeNull();
}
// o texto do contato continua achável (Ctrl+F) e visível para leitores de tela
const contact = document.getElementById("contact") as HTMLElement;
expect(contact).toBeVisible();
expect(contact).toHaveTextContent("Contato");
expect(contact.closest("[aria-hidden]")).toBeNull();
});
it("uses one passage only: opacity and a vertical offset, with the previous scene ending at 0", async () => {
await mount();
const allowed = new Set([
"opacity",
"y",
"ease",
"clearProps",
"immediateRender",
]);
const entering: HTMLElement[] = [];
const leaving: HTMLElement[] = [];
for (const { vars, calls } of mockTimelines) {
expect(vars.scrollTrigger.scrub).toBe(true);
expect(calls).toHaveLength(1);
const [target, from, to] = calls[0] as [
HTMLElement,
Record<string, unknown>,
Record<string, unknown>,
];
for (const key of [...Object.keys(from), ...Object.keys(to)])
expect(allowed.has(key)).toBe(true);
expect(JSON.stringify([from, to])).not.toMatch(
/autoAlpha|visibility|clipPath|scale|rotation|xPercent/,
);
if (to.opacity === 1) {
entering.push(target);
expect(from).toMatchObject({ opacity: 0, y: ENTER_OFFSET });
expect(vars.scrollTrigger.trigger).toBe(target);
} else {
leaving.push(target);
expect(from).toMatchObject({ opacity: 1 });
expect(to.opacity).toBe(0);
}
}
const all = scenes();
// todas entram, menos a primeira; todas saem, menos a última
expect(entering).toEqual(all.slice(1));
expect(leaving).toEqual(all.slice(0, -1));
});
it("starts each exit only after the same scene finished entering, even for a scene shorter than the screen", async () => {
await mount();
const all = scenes();
const ranges = new Map<
HTMLElement,
{ enter?: [number, number]; exit?: [number, number] }
>();
for (const { vars, calls } of mockTimelines) {
const target = calls[0][0] as HTMLElement;
const to = calls[0][2] as { opacity: number };
const { start, end } = vars.scrollTrigger;
const entry = ranges.get(target) ?? {};
if (to.opacity === 1) entry.enter = [start(), end()];
else entry.exit = [start(), end()];
ranges.set(target, entry);
}
const vh = window.innerHeight;
for (const element of all.slice(1)) {
const top = TOPS[element.id];
expect(ranges.get(element)?.enter).toEqual([
top - vh,
top - vh * PASSAGE_END,
]);
}
for (const element of all.slice(0, -1)) {
const { enter, exit } = ranges.get(element) ?? {};
expect(exit).toBeDefined();
const [start, end] = exit as [number, number];
expect(start).toBeLessThan(end);
// termina quando o topo da cena seguinte chegou ao fim da passagem dela
const next = all[all.indexOf(element) + 1];
expect(end).toBe(TOPS[next.id] - vh * PASSAGE_END);
if (enter) expect(start).toBeGreaterThanOrEqual(enter[1]);
}
// a cena curta (300 px): a saída começa onde a entrada acabou, não 500 px antes
const short = ranges.get(document.getElementById("short") as HTMLElement);
expect(short?.exit?.[0]).toBe(short?.enter?.[1]);
});
it("does no scroll work for users who prefer reduced motion", async () => {
it("does no scroll work for users who prefer reduced motion, and leaves anchor clicks to the browser", async () => {
mockMedia(["(prefers-reduced-motion: reduce)"]);
await mount();
expect(mockTimelines).toHaveLength(0);
const link = document.querySelector('a[href="/#projects"]') as HTMLElement;
const event = new MouseEvent("click", { bubbles: true, cancelable: true });
act(() => {
link.dispatchEvent(event);
});
expect(event.defaultPrevented).toBe(false);
expect(mockLenis).toHaveLength(0);
expect(mockTicker.add).not.toHaveBeenCalled();
expect(window.scrollTo).not.toHaveBeenCalled();
});
describe("smooth scroll", () => {
it("is not created on a touch screen, where the passages still run on the native scroll", async () => {
it("is not created on a touch screen: the package is not even loaded, and the native scroll does the work", async () => {
mockMedia(["(pointer: coarse)"]);
await mount();
expect(mockLenis).toHaveLength(0);
expect(mockTicker.add).not.toHaveBeenCalled();
expect(mockTicker.lagSmoothing).not.toHaveBeenCalled();
expect(mockTimelines.length).toBeGreaterThan(0);
expect(frames).not.toHaveBeenCalled();
act(() => {
window.dispatchEvent(new Event("wheel"));
window.dispatchEvent(new Event("scroll"));
});
advance(500);
expect(frames).not.toHaveBeenCalled();
});
it("is created with a mouse, and the GSAP clock sleeps once the scroll is idle and wakes on the wheel", async () => {
it("is created with a mouse, with the smoothing options", async () => {
await mount();
expect(mockLenis).toHaveLength(1);
expect(mockLenis[0].options).toEqual({
lerp: 0.075,
wheelMultiplier: 0.85,
});
});
it("feeds Lenis in milliseconds while scrolling, and lets the animation clock go once idle", async () => {
await mount();
const lenis = mockLenis[0];
expect(mockTicker.add).toHaveBeenCalledTimes(1);
const tick = mockTicker.add.mock.calls[0][0] as (time: number) => void;
// rolando: segue ligado e alimenta o Lenis em milissegundos
// rolando: segue ligado, a cada quadro
lenis.isScrolling = "smooth";
tick(0.5);
expect(lenis.raf).toHaveBeenLastCalledWith(500);
expect(mockTicker.remove).not.toHaveBeenCalled();
advance(500);
const whileScrolling = lenis.raf.mock.calls.length;
expect(whileScrolling).toBeGreaterThan(20);
expect(lenis.raf.mock.calls.at(-1)?.[0]).toBeGreaterThan(400);
// parado há mais que o tempo de espera: o relógio é solto
// parado: depois de IDLE_SECONDS o relógio é solto de vez (nenhum quadro de animação pedido)
lenis.isScrolling = false;
tick(0.5 + IDLE_SECONDS / 2);
expect(mockTicker.remove).not.toHaveBeenCalled();
tick(0.5 + IDLE_SECONDS + 0.1);
expect(mockTicker.remove).toHaveBeenCalledTimes(1);
expect(mockTicker.remove).toHaveBeenCalledWith(tick);
advance(IDLE_SECONDS * 1000 + 200);
const stopped = lenis.raf.mock.calls.length;
const requested = frames.mock.calls.length;
advance(3000);
expect(lenis.raf.mock.calls.length).toBe(stopped);
expect(frames.mock.calls.length).toBe(requested);
});
it("wakes up again with the wheel or any scroll event, and a second event does not start a second clock", async () => {
await mount();
const lenis = mockLenis[0];
advance(IDLE_SECONDS * 1000 + 500);
lenis.raf.mockClear();
// a roda do mouse o acorda de novo
mockTicker.time = 10;
act(() => {
window.dispatchEvent(new Event("wheel"));
window.dispatchEvent(new Event("scroll"));
});
expect(mockTicker.add).toHaveBeenCalledTimes(2);
// e uma roda seguinte, com o relógio já ligado, não o liga duas vezes
act(() => {
window.dispatchEvent(new Event("wheel"));
});
expect(mockTicker.add).toHaveBeenCalledTimes(2);
advance(100);
// um laço só: cerca de um quadro a cada 16 ms
expect(lenis.raf.mock.calls.length).toBeGreaterThan(0);
expect(lenis.raf.mock.calls.length).toBeLessThanOrEqual(8);
});
it("jumps straight to a far anchor (the header button to #contact) and animates a near one for 0.8 s at most", async () => {
@@ -467,7 +438,7 @@ describe("useCinematicScroll", () => {
expect(lenis.scrollTo).toHaveBeenCalledTimes(1);
const [farTop, farOptions] = lenis.scrollTo.mock.calls[0];
expect(farTop).toBe(TOPS.contact);
expect(farOptions).toMatchObject({ immediate: true });
expect(farOptions).toMatchObject({ immediate: true, duration: 0 });
expect(window.location.hash).toBe("#contact");
expect(document.getElementById("contact")).toHaveFocus();
@@ -481,7 +452,22 @@ describe("useCinematicScroll", () => {
expect(nearOptions.duration).toBeLessThanOrEqual(MAX_ANCHOR_SECONDS);
});
it("without Lenis (touch) a far anchor is an instant jump", async () => {
it("wakes the clock before an anchor scroll, so the animation actually runs after a long rest", async () => {
await mount();
const lenis = mockLenis[0];
advance(IDLE_SECONDS * 1000 + 500);
lenis.raf.mockClear();
act(() => {
(document.querySelector('a[href="/#projects"]') as HTMLElement).click();
});
advance(100);
expect(lenis.scrollTo).toHaveBeenCalledTimes(1);
expect(lenis.raf).toHaveBeenCalled();
});
it("without Lenis (touch) a far anchor is an instant jump and a near one is a native smooth scroll", async () => {
mockMedia(["(pointer: coarse)"]);
await mount();
act(() => {
@@ -501,17 +487,33 @@ describe("useCinematicScroll", () => {
});
});
it("releases the clock, the listeners and Lenis when the page unmounts", async () => {
it("releases Lenis, the clock and the listeners when the page unmounts", async () => {
const view = await mount();
const lenis = mockLenis[0];
view.unmount();
expect(lenis.destroy).toHaveBeenCalledTimes(1);
expect(mockTicker.remove).toHaveBeenCalled();
mockTicker.add.mockClear();
lenis.raf.mockClear();
const requested = frames.mock.calls.length;
act(() => {
window.dispatchEvent(new Event("wheel"));
window.dispatchEvent(new Event("scroll"));
});
expect(mockTicker.add).not.toHaveBeenCalled();
advance(200);
expect(lenis.raf).not.toHaveBeenCalled();
expect(frames.mock.calls.length).toBe(requested);
});
it("creates nothing when the page unmounted before the package finished loading", async () => {
const view = render(<Home />);
view.unmount();
await act(async () => {
for (let i = 0; i < 10; i++) await Promise.resolve();
});
expect(mockLenis).toHaveLength(0);
expect(frames).not.toHaveBeenCalled();
});
});
});