Static HTML passed on all 46 pages. Rendered in Chrome, eight of them were broken.
The audit was routine: 46 new pages across four sites, all generated from templates, all with correct <title>, <h1>, canonical and structured data. A static checker would return 46/46 green. Then I loaded them in headless Chrome and measured the box model, and eight of them had a layout that no HTML validator could see.
Why static checks cannot find layout bugs
A static check reads the document. A layout bug lives in the computed box model, which only exists after CSS cascade, font loading and JS hydration. Three of the eight had valid markup and a completely broken visual result:
- A sidebar that was a grid child of the article container instead of a sibling, so it got placed inside the content column and squeezed the text to 40% width.
- A page with no
<main>element at all, because the template's hero section had been written before the wrapper and the wrapper was never added. - A page where an absolutely positioned element was 320px wide on a 390px viewport, forcing a horizontal scrollbar.
None of these throw, none fail validation, and all of them return 200.
The four measurements I run
const { chromium } = require("playwright-core");
const browser = await chromium.launch();
const page = await browser.newPage({ viewport: { width: 390, height: 844 } });
await page.goto(url, { waitUntil: "networkidle" });
const m = await page.evaluate(() => {
const main = document.querySelector("main");
const rail = document.querySelector("aside");
const overflow = document.documentElement.scrollWidth - window.innerWidth;
// 两个元素的重叠面积
let overlap = 0;
if (main && rail) {
const a = main.getBoundingClientRect();
const b = rail.getBoundingClientRect();
const w = Math.min(a.right, b.right) - Math.max(a.left, b.left);
const h = Math.min(a.bottom, b.bottom) - Math.max(a.top, b.top);
overlap = w > 0 && h > 0 ? Math.round(w * h) : 0;
}
return {
mainWidth: main ? Math.round(main.getBoundingClientRect().width) : 0,
mainCount: document.querySelectorAll("main").length,
railIsSibling: rail ? rail.parentElement === main?.parentElement : null,
overlap,
overflow,
ctaVisible: !!document.querySelector("a[href*='t.me']")?.offsetParent,
bodyText: document.body.innerText.length,
};
});
Assertions, not prints:
| measurement | fail condition | what it catches |
|---|---|---|
mainCount |
!== 1 |
missing or duplicated content landmark |
mainWidth (390px viewport) |
< 300 |
content squeezed by a misplaced grid child |
railIsSibling |
false |
sidebar nested inside the article column |
overlap |
> 0 |
text running underneath a floated rail |
overflow |
> 0 |
horizontal scrollbar |
bodyText |
< 800 |
page rendered a shell instead of content |
The width check on a mobile viewport is the one that earned its keep. At 1280px the squeezed column measured 512px and looked plausible in a screenshot. At 390px it measured 118px, which is obviously wrong the moment a threshold exists.
The bug behind the bug: hydrated content that disappears
Two of the sites are server-rendered app frameworks, and there the layout was fine while the content was the problem.
I had injected extra HTML into the static output — an internal-linking block, some extra structured data. It was present in the served HTML. Then the app hydrated, and the block vanished.
That is not a rendering bug, it is the framework doing what it is told: the client render owns the tree, and any DOM node that the client component tree does not know about gets discarded on hydration. Static injection into a hydrating app is fundamentally unstable.
The fix was to remove the hydration path from those pages entirely: strip type="module" scripts, modulepreload hints and the framework's payload scripts, keep the CSS, the analytics tag and the JSON-LD. The pages become fully static and the injected content stays. The cost is real — client-side navigation from those pages stops working — but for content pages that nobody navigates from, that trade was worth it.
The nastiest one: a streaming payload with a length prefix
If you hand-edit text inside a React Server Components payload (the self.__next_f.push([1, "..."]) blocks, or the flight stream), you can produce a white page with no error message.
Those payloads are length-prefixed:
3:TL5b,{"title":"Bitcoin forecast"}
L5b is a hex byte count of what follows. Change the text, forget to recompute the prefix, and the parser reads the wrong number of bytes, splits the record in half, and the client throws — React #412 territory, which surfaces as a blank page rather than a readable error.
The safe way to edit a text chunk:
import json
def rewrite_tchunk(record: str, new_text: str) -> str:
# record = '3:TL5b,{"title":"..."}' -> 首段 id + 长度前缀
head, rest = record.split(":T", 1)
old_len, body = rest.split(",", 1)
payload = json.loads(body) # 用真正的 JSON 解析拿到对象
payload["title"] = new_text
body = json.dumps(payload, ensure_ascii=False, separators=(",", ":"))
return "%s:T%x,%s" % (head, len(body.encode("utf-8")), body)
Two details that matter:
- The prefix counts UTF-8 bytes, not characters. Chinese text will silently corrupt if you use
len(str). - The record boundary is found by
json.raw_decode()at the top level, not by regex. Splitting the stream on"produces garbage the moment a string contains an escaped quote.
I now run this as a post-generation assertion: every T chunk must parse, and the declared length must equal the byte length of the following segment. It fails loudly at build time instead of silently in a browser.
Two examples
The same audit, two page shapes:
-
A static page with a sibling rail — plain HTML, one
main,asideas a sibling. Nothing can disappear on it. - A route from a hydrating app — server-rendered, hydration path removed so injected content survives. Same URL, different failure modes.
What I changed
The static checker still runs first — it is fast and catches typos, missing canonicals and duplicate analytics tags. But it is no longer allowed to be the gate on its own. Rendered measurements run second, on two viewports, with the table above as hard failures and a non-zero exit code.
Everything else — screenshots, read time, word count — is a report, not a check. If nothing can fail, nothing is being tested.
Top comments (0)