Skip to content

Fix #86679: Prevent document title flicker during navigation - #1

Draft
DragonBot00 wants to merge 1 commit into
mainfrom
fix/86679-title-flicker
Draft

DragonBot00 wants to merge 1 commit into
mainfrom
fix/86679-title-flicker

Conversation

@DragonBot00

Copy link
Copy Markdown
Owner

Proposal

Please re-state the problem that we are trying to solve in this issue.

When navigating away from any page (not just expense reports), the browser tab title briefly flashes blank or shows "New Expensify" before settling on the correct destination title. This is caused by document.title = '' being applied on every updateDocumentTitle() call, and multiple triggers firing during a single navigation transition.

What is the root cause of that problem?

One line in src/libs/UnreadIndicatorUpdater/updateUnread/index.ts line 30:

document.title = '';

This blank write runs inside setTimeout(0) on every call to updateDocumentTitle(), not just back-navigation. During a single navigation, 4 independent triggers each schedule their own setTimeout(0) with the blank→real sequence:

  1. popstate listener (line 51-53)
  2. useDocumentTitle focus effect (new screen gains focus)
  3. Navigation state listener (debounced 300ms in UnreadIndicatorUpdater)
  4. Onyx recompute triggers

The browser paints intermediate frames with the blank title between these macrotasks. Under main-thread load the setTimeout(0) queue slips, widening the flicker window.

Additionally, no deduplication exists — if updateDocumentTitle() fires 4 times in 50ms, 4 separate setTimeout(0) callbacks each do document.title = '' then set the real title, creating 4 opportunities for the browser to paint a blank tab.

What changes do you think we should make in order to solve the problem?

Three surgical changes, all in src/libs/UnreadIndicatorUpdater/updateUnread/index.ts. Zero API changes — setPageTitle and updateUnread keep their signatures. No new files, no platform splits, no changes to useDocumentTitle.ts or NavigationRoot.tsx.

Change 1: Gate the Chrome workaround behind popstate only

// Module scope
let isPopstateNavigation = false;

// Only popstate sets this — the ONLY case where the Chrome title-reversion bug occurs
window.addEventListener('popstate', () => {
    isPopstateNavigation = true;
    updateUnread(unreadTotalCount);
});

// In updateDocumentTitle:
if (isPopstateNavigation) {
    document.title = '';
    isPopstateNavigation = false;
}

The document.title = '' workaround was added for a Chrome bug where history.go(-1) reverts the title. This only happens on popstate. Normal forward navigation doesn't need it. By gating the blank write, forward navigations get a single clean title write with no intermediate blank.

Change 2: Coalesce via requestAnimationFrame instead of setTimeout(0)

let pendingRAF: number | null = null;

function scheduleCommit() {
    if (pendingRAF !== null) cancelAnimationFrame(pendingRAF);
    pendingRAF = requestAnimationFrame(commitTitleAndFavicon);
}

All triggers call scheduleCommit() instead of directly writing. Multiple calls within the same animation frame cancel the previous and schedule one commit. This guarantees at most one title write per frame, eliminating the race between competing setTimeout(0) callbacks.

Change 3: Skip no-op writes

if (target !== document.title) {
    document.title = target;
}

If the computed title already matches document.title, don't touch it. This prevents redundant DOM writes when the title hasn't actually changed (e.g., unread count update that doesn't affect the title format).

Why this is different from the rejected proposals

The C+ reviewer's rejection video showed that prior proposals still had flicker. Here's why those failed and this won't:

  1. "Only clear on popstate" proposals (trasnake87, skylarkerx) — They gated the blank correctly but still used setTimeout(0). Multiple setTimeout(0) calls queue as separate macrotasks. Even without the blank, rapid sequential title writes can cause the browser to paint intermediate states if the old title flashes between writes.

  2. TaduJR's proposal — Too invasive: rewires title ownership to React Navigation, creates platform-specific file splits (.web.ts), removes setPageTitle export. High risk of breaking other screens. The C+ is unlikely to approve a refactor when a surgical fix works.

  3. rAF proposal (anonymous) — Good coalescing idea but used navigationRef.getCurrentOptions() to read the title, coupling the fix to React Navigation internals and requiring a null guard chain.

This fix combines the best of all three:

  • Popstate gating (proven correct root cause direction)
  • rAF coalescing (proven correct scheduling approach)
  • No-op skipping (extra safety net)
  • Minimal blast radius — one file, zero API changes, zero new dependencies

What alternative solutions did you explore? (Optional)

  • Remove the workaround entirely — Risky. The Chrome back-nav title bug may still exist on older Chrome versions that Expensify supports. Gating is safer than removing.
  • Debounce with a timer — rAF is cleaner because it naturally aligns with browser paint cycles. A manual debounce (e.g., 50ms) is arbitrary and could add latency.
  • Use a microtask (queueMicrotask/Promise.resolve()) — Doesn't help because the issue is multiple macrotasks, not microtasks. rAF is the correct primitive for coalescing DOM writes.

Three changes:
1. Gate Chrome back-nav workaround behind popstate flag only
2. Coalesce rapid title updates via requestAnimationFrame
3. Skip no-op writes when title unchanged
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