We shipped a small blog editor inside our dashboard. The same evening I opened it and the form was cut off at the bottom of the screen. The body field was half visible. Everything under it, the FAQ section and the publish options, was simply gone. The mouse wheel did nothing. Page Down did nothing.
The end-to-end test for that exact form was green. It filled the form, clicked "Add question" at the very bottom, published the post, and checked that it appeared on the public blog. Every step passed.
So the test could reach a button that no human could reach. This post is about how that happens, and the one-line check that would have caught it.
The bug itself
Our dashboard has a mail view that fills the screen exactly, like a desktop mail client. The list and the reader each scroll on their own, and the outer shell never scrolls. The shell looks like this:
.dash {
display: grid;
height: 100vh;
overflow: hidden;
}
Most pages don't render inside that full-height mode. They render inside a normal scrolling container instead. The code picks between the two with a condition, and that condition was a list of exclusions:
const isMailFull =
!billing && !profile && !admin &&
mode === "person" && nav === "inbox-mail";
The blog editor was a new top-level page. I added it to the router and forgot to add it to this list. It opened in the mail mode, so it was rendered inside the fixed shell with overflow: hidden. The form was taller than the screen, and the bottom of it was clipped with no way to scroll down.
The fix was two words, && !blog, plus a comment on that line telling the next person to add new pages there. The interesting part is why the test said everything was fine.
overflow: hidden does not mean "cannot scroll"
This is the part I had wrong in my head. An element with overflow: hidden is still a scroll container. It clips its content and it hides the scrollbar. It does not listen to the mouse wheel or to the keyboard. But its scrollTop is still a live property, and anything that scrolls it from code still works: element.scrollTop = 500, element.scrollIntoView(), and focus moving to an element inside it.
Playwright's click() runs actionability checks before it clicks. One of them is: if the element is not in view, scroll it into view. It does that from code, so an overflow: hidden ancestor is no obstacle at all.
I didn't want to put that in a post based on my reading of docs, so I measured it in a tiny page:
import { chromium } from "playwright";
const html = `
<style>
body { margin: 0 }
.shell { height: 100vh; overflow: hidden }
.form { height: 2000px; position: relative }
#btn { position: absolute; top: 1800px }
</style>
<div class="shell"><div class="form">
<button id="btn" onclick="window.clicked = true">Add question</button>
</div></div>`;
const browser = await chromium.launch();
const page = await browser.newPage({ viewport: { width: 1200, height: 800 } });
await page.setContent(html);
const shell = page.locator(".shell");
// What a person can do
await page.mouse.move(600, 400);
await page.mouse.wheel(0, 1500);
await page.keyboard.press("End");
console.log("user scrollTop:", await shell.evaluate((e) => e.scrollTop));
// What the test does
await page.locator("#btn").click();
console.log("clicked:", await page.evaluate(() => window.clicked));
console.log("test scrollTop:", await shell.evaluate((e) => e.scrollTop));
await browser.close();
The output:
user scrollTop: 0
clicked: true
test scrollTop: 1200
The wheel and the End key moved the page by zero pixels. The button stayed below the fold. Then click() scrolled the same container by 1200 pixels on its own and clicked the button. From the test's point of view nothing was wrong, because for the test nothing was.
Why nothing else caught it either
This one got past every layer we have. The unit tests don't render the layout. The production build doesn't care about CSS. The end-to-end suite drove the real form in a real browser and the form worked.
My first attempt at a fix was also wrong. I saw a cramped form, decided it was a width problem, and widened the editor. It shipped, it looked better, and the form was still cut off. I had checked the thing I expected instead of the thing that was reported. "I can't get to the bottom of the form" is a scrolling complaint, not a width complaint.
The check that catches it
The test now asserts the thing the user actually needs: the page is in the scrolling container, not the fixed one, and that container really scrolls.
await expect(page.locator(".content .blog-page")).toHaveCount(1);
await expect(page.locator(".content-fill .blog-page")).toHaveCount(0);
const scroller = page.locator(".content").first();
const canScroll = await scroller.evaluate((el) => {
el.scrollTop = el.scrollHeight;
return el.scrollTop > 0;
});
expect(canScroll, "blog editor cannot be scrolled").toBe(true);
A caveat on that last check: setting scrollTop from code also works on an overflow: hidden element, which is the whole point of this post. It is meaningful here only because it targets .content, which is supposed to be overflow-y: auto, together with the two assertions above it. If you want a check that doesn't depend on knowing your class names, drive the page the way a person does and then look:
await page.mouse.move(600, 400);
await page.mouse.wheel(0, 5000);
await expect(page.getByRole("button", { name: "Add question" })).toBeInViewport();
mouse.wheel goes through the browser's real scroll handling, so a clipped container stays at zero and toBeInViewport() fails.
Before trusting the new test I put the bug back, reverting the two-word fix, and ran it. It went red. Then I restored the fix and it went green. A regression test that you have never seen fail hasn't shown you anything yet.
What I took from it
Playwright is doing the right thing. Auto-scrolling before a click is what you want in almost every test, otherwise every test would be full of manual scrolling. But it means click() answers "does this button work?" and not "can a person get to this button?". Those are different questions, and a layout bug lives entirely in the gap between them.
So now, for any page that is taller than the screen, one test reaches the bottom the way a user would: with the wheel, and with an assertion on what's visible, before it clicks anything.
We also have a rule written next to that condition now: a new top-level page in the dashboard means checking that list. It's a list of exclusions, and a list of exclusions fails quietly when you forget to add to it. That is probably the real bug, and a good candidate for the next refactor.
I'm building MailFlat, a permanent encrypted inbox with an email API for developers and AI agents. I write about bugs that pass locally and fail in production.
Top comments (0)