Files
playwriter/docs/page-ready-investigation.md
Tommy D. Rossi 2b0a5db49e adding docs
2026-01-01 12:00:02 +01:00

4.3 KiB

Page Ready Investigation Summary

Goal

Convert undeterministic sleep() calls into deterministic event waits when toggling the extension.

Problem

After toggleExtensionForActiveTab() returns, the page is not immediately available in Playwright's context.pages(). Tests currently use arbitrary sleeps (100-400ms) to work around this.

Key Events Required

For a page to appear in context.pages(), Playwright needs:

  1. Target.attachedToTarget - Tells Playwright a new target exists with its URL
  2. Runtime.executionContextCreated - Tells Playwright the JS context is ready

The page appears in context.pages() only after BOTH events are processed.

Current Flow (Timeline from test)

  63ms ← EVT Target.attachedToTarget        # Extension sends to relay
 431ms ← EVT Runtime.executionContextCreated # Relay's proactive Runtime.enable
 437ms → CMD Runtime.enable                  # Playwright sends its own
 438ms   Toggle completes (pageReady received)
 467ms ← EVT Runtime.executionContextCreated # Response to Playwright's Runtime.enable  
 728ms   Page appears in context.pages()

Root Cause

The ~300ms delay between toggle completing and page appearing is caused by:

  1. Extension's Runtime.enable handler (background.ts:256-272):

    • Always calls Runtime.disable then Runtime.enable
    • Has a 50ms sleep() between them
    • This is needed to force Chrome to re-send executionContextCreated events for multiple clients
  2. Double Runtime.enable cycle:

    • Relay proactively enables Runtime → events at ~430ms
    • Playwright receives Target.attachedToTarget, sends its own Runtime.enable
    • Extension does disable+enable again → events at ~470ms
    • Playwright waits for ITS events before adding page to pages()
  3. Playwright's internal processing:

    • Even after receiving events, Playwright takes time to create Page objects
    • This is async and cannot be controlled from our side

What We Tried

  1. Proactive Runtime.enable in relay - Enable Runtime before forwarding Target.attachedToTarget

    • Helps get events faster, but Playwright still calls Runtime.enable itself
  2. Skip disable cycle if recently enabled - Track recent enables in extension

    • Breaks because relay's Runtime.enable handler waits for events that won't come
  3. pageReady handshake - Extension waits for relay confirmation before returning from attachTab

    • Toggle now waits for executionContextCreated
    • But Playwright STILL calls Runtime.enable after, causing another cycle

The Core Issue

Playwright always calls Runtime.enable after receiving Target.attachedToTarget, regardless of whether we pre-enabled it. The extension's disable+enable cycle adds ~200ms, and we cannot skip it without breaking the multi-client case.

Possible Solutions

Option 1: Accept the delay, use proper waiting

Instead of sleep, use context.waitForEvent('page') with a predicate:

await serviceWorker.evaluate(() => globalThis.toggleExtensionForActiveTab())
const page = await context.waitForEvent('page', { 
  predicate: p => p.url().includes('target-url') 
})

Option 2: Expose a "page ready" promise from the relay

Add an endpoint or event that resolves when the page is fully ready in Playwright:

await serviceWorker.evaluate(() => globalThis.toggleExtensionForActiveTab())
await relay.waitForPageReady(sessionId) // Waits for Playwright to process everything

Option 3: Have extension track Runtime state per session

Skip the disable+enable if:

  • This is the SAME Playwright client that just received Target.attachedToTarget
  • The session was JUST created (within last 100ms)

This requires tracking which client enabled Runtime and when.

Recommendation

Option 1 is the simplest and most reliable. The delay is inherent to how Playwright processes CDP events. We cannot eliminate it, but we can wait for it properly using context.waitForEvent('page') instead of arbitrary sleeps.

The test should be:

const pagePromise = context.waitForEvent('page', { 
  predicate: p => p.url().includes('discord.com'),
  timeout: 5000 
})
await serviceWorker.evaluate(() => globalThis.toggleExtensionForActiveTab())
const page = await pagePromise
// Page is now guaranteed to be ready