Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

#4190: Fix content script readiness race conditions and context invalidation handling #4240

Merged
merged 5 commits into from
Sep 13, 2022
Merged
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
18 changes: 15 additions & 3 deletions src/contentScript/contentScript.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,10 @@ import {
isReadyInThisDocument,
setInstalledInThisSession,
setReadyInThisDocument,
unsetReadyInThisDocument,
} from "@/contentScript/ready";
import { logPromiseDuration } from "@/utils";
import { onContextInvalidated } from "@/errors/contextInvalidated";

// See note in `@/contentScript/ready.ts` for further details about the lifecycle of content scripts
async function initContentScript() {
Expand All @@ -49,12 +51,22 @@ async function initContentScript() {
setInstalledInThisSession();

// Keeping the import separate ensures that no side effects are run until this point
const { init } = await logPromiseDuration(
"contentScript: imported", // "imported" timing includes the parsing of the file, which can take 500-1000ms
import(/* webpackChunkName: "contentScriptCore" */ "./contentScriptCore")
const contentScript = import(
/* webpackChunkName: "contentScriptCore" */ "./contentScriptCore"
);

// "imported" timing includes the parsing of the file, which can take 500-1000ms
void logPromiseDuration("contentScript: imported", contentScript);

const { init } = await contentScript;
await init(uuid);
setReadyInThisDocument(uuid);

// eslint-disable-next-line promise/prefer-await-to-then -- It's an unrelated event listener
void onContextInvalidated().then(() => {
unsetReadyInThisDocument(uuid);
console.debug("contentScript: invalidated", uuid);
});
Copy link
Contributor Author

@fregante fregante Sep 10, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Part 1:

Demo

Screen.Recording.mov

Console sequence

The "invalidated" message here marks my "refresh extension" click:

Screen Shot

}

void logPromiseDuration("contentScript: ready", initContentScript()).catch(
Expand Down
10 changes: 1 addition & 9 deletions src/contentScript/contentScriptCore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,6 @@ import { initPartnerIntegrations } from "@/contentScript/partnerIntegrations";
import {
isContextInvalidatedError,
notifyContextInvalidated,
onContextInvalidated,
} from "@/errors/contextInvalidated";
import { uncaughtErrorHandlers } from "@/telemetry/reportUncaughtErrors";
import { UUID } from "@/core";
Expand All @@ -48,7 +47,7 @@ function ignoreContextInvalidatedErrors(
// they're actually interacting with PixieBrix, otherwise they might receive the notification
// at random times.
if (isContextInvalidatedError(errorEvent)) {
notifyContextInvalidated();
void notifyContextInvalidated();
errorEvent.preventDefault();
}
}
Expand Down Expand Up @@ -77,11 +76,4 @@ export async function init(uuid: UUID): Promise<void> {

// Let the partner page know
initPartnerIntegrations();

// Put here instead of in the sync contentScript because contextInvalidated has a transitive dependency on the
// messenger (which causes a race on messenger initialization)
// eslint-disable-next-line promise/prefer-await-to-then -- It's an unrelated event listener
void onContextInvalidated().then(() => {
console.debug("contentScript: invalidated", uuid);
});
}
31 changes: 21 additions & 10 deletions src/contentScript/ready.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,14 +28,18 @@
* being deactivated: https://github.com/pixiebrix/pixiebrix-extension/issues/3132
*
* Using both a symbol and an attribute accounts for these 2 situations, to detect
* and handle duplicate injections in the same session and across sessions.
* and handle duplicate injections in the same session and across sessions:
* - Same session (mistake in injection): ignore
* - Context invalidated: CS must be injected again
*/

import { UUID } from "@/core";
import { Target } from "@/types";
import { expectContext } from "@/utils/expectContext";
import { forbidContext } from "@/utils/expectContext";
import { executeFunction } from "webext-content-scripts";

const html = globalThis.document?.documentElement;

// These two must be synched in `getTargetState`
export const CONTENT_SCRIPT_INJECTED_SYMBOL = Symbol.for(
"content-script-injected"
Expand Down Expand Up @@ -63,14 +67,17 @@ export function setInstalledInThisSession(): void {
}

export function isReadyInThisDocument(): boolean {
return document.documentElement.hasAttribute(CONTENT_SCRIPT_READY_ATTRIBUTE);
return html.hasAttribute(CONTENT_SCRIPT_READY_ATTRIBUTE);
}

export function setReadyInThisDocument(uuid: UUID): void {
html.setAttribute(CONTENT_SCRIPT_READY_ATTRIBUTE, uuid);
}

export function setReadyInThisDocument(uuid: UUID | false): void {
if (uuid) {
document.documentElement.setAttribute(CONTENT_SCRIPT_READY_ATTRIBUTE, uuid);
} else {
document.documentElement.removeAttribute(CONTENT_SCRIPT_READY_ATTRIBUTE);
/** Only removes the attribute if the `uuid` matches. This avoids race conditions with the new content script */
export function unsetReadyInThisDocument(uuid: UUID): void {
if (uuid === html.getAttribute(CONTENT_SCRIPT_READY_ATTRIBUTE)) {
html.removeAttribute(CONTENT_SCRIPT_READY_ATTRIBUTE);
}
}

Expand All @@ -79,10 +86,14 @@ export function setReadyInThisDocument(uuid: UUID | false): void {
* @throws Error if background page doesn't have permission to access the tab
* */
export async function getTargetState(target: Target): Promise<TargetState> {
expectContext("background");
forbidContext(
"web",
"chrome.tabs is only available in chrome-extension:// pages"
);

return executeFunction(target, () => {
// Thise two must also be inlined here because `executeFunction` does not include non-local variables
// This function does not have access to globals, the outside scope, nor `import()`
// These two symbols must be repeated inline
const CONTENT_SCRIPT_INJECTED_SYMBOL = Symbol.for(
"content-script-injected"
);
Expand Down
7 changes: 5 additions & 2 deletions src/errors/contextInvalidated.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@
*/

import { expectContext } from "@/utils/expectContext";
import notify from "@/utils/notify";
import { once } from "lodash";
import {
CONTEXT_INVALIDATED_ERROR,
Expand All @@ -30,7 +29,11 @@ const id = "connection-lost";
* Display a notification when the background page unloads/reloads because at this point
* all communcation becomes impossible.
*/
export function notifyContextInvalidated(): void {
export async function notifyContextInvalidated(): Promise<void> {
// `import()` is only needed to avoid execution of its dependencies, not to lazy-load it
// https://github.com/pixiebrix/pixiebrix-extension/issues/4058#issuecomment-1217391772
// eslint-disable-next-line import/dynamic-import-chunkname
const { notify } = await import(/* webpackMode: "eager" */ "@/utils/notify");
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Part 2:

  • fix the issue closer to the cause

Part 3, in the future:

notify.error({
id,
message: "PixieBrix was updated or restarted. Reload the page to continue",
Expand Down
2 changes: 1 addition & 1 deletion src/telemetry/BackgroundLogger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ class BackgroundLogger implements Logger {

async error(error: unknown, data: JsonObject): Promise<void> {
if (wasContextInvalidated() && !isBackground() && !isDevToolsPage()) {
notifyContextInvalidated();
void notifyContextInvalidated();
}

console.error("BackgroundLogger:error", {
Expand Down
13 changes: 13 additions & 0 deletions src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -535,6 +535,19 @@ export async function logPromiseDuration<P>(
try {
return await promise;
} finally {
// Prefer `debug` level; `console.time` has `log` level
console.debug(title, `${Math.round(Date.now() - start)}ms`);
}
}

export async function logFunctionDuration<
Fn extends (...args: unknown[]) => Promise<unknown>
>(title: string, fn: Fn): Promise<ReturnType<Fn>> {
const start = Date.now();
try {
return (await fn()) as Awaited<ReturnType<Fn>>;
} finally {
// Prefer `debug` level; `console.time` has `log` level
console.debug(title, `${Math.round(Date.now() - start)}ms`);
}
}
Expand Down
3 changes: 3 additions & 0 deletions src/utils/expectContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
isBackground,
isContentScript,
isExtensionContext,
isWebPage,
} from "webext-detect-page";

/**
Expand All @@ -43,12 +44,14 @@ function createError(
}

const contexts = [
"web",
"extension",
"background",
"contentScript",
"devTools",
] as const;
const contextMap = new Map<typeof contexts[number], () => boolean>([
["web", isWebPage],
["extension", isExtensionContext],
["background", isBackground],
["contentScript", isContentScript],
Expand Down
2 changes: 1 addition & 1 deletion src/utils/notify.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -235,7 +235,7 @@ function _show(
}

// Please only add logic to `showNotification`
const notify = {
export const notify = {
/**
* @example notify.error('User message')
* @example notify.error({error: new Error('Error that can be shown to the user')})
Expand Down