Styles and fonts
A frame is DOM plus stylesheets. What was on the page during the test is what the viewer shows, plus the preview head. This page covers how to get the rest in: global styles, fonts and the files your CSS points at.
What a frame captures #
Each frame is the DOM of the page and its stylesheets, serialized by rrweb-snapshot. There
is no layout and no screenshot. The viewer rebuilds the DOM in an iframe and your browser
lays it out again. The iframe is sandboxed with allow-same-origin only, so no
script runs in the viewer, neither the page's nor the preview head's.
So a style shows when it was on the page during the test, in a form rrweb can read. The
table below says which forms that covers. Each frame also
records hover and focus, and the replay applies their rules. :active is not
recorded (both environments).
previewHead is the one
addition. The viewer adds it at the start of every frame's <head> at
replay. It never reaches the test page, so the test cannot see it.
Snapshots and
replay explain the internals.
Styling techniques #
| Technique | Used by | Browser mode | jsdom |
|---|---|---|---|
style attributes |
everyone | yes | yes |
<style> in the document |
CSS imports, CSS modules, Tailwind, vanilla-extract, dev builds of emotion and styled-components | yes |
yes. CSS imports need test.css, which the plugin turns on (what the plugin changes)
|
<link rel="stylesheet"> |
stylesheets and web fonts that a setup file or a library links | same-origin sheets are inlined, cross-origin ones stay links |
the link never loads during the test. A project file is copied with its
url() targets (assets). Remote URLs load from
the network, except font stylesheets that describe-me build downloads
(vendoring)
|
CSSOM insertRule into an empty <style> |
emotion and styled-components in production ("speedy") mode | yes, rrweb reads cssRules |
yes, for simple rules |
document.adoptedStyleSheets |
Lit and other web components | yes, copied into a temporary <style> during capture |
no, jsdom has no constructable stylesheets |
adoptedStyleSheets on a shadow root |
web components with shadow DOM | not yet | no |
StyledText.test.tsx in react-browser and react-jsdom renders one line per
technique. Compare it in the
browser-mode viewer and
the jsdom viewer: the
adoptedStyleSheets line loses its orange in jsdom. In
examples/react-jsdom, pnpm check-styles checks that each rule
and its colour are in the snapshots.
In jsdom, rules go through jsdom's own CSS parser, which drops what it does not
understand. Simple rules are verified. Nesting, @layer and
@container are not verified yet (limitations).
Global styles #
Your app shell or Storybook's preview usually adds a reset and a
body { font-family } around every component. A test renders the component
alone, so they are missing from the frames too: buttons show the browser's grey default
and text shows in Times. Put them in the wrapper of your custom render, next
to your providers:
src/test-utils.tsx
import { render, type RenderOptions, type RenderResult } from '@testing-library/react'
import type { ReactElement, ReactNode } from 'react'
import { ThemeProvider } from 'styled-components'
import { GlobalStyles } from './GlobalStyles'
import { theme } from './theme'
export * from '@testing-library/react'
function AllTheProviders({ children }: { children: ReactNode }) {
return (
<ThemeProvider theme={theme}>
<GlobalStyles />
{children}
</ThemeProvider>
)
}
function customRender(ui: ReactElement, options?: Omit<RenderOptions, 'wrapper'>): RenderResult {
return render(ui, { wrapper: AllTheProviders, ...options })
}
export { customRender as render }
Tests import render from this file. It imports
@testing-library/react itself, so the plugin's redirect records through it
and neither the tests nor this file need to know. The jsdom example's
Heading test renders this
way and takes its colour from the theme. In jsdom with styled-components, also read the
next section.
styled-components in jsdom #
Vitest loads the Node build of styled-components in jsdom. Its
createGlobalStyle never inserts its CSS on the client, so
<GlobalStyles /> silently does nothing and the reset and fonts are
missing from every frame. With styled-components 5 or later in a DOM environment, the
plugin fixes this on its own.
What the plugin changes lists the config it adds.
This is why:
-
The browser build. An alias points
styled-componentsat its browser ES module build, whosecreateGlobalStyleworks. -
One instance.
jest-styled-componentsloads styled-components through Node, which ignores the alias. It would get a second instance whose styles its serializer cannot see. The plugin pre-bundles both together, so they share one instance andtoMatchSnapshotoutput stays the same. - tslib. styled-components 6.0 to 6.3 import tslib. Vite's optimizer bundles tslib's UMD file without its default export, and every test that imports styled-components fails with an error that contains:
Cannot destructure property '__extends'
So the plugin points tslib at the tslib.es6.mjs of the tslib
that styled-components resolves, and you need no tslib alias of your own. tslib ships that
file from 2.5.3. styled-components 6.1.3 to 6.1.9 pin tslib 2.5.0, which has none. Then
the plugin leaves tslib alone, and the reporter prints a
setup warning after the run, for
example:
styled-components 6.1.5 resolves tslib 2.5.0, which has no tslib.es6.mjs, so its browser build may fail to load ("Cannot destructure property '__extends'"). Upgrade styled-components to 6.1.10 or later, or override tslib to 2.5.3 or later.
To keep the Node build, turn all of this off with
styledComponentsBrowserBuild: false. Global styles from createGlobalStyle are then missing again.
Fonts #
Fonts usually go missing because the app shell loads them, and tests never run the shell:
a Google Fonts <link> in index.html, a font loader called
at startup, or a fonts.css that only the app entry imports. Give the viewer
the same links with previewHead, like Storybook's
preview-head.html:
vitest.config.ts
describeMe({
previewHead: `
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600&display=swap">
<link rel="stylesheet" href="/src/fonts.css">
`,
})
A project that already has a preview-head.html can pass it as it is:
import { readFileSync } from 'node:fs'
describeMe({ previewHead: readFileSync('.storybook/preview-head.html', 'utf8') })
The head is stored once, in the manifest. The viewer adds it to the start of every frame's
<head>, so the captured styles win ties. It waits at most 3 seconds for
the fonts and fits the frame again once they have loaded. A note under the frame names the
fonts and stylesheets that did not load in your browser.
Local files that the head links with href or src, and the
url()s in its <style> blocks, are copied into the output
directory. A relative path counts from the project root. /src/fonts.css
above is copied together with the fonts it points at. The browser-mode example loads Inter
from Google Fonts and a local Lora this way. Its
Postcard test shows the
caption in Lora.
Local fonts of an app shell
Say the app shell loads src/app-shell/fonts.css, with
@font-face rules that point at .ttf files. There are two ways
in. The first is the preview head:
describeMe({ previewHead: '<link rel="stylesheet" href="/src/app-shell/fonts.css">' })
The CSS is stored once, outside the snapshots, and copied with its .ttf
files. It must be plain CSS, see the list below.
The second is a setup file that only the describe-me run loads:
describe-me.setup.ts
import './src/app-shell/fonts.css'
Add it to the test block of a
config of its own, and the regular run
stays as it is:
test: { setupFiles: ['./describe-me.setup.ts'] },
The CSS goes through Vite, so aliases and PostCSS work. It lands in the styles of every snapshot, its fonts are copied, and the font check counts the family as loaded. The same import in an existing setup file works too, in every run.
Do not call the app's font loaders in tests. The <link> a loader adds
lands in every snapshot, and in jsdom it never loads. The preview head does the same job
once.
Keep in mind
-
The viewer ignores scripts in the head. The reporter warns about a
<script>. - Fonts never load inside jsdom, so the test itself never sees them. Only the viewer shows them (Limitations in jsdom).
- CSS linked from the head is copied as it is on disk. It skips PostCSS, Tailwind and every other Vite transform, so link plain CSS.
-
describe-me devloads remote fonts from the network.describe-me builddownloads them into the site (next section).
Fonts in the static site #
describe-me build copies the data into the site, then downloads the web fonts
that the copy loads from known font hosts. It scans the preview head, the snapshots, the
style chunks and the CSS assets of the copy, so a link that a font loader added during a
test is covered too, and so is data written by 0.4. Vendoring also points the copy's
foundations.json at the chunks it moves. The data directory itself never
changes. describe-me dev never downloads fonts.
| Provider | Hosts | In the build |
|---|---|---|
| Google Fonts | fonts.googleapis.com, fonts.gstatic.com |
downloaded |
| Bunny Fonts | fonts.bunny.net |
downloaded |
| Fontsource on jsDelivr | cdn.jsdelivr.net, Fontsource paths only |
downloaded |
| Adobe Fonts | use.typekit.net and others |
stays remote: "Adobe Fonts do not allow self-hosting" |
| any other host | a stylesheet or font file elsewhere | stays remote: "not on the font allowlist" |
-
All or nothing. A stylesheet is replaced only when it and every font
and nested
@importit names downloaded. Otherwise it keeps loading from the network, and the build names the URL. -
Every subset. Every
unicode-rangesubset of a stylesheet is downloaded, so the site grows (output size). -
The cache. Downloads are kept in
node_modules/.cache/describe-me/fonts, in the nearestnode_modulesat or above the data directory. Without one, they go todescribe-me-fontsin the system temp directory. - Freshness. Font files are cached for good. A stylesheet is fresh for 7 days. After that it is fetched again, and the stale copy is used when the network fails. So after one online build, later builds of the same data run offline.
- Slow hosts. Each request times out after 10 seconds. A host that cannot be reached is skipped for the rest of the build.
- The budget. One build downloads at most 100 MB of fonts. What is left then stays remote.
- Never fatal. Font downloads never fail the build. What did not download keeps loading from the network.
The build says what it did:
describe-me: vendored 1 stylesheet from fonts.googleapis.com and 7 font files (…) into __data/assets describe-me: left remote: use.typekit.net (Adobe Fonts do not allow self-hosting) describe-me: https://fonts.bunny.net/css?family=… did not download (…). It keeps loading from the network.
It lists at most five URLs that did not download, then how many more. To keep the build
off the network, pass
--no-vendor-fonts. Fonts then load from their hosts, as in describe-me dev.
The font check #
After each run, the reporter warns about font families that the captured CSS uses but nothing loads. The viewer lists them in the issues menu as "Fonts not loaded", with the number of tests and a link to the first. This is the rule:
-
Used. The first family of every
font-familyandfontdeclaration, in a frame's stylesheets, the CSS files they link or import, and itsstyleattributes. The fallbacks after the first family do not count. -
var(). Avar()in the place of the first family is followed through the frame's custom properties, up to 3 levels, else its fallback. -
Ignored. CSS-wide keywords such as
inherit, generic families such assans-serifandsystem-ui, and families that ship with common operating systems, such as Arial, Helvetica, Segoe UI, Georgia and-apple-system. Roboto, Open Sans, Noto Sans and Inter are not ignored. They are Google Fonts too, and a page that uses them usually loads them. -
Loaded. An
@font-facerule in the frame or in the preview head, or a stylesheet from Google Fonts, Bunny Fonts or Fontsource on jsDelivr, whose URL names the family. What the preview head uses itself does not count, only what it loads. - Unknown hosts. A stylesheet from any other host, such as Adobe Fonts or your own CDN, turns the check off for that frame, because its URL does not say which families it loads. Linked from the preview head, it turns the check off for every frame.
- Your machine. A family installed only on your machine still counts as missing. It would not show for anyone else.
The reporter also lists the hosts that frames load stylesheets from. The test server
(localhost, 127.0.0.1) is never one of them. The check reads the
data, so it cannot see a font that fails only in your browser. The note under the frame
names those. Troubleshooting says
what to do about each family.
Assets #
rrweb records images, videos, fonts and CSS url()s as absolute URLs on the
test page, such as http://localhost:3000/src/logo.svg in jsdom or the Vitest
server in browser mode. Those stop working once the tests end. So when the snapshots are
written, every one of them that points at a project file is copied into
.describe-me/assets/, and the snapshot points at the copy. They load in
describe-me dev and in the static site alike.
-
Lookup. A
/@fs/path, which Vite uses for files outside the root, is read from its place on disk. Any other path is looked up under the project root, then underpublic/. -
Missing files. A path with a file extension that is not found is listed
as
assetsMissingin the manifest, in the reporter's output and in the issues menu (assetsMissing). -
Left alone. Links to pages, such as
<a href="/">, URLs of script files and the dev server's own paths are not assets and stay as they are.<script>elements are dropped from the snapshot, because the viewer never runs them. -
CSS files. A copied CSS file keeps working, because its
url()and@importtargets are copied with it and the copy points at them. Only stylesheets, fonts, images and cursors are pulled in this way, so a stylesheet cannot publish a source file or a secret. - The preview head. Its local files are copied too, see fonts.
The Postcard test shows a photo imported as a module on a background from a
stylesheet url(), both copied. See it in the
browser-mode viewer and
the jsdom viewer.
Storage covers file names and garbage collection.