DEV Community

Cover image for All Tests Passed. Safari on iOS Still Broke the Menu
Phil Rentier Digital
Phil Rentier Digital

Posted on Originally published at rentierdigital.xyz

All Tests Passed. Safari on iOS Still Broke the Menu

The request seemed local: stabilize the header and mobile menu on an older PrestaShop store.

The scope was deliberately narrow. Only two front-end files could change, with no edits to templates, modules, PrestaShop configuration, or data.

On desktop, everything looked right. On emulated mobile Chromium, everything looked right. On Playwright WebKit, everything looked right. Even 2,000 random actions across categories and submenus found nothing.

The test suite was green enough to qualify as renewable energy.

Then the result came back from a real iPhone. After opening the menu a few times, navigating across several pages, and scrolling inside a submenu, the entire header moved down, bounced, and sometimes settled in the wrong position.

On an e-commerce site, the mobile menu is the main road to categories and products. When it starts moving independently of the user's intentions, the interface does not merely look untidy. It becomes unreliable.

We did not have a difficult red test to fix. We had something more dangerous: a green test suite faithfully exercising the wrong physical path.

A green test is evidence, not absolution.

This postmortem explains why automation missed the problem, how a real iPhone exposed two defects with almost identical symptoms, and why the final fix required fewer lines than the investigation.

Why Every Automated Test Missed It

Our Playwright tests could open the hamburger menu, click a category, scroll the panel, and verify positions. They could repeat those operations thousands of times.

But they did not reproduce the physical input we observed.

A synthetic mouse.wheel event follows the wheel-event path. A finger on an iPhone produces a touch sequence, interacts with the native scrolling engine, and can move the visual viewport. That difference is not cosmetic. It changes which part of the browser makes the decision.

The WebKit engine bundled with Playwright is also not the Safari browser installed on an iPhone. Playwright explains that its WebKit build comes from recent WebKit sources, includes tool-specific patches, and does not automate branded Safari itself. The distinction is explicit in the Playwright browser documentation.

The automated tests were still valuable for DOM invariants:

  • The menu opens and closes.
  • Only one category is open at a time.
  • The panel keeps a valid height.
  • The page does not scroll while the menu is open.
  • Listeners are not multiplied after several cycles.
  • Closing the menu restores the initial state.

What they could not certify was the absence of rubber-banding on the real device.

The fuzz test had the same methodological flaw. Two thousand random actions sound impressive until none of them models the gesture that triggers the bug.

Two thousand wrong events are still wrong events, just with better statistics.

We replaced blind fuzzing with a deterministic journey:

  1. Open the menu from the home page.
  2. Open a submenu.
  3. Scroll it to the top and then to the bottom.
  4. Navigate to a category.
  5. Reopen the menu.
  6. Navigate to a product page.
  7. Use Safari's Back button.
  8. Repeat across 10 to 20 pages.

We kept the random campaign only after this path, as a secondary regression check.

The Browser Had More State Than Our Code Admitted

The site used a custom PrestaShop theme, jQuery, and a third-party multilevel menu module.

On mobile, the system combined at least six states:

  1. whether the main panel was open;
  2. which submenu classes were active;
  3. the panel's scroll position;
  4. the page scroll lock;
  5. the fixed header position;
  6. any state restored by browser history.

The browser added two more layers: the layout viewport, which calculates the page layout, and the visual viewport, which is the portion actually visible on screen. On mobile, they are not always the same. The address bar, virtual keyboard, zoom, and certain gestures can resize or move the visual viewport without triggering the document scroll event our code was watching. The distinction is documented in the VisualViewport API.

The symptom was misleading. The menu appeared to jump, so our first suspects were a scrollTop change, a height recalculation, or a conflict between position: sticky and position: fixed.

The video from the iPhone showed something else. The banner, logo, search field, and hamburger button all moved down together during the gesture, then moved back. The menu content was not merely scrolling too far. The visible viewport itself was following the finger.

The browser had reached a state our tests considered impossible. The browser, rather rudely, had not read the test plan.

Instrument a Real iPhone Without Taking Over Its Touch Input

We initially tried controlling Safari remotely. The page opened and WebDriver commands worked, but the automation session interfered with manual interaction on the iPhone. The human gesture was precisely the signal we needed to observe.

So we separated control from inspection.

We connected the iPhone to a Mac, enabled Web Inspector, and opened the page in mobile Safari. The Mac inspected the DOM, events, and viewport properties while the finger remained the real source of interaction. Apple documents the process in Inspecting iOS and iPadOS.

To avoid developing directly in production, we created a tightly constrained intermediate environment:

  • Public GET and HEAD requests were proxied to the site.
  • The theme's JavaScript file was replaced with the local candidate.
  • POST requests were rejected.
  • No database access was exposed.
  • A temporary HTTPS tunnel made the proxy reachable from the iPhone.

The proxy revealed two traps of its own. Some assets were initially rewritten to HTTP under an HTTPS page, and some protocol-relative URLs were interpreted as hostnames. Until those CSS errors were fixed, the page did not have the same geometry as production, so any conclusion about scrolling would have been invalid.

If the bug starts with a finger, do not debug it with a mouse and optimism.

Defect One: The Internal Scroller Handed the Gesture to the Viewport

A scrollable mobile menu panel creates a scroll chain. As long as its content can move, the panel consumes the gesture. When it reaches its upper or lower boundary, the browser can pass the remainder of the gesture to an ancestor and then to the viewport. The specification calls this mechanism scroll chaining.

The first line of defense was conventional:

html.menu-open,
body.menu-open {
  overflow: hidden;
  overscroll-behavior: none;
}

.mobile-menu-panel {
  overflow-y: auto;
  overscroll-behavior-y: contain;
  -webkit-overflow-scrolling: touch;
}
Enter fullscreen mode Exit fullscreen mode

This CSS remains useful. overscroll-behavior tells the browser whether a scrolling area should chain movement to an ancestor when it reaches a boundary. However, browser support and behavior have varied, as noted in MDN's documentation.

In our real sequence, CSS alone was not enough. The gesture began in the middle of a submenu, so the internal scroll was legitimate. The boundary was reached during that same gesture. Safari could then pull the visual viewport into its rubber-banding effect and even begin a pull-to-refresh action.

The fix added a touch guard active only while the menu was open. It distinguished three cases:

  • A scrollable area still has room, so native scrolling remains enabled.
  • The gesture begins outside the panel, so it is cancelled.
  • The scroller is at a boundary and the gesture tries to cross it, so it is cancelled before reaching the viewport.
let startY = 0;

function maxScrollTop(element) {
  return Math.max(0, element.scrollHeight - element.clientHeight);
}

function resolveScroller(target) {
  return target instanceof Element
    ? target.closest("[data-menu-scroller]")
    : null;
}

function onTouchStart(event) {
  if (event.touches.length !== 1) return;

  startY = event.touches[0].clientY;
  const scroller = resolveScroller(event.target);
  if (!scroller) return;

  const max = maxScrollTop(scroller);
  if (max <= 1) return;

  if (scroller.scrollTop <= 0) scroller.scrollTop = 1;
  if (scroller.scrollTop >= max) scroller.scrollTop = max - 1;
}

function onTouchMove(event) {
  if (event.touches.length !== 1 || !event.cancelable) return;

  const scroller = resolveScroller(event.target);
  if (!scroller) {
    event.preventDefault();
    return;
  }

  const deltaY = event.touches[0].clientY - startY;
  const max = maxScrollTop(scroller);
  const crossesTop = scroller.scrollTop <= 1 && deltaY > 0;
  const crossesBottom = scroller.scrollTop >= max - 1 && deltaY < 0;

  if (max <= 0 || crossesTop || crossesBottom) {
    event.preventDefault();
  }
}

function enableTouchGuard() {
  document.addEventListener("touchstart", onTouchStart, { passive: true });
  document.addEventListener("touchmove", onTouchMove, { passive: false });
}

function disableTouchGuard() {
  document.removeEventListener("touchstart", onTouchStart);
  document.removeEventListener("touchmove", onTouchMove);
}
Enter fullscreen mode Exit fullscreen mode

The { passive: false } option on touchmove is essential because a passive listener cannot cancel movement with preventDefault(). The compatibility details are covered in the TouchEvent documentation.

The guard also has a cost. A non-passive listener can delay scrolling while the browser waits for its decision. It must do minimal work, exist only while the menu is open, and be removed reliably.

The scrollTop clamping to 1 and max - 1 is a Safari-oriented workaround, not a universal recipe. It keeps the scroller away from its exact boundary at gesture start, but it must be validated against nested scrollers, horizontal gestures, Android, keyboard accessibility, and any visible one-pixel movement.

Defect Two: The Panel Closed, but Its Classes Stayed Open

After the touch fix, the short scenario stopped bouncing. Then a close-and-reopen test failed.

This time, the visual viewport was innocent. The system had two competing truths:

  • The main container was hidden.
  • A top-level item and its child panel still carried their open classes.

On the next opening, the arrow could indicate an open state while the content remained hidden, or the module could apply another transition to an already active state. The symptom still looked like a jump, but the cause was an incomplete DOM state machine.

The final fix was smaller than the diagnosis:

function resetSubmenus(menuRoot) {
  menuRoot
    .querySelectorAll(":scope > ul > li.is-submenu-open")
    .forEach((item) => item.classList.remove("is-submenu-open"));

  menuRoot
    .querySelectorAll(":scope > ul > li > .is-panel-open")
    .forEach((panel) => panel.classList.remove("is-panel-open"));
}

function closeMenu() {
  resetSubmenus(menuRoot);
  hideMenuPanel();
  disableTouchGuard();
  unlockDocumentScroll();
}

window.addEventListener("pageshow", (event) => {
  if (event.persisted) resetSubmenus(resolveCurrentMenuRoot());
  rebindCurrentMenuNodes();
  synchronizeMenuState();
});
Enter fullscreen mode Exit fullscreen mode

On a legacy site, remembering nodes once at DOMContentLoaded is not enough. A module can replace part of the DOM, reinstall handlers, or reveal an existing structure again. Back navigation can also restore a page from the back-forward cache with its DOM and part of its JavaScript state.

The pageshow event exposes that return. Its persisted property indicates a restoration from the bfcache, as documented in the reference article on the back-forward cache.

The solution was not to rerun the entire initialization after every navigation, which could duplicate the third-party module's listeners. It was to resolve the current nodes, attach every listener at most once, and synchronize the classes and scroll state.

Rollback Is a Feature, Not an Apology

Each deployment attempt followed a reversible process:

  1. Download and checksum the production file.
  2. Keep the backup on two machines.
  3. Upload only the validated JavaScript file.
  4. Read it back over SFTP.
  5. Compare the local, SFTP, and HTTP-served checksums.
  6. Run the short production journey.
  7. Restore the backup immediately if an invariant fails.

We rolled back the first deployment. The touch guard worked, but the close-and-reopen test exposed the leftover submenu classes.

That rollback was not a narrowly avoided disaster. It was a normal branch of the procedure.

Rollback is Ctrl+Z with operational discipline.

After the reset was added, final validation combined targeted state tests, the random regression campaign, and the full touch journey on the real iPhone. The final deployment changed one JavaScript file. No database, template, or module configuration was touched.

What We Will Test Differently Next Time

This intervention does not prove that every Safari bug requires a physical device. It proves that a test must cover the right engine, the right input source, and the right duration of user journey.

For a scrollable mobile panel, our shorter checklist is now:

  • Distinguish the layout viewport, visual viewport, document, and internal scroller.
  • Log scroll dimensions, event.cancelable, and visualViewport.offsetTop during reproduction.
  • Cross both scroll boundaries within a continuing touch gesture.
  • Verify opening, closing, listener cleanup, and bfcache restoration.
  • Replay the actual multi-page journey before adding random actions.
  • Document what Chromium and Playwright WebKit do not prove.
  • Validate native viewport behavior on the affected device.
  • Prepare checksums and rollback before deployment.

A green test says that the simulated path satisfied the assertions we wrote. It does not prove that the browser and the user followed that path.

When a bug depends on a finger, a mobile viewport, a legacy module, and a page restored from history, the answer is not a larger test counter. It is faithful reproduction, targeted instrumentation, and a deployment that can be reversed without drama.

Written by Phil, Inforeole.

Top comments (0)