Skip to content

fix(content): MutationObserver retry for tryInitVideo late-appearing video element #13

Description

@Kotmin

Problem

TC-01/TC-07 (scripts/test-extension-e2e.mjs, Firefox e2e, playback-speed
assertions) flake intermittently — alternating which one fails across runs,
never both together, while TC-17/TC-18 (queue-overlay, same content script)
pass consistently. Root-caused by code inspection, not yet fixed in code per
K's direction (investigate + file issue only, no implementation this pass).

Root cause

tryInitVideo (apps/shared/src/content/content.js) is a one-shot,
debounced (300ms) call to findVideoElement(document) =
document.querySelector('video'). It runs exactly once per trigger with no
retry:

  • On cold load: called unconditionally at the end of init() if
    isYouTubeWatchPage(location.href). Content script runs at
    document_idle (apps/firefox-extension/manifest.json), which guarantees
    the initial HTML parse finished, not that YouTube's client-rendered
    <video> element already exists. If it appears after the 300ms debounce
    window closes, tryInitVideo finds nothing and never runs again for that
    page load.
  • The only other trigger, siteAdapter.onNavigate()
    (src/site-adapters/youtube/youtube-site-adapter.js), listens for
    yt-navigate-finish but ignores same-id events: lastId is captured from
    window.location.href at registration time, which on a cold/hard
    navigation already equals the target page's id. So YouTube's own
    hydration-completion signal (same id, later timing) can never serve as a
    retry — the callback only fires on an actual page-to-page SPA
    navigation.

Compounding factor: createPlayerAdapter (src/player-adapters/html5-video-player-adapter.js)
binds to one specific DOM node with no rebind path if YouTube swaps the
<video> element later (e.g. ad transition). isReady() is defined
(videoElement.readyState >= 1) but is dead code — no call sites anywhere
in the repo — suggesting a readiness-gating mechanism was planned but never
wired in.

Proposed fix

Add a bounded MutationObserver fallback so tryInitVideo catches a
late-appearing <video> element instead of giving up after one check:

  • On the same trigger paths as today (init()'s cold-load call and
    onNavigate()'s callback), if findVideoElement finds nothing, start
    observing (childList + subtree) a narrow-as-possible root — ideally a
    YouTube app container rather than document.body — for mutations, and
    re-run findVideoElement on each mutation batch.
  • On success: disconnect the observer immediately, then proceed with the
    existing createPlayerAdapter(el) setup.
  • On a new navigation cycle starting (onNavigate firing again) or a bound
    attempt-count/timeout being exceeded: disconnect the observer instead of
    leaving it running past its useful window.
  • Possibly revive isReady() as part of the found-element check instead of
    just existence, since a freshly-inserted <video> node can still be at
    readyState 0.

Open doubts

  • What to observe: document.body is simplest but fires on every DOM churn
    YouTube does (a lot, on a video-heavy SPA); a narrower YouTube-specific
    container (e.g. #movie_player or ytd-app) would cut mutation-callback
    volume but needs confirming it exists early enough itself, and isn't
    itself replaced across navigations.
  • Whether to cap observation with a timeout/attempt count, or let it run
    until the next navigation resets it — an unbounded observer on a page
    where the video genuinely never loads (e.g. removed/private video) would
    sit connected doing nothing useful indefinitely.
  • How this interacts with the existing onNavigate/yt-navigate-finish
    path so the two retry mechanisms don't fight each other or double-invoke
    tryInitVideo for the same page load.
  • Whether reviving isReady() is in scope here or a separate concern —
    finding the node is not the same problem as the node being ready to read
    playbackRate from.

Memory-efficiency constraint

Must not accumulate MutationObserver instances over a long-lived tab's
session. YouTube is an SPA — a single real tab can fire many
yt-navigate-finish navigations without the content script ever being
re-injected, unlike the e2e suite's hard-navigation-per-test-case pattern
(navigate() in scripts/test-extension-e2e.mjs does a full
window.location.href reload, which is not representative of real usage
here). The fix needs:

  • At most one live observer per content-script instance at any time — reuse
    a single observer reference (disconnect + reconnect, or just leave
    disconnected until needed) rather than new MutationObserver on every
    tryInitVideo invocation.
  • Guaranteed disconnect on the success path and on navigation-reset, so cost
    doesn't grow with number of navigations within one page/tab.
  • Total added cost should scale with number of open tabs/pages (one
    content-script instance each), not with number of navigations any single
    tab makes over its lifetime.

Non-goals for this issue

No code changes here — investigation + fix proposal only, per K's
direction. Implementation is a separate follow-up once the open doubts
above are resolved.

Relates to docs/specs/keyboard-shortcuts.md (E2E section, TC-01/TC-07).

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions