Our stylesheet was quietly restyling our users' apps. It took us weeks to notice.
Our stylesheet was quietly restyling our users' apps. It took us weeks to notice. A user opened an issue that made no sense to us. Their sidebar had gone wrong. Wrong width, white background where it should have been d
Our stylesheet was quietly restyling our users' apps. It took us weeks to notice.
A user opened an issue that made no sense to us.
Their sidebar had gone wrong. Wrong width, white background where it should have been dark, and it had shifted a few pixels down the page. Nothing to do with PDFs. They had not opened a PDF. They had not even clicked the button that opens one.
They had installed our viewer package, imported it, and their own sidebar broke.
We spent longer than I want to admit looking in the wrong place.
The part that did not add up
The thing that kept throwing us off was the timing. The breakage happened before any document was loaded. Our renderer had not run. No canvas existed. As far as we were concerned the library had not done anything yet.
That turned out to be the clue, not the mystery. Because something had run. The import had pulled in our stylesheet.
What was actually in our CSS
Our package ships dist/style.css. We had assumed that file contained our rules. It contained our rules plus pdfjs-dist/web/pdf_viewer.css, which is around 900 selectors written for pdf.js's own standalone viewer application.
That file was never meant to be a dependency of somebody else's design system. It was written for a page where pdf.js owns the whole document, so its class names are as plain as you would expect in that situation:
.sidebar
.dialog
.toggle-button
.closeButton
.messageBar
Read that list again as a library author. Those are not namespaced names. Those are the names half the web already uses.
We had scoped our own rules under .tspdf-root. We were reasonably careful about that. The pdf.js rules went out completely unscoped, because we had never actually looked at what our bundled CSS contained after the build ran.
Why it always won
Here is the bit worth remembering even if you never touch a PDF library.
Our stylesheet is injected at runtime, when the module is imported. That puts it late in document order. And in the cascade, when two rules have the same specificity, the later one wins.
So the host application had a perfectly sensible .sidebar rule of its own, written first, loaded normally. Ours arrived afterwards and quietly took the tie. Same specificity, later position, done. Their sidebar picked up position: relative, an inset-block-start they never asked for, a width meant for a PDF thumbnail rail, and a white background.
No error. No warning. No console output. Just a layout that was subtly wrong and a developer with no reason to suspect the PDF library, because they had not used it yet.
Silent conflicts are the worst kind. A crash tells you where to look.
The fix that did not work
The first idea was the obvious one: prefix every selector in the bundled file with.tspdf-root and move on.
It broke the viewer.
Two reasons, and both are easy to walk into.
Nesting. If you run a naive prefix pass over CSS that still has nesting in it, you prefix the outer selector and leave the inner ones alone, or you catch some of them twice. What comes out the other side is a file where some rules match nothing at all. Everything looks fine until you open the viewer and half the toolbar has no styling.
:root, html and body. You cannot prefix these. .tspdf-root :root matches nothing, ever. And :root was not decorative — it held the custom properties that the rest of the pdf.js rules read through var(). Prefix it and every one of those lookups fails, so you get a viewer with no colours and no spacing, and a stylesheet that technically contains every value it needs.
What we actually did
Two changes.
Scope after nesting is flattened, not before. The scoping pass now runs at build time, once the nesting is already resolved, so every selector in the output is a real flat selector and gets prefixed exactly once.
*Re-target :root, html and body instead of prefixing them. *
They become .tspdf-root. The custom properties then live on our root element rather than the document root, and every descendant that reads them still resolves correctly, because those descendants are
inside .tspdf-root anyway.
That was the whole fix. Not complicated. It just needed us to stop thinking of the scoping problem as a string replacement.
The part I would actually recommend copying
We added a check to npm run verify:package that fails the build in two cases:
Any selector in the shipped CSS is unscoped.
Any selector is prefixed more than once.
The second one matters more than it looks. A double-prefixed selector matches nothing, so it does not throw and it does not warn. It just silently stops applying, and you find out when a user tells you the toolbar looks broken. Catching it at build time turns an invisible bug into a failed CI run.
If you ship CSS in an npm package, that check costs an afternoon, and it will pay for itself.
If you ship a library with styles
Three things I would check tonight, in order:
Open your built CSS file. Not your source. The file that actually goes in the tarball. Read the selectors. If any of them could plausibly appear in somebody else's application, that is a bug waiting to be filed against you by someone who will have no idea it was your fault.
Check every transitive stylesheet. Ours came in through a dependency we were glad to have. We inherited its CSS along with its rendering, and we never inspected the result. Anything you bundle, you ship.
*Assume you win every tie. * Runtime injection means late in document order. Late means you take every equal-specificity conflict against your own users' code. That is not a position you want to be in by accident.
None of this is difficult. It is just not on the checklist, and it should be.
We hit this while working on TeamSync PDF Viewer, an open-source client-side PDF viewer for React. The fix shipped in v1.2.1 if you want to read the changelog entry.
If you have run into the same thing from a different library, I would be curious which one. I suspect this is far more common than the number of issues filed about it.
Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.