Skip to content

Commit 22d488f

Browse files
muhammad-ali-ejaseemjaskppraveen-formidoclaude
authored
UN-4502 [FEAT] Add the v1 PDF loader, document hook and viewer states (#2404)
* feat(frontend): v1 scaffold, strict TypeScript and import boundary (UN-4270) Adds frontend/src/v1/ as an isolated tree for the v1 frontend (P0-T01): - Folder taxonomy from the spec, a public entry (src/v1/index.ts, imported by v0 as `@v1`) and a cloud overlay placeholder. - src/v1/tsconfig.json: strict, no JS, unused locals/params are errors, `@v1` alias only. v0's tsconfig/jsconfig exclude src/v1. - Biome overrides for the two-way import boundary and v1 bans (no `../`, `@/`, prop-types, antd, component CSS; noExplicitAny). - scripts/check-v1.mjs for what Biome cannot express: TypeScript-only files, the folder taxonomy and import contract, PropTypes, `ant-`. - typecheck:v1, lint:v1 and test:v1 scripts; v0 scripts are unchanged. - test-fixtures/v1-boundary/ proves each rule fails and passes as specified, run with node --test so the v0 vitest suite never sees it. - .gitignore re-includes src/v1/lib/ (the Python `lib/` rule matched it). v0 build output, test counts, typecheck and Biome results are unchanged. * fix(frontend): close v1 guardrail bypasses found in self-review (UN-4270) check-v1.mjs: - Scan comments with a string/template-aware tokenizer; a `/*` or `//` inside a string or line comment no longer hides the imports after it. - Read template-literal specifiers, import.meta.glob and `import(..., opts)`. - Flag specifiers that leave src/v1 (`/`, `..`, `@/`, `src/`) itself, not only via Biome, which skips template literals. - Check `@import`/`url()` and `ant-` in styles/ CSS; report symlinks; catch built `ant-` names; treat .test/.spec .mts/.cts as test files; ignore .DS_Store; drop the unused --root option. biome.json: also ban `..`, `./../**`, `/**`, `antd/**`, `prop-types/**`. tsconfig: skipLibCheck off so v1's own .d.ts files are checked. .gitignore: re-include lib/ at any depth under src/v1. Fixtures: a generated matrix over every folder-contract pair, every import form, every outside spelling, a symlink case, and cases for each new rule; tsc cases assert error codes; output matching excludes the command text and spawn errors fail loudly. Comment and README corrections. * feat(frontend): run v1 checks in CI and tighten what pages may import (UN-4270) - ci-frontend-lint: the typecheck job also runs `typecheck:v1` and `lint:v1`, so the strict v1 tsconfig and the scripts/check-v1.mjs checks (folder contract, TypeScript-only files, PropTypes, `ant-`) gate PRs now rather than from P0-T10. - Pages may no longer import app/, test/ or the v1 entry, only the shared folders (components, shell, services, stores, hooks, lib, styles, plugins). Test files in a page may still import test/. Contract table, README and the fixture matrix updated together. * refactor(frontend): address SonarCloud findings on the v1 guardrails (UN-4270) check-v1.mjs: split the comment scanner into small helpers (cognitive complexity 40 -> under 15), drop the nested ternary and nested template literal, use String.raw for the import regexes, keep CLI flags in a Set. Fixture runner: - Cases and the expected folder contract are parsed text tables instead of repeated object literals (the 10% duplication). - Sorts use an explicit localeCompare comparator. - The contract matrix is built by a helper. - Spawns /bin/sh by absolute path and no longer needs `git init` (Biome only needs the ignore file). The git check-ignore call keeps a PATH lookup, with the reason and a NOSONAR marker. Fixture cases: re-exports rewritten as small functions and read-only props, without changing what each case exercises. * refactor(frontend): split the contract-matrix builder to cut its complexity (UN-4270) * fix(frontend): check every glob pattern and v0 -> v1 imports Biome cannot see (UN-4270) Addresses Greptile review on #2380: - import.meta.glob([...]): every pattern in the array is checked, not only the first, so an allowed first pattern no longer hides a forbidden one. - check-v1 --code also scans v0 (src/ outside v1, including the cloud src/plugins overlay) for template-literal imports, globs, require(), @/v1, src/v1, relative paths and CSS @import that reach into v1 other than through `@v1`. Biome still covers plain imports. Fixtures: Greptile's example as a case, a glob array with a forbidden later pattern, and a generated test of every v0 -> v1 form (denied and allowed). The v0 scan reports nothing on the current tree. * UN-4299 [MISC] Turn on skipLibCheck for v1: vitest 3.2 and @types/chai 5.2 both declare containSubset Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * UN-4299 [FEAT] Add the v1 theme model: three modes, follow-system and per-browser preference Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * UN-4299 [FEAT] Add the v1 portal container and the theme preference store Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * UN-4299 [FEAT] Add the v1 token source, the generator and tokens.css in three modes Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * UN-4299 [FIX] Prefix the spacing utilities in the token table so they match the v1 Tailwind root Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * UN-4299 [FEAT] Add cn() configured for the v1 utility prefix Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * UN-4299 [FIX] Pin the v1 prefix in cn() with a test that fails without it Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * UN-4299 [FIX] Use "ui" as the v1 utility prefix: Tailwind accepts lowercase letters only Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * UN-4299 [FEAT] Add the prefixed v1 Tailwind root with fonts, modes, theme mapping and the focus utility Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * UN-4299 [FIX] Exclude the generated tokens.css from Biome formatting so biome ci passes src/v1/styles/tokens.css is written by scripts/build-v1-tokens.mjs and checked byte-for-byte by styles/tokens.test.ts; the formatter would rewrap it and break the check. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * UN-4299 [FIX] Sort the imports in cn.ts so biome ci passes Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * UN-4299 [FIX] Make the root compile test fail on unscoped selector lists and on missing variants Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * UN-4299 [FEAT] Add ThemeProvider: the v1 root, follow-system binding and portal sync Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * UN-4299 [FIX] Apply the stored theme before first paint with useLayoutEffect Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * UN-4299 [FEAT] Enforce the v1 styling policy: raw colours, pixels, inline styles, CSS imports and !important fail lint Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * UN-4299 [FIX] Scope the styling lint to class strings and cover vh/vw arbitrary values Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * UN-4299 [FIX] Keep v0's Tailwind scan out of src/v1, the lint scripts and the fixtures v0's root auto-detected sources across the whole frontend, so the v1 tree, scripts/check-v1-styles.mjs and test-fixtures/ added 12 unprefixed utilities and 8 theme variables to v0's stylesheet. With these three @source not lines the v0 build is byte-identical to the base again (239/239 files). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * UN-4299 [FIX] Teach cn the type roles so a role and a text colour no longer cancel each other Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * UN-4299 [FIX] Reject colour literals outside class strings, named-colour arbitrary values and !important after a variant Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * UN-4299 [FIX] Keep the focus ring visible in forced-colors mode and scope variants, shadows and the root fill to v1 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * UN-4299 [FIX] Create the portal container before the mode changes so the theme test exercises the mode effect Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * UN-4299 [FIX] Document the v0 rules that reach v1, the focus-ring usage, the pixel escape and the current unprefixed-class rationale Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * UN-4299 [FIX] Name the theme storage key in the v1 README Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * UN-4299 [MISC] Name the three theme modes in the v1 README Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * UN-4502 [FEAT] Add the v1 PDF loader, usePdfDocument hook and viewer states - lib/pdf/pdfjs.ts is the only v1 import of pdf.js: the existing pdfjs-dist 3.11.174 legacy build, its worker bundled with ?url, both loaded on first use. getDocument always gets isEvalSupported: false (CVE-2024-4367), and in-memory bytes are copied before the worker transfer empties them. - Page sizes are read in parallel, bounded at 16 in flight. - hooks/usePdfDocument tracks empty, loading, password (missing or wrong), unsupported, failed (with Retry) and ready. It reopens only when the source's URL, request options or bytes change, and destroys the previous load on change and on unmount. - components/patterns/document-viewer/ViewerStatus renders every state except ready. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R7trgcAzu6jxDnit3eeozm * UN-4299 [FEAT] Let a v1 style attribute set CSS custom properties only Runtime geometry (a page's aspect ratio, a highlight's position) cannot be a token or a class. Rule S1 now accepts a style attribute whose value is an object literal with only "--name" keys, so the numbers travel through custom properties that ui: classes read (ui:aspect-(--page-aspect)); a string, a variable, a spread or a plain key still fails. Three fixtures pin it. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * UN-4299 [FIX] Make check-v1-styles.mjs executable to match its shebang pre-commit's check-shebang-scripts-are-executable fails on branches stacked on this one; check-v1.mjs already carries the bit. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * UN-4299 [FIX] Type the custom-property keys a v1 style attribute may set React's CSSProperties has no index signature for "--*" keys, so the object literal rule S1 allows fails TS2353 under strict mode. Augment it under lib/, and typecheck the passing fixture so the augmentation is exercised. Raised by Ali on the viewer stack. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * UN-4502 Keep signed URLs out of load errors and test the loader retry pdf.js puts the full URL in MissingPDFException and UnexpectedResponseException messages, so a signed URL's token reached the screen. Build those messages from the status instead. Also cover the reset that lets Retry load pdf.js again after a failed chunk. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R7trgcAzu6jxDnit3eeozm * UN-4502 Put the focus back in the password field after a wrong password Submitting swaps the prompt for the loading state, so the field lost focus and a keyboard user had to find it again. Raised by Greptile on #2404. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R7trgcAzu6jxDnit3eeozm * UN-4502 Answer passwords on the same load, keep damaged pages from failing the open From review on #2404: - A password goes to pdf.js's onPassword callback on the live loading task, so a password (right or wrong) no longer downloads the file again or starts a new worker, and Retry carries no stale password. - A page whose size cannot be read borrows a neighbour's size and is logged; only a document with no readable page fails to open. - An unknown pdf.js error shows a plain message and is logged, instead of showing pdf.js's own wording. - destroy() handles the rejection pdf.js gives when the worker is gone. - The hook's request type is OpenRequest, not a shadow of fetch's Request. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R7trgcAzu6jxDnit3eeozm * UN-4502 Load the same pdf.js build and worker as v0, not the legacy build The legacy build put a second pdf.js engine and worker next to v0's. v1 now imports pdfjs-dist and its default worker, the copy v0 already ships. Also hands the rejected destroy promise in the loader test a handler up front, so Vitest no longer reports it as an unhandled rejection while the test waits. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R7trgcAzu6jxDnit3eeozm --------- Co-authored-by: Jaseem Jas <jaseem@zipstack.com> Co-authored-by: pk-zipstack <praveen@zipstack.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 3ce4b6a commit 22d488f

7 files changed

Lines changed: 1171 additions & 0 deletions

File tree

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import { render, screen } from "@testing-library/react";
2+
import userEvent from "@testing-library/user-event";
3+
import { expect, it, vi } from "vitest";
4+
import { ViewerStatus, type ViewerStatusState } from "./ViewerStatus";
5+
6+
function setup(state: ViewerStatusState) {
7+
const onRetry = vi.fn();
8+
const onSubmitPassword = vi.fn();
9+
render(
10+
<ViewerStatus
11+
state={state}
12+
onRetry={onRetry}
13+
onSubmitPassword={onSubmitPassword}
14+
/>,
15+
);
16+
return { onRetry, onSubmitPassword };
17+
}
18+
19+
it("announces loading politely", () => {
20+
setup({ status: "loading" });
21+
expect(screen.getByRole("status").textContent).toContain("Loading document");
22+
});
23+
24+
it("shows the empty state", () => {
25+
setup({ status: "empty" });
26+
expect(screen.getByText("No document")).toBeTruthy();
27+
});
28+
29+
it("explains an unsupported file without offering Retry", () => {
30+
setup({ status: "unsupported" });
31+
expect(screen.getByRole("alert").textContent).toContain("can't be previewed");
32+
expect(screen.queryByRole("button")).toBeNull();
33+
});
34+
35+
it("shows the failure message and retries on click", async () => {
36+
const { onRetry } = setup({ status: "failed", message: "Network error" });
37+
expect(screen.getByRole("alert").textContent).toContain("Network error");
38+
await userEvent.click(screen.getByRole("button", { name: "Retry" }));
39+
expect(onRetry).toHaveBeenCalledTimes(1);
40+
});
41+
42+
it("submits a typed password, and not an empty one", async () => {
43+
const { onSubmitPassword } = setup({ status: "password", incorrect: false });
44+
await userEvent.click(screen.getByRole("button", { name: "Open" }));
45+
expect(onSubmitPassword).not.toHaveBeenCalled();
46+
47+
await userEvent.type(screen.getByLabelText("Password"), "secret{Enter}");
48+
expect(onSubmitPassword).toHaveBeenCalledWith("secret");
49+
});
50+
51+
it("marks a wrong password on the field", () => {
52+
setup({ status: "password", incorrect: true });
53+
const field = screen.getByLabelText("Password");
54+
expect(field.getAttribute("aria-invalid")).toBe("true");
55+
const description = field.getAttribute("aria-describedby") ?? "";
56+
expect(document.getElementById(description)?.textContent).toBe(
57+
"That password is incorrect.",
58+
);
59+
});
60+
61+
it("puts the focus back in the field after a wrong password, not before", () => {
62+
const show = (state: ViewerStatusState) => (
63+
<ViewerStatus state={state} onRetry={vi.fn()} onSubmitPassword={vi.fn()} />
64+
);
65+
const { rerender } = render(show({ status: "password", incorrect: false }));
66+
expect(document.activeElement).not.toBe(screen.getByLabelText("Password"));
67+
// Submitting shows the loading state, then the prompt comes back.
68+
rerender(show({ status: "loading" }));
69+
rerender(show({ status: "password", incorrect: true }));
70+
expect(document.activeElement).toBe(screen.getByLabelText("Password"));
71+
});
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
import type { PdfDocumentState } from "@v1/hooks/usePdfDocument";
2+
import { type FormEvent, useEffect, useId, useRef, useState } from "react";
3+
4+
/** Every state except `ready`, which the page list renders instead. */
5+
export type ViewerStatusState = Exclude<PdfDocumentState, { status: "ready" }>;
6+
7+
export interface ViewerStatusProps {
8+
state: ViewerStatusState;
9+
onRetry: () => void;
10+
onSubmitPassword: (password: string) => void;
11+
}
12+
13+
const panel =
14+
"ui:flex ui:h-full ui:min-h-48 ui:flex-col ui:items-center ui:justify-center ui:gap-3 ui:p-6 ui:text-center ui:text-body ui:text-muted-foreground";
15+
const title = "ui:text-section-title ui:text-foreground";
16+
const button =
17+
"ui:inline-flex ui:items-center ui:justify-center ui:rounded-md ui:bg-primary ui:px-4 ui:py-2 ui:text-body ui:text-primary-foreground ui:focus-visible:focus-ring";
18+
19+
/** What the viewer shows while a document loads, fails, or needs a password. */
20+
export function ViewerStatus({
21+
state,
22+
onRetry,
23+
onSubmitPassword,
24+
}: ViewerStatusProps) {
25+
switch (state.status) {
26+
case "loading":
27+
return (
28+
<output className={panel} aria-live="polite">
29+
Loading document…
30+
</output>
31+
);
32+
case "empty":
33+
return (
34+
<div className={panel}>
35+
<p className={title}>No document</p>
36+
<p>There is nothing to show yet.</p>
37+
</div>
38+
);
39+
case "unsupported":
40+
return (
41+
<div className={panel} role="alert">
42+
<p className={title}>This file can't be previewed</p>
43+
<p>It isn't a PDF, or the PDF is damaged.</p>
44+
</div>
45+
);
46+
case "failed":
47+
return (
48+
<div className={panel} role="alert">
49+
<p className={title}>The document didn't load</p>
50+
<p>{state.message}</p>
51+
<button type="button" className={button} onClick={onRetry}>
52+
Retry
53+
</button>
54+
</div>
55+
);
56+
case "password":
57+
return (
58+
<PasswordPrompt
59+
incorrect={state.incorrect}
60+
onSubmit={onSubmitPassword}
61+
/>
62+
);
63+
}
64+
}
65+
66+
function PasswordPrompt({
67+
incorrect,
68+
onSubmit,
69+
}: {
70+
incorrect: boolean;
71+
onSubmit: (password: string) => void;
72+
}) {
73+
const [password, setPassword] = useState("");
74+
const inputId = useId();
75+
const errorId = useId();
76+
const inputRef = useRef<HTMLInputElement | null>(null);
77+
// Submitting swaps this form for the loading state, so the field's focus is
78+
// lost. When the prompt comes back after a wrong password, give it back, so
79+
// a keyboard user can retype straight away. Not on first showing: the
80+
// viewer does not take focus from the page around it unasked.
81+
useEffect(() => {
82+
if (incorrect) {
83+
inputRef.current?.focus();
84+
}
85+
}, [incorrect]);
86+
const submit = (event: FormEvent<HTMLFormElement>) => {
87+
event.preventDefault();
88+
if (password !== "") {
89+
onSubmit(password);
90+
}
91+
};
92+
return (
93+
<form className={panel} onSubmit={submit}>
94+
<p className={title}>This document is password-protected</p>
95+
<label htmlFor={inputId}>Password</label>
96+
<input
97+
ref={inputRef}
98+
id={inputId}
99+
type="password"
100+
autoComplete="off"
101+
value={password}
102+
onChange={(event) => setPassword(event.target.value)}
103+
aria-invalid={incorrect}
104+
aria-describedby={incorrect ? errorId : undefined}
105+
className="ui:rounded-md ui:border ui:border-input ui:bg-background ui:px-3 ui:py-2 ui:text-body ui:text-foreground ui:focus-visible:focus-ring"
106+
/>
107+
{incorrect && (
108+
<p id={errorId} role="alert" className="ui:text-destructive">
109+
That password is incorrect.
110+
</p>
111+
)}
112+
<button type="submit" className={button}>
113+
Open
114+
</button>
115+
</form>
116+
);
117+
}
Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,195 @@
1+
import { act, renderHook } from "@testing-library/react";
2+
import type { PdfOpening, PdfOpenOptions } from "@v1/lib/pdf/pdfjs";
3+
import type { OpenedPdf, PdfSource } from "@v1/lib/pdf/types";
4+
import type { PDFDocumentProxy } from "pdfjs-dist";
5+
import { beforeEach, expect, it, vi } from "vitest";
6+
import { usePdfDocument } from "./usePdfDocument";
7+
8+
interface FakeOpening extends PdfOpening {
9+
source: PdfSource;
10+
/** Asks for a password the way pdf.js does, mid-load. */
11+
askPassword: (incorrect: boolean) => void;
12+
submitted: string[];
13+
resolve: (value: OpenedPdf) => void;
14+
reject: (error: unknown) => void;
15+
destroy: ReturnType<typeof vi.fn>;
16+
}
17+
18+
const openings: FakeOpening[] = [];
19+
20+
vi.mock("@v1/lib/pdf/pdfjs", async (importOriginal) => {
21+
const actual = await importOriginal<typeof import("@v1/lib/pdf/pdfjs")>();
22+
return {
23+
...actual,
24+
openPdfDocument: (
25+
source: PdfSource,
26+
options: PdfOpenOptions = {},
27+
): PdfOpening => {
28+
let resolve!: (value: OpenedPdf) => void;
29+
let reject!: (error: unknown) => void;
30+
const promise = new Promise<OpenedPdf>((res, rej) => {
31+
resolve = res;
32+
reject = rej;
33+
});
34+
const submitted: string[] = [];
35+
const opening: FakeOpening = {
36+
source,
37+
submitted,
38+
askPassword: (incorrect) =>
39+
options.onPassword?.({
40+
incorrect,
41+
submit: (password) => submitted.push(password),
42+
}),
43+
promise,
44+
resolve,
45+
reject,
46+
destroy: vi.fn(),
47+
};
48+
openings.push(opening);
49+
return opening;
50+
},
51+
};
52+
});
53+
54+
const document = { numPages: 2 } as unknown as PDFDocumentProxy;
55+
const pageSizes = [
56+
{ width: 612, height: 792 },
57+
{ width: 792, height: 612 },
58+
];
59+
const url = (path: string): PdfSource => ({ kind: "url", url: path });
60+
61+
function latest(): FakeOpening {
62+
const opening = openings.at(-1);
63+
if (!opening) {
64+
throw new Error("nothing was opened");
65+
}
66+
return opening;
67+
}
68+
69+
beforeEach(() => {
70+
openings.length = 0;
71+
});
72+
73+
it("is empty without a source and opens nothing", () => {
74+
const { result } = renderHook(() => usePdfDocument(null));
75+
expect(result.current.state).toEqual({ status: "empty" });
76+
expect(openings).toHaveLength(0);
77+
});
78+
79+
it("goes from loading to ready with the page sizes", async () => {
80+
const { result } = renderHook(() => usePdfDocument(url("/a.pdf")));
81+
expect(result.current.state).toEqual({ status: "loading" });
82+
await act(async () => latest().resolve({ document, pageSizes }));
83+
expect(result.current.state).toEqual({
84+
status: "ready",
85+
document,
86+
pageSizes,
87+
});
88+
});
89+
90+
it("does not reopen when re-rendered with an equal, new source object", () => {
91+
const { rerender } = renderHook(() => usePdfDocument(url("/a.pdf")));
92+
rerender();
93+
rerender();
94+
expect(openings).toHaveLength(1);
95+
});
96+
97+
it("treats a document with no pages as empty", async () => {
98+
const { result } = renderHook(() => usePdfDocument(url("/a.pdf")));
99+
await act(async () => latest().resolve({ document, pageSizes: [] }));
100+
expect(result.current.state).toEqual({ status: "empty" });
101+
});
102+
103+
it("fails with the message, and Retry opens the same source again", async () => {
104+
const { result } = renderHook(() => usePdfDocument(url("/a.pdf")));
105+
await act(async () =>
106+
latest().reject(
107+
Object.assign(new Error("Missing PDF"), { name: "MissingPDFException" }),
108+
),
109+
);
110+
expect(result.current.state).toEqual({
111+
status: "failed",
112+
message: "The document wasn't found.",
113+
});
114+
115+
act(() => result.current.retry());
116+
expect(result.current.state).toEqual({ status: "loading" });
117+
expect(openings).toHaveLength(2);
118+
expect(openings[0]?.destroy).toHaveBeenCalled();
119+
expect(latest().source).toEqual(url("/a.pdf"));
120+
});
121+
122+
it("reports a non-PDF as unsupported", async () => {
123+
const { result } = renderHook(() => usePdfDocument(url("/a.txt")));
124+
await act(async () =>
125+
latest().reject(
126+
Object.assign(new Error("Invalid PDF"), { name: "InvalidPDFException" }),
127+
),
128+
);
129+
expect(result.current.state).toEqual({ status: "unsupported" });
130+
});
131+
132+
it("asks for a password and carries on with the same load, without reopening", async () => {
133+
const { result } = renderHook(() => usePdfDocument(url("/locked.pdf")));
134+
act(() => latest().askPassword(false));
135+
expect(result.current.state).toEqual({
136+
status: "password",
137+
incorrect: false,
138+
});
139+
140+
act(() => result.current.submitPassword("wrong"));
141+
expect(latest().submitted).toEqual(["wrong"]);
142+
expect(result.current.state).toEqual({ status: "loading" });
143+
act(() => latest().askPassword(true));
144+
expect(result.current.state).toEqual({ status: "password", incorrect: true });
145+
146+
act(() => result.current.submitPassword("right"));
147+
await act(async () => latest().resolve({ document, pageSizes }));
148+
expect(result.current.state.status).toBe("ready");
149+
expect(openings).toHaveLength(1);
150+
expect(latest().submitted).toEqual(["wrong", "right"]);
151+
});
152+
153+
it("answers a password prompt once, and ignores a password nobody asked for", () => {
154+
const { result } = renderHook(() => usePdfDocument(url("/locked.pdf")));
155+
act(() => result.current.submitPassword("early"));
156+
act(() => latest().askPassword(false));
157+
act(() => result.current.submitPassword("first"));
158+
act(() => result.current.submitPassword("second"));
159+
expect(latest().submitted).toEqual(["first"]);
160+
});
161+
162+
it("destroys the old load when the source changes, and ignores its result", async () => {
163+
const { result, rerender } = renderHook(
164+
({ path }) => usePdfDocument(url(path)),
165+
{ initialProps: { path: "/a.pdf" } },
166+
);
167+
const first = latest();
168+
rerender({ path: "/b.pdf" });
169+
expect(first.destroy).toHaveBeenCalledTimes(1);
170+
expect(result.current.state).toEqual({ status: "loading" });
171+
172+
await act(async () => first.resolve({ document, pageSizes }));
173+
expect(result.current.state).toEqual({ status: "loading" });
174+
expect(latest().source).toEqual(url("/b.pdf"));
175+
});
176+
177+
it("drops a password prompt when the source changes", () => {
178+
const { result, rerender } = renderHook(
179+
({ path }) => usePdfDocument(url(path)),
180+
{ initialProps: { path: "/a.pdf" } },
181+
);
182+
const first = latest();
183+
act(() => first.askPassword(false));
184+
rerender({ path: "/b.pdf" });
185+
act(() => result.current.submitPassword("secret"));
186+
expect(first.submitted).toEqual([]);
187+
expect(latest().submitted).toEqual([]);
188+
expect(result.current.state).toEqual({ status: "loading" });
189+
});
190+
191+
it("destroys the load on unmount", () => {
192+
const { unmount } = renderHook(() => usePdfDocument(url("/a.pdf")));
193+
unmount();
194+
expect(latest().destroy).toHaveBeenCalledTimes(1);
195+
});

0 commit comments

Comments
 (0)