Applies to
states.no-js-fallback, states.preloader-budget.
Covers states.no-js-fallback, states.preloader-budget. Related: crawl.content-in-server-html, states.scroll-reveal-recovery, perf.lcp.
A page whose resting state is invisible has no degraded mode: when the script that reveals it does
not run, the visitor gets a blank page rather than a plain one. Every other rule in this dimension
degrades gracefully - a missing aria-current costs a cue, an undesigned 404 looks cheap. These
two produce nothing at all, which is why both are blockers, and one failsafe answers both.
What we shipped before we wrote the rule
Both reference builds carry an unconditional invisible resting state - visibility: hidden and
opacity: 0 on the reveal class in src/scss/base/_global.scss - used 146 and 210 times across
their templates and 44 and 70 times on a rendered home page. Scripting off, the only thing that
returns is the hero, the one element with a script-gated override, added for LCP. One build hides
its own 404 heading this way, so the page you land on when something went wrong is itself blank.
The copy is in the served bytes throughout: crawl.content-in-server-html is evidenced on both
builds, so a crawler reads the whole page and only the human gets nothing. The technical SEO
audit's row on content hidden by CSS maps to both rules on purpose, one for each reader.
The js class covers the rarest failure
The usual pattern puts a class on <html> from an inline head script and gates the hidden state on
it. It answers one question - is scripting enabled - and that is not the question.
| What happened | Scripting on | Head class set | Reveal code ran |
|---|---|---|---|
| Bundle 404s after a deploy: hashed filename, stale HTML in a cache | yes | yes | no |
| Bundle throws on parse: one unsupported syntax, one old browser | yes | yes | no |
| Script origin blocked: proxy, extension, network filter | yes | yes | no |
| Slow connection, bundle still in flight | yes | yes | not yet |
| Scripting genuinely disabled | no | no | no |
Only the last row is the one the head class catches, and it almost never happens. In the four above it the class is set, everything stays hidden, and nothing is alive to undo it. So run the top row - scripting on, the bundle blocked at the network layer. Neither accept asks for it; it is the test.
Arm the hidden state from the thing that releases it
Add the gating class from the module that owns the reveal, never from the head, and set the release
timer at parse time rather than inside init().
const root = document.documentElement;
root.classList.add("reveals-armed"); // hidden only while something can undo it
const release = () => root.classList.remove("reveals-armed");
setTimeout(release, 3000); // ...and not after that, whatever happened
// The success path calls the same release(), so it has to be safe to call twice.
Parse time is the whole point. One reference build has a real watchdog - WATCHDOG_DELAY of
8000 ms and a setTimeout that removes the loader - armed inside the function that starts the
loader intro, so if the intro never starts the watchdog never arms and the cap is absent from every
failure that stops the module earlier. 8 seconds is 2.7 times this rule's cap anyway. The other
build has the opposite half: a CSS-only escape revealing the page after 1.6 s, gated on the absence
of a class the head script sets whenever scripting works. Dead in the cases it exists for.
Never gate a preloader on something that can fail
| Gate | What can hold it open forever |
|---|---|
window.load |
any image, iframe or subresource on the page, third-party ones included |
a counter over N image load events |
a broken src fires error, never load, so the count stops at N-1 |
| a consent, tag manager or font service callback | a blocked origin, or a proxy that never answers |
Promise.all over asset fetches |
one rejection, landing in an unhandled rejection rather than the reveal |
Same shape every time: the loader waits on something that is not the loader, and that list cannot be enumerated in advance. Nothing in wp-starter helps - its transition panel runs on route change, nothing gates first paint on it, and the theme ships no progress or spinner at all.
The cap reveals, it does not retry
When it fires, show the page. Do not restart the loader, re-request the asset, or extend the wait
because progress reached 80%. A retry buys the same failure twice while competing with the requests
that are already late, and "nearly there" is how a 3 s cap becomes twenty. Most important,
nothing else may hang off the loader finishing: if the navigation, the cookie bar or the scroll
module bind inside onLoaderDone, the cap reveals a page that looks finished and does nothing.
Two costs travel with this. Hiding the page does to LCP what fading in the hero does - an element
that finishes loading while invisible is dropped from consideration for good, so a hero still
hidden when the cap fires was disqualified long before (perf.lcp, and the front-end volume's
measuring page). And a reveal bound to a downward scroll never fires for a visitor who starts below
its trigger, which a /page#section link, the back button and a bfcache restore all do:
states.scroll-reveal-recovery owns that one. Same blank block, different cause.
What the documents asked for, and what we added
Colibrity's checklist carries both as one-line boxes in its animation section, and neither box carries a number. Item 11, Preloader has a max duration and a fallback, becomes readable within 3 s of first byte whatever failed. Item 12, Content stays visible if JS fails, becomes 0 elements left at opacity 0, visibility hidden or translated out of the viewport. Both rest on a client document, not a specification, so they sit outside the standard's 100 external citations - but they have the shape that made 99 of those 100 partial: the source names the requirement, the number is ours. The 3 s comes off the Slow 3G run the accept asks for and is arguable; the cap itself is not.