DEV Community

Daniel Pertu
Daniel Pertu

Posted on

664KB of expert answer keys, and an import graph is the only thing keeping them off your machine

CogniPrep's assessment centre section has 37 timed exercises: e-tray and in-tray inbox triage, written exercises, presentations, group discussions and role-plays. One is free to play in full, the rest are behind a one-time unlock. You can see the list here:

cogniprep.app/assessment-centre-practice

Serialised, the library is 679,475 bytes. 37 scenarios, 280 items, 151 inbox messages, and attached to every one of those items the expert answer key and the written rationale explaining why each option is better or worse than the others.

That number is the entire reason the module is split in two.

The failure mode is a static chunk, not a prop

The exercise runner is a client component. It has to be: timers, inbox state, draft answers, a review step.

So the obvious thing happens. The client component imports the library to get the exercise it is rendering, and now a 664KB data structure, including the keys and rationales for 36 exercises the visitor has not paid for, is reachable from the client graph.

Here is the part that makes this a bundling problem rather than an access-control problem. EXERCISE_LIBRARY is built at module scope out of 37 content modules. It is not a function you call with an id. By the time anything imports that file, the array exists. So:

  • Narrowing the props does nothing. The component can receive exactly one exercise and the chunk still contains all 37.
  • Server-side authorisation does nothing. The authorisation decides what renders. The chunk is what got downloaded.
  • Tree shaking does not help, because none of it is unreachable. A module-scope array built from 37 imports is live code.

And it is a static chunk, served identically to every visitor, cached, versioned, sitting on a CDN. Not a per-render payload you can trim. If it ships once, it ships to everyone, forever, for that deployment.

The fix is a file with nothing in it but types

lib/exercises/meta.ts exists purely so that client components have somewhere safe to import from:

Deliberately separate from ./constants, and deliberately importing nothing
but types: this is the half of the exercise module that client components are
allowed to reach for. constants.ts builds EXERCISE_LIBRARY out of the 37
content modules, so any client component importing from it pulls every brief,
inbox message and item into the browser bundle, expert answer keys and
rationales for exercises the viewer has not paid for included. That is a
static chunk served to every visitor, not a per-render payload, so it cannot
be fixed by narrowing props alone.

Keeping the labels here means the import graph enforces the boundary rather
than tree-shaking happening to.
Enter fullscreen mode Exit fullscreen mode

It holds the section label, the per-kind display metadata and blurbs, the canonical ordering the dashboard groups by, and one stable access key string. Its only non-type import is import type { Exercise, ExerciseKind, ExerciseLayout } from './types', which compiles to nothing at all.

Client components import from meta. Anything that needs an actual Exercise object stays on the server and passes down only what it is allowed to.

The rule generalises past this feature: when a module has a large data dependency and a small API over it, the way to keep the data out of a bundle is to make the import graph physically unable to reach it. Not a lint rule, not a convention, not a comment. A file with no path to the data. Then the boundary holds even when someone adds an import in a hurry, because the import they add does not resolve to anything that contains it.

The marketing preview is a hand-written mock

There is a nice consequence of this on the public page. Open cogniprep.app/assessment-centre-practice and you will see a little e-tray inbox mock up top, with three messages, a timer and priority flags.

None of that comes from the library. It is a standalone component with its own hard-coded messages, written for the page. The marketing surface that illustrates the product therefore has no import path to the product's content whatsoever, which was not a sacrifice: the mock wants short punchy subject lines that fit a phone, and a real exercise wants realistic workplace sprawl. Those are different artefacts, and discovering that the technically safe option was also the better-looking one does not happen often enough to go unremarked.

See it

Thirty seconds, your own browser, no account needed.

Open cogniprep.app/assessment-centre-practice, open DevTools, Network tab, filter to JS. 18 chunks, about 1.0MB total. Now open the DevTools Search panel, the one that greps every loaded resource rather than filtering the request list, and search for:

rationale
Enter fullscreen mode Exit fullscreen mode

Zero results. Search for any exercise id you can think of from the list on the page. Zero results.

I ran the same check from the command line over every script tag on that page: zero hits for rationale, zero for the id of a paid exercise, zero for a distinctive phrase out of one of its answer rationales. The one hit for the word "effectiveness" is in the games catalogue, describing what a provider's tests measure, which is public marketing copy and is supposed to be there.

That last bit is the useful contrast. The same page's chunks do contain the full game catalogue, hundreds of entries with names and descriptions, because that is published copy with public pages of its own. Two bodies of content, two different answers, and the difference is enforced by which files can import which.

The validator that keeps the library honest

Since the library never reaches the browser, nothing in the browser can ever tell you it is malformed. So it is checked on the way in, by a script rather than a type:

pnpm tsx scripts/check-exercises.ts
Enter fullscreen mode Exit fullscreen mode

It walks every exercise and asserts the things the typechecker cannot see. Item ids unique within an exercise. Every item that points at an inbox message points at one that exists. An inbox-layout exercise actually has an inbox. Written items have a rubric whose weights sum to 1, and a minimum word count below its maximum. Every competency an item assesses appears on the exercise, and every competency the exercise claims is assessed by at least one item, checked in both directions because the two mistakes are different mistakes.

Problems exit non-zero. Softer findings, a thin rationale or a duration that looks wrong for the format, print as warnings and do not fail the run, because a content library needs a channel for "look at this" that is not "stop the build".

Exercises are listed at cogniprep.app/assessment-centre-practice, one of them is free to play in full, and the unlock is a one-time payment listed on cogniprep.app/pricing.

Top comments (0)