perf(movimento): sem tempo morto, vídeos regravados e 3D sem React Three Fiber
Legenda do mergulho até Projetos cobrir a cena; transição de página sem blur e com fundo escuro; foco no conteúdo ao trocar de página; listas com marcador e dois pesos de título; vídeos da SEV e da SC Plus regravados sem quadros brancos; scrub a 12-15 fps (-30%); vitrine com uma parada de Tab e setas, contador no celular e CSS sob demanda; âncoras num módulo só (fragment-nav); FramePulse com setInterval; CSS morto removido.
This commit is contained in:
1 parent
f8545cd442
commit
6ac87824f3
68 files changed
+5735
-3300
No files matched your search
@@ -1,51 +1,241 @@
|
||||
import { render } from "@testing-library/react";
|
||||
import { act, render } from "@testing-library/react";
|
||||
import HeroFieldScene from "./HeroFieldScene";
|
||||
|
||||
type CanvasProps = {
|
||||
dpr?: [number, number];
|
||||
frameloop?: string;
|
||||
gl?: Record<string, unknown>;
|
||||
};
|
||||
const mockCanvas: CanvasProps[] = [];
|
||||
/*
|
||||
* O componente que monta a cena: cria o renderizador do Three.js (sem React Three Fiber), põe o canvas na página,
|
||||
* liga a batida só com o hero à vista e solta tudo ao sair. O WebGL do jsdom não existe: o renderizador é um dublê.
|
||||
*/
|
||||
|
||||
jest.mock("@react-three/fiber", () => ({
|
||||
Canvas: (props: CanvasProps) => {
|
||||
mockCanvas.push(props);
|
||||
return null;
|
||||
},
|
||||
useFrame: jest.fn(),
|
||||
useThree: jest.fn(),
|
||||
type RendererOptions = {
|
||||
alpha?: boolean;
|
||||
antialias?: boolean;
|
||||
powerPreference?: string;
|
||||
};
|
||||
const mockRenderers: MockRenderer[] = [];
|
||||
let mockFailToCreate = false;
|
||||
|
||||
class MockRenderer {
|
||||
domElement = document.createElement("canvas");
|
||||
pixelRatio = 1;
|
||||
options: RendererOptions;
|
||||
render = jest.fn();
|
||||
dispose = jest.fn();
|
||||
forceContextLoss = jest.fn();
|
||||
setSize = jest.fn();
|
||||
setPixelRatio = (value: number) => {
|
||||
this.pixelRatio = value;
|
||||
};
|
||||
getPixelRatio = () => this.pixelRatio;
|
||||
constructor(options: RendererOptions) {
|
||||
if (mockFailToCreate) throw new Error("sem WebGL");
|
||||
this.options = options;
|
||||
mockRenderers.push(this);
|
||||
}
|
||||
}
|
||||
|
||||
// a classe é criada só quando a cena pede o renderizador (o mock é içado antes da declaração dela)
|
||||
jest.mock("three", () => ({
|
||||
...jest.requireActual("three"),
|
||||
WebGLRenderer: jest.fn(
|
||||
(options: RendererOptions) => new MockRenderer(options),
|
||||
),
|
||||
}));
|
||||
|
||||
const props = () => ({
|
||||
progressRef: { current: 0 },
|
||||
visible: false,
|
||||
onReady: jest.fn(),
|
||||
onError: jest.fn(),
|
||||
});
|
||||
|
||||
function setDevicePixelRatio(value: number) {
|
||||
Object.defineProperty(window, "devicePixelRatio", {
|
||||
configurable: true,
|
||||
value,
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
mockRenderers.length = 0;
|
||||
mockFailToCreate = false;
|
||||
setDevicePixelRatio(1);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
describe("HeroFieldScene (ambient 3D)", () => {
|
||||
beforeEach(() => {
|
||||
mockCanvas.length = 0;
|
||||
it("creates a transparent renderer without antialiasing, on the high-performance GPU, and puts its canvas in the page", () => {
|
||||
const { container } = render(<HeroFieldScene {...props()} />);
|
||||
|
||||
expect(mockRenderers).toHaveLength(1);
|
||||
expect(mockRenderers[0].options).toMatchObject({
|
||||
alpha: true,
|
||||
antialias: false,
|
||||
powerPreference: "high-performance",
|
||||
});
|
||||
const canvas = container.querySelector("canvas");
|
||||
expect(canvas).toBe(mockRenderers[0].domElement);
|
||||
expect(canvas).toHaveAttribute("aria-hidden", "true");
|
||||
expect(container.firstElementChild).toHaveAttribute("aria-hidden", "true");
|
||||
});
|
||||
|
||||
it("draws on demand, never on a fixed 60 fps loop, and caps the pixel ratio at 1.5", () => {
|
||||
render(
|
||||
<HeroFieldScene
|
||||
progressRef={{ current: 0 }}
|
||||
visible
|
||||
onReady={() => {}}
|
||||
onError={() => {}}
|
||||
/>,
|
||||
it.each([
|
||||
[0.5, 1],
|
||||
[1, 1],
|
||||
[1.25, 1.25],
|
||||
[2, 1.5],
|
||||
[3, 1.5],
|
||||
])("caps the pixel ratio at 1.5 (device %s -> %s)", (device, expected) => {
|
||||
setDevicePixelRatio(device);
|
||||
render(<HeroFieldScene {...props()} />);
|
||||
expect(mockRenderers[0].pixelRatio).toBe(expected);
|
||||
});
|
||||
|
||||
it("draws on demand: a frame is asked for, never a fixed 60 fps loop, and nothing keeps running once settled", () => {
|
||||
const frames = jest.spyOn(window, "requestAnimationFrame");
|
||||
render(<HeroFieldScene {...props()} />);
|
||||
expect(frames).toHaveBeenCalledTimes(1);
|
||||
|
||||
// a entrada ainda corre: cada quadro pede o seguinte, até assentar (2,6 s de entrada)
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(5000);
|
||||
});
|
||||
const settled = frames.mock.calls.length;
|
||||
expect(settled).toBeGreaterThan(2);
|
||||
expect(settled).toBeLessThan(5000 / 16);
|
||||
|
||||
// hero fora de cena e tudo assentado: nenhum quadro a mais
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(5000);
|
||||
});
|
||||
expect(frames.mock.calls.length).toBe(settled);
|
||||
expect(mockRenderers[0].render.mock.calls.length).toBeGreaterThan(2);
|
||||
});
|
||||
|
||||
it("tells the page it is ready after the second frame", () => {
|
||||
const scene = props();
|
||||
render(<HeroFieldScene {...scene} />);
|
||||
expect(scene.onReady).not.toHaveBeenCalled();
|
||||
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(100);
|
||||
});
|
||||
|
||||
expect(scene.onReady).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("starts the 30 fps pulse only while the hero is visible, and stops it when it leaves", () => {
|
||||
const frames = jest.spyOn(window, "requestAnimationFrame");
|
||||
const scene = props();
|
||||
const { rerender } = render(<HeroFieldScene {...scene} visible={false} />);
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(6000);
|
||||
});
|
||||
const hidden = frames.mock.calls.length;
|
||||
expect(jest.getTimerCount()).toBe(0);
|
||||
|
||||
rerender(<HeroFieldScene {...scene} visible />);
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(20); // o quadro que a batida pediu sai
|
||||
});
|
||||
expect(jest.getTimerCount()).toBe(1); // um setInterval, nenhum segundo laço
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(980);
|
||||
});
|
||||
const shown = frames.mock.calls.length - hidden;
|
||||
expect(shown).toBeGreaterThan(20);
|
||||
expect(shown).toBeLessThanOrEqual(34);
|
||||
|
||||
rerender(<HeroFieldScene {...scene} visible={false} />);
|
||||
const stopped = frames.mock.calls.length;
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(2000);
|
||||
});
|
||||
expect(frames.mock.calls.length).toBe(stopped);
|
||||
expect(jest.getTimerCount()).toBe(0);
|
||||
});
|
||||
|
||||
it("releases the context, the canvas and the timers when it unmounts", () => {
|
||||
const scene = props();
|
||||
const { container, unmount } = render(
|
||||
<HeroFieldScene {...scene} visible />,
|
||||
);
|
||||
const props = mockCanvas.at(-1);
|
||||
expect(props?.frameloop).toBe("demand");
|
||||
expect(props?.dpr).toEqual([1, 1.5]);
|
||||
expect(props?.gl).toMatchObject({ alpha: true, antialias: false });
|
||||
expect(container.querySelector("canvas")).not.toBeNull();
|
||||
|
||||
unmount();
|
||||
|
||||
expect(mockRenderers[0].dispose).toHaveBeenCalledTimes(1);
|
||||
expect(mockRenderers[0].forceContextLoss).toHaveBeenCalledTimes(1);
|
||||
expect(mockRenderers[0].domElement.isConnected).toBe(false);
|
||||
expect(jest.getTimerCount()).toBe(0);
|
||||
const drawn = mockRenderers[0].render.mock.calls.length;
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(1000);
|
||||
});
|
||||
expect(mockRenderers[0].render.mock.calls.length).toBe(drawn);
|
||||
});
|
||||
|
||||
it("keeps the same on-demand loop when the hero is off screen (the pulse is what stops)", () => {
|
||||
render(
|
||||
it("does not rebuild the scene when the parent passes new callbacks, and calls the newest ones", () => {
|
||||
const first = props();
|
||||
const { rerender } = render(<HeroFieldScene {...first} />);
|
||||
const second = props();
|
||||
rerender(
|
||||
<HeroFieldScene
|
||||
progressRef={{ current: 0 }}
|
||||
progressRef={first.progressRef}
|
||||
visible={false}
|
||||
onReady={() => {}}
|
||||
onError={() => {}}
|
||||
onReady={second.onReady}
|
||||
onError={second.onError}
|
||||
/>,
|
||||
);
|
||||
expect(mockCanvas.at(-1)?.frameloop).toBe("demand");
|
||||
expect(mockRenderers).toHaveLength(1);
|
||||
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(100);
|
||||
});
|
||||
|
||||
expect(first.onReady).not.toHaveBeenCalled();
|
||||
expect(second.onReady).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("tells the page and renders nothing but the empty host when WebGL cannot be created", () => {
|
||||
mockFailToCreate = true;
|
||||
const scene = props();
|
||||
|
||||
const { container } = render(<HeroFieldScene {...scene} />);
|
||||
|
||||
expect(scene.onError).toHaveBeenCalledTimes(1);
|
||||
expect(container.querySelector("canvas")).toBeNull();
|
||||
});
|
||||
|
||||
it.each([
|
||||
[0, 1], // aparelho que não informa a proporção: 1
|
||||
[0.5, 1], // nunca abaixo de 1
|
||||
[1.25, 1.25],
|
||||
[3, 1.5], // nunca acima de 1,5: cada ponto custa fragmentos e o fundo é ambiente
|
||||
])(
|
||||
"draws at a pixel ratio of %p -> the renderer gets %p",
|
||||
(device, expected) => {
|
||||
setDevicePixelRatio(device);
|
||||
|
||||
render(<HeroFieldScene {...props()} />);
|
||||
|
||||
expect(mockRenderers[0].pixelRatio).toBe(expected);
|
||||
},
|
||||
);
|
||||
|
||||
it("tells the page when the WebGL context is lost after mounting, through the newest callback", () => {
|
||||
const scene = props();
|
||||
render(<HeroFieldScene {...scene} />);
|
||||
|
||||
act(() => {
|
||||
mockRenderers[0].domElement.dispatchEvent(
|
||||
new Event("webglcontextlost", { cancelable: true }),
|
||||
);
|
||||
});
|
||||
|
||||
expect(scene.onError).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user