From c864a2b0021f28a16904a2ab398489c9aaa0034e Mon Sep 17 00:00:00 2001 From: Federico Brigante Date: Thu, 4 Apr 2024 02:14:12 +0700 Subject: [PATCH 01/12] Add `renderWidget` --- src/components/InvalidatedContextGate.tsx | 32 +++++++++++++++---- src/components/quickBar/QuickBarApp.tsx | 22 ++++--------- src/domConstants.ts | 4 --- src/starterBricks/quickBarExtension.test.ts | 6 ++-- .../quickbarProviderExtension.test.ts | 14 +++----- src/utils/reactUtils.tsx | 32 +++++++++++++++++++ 6 files changed, 70 insertions(+), 40 deletions(-) create mode 100644 src/utils/reactUtils.tsx diff --git a/src/components/InvalidatedContextGate.tsx b/src/components/InvalidatedContextGate.tsx index 4903711e73..c6efb72527 100644 --- a/src/components/InvalidatedContextGate.tsx +++ b/src/components/InvalidatedContextGate.tsx @@ -20,18 +20,28 @@ import { Button } from "react-bootstrap"; import useContextInvalidated from "@/hooks/useContextInvalidated"; import useDocumentVisibility from "@/hooks/useDocumentVisibility"; -const InvalidatedContextGate: React.FunctionComponent<{ - contextNameTitleCase: string; +type ContextInvalidatedProps = { autoReload?: boolean; -}> = ({ children, contextNameTitleCase, autoReload }) => { - const wasContextInvalidated = useContextInvalidated(); + emptyOnInvalidation?: boolean; + contextNameTitleCase?: string; +}; + +const ContextInvalidated: React.FunctionComponent = ({ + autoReload, + emptyOnInvalidation, + contextNameTitleCase = "Page", +}) => { // Only auto-reload if the document is in the background const isDocumentVisible = useDocumentVisibility(); - if (wasContextInvalidated && autoReload && !isDocumentVisible) { + if (autoReload && !isDocumentVisible) { location.reload(); } - return wasContextInvalidated ? ( + if (emptyOnInvalidation) { + return null; + } + + return (

PixieBrix was updated or restarted. Reload the{" "} @@ -45,6 +55,16 @@ const InvalidatedContextGate: React.FunctionComponent<{ Reload {contextNameTitleCase}

+ ); +}; + +const InvalidatedContextGate: React.FunctionComponent< + ContextInvalidatedProps +> = ({ children, ...props }) => { + const wasContextInvalidated = useContextInvalidated(); + + return wasContextInvalidated ? ( + ) : ( <>{children} ); diff --git a/src/components/quickBar/QuickBarApp.tsx b/src/components/quickBar/QuickBarApp.tsx index 70c4abf84c..11b1dc5c30 100644 --- a/src/components/quickBar/QuickBarApp.tsx +++ b/src/components/quickBar/QuickBarApp.tsx @@ -16,7 +16,6 @@ */ import React, { useEffect } from "react"; -import ReactDOM from "react-dom"; import { KBarAnimator, KBarPortal, @@ -30,10 +29,7 @@ import EmotionShadowRoot from "@/components/EmotionShadowRoot"; import faStyleSheet from "@fortawesome/fontawesome-svg-core/styles.css?loadAsUrl"; import { expectContext } from "@/utils/expectContext"; import { once } from "lodash"; -import { - MAX_Z_INDEX, - PIXIEBRIX_QUICK_BAR_CONTAINER_CLASS, -} from "@/domConstants"; +import { MAX_Z_INDEX } from "@/domConstants"; import useEventListener from "@/hooks/useEventListener"; import { Stylesheets } from "@/components/Stylesheets"; import selection from "@/utils/selectionController"; @@ -47,11 +43,11 @@ import defaultActions, { pageEditorAction, } from "@/components/quickBar/defaultActions"; import quickBarRegistry from "@/components/quickBar/quickBarRegistry"; -import { onContextInvalidated } from "webext-events"; import StopPropagation from "@/components/StopPropagation"; import useScrollLock from "@/hooks/useScrollLock"; import { flagOn } from "@/auth/featureFlagStorage"; import useOnMountOnly from "@/hooks/useOnMountOnly"; +import { renderWidget } from "@/utils/reactUtils"; /** * Set to true if the KBar should be displayed on initial mount (i.e., because it was triggered by the @@ -126,6 +122,9 @@ const KBarComponent: React.FC = () => { // - hotkey: https://github.com/github/hotkey/blob/main/src/utils.ts#L1 // - Salesforce: https://salesforce.stackexchange.com/questions/183771/disable-keyboard-shortcuts-in-lightning-experience + // TODO: Drop contentEditable stuff since we have + // TODO: Use instead of KBarPortal and EmotionShadowRoot + return ( { quickBarRegistry.addAction(pageEditorAction); } - const container = document.createElement("div"); - container.className = PIXIEBRIX_QUICK_BAR_CONTAINER_CLASS; - document.body.prepend(container); - ReactDOM.render(, container); + renderWidget(); console.debug("Initialized quick bar"); - - onContextInvalidated.addListener(() => { - console.debug("Removed quick bar due to context invalidation"); - ReactDOM.unmountComponentAtNode(container); - container.remove(); - }); }); /** diff --git a/src/domConstants.ts b/src/domConstants.ts index 47181e3f8e..4b8b78944a 100644 --- a/src/domConstants.ts +++ b/src/domConstants.ts @@ -27,9 +27,6 @@ export const PANEL_FRAME_ID = "pixiebrix-extension"; export const PIXIEBRIX_DATA_ATTR = "data-pb-uuid"; -export const PIXIEBRIX_QUICK_BAR_CONTAINER_CLASS = - "pixiebrix-quickbar-container"; - export const PIXIEBRIX_TOOLTIPS_CONTAINER_CLASS = "pixiebrix-tooltips-container"; @@ -46,7 +43,6 @@ export const EXTENSION_POINT_DATA_ATTR = "data-pb-extension-point"; export const PRIVATE_ATTRIBUTES_SELECTOR = ` #${PANEL_FRAME_ID}, .${PIXIEBRIX_TOOLTIPS_CONTAINER_CLASS}, - .${PIXIEBRIX_QUICK_BAR_CONTAINER_CLASS}, .${PIXIEBRIX_NOTIFICATION_CLASS}, [${PIXIEBRIX_DATA_ATTR}], [${EXTENSION_POINT_DATA_ATTR}] diff --git a/src/starterBricks/quickBarExtension.test.ts b/src/starterBricks/quickBarExtension.test.ts index a68d49fd5d..a9baa74e3a 100644 --- a/src/starterBricks/quickBarExtension.test.ts +++ b/src/starterBricks/quickBarExtension.test.ts @@ -109,7 +109,7 @@ describe("quickBarExtension", () => { it("quick bar smoke test", async () => { const user = userEvent.setup(); - document.body.innerHTML = getDocument("
").body.innerHTML; + document.body.innerHTML = getDocument("

Title

").body.innerHTML; // Ensure default actions are registered await initQuickBarApp(); @@ -135,9 +135,7 @@ describe("quickBarExtension", () => { expect(rootReader.readCount).toBe(0); // QuickBar adds another div to the body - expect(document.body.innerHTML).toBe( - '
', - ); + expect(document.body.innerHTML).toBe("

Title

"); // :shrug: I'm not sure how to get the kbar to show using shortcuts in jsdom, so just toggle manually await user.keyboard("[Ctrl] k"); diff --git a/src/starterBricks/quickbarProviderExtension.test.ts b/src/starterBricks/quickbarProviderExtension.test.ts index 22d13ea40f..c5a3f71b63 100644 --- a/src/starterBricks/quickbarProviderExtension.test.ts +++ b/src/starterBricks/quickbarProviderExtension.test.ts @@ -86,7 +86,7 @@ const extensionFactory = define>({ const rootReader = new RootReader(); beforeAll(async () => { - const html = getDocument("
").body.innerHTML; + const html = getDocument("

Title

").body.innerHTML; document.body.innerHTML = html; // Ensure default actions are registered @@ -129,9 +129,7 @@ describe("quickBarProviderExtension", () => { expect(rootReader.readCount).toBe(1); // QuickBar installation adds another div to the body - expect(document.body.innerHTML).toBe( - '
', - ); + expect(document.body.innerHTML).toBe("

Title

"); // :shrug: I'm not sure how to get the kbar to show using shortcuts in jsdom, so just toggle manually await user.keyboard("[Ctrl] k"); @@ -186,9 +184,7 @@ describe("quickBarProviderExtension", () => { expect(rootReader.readCount).toBe(1); // QuickBar installation adds another div to the body - expect(document.body.innerHTML).toBe( - '
', - ); + expect(document.body.innerHTML).toBe("

Title

"); // :shrug: I'm not sure how to get the kbar to show using shortcuts in jsdom, so just toggle manually await user.keyboard("[Ctrl] k"); @@ -199,9 +195,7 @@ describe("quickBarProviderExtension", () => { await tick(); // Should be showing the QuickBar portal. The innerHTML doesn't contain the QuickBar actions at this point - expect(document.body.innerHTML).not.toBe( - '
', - ); + expect(document.body.innerHTML).not.toBe("

Title

"); // Getting an error here: make sure you apple `query.inputRefSetter` // await user.keyboard("abc"); diff --git a/src/utils/reactUtils.tsx b/src/utils/reactUtils.tsx new file mode 100644 index 0000000000..fd5cd371b8 --- /dev/null +++ b/src/utils/reactUtils.tsx @@ -0,0 +1,32 @@ +/* + * Copyright (C) 2024 PixieBrix, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import React from "react"; +import InvalidatedContextGate from "@/components/InvalidatedContextGate"; +import { render } from "react-dom"; + +// TODO: Use createRoot(document.createDocumentFragment()) and root.unmount() when switching to React 18 +export function renderWidget(widget: JSX.Element): void { + const insertionPoint = document.createDocumentFragment(); + render( + + {widget} + , + insertionPoint, + ); + document.body.after(insertionPoint); +} From 6f7467272d2463b4c0d4f24e1e348b18000ebfa3 Mon Sep 17 00:00:00 2001 From: Federico Brigante Date: Thu, 4 Apr 2024 13:13:54 +0700 Subject: [PATCH 02/12] Revert QuickBar changes for now --- src/components/quickBar/QuickBarApp.tsx | 22 ++++++++++++++----- src/domConstants.ts | 4 ++++ src/starterBricks/quickBarExtension.test.ts | 6 +++-- .../quickbarProviderExtension.test.ts | 14 ++++++++---- 4 files changed, 34 insertions(+), 12 deletions(-) diff --git a/src/components/quickBar/QuickBarApp.tsx b/src/components/quickBar/QuickBarApp.tsx index 11b1dc5c30..70c4abf84c 100644 --- a/src/components/quickBar/QuickBarApp.tsx +++ b/src/components/quickBar/QuickBarApp.tsx @@ -16,6 +16,7 @@ */ import React, { useEffect } from "react"; +import ReactDOM from "react-dom"; import { KBarAnimator, KBarPortal, @@ -29,7 +30,10 @@ import EmotionShadowRoot from "@/components/EmotionShadowRoot"; import faStyleSheet from "@fortawesome/fontawesome-svg-core/styles.css?loadAsUrl"; import { expectContext } from "@/utils/expectContext"; import { once } from "lodash"; -import { MAX_Z_INDEX } from "@/domConstants"; +import { + MAX_Z_INDEX, + PIXIEBRIX_QUICK_BAR_CONTAINER_CLASS, +} from "@/domConstants"; import useEventListener from "@/hooks/useEventListener"; import { Stylesheets } from "@/components/Stylesheets"; import selection from "@/utils/selectionController"; @@ -43,11 +47,11 @@ import defaultActions, { pageEditorAction, } from "@/components/quickBar/defaultActions"; import quickBarRegistry from "@/components/quickBar/quickBarRegistry"; +import { onContextInvalidated } from "webext-events"; import StopPropagation from "@/components/StopPropagation"; import useScrollLock from "@/hooks/useScrollLock"; import { flagOn } from "@/auth/featureFlagStorage"; import useOnMountOnly from "@/hooks/useOnMountOnly"; -import { renderWidget } from "@/utils/reactUtils"; /** * Set to true if the KBar should be displayed on initial mount (i.e., because it was triggered by the @@ -122,9 +126,6 @@ const KBarComponent: React.FC = () => { // - hotkey: https://github.com/github/hotkey/blob/main/src/utils.ts#L1 // - Salesforce: https://salesforce.stackexchange.com/questions/183771/disable-keyboard-shortcuts-in-lightning-experience - // TODO: Drop contentEditable stuff since we have - // TODO: Use instead of KBarPortal and EmotionShadowRoot - return ( { quickBarRegistry.addAction(pageEditorAction); } - renderWidget(); + const container = document.createElement("div"); + container.className = PIXIEBRIX_QUICK_BAR_CONTAINER_CLASS; + document.body.prepend(container); + ReactDOM.render(, container); console.debug("Initialized quick bar"); + + onContextInvalidated.addListener(() => { + console.debug("Removed quick bar due to context invalidation"); + ReactDOM.unmountComponentAtNode(container); + container.remove(); + }); }); /** diff --git a/src/domConstants.ts b/src/domConstants.ts index 4b8b78944a..47181e3f8e 100644 --- a/src/domConstants.ts +++ b/src/domConstants.ts @@ -27,6 +27,9 @@ export const PANEL_FRAME_ID = "pixiebrix-extension"; export const PIXIEBRIX_DATA_ATTR = "data-pb-uuid"; +export const PIXIEBRIX_QUICK_BAR_CONTAINER_CLASS = + "pixiebrix-quickbar-container"; + export const PIXIEBRIX_TOOLTIPS_CONTAINER_CLASS = "pixiebrix-tooltips-container"; @@ -43,6 +46,7 @@ export const EXTENSION_POINT_DATA_ATTR = "data-pb-extension-point"; export const PRIVATE_ATTRIBUTES_SELECTOR = ` #${PANEL_FRAME_ID}, .${PIXIEBRIX_TOOLTIPS_CONTAINER_CLASS}, + .${PIXIEBRIX_QUICK_BAR_CONTAINER_CLASS}, .${PIXIEBRIX_NOTIFICATION_CLASS}, [${PIXIEBRIX_DATA_ATTR}], [${EXTENSION_POINT_DATA_ATTR}] diff --git a/src/starterBricks/quickBarExtension.test.ts b/src/starterBricks/quickBarExtension.test.ts index a9baa74e3a..a68d49fd5d 100644 --- a/src/starterBricks/quickBarExtension.test.ts +++ b/src/starterBricks/quickBarExtension.test.ts @@ -109,7 +109,7 @@ describe("quickBarExtension", () => { it("quick bar smoke test", async () => { const user = userEvent.setup(); - document.body.innerHTML = getDocument("

Title

").body.innerHTML; + document.body.innerHTML = getDocument("
").body.innerHTML; // Ensure default actions are registered await initQuickBarApp(); @@ -135,7 +135,9 @@ describe("quickBarExtension", () => { expect(rootReader.readCount).toBe(0); // QuickBar adds another div to the body - expect(document.body.innerHTML).toBe("

Title

"); + expect(document.body.innerHTML).toBe( + '
', + ); // :shrug: I'm not sure how to get the kbar to show using shortcuts in jsdom, so just toggle manually await user.keyboard("[Ctrl] k"); diff --git a/src/starterBricks/quickbarProviderExtension.test.ts b/src/starterBricks/quickbarProviderExtension.test.ts index c5a3f71b63..22d13ea40f 100644 --- a/src/starterBricks/quickbarProviderExtension.test.ts +++ b/src/starterBricks/quickbarProviderExtension.test.ts @@ -86,7 +86,7 @@ const extensionFactory = define>({ const rootReader = new RootReader(); beforeAll(async () => { - const html = getDocument("

Title

").body.innerHTML; + const html = getDocument("
").body.innerHTML; document.body.innerHTML = html; // Ensure default actions are registered @@ -129,7 +129,9 @@ describe("quickBarProviderExtension", () => { expect(rootReader.readCount).toBe(1); // QuickBar installation adds another div to the body - expect(document.body.innerHTML).toBe("

Title

"); + expect(document.body.innerHTML).toBe( + '
', + ); // :shrug: I'm not sure how to get the kbar to show using shortcuts in jsdom, so just toggle manually await user.keyboard("[Ctrl] k"); @@ -184,7 +186,9 @@ describe("quickBarProviderExtension", () => { expect(rootReader.readCount).toBe(1); // QuickBar installation adds another div to the body - expect(document.body.innerHTML).toBe("

Title

"); + expect(document.body.innerHTML).toBe( + '
', + ); // :shrug: I'm not sure how to get the kbar to show using shortcuts in jsdom, so just toggle manually await user.keyboard("[Ctrl] k"); @@ -195,7 +199,9 @@ describe("quickBarProviderExtension", () => { await tick(); // Should be showing the QuickBar portal. The innerHTML doesn't contain the QuickBar actions at this point - expect(document.body.innerHTML).not.toBe("

Title

"); + expect(document.body.innerHTML).not.toBe( + '
', + ); // Getting an error here: make sure you apple `query.inputRefSetter` // await user.keyboard("abc"); From 28519c3effb89a2dbec30a01d1e217c6a89cb517 Mon Sep 17 00:00:00 2001 From: Federico Brigante Date: Thu, 4 Apr 2024 14:34:26 +0700 Subject: [PATCH 03/12] Introduce `useAbortSignal`/`AbortSignalGate` --- src/components/AbortSignalGate.test.tsx | 47 +++++++++++++++++++++++++ src/components/AbortSignalGate.tsx | 29 +++++++++++++++ src/hooks/useAbortSignal.test.ts | 36 +++++++++++++++++++ src/hooks/useAbortSignal.ts | 38 ++++++++++++++++++++ src/tsconfig.strictNullChecks.json | 4 +++ 5 files changed, 154 insertions(+) create mode 100644 src/components/AbortSignalGate.test.tsx create mode 100644 src/components/AbortSignalGate.tsx create mode 100644 src/hooks/useAbortSignal.test.ts create mode 100644 src/hooks/useAbortSignal.ts diff --git a/src/components/AbortSignalGate.test.tsx b/src/components/AbortSignalGate.test.tsx new file mode 100644 index 0000000000..ace9d62353 --- /dev/null +++ b/src/components/AbortSignalGate.test.tsx @@ -0,0 +1,47 @@ +/* + * Copyright (C) 2024 PixieBrix, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import React from "react"; +import { render, screen } from "@testing-library/react"; +import AbortSignalGate from "./AbortSignalGate"; + +it("renders the children when active and hides them when aborted", () => { + const controller = new AbortController(); + const { rerender } = render( + +
Content
+
, + ); + expect(screen.getByText("Content")).toBeInTheDocument(); + + controller.abort(); + rerender( + +
Content
+
, + ); + expect(screen.queryByText("Content")).not.toBeInTheDocument(); +}); + +it("does not render children when the signal is already aborted", () => { + render( + +
Content
+
, + ); + expect(screen.queryByText("Content")).not.toBeInTheDocument(); +}); diff --git a/src/components/AbortSignalGate.tsx b/src/components/AbortSignalGate.tsx new file mode 100644 index 0000000000..fd312d8fab --- /dev/null +++ b/src/components/AbortSignalGate.tsx @@ -0,0 +1,29 @@ +/* + * Copyright (C) 2024 PixieBrix, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import useAbortSignal from "@/hooks/useAbortSignal"; +import React from "react"; + +const AbortSignalGate: React.FunctionComponent<{ signal: AbortSignal }> = ({ + signal, + children, +}) => { + const aborted = useAbortSignal(signal); + return aborted ? null : <>{children}; +}; + +export default AbortSignalGate; diff --git a/src/hooks/useAbortSignal.test.ts b/src/hooks/useAbortSignal.test.ts new file mode 100644 index 0000000000..a7a074ee67 --- /dev/null +++ b/src/hooks/useAbortSignal.test.ts @@ -0,0 +1,36 @@ +/* + * Copyright (C) 2024 PixieBrix, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import { renderHook } from "@testing-library/react-hooks"; +import useAbortSignal from "./useAbortSignal"; + +it("returns the initial state of the signal", () => { + const active = renderHook(() => useAbortSignal(new AbortController().signal)); + expect(active.result.current).toBe(false); + + const aborted = renderHook(() => useAbortSignal(AbortSignal.abort())); + expect(aborted.result.current).toBe(true); +}); + +it("updates the state when the signal is aborted", () => { + const controller = new AbortController(); + const { result } = renderHook(() => useAbortSignal(controller.signal)); + expect(result.current).toBe(false); + + controller.abort(); + expect(result.current).toBe(true); +}); diff --git a/src/hooks/useAbortSignal.ts b/src/hooks/useAbortSignal.ts new file mode 100644 index 0000000000..72bf0943f7 --- /dev/null +++ b/src/hooks/useAbortSignal.ts @@ -0,0 +1,38 @@ +/* + * Copyright (C) 2024 PixieBrix, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import { useEffect, useState } from "react"; + +export default function useAbortSignal(signal: AbortSignal): boolean { + const [isAborted, setIsAborted] = useState(signal.aborted); + useEffect(() => { + if (signal.aborted) { + // No need to add an event listener if the signal is already aborted + return; + } + + const handler = () => { + setIsAborted(true); + }; + + signal.addEventListener("abort", handler); + return () => { + signal.removeEventListener("abort", handler); + }; + }, [signal]); + return isAborted; +} diff --git a/src/tsconfig.strictNullChecks.json b/src/tsconfig.strictNullChecks.json index 78cbb25fa1..3163e3b8fe 100644 --- a/src/tsconfig.strictNullChecks.json +++ b/src/tsconfig.strictNullChecks.json @@ -176,6 +176,8 @@ "./bricks/transformers/traverseElements.ts", "./bricks/transformers/url.ts", "./bricks/types.ts", + "./components/AbortSignalGate.test.tsx", + "./components/AbortSignalGate.tsx", "./components/AceEditor.tsx", "./components/AceEditorSync.tsx", "./components/Alert.tsx", @@ -449,6 +451,8 @@ "./hooks/logging.ts", "./hooks/useAsyncExternalStore.ts", "./hooks/useAsyncState.ts", + "./hooks/useAbortSignal.test.ts", + "./hooks/useAbortSignal.ts", "./hooks/useAuthorizationGrantFlow.ts", "./hooks/useAutoFocusConfiguration.ts", "./hooks/useBrowserIdentifier.ts", From c60e2c6843c12dd4d89b08ab2dbdfc969b5eebfc Mon Sep 17 00:00:00 2001 From: Federico Brigante Date: Thu, 4 Apr 2024 14:36:13 +0700 Subject: [PATCH 04/12] renderWidget: add support for signal and position --- src/utils/reactUtils.tsx | 32 +++++++++++++++++++++++++------- 1 file changed, 25 insertions(+), 7 deletions(-) diff --git a/src/utils/reactUtils.tsx b/src/utils/reactUtils.tsx index fd5cd371b8..54dbc128f7 100644 --- a/src/utils/reactUtils.tsx +++ b/src/utils/reactUtils.tsx @@ -16,17 +16,35 @@ */ import React from "react"; -import InvalidatedContextGate from "@/components/InvalidatedContextGate"; import { render } from "react-dom"; +import { mergeSignals } from "abort-utils"; +import { onContextInvalidated } from "webext-events"; +import AbortSignalGate from "@/components/AbortSignalGate"; + +// TODO: When switching to React 18, use: +// createRoot(document.createDocumentFragment()) +// signal.onabort = () => root.unmount() +// instead of AbortSignalGate +export function renderWidget( + widget: JSX.Element, + { + signal, + position = "after", + }: { + signal?: AbortSignal; + position?: "before" | "after"; + } = {}, +): void { + if (signal) { + signal = mergeSignals(signal, onContextInvalidated.signal); + } else { + signal = onContextInvalidated.signal; + } -// TODO: Use createRoot(document.createDocumentFragment()) and root.unmount() when switching to React 18 -export function renderWidget(widget: JSX.Element): void { const insertionPoint = document.createDocumentFragment(); render( - - {widget} - , + {widget}, insertionPoint, ); - document.body.after(insertionPoint); + document.body[position](insertionPoint); } From 9fcb54d9fbe950f225f4159ca6de56b4289a844d Mon Sep 17 00:00:00 2001 From: Federico Brigante Date: Thu, 4 Apr 2024 14:33:31 +0700 Subject: [PATCH 05/12] Simplify ContextInvalidatedGate --- src/components/InvalidatedContextGate.tsx | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/src/components/InvalidatedContextGate.tsx b/src/components/InvalidatedContextGate.tsx index c6efb72527..3b100f52a3 100644 --- a/src/components/InvalidatedContextGate.tsx +++ b/src/components/InvalidatedContextGate.tsx @@ -22,14 +22,14 @@ import useDocumentVisibility from "@/hooks/useDocumentVisibility"; type ContextInvalidatedProps = { autoReload?: boolean; - emptyOnInvalidation?: boolean; - contextNameTitleCase?: string; + + /** The name to show on "Reload Context Name" button */ + contextNameTitleCase: string; }; -const ContextInvalidated: React.FunctionComponent = ({ +const InformationPanel: React.FunctionComponent = ({ autoReload, - emptyOnInvalidation, - contextNameTitleCase = "Page", + contextNameTitleCase, }) => { // Only auto-reload if the document is in the background const isDocumentVisible = useDocumentVisibility(); @@ -37,10 +37,6 @@ const ContextInvalidated: React.FunctionComponent = ({ location.reload(); } - if (emptyOnInvalidation) { - return null; - } - return (

@@ -58,13 +54,18 @@ const ContextInvalidated: React.FunctionComponent = ({ ); }; +/** + * A gate that shows an information panel with a reload button if the context was invalidated. + * + * Use `` if you just want to unmount the children instead. + */ const InvalidatedContextGate: React.FunctionComponent< ContextInvalidatedProps > = ({ children, ...props }) => { const wasContextInvalidated = useContextInvalidated(); return wasContextInvalidated ? ( - + ) : ( <>{children} ); From e24b61a51eb72fb4317f5d1772116d02607090fe Mon Sep 17 00:00:00 2001 From: Federico Brigante Date: Thu, 4 Apr 2024 14:37:17 +0700 Subject: [PATCH 06/12] renderWidget: use in `showModal()` --- src/contentScript/modalDom.tsx | 71 +++++++++++++--------------------- 1 file changed, 26 insertions(+), 45 deletions(-) diff --git a/src/contentScript/modalDom.tsx b/src/contentScript/modalDom.tsx index 647327b57c..5f9c83b64e 100644 --- a/src/contentScript/modalDom.tsx +++ b/src/contentScript/modalDom.tsx @@ -16,11 +16,10 @@ */ import React from "react"; -import { scrollbarWidth } from "@xobotyi/scrollbar-width"; -import { render, unmountComponentAtNode } from "react-dom"; -import { mergeSignals } from "abort-utils"; -import { onContextInvalidated } from "webext-events"; import { expectContext } from "@/utils/expectContext"; +import { renderWidget } from "@/utils/reactUtils"; +import useScrollLock from "@/hooks/useScrollLock"; +import useAbortSignal from "@/hooks/useAbortSignal"; // This cannot be moved to globals.d.ts because it's a module augmentation // https://stackoverflow.com/a/42085876/288906 @@ -29,41 +28,16 @@ declare module "react" { onClose?: ReactEventHandler | undefined; } } -/** - * Show a modal with the given URL in the host page - * @param url the URL to show - * @param controller AbortController to cancel the modal - * @param onOutsideClick callback to call when the user clicks outside the modal - */ -export function showModal({ - url, - controller, - onOutsideClick, -}: { + +const IframeModal: React.VFC<{ url: URL; controller: AbortController; onOutsideClick?: () => void; -}): void { - // In React apps, should use React modal component - expectContext("contentScript"); - - // Using `