Skip to content

Commit 907ceaa

Browse files
alex-strukclaude
andcommitted
fix(dynamic-nodes): bundle Monaco locally and bound its bring-up (D-13)
`@monaco-editor/react` fetches Monaco's loader and every chunk from `cdn.jsdelivr.net` at runtime, so the script editor silently depended on public-internet reach from the *browser*. Behind an egress proxy, on an air-gapped deploy, or with the CDN simply blocked, the pane sat on "Loading…" indefinitely — and Publish stayed enabled the whole time, so an author could ship a script they had never been shown. Two halves: 1. `monaco-loader.ts` points the wrapper at the copy in `node_modules` via `loader.config({ monaco })`, and wires `MonacoEnvironment` so the workers are bundled too. The import is dynamic on purpose — the build code-splits Monaco into its own 3.8 MB chunk instead of growing the main bundle for every user who never opens a dynamic node. `monaco-editor` was already installed as an auto-installed peer; it is now declared. 2. The bring-up is bounded. `CodePane` awaits the chunk before rendering `<Editor>` (the wrapper reads its loader config once, at first init — configuring afterwards silently falls back to the CDN), and gives up if `onMount` has not arrived within 15s. On failure it renders an explicit alert and reports upward, and Publish disables with the reason as its tooltip. The transient loading window deliberately does NOT block: what gets published lives in React state, not in Monaco. Verified live — the editor renders with zero requests outside localhost, and `vite build` emits `editor.main` + the workers as separate chunks. The failure and timeout paths are unit-tested, both falsified against the fix removed. Also corrects a stale note in DYNAMIC_NODES_DESIGN.md that still claimed the pane mounts CodeMirror because "Monaco is not installed". Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 5b37af8 commit 907ceaa

11 files changed

Lines changed: 359 additions & 30 deletions

File tree

apps/frontend/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@
4343
"dayjs": "1.11.20",
4444
"konva": "10.2.0",
4545
"mantine-form-zod-resolver": "1.3.0",
46+
"monaco-editor": "0.55.1",
4647
"pdfjs-dist": "5.4.530",
4748
"react": "19.2.1",
4849
"react-dom": "19.2.1",

apps/frontend/src/features/workflow-builder/dynamic-nodes/CodePane.spec.tsx

Lines changed: 85 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,17 @@ import { CodePane } from "./CodePane";
2626
// through `value` / `onChange`; Monaco's marker decorations + the deno-
2727
// runner-style language services don't fire in this stub, but the
2828
// parse-strip behaviour is driven by the editor's text, not the gutter.
29+
// D-13 — `CodePane` bundles Monaco locally and awaits the chunk before
30+
// rendering `<Editor>`. Stub the loader so the 70 MB `monaco-editor` import
31+
// never runs under jsdom; the editor surface itself is stubbed just below.
32+
// Hoisted + per-test overridable so the failure surface can be exercised.
33+
const ensureLocalMonacoMock = vi.hoisted(() =>
34+
vi.fn((): Promise<void> => Promise.resolve()),
35+
);
36+
vi.mock("./monaco-loader", () => ({
37+
ensureLocalMonaco: ensureLocalMonacoMock,
38+
}));
39+
2940
vi.mock("@monaco-editor/react", () => ({
3041
default: ({
3142
value,
@@ -50,13 +61,15 @@ function renderPane(props: Partial<Parameters<typeof CodePane>[0]>) {
5061
script={props.script ?? ""}
5162
onChange={onChange}
5263
publishErrors={props.publishErrors}
64+
onEditorUnavailable={props.onEditorUnavailable}
5365
/>
5466
</MantineProvider>,
5567
);
5668
}
5769

5870
beforeEach(() => {
5971
vi.useFakeTimers({ shouldAdvanceTime: true });
72+
ensureLocalMonacoMock.mockImplementation(() => Promise.resolve());
6073
});
6174

6275
afterEach(() => {
@@ -67,19 +80,24 @@ describe("CodePane (US-177)", () => {
6780
// -----------------------------------------------------------------------
6881
// Scenario 2 — Boilerplate prefill in create mode
6982
// -----------------------------------------------------------------------
70-
it("seeds the editor with the boilerplate when `script` is empty", () => {
83+
it("seeds the editor with the boilerplate when `script` is empty", async () => {
7184
renderPane({ script: "" });
72-
const editor = screen.getByTestId("monaco-stub") as HTMLTextAreaElement;
85+
// D-13 — the editor renders only after the local Monaco chunk resolves.
86+
const editor = (await screen.findByTestId(
87+
"monaco-stub",
88+
)) as HTMLTextAreaElement;
7389
expect(editor.value).toBe(DYNAMIC_NODE_BOILERPLATE);
7490
});
7591

7692
// -----------------------------------------------------------------------
7793
// Scenario 2 (edit-mode hydrate) — receives `script` prop
7894
// -----------------------------------------------------------------------
79-
it("hydrates the editor from the `script` prop in edit mode", () => {
95+
it("hydrates the editor from the `script` prop in edit mode", async () => {
8096
const script = `/** @workflow-node @name foo */ export default async () => ({});`;
8197
renderPane({ script });
82-
const editor = screen.getByTestId("monaco-stub") as HTMLTextAreaElement;
98+
const editor = (await screen.findByTestId(
99+
"monaco-stub",
100+
)) as HTMLTextAreaElement;
83101
expect(editor.value).toBe(script);
84102
});
85103

@@ -151,3 +169,66 @@ describe("CodePane (US-177)", () => {
151169
expect(screen.getByTestId("code-pane-editor")).toBeInTheDocument();
152170
});
153171
});
172+
173+
// ---------------------------------------------------------------------------
174+
// D-13 — the editor's bring-up is bounded and its failure is visible.
175+
//
176+
// Before this, Monaco was fetched from cdn.jsdelivr.net at runtime. With the
177+
// CDN blocked — an egress proxy, an air-gapped deploy — the pane sat on
178+
// "Loading…" indefinitely and Publish stayed enabled, so an author could ship
179+
// a script they had never been shown. Monaco is bundled locally now; these
180+
// cover what happens when the bring-up still doesn't finish.
181+
// ---------------------------------------------------------------------------
182+
describe("CodePane — editor bring-up failure (D-13)", () => {
183+
it("surfaces an error and reports unavailable when the Monaco chunk fails to load", async () => {
184+
ensureLocalMonacoMock.mockImplementation(() =>
185+
Promise.reject(new Error("Failed to fetch dynamically imported module")),
186+
);
187+
const onEditorUnavailable = vi.fn();
188+
189+
renderPane({ script: "", onEditorUnavailable });
190+
191+
const alert = await screen.findByTestId("code-pane-editor-failed");
192+
expect(alert).toHaveTextContent(/failed to load/i);
193+
// Never silently degrades to an empty box: the editor is not rendered.
194+
expect(screen.queryByTestId("monaco-stub")).not.toBeInTheDocument();
195+
await waitFor(() => {
196+
expect(onEditorUnavailable).toHaveBeenLastCalledWith(
197+
expect.stringContaining("failed to load"),
198+
);
199+
});
200+
});
201+
202+
it("gives up after the mount timeout when the editor never reaches onMount", async () => {
203+
// The loader resolves, so `<Editor>` renders — but the stub never calls
204+
// `onMount`, standing in for an editor that hangs mid-init. This is the
205+
// exact shape of the old bug: no rejection to catch, just silence.
206+
const onEditorUnavailable = vi.fn();
207+
renderPane({ script: "", onEditorUnavailable });
208+
209+
await screen.findByTestId("monaco-stub");
210+
expect(
211+
screen.queryByTestId("code-pane-editor-failed"),
212+
).not.toBeInTheDocument();
213+
214+
await act(async () => {
215+
vi.advanceTimersByTime(15_000);
216+
});
217+
218+
expect(screen.getByTestId("code-pane-editor-failed")).toBeInTheDocument();
219+
expect(onEditorUnavailable).toHaveBeenLastCalledWith(
220+
expect.stringContaining("failed to load"),
221+
);
222+
});
223+
224+
// Deliberate: the transient loading window does NOT block Publish. What
225+
// gets published lives in React state, not in Monaco, so a click during
226+
// load publishes exactly what a click after load would. Only the terminal
227+
// failure above blocks.
228+
it("does not report unavailable during the normal loading window", async () => {
229+
const onEditorUnavailable = vi.fn();
230+
renderPane({ script: "", onEditorUnavailable });
231+
await screen.findByTestId("monaco-stub");
232+
expect(onEditorUnavailable).toHaveBeenLastCalledWith(null);
233+
});
234+
});

apps/frontend/src/features/workflow-builder/dynamic-nodes/CodePane.tsx

Lines changed: 126 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -26,19 +26,27 @@
2626
* Live parse + publish errors render *both* in the strip + the editor
2727
* gutter; the strip is the explicit list (clickable + sortable), the
2828
* gutter is the inline anchor.
29+
*
30+
* D-13 — Monaco is bundled locally (`./monaco-loader`), not fetched from a
31+
* CDN, and the mount is bounded: if the loader rejects or the editor never
32+
* reaches `onMount` inside {@link EDITOR_MOUNT_TIMEOUT_MS}, the pane says so
33+
* and reports upward via `onEditorUnavailable` so Publish can refuse. An
34+
* editor that cannot render must never leave Publish live — the author would
35+
* be shipping a script they have not been shown.
2936
*/
3037

3138
import {
3239
type ParseError,
3340
parseDynamicNodeSignature,
3441
} from "@ai-di/graph-workflow";
35-
import { Alert, Anchor, Box, Group, Stack, Text } from "@mantine/core";
42+
import { Alert, Anchor, Box, Group, Loader, Stack, Text } from "@mantine/core";
3643
import { useDebouncedValue } from "@mantine/hooks";
3744
import Editor, { type Monaco, type OnMount } from "@monaco-editor/react";
3845
import { IconAlertTriangle, IconCheck } from "@tabler/icons-react";
3946
import type { editor } from "monaco-editor";
4047
import { useEffect, useMemo, useRef, useState } from "react";
4148
import { DYNAMIC_NODE_BOILERPLATE } from "./boilerplate";
49+
import { ensureLocalMonaco } from "./monaco-loader";
4250

4351
export interface CodePaneProps {
4452
/** Source text. Empty string in create-mode → boilerplate is shown. */
@@ -54,10 +62,26 @@ export interface CodePaneProps {
5462
publishErrors?: ParseError[];
5563
/** Pixel height of the editor surface. Defaults to 480. */
5664
height?: number;
65+
/**
66+
* D-13 — called whenever the editor's availability changes: a human-readable
67+
* reason while the code surface cannot be shown, `null` once it is live.
68+
* The parent MUST refuse Publish while a reason is set.
69+
*/
70+
onEditorUnavailable?: (reason: string | null) => void;
5771
}
5872

5973
const PARSE_DEBOUNCE_MS = 300;
6074
const ONCHANGE_DEBOUNCE_MS = 150;
75+
/**
76+
* How long the editor may take to reach `onMount` before we call it dead.
77+
* Generous — a cold local Monaco chunk on a slow machine is well under this.
78+
* The point is that *some* finite bound exists: the failure this replaces was
79+
* an indefinite "Loading…".
80+
*/
81+
const EDITOR_MOUNT_TIMEOUT_MS = 15_000;
82+
83+
const EDITOR_LOAD_FAILED_REASON =
84+
"The script editor failed to load, so the code below cannot be shown or edited.";
6185
/**
6286
* Owner string Monaco associates with our publish-time markers. Used as the
6387
* second argument to `monaco.editor.setModelMarkers` so we can clear them
@@ -120,6 +144,7 @@ export function CodePane({
120144
onChange,
121145
publishErrors,
122146
height,
147+
onEditorUnavailable,
123148
}: CodePaneProps) {
124149
const editorRef = useRef<editor.IStandaloneCodeEditor | null>(null);
125150
const monacoRef = useRef<Monaco | null>(null);
@@ -140,6 +165,61 @@ export function CodePane({
140165
}
141166
}, [script]);
142167

168+
// ── D-13: bounded editor bring-up ─────────────────────────────────────
169+
// "configuring" → the local Monaco chunk is still loading, so `<Editor>`
170+
// is not rendered yet (the wrapper reads `loader.config` once, at first
171+
// init — configuring afterwards silently falls back to the CDN).
172+
// "mounting" → `<Editor>` is rendered and we are waiting for onMount.
173+
// "ready" → the editor is live.
174+
// "failed" → the chunk rejected, or onMount never arrived in time.
175+
const [editorStatus, setEditorStatus] = useState<
176+
"configuring" | "mounting" | "ready" | "failed"
177+
>("configuring");
178+
179+
const onEditorUnavailableRef = useRef(onEditorUnavailable);
180+
useEffect(() => {
181+
onEditorUnavailableRef.current = onEditorUnavailable;
182+
}, [onEditorUnavailable]);
183+
184+
useEffect(() => {
185+
let cancelled = false;
186+
ensureLocalMonaco().then(
187+
() => {
188+
if (!cancelled) setEditorStatus("mounting");
189+
},
190+
() => {
191+
if (!cancelled) setEditorStatus("failed");
192+
},
193+
);
194+
return () => {
195+
cancelled = true;
196+
};
197+
}, []);
198+
199+
// A bound on the whole bring-up, not just the chunk fetch: `loader.init()`
200+
// resolving is not the same as the editor rendering, and the old failure
201+
// mode was a hang somewhere in between.
202+
useEffect(() => {
203+
if (editorStatus === "ready" || editorStatus === "failed") return;
204+
const timer = setTimeout(
205+
() => setEditorStatus("failed"),
206+
EDITOR_MOUNT_TIMEOUT_MS,
207+
);
208+
return () => clearTimeout(timer);
209+
}, [editorStatus]);
210+
211+
// Report availability upward so Publish can refuse once the code surface is
212+
// known to be missing. Only the terminal `failed` state blocks: the loading
213+
// window is transient, and the text that gets published lives in React
214+
// state (hydrated from the server or the boilerplate), not in Monaco — so a
215+
// click during load publishes exactly what a click after load would. What
216+
// must never happen is publishing from a pane that will never render.
217+
useEffect(() => {
218+
onEditorUnavailableRef.current?.(
219+
editorStatus === "failed" ? EDITOR_LOAD_FAILED_REASON : null,
220+
);
221+
}, [editorStatus]);
222+
143223
// ── Live parse strip — debounced 300 ms client-side parse ─────────────
144224
const [debouncedParseInput] = useDebouncedValue(
145225
internalText,
@@ -212,6 +292,7 @@ export function CodePane({
212292
const handleMount: OnMount = (mountedEditor, mountedMonaco) => {
213293
editorRef.current = mountedEditor;
214294
monacoRef.current = mountedMonaco;
295+
setEditorStatus("ready");
215296
// Disable Monaco's built-in TS checker — publish-time `deno check`
216297
// is the source of truth (per US-177 technical notes). Monaco's
217298
// type-system has different libs/strictness than Deno's, so leaving
@@ -244,21 +325,50 @@ export function CodePane({
244325
overflow: "hidden",
245326
}}
246327
>
247-
<Editor
248-
value={internalText}
249-
language="typescript"
250-
theme="vs-dark"
251-
height={`${height ?? 480}px`}
252-
onMount={handleMount}
253-
onChange={(v) => setInternalText(v ?? "")}
254-
options={{
255-
automaticLayout: true,
256-
minimap: { enabled: false },
257-
scrollBeyondLastLine: false,
258-
wordWrap: "on",
259-
fontSize: 13,
260-
}}
261-
/>
328+
{editorStatus === "failed" ? (
329+
<Alert
330+
color="red"
331+
variant="light"
332+
icon={<IconAlertTriangle size={16} />}
333+
title="Script editor unavailable"
334+
data-testid="code-pane-editor-failed"
335+
style={{ height: `${height ?? 480}px` }}
336+
>
337+
<Text size="sm">
338+
{EDITOR_LOAD_FAILED_REASON} Publishing is blocked until it loads —
339+
reload the page to try again. If it keeps failing, the editor
340+
bundle is not reaching your browser.
341+
</Text>
342+
</Alert>
343+
) : editorStatus === "configuring" ? (
344+
<Group
345+
justify="center"
346+
align="center"
347+
style={{ height: `${height ?? 480}px` }}
348+
data-testid="code-pane-editor-loading"
349+
>
350+
<Loader size="sm" />
351+
<Text size="sm" c="dimmed">
352+
Loading the script editor…
353+
</Text>
354+
</Group>
355+
) : (
356+
<Editor
357+
value={internalText}
358+
language="typescript"
359+
theme="vs-dark"
360+
height={`${height ?? 480}px`}
361+
onMount={handleMount}
362+
onChange={(v) => setInternalText(v ?? "")}
363+
options={{
364+
automaticLayout: true,
365+
minimap: { enabled: false },
366+
scrollBeyondLastLine: false,
367+
wordWrap: "on",
368+
fontSize: 13,
369+
}}
370+
/>
371+
)}
262372
</Box>
263373

264374
{parseStrip.ok ? (

0 commit comments

Comments
 (0)