Candidates can download their results as a PDF: a single session report, a master report covering everything they have done, an interview feedback report, and an assessment-exercise report. They are generated client-side with @react-pdf/renderer, which lets you build a document out of React components and render it to a blob in the browser.
All four used to live in one module. Splitting them up produced three decisions worth writing down, and one deliberate non-decision that I think is the most useful part.
The split, and where shared styles stop being shared
The obvious split is one module per document. The less obvious question is what happens to the stylesheet.
/**
* Shared black and white PDF styles used by two or more report documents
* (session, master, interview, exercise). Styles used by only a single
* document live alongside that document's module instead, so a report
* surface that lazy-loads its own module does not pull in style objects it
* never renders.
*/
The rule is "two or more documents". A style used by one document lives with that document, even though it would be tidier to have every style in the stylesheet.
The reason is lazy loading. Each report surface imports only its own document module, so a style object that lives in the shared file is downloaded by every surface regardless of who uses it. StyleSheet.create objects are not large individually, but the principle is the one that matters: a "shared" file that collects things used by one caller is not shared, it is a junk drawer with a nice name, and it silently widens everyone's bundle.
So the master report's game-breakdown card styles live in the master module:
const masterStyles = StyleSheet.create({
gameBreakdownCard: { backgroundColor: '#ffffff', borderWidth: 1, borderColor: '#cccccc', borderRadius: 4, padding: 12, marginBottom: 16 },
gameRow: { flexDirection: 'row', alignItems: 'center', paddingVertical: 6, borderBottomWidth: 1, borderBottomColor: '#e5e5e5' },
// ...
});
and the page, header, brand name and title card styles, which all four use, live in the shared one.
Black and white on purpose
Every report renders in greyscale, and that is a product decision rather than an aesthetic one. These documents get printed and taken to interviews. A colour-coded score band is meaningless from an office laser printer, and a dark card background that looks refined on screen is a solid grey box that eats toner.
Designing for the worst likely output device is the whole job with a PDF. It is the one surface where you cannot adapt at runtime.
Deriving a type from a function you cannot name
The shared download helper takes a document element. Typing that parameter turns out to be awkward, because react-pdf exports its DocumentProps from a declare namespace, so the type is not importable by name.
You could write any and move on. Instead:
// react-pdf's `pdf()` takes a `React.ReactElement<DocumentProps>` (from a
// `declare namespace` export, so the prop type isn't importable by name).
// Deriving it from `pdf`'s own parameter keeps this in sync with the
// installed version without needing to name that type.
type PdfDocumentElement = NonNullable<Parameters<typeof pdf>[0]>;
export async function downloadPdf(element: PdfDocumentElement, filename: string): Promise<void> {
const blob = await pdf(element).toBlob();
// ...
}
Parameters<typeof fn>[n] is the tool for this, and it is better than naming the type even when the type is importable, because it cannot drift. If the library changes its signature in a minor version, this type changes with it and the call sites that no longer fit fail at compile time, which is exactly when you want to hear about it.
The same trick works for return types (ReturnType<typeof fn>), for awaited results (Awaited<ReturnType<typeof fn>>), and for the element type of an array a library hands you. Any time you catch yourself hunting through a package's .d.ts files for an exported name, try deriving instead.
The line I did not fix
Here is the body of the download helper:
const blob = await pdf(element).toBlob();
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = filename;
link.click();
URL.revokeObjectURL(url);
That last line is suspicious. Revoking the object URL synchronously, immediately after click(), looks like it could race the browser's download in some engines. The conventional shape is to revoke on a timeout, or on the next task, or never for short-lived pages.
I left it exactly as it was, and wrote down that I had:
/**
* Note: the URL.revokeObjectURL call happens synchronously right after
* click(), which looks like it could race the download in some browsers.
* That is the pre-existing behaviour from lib/utils/pdf-generator.tsx,
* preserved as-is here rather than "fixed" as part of this split.
*/
The reasoning is a rule I have come to trust: a refactor that also fixes a bug is two changes pretending to be one.
If I had changed the revoke while moving four documents into four files, and downloads then broke in some browser, the bisect tells me "the PDF refactor broke downloads" and I get to re-read the whole thing. Keeping the move behaviour-preserving means the split is verifiable by inspection: same code, different file. Any later change to the revoke is then a two-line commit with an obvious title and an obvious revert.
It also keeps me honest about what I actually know. I have not observed this racing. I have observed that it looks wrong. Those deserve different actions, and the correct action for the second one is a comment that hands the next person my suspicion along with the fact that it has never been seen to fail.
If it does get changed, the comment is the ideal starting point: it names the behaviour, names where it came from, and says the change was deliberately deferred rather than overlooked.
Three things to take away
- Put a style in a shared module only when two or more modules use it. Otherwise the shared module becomes an unconditional download for everyone.
-
Parameters<typeof fn>[0]beats hunting for an exported type name, and it stays correct across library upgrades. - When refactoring, write down the things you noticed and chose not to change. A suspicious line with a comment explaining the suspicion is far more useful than either an unexplained line or a drive-by fix buried in a 40 file diff.
The reports are generated from the dashboard: sign up at https://cogniprep.app/games, finish a practice game, and the feedback page will render one of these documents in your browser and hand you the blob. Print it in greyscale, which is what it was designed for.
Top comments (0)