diff --git a/frontend/src/components/Flow/FlowEditor.tsx b/frontend/src/components/Flow/FlowEditor.tsx index b9dd994..9e5b0ae 100644 --- a/frontend/src/components/Flow/FlowEditor.tsx +++ b/frontend/src/components/Flow/FlowEditor.tsx @@ -20,7 +20,7 @@ import { useStore, useUpdateNodeInternals, } from "@xyflow/react" -import { Workflow } from "lucide-react" +import { TriangleAlert, Workflow } from "lucide-react" import { motion } from "motion/react" import { useCallback, useEffect, useMemo, useRef, useState } from "react" @@ -86,6 +86,7 @@ import { InputInspector, type InspectedInput } from "./InputInspector" import { LiveEdge } from "./LiveEdge" import { type Direction, + elk, type Layout, layoutGraph, type NodeShape, @@ -107,6 +108,11 @@ import { listenRenames, useFlowSocket } from "./useFlowSocket" const nodeTypes = { flow: FlowNode, [ENDPOINT_TYPE]: EndpointNode } +// Asked for as soon as the editor's code runs, so the layout engine is on its +// way while the flow document is. A failure here is met again, and shown, when +// the canvas asks for the engine itself. +elk().catch(() => {}) + /** * Where every node sits and how every edge runs, once the engine has said so. * @@ -114,7 +120,8 @@ const nodeTypes = { flow: FlowNode, [ENDPOINT_TYPE]: EndpointNode } * a moment before it knows where anything goes. It draws no node until then, * only a loader, and an *edit* keeps the arrangement it had, so only the node * that changed lands a frame late rather than the whole graph flickering - * through the corner. + * through the corner. An engine that did not load says so, and `retry` asks + * for it again. */ function useLayout( ids: string[], @@ -122,21 +129,33 @@ function useLayout( direction: Direction, shapes: Map, shapeKey: string, -): Layout | null { +) { const [placed, setPlaced] = useState(null) + const [failed, setFailed] = useState(false) + const [attempt, setAttempt] = useState(0) // biome-ignore lint/correctness/useExhaustiveDependencies: the shape key is the dependency; the arrays are rebuilt every render. useEffect(() => { let stale = false - layoutGraph(ids, edges, direction, shapes).then((layout) => { - // A graph edited while the last one was still being laid out has already - // asked for another; that one is the answer, not this. - if (!stale) setPlaced(layout) - }) + setFailed(false) + layoutGraph(ids, edges, direction, shapes).then( + (layout) => { + // A graph edited while the last one was still being laid out has + // already asked for another; that one is the answer, not this. + if (!stale) setPlaced(layout) + }, + () => { + if (!stale) setFailed(true) + }, + ) return () => { stale = true } - }, [shapeKey, edges]) - return placed + }, [shapeKey, edges, attempt]) + return { + layout: placed, + failed, + retry: () => setAttempt((count) => count + 1), + } } /** Is this canvas node actually part of the flow document? */ @@ -736,7 +755,11 @@ function FlowEditorInner({ ] as const }), ]) - const layout = useLayout(ids, edges, direction, shapes, shapeKey) + const { + layout, + failed: layoutFailed, + retry: retryLayout, + } = useLayout(ids, edges, direction, shapes, shapeKey) const positions = layout?.positions ?? null /** @@ -1568,7 +1591,7 @@ function FlowEditorInner({ {/* On a slow link the layout engine arrives well after the document, and a bare canvas until then reads as a flow with no nodes. */} - {definitions.length > 0 && !positions ? ( + {definitions.length > 0 && !positions && !layoutFailed ? (
) : null} + {definitions.length > 0 && !positions && layoutFailed ? ( +
+
+ + + +

+ The layout engine did not load +

+

+ Check the connection, then try again. +

+ +
+
+ ) : null} + {definitions.length === 0 ? (
this.onerror?.(new ErrorEvent("error"))) + this.postMessage = () => {} + this.terminate = () => {} +} // @ts-expect-error bun's global, which the DOM types do not know. Bun.plugin({ name: "elk in-thread", setup: (build: { module: (id: string, load: () => object) => void }) => build.module("elkjs/lib/elk-worker.min.js?worker", () => ({ - exports: { default: InThread }, + exports: { default: FirstDropped }, loader: "object", })), }) @@ -32,6 +41,11 @@ const { layoutGraph, nodeHeight, portFraction, PORT_SPAN } = await import( "./layout" ) +// That failure is the answer to the first layout rather than a hang, and is +// not kept: the next layout fetches the engine again. +await assert.rejects(layoutGraph(["a"], [], "LR")) +assert.equal((await layoutGraph(["a"], [], "LR")).positions.size, 1) + /** Handles are 12px across — see `.react-flow__handle` in `flow.css`. */ const HANDLE = 12 const NODE_H = 56 diff --git a/frontend/src/components/Flow/layout.ts b/frontend/src/components/Flow/layout.ts index cf94b02..2faecb7 100644 --- a/frontend/src/components/Flow/layout.ts +++ b/frontend/src/components/Flow/layout.ts @@ -1,4 +1,5 @@ -import type { ElkNode, ElkPort } from "elkjs/lib/elk-api" +import ElkApi, { type ELK, type ElkNode, type ElkPort } from "elkjs/lib/elk-api" +import ElkWorker from "elkjs/lib/elk-worker.min.js?worker" /** * Where the nodes of a flow go, and how the edges between them run. @@ -394,22 +395,35 @@ function wrapWideRanks(placed: Map, ids: string[]): Wrap[] { /** * The layout engine, fetched the first time a flow needs laying out. * - * Its own chunk: ELK is a compiled Java library and weighs more than the rest - * of the canvas put together, so nothing but this route ever loads it. And - * its own thread: a phone lays the Demo house out eight times over, about - * half a second each, which on the page would freeze it for seconds. + * A file of its own: ELK is a compiled Java library and weighs more than the + * rest of the canvas put together, so nothing but this route ever loads it. + * And its own thread: a phone lays the Demo house out eight times over, about + * half a second each, which on the page would freeze it for seconds. The API + * that talks to it is a few kilobytes and comes with the editor, so the + * worker's script is the one fetch. + * + * An engine that failed to arrive is not kept: the next call starts another + * worker, which fetches the script again, so a moment offline costs a retry + * rather than a reload. A dynamic `import()` could not do that, since the + * browser remembers a module that failed to load and fails every later import + * of it without asking the server again. */ -let engine: Promise<{ layout: (graph: ElkNode) => Promise }> | null = - null +let engine: Promise | null = null -function elk() { - engine ??= Promise.all([ - import("elkjs/lib/elk-api.js"), - import("elkjs/lib/elk-worker.min.js?worker"), - ]).then( - ([api, worker]) => - new api.default({ workerFactory: () => new worker.default() }), - ) +export function elk(): Promise { + if (engine) return engine + const worker = new ElkWorker() + const loaded = new ElkApi({ workerFactory: () => worker }) + engine = new Promise((resolve, reject) => { + // A worker whose script did not load never answers, so this is the only + // word of it. Asking for the algorithms waits until it has loaded. + worker.onerror = reject + loaded.knownLayoutAlgorithms().then(() => resolve(loaded), reject) + }).catch((error) => { + engine = null + worker.terminate() + throw error + }) return engine }