Fingerprinting to speed up builds
A pull request that touches one component still renders every story in your Storybook. If your build can tell Merrykat which stories are byte-identical between the two commits, those are skipped entirely.
Knowing which stories cannot have changed is a module-graph question, and only a bundler can answer it. So Merrykat does not answer it: it defines a file your build may write, and never looks at how the values were produced.
The file
Write merrykat-fingerprints.json next to index.json in your Storybook build output.
{
"version": 1,
"global": "9f2c…",
"files": {
"./src/components/Button.stories.tsx": "a1b2…",
"./src/components/Card.stories.tsx": "c3d4…"
}
}| Field | Meaning |
|---|---|
version | 1. |
global | Required. Covers everything that changes a render without appearing in any story’s imports. |
files | Keyed by importPath — the same key index.json already carries. A leading ./ is normalised, so copying the paths out of index.json verbatim works. One entry covers every story in that file. |
stories | Optional, keyed by story id, and wins over files for that story. Use it when you have finer-grained information than the file level; most generators do not. |
The single most common thing to get wrong is the key. A generator that emits absolute build-machine paths (/home/runner/work/app/src/Button.stories.tsx) matches nothing at all, every story is compared, and the only sign is a line in your upload log:
fingerprints cover 0 of 514 stories
merrykat-fingerprints.json matched no stories at all, so nothing will be skipped.merrykat upload prints coverage on every run. Read it the first time.
The contract
If two builds report the same
globaland the same fingerprint for a story, Merrykat may assume the two renders are pixel-identical, and will never look.
The values are opaque and only ever compared with ===. That is what lets a per-module closure hash, a turbo package hash and a plain git hash-object all be valid answers at different levels of precision. Pick whichever your build can produce honestly.
Why global is required
A per-story hash alone cannot be sound. global must change whenever anything that affects a render changes but appears in no story’s module graph:
.storybook/preview.js/preview.ts— global decorators, global CSS, parameters.storybook/preview-head.html,preview-body.html.storybook/main.ts, including the framework and builder optionsstaticDirsand anything inpublic/— images, fonts, favicons- webfonts, wherever they come from
- your builder’s
definevalues andimport.meta.env/process.envsubstitutions - the Storybook version and your renderer’s version
The define values are the ones people forget: an inlined environment variable changes what renders while leaving every module’s source byte-identical. Merrykat folds the Storybook version into the check for you, so a Storybook upgrade invalidates every claim in the build whether or not you remembered it. Nothing else is added.
Split your CSS by scope, not by file type
This is the part that is easiest to get wrong in both directions, so here is what Merrykat’s own build does, and why.
- Unscoped CSS belongs in
global. A stylesheet that reaches the browser throughpreview.tsrather than through a component import changes zero per-story fingerprints. Leave it out ofglobaland a pull request that rewrote every design token in the app would have every story declared identical and not one rendered. - CSS Modules do not. They are scoped to their importer, and a bundler does surface them in the importing component’s closure. Editing
Header.module.cssshould invalidate exactly the story files that reach it.
Getting the second half wrong is not free, even though it is safe. An earlier version of Merrykat’s own generator put all CSS in global, which is sound and was far too coarse: three consecutive pull requests had every claim discarded over a *.module.css edit, and on one of them that threw away a correct 89-of-95 skip in order to re-render 283 story renders that could not have changed. Safe and useful are different bars, and you have to clear both.
One gap that split leaves, worth closing the same way: a CSS Module that appears in no story’s closure is covered by neither rule. Collect the CSS your story closures actually named and fold anything left over into global. That way every CSS file is accounted for exactly once, and a build tool that stops reporting CSS Modules degrades to “compare everything” rather than to a wrong skip.
Rolling it out
export default {
compare: {
fingerprints: 'auto', // 'auto' | 'off' | 'verify'
audit: 0.02, // compare a few skipped stories anyway
alwaysCompare: ['Charts/**'],
},
};| Option | Default | Meaning |
|---|---|---|
fingerprints | 'auto' | auto uses the fingerprints when both builds ship them. verify compares everything as usual and reports how many of your claims a real comparison contradicted. Start here. off is the kill switch for a generator you no longer trust, with no need to re-plumb the build. |
audit | 0.02 | Fraction of the skipped stories compared anyway on every run, capped at ten stories. |
alwaysCompare | [] | Story id / title globs never skipped, whatever the fingerprints say. This is an escape hatch for a story whose render depends on something no module graph can see. |
Start with fingerprints: 'verify'. Every story is still rendered and compared, and the comment tells you how many of your claims a real comparison contradicted; a run that reports zero wrong over a few hundred claims is evidence your generator works. Then switch to 'auto', and the stories your build declares identical stop being rendered.
Do not set audit to zero. Allowing a fraction of skipped stories to render anyway will build trust into the fingerprinting. If it ever catches a claim that was wrong, the comment says so in as many words, and the right response is fingerprints: 'off' until the generator is fixed.
Anything missing means “compare it”: a story with no entry, a build with no file, a global that moved, a file that fails to parse. Partial adoption is fine, and a baseline uploaded before you adopted this needs no migration.
What it is not
A skip, not a cache. Both sides still render together whenever they render at all, so there is nothing stored that could go stale and no cache to invalidate when the Merrykat engine changes.
Writing a generator
Merrykat ships no plugin for this, deliberately: a plugin is one per bundler per major version, and it would still be wrong about everything global exists to cover. Here is what each of the three usual approaches looks like.
Vite
A plugin in viteFinal that walks the module graph at build time. Roughly:
import { createHash } from 'node:crypto';
import { writeFileSync } from 'node:fs';
import path from 'node:path';
function merrykatFingerprints({ root, outDir, global }) {
return {
name: 'merrykat-fingerprints',
apply: 'build',
generateBundle() {
const rel = (id) => path.relative(root, id).split(path.sep).join('/');
const hashOf = new Map();
for (const id of this.getModuleIds()) {
const info = this.getModuleInfo(id);
if (info?.code != null) {
hashOf.set(id, createHash('sha256').update(info.code).digest('hex'));
}
}
// Closure per story file, as a set rather than a Merkle DAG: circular imports
// are normal in ESM and would need SCC condensation to hash as a tree, for no
// benefit.
const files = {};
for (const id of hashOf.keys()) {
if (!/\.stories\.[jt]sx?$/.test(id)) continue;
const seen = new Set();
const stack = [id];
while (stack.length > 0) {
const current = stack.pop();
if (seen.has(current)) continue;
seen.add(current);
const info = this.getModuleInfo(current);
for (const next of [
...(info?.importedIds ?? []),
...(info?.dynamicallyImportedIds ?? []),
]) {
stack.push(next);
}
}
const closure = [...seen]
.filter((m) => hashOf.has(m))
.map((m) => `${rel(m)}:${hashOf.get(m)}`)
.sort();
files[`./${rel(id)}`] = createHash('sha256')
.update(closure.join('\n'))
.digest('hex');
}
writeFileSync(
path.join(outDir, 'merrykat-fingerprints.json'),
JSON.stringify({ version: 1, global, files }),
);
},
};
}Two things this sketch does not do for you, and both matter:
globalis passed in. Hash your.storybookdirectory, yourstaticDirs, your fonts and yourdefinevalues yourself — see the list above.- The module ids are relativized to
root. Two builds on two CI machines must produce the same string for the same file, and an absolute path will not.
webpack
The same walk over compilation.moduleGraph, added through webpackFinal in .storybook/main.ts. Three things Merrykat’s own generator got wrong first, all of which fail silently:
- Compute in
finishModules, not inprocessAssets. By the time assets exist, a production build has scope-hoisted, and a story file webpack concatenated into another module is no longer incompilation.modulesunder its own name. Ten of that build’s twenty-three story files went missing that way, and a story with no fingerprint is simply compared — so the only symptom was a smaller saving than expected. - Sort the keys before writing the file.
compilation.modulesdoes not iterate in a stable order, so two builds of identical sources produced identical fingerprint values in a different key order. Different bytes means a different archive hash, which silently disables Merrykat’s own “the two builds are byte-identical, render nothing” fast path. - Hash the bytes on disk under a repo-relative path, not webpack’s processed source under an absolute one. The two builds being compared come from two different CI machines.
No bundler at all
You do not need a module graph to make a sound claim — only a conservative one. If your components live in packages, a per-package content hash you already have is enough.
# One fingerprint per package, applied to every story file in it.
turbo run build --dry-run=json | jq '...' # or nx print-affected
# Or per directory, with plain git:
git ls-files -s src/components | git hash-object --stdinMap each package’s hash onto every importPath inside it. That over-invalidates — touching one component in a package re-renders all of its stories — but it is correct, it needs no plugin, and it still skips every package the pull request did not touch. For most monorepos that is where nearly all of the saving is.
Whichever route you take, the accessibility trade-off is worth knowing: a story that is never rendered is never analysed, so its pre-existing violations go uncounted. New ones cannot hide there, since identical source renders identical markup. See Accessibility testing.