diff --git a/.env.example b/.env.example index ebdb096f05..c242397298 100644 --- a/.env.example +++ b/.env.example @@ -4,8 +4,11 @@ CHROME_MANIFEST_KEY= CHROME_EXTENSION_ID=mpjjildhmpddojocokjkgmlkkkfjnepo -# Chrome extension manifest version. Default is MV=2 -# MV=3 +# Chrome extension manifest version. Default is MV=3 +# MV=2 + +# Shadow DOM mode for all components. Default is SHADOW_DOM=closed in regular webpack builds, open elsewhere +# SHADOW_DOM=open # This makes all optional permissions required in the manifest.json to avoid permission popups. Only required for Playwright tests. # REQUIRE_OPTIONAL_PERMISSIONS_IN_MANIFEST=1 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 68e3837596..03b05db9ba 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -197,6 +197,7 @@ jobs: timeout-minutes: 60 runs-on: ubuntu-latest env: + SHADOW_DOM: open SERVICE_URL: https://app.pixiebrix.com MV: ${{ matrix.MV }} CHROME_MANIFEST_KEY: ${{ matrix.CHROME_MANIFEST_KEY }} diff --git a/jest.config.js b/jest.config.js index 6b7290b124..05fe397960 100644 --- a/jest.config.js +++ b/jest.config.js @@ -55,16 +55,25 @@ const config = { silent: true, testEnvironment: "./src/testUtils/FixJsdomEnvironment.js", modulePaths: ["/src"], - moduleFileExtensions: ["ts", "tsx", "js", "jsx", "yaml", "yml", "json"], + moduleFileExtensions: [ + "ts", + "tsx", + "js", + "jsx", + "mjs", + "yaml", + "yml", + "json", + ], modulePathIgnorePatterns: ["/headers.json", "/dist/"], testPathIgnorePatterns: ["/end-to-end-tests"], transform: { - "^.+\\.[jt]sx?$": "@swc/jest", - "^.+\\.mjs$": "@swc/jest", - "^.+\\.ya?ml$": "yaml-jest-transform", - "^.+\\.ya?ml\\?loadAsText$": - "/src/testUtils/rawJestTransformer.mjs", - "^.+\\.txt$": "/src/testUtils/rawJestTransformer.mjs", + "\\.[jt]sx?$": "@swc/jest", + "\\.mjs$": "@swc/jest", + "\\.ya?ml$": "yaml-jest-transform", + "\\.txt$": "/src/testUtils/rawJestTransformer.mjs", + // Note: `?param` URLs aren't supported here: https://github.com/jestjs/jest/pull/6282 + // You can only use a mock via `moduleNameMapper` for these. }, transformIgnorePatterns: [`node_modules/(?!${esmPackages.join("|")})`], setupFiles: [ @@ -89,11 +98,15 @@ const config = { "!**/vendor/**", ], moduleNameMapper: { + "^@contrib/([^?]+)": "/contrib/$1", + "^@schemas/([^?]+)": "/schemas/$1", + "\\.s?css$": "identity-obj-proxy", - "\\.(gif|svg|png)$|\\?loadAsUrl$|\\?loadAsComponent$": - "/src/__mocks__/stringMock.js", - "^@contrib/(.*?)(\\?loadAsText)?$": "/contrib/$1", - "^@schemas/(.*)": "/schemas/$1", + "\\.(gif|svg|png)$": "/src/__mocks__/stringMock.js", + + "\\?loadAsUrl$": "/src/__mocks__/stringMock.js", + "\\?loadAsText$": "/src/__mocks__/stringMock.js", + "\\?loadAsComponent$": "/src/__mocks__/stringMock.js", // Auto-mocks. See documentation in ./src/__mocks__/readme.md "^@/(.*)$": ["/src/__mocks__/@/$1", "/src/$1"], diff --git a/package-lock.json b/package-lock.json index 010197f0b5..823dde96d9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -29,7 +29,7 @@ "@uipath/robot": "1.3.1", "@vespaiach/axios-fetch-adapter": "^0.3.1", "@xobotyi/scrollbar-width": "^1.9.5", - "abort-utils": "^1.1.0", + "abort-utils": "^1.2.0", "ace-builds": "^1.32.9", "autocompleter": "^9.1.2", "axios": "^0.27.2", @@ -10270,9 +10270,9 @@ } }, "node_modules/abort-utils": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/abort-utils/-/abort-utils-1.1.0.tgz", - "integrity": "sha512-c/RkrNs9HS8F+cnX+yNidnbVI4LPTM56fs50CFuMX2V/KNkmjYxBhMeQyvy3zlhVZcZsc7PDwesbju2XCWmmKQ==", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/abort-utils/-/abort-utils-1.2.0.tgz", + "integrity": "sha512-C++yavghYIAoAtd7NDHqVEgNLoZVNIxCNZQZrQd8mbHY173NpT/qOJmGU9Z0565GiCA7kqXWMjaVchIP4F0WMg==", "engines": { "node": ">=18" }, diff --git a/package.json b/package.json index f59dced839..fb46636392 100644 --- a/package.json +++ b/package.json @@ -55,7 +55,7 @@ "@uipath/robot": "1.3.1", "@vespaiach/axios-fetch-adapter": "^0.3.1", "@xobotyi/scrollbar-width": "^1.9.5", - "abort-utils": "^1.1.0", + "abort-utils": "^1.2.0", "ace-builds": "^1.32.9", "autocompleter": "^9.1.2", "axios": "^0.27.2", diff --git a/scripts/DiscardFilePlugin.mjs b/scripts/DiscardFilePlugin.mjs index f7cd9f3058..322dfc75a8 100644 --- a/scripts/DiscardFilePlugin.mjs +++ b/scripts/DiscardFilePlugin.mjs @@ -1,6 +1,11 @@ import webpack from "webpack"; + // https://github.com/pixiebrix/pixiebrix-extension/pull/7363#discussion_r1458224740 export default class DiscardFilePlugin { + constructor(filesToDiscard) { + this.filesToDiscard = filesToDiscard; + } + apply(compiler) { compiler.hooks.compilation.tap("DiscardFilePlugin", (compilation) => { compilation.hooks.processAssets.tapPromise( @@ -9,11 +14,16 @@ export default class DiscardFilePlugin { stage: webpack.Compilation.PROCESS_ASSETS_STAGE_OPTIMIZE_SIZE, }, async (assets) => { + for (const componentPath of this.filesToDiscard) { + delete assets[`${componentPath.split("/").pop()}.js`]; + // If `delete assets[]` causes issues in the future, try replacing the content instead: + // assets["DocumentView.js"] = new webpack.sources.RawSource('"Dropped"'); + } + + // TODO: Use and move these to isolatedComponentList delete assets["DocumentView.js"]; delete assets["EphemeralFormContent.js"]; delete assets["CustomFormComponent.js"]; - // If this causes issues in the future, try replacing the content instead: - // assets["DocumentView.js"] = new webpack.sources.RawSource('"Dropped"'); }, ); }); diff --git a/scripts/__snapshots__/manifest.test.js.snap b/scripts/__snapshots__/manifest.test.js.snap index 5302d9b544..1919d2fa71 100644 --- a/scripts/__snapshots__/manifest.test.js.snap +++ b/scripts/__snapshots__/manifest.test.js.snap @@ -133,21 +133,15 @@ exports[`customizeManifest beta 1`] = ` "bundles/*", "sandbox.html", "frame.html", - "frame.css", "sidebar.html", - "sidebar.css", - "pageEditor.css", "pageScript.js", "ephemeralForm.html", "walkthroughModal.html", "ephemeralPanel.html", - "ephemeralModal.css", - "DocumentView.css", - "CustomFormComponent.css", - "EphemeralFormContent.css", "audio/*", "user-icons/*", "img/*", + "*.css", ], }, ], @@ -273,21 +267,15 @@ exports[`customizeManifest mv2 1`] = ` "bundles/*", "sandbox.html", "frame.html", - "frame.css", "sidebar.html", - "sidebar.css", - "pageEditor.css", "pageScript.js", "ephemeralForm.html", "walkthroughModal.html", "ephemeralPanel.html", - "ephemeralModal.css", - "DocumentView.css", - "CustomFormComponent.css", - "EphemeralFormContent.css", "audio/*", "user-icons/*", "img/*", + "*.css", ], } `; @@ -425,21 +413,15 @@ exports[`customizeManifest mv3 1`] = ` "bundles/*", "sandbox.html", "frame.html", - "frame.css", "sidebar.html", - "sidebar.css", - "pageEditor.css", "pageScript.js", "ephemeralForm.html", "walkthroughModal.html", "ephemeralPanel.html", - "ephemeralModal.css", - "DocumentView.css", - "CustomFormComponent.css", - "EphemeralFormContent.css", "audio/*", "user-icons/*", "img/*", + "*.css", ], }, ], diff --git a/src/__snapshots__/Storyshots.test.js.snap b/src/__snapshots__/Storyshots.test.js.snap index c042ced575..f36318515a 100644 --- a/src/__snapshots__/Storyshots.test.js.snap +++ b/src/__snapshots__/Storyshots.test.js.snap @@ -3513,22 +3513,85 @@ exports[`Storyshots Editor/LogToolbar Default 1`] = ` exports[`Storyshots Enhancements/SelectionMenu Emoji Buttons 1`] = `
+ + +
`; exports[`Storyshots Enhancements/SelectionMenu Mixed Buttons 1`] = `
+ + +
`; exports[`Storyshots Enhancements/SnippetShortcutMenu Demo 1`] = ` diff --git a/src/bricks/renderers/PropertyTree.tsx b/src/bricks/renderers/PropertyTree.tsx index 97064b961c..7f6a18035e 100644 --- a/src/bricks/renderers/PropertyTree.tsx +++ b/src/bricks/renderers/PropertyTree.tsx @@ -15,25 +15,22 @@ * along with this program. If not, see . */ +import "primereact/resources/themes/saga-blue/theme.css"; +import "primereact/resources/primereact.min.css"; +import "primeicons/primeicons.css"; + import { TreeTable } from "primereact/treetable"; import type TreeNode from "primereact/treenode"; import { Column } from "primereact/column"; -import { Stylesheets } from "@/components/Stylesheets"; import React from "react"; -import theme from "primereact/resources/themes/saga-blue/theme.css?loadAsUrl"; -import primereact from "primereact/resources/primereact.min.css?loadAsUrl"; -import primeicons from "primeicons/primeicons.css?loadAsUrl"; - const PropertyTree: React.FunctionComponent<{ value: TreeNode[] }> = ({ value, }) => ( - - - - - - + + + + ); export default PropertyTree; diff --git a/src/bricks/renderers/propertyTable.tsx b/src/bricks/renderers/propertyTable.tsx index 18d984b3f1..f4b1d4944e 100644 --- a/src/bricks/renderers/propertyTable.tsx +++ b/src/bricks/renderers/propertyTable.tsx @@ -21,6 +21,8 @@ import { sortBy, isPlainObject } from "lodash"; import { type BrickArgs, type BrickOptions } from "@/types/runtimeTypes"; import { canParseUrl } from "@/utils/urlUtils"; import { propertiesToSchema } from "@/utils/schemaUtils"; +import IsolatedComponent from "@/components/IsolatedComponent"; +import type TreeNode from "primereact/treenode"; interface Item { key: string; @@ -115,13 +117,21 @@ export class PropertyTableRenderer extends RendererABC { ); async render({ data }: BrickArgs, { ctxt }: BrickOptions) { - const PropertyTree = await import( - /* webpackChunkName: "widgets" */ - "./PropertyTree" + const PropertyTree: React.FC<{ value: TreeNode[] }> = ({ value }) => ( + + import( + /* webpackChunkName: "isolated/PropertyTree" */ + "./PropertyTree" + ) + } + factory={(PropertyTree) => } + /> ); return { - Component: PropertyTree.default, + Component: PropertyTree, props: { value: shapeData(data ?? ctxt), }, diff --git a/src/components/EmotionShadowRoot.ts b/src/components/EmotionShadowRoot.ts index f020f85a1d..8d0fad6a7a 100644 --- a/src/components/EmotionShadowRoot.ts +++ b/src/components/EmotionShadowRoot.ts @@ -1,3 +1,7 @@ +/* +eslint-disable @typescript-eslint/no-non-null-assertion, @typescript-eslint/no-unnecessary-type-assertion -- +"Every property exists" (via Proxy), TypeScript doesn't offer such type +Also strictNullChecks config mismatch */ /* * Copyright (C) 2024 PixieBrix, Inc. * @@ -19,10 +23,13 @@ import EmotionShadowRoot from "react-shadow/emotion"; import { type CSSProperties } from "react"; -/* eslint-disable-next-line @typescript-eslint/no-non-null-assertion, @typescript-eslint/no-unnecessary-type-assertion -- -"Every property exists" (via Proxy), TypeScript doesn't offer such type -Also strictNullChecks config mismatch */ +/** + * Wrap components in a shadow DOM. This isolates them from styles inherited from + * the host website. To support react-select and any future potential emotion + * components we used the emotion variant of the react-shadow library. + */ const ShadowRoot = EmotionShadowRoot.div!; +// TODO: Use EmotionShadowRoot["pixiebrix-widget"] to avoid any CSS conflicts. Requires snapshot/test updates export const styleReset: CSSProperties = { all: "initial", diff --git a/src/components/IsolatedComponent.scss b/src/components/IsolatedComponent.scss new file mode 100644 index 0000000000..09ee9fe6e0 --- /dev/null +++ b/src/components/IsolatedComponent.scss @@ -0,0 +1,20 @@ +// Injected unminified. Don't add too much fluff + +:host { + // Don't inherit any style + all: initial; + + // Set a font baseline style. Bootstrap targets `body` specifically, which isn't available in a shadow DOM + font: 16px / 1.5 sans-serif; + + // Set a good default for our custom `pixiebrix-widget` element + display: block; + + // Avoid black scrollbars on dark websites that set `color-scheme: light dark` + color-scheme: light; +} + +// Don't inherit the selection color +:host::selection { + background: initial; +} diff --git a/src/components/IsolatedComponent.tsx b/src/components/IsolatedComponent.tsx new file mode 100644 index 0000000000..af10027224 --- /dev/null +++ b/src/components/IsolatedComponent.tsx @@ -0,0 +1,129 @@ +/* + * 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 . + */ + +// This cannot be a CSS module or URL because it must live inside the +// shadow DOM and synchronously set the :host element style. +import cssText from "./IsolatedComponent.scss?loadAsText"; + +import React, { Suspense } from "react"; +import { Stylesheets } from "@/components/Stylesheets"; +import EmotionShadowRoot from "@/components/EmotionShadowRoot"; +import isolatedComponentList from "./isolatedComponentList"; +import { signalFromPromise } from "abort-utils"; + +const MODE = process.env.SHADOW_DOM as "open" | "closed"; + +type LazyFactory = () => Promise<{ + default: React.ComponentType; +}>; + +// Drop the stylesheet injected by `mini-css-extract-plugin` into the main document. +// Until this is resolved https://github.com/webpack-contrib/mini-css-extract-plugin/issues/1092#issuecomment-2037540032 +async function discardNewStylesheets(signal: AbortSignal) { + const baseUrl = chrome.runtime.getURL(""); + + const observer = new MutationObserver((mutations) => { + for (const mutation of mutations) { + for (const node of mutation.addedNodes) { + if (node instanceof HTMLLinkElement && node.href.startsWith(baseUrl)) { + // Disable stylesheet without removing it. Webpack still awaits its loading. + node.media = "not all"; + node.dataset.pixiebrix = "Disabled by IsolatedComponent"; + } + } + } + }); + + observer.observe(document.head, { + childList: true, + }); + + signal.addEventListener("abort", () => { + observer.disconnect(); + }); +} + +type Props = { + /** + * It must match the `import()`ed component's filename + */ + name: string; + + /** + * It must follow the format `isolated/${name}` specified above + * @example () => import(/* webpackChunkName: "isolated/Moon" * /, "@/components/Moon") + */ + lazy: LazyFactory; + + /** + * A function that provides the loaded component as an argument, and calls it + * @example (Moon) => + */ + factory: ( + Component: React.LazyExoticComponent>, + ) => JSX.Element; + + /** + * If true, the component will not attempt to load the stylesheet. + * + * @example + */ + noStyle?: boolean; +}; + +/** + * Isolate component loaded via React.lazy() in a shadow DOM, including its styles. + * + * @example + * render( + * import(/* webpackChunkName: "isolated/Moon" * / "@/components/Moon")} + * factory={(Moon) => } + * />, + * document.querySelector('#container'), + * ); + */ +export default function IsolatedComponent({ + name, + factory, + noStyle, + lazy, + ...props +}: Props) { + if (!isolatedComponentList.some((url) => url.endsWith("/" + name))) { + throw new Error( + `Isolated component "${name}" is not listed in isolatedComponentList.mjs. Add it there and restart webpack to create it.`, + ); + } + + // `discard` must be called before `React.lazy` + void discardNewStylesheets(signalFromPromise(lazy())); + const LazyComponent = React.lazy(lazy); + + const stylesheetUrl = noStyle ? null : chrome.runtime.getURL(`${name}.css`); + + return ( + // `pb-name` is used to visually identify it in the dev tools + + + + {factory(LazyComponent)} + + + ); +} diff --git a/src/components/Stylesheets.test.tsx b/src/components/Stylesheets.test.tsx new file mode 100644 index 0000000000..a8ea91a980 --- /dev/null +++ b/src/components/Stylesheets.test.tsx @@ -0,0 +1,107 @@ +/* + * 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 { fireEvent, render, screen } from "@testing-library/react"; +import { Stylesheets } from "./Stylesheets"; + +it("renders the children immediately when no stylesheets are provided", () => { + render( + +
hello
+
, + ); + + expect(screen.getByText("hello")).toBeInTheDocument(); +}); + +it("renders the children after the stylesheet is loaded", () => { + const { container } = render( + + + , + ); + + // eslint-disable-next-line testing-library/no-container, testing-library/no-node-access, @typescript-eslint/no-unnecessary-type-assertion -- Not a visible element + const stylesheet = container.querySelector("link")!; + expect(stylesheet).toHaveAttribute("href", "https://example.com/style.css"); + + // It should be in the document but not visible + expect(screen.getByText("Buy flowers")).toBeInTheDocument(); + expect(screen.queryByRole("button")).not.toBeInTheDocument(); + + fireEvent.load(stylesheet); + + // Now it should be visible + expect(screen.getByRole("button")).toBeInTheDocument(); +}); + +it("renders the children after the stylesheet is loaded when multiple stylesheets are provided", () => { + const { container } = render( + + + , + ); + + // eslint-disable-next-line testing-library/no-container, testing-library/no-node-access -- Not a visible element + const stylesheets = container.querySelectorAll("link") as unknown as [ + HTMLLinkElement, + HTMLLinkElement, + ]; + expect(stylesheets).toHaveLength(2); + expect(stylesheets[0]).toHaveAttribute( + "href", + "https://example.com/style1.css", + ); + expect(stylesheets[1]).toHaveAttribute( + "href", + "https://example.com/style2.css", + ); + + expect(screen.queryByRole("button")).not.toBeInTheDocument(); + + fireEvent.load(stylesheets[0]); + fireEvent.load(stylesheets[1]); + + // Now it should be visible + expect(screen.getByRole("button")).toBeInTheDocument(); +}); + +it("renders the children immediately even when the loading of the stylesheet fails", () => { + const { container } = render( + + + , + ); + + // eslint-disable-next-line testing-library/no-container, testing-library/no-node-access, @typescript-eslint/no-unnecessary-type-assertion -- Not a visible element + const stylesheet = container.querySelector("link")!; + expect(stylesheet).not.toBeNull(); + expect(stylesheet).toHaveAttribute("href", "https://example.com/style.css"); + + expect(screen.queryByRole("button")).not.toBeInTheDocument(); + + fireEvent.error(stylesheet); + + // It should show anyway + expect(screen.getByRole("button")).toBeInTheDocument(); +}); diff --git a/src/components/Stylesheets.tsx b/src/components/Stylesheets.tsx index 2cc28df60c..5328a29e59 100644 --- a/src/components/Stylesheets.tsx +++ b/src/components/Stylesheets.tsx @@ -17,6 +17,35 @@ import React, { useState } from "react"; import { castArray, uniq } from "lodash"; +import oneEvent from "one-event"; +import { assertNotNullish } from "@/utils/nullishUtils"; + +/** + * Detect and extract font-face rules because Chrome fails to load them from + * the shadow DOM's stylesheets: https://issues.chromium.org/issues/41085401 + */ +async function extractFontFaceRulesToMainDocument( + link: HTMLLinkElement | null, +) { + const isShadowRoot = link?.getRootNode() instanceof ShadowRoot; + if (!isShadowRoot) { + return; + } + + if (!link.sheet) { + await oneEvent(link, "load"); + assertNotNullish(link.sheet, "The stylesheet wasn't parsed after loading"); + } + + const fontFaceStylesheet = new CSSStyleSheet(); + for (const rule of link.sheet.cssRules) { + if (rule instanceof CSSFontFaceRule) { + fontFaceStylesheet.insertRule(rule.cssText); + } + } + + document.adoptedStyleSheets.push(fontFaceStylesheet); +} /** * Loads one or more stylesheets and hides the content until they're done loading. @@ -33,10 +62,13 @@ export const Stylesheets: React.FC<{ */ mountOnLoad?: boolean; }> = ({ href, children, mountOnLoad = false }) => { - const urls = uniq(castArray(href)); const [resolved, setResolved] = useState([]); + if (href.length === 0) { + // Shortcut if no stylesheets are needed + return <>{children}; + } - // `every` returns true for empty arrays + const urls = uniq(castArray(href)); const allResolved = urls.every((url) => resolved.includes(url)); return ( @@ -49,6 +81,7 @@ export const Stylesheets: React.FC<{ return ( . + */ + +/** + * @file Read by: + * - Webpack to create individual bundles + * - IsolatedComponent.tsx to ensure the `{webpackChunkName}.css` file will exist + * - ESLint to validate the usage of `webpackChunkName` + */ + +// These URLs are not automatically updated when refactoring. +// TODO: Find a format supported by IDEs. https://github.com/microsoft/TypeScript/issues/43759#issuecomment-2041000482 + +// Path rules: +// - Use relative paths from the `src` directory +// - Do not start with `/` +// - Do not add the file extension +const isolatedComponentList = [ + "bricks/renderers/PropertyTree", + "components/selectionToolPopover/SelectionToolPopover", + "contentScript/textSelectionMenu/SelectionMenu", +]; + +export default isolatedComponentList; diff --git a/src/components/quickBar/QuickBarApp.tsx b/src/components/quickBar/QuickBarApp.tsx index 70c4abf84c..069de21ed7 100644 --- a/src/components/quickBar/QuickBarApp.tsx +++ b/src/components/quickBar/QuickBarApp.tsx @@ -135,12 +135,6 @@ const KBarComponent: React.FC = () => { }} > - {/* - Wrap the quickbar in a shadow dom. This isolates the quickbar from styles being passed down from - whichever website it's rendering on. - To support react-select and any future potential emotion components we used the - emotion variant of the react-shadow library. - */} tag but it doesn't work in storybook - */ export default { title: "Components/SelectionToolPopover", component: SelectionToolPopover, diff --git a/src/components/selectionToolPopover/SelectionToolPopover.tsx b/src/components/selectionToolPopover/SelectionToolPopover.tsx index efcec4f43c..bec6b74db5 100644 --- a/src/components/selectionToolPopover/SelectionToolPopover.tsx +++ b/src/components/selectionToolPopover/SelectionToolPopover.tsx @@ -14,26 +14,24 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ +import "@/vendors/bootstrapWithoutRem.css"; +import "bootstrap-switch-button-react/src/style.css"; +import styles from "./SelectionToolPopover.module.scss"; import React, { type ChangeEvent, useEffect, useState } from "react"; -import ReactDOM from "react-dom"; -import bootstrap from "@/vendors/bootstrapWithoutRem.css?loadAsUrl"; import Draggable from "react-draggable"; -import EmotionShadowRoot from "@/components/EmotionShadowRoot"; import SwitchButtonWidget, { type CheckBoxLike, } from "@/components/form/widgets/switchButton/SwitchButtonWidget"; -import switchStyle from "@/components/form/widgets/switchButton/SwitchButtonWidget.module.scss?loadAsUrl"; -import switchButtonStyle from "bootstrap-switch-button-react/src/style.css?loadAsUrl"; -import custom from "./SelectionToolPopover.module.scss?loadAsUrl"; -import { Stylesheets } from "@/components/Stylesheets"; import { Button, FormLabel } from "react-bootstrap"; import pluralize from "@/utils/pluralize"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { faGripHorizontal } from "@fortawesome/free-solid-svg-icons"; export type SelectionHandlerType = (count: number) => void; -type SetSelectionHandlerType = (handler: SelectionHandlerType | null) => void; +export type SetSelectionHandlerType = ( + handler: SelectionHandlerType | null, +) => void; const SelectionToolPopover: React.FC<{ isMulti: boolean; @@ -66,100 +64,63 @@ const SelectionToolPopover: React.FC<{ }, [setSelectionHandler]); return ( - // To support react-select and any future potential emotion components we used the - // emotion variant of the react-shadow library. + +
+
+ + {`Selection Tool: ${matchingCount} ${pluralize( + matchingCount, + "matching element", + "matching elements", + )}`} +
+
+ ) => { + setMultiEnabled(target.value); + if (!target.value) { + setSimilarEnabled(false); + } - - - -
-
- - {`Selection Tool: ${matchingCount} ${pluralize( - matchingCount, - "matching element", - "matching elements", - )}`} -
-
+ onChangeMultiSelection(target.value); + }} + /> + + Select Multiple + + + {multiEnabled && ( + <> ) => { - setMultiEnabled(target.value); - if (!target.value) { - setSimilarEnabled(false); - } - - onChangeMultiSelection(target.value); + setSimilarEnabled(target.value); + onChangeSimilarSelection(target.value); }} /> - Select Multiple + Select Similar + + )} - {multiEnabled && ( - <> - ) => { - setSimilarEnabled(target.value); - onChangeSimilarSelection(target.value); - }} - /> - - Select Similar - - - )} - - - -
-
-
-
-
- ); -}; - -export const showSelectionToolPopover = ({ - rootElement, - isMulti, - handleCancel, - handleDone, - handleMultiChange, - handleSimilarChange, - setSelectionHandler, -}: { - rootElement: HTMLElement; - isMulti: boolean; - handleCancel: () => void; - handleDone: () => void; - handleMultiChange: (value: boolean) => void; - handleSimilarChange: (value: boolean) => void; - setSelectionHandler: SetSelectionHandlerType; -}) => { - ReactDOM.render( - , - rootElement, + + +
+
+
); }; diff --git a/src/components/selectionToolPopover/showSelectionToolPopover.tsx b/src/components/selectionToolPopover/showSelectionToolPopover.tsx new file mode 100644 index 0000000000..f3cfedbfca --- /dev/null +++ b/src/components/selectionToolPopover/showSelectionToolPopover.tsx @@ -0,0 +1,42 @@ +/* + * 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 } from "react-dom"; +import IsolatedComponent from "@/components/IsolatedComponent"; +import type Component from "@/components/selectionToolPopover/SelectionToolPopover"; + +export default function showSelectionToolPopover({ + rootElement, + ...props +}: { + rootElement: HTMLElement; +} & React.ComponentProps) { + render( + + import( + /* webpackChunkName: "isolated/SelectionToolPopover" */ + "@/components/selectionToolPopover/SelectionToolPopover" + ) + } + factory={(SelectionToolPopover) => } + />, + rootElement, + ); +} diff --git a/src/contentScript/pageEditor/elementPicker.test.ts b/src/contentScript/pageEditor/elementPicker.test.ts index 9ae1266720..321f37d18a 100644 --- a/src/contentScript/pageEditor/elementPicker.test.ts +++ b/src/contentScript/pageEditor/elementPicker.test.ts @@ -17,16 +17,14 @@ import { userSelectElement } from "@/contentScript/pageEditor/elementPicker"; import { BusinessError } from "@/errors/businessErrors"; -import { showSelectionToolPopover } from "@/components/selectionToolPopover/SelectionToolPopover"; +import showSelectionToolPopover from "@/components/selectionToolPopover/showSelectionToolPopover"; // Mock because the React vs. JSDOM event handling and dom manipulation isn't playing nicely together -jest.mock("@/components/selectionToolPopover/SelectionToolPopover"); +jest.mock("@/components/selectionToolPopover/showSelectionToolPopover"); const showSelectionToolPopoverMock = jest.mocked(showSelectionToolPopover); -beforeAll(() => { - Element.prototype.scrollTo = jest.fn(); -}); +Element.prototype.scrollTo = jest.fn(); beforeEach(() => { showSelectionToolPopoverMock.mockClear(); diff --git a/src/contentScript/pageEditor/elementPicker.ts b/src/contentScript/pageEditor/elementPicker.ts index 38631d7d92..08d4e9944d 100644 --- a/src/contentScript/pageEditor/elementPicker.ts +++ b/src/contentScript/pageEditor/elementPicker.ts @@ -20,13 +20,11 @@ import Overlay from "@/vendors/Overlay"; import ReactDOM from "react-dom"; import { expandedCssSelector } from "@/utils/inference/selectorInference"; import { compact, difference, uniq } from "lodash"; -import { - type SelectionHandlerType, - showSelectionToolPopover, -} from "@/components/selectionToolPopover/SelectionToolPopover"; +import type { SelectionHandlerType } from "@/components/selectionToolPopover/SelectionToolPopover"; import { BusinessError, CancelError } from "@/errors/businessErrors"; import { FLOATING_ACTION_BUTTON_CONTAINER_ID } from "@/components/floatingActions/floatingActionsConstants"; import { onContextInvalidated } from "webext-events"; +import showSelectionToolPopover from "@/components/selectionToolPopover/showSelectionToolPopover"; /** * Primary overlay that moved with the user's mouse/selection. @@ -372,12 +370,10 @@ export async function userSelectElement({ showSelectionToolPopover({ rootElement: multiSelectionToolElement, isMulti, - handleCancel: cancel, - handleDone() { - handleDone(); - }, - handleMultiChange: handleMultiSelectionChange, - handleSimilarChange: handleSimilarSelectionChange, + onCancel: cancel, + onDone: handleDone, + onChangeMultiSelection: handleMultiSelectionChange, + onChangeSimilarSelection: handleSimilarSelectionChange, setSelectionHandler, }); } diff --git a/src/contentScript/pageEditor/selectElement.test.ts b/src/contentScript/pageEditor/selectElement.test.ts index b6363b0821..96fe5e90d7 100644 --- a/src/contentScript/pageEditor/selectElement.test.ts +++ b/src/contentScript/pageEditor/selectElement.test.ts @@ -16,10 +16,10 @@ */ import selectElement from "./selectElement"; -import { showSelectionToolPopover } from "@/components/selectionToolPopover/SelectionToolPopover"; +import showSelectionToolPopover from "@/components/selectionToolPopover/showSelectionToolPopover"; // Mock because the React vs. JSDOM event handling and dom manipulation isn't playing nicely together -jest.mock("@/components/selectionToolPopover/SelectionToolPopover"); +jest.mock("@/components/selectionToolPopover/showSelectionToolPopover"); const showSelectionToolPopoverMock = jest.mocked(showSelectionToolPopover); @@ -132,7 +132,7 @@ describe("selectElement", () => { expect(selectionHandlerMock).toHaveBeenCalledTimes(2); - args.handleDone(); + args.onDone(); await expect(selectPromise).resolves.toEqual({ parent: null, @@ -159,7 +159,7 @@ describe("selectElement", () => { args.setSelectionHandler(selectionHandlerMock); - args.handleSimilarChange(true); + args.onChangeSimilarSelection(true); expect(selectionHandlerMock).toHaveBeenCalledTimes(1); // React testing userEvent library doesn't seem to work here @@ -176,7 +176,7 @@ describe("selectElement", () => { expect(selectionHandlerMock).toHaveBeenCalledTimes(3); - args.handleDone(); + args.onDone(); await expect(selectPromise).resolves.toEqual({ parent: null, diff --git a/src/contentScript/textSelectionMenu/SelectionMenu.tsx b/src/contentScript/textSelectionMenu/SelectionMenu.tsx index 112db96db8..f7607edfdf 100644 --- a/src/contentScript/textSelectionMenu/SelectionMenu.tsx +++ b/src/contentScript/textSelectionMenu/SelectionMenu.tsx @@ -15,16 +15,13 @@ * along with this program. If not, see . */ +import "@/contentScript/textSelectionMenu/SelectionMenu.scss"; import React from "react"; -// We're rendering in the shadow DOM, so we need to load styles as a URL. loadAsUrl doesn't work with module mangling -import stylesUrl from "@/contentScript/textSelectionMenu/SelectionMenu.scss?loadAsUrl"; import Icon from "@/icons/Icon"; import { splitStartingEmoji } from "@/utils/stringUtils"; import { truncate } from "lodash"; import useDocumentSelection from "@/hooks/useDocumentSelection"; import type { Nullishable } from "@/utils/nullishUtils"; -import { Stylesheets } from "@/components/Stylesheets"; -import EmotionShadowRoot from "@/components/EmotionShadowRoot"; import { getSelection } from "@/utils/selectionController"; import { type RegisteredAction } from "@/contentScript/textSelectionMenu/ActionRegistry"; import type ActionRegistry from "@/contentScript/textSelectionMenu/ActionRegistry"; @@ -95,26 +92,21 @@ const SelectionMenu: React.FC< // https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Roles/menu_role return ( - // Prevent page styles from leaking into the menu - - -
- {[...actions.entries()].map(([id, action]) => ( - - ))} -
-
-
+
+ {[...actions.entries()].map(([id, action]) => ( + + ))} +
); }; diff --git a/src/contentScript/textSelectionMenu/selectionMenuController.tsx b/src/contentScript/textSelectionMenu/selectionMenuController.tsx index e8cb149d8c..1e7a4e3266 100644 --- a/src/contentScript/textSelectionMenu/selectionMenuController.tsx +++ b/src/contentScript/textSelectionMenu/selectionMenuController.tsx @@ -30,17 +30,16 @@ import { type VirtualElement, } from "@floating-ui/dom"; import { getCaretCoordinates } from "@/utils/textAreaUtils"; -import TextSelectionMenu from "@/contentScript/textSelectionMenu/SelectionMenu"; import { expectContext } from "@/utils/expectContext"; import { onContextInvalidated } from "webext-events"; import { isNativeField } from "@/types/inputTypes"; import { onAbort, ReusableAbortController } from "abort-utils"; import { prefersReducedMotion } from "@/utils/a11yUtils"; import { getSelectionRange } from "@/utils/domUtils"; - import { snapWithin } from "@/utils/mathUtils"; import ActionRegistry from "@/contentScript/textSelectionMenu/ActionRegistry"; import { SELECTION_MENU_READY_ATTRIBUTE } from "@/domConstants"; +import IsolatedComponent from "@/components/IsolatedComponent"; const MIN_SELECTION_LENGTH_CHARS = 3; @@ -166,9 +165,20 @@ function createSelectionMenu(): HTMLElement { selectionMenu.dataset.testid = "pixiebrix-selection-menu"; render( - + import( + /* webpackChunkName: "isolated/SelectionMenu" */ + "@/contentScript/textSelectionMenu/SelectionMenu" + ) + } + factory={(SelectionMenu) => ( + + )} />, selectionMenu, ); diff --git a/src/extensionConsole/pages/brickEditor/useSubmitBrick.test.tsx b/src/extensionConsole/pages/brickEditor/useSubmitBrick.test.tsx index 935b135ad7..79f380b3a8 100644 --- a/src/extensionConsole/pages/brickEditor/useSubmitBrick.test.tsx +++ b/src/extensionConsole/pages/brickEditor/useSubmitBrick.test.tsx @@ -27,8 +27,9 @@ import { type SettingsState } from "@/store/settings/settingsTypes"; import { configureStore } from "@reduxjs/toolkit"; import { authSlice } from "@/auth/authSlice"; import settingsSlice from "@/store/settings/settingsSlice"; -// FIXME: this is coming through as a module with default being a JSON object. (yaml-jest-transform is being applied) -import pipedriveYaml from "@contrib/integrations/pipedrive.yaml?loadAsText"; + +// FIXME: Use ?loadAsText when supported by Jest https://github.com/jestjs/jest/pull/6282 +import pipedriveYaml from "@contrib/integrations/pipedrive.yaml"; import { appApi } from "@/data/service/api"; import { brickToYaml } from "@/utils/objToYaml"; import testMiddleware, { diff --git a/src/manifest.json b/src/manifest.json index f28355735b..89dad77a48 100644 --- a/src/manifest.json +++ b/src/manifest.json @@ -66,21 +66,15 @@ "bundles/*", "sandbox.html", "frame.html", - "frame.css", "sidebar.html", - "sidebar.css", - "pageEditor.css", "pageScript.js", "ephemeralForm.html", "walkthroughModal.html", "ephemeralPanel.html", - "ephemeralModal.css", - "DocumentView.css", - "CustomFormComponent.css", - "EphemeralFormContent.css", "audio/*", "user-icons/*", - "img/*" + "img/*", + "*.css" ], "browser_action": { "default_title": "PixieBrix", diff --git a/src/testUtils/testEnv.js b/src/testUtils/testEnv.js index 9e53655e08..3b27cc1dc7 100644 --- a/src/testUtils/testEnv.js +++ b/src/testUtils/testEnv.js @@ -21,6 +21,7 @@ import { TextEncoder, TextDecoder } from "node:util"; // eslint-disable-next-line import/no-unassigned-import -- It's a polyfill import "urlpattern-polyfill"; +process.env.SHADOW_ROOT = "open"; process.env.SERVICE_URL = "https://app.pixiebrix.com"; process.env.MARKETPLACE_URL = "https://www.pixiebrix.com/marketplace/"; diff --git a/src/tsconfig.strictNullChecks.json b/src/tsconfig.strictNullChecks.json index 8e18d1f343..c31a09ff3e 100644 --- a/src/tsconfig.strictNullChecks.json +++ b/src/tsconfig.strictNullChecks.json @@ -205,6 +205,7 @@ "./components/ErrorBoundary.tsx", "./components/InvalidatedContextGate.test.tsx", "./components/InvalidatedContextGate.tsx", + "./components/IsolatedComponent.tsx", "./components/LayoutWidget.tsx", "./components/LinkButton.tsx", "./components/Loader.tsx", @@ -214,6 +215,7 @@ "./components/ModalLayout.tsx", "./components/StopPropagation.test.tsx", "./components/StopPropagation.tsx", + "./components/Stylesheets.test.tsx", "./components/Stylesheets.tsx", "./components/StylesheetsContext.ts", "./components/TooltipIconButton.tsx", @@ -296,6 +298,7 @@ "./components/imagePlaceholder/ImagePlaceholder.tsx", "./components/integrations/integrationHelpers.ts", "./components/integrations/integrationHelpers.test.ts", + "./components/isolatedComponentList.d.ts", "./components/jsonTree/JsonTree.tsx", "./components/jsonTree/treeHooks.tsx", "./components/logViewer/EntryRow.tsx", @@ -319,6 +322,7 @@ "./components/quickBar/useActionGenerators.ts", "./components/quickBar/useActions.ts", "./components/quickBar/utils.ts", + "./components/selectionToolPopover/showSelectionToolPopover.tsx", "./components/selectionToolPopover/SelectionToolPopover.tsx", "./components/walkthroughModal/showWalkthroughModal.ts", "./contentScript/activationConstants.ts", diff --git a/webpack.config.mjs b/webpack.config.mjs index 355e3beac1..d8ab78a482 100644 --- a/webpack.config.mjs +++ b/webpack.config.mjs @@ -32,6 +32,7 @@ import { parseEnv, loadEnv } from "./scripts/env.mjs"; import customizeManifest from "./scripts/manifest.mjs"; import { createRequire } from "node:module"; import DiscardFilePlugin from "./scripts/DiscardFilePlugin.mjs"; +import isolatedComponentList from "./src/components/isolatedComponentList.mjs"; import ReactRefreshWebpackPlugin from "@pmmmwh/react-refresh-webpack-plugin"; const require = createRequire(import.meta.url); @@ -113,19 +114,17 @@ const createConfig = (env, options) => entry: Object.fromEntries( [ "background/background", - // Components rendered within the Shadow DOM, such as those used by the Document Renderer brick in the sidebar, - // are isolated from global styles. This prevents access to CSS module classes used by these components. - // To resolve this, add the root component of the affected component hierarchy to the webpack function that - // bundles styles. This will make the CSS available to be loaded by the component tree. - // Additionally, remember to add the related JavaScript file to the DiscardFilePlugin.mjs file to exclude - // it from the bundle, as it is not needed for rendering in this context. + + // TODO: Move to isolatedComponentList.mjs and use "bricks/renderers/CustomFormComponent", "bricks/renderers/documentView/DocumentView", "bricks/transformers/ephemeralForm/EphemeralFormContent", + "contentScript/contentScript", "contentScript/loadActivationEnhancements", "contentScript/browserActionInstantHandler", "contentScript/setExtensionIdInApp", + "pageEditor/pageEditor", "extensionConsole/options", "sidebar/sidebar", @@ -142,6 +141,9 @@ const createConfig = (env, options) => // The script that gets injected into the host page "pageScript/pageScript", + + // The isolated components whose CSS will be loaded in a shadow DOM + ...isolatedComponentList, ].map((name) => [path.basename(name), `./src/${name}`]), ), @@ -255,6 +257,7 @@ const createConfig = (env, options) => DEV_EVENT_TELEMETRY: false, SANDBOX_LOGGING: false, IS_BETA: process.env.PUBLIC_NAME === "-beta", + SHADOW_DOM: "closed", // If not found, "undefined" will cause the build to fail SERVICE_URL: undefined, @@ -294,7 +297,11 @@ const createConfig = (env, options) => "static", ], }), - new DiscardFilePlugin(), + + // These files are not used, they're only webpack entry points in order to generate + // a full CSS files that can be injected in shadow DOM. See this for more context: + // https://github.com/webpack-contrib/mini-css-extract-plugin/issues/1092#issuecomment-2037540032 + new DiscardFilePlugin(isolatedComponentList), isHMR && new ReactRefreshWebpackPlugin({ @@ -305,7 +312,7 @@ const createConfig = (env, options) => rules: [ { test: /\.s?css$/, - resourceQuery: { not: [/loadAsUrl/] }, + resourceQuery: { not: [/loadAsUrl|loadAsText/] }, use: [MiniCssExtractPlugin.loader, "css-loader"], }, { diff --git a/webpack.sharedConfig.js b/webpack.sharedConfig.js index 53bd3151f1..0c4733182e 100644 --- a/webpack.sharedConfig.js +++ b/webpack.sharedConfig.js @@ -49,7 +49,7 @@ const shared = { // Lighter jQuery version jquery: "jquery/dist/jquery.slim.min.js", }, - extensions: [".ts", ".tsx", ".jsx", ".js"], + extensions: [".ts", ".tsx", ".jsx", ".js", ".mjs"], fallback: { fs: false, crypto: false, @@ -115,6 +115,10 @@ const shared = { test: /\.txt/, type: "asset/source", }, + { + resourceQuery: /loadAsText/, + type: "asset/source", + }, { // CSS-only, must include .css or else it may output .scss files test: /\.s?css$/,