Files
pdfme/packages/ui/__tests__/hooks.test.tsx
Kyohei Fukuda ed0ee1cd6b fix(ui): prevent infinite loading spinner and mobile crashes in Designer (#1549)
On narrow viewports (< ~445px) the open right sidebar consumed more width
than the screen provides, producing a negative base scale that locked the
Designer on the loading spinner forever. The sidebar now auto-closes when
it would not leave usable canvas width, the canvas width is clamped to a
non-negative value, and the base scale is kept strictly positive.

Real-PDF templates also crashed mobile Safari because page backgrounds were
rendered at the maxZoom scale (5x with the playground's maxZoom: 500),
creating canvases above iOS Safari's ~16.7M pixel limit. pdf2img now accepts
a maxCanvasPixels option that clamps the render scale per page, and the UI
caps background rendering at 4096x4096 pixels. arrayBufferToBase64 also
encodes in 32KB chunks instead of byte-by-byte concatenation to reduce peak
memory while encoding multi-megabyte background images.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-10 16:07:32 +09:00

259 lines
7.5 KiB
TypeScript

import React from 'react';
import { act, renderHook, waitFor } from '@testing-library/react';
import { BLANK_PDF, PAGE_SIZE_PRESETS, type SchemaForUI, type Template } from '@pdfme/common';
import * as converter from '@pdfme/converter';
import * as helper from '../src/helper';
import { useInitEvents, useScrollPageCursor, useUIPreProcessor } from '../src/hooks';
vi.mock('@pdfme/converter', () => ({
pdf2size: vi.fn(),
pdf2img: vi.fn(),
}));
const createTemplate = (): Template => ({
basePdf: 'data:application/pdf;base64,AA==',
schemas: [[]],
});
test('useUIPreProcessor stores converter failures without unhandled rejections', async () => {
vi.spyOn(console, 'error').mockImplementation(() => undefined);
const pdf2sizeMock = vi.mocked(converter.pdf2size);
const pdf2imgMock = vi.mocked(converter.pdf2img);
const template = createTemplate();
const size = { width: 1200, height: 1200 };
pdf2sizeMock.mockRejectedValue(new Error('corrupt basePdf'));
const { result } = renderHook(() =>
useUIPreProcessor({
template,
size,
zoomLevel: 1,
maxZoom: 1,
}),
);
await waitFor(() => expect(result.current.error).toBeInstanceOf(Error));
expect(result.current.error?.message).toContain('corrupt basePdf');
expect(pdf2imgMock).toHaveBeenCalledTimes(1);
});
test('useUIPreProcessor runs pdf sizing and imaging in parallel with isolated buffers', async () => {
const pdf2sizeMock = vi.mocked(converter.pdf2size);
const pdf2imgMock = vi.mocked(converter.pdf2img);
const template = createTemplate();
const size = { width: 1200, height: 1200 };
let resolvePdf2size!: (value: Array<{ width: number; height: number }>) => void;
const pdf2sizePromise = new Promise<Array<{ width: number; height: number }>>((resolve) => {
resolvePdf2size = resolve;
});
pdf2sizeMock.mockImplementation(() => pdf2sizePromise);
pdf2imgMock.mockResolvedValueOnce([new Uint8Array([137, 80, 78, 71]).buffer]);
const { result } = renderHook(() =>
useUIPreProcessor({
template,
size,
zoomLevel: 1,
maxZoom: 1,
}),
);
await waitFor(() => expect(pdf2imgMock).toHaveBeenCalled());
expect(pdf2sizeMock).toHaveBeenCalled();
expect(pdf2sizeMock.mock.calls[0][0]).not.toBe(pdf2imgMock.mock.calls[0][0]);
resolvePdf2size([PAGE_SIZE_PRESETS.A4]);
await waitFor(() => expect(result.current.pageSizes).toEqual([PAGE_SIZE_PRESETS.A4]));
});
test('useUIPreProcessor passes a canvas pixel budget to pdf2img', async () => {
const pdf2sizeMock = vi.mocked(converter.pdf2size);
const pdf2imgMock = vi.mocked(converter.pdf2img);
pdf2sizeMock.mockResolvedValue([PAGE_SIZE_PRESETS.A4]);
pdf2imgMock.mockResolvedValue([new Uint8Array([137, 80, 78, 71]).buffer]);
const { result } = renderHook(() =>
useUIPreProcessor({
template: createTemplate(),
size: { width: 1200, height: 1200 },
zoomLevel: 1,
maxZoom: 5,
}),
);
await waitFor(() => expect(result.current.backgrounds.length).toBe(1));
expect(pdf2imgMock).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ scale: 5, maxCanvasPixels: 4096 * 4096 }),
);
});
test('useUIPreProcessor keeps a positive base scale on narrow viewports', async () => {
const pdf2sizeMock = vi.mocked(converter.pdf2size);
const pdf2imgMock = vi.mocked(converter.pdf2img);
pdf2sizeMock.mockResolvedValue([PAGE_SIZE_PRESETS.A4]);
pdf2imgMock.mockResolvedValue([new Uint8Array([137, 80, 78, 71]).buffer]);
// Simulates a smartphone viewport where the open sidebars consume more
// width than the screen provides.
const { result } = renderHook(() =>
useUIPreProcessor({
template: createTemplate(),
size: { width: -55, height: 600 },
zoomLevel: 1,
maxZoom: 2,
}),
);
await waitFor(() => expect(result.current.backgrounds.length).toBe(1));
expect(result.current.baseScale).toBeGreaterThan(0);
expect(result.current.scale).toBeGreaterThan(0);
});
test('useInitEvents paste ignores missing DOM nodes instead of storing null active elements', () => {
vi.useFakeTimers();
const schema = {
id: 'field-1',
name: 'field1',
type: 'text',
content: 'value',
position: { x: 0, y: 0 },
width: 100,
height: 20,
} as SchemaForUI;
const activeElement = document.createElement('div');
activeElement.id = schema.id;
const template: Template = {
basePdf: BLANK_PDF,
schemas: [[schema]],
};
const pageSizes = [PAGE_SIZE_PRESETS.A4];
const schemasList = [[schema]];
const changeSchemas = vi.fn();
const commitSchemas = vi.fn();
const removeSchemas = vi.fn();
const onSaveTemplate = vi.fn();
const setSchemasList = vi.fn();
const onEdit = vi.fn();
const onEditEnd = vi.fn();
const past = { current: [] as SchemaForUI[][] };
const future = { current: [] as SchemaForUI[][] };
let shortcuts: Parameters<typeof helper.initShortCuts>[0] | undefined;
vi.spyOn(helper, 'initShortCuts').mockImplementation((arg) => {
shortcuts = arg;
});
vi.spyOn(helper, 'destroyShortCuts').mockImplementation(() => undefined);
vi.spyOn(helper, 'uuid').mockReturnValue('pasted-field');
vi.spyOn(document, 'getElementById').mockReturnValue(null);
renderHook(() =>
useInitEvents({
pageCursor: 0,
pageSizes,
activeElements: [activeElement],
template,
schemasList,
changeSchemas,
commitSchemas,
removeSchemas,
onSaveTemplate,
past,
future,
setSchemasList,
onEdit,
onEditEnd,
}),
);
expect(shortcuts).toBeDefined();
act(() => {
shortcuts!.copy();
shortcuts!.paste();
vi.runAllTimers();
});
expect(commitSchemas).toHaveBeenCalledTimes(1);
expect(onEdit).toHaveBeenCalledWith([]);
vi.useRealTimers();
});
const mockRect = ({
left,
top,
width,
height,
}: {
left: number;
top: number;
width: number;
height: number;
}) =>
({
left,
top,
right: left + width,
bottom: top + height,
width,
height,
x: left,
y: top,
toJSON: () => undefined,
}) as DOMRect;
test('useScrollPageCursor selects the page with the largest visible area', async () => {
const container = document.createElement('div');
const firstPaper = document.createElement('div');
const secondPaper = document.createElement('div');
const containerRef = { current: container };
const paperRefs = { current: [firstPaper, secondPaper] };
const onChangePageCursor = vi.fn();
let firstPaperRect = mockRect({ left: 0, top: -20, width: 100, height: 80 });
let secondPaperRect = mockRect({ left: 0, top: 60, width: 100, height: 100 });
vi.spyOn(container, 'getBoundingClientRect').mockReturnValue(
mockRect({ left: 0, top: 0, width: 100, height: 100 }),
);
vi.spyOn(firstPaper, 'getBoundingClientRect').mockImplementation(() => firstPaperRect);
vi.spyOn(secondPaper, 'getBoundingClientRect').mockImplementation(() => secondPaperRect);
renderHook(() =>
useScrollPageCursor({
ref: containerRef,
paperRefs,
pageSizes: [PAGE_SIZE_PRESETS.A4, PAGE_SIZE_PRESETS.A4],
scale: 2.5,
pageCursor: 0,
onChangePageCursor,
}),
);
await act(async () => {
await new Promise((resolve) => window.requestAnimationFrame(resolve));
});
expect(onChangePageCursor).not.toHaveBeenCalled();
firstPaperRect = mockRect({ left: 0, top: -60, width: 100, height: 100 });
secondPaperRect = mockRect({ left: 0, top: 40, width: 100, height: 100 });
act(() => {
container.dispatchEvent(new Event('scroll'));
});
expect(onChangePageCursor).toHaveBeenCalledWith(1);
});