ADR-0007: Popup playback-status delivery via a background per-tab map with push¶
Status¶
Accepted
Date¶
2026-07-12
Context¶
The toolbar popup is meant to answer, in one glance, "what is this tab doing?" Today it cannot: it renders from the stored settings signals (audioOnlyEnabledSignal, etc.), so on an auth-required, live, or otherwise-ineligible video — exactly the cases the credentialless-first design expects to fall back to normal playback — it still shows "Audio-only: Active" in green while the user is watching full video. The popup is dishonest on its most important line (see docs/design/ux-popup.md §1.2).
The real per-video outcome already exists. entrypoints/main-world.ts:emitStatus computes a PlaybackStatus (idle | fetching | active | fallback | disabled) with an honest reason (live, no-direct-audio, unplayable, http-<code>, not-a-watch-page, media-attach-failed, request-failed, or a playability status such as LOGIN_REQUIRED) and dispatches it as a yta:status DOM CustomEvent. content.ts listens, but writes it to dataset.ytaStatus/ytaReason only under __BENCH__; production discards it. The popup consumes none of it.
Problem Statement¶
Make the real, per-video PlaybackStatus available to the popup honestly — for the tab in front of the user — surviving SPA navigation and multiple tabs, so the popup can stop showing the stored toggle and can tell the truth on fallback videos.
Constraints¶
- Logged-out, credentialless, fail-open hard invariants hold: this is internal runtime messaging with no external egress (fine under
data_collection_permissions.required: ['none']), it must not touchPlayerHandle/<video>.src, and any messaging failure must never break playback or the page. - No new permissions.
tabsand the four YouTube host matches are already granted. - MV2 ships, MV3 stays buildable. No background-lifetime assumption beyond what the existing persistent MV2 background already makes.
- Multi-tab and SPA reality. YouTube is a single-page app; a tab's video changes without a document reload, and the user can have several YouTube tabs open. The popup always describes the active tab of the current window.
Decision¶
Deliver status through the background, which keeps a per-tab map and answers/pushes to the popup. The content script relays each yta:status event to the background; the background folds it into Map<tabId, TabStatusEntry>; the popup queries the active tab's resolved state on open and subscribes to pushes.
main-world.ts --yta:status DOM event--> content.ts
content.ts --yta:status-update ----> background.ts (per-tab map, keyed by sender.tab.id)
popup --yta:get-status -----> background.ts --> resolved PlaybackUiState
background.ts --yta:status-changed -----> popup (re-render when the active tab changes)
All decision logic (host classification, the update reducer, the resolved-state machine) lives in a pure, unit-tested module, src/shared/status.ts; the entrypoints are thin messaging glue.
Considered Options¶
- Popup ↔ content script directly. The popup asks the active tab's content script for its last
{status, reason}and renders the reply. - Pros: no background state; fewer moving parts.
- Cons: Injection race — on a just-opened or just-navigated tab the content script may not have initialized (or
document_starthas not reached the status yet), so the request errors and the popup has nothing to show. No push — a status that changes while the popup is open (fetching → active/fallback) never reaches it without polling. Per-open cost — every popup open round-trips to the page. SPA staleness — the content script would itself have to track which navigation a status belongs to. This pushes race-handling into the popup, the least reliable place for it. - Background per-tab map + push (chosen). Content pushes every status to the background; the background owns the per-tab truth, resolves the active tab on request, and pushes changes.
- Pros: The background is always alive to receive updates, so the map is populated even before the popup opens (no injection race at popup time — an absent entry resolves to an honest
connecting, not a lie). Push keeps an open popup live. Multi-tab is a natural map key (sender.tab.id). SPA staleness is handled centrally by the reducer + atabs.onUpdatednavigation clear + a video-id cross-check. The popup stays a thin consumer. - Cons: A little background state and three new message types; a non-persistent (MV3) background could in principle drop the in-memory map, tolerated because an empty map resolves to
connectingand the next status emit repopulates it. - Content mirrors to
browser.storage; popup reads storage. Content writes status tostorage.local; the popup reads and watches it. - Pros: Survives background restarts; reuses the settings-watch pattern.
- Cons: Persisting volatile per-tab playback state to disk is a category error (write amplification, cross-tab key collisions, stale values across restarts), and multi-tab needs a tab-keyed schema anyway. Storage is for settings, not live per-tab telemetry.
Chosen Option¶
Option 2. It is the only one that is honest at popup-open time (the background already holds the tab's status, so there is no injection race to lose to), keeps an open popup live via push, and makes multi-tab and SPA-staleness handling centralized and testable rather than scattered into the popup. The staleness and race reasoning the review raised is addressed concretely:
- Multi-tab: the map is keyed by
sender.tab.id; each tab is independent.get-statusresolvestabs.query({active, currentWindow});tabs.onRemoveddeletes the entry. - SPA staleness: each report carries
(runStart, generation).generationis a content-owned counter in the isolated content script: it begins a new value each time the forwardedvideoIdchanges, so a same-operationfetching→active(same video) keeps one generation while a new video supersedes the old. It is deliberately not taken from the observable, forgeableyta:statusevent, and it resets per document lifetime.runStartis a monotonic per-tab epoch fromnextStatusRunStart():max(Date.now(), previous + 1)over the last value persisted in per-tabsessionStorage, so a fresh load is always strictly later even across a same-millisecond collision or a system-clock rollback (the stored value is validated as a safe integer no more than a day pastnow, so the origin-shared page cannot poison ordering, and it falls back toDate.now()when storage throws). The reducer orders updates lexicographically: a report from a newer lifetime (runStart) always wins, and within one lifetime a strictly-oldergeneration(a superseded SPA navigation) is dropped, so a full reload (generation resets to 0) or a late message from an unloading old document can never freeze the tab's state, the pitfall a bare per-lifetime generation would hit.tabs.onUpdatedadditionally marks the entry stale, but only whenshouldMarkStaleagrees: aloadingreload, or a URL change to a different video id. A same-video URL rewrite (YouTube appending&t=/listparams during playback) is deliberately not stale, since marking it so would reject the same operation's ownactivereport and strand the popup onconnecting. A stale entry is then revived only by a strictly newergeneration, and the resolver additionally cross-checks the storedvideoIdagainst the active tab's URL, returningconnectingon a mismatch (a navigation outran its report). - Injection race: an absent/rejected/stale entry resolves to
connecting(an honest "checking this tab"), never to a stored toggle or an optimisticactive. - Trust boundary: the
yta:statusevent travels through the page world and is observable and forgeable by arbitrary page JS, so its ordering is generated in the isolated content context and never trusted from the event:content.tsassigns the content-ownedgenerationandrunStart, ignoring any generation the event carries, so a hostile page cannot forge a huge generation to poison ordering. Only display fields (status,reason,videoId) come from the event; a forgedvideoIdis caught by the resolver's URL cross-check, and a momentary forgedstatusis superseded by the next genuine report. At the background, updates are then accepted only from the top frame (sender.frameId === 0) of a real tab (sender.tab.id), and validated byparseStatusUpdatebefore storing. Sub-frames and extension pages are ignored. - Bounded:
get-statusis answered under a timeout that falls back toconnecting, and the popup's fetch is itself time-boxed, so nothing hangs.
Consequences¶
Positive¶
- The popup can render the truth for the current tab, including on fallback videos where it currently lies, from a signal the extension already produces — no new detection logic.
- Race, staleness, and multi-tab handling are centralized in
background.ts+ the puresrc/shared/status.ts, unit-tested without a browser and coverage-gated at ≥90%. - Fail-open throughout: content's relay, the background's broadcast, and the popup's fetch each swallow failures, so the status channel can never affect playback or the page.
Negative¶
- New in-memory background state and three message types to maintain.
- A non-persistent background (MV3) may drop the map; mitigated because an empty map resolves to
connectingand repopulates on the next emit (acceptable; MV2 is the shipping target and its background is persistent).
Neutral¶
- The
__BENCH__-gateddataset.ytaStatus/ytaReasonmarker is unchanged; the production push is purely additive, so the hermetic bench keeps reading the marker while production now also feeds the map. - The popup only exposes the resolved state (
playbackStatusSignal) in this pass; rendering the honest hero from it is a follow-up UI stack (SPEC-012, later sections).
Related ADRs¶
- ADR-0005 (PII-free diagnostics): the same
emitStatusvalue already feeds the diagnostics log; this ADR routes it to the popup instead of the disk log.
References¶
docs/specs/SPEC-012-holistic-ux-foundations.md— the status-channel specification.docs/design/ux-popup.md§1.2, §2 P0-1/P0-2 — the "popup is blind to the tab" review.src/shared/status.ts,entrypoints/{content,background}.ts,entrypoints/popup/playback-status.ts— the implementation.