DEV Community

Chris
Chris

Posted on • Originally published at paragraph.com

How We Fixed Firefox's localStorage Race in Playwright: Two Navigation Helpers

TL;DR

Firefox's addInitScript can race against page rendering when seeding localStorage for E2E tests. We fixed it by splitting navigation into two helpers: gotoPage (fast, commit) for interactive flows and gotoAgreementPage (reliable, domcontentloaded) for data-dependent pages.

The Problem

We use Playwright's addInitScript to seed localStorage with mock agreement data before each test. This works perfectly in Chromium — the init script runs, sets localStorage, and by the time React mounts, the data is there.

Firefox? Not so much. About 25% of our lifecycle tests were failing with "Loading agreement..." stuck on screen. The page rendered before the init script finished writing to localStorage.

The Diagnosis

Playwright's error context snapshots told the story. On first failure:

- paragraph: Loading agreement...
Enter fullscreen mode Exit fullscreen mode

On retry (with more time):

- heading "Retainer Agreement"
- button "Connect Wallet"  # Auth data missing too!
Enter fullscreen mode Exit fullscreen mode

The page loaded fine on retry, but auth data from localStorage wasn't picked up either. Classic timing issue.

The Fix

We had one navigation helper:

export async function gotoPage(page: Page, url: string) {
  await page.goto(url, { waitUntil: 'commit' });
  await page
    .getByRole('link', { name: /Papre/i })
    .first()
    .waitFor({ state: 'visible', timeout: 30000 });
}
Enter fullscreen mode Exit fullscreen mode

commit returns as soon as the server responds — before scripts execute. Fast for interactive tests, but too fast for Firefox when init scripts need to complete.

We added a second helper:

export async function gotoAgreementPage(page: Page, url: string) {
  await page.goto(url, { waitUntil: 'domcontentloaded' });
  await page
    .getByRole('link', { name: /Papre/i })
    .first()
    .waitFor({ state: 'visible', timeout: 30000 });
}
Enter fullscreen mode Exit fullscreen mode

domcontentloaded waits for the HTML to be fully parsed and all deferred scripts to execute. This gives addInitScript time to complete before React reads localStorage.

The key insight: you can't use domcontentloaded everywhere. Our wizard/create tests use keyboard shortcuts (Ctrl+Shift+D for auto-fill) immediately after navigation. The extra wait from domcontentloaded caused those tests to timeout — the page wasn't interactive fast enough.

So: gotoPage for interactive flows, gotoAgreementPage for data-dependent pages.

Results

  • Before: 23 Firefox failures out of 240 overnight tests
  • After: 240/240 pass across Chromium, Firefox, WebKit, Mobile Chrome, Mobile Safari

The Broader Session

Beyond the Firefox fix, we shipped:

  • 8 retainer lifecycle tests (full state machine)
  • NDA gap tests (one-way variant, terminate action)
  • Safety-net gap tests (expired deadline enforcement, dispute evidence)
  • GitHub Actions overnight workflow
  • 3 app bugs found only through E2E coverage

Builder's Note

Spent most of the day thinking about fragmentation — how splitting yourself into roles creates anxiety. The code mirrored that perfectly: scattered tests across directories, inconsistent browser behavior. Consolidation brought clarity to both. Wholeness is ease.


Building Papre — composable agreement infrastructure for trustless coordination.

Top comments (0)