Skip to content

feat: runtime offscreen polyfill (fixes OneNote-class breakage) - #9

Merged
OtsoBear merged 10 commits into
mainfrom
feature/offscreen-polyfill
Jul 31, 2026
Merged

feat: runtime offscreen polyfill (fixes OneNote-class breakage)#9
OtsoBear merged 10 commits into
mainfrom
feature/offscreen-polyfill

Conversation

@OtsoBear

@OtsoBear OtsoBear commented Jul 31, 2026

Copy link
Copy Markdown
Owner

Summary

Adds a runtime chrome.offscreen polyfill for Firefox, closing the gap
described in #2: extensions that call chrome.offscreen.createDocument/
chrome.runtime.getContexts without feature-detecting them crash
synchronously on Firefox (chrome.runtime.ContextType.OFFSCREEN_DOCUMENT is
undefined there, and Firefox's parameter validation throws on it), which
commonly cascades into killing an extension's entire post-install setup when
that call sits behind an unguarded .then() chain -- exactly OneNote Web
Clipper's shape.

Root cause → polyfill → gate → measurement

  1. Root cause (Extensions using chrome.offscreen without feature-detection break entirely on Firefox (e.g. OneNote Web Clipper) #2): OneNote's background script calls
    chrome.runtime.getContexts({contextTypes:[chrome.runtime.ContextType.OFFSCREEN_DOCUMENT], ...})
    with no guard. Firefox has no offscreen-document context type at all, so
    this throws synchronously, and the throw propagates through an
    unhandled-rejecting promise chain (clipperIdProcessed) that gates
    registerContextMenuItems(), onInstalled(), and the actual clip-trigger
    path -- none of it ever runs on Firefox.

  2. Polyfill (shims/offscreen-polyfill.js, injected only when a
    background section exists and the extension actually references
    chrome.offscreen/getContexts with an offscreen context type):

    • Backfills chrome.runtime.ContextType.OFFSCREEN_DOCUMENT as a real
      string, removing the Firefox validation throw.
    • Wraps runtime.getContexts to report a synthetic offscreen-document
      context once one has been created.
    • Implements chrome.offscreen.createDocument/closeDocument/
      hasDocument, backed by a hidden iframe in the background page (the
      converted background is an event page with a DOM, so this works).
  3. Differential gate (e2e/testdata/offscreen-extension, corpus entry
    offscreen-gate): a synthetic extension exercising the same
    getContexts+createDocument+sendMessage pattern OneNote uses, with no
    feature-detection guard. Passes with identical stored results on both
    browsers
    , confirming the polyfill is functionally correct end-to-end,
    not just trace-clean. (The offscreen.*/runtime.getContexts*/
    runtime.getURL#offscreen.html allowances on this entry are a pre-existing
    tracing limitation, Spy shim injected before converter compat shims (shims/*.js), hiding converter-added API surface from the trace diff #6 -- the harness's spy shim runs before the
    converter's own compat shims, so polyfill-internal calls are untraced on
    Firefox. Documented in the entry's _notes.)

  4. OneNote measurement: re-triaged e2e/corpus.json's OneNote entry
    against the polyfill. Firefox's traced background-event count went from
    8 to 7 -- but that drop is NOT a regression: Firefox now demonstrably
    executes further into OneNote's code (getContexts succeeds,
    createDocument succeeds, the offscreen document loads, the first relay
    message is sent -- none of that ran before). The lower raw count is a
    side effect of issue Spy shim injected before converter compat shims (shims/*.js), hiding converter-added API surface from the trace diff #6 (those steps run through the polyfill's untraced
    surface). What actually stops it now is a second, independent bug in
    OneNote's offscreen-relay messaging -- filed as chrome.runtime.onMessage: async listener that calls sendResponse synchronously has its response discarded on Firefox (exposed by OneNote once the offscreen polyfill unblocks it) #8, with a decisive,
    deterministic reproduction: an async onMessage listener that also
    calls sendResponse synchronously has its response silently discarded by
    Firefox's native chrome.* compat surface, unlike Chrome. OneNote is
    still non-functional end-to-end on Firefox, but the specific failure
    mode Extensions using chrome.offscreen without feature-detection break entirely on Firefox (e.g. OneNote Web Clipper) #2 reports (unguarded offscreen API usage killing everything) is
    fixed for extensions in this shape generally, per the gate's clean pass.
    Full numbers and allowlist rationale: see the comment on Extensions using chrome.offscreen without feature-detection break entirely on Firefox (e.g. OneNote Web Clipper) #2 and
    e2e/corpus.json's updated _notes. Entry stays quarantined: true
    (blocked separately on E2E harness backlog: kill/wake probe, alarms fast-forward, monkey crawler + LLM judge, CI watchlist #5, no fixture host).

  5. Issue chrome.runtime.onMessage: async listener that calls sendResponse synchronously has its response discarded on Firefox (exposed by OneNote once the offscreen polyfill unblocks it) #8 regression control: added a second, isolated onMessage
    listener to the offscreen-gate extension, matching OneNote's exact
    async-listener/manual-sendResponse shape on its own message kind, to
    turn the chrome.runtime.onMessage: async listener that calls sendResponse synchronously has its response discarded on Firefox (exposed by OneNote once the offscreen polyfill unblocks it) #8 theory into a decisive, reproducible probe rather than an
    inference from code reading alone. Confirmed (2 consecutive runs): Chrome
    resolves sendMessage to the real sendResponse value; Firefox resolves
    it to undefined. This divergence is intentionally allowlisted (tight,
    content-pinned patterns) and documented as a permanent regression gate to
    delete once a compat fix ships -- not something quietly worked around.

Test plan

Final fix wave (this update)

  • Callback-form support: getContexts, createDocument,
    closeDocument, hasDocument now accept an optional trailing callback
    (Chrome's classic calling convention) in addition to the existing Promise
    form -- cb(result) on success, cb(undefined) on failure.
  • Parity fixes: an explicit empty contextTypes: [] filter now matches
    everything (native semantics), matching !hasTypes handling instead of
    matching nothing; a disconnected iframe (window.close()/DOM teardown) is
    now treated as "no document" and self-heals on the next
    createDocument/hasDocument/getContexts call instead of staying wedged
    on a stale reference; createDocument now rejects if the resolved URL
    isn't an extension-origin page, matching Chrome's own guard.
  • New gate probe 3 (e2e/testdata/offscreen-extension/background.js):
    exercises hasDocument/closeDocument via the callback form, then
    recreates the document and repeats the parse round-trip. Chrome (native)
    and Firefox (polyfill) store byte-identical results
    (offscreenCallbackResult) -- confirmed via trace inspection, matched
    automatically with no new allowed_diffs entry required.
  • Spec updated (docs/superpowers/specs/2026-07-31-offscreen-polyfill-design.md):
    Verification section now reflects the shipped OneNote measurement (8→7
    traced events while executing strictly further, not "8 toward 42"; Chrome
    traces 36 this session) and the actual crash root cause (issue chrome.runtime.onMessage: async listener that calls sendResponse synchronously has its response discarded on Firefox (exposed by OneNote once the offscreen polyfill unblocks it) #8, proven
    by the gate's regression control), replacing the earlier draft's
    speculative framing. Documents the background-section injection
    precondition and two known behavioral limits (offscreen doc doesn't
    survive event-page suspension; window.close() self-teardown is a no-op).
  • Follow-up issue filed: chrome.offscreen polyfill: constant contextId, iframe error-listener outliving its promise, createDocument resolves on missing pages #10, tracking three remaining known polyfill
    gaps intentionally left out of this wave (constant contextId across
    recreate cycles, iframe error-listener outliving its promise,
    createDocument resolving instead of rejecting for a missing page).

Refs #2, #6, #8, #10.

OtsoBear added 8 commits July 30, 2026 21:43
Firefox has no chrome.offscreen and its runtime.getContexts rejects the
OFFSCREEN_DOCUMENT context type, so OneNote-class extensions that call
these APIs unguarded throw and die on startup after conversion.

Adds a conditionally-injected shim (shims/offscreen-polyfill.js) that
emulates the offscreen document as a hidden iframe and patches
getContexts to report it. Unlike the existing always-on shims, this one
only fires when the manifest declares the "offscreen" permission or a
packaged .js file references chrome.offscreen/OFFSCREEN_DOCUMENT, via a
shared should_inject_offscreen_polyfill() predicate used by both the
shim generator and the manifest background.scripts wiring (which lists
it before the extension's own scripts). offscreen_converter.rs (the
static, per-usage offscreen conversion path) is untouched.
generate_shims() and the conversion-report note both gated purely on
should_inject_offscreen_polyfill() (the trigger), while
transform_background() only wires shims/offscreen-polyfill.js into
background.scripts when a background section exists. A triggering
extension with no background got an orphaned shim file plus a
misleading "injected" report note.

Adds will_inject_offscreen_polyfill() = trigger && background.is_some()
as the single source of truth for all three call sites (shim
generator, manifest wiring, report note), so they can't drift again.
Adds a minimal test extension (aliased, unguarded getContexts +
createDocument, no feature detection) reproducing the OneNote failure
shape as a non-quarantined e2e corpus entry. Chrome runs real offscreen
documents; Firefox runs the converted build through Task 1's polyfill;
equivalence is asserted via the storage.local.set offscreenResult write,
verified to match on both sides (not just a clean trace diff).

allowed_diffs widened beyond the original offscreen.* pattern to also
cover runtime.getContexts* and a pinned runtime.getURL call, both
consequences of the same spy-shim-before-polyfill injection order
(issue #6) one layer deeper than expected -- documented in _notes, no
shim code changed.
Local Firefox auto-updated 152.0.6 -> 153.0.1, pulling in geckodriver
0.37.1 which blocks WebDriver navigation to internal schemes
(moz-extension:, about:, chrome:) from content context. Launching the
geckodriver service with --allow-system-access restores popup
navigation without needing a chrome-context switch (which turns out to
be a dead end: navigateTo is content-context-only regardless).
The polyfill unblocks the original getContexts crash (#2): Firefox now
creates the offscreen document and getContexts reports it present. But
OneNote's offscreen-communicator relay hits a second, independent bug
one layer deeper -- its onMessage listener is an async function that
also calls sendResponse synchronously, and Firefox's native chrome.*
compat surface discards that response in favor of the async function's
own (always-undefined) implicit promise resolution, crashing on
JSON.parse(undefined). Filed as #8, with the same "OneNote's own bug,
not a chrome2moz mis-conversion" reasoning as #2.

Since OneNote still gates its whole post-install setup behind the same
unhandled-rejecting promise chain, the same downstream patterns
(getManifest, tour tab, contextMenus, etc.) remain chrome-only for a
different reason than before. Deleted the four pre-polyfill cascade
patterns and re-derived allowed_diffs from the current traces:
re-added offscreen.*/runtime.getContexts*/runtime.getURL#offscreen.html
as issue-#6 tracing-blind-spot allowances (same style as the
offscreen-gate entry), added runtime.sendMessage#offscreen and
runtime.error#JSON.parse for #8's crash and its now-unreached
downstream calls, and rewrote every remaining note to point at the new
root cause. Entry stays quarantined (issue #5, no fixture host).

pnpm e2e --only gojbdfnpnhogfdgjbigejoaolejmgdhk: PASS, 0 unallowed
divergences (Firefox 7 background events, Chrome 36 -- was 8 vs 36
pre-polyfill; see #2 comment for full before/after).
Review on PR #9 found the 8->7 Firefox event-count writeup had the
crash-point direction backwards: issue #8's crash happens LATER in
OneNote's actual control flow than the old getContexts throw (getContexts
succeeds, createDocument succeeds, the offscreen document loads, the
first relay message sends -- none of that ran before). The lower raw
count is an issue-#6 tracing-blind-spot artifact (getContexts/createDocument
now run through the polyfill's untraced surface), not less progress.
Rewrote corpus.json's _offscreen_relay_root_cause note and the task
report to state this explicitly, and to stop treating raw event count
as a progress metric on this entry. Marked the pre-polyfill 8-event
trace as not re-derivable (results/ is gitignored, overwritten by this
session's own runs) instead of implying it's known.

Added a decisive, isolated regression-control probe to the offscreen-gate
synthetic extension: a second onMessage listener matching OneNote's exact
shape (async function, manual synchronous sendResponse, no return true),
on its own message kind/storage key so it can't touch the already-passing
baseline probe. Confirmed deterministically (2 runs): Chrome's sendMessage
resolves to the real sendResponse value, Firefox's resolves to undefined,
discarding it -- proving the issue #8 theory decisively rather than
asserting it from code inspection alone. The resulting divergence is
allowlisted with four tight, content-pinned patterns and a note stating
this is an intentional, permanent regression gate to delete once
chrome2moz ships a compat fix -- not a tracing artifact to quietly work
around.

Also: pinned OneNote's runtime.getURL allowance to its actual observed
(normalized <ext>/... ) trace form instead of the looser unnormalized
one that happened to still match; added a side-agnostic-masking caveat
to the getContexts* note; corrected "reaches the offscreen document
round-trip successfully" to state the response leg fails.

cargo test: 70 passed, 1 ignored (was mis-reported as "15+12" off a
truncated tail). pnpm e2e --only offscreen-gate: PASS, 0 unallowed
divergences (now includes the issue-#8 regression-control probe). Full
pnpm e2e: exit 0, all 3 entries PASS.

Issue #2 comment edited in place to mark the Chrome baseline as
not re-measured pre-polyfill and add the likely pingProbe-fix
explanation for the ~42->36 figure. Issue #8 comment added with the
decisive repro plus two precision corrections (multi-listener
response-channel caveat; Chrome doesn't "fall back" to the promise,
it ignores it once sendResponse fired).
OtsoBear added 2 commits July 30, 2026 23:13
getContexts/createDocument/closeDocument/hasDocument now accept an
optional trailing callback (Chrome's classic calling convention)
alongside the existing Promise form. Also: an explicit empty
contextTypes filter now matches everything (native semantics) instead
of matching nothing; a disconnected iframe (window.close()/DOM
teardown) is treated as no document and self-heals on the next call;
createDocument rejects non-extension-origin URLs, matching Chrome's
own guard.
…tion

Add a third offscreen-gate probe exercising hasDocument/closeDocument
via callback form, then recreate + parse round-trip -- Chrome (native)
and Firefox (polyfill) store byte-identical results, matched with no
new allowed_diffs entry needed.

Spec's Verification section now reflects the shipped OneNote
measurement (8->7 traced events while executing strictly further, not
"8 toward 42"; root cause is issue #8, proven by the gate's regression
control) instead of the draft's speculative framing, documents the
background-section injection precondition, and adds two known
behavioral out-of-scope limits (suspension non-survival, window.close()
self-teardown no-op) plus a pointer to follow-up issue #10 for the
remaining known polyfill gaps.
@OtsoBear
OtsoBear merged commit 71b27a7 into main Jul 31, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant