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

@@ -1,31 +1,37 @@
import { act, render } from "@testing-library/react";
import { ScrollTrigger } from "gsap/ScrollTrigger";
import { useRef } from "react";
import { IDLE_SECONDS, useCinematicScroll } from "./useCinematicScroll";
import { useCinematicScroll } from "./useCinematicScroll";
/**
* Testes de caminhos-base de useCinematicScroll (complemento de useCinematicScroll.test.tsx):
* - onClick (âncoras): cada guarda que devolve o clique ao navegador, alvo dentro de uma cena x a própria cena
* Testes de caminhos-base de useCinematicScroll (complemento de useCinematicScroll.test.tsx), com o hook montado
* numa página de verdade:
* - cliques em âncoras: cada guarda que devolve o clique ao navegador, alvo dentro de uma cena x a própria cena
* x fora de qualquer cena, desconto do cabeçalho, pushState só quando a âncora muda, foco no alvo;
* - salto direto (acima de 3 telas) x rolagem de até 0,8 s, nos limites exatos;
* - criação do Lenis (opções, ligação com o ScrollTrigger), relógio que dorme e acorda, desmontagem antes das
* importações terminarem, ref vazia, e o ResizeObserver que remede as passagens com atraso.
* GSAP, ScrollTrigger e Lenis são trocados por fakes que registram o que recebem.
* - salto direto (acima de 3 telas) x rolagem de até 0,8 s, nos limites exatos, com Lenis e sem ele (toque);
* - ref vazia, movimento reduzido e a limpeza dos ouvintes ao desmontar.
* O Lenis é trocado por um dublê que registra o que recebe; o GSAP nem existe mais (se alguém o importar, o
* dublê abaixo quebra o teste).
*/
type Call = unknown[];
const mockTimelines: Array<{ calls: Call[] }> = [];
const mockTicker = {
add: jest.fn(),
remove: jest.fn(),
lagSmoothing: jest.fn(),
time: 0,
};
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 },
);
const mockLenis: MockLenis[] = [];
class MockLenis {
isScrolling: false | "smooth" = false;
on = jest.fn();
raf = jest.fn();
scrollTo = jest.fn();
destroy = jest.fn();
@@ -34,30 +40,6 @@ class MockLenis {
}
}
jest.mock("gsap", () => ({
gsap: {
registerPlugin: jest.fn(),
ticker: mockTicker,
context: (fn: () => void) => {
fn();
return { revert: jest.fn() };
},
timeline: () => {
const record = { 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() {
@@ -126,7 +108,6 @@ function Home({ attach = true }: { attach?: boolean }) {
}
const originalMatchMedia = window.matchMedia;
const OriginalResizeObserver = global.ResizeObserver;
const originalOffsetTop = Object.getOwnPropertyDescriptor(
HTMLElement.prototype,
"offsetTop",
@@ -216,14 +197,7 @@ afterAll(() => {
});
beforeEach(() => {
mockTimelines.length = 0;
mockLenis.length = 0;
mockTicker.add.mockClear();
mockTicker.remove.mockClear();
mockTicker.lagSmoothing.mockClear();
mockTicker.time = 0;
jest.mocked(ScrollTrigger.update).mockClear();
jest.mocked(ScrollTrigger.refresh).mockClear();
mockMedia([]);
window.history.replaceState(null, "", "/");
jest.spyOn(window, "scrollTo").mockImplementation(() => {});
@@ -236,7 +210,6 @@ beforeEach(() => {
afterEach(() => {
window.matchMedia = originalMatchMedia;
global.ResizeObserver = OriginalResizeObserver;
jest.useRealTimers();
jest.restoreAllMocks();
});
@@ -527,113 +500,13 @@ describe("useCinematicScroll: salto direto x rolagem curta", () => {
});
});
describe("useCinematicScroll: Lenis e o relógio do GSAP", () => {
it("path: mouse -> Lenis gets the smoothing options and drives ScrollTrigger; the GSAP lag smoothing is turned off", async () => {
await mount();
expect(mockLenis).toHaveLength(1);
expect(mockLenis[0].options).toEqual({
lerp: 0.075,
wheelMultiplier: 0.85,
});
expect(mockLenis[0].on).toHaveBeenCalledWith(
"scroll",
ScrollTrigger.update,
);
expect(mockTicker.lagSmoothing).toHaveBeenCalledWith(0);
expect(mockTicker.add).toHaveBeenCalledTimes(1);
});
it("path: touch -> no Lenis, no ticker, no lag-smoothing change and the passages are still created", 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);
});
it("path: touch -> window scroll events do not touch the clock", async () => {
mockMedia(["(pointer: coarse)"]);
await mount();
act(() => {
window.dispatchEvent(new Event("scroll"));
window.dispatchEvent(new Event("wheel"));
});
expect(mockTicker.add).not.toHaveBeenCalled();
});
it("path: the clock went to sleep, then a native scroll event -> wakes it", async () => {
await mount();
const tick = mockTicker.add.mock.calls[0][0] as (time: number) => void;
tick(IDLE_SECONDS + 1); // parado há mais que o tempo de espera
expect(mockTicker.remove).toHaveBeenCalledTimes(1);
mockTicker.time = 5;
act(() => {
window.dispatchEvent(new Event("scroll"));
});
expect(mockTicker.add).toHaveBeenCalledTimes(2);
});
it("path: the clock went to sleep, then an anchor click -> wakes it before scrolling", async () => {
await mount();
const tick = mockTicker.add.mock.calls[0][0] as (time: number) => void;
tick(IDLE_SECONDS + 1);
click(link("projetos"));
expect(mockTicker.add).toHaveBeenCalledTimes(2);
expect(mockLenis[0].scrollTo).toHaveBeenCalledTimes(1);
});
it("path: the clock is already asleep and the page unmounts -> it is not removed twice", async () => {
const view = await mount();
const tick = mockTicker.add.mock.calls[0][0] as (time: number) => void;
tick(IDLE_SECONDS + 1);
expect(mockTicker.remove).toHaveBeenCalledTimes(1);
view.unmount();
expect(mockTicker.remove).toHaveBeenCalledTimes(1);
expect(mockLenis[0].destroy).toHaveBeenCalledTimes(1);
});
it("path: Lenis still scrolling -> the tick keeps the clock awake and feeds it in milliseconds", async () => {
await mount();
const tick = mockTicker.add.mock.calls[0][0] as (time: number) => void;
mockLenis[0].isScrolling = "smooth";
tick(IDLE_SECONDS * 10);
tick(IDLE_SECONDS * 10 + IDLE_SECONDS / 2);
expect(mockLenis[0].raf).toHaveBeenLastCalledWith(
(IDLE_SECONDS * 10 + IDLE_SECONDS / 2) * 1000,
);
expect(mockTicker.remove).not.toHaveBeenCalled();
});
});
describe("useCinematicScroll: montagem e desmontagem", () => {
it("path: unmounted before the dynamic imports finish -> nothing is created", async () => {
const view = render(<Home />);
view.unmount();
await flushImports();
expect(mockTimelines).toHaveLength(0);
expect(mockLenis).toHaveLength(0);
expect(mockTicker.add).not.toHaveBeenCalled();
});
it("path: ref not attached to an element -> the hook does nothing", async () => {
await mount({ attach: false });
expect(mockTimelines).toHaveLength(0);
expect(mockLenis).toHaveLength(0);
const { prevented } = click(link("projetos"));
expect(prevented).toBe(false);
});
it("path: reduced motion -> nothing, and anchor clicks are left to the browser", async () => {
@@ -644,6 +517,20 @@ describe("useCinematicScroll: montagem e desmontagem", () => {
expect(prevented).toBe(false);
expect(mockLenis).toHaveLength(0);
expect(window.scrollTo).not.toHaveBeenCalled();
});
it("path: touch -> anchors are still handled (the click listener does not wait for any package)", async () => {
mockMedia(["(pointer: coarse)"]);
const view = render(<Home />);
// sem esperar importação nenhuma: o clique já é tratado
const { prevented } = click(link("projetos"));
expect(prevented).toBe(true);
expect(window.scrollTo).toHaveBeenCalledTimes(1);
expect(mockLenis).toHaveLength(0);
view.unmount();
});
it("path: unmount -> the document click listener and the wheel/scroll listeners are removed", async () => {
@@ -662,59 +549,16 @@ describe("useCinematicScroll: montagem e desmontagem", () => {
expect.arrayContaining(["wheel", "scroll"]),
);
});
});
describe("useCinematicScroll: remedida depois de a página mudar de altura", () => {
type Resize = { callback: () => void; disconnect: jest.Mock };
let resizers: Resize[] = [];
beforeEach(() => {
resizers = [];
global.ResizeObserver = class {
private readonly record: Resize;
constructor(callback: () => void) {
this.record = { callback, disconnect: jest.fn() };
resizers.push(this.record);
}
observe() {}
unobserve() {}
disconnect() {
this.record.disconnect();
}
} as unknown as typeof ResizeObserver;
});
it("path: several size changes in a row -> one refresh, 150 ms after the last", async () => {
jest.useFakeTimers();
await mount();
act(() => {
resizers[0].callback();
jest.advanceTimersByTime(100);
resizers[0].callback();
jest.advanceTimersByTime(149);
});
expect(ScrollTrigger.refresh).not.toHaveBeenCalled();
act(() => {
jest.advanceTimersByTime(1);
});
expect(ScrollTrigger.refresh).toHaveBeenCalledTimes(1);
});
it("path: unmount with a refresh pending -> the refresh never happens and the observer is disconnected", async () => {
jest.useFakeTimers();
it("path: after unmount an anchor click is no longer intercepted", async () => {
const view = await mount();
act(() => {
resizers[0].callback();
});
const target = link("projetos");
view.unmount();
act(() => {
jest.advanceTimersByTime(1000);
});
document.body.append(target);
expect(ScrollTrigger.refresh).not.toHaveBeenCalled();
expect(resizers[0].disconnect).toHaveBeenCalledTimes(1);
const { prevented } = click(target);
expect(prevented).toBe(false);
target.remove();
});
});