Practice reports in CogniPrep now end with an answer review: every question of the sitting, what you picked, what the key was, and the reason where the content has one. It appears in two places that could not be more different. One is a page you scroll on a phone. The other is a PDF.

I have written before about giving a results screen a URL and ending up with one shape and three renderers for our assessment centre exercises. This is the same idea applied to a harder case, because this content is unbounded: a sitting can be six questions or sixty.

The shape carries strings, not components

export interface ReviewAnswer { /** Shown first and in bold: "A.", "3." or "Most likely". Null for none. */ marker: string | null; /** The action, statement or question being answered. */ text: string | null; /** A line before the answer, such as the customer's message it replies to. */ prompt: string | null; yoursLabel: string; yours: string; correctLabel: string; /** Null when the answer given is the key and repeating it would add nothing. */ correct: string | null; verdict: ReviewVerdict; verdictText: string; notes: ReviewNote[]; } 

Three properties of that interface are doing the real work.

The labels are data. yoursLabel and correctLabel are strings, not an enum the renderer switches on, because the right words differ by question type: "You rated" against "Key", "You picked" against "Best reply", "You typed" against "Expected". Neither renderer knows those words exist.

correct: string | null is a presentation decision made once. When your answer is the key, repeating it on the next line adds nothing, so the builder sets null and both renderers skip the line. Had this been correct: string with the comparison left to the renderers, the screen and the PDF would eventually have disagreed about it, because somebody would fix one of them.

The module imports nothing. Its header says so:

Imports nothing, so client components can use it.

A types file that imports a content module is a types file that drags a content library into a client bundle. We learned that one the expensive way.

Same data, opposite information architecture

Here is where the two renderers stop looking alike at all.

On screen the problem is length. A sitting with sixty questions is a wall, so every question collapses to one line and opens on demand:

<details className="group rounded-lg border"> <summary className="flex cursor-pointer list-none items-start justify-between gap-3 p-3 sm:items-center sm:p-4"> <span className="min-w-0 text-sm leading-snug"> <span className="font-medium">{question.title}</span> {question.label && <span className="text-muted-foreground">: {question.label}</span>} </span> <span className="flex shrink-0 items-center gap-2"> <span className="text-muted-foreground text-xs whitespace-nowrap tabular-nums"> {question.summary} </span> 

summary is the one-line verdict ("3 of 4 marks", "Right"), which is exactly what you want when the closed state is all you read. Native <details> means no open-state to hold, no JavaScript, and no library.

On paper the problem is page breaks. Nothing collapses, so the only question is where the cut lands. I wrote a whole post on the react-pdf props that decide that, and this component needed one more trick from it:

const ReviewQuestionBlock: React.FC<{ question: ReviewQuestion }> = ({ question }) => { const [first, ...rest] = question.answers; return ( <> <View wrap={false}> <Text style={sessionStyles.reviewQuestion}> {question.title} {question.label ? `: ${question.label}` : ''} ({question.summary}) </Text> {question.setting && <Text style={sessionStyles.reviewSetting}>{question.setting}</Text>} {question.scenario?.split(/\n\s*\n/).map((paragraph, i) => ( <Text key={i} style={sessionStyles.reviewText}>{paragraph}</Text> ))} {/* ...transcript, table, options... */} {first && <ReviewAnswerBlock answer={first} />} </View> {rest.map((answer, i) => ( <ReviewAnswerBlock key={i} answer={answer} /> ))} </> ); }; 

The destructure is the whole idea. The question heading, its scenario and its first answer are one atomic block; every later answer is atomic on its own. A heading plus scenario kept together on its own would still allow the classic orphan, a scenario at the bottom of page three with every answer to it on page four. Pulling the first answer into the atomic block means the break can only ever happen somewhere a reader already has context.

The same divergence shows up in the small things. The screen decides, per answer, whether "yours" and "the key" fit side by side:

// Ratings and letters sit side by side from sm; a customer reply is a // paragraph, so long answers always stack. const short = answer.yours.length + (answer.correct?.length ?? 0) <= 60; 

The PDF has no breakpoints, so it stacks them always. The screen's data table scrolls sideways on a phone with overflow-x-auto; the PDF builds the same table out of flex rows with flex: 1 cells, because A4 does not scroll. Neither of those decisions belongs in the shape, and neither leaks into it.

One place where they agree by convention, not by code

question.scenario?.split(/\n\s*\n/) 

That regex is in both renderers. The shape's comment says what it means ("Paragraphs are separated by a blank line") and both consumers implement it independently. I could extract a paragraphs() helper, and if a third renderer appears I will. With two consumers and one line each, the cost of a shared module that a client component and a Node-side PDF document both import is not obviously lower than the cost of the duplication, and the comment on the field is the actual contract.

The test renders both, and the PDF assertion is deliberately dumb

it('prints every part and question kind in the PDF', async () => { const buffer = await renderToBuffer( createElement(SessionPDFDocument, { data: { /* ...a review with every question kind */ } }) ); expect(buffer.length).toBeGreaterThan(10_000); }, 30_000); 

expect(buffer.length).toBeGreaterThan(10_000) asserts almost nothing about the document. It is still the most valuable line in the file, because react-pdf throws rather than degrades: an unsupported style property, a <div> that slipped in, a null where a string was expected, and you get an exception instead of a PDF. So "it produced a buffer bigger than a trivial one" is a real statement about every element and style in the tree.

Next to it, the screen renderer gets the assertions a string can support:

const html = renderToStaticMarkup(createElement(FeedbackReport, { /* ... */ review })); expect(html).toContain('Answers and explanations'); expect(html).toContain('Best reply'); expect(html).toContain('Most likely'); expect(html).toContain('Errors in the copy'); expect(html).toContain('Document 1: '); expect(html).toContain('<table'); expect(html).toContain('Wrong: a trial question, not scored'); 

The fixture is one review built by concatenating the sections of four different sittings, so a single object contains every question shape the product has: rated actions, most and least, branching customer replies, case pack statements with their documents, checking records and a numerical data table. That is the part worth copying if you have two renderers over one shape. Not the assertions. The fixture.

Both of these run in the normal suite, in the 4-second unit test run with no database, because a PDF renderer that works entirely in memory is a pure function with a very large output.

Where to see it

The review shipped first on the eight UK Civil Service online tests, which is the suite with the widest spread of question shapes: that is why the fixture above has case pack documents and a timed checking part in it. If you want the format without the branding, the situational judgement format page explains rating and most-least items, and the Scores and Reports upgrade is the one-time purchase that turns a finished sitting into the report both of these renderers draw.