Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions packages/extension/src/newtab/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,19 @@ import { get as getCache } from 'idb-keyval';
import browser from 'webextension-polyfill';
import type { DndSettings } from '@dailydotdev/shared/src/contexts/DndContext';
import App from './App';
import { preventFocusSteal } from './preventFocusSteal';

declare global {
interface Window {
windowLoaded: boolean;
}
}

// Before React, and before any dependency gets a chance to register its own
// focus bookkeeping, so no `focus()` on this page can pull the caret out of
// Chrome's address bar.
preventFocusSteal();

window.addEventListener(
'load',
() => {
Expand Down
60 changes: 60 additions & 0 deletions packages/extension/src/newtab/preventFocusSteal.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { preventFocusSteal } from './preventFocusSteal';

const originalFocus = HTMLElement.prototype.focus;

describe('preventFocusSteal', () => {
let input: HTMLInputElement;
let hasFocus: jest.SpyInstance<boolean, []>;
let warn: jest.SpyInstance;

beforeAll(() => {
preventFocusSteal();
});

afterAll(() => {
HTMLElement.prototype.focus = originalFocus;
});

beforeEach(() => {
input = document.createElement('input');
document.body.appendChild(input);
hasFocus = jest.spyOn(document, 'hasFocus');
// The guard warns outside production; the blocked cases expect it.
warn = jest.spyOn(console, 'warn').mockImplementation(() => undefined);
});

afterEach(() => {
input.remove();
hasFocus.mockRestore();
warn.mockRestore();
});

it('focuses normally while the page holds the keyboard', () => {
hasFocus.mockReturnValue(true);

input.focus();

expect(input).toHaveFocus();
});

it('drops focus() while the keyboard belongs to browser chrome', () => {
hasFocus.mockReturnValue(false);

input.focus();

expect(input).not.toHaveFocus();
});

it('stays a single layer when installed more than once', () => {
preventFocusSteal();
hasFocus.mockReturnValue(false);

input.focus();
expect(input).not.toHaveFocus();

hasFocus.mockReturnValue(true);

input.focus();
expect(input).toHaveFocus();
});
});
68 changes: 68 additions & 0 deletions packages/extension/src/newtab/preventFocusSteal.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
/**
* The new tab page shares a keyboard with Chrome's address bar. On a fresh tab
* the omnibox holds the caret while this page renders, and the user can click
* back into it at any moment. `document.hasFocus()` is false in exactly those
* moments: the tab is visible and painted, but the keyboard belongs to browser
* chrome, not to us.
*
* A programmatic `focus()` made in that state does not "restore" anything — it
* drags the caret out of the address bar and into the page, so the next thing
* the user types goes nowhere. From their side the address bar simply stops
* accepting input until something resets whatever is doing the pulling.
*
* Nothing on this page needs to focus an element while the page itself is
* unfocused, but several dependencies do it as a side effect of their own focus
* bookkeeping. react-modal is the clearest: its scoped-focus helper arms a flag
* on every window `blur` (which is what clicking the address bar is) and then
* pulls focus into the modal element on the next focus event in the document.
* It also never removes its `focus` listener — it is registered in the capture
* phase and removed without the capture flag — and its teardown is skipped
* entirely when a modal unmounts before its open state commits, which leaves
* that pull armed with no modal on screen. Radix focus scopes and the embedded
* ad measurement frames have their own variants of the same move. That is why
* the symptom is intermittent, survives across interactions, and clears as soon
* as a real modal is opened and closed (react-modal's teardown finally runs).
*
* Rather than chase each one, refuse the whole move: drop programmatic focus
* while the document does not have focus. User gestures are unaffected —
* clicking or tabbing into the page hands focus over before any handler runs,
* so `hasFocus()` is already true by then.
*/

let isInstalled = false;

const logBlockedFocus = (target: unknown): void => {
if (process.env.NODE_ENV === 'production') {
return;
}

// eslint-disable-next-line no-console
console.warn(
'[newtab] blocked a focus() call made while the page was unfocused — ' +
'it would have stolen the caret from the address bar.',
target,
new Error().stack,
);
};

export const preventFocusSteal = (): void => {
if (isInstalled || typeof HTMLElement === 'undefined') {
return;
}

isInstalled = true;

const original = HTMLElement.prototype.focus;

HTMLElement.prototype.focus = function guardedFocus(
this: HTMLElement,
options?: FocusOptions,
): void {
if (!document.hasFocus()) {
logBlockedFocus(this);
return;
}

original.call(this, options);
};
};
Loading