DEV Community

tong zhang
tong zhang

Posted on Edited on

Static HTML passed on all 46 pages. Rendered in Chrome, 8 were broken

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,
  };
});
Enter fullscreen mode Exit fullscreen mode

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"}
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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:

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)