From e185fc29e31b3142f6fe23c7e02b3692beaadef1 Mon Sep 17 00:00:00 2001 From: Michael Gartner Date: Fri, 28 Aug 2026 10:02:57 -0600 Subject: [PATCH 1/6] Fix Roam direct canvas load race --- .../__tests__/deferredCanvasRenderer.test.ts | 82 +++++++++++++++++++ apps/roam/src/utils/deferredCanvasRenderer.ts | 42 ++++++++++ .../utils/initializeObserversAndListeners.ts | 19 ++++- 3 files changed, 140 insertions(+), 3 deletions(-) create mode 100644 apps/roam/src/utils/__tests__/deferredCanvasRenderer.test.ts create mode 100644 apps/roam/src/utils/deferredCanvasRenderer.ts diff --git a/apps/roam/src/utils/__tests__/deferredCanvasRenderer.test.ts b/apps/roam/src/utils/__tests__/deferredCanvasRenderer.test.ts new file mode 100644 index 000000000..92cafd6e2 --- /dev/null +++ b/apps/roam/src/utils/__tests__/deferredCanvasRenderer.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it, vi } from "vitest"; +import { createDeferredCanvasRenderer } from "~/utils/deferredCanvasRenderer"; + +const createFrameScheduler = () => { + const callbacks = new Map void>(); + let nextFrameId = 1; + + const requestFrame = vi.fn((callback: () => void): number => { + const frameId = nextFrameId++; + callbacks.set(frameId, callback); + return frameId; + }); + const cancelFrame = vi.fn((frameId: number): void => { + callbacks.delete(frameId); + }); + const runFrame = (frameId: number): void => { + const callback = callbacks.get(frameId); + callbacks.delete(frameId); + callback?.(); + }; + + return { callbacks, requestFrame, cancelFrame, runFrame }; +}; + +describe("createDeferredCanvasRenderer", () => { + it("defers rendering until the next animation frame", () => { + const scheduler = createFrameScheduler(); + const render = vi.fn(); + const renderer = createDeferredCanvasRenderer(scheduler); + const element = { isConnected: true } as HTMLElement; + + renderer.schedule({ element, render }); + + expect(render).not.toHaveBeenCalled(); + scheduler.runFrame(1); + expect(render).toHaveBeenCalledOnce(); + }); + + it("does not render an element removed before the frame", () => { + const scheduler = createFrameScheduler(); + const render = vi.fn(); + const renderer = createDeferredCanvasRenderer(scheduler); + const element = { isConnected: true } as HTMLElement; + + renderer.schedule({ element, render }); + Object.assign(element, { isConnected: false }); + scheduler.runFrame(1); + + expect(render).not.toHaveBeenCalled(); + }); + + it("replaces a pending render for the same element", () => { + const scheduler = createFrameScheduler(); + const firstRender = vi.fn(); + const secondRender = vi.fn(); + const renderer = createDeferredCanvasRenderer(scheduler); + const element = { isConnected: true } as HTMLElement; + + renderer.schedule({ element, render: firstRender }); + renderer.schedule({ element, render: secondRender }); + + expect(scheduler.cancelFrame).toHaveBeenCalledWith(1); + scheduler.runFrame(1); + scheduler.runFrame(2); + expect(firstRender).not.toHaveBeenCalled(); + expect(secondRender).toHaveBeenCalledOnce(); + }); + + it("cancels pending renders during cleanup", () => { + const scheduler = createFrameScheduler(); + const render = vi.fn(); + const renderer = createDeferredCanvasRenderer(scheduler); + const element = { isConnected: true } as HTMLElement; + + renderer.schedule({ element, render }); + renderer.cancelAll(); + scheduler.runFrame(1); + + expect(scheduler.cancelFrame).toHaveBeenCalledWith(1); + expect(render).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/roam/src/utils/deferredCanvasRenderer.ts b/apps/roam/src/utils/deferredCanvasRenderer.ts new file mode 100644 index 000000000..4b9be595e --- /dev/null +++ b/apps/roam/src/utils/deferredCanvasRenderer.ts @@ -0,0 +1,42 @@ +type FrameCallback = () => void; + +type DeferredCanvasRenderer = { + schedule: (args: { element: HTMLElement; render: () => void }) => void; + cancelAll: () => void; +}; + +export const createDeferredCanvasRenderer = ({ + requestFrame = window.requestAnimationFrame.bind(window), + cancelFrame = window.cancelAnimationFrame.bind(window), +}: { + requestFrame?: (callback: FrameCallback) => number; + cancelFrame?: (frameId: number) => void; +} = {}): DeferredCanvasRenderer => { + const pendingFrames = new Map(); + + const schedule = ({ + element, + render, + }: { + element: HTMLElement; + render: () => void; + }): void => { + const pendingFrame = pendingFrames.get(element); + if (pendingFrame !== undefined) cancelFrame(pendingFrame); + + const frameId = requestFrame(() => { + pendingFrames.delete(element); + if (!element.isConnected) return; + + render(); + }); + pendingFrames.set(element, frameId); + }; + + const cancelAll = (): void => { + pendingFrames.forEach((frameId) => cancelFrame(frameId)); + pendingFrames.clear(); + }; + + return { schedule, cancelAll }; +}; diff --git a/apps/roam/src/utils/initializeObserversAndListeners.ts b/apps/roam/src/utils/initializeObserversAndListeners.ts index 6446d69b4..eee9f64dd 100644 --- a/apps/roam/src/utils/initializeObserversAndListeners.ts +++ b/apps/roam/src/utils/initializeObserversAndListeners.ts @@ -66,6 +66,7 @@ import { PERSONAL_KEYS, GLOBAL_KEYS, } from "~/components/settings/utils/settingKeys"; +import { createDeferredCanvasRenderer } from "~/utils/deferredCanvasRenderer"; const debounce = (fn: () => void, delay = 250) => { let timeout: number; @@ -105,6 +106,7 @@ export const initObservers = ({ }; cleanups: Array<() => void>; } => { + const deferredCanvasRenderer = createDeferredCanvasRenderer(); const pageTitleObserver = createHTMLObserver({ tag: "H1", className: "rm-title-display", @@ -148,9 +150,15 @@ export const initObservers = ({ if (isQueryPage({ title, snapshot: settings })) { renderQueryPage(props); } else if (isCurrentPageCanvas({ title, h1, snapshot: settings })) { - renderTldrawCanvas(props); + deferredCanvasRenderer.schedule({ + element: h1, + render: () => renderTldrawCanvas(props), + }); } else if (isSidebarCanvas({ title, h1, snapshot: settings })) { - renderTldrawCanvasInSidebar(props); + deferredCanvasRenderer.schedule({ + element: h1, + render: () => renderTldrawCanvasInSidebar(props), + }); } }, }); @@ -473,6 +481,11 @@ export const initObservers = ({ discourseNodeSearchTriggerListener, nodeCreationPopoverListener, }, - cleanups: [unsubGlobalTrigger, unsubPersonalTrigger, unsubSearchTrigger], + cleanups: [ + unsubGlobalTrigger, + unsubPersonalTrigger, + unsubSearchTrigger, + deferredCanvasRenderer.cancelAll, + ], }; }; From 5e12efffbecd57137bb2343b5ce937d6707a029c Mon Sep 17 00:00:00 2001 From: Michael Gartner Date: Sun, 6 Sep 2026 15:30:49 -0600 Subject: [PATCH 2/6] Disable Roam bundle minification for cloud canvas debugging --- apps/roam/scripts/compile.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/roam/scripts/compile.ts b/apps/roam/scripts/compile.ts index 8c4314360..54c5fa654 100644 --- a/apps/roam/scripts/compile.ts +++ b/apps/roam/scripts/compile.ts @@ -192,6 +192,9 @@ export const compile = ({ bundle: true, format, define: { + "process.env.NODE_ENV": JSON.stringify( + process.env.NODE_ENV || "development", + ), "process.env.SUPABASE_URL": dbEnv.SUPABASE_URL ? `"${dbEnv.SUPABASE_URL}"` : "null", @@ -205,7 +208,8 @@ export const compile = ({ "window.__DISCOURSE_GRAPH_BUILD_BRANCH__": `"${getBuildBranch()}"`, }, sourcemap: process.env.NODE_ENV === "production" ? "external" : "inline", - minify: process.env.NODE_ENV === "production", + // Keep the debug release readable when investigating direct cloud canvas loads. + minify: false, entryNames: out, external: externalModules.map(([e]) => e).concat(["crypto"]), plugins: [ From a00c7c8816325be6e51da354516020c26f873f03 Mon Sep 17 00:00:00 2001 From: Michael Gartner Date: Sun, 6 Sep 2026 16:07:41 -0600 Subject: [PATCH 3/6] Isolate Roam React compatibility hook from other extensions --- .../__tests__/reactCompatibility.test.ts | 95 +++++++++++++++++++ apps/roam/scripts/compile.ts | 54 +---------- apps/roam/scripts/importAsGlobals.ts | 54 +++++++++++ apps/roam/scripts/react.cjs | 11 +++ .../__tests__/deferredCanvasRenderer.test.ts | 82 ---------------- apps/roam/src/utils/deferredCanvasRenderer.ts | 42 -------- .../utils/initializeObserversAndListeners.ts | 19 +--- patches/roamjs-components@0.88.3.patch | 18 ++++ pnpm-lock.yaml | 7 +- pnpm-workspace.yaml | 1 + 10 files changed, 189 insertions(+), 194 deletions(-) create mode 100644 apps/roam/scripts/__tests__/reactCompatibility.test.ts create mode 100644 apps/roam/scripts/importAsGlobals.ts create mode 100644 apps/roam/scripts/react.cjs delete mode 100644 apps/roam/src/utils/__tests__/deferredCanvasRenderer.test.ts delete mode 100644 apps/roam/src/utils/deferredCanvasRenderer.ts create mode 100644 patches/roamjs-components@0.88.3.patch diff --git a/apps/roam/scripts/__tests__/reactCompatibility.test.ts b/apps/roam/scripts/__tests__/reactCompatibility.test.ts new file mode 100644 index 000000000..d0cb36fa0 --- /dev/null +++ b/apps/roam/scripts/__tests__/reactCompatibility.test.ts @@ -0,0 +1,95 @@ +import esbuild from "esbuild"; +import { runInNewContext } from "node:vm"; +import { describe, expect, it, vi } from "vitest"; +import { importAsGlobals } from "../importAsGlobals"; + +type Fixture = { + react: Record; + renderer: object; + read: () => number; + getHook: () => unknown; +}; + +const buildFixture = async (): Promise => { + const result = await esbuild.build({ + bundle: true, + stdin: { + contents: ` + import React, { useSyncExternalStore } from "react"; + import ReactDOM from "react-dom"; + export const react = React; + export const renderer = ReactDOM; + export const getHook = () => useSyncExternalStore; + export const read = () => useSyncExternalStore(() => () => {}, () => 42); + `, + resolveDir: process.cwd(), + }, + format: "cjs", + write: false, + sourcemap: false, + define: { "process.env.NODE_ENV": '"production"' }, + plugins: [ + importAsGlobals({ + react: "./scripts/react.cjs", + "react-dom": "window.ReactDOM", + }), + ], + }); + return result.outputFiles?.[0]?.text || ""; +}; + +describe("Roam React compatibility bundle", () => { + it.each([false, true])( + "keeps the host renderer and a private hook when a global shim already exists: %s", + async (hasExistingShim): Promise => { + const existingShim = vi.fn(() => -1); + const useState = vi.fn((value: unknown) => [value, vi.fn()]); + const dispatcher = {}; + const hostReact = { + useState, + useEffect: vi.fn(), + useLayoutEffect: vi.fn(), + useDebugValue: vi.fn(), + createElement: vi.fn(), + __SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED: dispatcher, + ...(hasExistingShim ? { useSyncExternalStore: existingShim } : {}), + }; + const renderer = {}; + const module = { exports: {} as Fixture }; + runInNewContext(await buildFixture(), { + module, + exports: module.exports, + window: { + React: hostReact, + ReactDOM: renderer, + document: { createElement: vi.fn() }, + }, + }); + const fixture = module.exports; + const privateHook = fixture.getHook(); + + expect(fixture.react).not.toBe(hostReact); + expect(fixture.react.useState).toBe(hostReact.useState); + expect(fixture.react.createElement).toBe(hostReact.createElement); + expect( + fixture.react.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED, + ).toBe(dispatcher); + expect(fixture.renderer).toBe(renderer); + expect(hostReact.useSyncExternalStore).toBe( + hasExistingShim ? existingShim : undefined, + ); + expect(privateHook).not.toBe(existingShim); + expect(fixture.read()).toBe(42); + expect(useState).toHaveBeenCalledTimes(1); + + // Simulate another extension loading between component renders. + const replacement = vi.fn(() => -2); + hostReact.useSyncExternalStore = replacement; + expect(fixture.getHook()).toBe(privateHook); + expect(fixture.read()).toBe(42); + expect(useState).toHaveBeenCalledTimes(2); + expect(existingShim).not.toHaveBeenCalled(); + expect(replacement).not.toHaveBeenCalled(); + }, + ); +}); diff --git a/apps/roam/scripts/compile.ts b/apps/roam/scripts/compile.ts index 54c5fa654..3eca99e4a 100644 --- a/apps/roam/scripts/compile.ts +++ b/apps/roam/scripts/compile.ts @@ -3,6 +3,7 @@ import { execSync } from "child_process"; import fs from "fs"; import path from "path"; import { z } from "zod"; +import { importAsGlobals } from "./importAsGlobals"; const getVersion = (): string => { try { @@ -64,57 +65,6 @@ try { throw error; } -// https://github.com/evanw/esbuild/issues/337#issuecomment-954633403 -const importAsGlobals = ( - mapping: Record = {}, -): esbuild.Plugin => { - const escRe = (s: string) => s.replace(/[-\/\\^$*+?.()|[\]{}]/g, "\\$&"); - const filter = new RegExp( - Object.keys(mapping).length - ? Object.keys(mapping) - .map((mod) => `^${escRe(mod)}$`) - .join("|") - : /$^/, - ); - - return { - name: "global-imports", - setup(build) { - build.onResolve({ filter }, (args) => { - if (!mapping[args.path]) { - throw new Error("Unknown global: " + args.path); - } - return { - path: args.path, - namespace: "external-global", - }; - }); - - build.onLoad( - { - filter, - namespace: "external-global", - }, - async (args) => { - const global = mapping[args.path]; - if (fs.existsSync(global)) { - return { - contents: fs.readFileSync(global).toString(), - loader: "js", - resolveDir: path.dirname(global), - }; - } - return { - contents: `module.exports = ${global};`, - loader: "js", - resolveDir: process.cwd(), - }; - }, - ); - }, - }; -}; - const DEFAULT_FILES_INCLUDED = ["package.json", "README.md"]; const addPlaceholderChangelogPlugin = (outdir: string): esbuild.Plugin => ({ @@ -156,7 +106,7 @@ export const args = { "marked=window.RoamLazy.Marked", "marked-react=window.RoamLazy.MarkedReact", "nanoid=window.Nanoid;module.exports.nanoid=window.Nanoid", - 'react=window.React;module.exports.useSyncExternalStore=require("use-sync-external-store/shim").useSyncExternalStore', + "react=./scripts/react.cjs", "react/jsx-runtime=./node_modules/react/jsx-runtime.js", "react-dom=window.ReactDOM", "react-youtube=window.ReactYoutube", diff --git a/apps/roam/scripts/importAsGlobals.ts b/apps/roam/scripts/importAsGlobals.ts new file mode 100644 index 000000000..1613829db --- /dev/null +++ b/apps/roam/scripts/importAsGlobals.ts @@ -0,0 +1,54 @@ +import esbuild from "esbuild"; +import fs from "fs"; +import path from "path"; + +// https://github.com/evanw/esbuild/issues/337#issuecomment-954633403 +export const importAsGlobals = ( + mapping: Record = {}, +): esbuild.Plugin => { + const escRe = (s: string): string => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const filter = new RegExp( + Object.keys(mapping).length + ? Object.keys(mapping) + .map((mod) => `^${escRe(mod)}$`) + .join("|") + : /$^/, + ); + + return { + name: "global-imports", + setup: (build): void => { + build.onResolve({ filter }, (args) => { + if (!mapping[args.path]) { + throw new Error("Unknown global: " + args.path); + } + return { + path: args.path, + namespace: "external-global", + }; + }); + + build.onLoad( + { + filter, + namespace: "external-global", + }, + (args) => { + const global = mapping[args.path]; + if (fs.existsSync(global)) { + return { + contents: fs.readFileSync(global).toString(), + loader: "js", + resolveDir: path.dirname(global), + }; + } + return { + contents: `module.exports = ${global};`, + loader: "js", + resolveDir: process.cwd(), + }; + }, + ); + }, + }; +}; diff --git a/apps/roam/scripts/react.cjs b/apps/roam/scripts/react.cjs new file mode 100644 index 000000000..bb4ec43ff --- /dev/null +++ b/apps/roam/scripts/react.cjs @@ -0,0 +1,11 @@ +// Keep Roam's React functions and dispatcher, but isolate our compatibility hook +// from extensions that replace window.React.useSyncExternalStore during startup. +module.exports = { ...window.React, useSyncExternalStore: undefined }; + +// Publish the facade before requiring the shim: its React import points back here. +// Clearing the hook above makes it select our bundled implementation even when +// another extension has already installed a global shim. +module.exports.useSyncExternalStore = + // eslint-disable-next-line @typescript-eslint/no-require-imports -- CommonJS preserves the shim's circular React import. + require("use-sync-external-store/shim").useSyncExternalStore; +/* global module, require */ diff --git a/apps/roam/src/utils/__tests__/deferredCanvasRenderer.test.ts b/apps/roam/src/utils/__tests__/deferredCanvasRenderer.test.ts deleted file mode 100644 index 92cafd6e2..000000000 --- a/apps/roam/src/utils/__tests__/deferredCanvasRenderer.test.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; -import { createDeferredCanvasRenderer } from "~/utils/deferredCanvasRenderer"; - -const createFrameScheduler = () => { - const callbacks = new Map void>(); - let nextFrameId = 1; - - const requestFrame = vi.fn((callback: () => void): number => { - const frameId = nextFrameId++; - callbacks.set(frameId, callback); - return frameId; - }); - const cancelFrame = vi.fn((frameId: number): void => { - callbacks.delete(frameId); - }); - const runFrame = (frameId: number): void => { - const callback = callbacks.get(frameId); - callbacks.delete(frameId); - callback?.(); - }; - - return { callbacks, requestFrame, cancelFrame, runFrame }; -}; - -describe("createDeferredCanvasRenderer", () => { - it("defers rendering until the next animation frame", () => { - const scheduler = createFrameScheduler(); - const render = vi.fn(); - const renderer = createDeferredCanvasRenderer(scheduler); - const element = { isConnected: true } as HTMLElement; - - renderer.schedule({ element, render }); - - expect(render).not.toHaveBeenCalled(); - scheduler.runFrame(1); - expect(render).toHaveBeenCalledOnce(); - }); - - it("does not render an element removed before the frame", () => { - const scheduler = createFrameScheduler(); - const render = vi.fn(); - const renderer = createDeferredCanvasRenderer(scheduler); - const element = { isConnected: true } as HTMLElement; - - renderer.schedule({ element, render }); - Object.assign(element, { isConnected: false }); - scheduler.runFrame(1); - - expect(render).not.toHaveBeenCalled(); - }); - - it("replaces a pending render for the same element", () => { - const scheduler = createFrameScheduler(); - const firstRender = vi.fn(); - const secondRender = vi.fn(); - const renderer = createDeferredCanvasRenderer(scheduler); - const element = { isConnected: true } as HTMLElement; - - renderer.schedule({ element, render: firstRender }); - renderer.schedule({ element, render: secondRender }); - - expect(scheduler.cancelFrame).toHaveBeenCalledWith(1); - scheduler.runFrame(1); - scheduler.runFrame(2); - expect(firstRender).not.toHaveBeenCalled(); - expect(secondRender).toHaveBeenCalledOnce(); - }); - - it("cancels pending renders during cleanup", () => { - const scheduler = createFrameScheduler(); - const render = vi.fn(); - const renderer = createDeferredCanvasRenderer(scheduler); - const element = { isConnected: true } as HTMLElement; - - renderer.schedule({ element, render }); - renderer.cancelAll(); - scheduler.runFrame(1); - - expect(scheduler.cancelFrame).toHaveBeenCalledWith(1); - expect(render).not.toHaveBeenCalled(); - }); -}); diff --git a/apps/roam/src/utils/deferredCanvasRenderer.ts b/apps/roam/src/utils/deferredCanvasRenderer.ts deleted file mode 100644 index 4b9be595e..000000000 --- a/apps/roam/src/utils/deferredCanvasRenderer.ts +++ /dev/null @@ -1,42 +0,0 @@ -type FrameCallback = () => void; - -type DeferredCanvasRenderer = { - schedule: (args: { element: HTMLElement; render: () => void }) => void; - cancelAll: () => void; -}; - -export const createDeferredCanvasRenderer = ({ - requestFrame = window.requestAnimationFrame.bind(window), - cancelFrame = window.cancelAnimationFrame.bind(window), -}: { - requestFrame?: (callback: FrameCallback) => number; - cancelFrame?: (frameId: number) => void; -} = {}): DeferredCanvasRenderer => { - const pendingFrames = new Map(); - - const schedule = ({ - element, - render, - }: { - element: HTMLElement; - render: () => void; - }): void => { - const pendingFrame = pendingFrames.get(element); - if (pendingFrame !== undefined) cancelFrame(pendingFrame); - - const frameId = requestFrame(() => { - pendingFrames.delete(element); - if (!element.isConnected) return; - - render(); - }); - pendingFrames.set(element, frameId); - }; - - const cancelAll = (): void => { - pendingFrames.forEach((frameId) => cancelFrame(frameId)); - pendingFrames.clear(); - }; - - return { schedule, cancelAll }; -}; diff --git a/apps/roam/src/utils/initializeObserversAndListeners.ts b/apps/roam/src/utils/initializeObserversAndListeners.ts index eee9f64dd..6446d69b4 100644 --- a/apps/roam/src/utils/initializeObserversAndListeners.ts +++ b/apps/roam/src/utils/initializeObserversAndListeners.ts @@ -66,7 +66,6 @@ import { PERSONAL_KEYS, GLOBAL_KEYS, } from "~/components/settings/utils/settingKeys"; -import { createDeferredCanvasRenderer } from "~/utils/deferredCanvasRenderer"; const debounce = (fn: () => void, delay = 250) => { let timeout: number; @@ -106,7 +105,6 @@ export const initObservers = ({ }; cleanups: Array<() => void>; } => { - const deferredCanvasRenderer = createDeferredCanvasRenderer(); const pageTitleObserver = createHTMLObserver({ tag: "H1", className: "rm-title-display", @@ -150,15 +148,9 @@ export const initObservers = ({ if (isQueryPage({ title, snapshot: settings })) { renderQueryPage(props); } else if (isCurrentPageCanvas({ title, h1, snapshot: settings })) { - deferredCanvasRenderer.schedule({ - element: h1, - render: () => renderTldrawCanvas(props), - }); + renderTldrawCanvas(props); } else if (isSidebarCanvas({ title, h1, snapshot: settings })) { - deferredCanvasRenderer.schedule({ - element: h1, - render: () => renderTldrawCanvasInSidebar(props), - }); + renderTldrawCanvasInSidebar(props); } }, }); @@ -481,11 +473,6 @@ export const initObservers = ({ discourseNodeSearchTriggerListener, nodeCreationPopoverListener, }, - cleanups: [ - unsubGlobalTrigger, - unsubPersonalTrigger, - unsubSearchTrigger, - deferredCanvasRenderer.cancelAll, - ], + cleanups: [unsubGlobalTrigger, unsubPersonalTrigger, unsubSearchTrigger], }; }; diff --git a/patches/roamjs-components@0.88.3.patch b/patches/roamjs-components@0.88.3.patch new file mode 100644 index 000000000..be4556313 --- /dev/null +++ b/patches/roamjs-components@0.88.3.patch @@ -0,0 +1,18 @@ +diff --git a/util/runExtension.js b/util/runExtension.js +--- a/util/runExtension.js ++++ b/util/runExtension.js +@@ -4,14 +4,10 @@ + const addStyle_1 = tslib_1.__importDefault(require("../dom/addStyle")); + const react_dom_1 = tslib_1.__importDefault(require("react-dom")); + const env_1 = require("./env"); +-const shim_1 = require("use-sync-external-store/shim"); + const extensionApiContext_1 = require("./extensionApiContext"); + const apiPost_1 = tslib_1.__importDefault(require("./apiPost")); + const Toast_1 = tslib_1.__importDefault(require("../components/Toast")); + const runExtension = (run) => { +- // eslint-disable-next-line @typescript-eslint/ban-ts-comment +- // @ts-ignore React17 shim +- window.React.useSyncExternalStore = shim_1.useSyncExternalStore; + let unload; + const extensionId = (0, env_1.getRoamJSExtensionIdEnv)(); + const registry = { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9d2a748ce..ca848f514 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -85,6 +85,9 @@ patchedDependencies: '@tldraw/state@2.4.6': hash: b5bbaa41bf2edae401d2bcd96dfa3c24086b564f297ef2df16a3b3156cd51ecc path: patches/@tldraw__state@2.4.6.patch + roamjs-components@0.88.3: + hash: 054e002496f801f5b2fa697a1265fda46a897ce11b81491860ddcb4b9832c1f5 + path: patches/roamjs-components@0.88.3.patch tldraw@2.4.6: hash: 56e196052862c9a58a11b43e5e121384cd1d6548416afa0f16e9fbfbf0e4080d path: patches/tldraw@2.4.6.patch @@ -352,7 +355,7 @@ importers: version: 3.5.2(react@18.2.0) roamjs-components: specifier: 0.88.3 - version: 0.88.3(323501797697e57d5f8d07d52746f463) + version: 0.88.3(patch_hash=054e002496f801f5b2fa697a1265fda46a897ce11b81491860ddcb4b9832c1f5)(323501797697e57d5f8d07d52746f463) tldraw: specifier: 2.4.6 version: 2.4.6(patch_hash=56e196052862c9a58a11b43e5e121384cd1d6548416afa0f16e9fbfbf0e4080d)(@types/react-dom@18.2.17)(@types/react@18.2.21)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) @@ -22757,7 +22760,7 @@ snapshots: dependencies: glob: 7.2.3 - roamjs-components@0.88.3(323501797697e57d5f8d07d52746f463): + roamjs-components@0.88.3(patch_hash=054e002496f801f5b2fa697a1265fda46a897ce11b81491860ddcb4b9832c1f5)(323501797697e57d5f8d07d52746f463): dependencies: '@blueprintjs/core': 3.50.4(patch_hash=51c5847e0a73a1be0cc263036ff64d8fada46f3b65831ed938dbca5eecf3edc0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) '@blueprintjs/datetime': 3.23.14(react-dom@18.2.0(react@18.2.0))(react@18.2.0) diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 2a125d6ce..629665342 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -70,6 +70,7 @@ overrides: roam>react-dom: 18.2.0 patchedDependencies: + roamjs-components@0.88.3: patches/roamjs-components@0.88.3.patch "@blueprintjs/core@^3.50.4": patches/@blueprintjs__core@3.50.4.patch "@blueprintjs/select@3.19.1": patches/@blueprintjs__select@3.19.1.patch "@tldraw/editor@2.4.6": patches/@tldraw__editor@2.4.6.patch From fa6f61bdee92644df691a5bf81d5bc339001b99d Mon Sep 17 00:00:00 2001 From: Michael Gartner Date: Sun, 6 Sep 2026 16:57:28 -0600 Subject: [PATCH 4/6] Use stable Roam React 18 exports without injecting a shim --- .../__tests__/reactCompatibility.test.ts | 26 +++++++++++-------- apps/roam/scripts/react.cjs | 15 +++-------- 2 files changed, 19 insertions(+), 22 deletions(-) diff --git a/apps/roam/scripts/__tests__/reactCompatibility.test.ts b/apps/roam/scripts/__tests__/reactCompatibility.test.ts index d0cb36fa0..d29750a4c 100644 --- a/apps/roam/scripts/__tests__/reactCompatibility.test.ts +++ b/apps/roam/scripts/__tests__/reactCompatibility.test.ts @@ -38,11 +38,13 @@ const buildFixture = async (): Promise => { return result.outputFiles?.[0]?.text || ""; }; -describe("Roam React compatibility bundle", () => { +describe("Roam React host bundle", () => { it.each([false, true])( - "keeps the host renderer and a private hook when a global shim already exists: %s", + "keeps the initial host hook stable when it is already replaced: %s", async (hasExistingShim): Promise => { - const existingShim = vi.fn(() => -1); + const initialHook = vi.fn((_subscribe, getSnapshot: () => number) => + getSnapshot(), + ); const useState = vi.fn((value: unknown) => [value, vi.fn()]); const dispatcher = {}; const hostReact = { @@ -52,8 +54,12 @@ describe("Roam React compatibility bundle", () => { useDebugValue: vi.fn(), createElement: vi.fn(), __SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED: dispatcher, - ...(hasExistingShim ? { useSyncExternalStore: existingShim } : {}), + version: "18.2.0", + useSyncExternalStore: hasExistingShim + ? vi.fn(initialHook) + : initialHook, }; + const capturedHook = hostReact.useSyncExternalStore; const renderer = {}; const module = { exports: {} as Fixture }; runInNewContext(await buildFixture(), { @@ -75,20 +81,18 @@ describe("Roam React compatibility bundle", () => { fixture.react.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED, ).toBe(dispatcher); expect(fixture.renderer).toBe(renderer); - expect(hostReact.useSyncExternalStore).toBe( - hasExistingShim ? existingShim : undefined, - ); - expect(privateHook).not.toBe(existingShim); + expect(hostReact.useSyncExternalStore).toBe(capturedHook); + expect(privateHook).toBe(capturedHook); expect(fixture.read()).toBe(42); - expect(useState).toHaveBeenCalledTimes(1); + expect(initialHook).toHaveBeenCalledTimes(1); // Simulate another extension loading between component renders. const replacement = vi.fn(() => -2); hostReact.useSyncExternalStore = replacement; expect(fixture.getHook()).toBe(privateHook); expect(fixture.read()).toBe(42); - expect(useState).toHaveBeenCalledTimes(2); - expect(existingShim).not.toHaveBeenCalled(); + expect(initialHook).toHaveBeenCalledTimes(2); + expect(useState).not.toHaveBeenCalled(); expect(replacement).not.toHaveBeenCalled(); }, ); diff --git a/apps/roam/scripts/react.cjs b/apps/roam/scripts/react.cjs index bb4ec43ff..30df4222c 100644 --- a/apps/roam/scripts/react.cjs +++ b/apps/roam/scripts/react.cjs @@ -1,11 +1,4 @@ -// Keep Roam's React functions and dispatcher, but isolate our compatibility hook -// from extensions that replace window.React.useSyncExternalStore during startup. -module.exports = { ...window.React, useSyncExternalStore: undefined }; - -// Publish the facade before requiring the shim: its React import points back here. -// Clearing the hook above makes it select our bundled implementation even when -// another extension has already installed a global shim. -module.exports.useSyncExternalStore = - // eslint-disable-next-line @typescript-eslint/no-require-imports -- CommonJS preserves the shim's circular React import. - require("use-sync-external-store/shim").useSyncExternalStore; -/* global module, require */ +// Capture Roam's React exports once so later extension assignments cannot change +// the hook implementation used by an already mounted DG component. +module.exports = { ...window.React }; +/* global module */ From f2c13fa055c29e98842628c8e023c5830fd6d7c1 Mon Sep 17 00:00:00 2001 From: Michael Gartner Date: Sun, 6 Sep 2026 17:15:12 -0600 Subject: [PATCH 5/6] Restore normal Roam production minification --- apps/roam/scripts/compile.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/apps/roam/scripts/compile.ts b/apps/roam/scripts/compile.ts index 3eca99e4a..bb65f1a54 100644 --- a/apps/roam/scripts/compile.ts +++ b/apps/roam/scripts/compile.ts @@ -142,9 +142,6 @@ export const compile = ({ bundle: true, format, define: { - "process.env.NODE_ENV": JSON.stringify( - process.env.NODE_ENV || "development", - ), "process.env.SUPABASE_URL": dbEnv.SUPABASE_URL ? `"${dbEnv.SUPABASE_URL}"` : "null", @@ -158,8 +155,7 @@ export const compile = ({ "window.__DISCOURSE_GRAPH_BUILD_BRANCH__": `"${getBuildBranch()}"`, }, sourcemap: process.env.NODE_ENV === "production" ? "external" : "inline", - // Keep the debug release readable when investigating direct cloud canvas loads. - minify: false, + minify: process.env.NODE_ENV === "production", entryNames: out, external: externalModules.map(([e]) => e).concat(["crypto"]), plugins: [ From daeb12c05b7cf5b234f186fc3520e56c6743fa18 Mon Sep 17 00:00:00 2001 From: Michael Gartner Date: Sun, 6 Sep 2026 17:34:51 -0600 Subject: [PATCH 6/6] Use published roamjs-components 0.90.0 and remove local patch --- apps/roam/package.json | 2 +- patches/roamjs-components@0.88.3.patch | 18 ------------------ pnpm-lock.yaml | 13 +++++-------- pnpm-workspace.yaml | 1 - 4 files changed, 6 insertions(+), 28 deletions(-) delete mode 100644 patches/roamjs-components@0.88.3.patch diff --git a/apps/roam/package.json b/apps/roam/package.json index 739ab8aec..7aabf89db 100644 --- a/apps/roam/package.json +++ b/apps/roam/package.json @@ -84,7 +84,7 @@ "react-draggable": "4.4.5", "react-in-viewport": "1.0.0-alpha.20", "react-vertical-timeline-component": "3.5.2", - "roamjs-components": "0.88.3", + "roamjs-components": "0.90.0", "tldraw": "2.4.6", "use-sync-external-store": "1.5.0", "xregexp": "^5.0.0", diff --git a/patches/roamjs-components@0.88.3.patch b/patches/roamjs-components@0.88.3.patch deleted file mode 100644 index be4556313..000000000 --- a/patches/roamjs-components@0.88.3.patch +++ /dev/null @@ -1,18 +0,0 @@ -diff --git a/util/runExtension.js b/util/runExtension.js ---- a/util/runExtension.js -+++ b/util/runExtension.js -@@ -4,14 +4,10 @@ - const addStyle_1 = tslib_1.__importDefault(require("../dom/addStyle")); - const react_dom_1 = tslib_1.__importDefault(require("react-dom")); - const env_1 = require("./env"); --const shim_1 = require("use-sync-external-store/shim"); - const extensionApiContext_1 = require("./extensionApiContext"); - const apiPost_1 = tslib_1.__importDefault(require("./apiPost")); - const Toast_1 = tslib_1.__importDefault(require("../components/Toast")); - const runExtension = (run) => { -- // eslint-disable-next-line @typescript-eslint/ban-ts-comment -- // @ts-ignore React17 shim -- window.React.useSyncExternalStore = shim_1.useSyncExternalStore; - let unload; - const extensionId = (0, env_1.getRoamJSExtensionIdEnv)(); - const registry = { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ca848f514..8393d9435 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -85,9 +85,6 @@ patchedDependencies: '@tldraw/state@2.4.6': hash: b5bbaa41bf2edae401d2bcd96dfa3c24086b564f297ef2df16a3b3156cd51ecc path: patches/@tldraw__state@2.4.6.patch - roamjs-components@0.88.3: - hash: 054e002496f801f5b2fa697a1265fda46a897ce11b81491860ddcb4b9832c1f5 - path: patches/roamjs-components@0.88.3.patch tldraw@2.4.6: hash: 56e196052862c9a58a11b43e5e121384cd1d6548416afa0f16e9fbfbf0e4080d path: patches/tldraw@2.4.6.patch @@ -354,8 +351,8 @@ importers: specifier: 3.5.2 version: 3.5.2(react@18.2.0) roamjs-components: - specifier: 0.88.3 - version: 0.88.3(patch_hash=054e002496f801f5b2fa697a1265fda46a897ce11b81491860ddcb4b9832c1f5)(323501797697e57d5f8d07d52746f463) + specifier: 0.90.0 + version: 0.90.0(323501797697e57d5f8d07d52746f463) tldraw: specifier: 2.4.6 version: 2.4.6(patch_hash=56e196052862c9a58a11b43e5e121384cd1d6548416afa0f16e9fbfbf0e4080d)(@types/react-dom@18.2.17)(@types/react@18.2.21)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) @@ -9787,8 +9784,8 @@ packages: deprecated: Rimraf versions prior to v4 are no longer supported hasBin: true - roamjs-components@0.88.3: - resolution: {integrity: sha512-eCKpgKSLoxtOqRZG9c0KOwTQIu2WrYzuipvfeGI377dzUZ8AIj4hJaq6qYzBPL7N1bnns0mMcjHiBhOgLgQTBg==} + roamjs-components@0.90.0: + resolution: {integrity: sha512-lp2a7soqJ8FciuoPsZKOXxciXvsp3IBZIIY/pr/W7nXebDALMeB2N1iz4xS+laPTP057G5TCpVFDS2vEXX438A==} engines: {node: '>=16.0.0', npm: '>=7.0.0'} hasBin: true peerDependencies: @@ -22760,7 +22757,7 @@ snapshots: dependencies: glob: 7.2.3 - roamjs-components@0.88.3(patch_hash=054e002496f801f5b2fa697a1265fda46a897ce11b81491860ddcb4b9832c1f5)(323501797697e57d5f8d07d52746f463): + roamjs-components@0.90.0(323501797697e57d5f8d07d52746f463): dependencies: '@blueprintjs/core': 3.50.4(patch_hash=51c5847e0a73a1be0cc263036ff64d8fada46f3b65831ed938dbca5eecf3edc0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) '@blueprintjs/datetime': 3.23.14(react-dom@18.2.0(react@18.2.0))(react@18.2.0) diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 629665342..2a125d6ce 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -70,7 +70,6 @@ overrides: roam>react-dom: 18.2.0 patchedDependencies: - roamjs-components@0.88.3: patches/roamjs-components@0.88.3.patch "@blueprintjs/core@^3.50.4": patches/@blueprintjs__core@3.50.4.patch "@blueprintjs/select@3.19.1": patches/@blueprintjs__select@3.19.1.patch "@tldraw/editor@2.4.6": patches/@tldraw__editor@2.4.6.patch