From eac6ca365cfadc7ac6f423f901ffadfe006c7712 Mon Sep 17 00:00:00 2001 From: Michael Gartner Date: Wed, 19 Aug 2026 09:17:33 -0600 Subject: [PATCH 1/4] Support URL and roam.js prototype loading --- AGENTS.md | 1 + README.md | 4 +- packages/extension-base/README.md | 2 + .../skills/react-rendering/SKILL.md | 2 +- packages/extension-base/template/README.md | 56 ++++++++++++++++++- packages/extension-base/template/src/index.ts | 46 ++++++++++----- prototypes/loaded-dialog/README.md | 56 ++++++++++++++++++- prototypes/loaded-dialog/src/index.ts | 29 ++++++++-- test/create-prototype.test.mjs | 9 +++ test/starter-integration.test.mjs | 11 ++++ 10 files changed, 192 insertions(+), 24 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index a8efcec..b6175c3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -14,6 +14,7 @@ This is a pnpm monorepo for public, installable Roam developer-extension artifac - Keep prototype code inside `prototypes/` and shared convention material inside `packages/extension-base`. - Import `roamjs-components` directly. `packages/extension-base` is build tooling, configuration, and a template, not a browser runtime API. - With this repository's ESM build, never default-import a published CommonJS subpath from `roamjs-components`. Import named exports from package barrels instead, such as `import { addStyle } from "roamjs-components/dom"`. +- Support both documented loading modes: Roam's developer-extension URL loader and the `roam/js` ESM loader. Treat `args.extensionAPI` as optional and provide a fallback or clearly gate features that require it; `window.roamAlphaAPI` remains available in both modes. - Read the relevant guidance under `packages/extension-base/skills` before using Roam graph writes, commands, navigation, or React rendering. - For new graph reads, prefer `await window.roamAlphaAPI.data.async.*`. Never use legacy top-level aliases such as `roamAlphaAPI.q`, `roamAlphaAPI.pull`, or `roamAlphaAPI.createBlock`. - Keep `runExtension` as the lifecycle wrapper. Dispose observers, listeners, commands, timers, and mounted UI when the extension unloads. diff --git a/README.md b/README.md index 8d979c6..5fd20ee 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,7 @@ The repository includes instructions for the assistant in [AGENTS.md](AGENTS.md) Public source repository for Discourse Graphs' installable Roam developer-extension prototypes. -Each prototype lives in `prototypes//`. Development uses ordinary feature branches and pull requests. Build artifacts are intentionally public so Roam can load them with **Load Developer Extensions from URL**. +Each prototype lives in `prototypes//`. Development uses ordinary feature branches and pull requests. Build artifacts are intentionally public so Roam can load them either with **Load Developer Extensions from URL** or from a `roam/js` code block. Each prototype README documents both methods. ## Release URLs @@ -68,6 +68,8 @@ It may also contain: - `extension.css` - `CHANGELOG.md` +URL loading is the full developer-extension environment: Roam supplies `extensionAPI`, loads `extension.css`, and owns unloading. The documented `roam/js` loader imports the same `extension.js`, supplies no `extensionAPI`, and therefore handles cache busting, stylesheet loading, and replacement unloading itself. Prototype behavior that depends on extension settings or other `extensionAPI` capabilities must feature-detect them; global `window.roamAlphaAPI` capabilities remain available in both modes. + ## Repository layout ```text diff --git a/packages/extension-base/README.md b/packages/extension-base/README.md index f64c960..529bfe6 100644 --- a/packages/extension-base/README.md +++ b/packages/extension-base/README.md @@ -26,4 +26,6 @@ import { runExtension } from "roamjs-components/util"; Use the same named-barrel form for every `roamjs-components` import. The package is published as TypeScript-compiled CommonJS, while prototypes are emitted as ESM. A default import from a published subpath can therefore bind the CommonJS export object (`{ default: fn }`) instead of the function. For example, use `import { addStyle } from "roamjs-components/dom"`, not a default import from `roamjs-components/dom/addStyle`. Prototype validation enforces this boundary. +Generated prototypes document two supported loading modes. Roam's developer-extension URL loader supplies `extensionAPI` and manages the release stylesheet. A `roam/js` code block can import the same ESM artifact, but it cannot manufacture Roam's extension-scoped API; its loader passes `extensionAPI: undefined` and manages the stylesheet and replacement lifecycle. Prototype code must treat extension-scoped capabilities as optional when it supports both modes. + Its production error reporting to SamePage is behavior inside `roamjs-components`, independent of which bundler produced `extension.js`; reports include the graph name and extension settings. Never store credentials or sensitive data in extension settings. diff --git a/packages/extension-base/skills/react-rendering/SKILL.md b/packages/extension-base/skills/react-rendering/SKILL.md index b286e52..893fd9d 100644 --- a/packages/extension-base/skills/react-rendering/SKILL.md +++ b/packages/extension-base/skills/react-rendering/SKILL.md @@ -14,7 +14,7 @@ import { addStyle } from "roamjs-components/dom"; import { runExtension } from "roamjs-components/util"; ``` -Roam automatically injects and removes a published `extension.css`. The extension API also automatically cleans up its commands, slash commands, settings panel, and experimental AI tools. DOM nodes, observers, event listeners, intervals, and custom registered components remain the extension's responsibility. +With URL loading, Roam automatically injects and removes a published `extension.css`. The extension API also automatically cleans up its commands, slash commands, settings panel, and experimental AI tools. The documented `roam/js` loader manages `extension.css`, but has no extension API, so feature-detect extension-scoped capabilities and explicitly clean up any fallback registrations. DOM nodes, observers, event listeners, intervals, and custom registered components remain the extension's responsibility in both modes. Use the supported Roam renderers when the UI is fundamentally Roam content: diff --git a/packages/extension-base/template/README.md b/packages/extension-base/template/README.md index cdc8f74..629218d 100644 --- a/packages/extension-base/template/README.md +++ b/packages/extension-base/template/README.md @@ -10,10 +10,62 @@ Internal prototype for evaluation by Discourse Graphs. - Document the prototype's user-visible behavior here. -## Install +## Install from a URL -Load this developer-extension URL in Roam: +In Roam, use **Load Developer Extensions from URL** with: ```text https://discoursegraphs.com/releases/prototypes/__PROTOTYPE_NAME__/ ``` + +Roam supplies the extension API, loads `extension.css`, and unloads the extension in this mode. + +## Load from roam/js + +Paste this loader into a `roam/js` code block. To test a pull-request preview, change only `baseUrl` to the preview release directory posted on the pull request. + +```javascript +(async () => { + const baseUrl = + "https://discoursegraphs.com/releases/prototypes/__PROTOTYPE_NAME__"; + const globalKey = "__roamPrototype:__PROTOTYPE_NAME__"; + const version = Date.now(); + + const previous = window[globalKey]; + const previousExtension = previous?.extension ?? previous; + + // Import and validate the replacement before unloading a working copy. + const module = await import(`${baseUrl}/extension.js?v=${version}`); + const extension = module.default; + if (!extension?.onload || !extension?.onunload) { + throw new Error("The loaded module is not a Roam extension."); + } + + if (previousExtension?.onunload) { + await previousExtension.onunload(); + } + previous?.stylesheet?.remove(); + delete window[globalKey]; + + const stylesheet = document.createElement("link"); + stylesheet.rel = "stylesheet"; + stylesheet.href = `${baseUrl}/extension.css?v=${version}`; + stylesheet.dataset.roamPrototype = "__PROTOTYPE_NAME__"; + + try { + document.head.appendChild(stylesheet); + await extension.onload({ + extensionAPI: undefined, + extension: { version: "roam/js" }, + }); + window[globalKey] = { extension, stylesheet }; + } catch (error) { + stylesheet.remove(); + throw error; + } +})().catch((error) => { + console.error("Could not load __PROTOTYPE_TITLE__:", error); +}); +``` + +A `roam/js` block can use global `window.roamAlphaAPI` capabilities, but Roam does not provide the extension-scoped `extensionAPI` through this loading path. Features that require extension settings or other `extensionAPI` methods are available only with URL loading unless the prototype provides a fallback. diff --git a/packages/extension-base/template/src/index.ts b/packages/extension-base/template/src/index.ts index bcbd195..5a62828 100644 --- a/packages/extension-base/template/src/index.ts +++ b/packages/extension-base/template/src/index.ts @@ -2,22 +2,42 @@ import { render as renderToast } from "roamjs-components/components/Toast"; import { runExtension } from "roamjs-components/util"; import "./styles.css"; -export default runExtension(async () => { - if (process.env.NODE_ENV === "development") { +const reportRoamJsLoadFailure = (error: unknown) => { + console.error("Failed to load the __PROTOTYPE_TITLE__ prototype from roam/js.", error); + try { renderToast({ - id: "__PROTOTYPE_NAME__-loaded", - content: __LOAD_MESSAGE_JSON__, - intent: "success", - timeout: 800, + id: "__PROTOTYPE_NAME__-error", + content: "Failed to load __PROTOTYPE_TITLE__. See the developer console for details.", + intent: "danger", }); + } catch (toastError) { + console.error("Could not display the __PROTOTYPE_TITLE__ failure toast.", toastError); } +}; - // Add prototype behavior here. Register every observer, listener, command, - // timer, and mounted element for cleanup when the extension unloads. +export default runExtension(async (args) => { + try { + if (process.env.NODE_ENV === "development") { + renderToast({ + id: "__PROTOTYPE_NAME__-loaded", + content: __LOAD_MESSAGE_JSON__, + intent: "success", + timeout: 800, + }); + } - return { - unload: () => { - // Remove anything that is not returned through runExtension's registry. - }, - }; + // Add prototype behavior here. Register every observer, listener, command, + // timer, and mounted element for cleanup when the extension unloads. + // args.extensionAPI is available with URL loading and undefined from roam/js. + + return { + unload: () => { + // Remove anything that is not returned through runExtension's registry. + }, + }; + } catch (error) { + if (args.extensionAPI) throw error; + reportRoamJsLoadFailure(error); + return {}; + } }); diff --git a/prototypes/loaded-dialog/README.md b/prototypes/loaded-dialog/README.md index 5665d0f..b7c4eb4 100644 --- a/prototypes/loaded-dialog/README.md +++ b/prototypes/loaded-dialog/README.md @@ -11,10 +11,62 @@ Internal prototype for evaluation by Discourse Graphs. - Opens a Blueprint alert when the extension loads. - Confirms that the extension loaded successfully with a single **Got it** action. -## Install +## Install from a URL -Load this developer-extension URL in Roam: +In Roam, use **Load Developer Extensions from URL** with: ```text https://discoursegraphs.com/releases/prototypes/loaded-dialog/ ``` + +Roam supplies the extension API, loads `extension.css`, and unloads the extension in this mode. + +## Load from roam/js + +Paste this loader into a `roam/js` code block. To test a pull-request preview, change only `baseUrl` to the preview release directory posted on the pull request. + +```javascript +(async () => { + const baseUrl = + "https://discoursegraphs.com/releases/prototypes/loaded-dialog"; + const globalKey = "__roamPrototype:loaded-dialog"; + const version = Date.now(); + + const previous = window[globalKey]; + const previousExtension = previous?.extension ?? previous; + + // Import and validate the replacement before unloading a working copy. + const module = await import(`${baseUrl}/extension.js?v=${version}`); + const extension = module.default; + if (!extension?.onload || !extension?.onunload) { + throw new Error("The loaded module is not a Roam extension."); + } + + if (previousExtension?.onunload) { + await previousExtension.onunload(); + } + previous?.stylesheet?.remove(); + delete window[globalKey]; + + const stylesheet = document.createElement("link"); + stylesheet.rel = "stylesheet"; + stylesheet.href = `${baseUrl}/extension.css?v=${version}`; + stylesheet.dataset.roamPrototype = "loaded-dialog"; + + try { + document.head.appendChild(stylesheet); + await extension.onload({ + extensionAPI: undefined, + extension: { version: "roam/js" }, + }); + window[globalKey] = { extension, stylesheet }; + } catch (error) { + stylesheet.remove(); + throw error; + } +})().catch((error) => { + console.error("Could not load Loaded Dialog:", error); +}); +``` + +A `roam/js` block can use global `window.roamAlphaAPI` capabilities, but Roam does not provide the extension-scoped `extensionAPI` through this loading path. Features that require extension settings or other `extensionAPI` methods are available only with URL loading unless the prototype provides a fallback. diff --git a/prototypes/loaded-dialog/src/index.ts b/prototypes/loaded-dialog/src/index.ts index 9a9a78f..a508fb6 100644 --- a/prototypes/loaded-dialog/src/index.ts +++ b/prototypes/loaded-dialog/src/index.ts @@ -1,10 +1,29 @@ import { render as renderAlert } from "roamjs-components/components/SimpleAlert"; +import { render as renderToast } from "roamjs-components/components/Toast"; import { runExtension } from "roamjs-components/util"; import "./styles.css"; -export default runExtension(async () => { - await renderAlert({ - content: "Loaded Dialog has loaded successfully.", - confirmText: "Got it", - }); +const reportRoamJsLoadFailure = (error: unknown) => { + console.error("Failed to load Loaded Dialog from roam/js.", error); + try { + renderToast({ + id: "loaded-dialog-error", + content: "Failed to load Loaded Dialog. See the developer console for details.", + intent: "danger", + }); + } catch (toastError) { + console.error("Could not display the Loaded Dialog failure toast.", toastError); + } +}; + +export default runExtension(async (args) => { + try { + await renderAlert({ + content: "Loaded Dialog has loaded successfully.", + confirmText: "Got it", + }); + } catch (error) { + if (args.extensionAPI) throw error; + reportRoamJsLoadFailure(error); + } }); diff --git a/test/create-prototype.test.mjs b/test/create-prototype.test.mjs index 551f2ab..345bf4d 100644 --- a/test/create-prototype.test.mjs +++ b/test/create-prototype.test.mjs @@ -65,6 +65,15 @@ test("creates a complete prototype with catalog dependencies", async () => { entry, /import runExtension from "roamjs-components\/util\/runExtension";/, ); + assert.match(entry, /if \(args\.extensionAPI\) throw error;/); + const readme = await readFile(path.join(result.destination, "README.md"), "utf8"); + assert.match(readme, /Load Developer Extensions from URL/); + assert.match(readme, /extensionAPI: undefined/); + assert.match(readme, /extension\.css\?v=/); + assert.match(readme, /previousExtension\?\.onunload/); + const loader = /```javascript\n([\s\S]*?)\n```/.exec(readme)?.[1]; + assert.ok(loader, "generated README should contain a roam/js loader"); + assert.doesNotThrow(() => new Function(loader)); }); }); diff --git a/test/starter-integration.test.mjs b/test/starter-integration.test.mjs index 61959ea..447f46d 100644 --- a/test/starter-integration.test.mjs +++ b/test/starter-integration.test.mjs @@ -157,6 +157,17 @@ extension.onunload(); if (errorReports) { throw new Error("Built artifact reported a lifecycle failure"); } + +const roamJsExtension = (await import("./dist/extension.js?mode=roam-js")).default; +roamJsExtension.onload({ + extensionAPI: undefined, + extension: { version: "roam/js" }, +}); +await new Promise((resolve) => setTimeout(resolve, 0)); +roamJsExtension.onunload(); +if (errorReports) { + throw new Error("Built artifact reported a roam/js lifecycle failure"); +} `, "utf8", ); From 26b796e866693f0de937db3a0665805b9f1cabd6 Mon Sep 17 00:00:00 2001 From: Michael Gartner Date: Wed, 19 Aug 2026 09:23:22 -0600 Subject: [PATCH 2/4] Accept Windows newlines in loader test --- test/create-prototype.test.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/create-prototype.test.mjs b/test/create-prototype.test.mjs index 345bf4d..97ef238 100644 --- a/test/create-prototype.test.mjs +++ b/test/create-prototype.test.mjs @@ -71,7 +71,7 @@ test("creates a complete prototype with catalog dependencies", async () => { assert.match(readme, /extensionAPI: undefined/); assert.match(readme, /extension\.css\?v=/); assert.match(readme, /previousExtension\?\.onunload/); - const loader = /```javascript\n([\s\S]*?)\n```/.exec(readme)?.[1]; + const loader = /```javascript\r?\n([\s\S]*?)\r?\n```/.exec(readme)?.[1]; assert.ok(loader, "generated README should contain a roam/js loader"); assert.doesNotThrow(() => new Function(loader)); }); From facdaa8834cf9a3b70f5f13bb94f5583bee87268 Mon Sep 17 00:00:00 2001 From: Michael Gartner Date: Thu, 20 Aug 2026 22:50:03 -0600 Subject: [PATCH 3/4] Serialize overlapping roam.js loads --- packages/extension-base/template/README.md | 75 ++++++++++-------- prototypes/loaded-dialog/README.md | 75 ++++++++++-------- test/create-prototype.test.mjs | 2 + test/roam-js-loader.test.mjs | 92 ++++++++++++++++++++++ 4 files changed, 180 insertions(+), 64 deletions(-) create mode 100644 test/roam-js-loader.test.mjs diff --git a/packages/extension-base/template/README.md b/packages/extension-base/template/README.md index 629218d..a8ad2a3 100644 --- a/packages/extension-base/template/README.md +++ b/packages/extension-base/template/README.md @@ -29,39 +29,50 @@ Paste this loader into a `roam/js` code block. To test a pull-request preview, c const baseUrl = "https://discoursegraphs.com/releases/prototypes/__PROTOTYPE_NAME__"; const globalKey = "__roamPrototype:__PROTOTYPE_NAME__"; - const version = Date.now(); - - const previous = window[globalKey]; - const previousExtension = previous?.extension ?? previous; - - // Import and validate the replacement before unloading a working copy. - const module = await import(`${baseUrl}/extension.js?v=${version}`); - const extension = module.default; - if (!extension?.onload || !extension?.onunload) { - throw new Error("The loaded module is not a Roam extension."); - } - - if (previousExtension?.onunload) { - await previousExtension.onunload(); - } - previous?.stylesheet?.remove(); - delete window[globalKey]; - - const stylesheet = document.createElement("link"); - stylesheet.rel = "stylesheet"; - stylesheet.href = `${baseUrl}/extension.css?v=${version}`; - stylesheet.dataset.roamPrototype = "__PROTOTYPE_NAME__"; - + const loadKey = `${globalKey}:load`; + + const previousLoad = window[loadKey] ?? Promise.resolve(); + const currentLoad = previousLoad.catch(() => {}).then(async () => { + const version = Date.now(); + const previous = window[globalKey]; + const previousExtension = previous?.extension ?? previous; + + // Import and validate the replacement before unloading a working copy. + const module = await import(`${baseUrl}/extension.js?v=${version}`); + const extension = module.default; + if (!extension?.onload || !extension?.onunload) { + throw new Error("The loaded module is not a Roam extension."); + } + + if (previousExtension?.onunload) { + await previousExtension.onunload(); + } + previous?.stylesheet?.remove(); + delete window[globalKey]; + + const stylesheet = document.createElement("link"); + stylesheet.rel = "stylesheet"; + stylesheet.href = `${baseUrl}/extension.css?v=${version}`; + stylesheet.dataset.roamPrototype = "__PROTOTYPE_NAME__"; + + try { + document.head.appendChild(stylesheet); + await extension.onload({ + extensionAPI: undefined, + extension: { version: "roam/js" }, + }); + window[globalKey] = { extension, stylesheet }; + } catch (error) { + stylesheet.remove(); + throw error; + } + }); + + window[loadKey] = currentLoad; try { - document.head.appendChild(stylesheet); - await extension.onload({ - extensionAPI: undefined, - extension: { version: "roam/js" }, - }); - window[globalKey] = { extension, stylesheet }; - } catch (error) { - stylesheet.remove(); - throw error; + await currentLoad; + } finally { + if (window[loadKey] === currentLoad) delete window[loadKey]; } })().catch((error) => { console.error("Could not load __PROTOTYPE_TITLE__:", error); diff --git a/prototypes/loaded-dialog/README.md b/prototypes/loaded-dialog/README.md index b7c4eb4..d0c4379 100644 --- a/prototypes/loaded-dialog/README.md +++ b/prototypes/loaded-dialog/README.md @@ -30,39 +30,50 @@ Paste this loader into a `roam/js` code block. To test a pull-request preview, c const baseUrl = "https://discoursegraphs.com/releases/prototypes/loaded-dialog"; const globalKey = "__roamPrototype:loaded-dialog"; - const version = Date.now(); - - const previous = window[globalKey]; - const previousExtension = previous?.extension ?? previous; - - // Import and validate the replacement before unloading a working copy. - const module = await import(`${baseUrl}/extension.js?v=${version}`); - const extension = module.default; - if (!extension?.onload || !extension?.onunload) { - throw new Error("The loaded module is not a Roam extension."); - } - - if (previousExtension?.onunload) { - await previousExtension.onunload(); - } - previous?.stylesheet?.remove(); - delete window[globalKey]; - - const stylesheet = document.createElement("link"); - stylesheet.rel = "stylesheet"; - stylesheet.href = `${baseUrl}/extension.css?v=${version}`; - stylesheet.dataset.roamPrototype = "loaded-dialog"; - + const loadKey = `${globalKey}:load`; + + const previousLoad = window[loadKey] ?? Promise.resolve(); + const currentLoad = previousLoad.catch(() => {}).then(async () => { + const version = Date.now(); + const previous = window[globalKey]; + const previousExtension = previous?.extension ?? previous; + + // Import and validate the replacement before unloading a working copy. + const module = await import(`${baseUrl}/extension.js?v=${version}`); + const extension = module.default; + if (!extension?.onload || !extension?.onunload) { + throw new Error("The loaded module is not a Roam extension."); + } + + if (previousExtension?.onunload) { + await previousExtension.onunload(); + } + previous?.stylesheet?.remove(); + delete window[globalKey]; + + const stylesheet = document.createElement("link"); + stylesheet.rel = "stylesheet"; + stylesheet.href = `${baseUrl}/extension.css?v=${version}`; + stylesheet.dataset.roamPrototype = "loaded-dialog"; + + try { + document.head.appendChild(stylesheet); + await extension.onload({ + extensionAPI: undefined, + extension: { version: "roam/js" }, + }); + window[globalKey] = { extension, stylesheet }; + } catch (error) { + stylesheet.remove(); + throw error; + } + }); + + window[loadKey] = currentLoad; try { - document.head.appendChild(stylesheet); - await extension.onload({ - extensionAPI: undefined, - extension: { version: "roam/js" }, - }); - window[globalKey] = { extension, stylesheet }; - } catch (error) { - stylesheet.remove(); - throw error; + await currentLoad; + } finally { + if (window[loadKey] === currentLoad) delete window[loadKey]; } })().catch((error) => { console.error("Could not load Loaded Dialog:", error); diff --git a/test/create-prototype.test.mjs b/test/create-prototype.test.mjs index 97ef238..0222765 100644 --- a/test/create-prototype.test.mjs +++ b/test/create-prototype.test.mjs @@ -71,6 +71,8 @@ test("creates a complete prototype with catalog dependencies", async () => { assert.match(readme, /extensionAPI: undefined/); assert.match(readme, /extension\.css\?v=/); assert.match(readme, /previousExtension\?\.onunload/); + assert.match(readme, /const loadKey = `\$\{globalKey\}:load`/); + assert.match(readme, /window\[loadKey\] = currentLoad/); const loader = /```javascript\r?\n([\s\S]*?)\r?\n```/.exec(readme)?.[1]; assert.ok(loader, "generated README should contain a roam/js loader"); assert.doesNotThrow(() => new Function(loader)); diff --git a/test/roam-js-loader.test.mjs b/test/roam-js-loader.test.mjs new file mode 100644 index 0000000..a06dad1 --- /dev/null +++ b/test/roam-js-loader.test.mjs @@ -0,0 +1,92 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import path from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); + +const waitFor = async (predicate) => { + for (let attempt = 0; attempt < 100; attempt += 1) { + if (predicate()) return; + await new Promise((resolve) => setImmediate(resolve)); + } + throw new Error("Timed out waiting for the loader state"); +}; + +test("serializes overlapping roam/js loader executions", async () => { + const readme = await readFile( + path.join(repoRoot, "packages", "extension-base", "template", "README.md"), + "utf8", + ); + const loader = /```javascript\r?\n([\s\S]*?)\r?\n```/.exec(readme)?.[1]; + assert.ok(loader, "template README should contain a roam/js loader"); + + const dynamicImport = + "const module = await import(`${baseUrl}/extension.js?v=${version}`);"; + const instrumentedLoader = loader.replace( + dynamicImport, + "const module = await window.__importPrototype(`${baseUrl}/extension.js?v=${version}`);", + ); + assert.notEqual(instrumentedLoader, loader, "test should instrument the loader import"); + + const events = []; + let importCount = 0; + let releaseFirstLoad; + const firstLoad = new Promise((resolve) => { + releaseFirstLoad = resolve; + }); + const originalWindow = globalThis.window; + const originalDocument = globalThis.document; + + globalThis.window = { + __importPrototype: async () => { + const id = ++importCount; + events.push(`import:${id}`); + return { + default: { + id, + onload: async () => { + events.push(`onload:${id}`); + if (id === 1) await firstLoad; + }, + onunload: async () => { + events.push(`onunload:${id}`); + }, + }, + }; + }, + }; + globalThis.document = { + createElement: () => ({ dataset: {}, remove: () => {} }), + head: { appendChild: () => {} }, + }; + + try { + const runLoader = new Function(instrumentedLoader); + runLoader(); + runLoader(); + + await waitFor(() => events.includes("onload:1")); + assert.deepEqual(events, ["import:1", "onload:1"]); + + releaseFirstLoad(); + await waitFor( + () => window["__roamPrototype:__PROTOTYPE_NAME__"]?.extension?.id === 2, + ); + + assert.deepEqual(events, [ + "import:1", + "onload:1", + "import:2", + "onunload:1", + "onload:2", + ]); + assert.equal(window["__roamPrototype:__PROTOTYPE_NAME__:load"], undefined); + } finally { + if (originalWindow === undefined) delete globalThis.window; + else globalThis.window = originalWindow; + if (originalDocument === undefined) delete globalThis.document; + else globalThis.document = originalDocument; + } +}); From 7ec0b78ed2e551a4d715049c81665c51bf8a9d80 Mon Sep 17 00:00:00 2001 From: Michael Gartner Date: Thu, 20 Aug 2026 23:02:46 -0600 Subject: [PATCH 4/4] Clean up failed RoamJS replacements --- packages/extension-base/template/README.md | 8 +++ prototypes/loaded-dialog/README.md | 8 +++ test/roam-js-loader.test.mjs | 62 +++++++++++++++++++++- 3 files changed, 77 insertions(+), 1 deletion(-) diff --git a/packages/extension-base/template/README.md b/packages/extension-base/template/README.md index a8ad2a3..8f2a1f1 100644 --- a/packages/extension-base/template/README.md +++ b/packages/extension-base/template/README.md @@ -63,6 +63,14 @@ Paste this loader into a `roam/js` code block. To test a pull-request preview, c }); window[globalKey] = { extension, stylesheet }; } catch (error) { + try { + await extension.onunload(); + } catch (cleanupError) { + console.error( + "Could not clean up the failed __PROTOTYPE_TITLE__ load:", + cleanupError, + ); + } stylesheet.remove(); throw error; } diff --git a/prototypes/loaded-dialog/README.md b/prototypes/loaded-dialog/README.md index d0c4379..cee76bd 100644 --- a/prototypes/loaded-dialog/README.md +++ b/prototypes/loaded-dialog/README.md @@ -64,6 +64,14 @@ Paste this loader into a `roam/js` code block. To test a pull-request preview, c }); window[globalKey] = { extension, stylesheet }; } catch (error) { + try { + await extension.onunload(); + } catch (cleanupError) { + console.error( + "Could not clean up the failed Loaded Dialog load:", + cleanupError, + ); + } stylesheet.remove(); throw error; } diff --git a/test/roam-js-loader.test.mjs b/test/roam-js-loader.test.mjs index a06dad1..b4862c1 100644 --- a/test/roam-js-loader.test.mjs +++ b/test/roam-js-loader.test.mjs @@ -14,7 +14,7 @@ const waitFor = async (predicate) => { throw new Error("Timed out waiting for the loader state"); }; -test("serializes overlapping roam/js loader executions", async () => { +const readInstrumentedLoader = async () => { const readme = await readFile( path.join(repoRoot, "packages", "extension-base", "template", "README.md"), "utf8", @@ -29,6 +29,11 @@ test("serializes overlapping roam/js loader executions", async () => { "const module = await window.__importPrototype(`${baseUrl}/extension.js?v=${version}`);", ); assert.notEqual(instrumentedLoader, loader, "test should instrument the loader import"); + return instrumentedLoader; +}; + +test("serializes overlapping roam/js loader executions", async () => { + const instrumentedLoader = await readInstrumentedLoader(); const events = []; let importCount = 0; @@ -90,3 +95,58 @@ test("serializes overlapping roam/js loader executions", async () => { else globalThis.document = originalDocument; } }); + +test("cleans up an extension whose roam/js onload rejects", async () => { + const instrumentedLoader = await readInstrumentedLoader(); + const events = []; + const errors = []; + let stylesheetRemoved = false; + const originalWindow = globalThis.window; + const originalDocument = globalThis.document; + const originalConsoleError = console.error; + + globalThis.window = { + __importPrototype: async () => ({ + default: { + onload: async () => { + events.push("onload"); + throw new Error("load failed"); + }, + onunload: async () => { + events.push("onunload"); + }, + }, + }), + }; + globalThis.document = { + createElement: () => ({ + dataset: {}, + remove: () => { + stylesheetRemoved = true; + }, + }), + head: { appendChild: () => {} }, + }; + console.error = (...args) => errors.push(args); + + try { + new Function(instrumentedLoader)(); + await waitFor( + () => + events.includes("onunload") && + window["__roamPrototype:__PROTOTYPE_NAME__:load"] === undefined, + ); + + assert.deepEqual(events, ["onload", "onunload"]); + assert.equal(stylesheetRemoved, true); + assert.equal(window["__roamPrototype:__PROTOTYPE_NAME__"], undefined); + assert.equal(errors.length, 1); + assert.match(errors[0][0], /Could not load __PROTOTYPE_TITLE__/); + } finally { + console.error = originalConsoleError; + if (originalWindow === undefined) delete globalThis.window; + else globalThis.window = originalWindow; + if (originalDocument === undefined) delete globalThis.document; + else globalThis.document = originalDocument; + } +});