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,5 +1,5 @@
|
||||
import { act, render, screen } from "@testing-library/react";
|
||||
import HeroField from "./HeroField";
|
||||
import HeroField, { DIVE_QUERY } from "./HeroField";
|
||||
|
||||
type SceneProps = {
|
||||
visible: boolean;
|
||||
@@ -9,16 +9,17 @@ type SceneProps = {
|
||||
onError: () => void;
|
||||
};
|
||||
const mockScenes: SceneProps[] = [];
|
||||
const mockPrepare = jest.fn();
|
||||
|
||||
jest.mock("next/dynamic", () => ({
|
||||
jest.mock("./HeroFieldScene", () => ({
|
||||
__esModule: true,
|
||||
default: () =>
|
||||
function MockScene(props: SceneProps) {
|
||||
mockScenes.push(props);
|
||||
return require("react").createElement("canvas", {
|
||||
"data-testid": "scene",
|
||||
});
|
||||
},
|
||||
default: function MockScene(props: SceneProps) {
|
||||
mockScenes.push(props);
|
||||
return require("react").createElement("canvas", {
|
||||
"data-testid": "scene",
|
||||
});
|
||||
},
|
||||
prepareField: () => mockPrepare(),
|
||||
}));
|
||||
|
||||
const original = {
|
||||
@@ -27,7 +28,8 @@ const original = {
|
||||
getContext: HTMLCanvasElement.prototype.getContext,
|
||||
};
|
||||
|
||||
function mockMedia(active: string[]) {
|
||||
/** Consultas de mídia que valem; o mergulho (DIVE_QUERY) vale por padrão: tela alta, sem movimento reduzido. */
|
||||
function mockMedia(active: string[] = [DIVE_QUERY]) {
|
||||
window.matchMedia = jest.fn().mockImplementation((query: string) => ({
|
||||
matches: active.includes(query),
|
||||
media: query,
|
||||
@@ -49,17 +51,37 @@ function Hero() {
|
||||
);
|
||||
}
|
||||
|
||||
/** O navegador ocioso: o Three.js só entra depois do tempo de espera. */
|
||||
function idle() {
|
||||
const section = () => document.querySelector(".hero") as HTMLElement;
|
||||
|
||||
/** Rola o hero no meio do mergulho e diz se a rolagem chegou a escrever o progresso no CSS. */
|
||||
function scrollWritesProgress() {
|
||||
section().getBoundingClientRect = () =>
|
||||
({ top: -700, height: 3000, bottom: 2300 }) as DOMRect;
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(3000);
|
||||
window.dispatchEvent(new Event("scroll"));
|
||||
jest.advanceTimersByTime(40);
|
||||
});
|
||||
return section().style.getPropertyValue("--hero-p") !== "";
|
||||
}
|
||||
|
||||
/** Avança o relógio e deixa as promessas (importação do pacote, passos ociosos) resolverem no caminho. */
|
||||
const advance = (ms: number) =>
|
||||
act(async () => {
|
||||
await jest.advanceTimersByTimeAsync(ms);
|
||||
});
|
||||
|
||||
/** A página fica ociosa (sem requestIdleCallback: 1,2 s) e o Three.js entra, passo a passo. */
|
||||
async function idle() {
|
||||
await advance(1200); // a espera
|
||||
await advance(60); // pacote carregado, geometria no próximo intervalo ocioso
|
||||
await advance(60); // Canvas montado
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
mockScenes.length = 0;
|
||||
mockMedia([]);
|
||||
mockPrepare.mockClear();
|
||||
mockMedia();
|
||||
mockNavigator({
|
||||
hardwareConcurrency: 8,
|
||||
deviceMemory: undefined,
|
||||
@@ -81,18 +103,50 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe("HeroField (ambient background)", () => {
|
||||
it("mounts the WebGL scene only after the page is idle, on a capable desktop", () => {
|
||||
it("mounts the WebGL scene only after the page is idle, on a capable desktop", async () => {
|
||||
render(<Hero />);
|
||||
expect(screen.queryByTestId("scene")).toBeNull();
|
||||
idle();
|
||||
|
||||
await idle();
|
||||
|
||||
expect(screen.getByTestId("scene")).toBeInTheDocument();
|
||||
// o IntersectionObserver do jest.setup avisa que o hero está à vista: o desenho está liberado
|
||||
expect(mockScenes.at(-1)?.visible).toBe(true);
|
||||
});
|
||||
|
||||
it("is decorative: hidden from assistive technology and with no labels over the 3D", () => {
|
||||
it("brings the geometry in before the Canvas, in a task of its own", async () => {
|
||||
render(<Hero />);
|
||||
|
||||
await advance(1200);
|
||||
await advance(10); // o pacote carregou, mas o intervalo ocioso da geometria ainda não veio
|
||||
expect(mockPrepare).not.toHaveBeenCalled();
|
||||
expect(screen.queryByTestId("scene")).toBeNull();
|
||||
|
||||
await advance(60);
|
||||
expect(mockPrepare).toHaveBeenCalledTimes(1);
|
||||
expect(screen.queryByTestId("scene")).toBeNull();
|
||||
|
||||
await advance(60);
|
||||
expect(screen.getByTestId("scene")).toBeInTheDocument();
|
||||
expect(mockPrepare).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not wait for the idle time once the visitor interacts", async () => {
|
||||
render(<Hero />);
|
||||
await advance(100);
|
||||
expect(screen.queryByTestId("scene")).toBeNull();
|
||||
|
||||
act(() => {
|
||||
window.dispatchEvent(new Event("pointermove"));
|
||||
});
|
||||
await advance(200);
|
||||
|
||||
expect(screen.getByTestId("scene")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("is decorative: hidden from assistive technology and with no labels over the 3D", async () => {
|
||||
const { container } = render(<Hero />);
|
||||
idle();
|
||||
await idle();
|
||||
const field = container.querySelector(".hero-field");
|
||||
expect(field).toHaveAttribute("aria-hidden", "true");
|
||||
expect(container.querySelector(".hero-tag")).toBeNull();
|
||||
@@ -102,7 +156,7 @@ describe("HeroField (ambient background)", () => {
|
||||
});
|
||||
|
||||
it.each([
|
||||
["a touch pointer", () => mockMedia(["(pointer: coarse)"])],
|
||||
["a touch pointer", () => mockMedia([DIVE_QUERY, "(pointer: coarse)"])],
|
||||
["reduced motion", () => mockMedia(["(prefers-reduced-motion: reduce)"])],
|
||||
["the data saver", () => mockNavigator({ connection: { saveData: true } })],
|
||||
["four cores", () => mockNavigator({ hardwareConcurrency: 4 })],
|
||||
@@ -115,26 +169,33 @@ describe("HeroField (ambient background)", () => {
|
||||
},
|
||||
],
|
||||
])(
|
||||
"keeps the light static background (no WebGL) with %s",
|
||||
(_label, arrange) => {
|
||||
"keeps the light static background (no WebGL, no dive, no scroll work) with %s",
|
||||
async (_label, arrange) => {
|
||||
arrange();
|
||||
const { container } = render(<Hero />);
|
||||
idle();
|
||||
|
||||
await idle();
|
||||
|
||||
expect(screen.queryByTestId("scene")).toBeNull();
|
||||
expect(mockScenes).toHaveLength(0);
|
||||
expect(container.querySelector(".hero-field")).not.toHaveAttribute(
|
||||
"data-ready",
|
||||
);
|
||||
// o hero é o palco de 100svh: sem marca de mergulho, sem variável de rolagem
|
||||
expect(section()).not.toHaveAttribute("data-dive");
|
||||
expect(scrollWritesProgress()).toBe(false);
|
||||
},
|
||||
);
|
||||
|
||||
it("follows the hero scroll with --hero-p and --hero-open, and stops drawing once the dive is over", () => {
|
||||
it("marks the hero for the dive (the CSS then gives it its scroll block) and follows the hero scroll with --hero-p and --hero-open", async () => {
|
||||
const { container } = render(<Hero />);
|
||||
idle();
|
||||
const section = container.querySelector(".hero") as HTMLElement;
|
||||
const hero = container.querySelector(".hero") as HTMLElement;
|
||||
expect(hero).toHaveAttribute("data-dive");
|
||||
await idle();
|
||||
// 3000 px de hero: 800 de palco, 800 da tela de Projetos subindo e 1400 de mergulho
|
||||
let top = 0;
|
||||
section.getBoundingClientRect = () =>
|
||||
({ top, height: 2000, bottom: top + 2000 }) as DOMRect;
|
||||
hero.getBoundingClientRect = () =>
|
||||
({ top, height: 3000, bottom: top + 3000 }) as DOMRect;
|
||||
Object.defineProperty(window, "innerHeight", {
|
||||
configurable: true,
|
||||
value: 800,
|
||||
@@ -150,30 +211,46 @@ describe("HeroField (ambient background)", () => {
|
||||
scroll(0);
|
||||
expect(mockScenes.at(-1)?.progressRef.current).toBe(0);
|
||||
|
||||
scroll(600); // meio do mergulho (1200 px de trilho)
|
||||
expect(Number(section.style.getPropertyValue("--hero-p"))).toBeCloseTo(0.5);
|
||||
scroll(700); // meio do mergulho (1400 px de trilho)
|
||||
expect(Number(hero.style.getPropertyValue("--hero-p"))).toBeCloseTo(0.5);
|
||||
// a máscara já se abriu por completo: o fundo aparece inteiro
|
||||
expect(Number(section.style.getPropertyValue("--hero-open"))).toBe(1);
|
||||
expect(Number(hero.style.getPropertyValue("--hero-open"))).toBe(1);
|
||||
|
||||
scroll(1200);
|
||||
expect(Number(section.style.getPropertyValue("--hero-p"))).toBe(1);
|
||||
scroll(1400);
|
||||
expect(Number(hero.style.getPropertyValue("--hero-p"))).toBe(1);
|
||||
expect(mockScenes.at(-1)?.visible).toBe(false);
|
||||
});
|
||||
|
||||
it("reveals the scene (data-ready) once it has drawn its first frames", () => {
|
||||
it("does not start the dive on a short screen (the CSS does not apply there), but the ambient scene still runs", async () => {
|
||||
mockMedia([]);
|
||||
|
||||
render(<Hero />);
|
||||
await idle();
|
||||
|
||||
expect(section()).not.toHaveAttribute("data-dive");
|
||||
expect(scrollWritesProgress()).toBe(false);
|
||||
expect(screen.getByTestId("scene")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("reveals the scene (data-ready) once it has drawn its first frames, and goes back to the plain hero if it fails", async () => {
|
||||
const { container } = render(<Hero />);
|
||||
idle();
|
||||
await idle();
|
||||
expect(container.querySelector(".hero-field")).not.toHaveAttribute(
|
||||
"data-ready",
|
||||
);
|
||||
|
||||
act(() => mockScenes.at(-1)?.onReady());
|
||||
expect(container.querySelector(".hero-field")).toHaveAttribute(
|
||||
"data-ready",
|
||||
);
|
||||
|
||||
act(() => mockScenes.at(-1)?.onError());
|
||||
expect(container.querySelector(".hero-field")).not.toHaveAttribute(
|
||||
"data-ready",
|
||||
);
|
||||
expect(screen.queryByTestId("scene")).toBeNull();
|
||||
// de volta ao palco de 100svh: sem marca de mergulho nem variáveis de rolagem
|
||||
expect(section()).not.toHaveAttribute("data-dive");
|
||||
expect(section().style.getPropertyValue("--hero-p")).toBe("");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user