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:
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();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user