From 37418381afa487a5e89e90cd05c2b0ffaacccb41 Mon Sep 17 00:00:00 2001 From: Leo McArdle Date: Tue, 10 Dec 2024 17:00:07 +0000 Subject: [PATCH] chore(playground): migrate runner iframe to web component https://mozilla-hub.atlassian.net/browse/MP-1806 --- client/src/playground/index.scss | 3 +- client/src/playground/index.tsx | 89 +++++----------------- client/src/playground/runner.js | 121 ++++++++++++++++++++++++++++++ client/src/playground/runner.scss | 5 ++ package.json | 1 + yarn.lock | 9 ++- 6 files changed, 154 insertions(+), 74 deletions(-) create mode 100644 client/src/playground/runner.js create mode 100644 client/src/playground/runner.scss diff --git a/client/src/playground/index.scss b/client/src/playground/index.scss index d4a921affbeb..efc55f8eae73 100644 --- a/client/src/playground/index.scss +++ b/client/src/playground/index.scss @@ -231,7 +231,8 @@ main.play { } } - iframe { + play-runner { + border: 1px solid var(--border-primary); height: 100%; width: 100%; } diff --git a/client/src/playground/index.tsx b/client/src/playground/index.tsx index fd494c5e26af..049214facfce 100644 --- a/client/src/playground/index.tsx +++ b/client/src/playground/index.tsx @@ -11,19 +11,14 @@ import prettierPluginHTML from "prettier/plugins/html"; import { Button } from "../ui/atoms/button"; import Editor, { EditorHandle } from "./editor"; import { SidePlacement } from "../ui/organisms/placement"; -import { - compressAndBase64Encode, - decompressFromBase64, - EditorContent, - SESSION_KEY, -} from "./utils"; +import { decompressFromBase64, EditorContent, SESSION_KEY } from "./utils"; import "./index.scss"; -import { PLAYGROUND_BASE_HOST } from "../env"; import { FlagForm, ShareForm } from "./forms"; import { Console, VConsole } from "./console"; import { useGleanClick } from "../telemetry/glean-context"; import { PLAYGROUND } from "../telemetry/constants"; +import { ReactPlayRunner } from "./runner"; const HTML_DEFAULT = ""; const CSS_DEFAULT = ""; @@ -79,14 +74,12 @@ export default function Playground() { let [shareUrl, setShareUrl] = useState(null); let [vConsole, setVConsole] = useState([]); let [state, setState] = useState(State.initial); + const [code, setCode] = useState(); let [codeSrc, setCodeSrc] = useState(); - let [iframeSrc, setIframeSrc] = useState("about:blank"); const [isEmpty, setIsEmpty] = useState(true); - const subdomain = useRef(crypto.randomUUID()); const [initialContent, setInitialContent] = useState( null ); - const [flipFlop, setFlipFlop] = useState(0); let { data: initialCode } = useSWRImmutable( !stateParam && !shared && gistId ? `/api/v1/play/${encodeURIComponent(gistId)}` @@ -118,34 +111,11 @@ export default function Playground() { const htmlRef = useRef(null); const cssRef = useRef(null); const jsRef = useRef(null); - const iframe = useRef(null); const diaRef = useRef(null); - const updateWithCode = useCallback( - async (code: EditorContent) => { - const { state } = await compressAndBase64Encode(JSON.stringify(code)); - - // We're using a random subdomain for origin isolation. - const url = new URL( - window.location.hostname.endsWith("localhost") - ? window.location.origin - : `${window.location.protocol}//${ - PLAYGROUND_BASE_HOST.startsWith("localhost") - ? "" - : `${subdomain.current}.` - }${PLAYGROUND_BASE_HOST}` - ); - setVConsole([]); - url.searchParams.set("state", state); - // ensure iframe reloads even if code doesn't change - url.searchParams.set("f", flipFlop.toString()); - url.pathname = `${codeSrc || code.src || ""}/runner.html`; - setIframeSrc(url.href); - // using an updater function causes the second "run" to not reload properly: - setFlipFlop((flipFlop + 1) % 2); - }, - [codeSrc, setVConsole, setIframeSrc, flipFlop, setFlipFlop] - ); + useEffect(() => { + setVConsole([]); + }, [code, setVConsole]); useEffect(() => { if (initialCode) { @@ -167,26 +137,9 @@ export default function Playground() { return code; }, [initialContent?.src, initialCode?.src]); - let messageListener = useCallback(({ data: { typ, prop, message } }) => { - if (typ === "console") { - if ( - (prop === "log" || prop === "error" || prop === "warn") && - typeof message === "string" - ) { - setVConsole((vConsole) => [...vConsole, { prop, message }]); - } else { - const warning = "[Playground] Unsupported console message"; - setVConsole((vConsole) => [ - ...vConsole, - { - prop: "warn", - message: `${warning} (see browser console)`, - }, - ]); - console.warn(warning, { prop, message }); - } - } - }, []); + const onConsole = ({ detail }: CustomEvent) => { + setVConsole((vConsole) => [...vConsole, detail]); + }; const setEditorContent = ({ html, css, js, src }: EditorContent) => { htmlRef.current?.setContent(html); @@ -205,7 +158,7 @@ export default function Playground() { setEditorContent(initialCode); if (!gistId) { // don't auto run shared code - updateWithCode(initialCode); + setCode(initialCode); } } else if (stateParam) { try { @@ -225,14 +178,7 @@ export default function Playground() { setState(State.ready); } })(); - }, [initialCode, state, gistId, stateParam, updateWithCode]); - - useEffect(() => { - window.addEventListener("message", messageListener); - return () => { - window.removeEventListener("message", messageListener); - }; - }, [messageListener]); + }, [initialCode, state, gistId, stateParam, setCode]); const clear = async () => { setSearchParams([], { replace: true }); @@ -284,7 +230,7 @@ export default function Playground() { iterations: 1, }; document.getElementById("run")?.firstElementChild?.animate(loading, timing); - updateWithCode({ html, css, js, src }); + setCode({ html, css, js, src }); }; const format = async () => { @@ -414,12 +360,11 @@ export default function Playground() { Seeing something inappropriate? )} - + diff --git a/client/src/playground/runner.js b/client/src/playground/runner.js new file mode 100644 index 000000000000..5c1f625a2e9e --- /dev/null +++ b/client/src/playground/runner.js @@ -0,0 +1,121 @@ +import { html, LitElement } from "lit"; +import { compressAndBase64Encode } from "./utils.ts"; +import { PLAYGROUND_BASE_HOST } from "../env.ts"; +import { createComponent } from "@lit/react"; +import { Task } from "@lit/task"; +import React from "react"; + +import styles from "./runner.scss?css" with { type: "css" }; + +/** @import { EditorContent } from "./utils.ts" */ +/** @import { VConsole } from "./console.tsx" */ +/** @import { EventName } from "@lit/react" */ + +export class PlayRunner extends LitElement { + static properties = { + code: { type: Object }, + srcPrefix: { type: String, attribute: "src-prefix" }, + _src: { state: true }, + }; + + static styles = styles; + + constructor() { + super(); + /** @type {EditorContent | undefined} */ + this.code = undefined; + /** @type {string | undefined} */ + this.srcPrefix = undefined; + this._src = "about:blank"; + + this._subdomain = crypto.randomUUID(); + this._flipFlop = 0; + } + + /** @param {MessageEvent} e */ + _onMessage({ data: { typ, prop, message } }) { + if (typ === "console") { + if ( + (prop === "log" || prop === "error" || prop === "warn") && + typeof message === "string" + ) { + /** @type {VConsole} */ + const detail = { prop, message }; + this.dispatchEvent( + new CustomEvent("console", { bubbles: true, composed: true, detail }) + ); + } else { + const warning = "[Playground] Unsupported console message"; + /** @type {VConsole} */ + const detail = { + prop: "warn", + message: `${warning} (see browser console)`, + }; + this.dispatchEvent( + new CustomEvent("console", { bubbles: true, composed: true, detail }) + ); + console.warn(warning, { prop, message }); + } + } + } + + _updateSrc = new Task(this, { + args: () => /** @type {const} */ ([this.code, this.srcPrefix]), + task: async ([code, srcPrefix], { signal }) => { + if (code) { + const { state } = await compressAndBase64Encode(JSON.stringify(code)); + signal.throwIfAborted(); + // We're using a random subdomain for origin isolation. + const url = new URL( + window.location.hostname.endsWith("localhost") + ? window.location.origin + : `${window.location.protocol}//${ + PLAYGROUND_BASE_HOST.startsWith("localhost") + ? "" + : `${this._subdomain}.` + }${PLAYGROUND_BASE_HOST}` + ); + url.searchParams.set("state", state); + // ensure iframe reloads even if code doesn't change + url.searchParams.set("f", this._flipFlop.toString()); + url.pathname = `${srcPrefix || code.src || ""}/runner.html`; + this._src = url.href; + this._flipFlop = (this._flipFlop + 1) % 2; + } else { + this._src = "about:blank"; + } + }, + }); + + connectedCallback() { + super.connectedCallback(); + this._onMessage = this._onMessage.bind(this); + window.addEventListener("message", this._onMessage); + } + + render() { + return html` + + `; + } + + disconnectedCallback() { + super.disconnectedCallback(); + window.removeEventListener("message", this._onMessage); + } +} + +customElements.define("play-runner", PlayRunner); + +export const ReactPlayRunner = createComponent({ + tagName: "play-runner", + elementClass: PlayRunner, + react: React, + events: { + onConsole: /** @type {EventName>} */ ("console"), + }, +}); diff --git a/client/src/playground/runner.scss b/client/src/playground/runner.scss new file mode 100644 index 000000000000..31d32859dceb --- /dev/null +++ b/client/src/playground/runner.scss @@ -0,0 +1,5 @@ +iframe { + border: none; + height: 100%; + width: 100%; +} diff --git a/package.json b/package.json index 471b449ced30..abca35712024 100644 --- a/package.json +++ b/package.json @@ -76,6 +76,7 @@ "@fast-csv/parse": "^5.0.2", "@inquirer/prompts": "^7.2.0", "@lit/react": "^1.0.6", + "@lit/task": "^1.0.1", "@mdn/bcd-utils-api": "^0.0.7", "@mdn/browser-compat-data": "^5.6.22", "@mdn/rari": "^0.1.0", diff --git a/yarn.lock b/yarn.lock index 2356edc087e0..5af6fb984209 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2183,13 +2183,20 @@ resolved "https://registry.yarnpkg.com/@lit/react/-/react-1.0.6.tgz#9518ba471157becd1a3e6fb7ddc16bcef16be64e" integrity sha512-QIss8MPh6qUoFJmuaF4dSHts3qCsA36S3HcOLiNPShxhgYPr4XJRnCBKPipk85sR9xr6TQrOcDMfexwbNdJHYA== -"@lit/reactive-element@^2.0.4": +"@lit/reactive-element@^1.0.0 || ^2.0.0", "@lit/reactive-element@^2.0.4": version "2.0.4" resolved "https://registry.yarnpkg.com/@lit/reactive-element/-/reactive-element-2.0.4.tgz#8f2ed950a848016383894a26180ff06c56ae001b" integrity sha512-GFn91inaUa2oHLak8awSIigYz0cU0Payr1rcFsrkf5OJ5eSPxElyZfKh0f2p9FsTiZWXQdWGJeXZICEfXXYSXQ== dependencies: "@lit-labs/ssr-dom-shim" "^1.2.0" +"@lit/task@^1.0.1": + version "1.0.1" + resolved "https://registry.yarnpkg.com/@lit/task/-/task-1.0.1.tgz#7462aeaa973766822567f5ca90fe157404e8eb81" + integrity sha512-fVLDtmwCau8NywnFIXaJxsCZjzaIxnVq+cFRKYC1Y4tA4/0rMTvF6DLZZ2JE51BwzOluaKtgJX8x1QDsQtAaIw== + dependencies: + "@lit/reactive-element" "^1.0.0 || ^2.0.0" + "@marijn/find-cluster-break@^1.0.0": version "1.0.2" resolved "https://registry.yarnpkg.com/@marijn/find-cluster-break/-/find-cluster-break-1.0.2.tgz#775374306116d51c0c500b8c4face0f9a04752d8"