The flow canvas says when its layout engine did not load and tries again on a click, instead of keeping its loader until a reload: the ELK API now comes with the editor, so the worker's script is the one fetch, a worker that failed to load is dropped and the next ask starts another, and the editor starts it as soon as its code runs, beside the flow document

This commit is contained in:
2026-11-10 19:27:52 +01:00
parent 33ed6f669f
commit 1a7a6a3747
3 changed files with 102 additions and 29 deletions
+57 -12
View File
@@ -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<string, NodeShape>,
shapeKey: string,
): Layout | null {
) {
const [placed, setPlaced] = useState<Layout | null>(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 ? (
<div
className="pointer-events-none absolute inset-0 flex items-center justify-center"
data-testid="flow-loading"
@@ -1577,6 +1600,28 @@ function FlowEditorInner({
</div>
) : null}
{definitions.length > 0 && !positions && layoutFailed ? (
<div
className="pointer-events-none absolute inset-0 flex items-center justify-center"
data-testid="flow-layout-failed"
>
<div className="pointer-events-auto flex flex-col items-center gap-3 text-center">
<span className="flex size-14 items-center justify-center rounded-full bg-muted text-muted-foreground">
<TriangleAlert className="size-6" />
</span>
<p className="text-lg font-medium">
The layout engine did not load
</p>
<p className="max-w-xs text-sm text-muted-foreground">
Check the connection, then try again.
</p>
<Button variant="outline" onClick={retryLayout}>
Try again
</Button>
</div>
</div>
) : null}
{definitions.length === 0 ? (
<div
className="pointer-events-none absolute inset-0 flex items-center justify-center"
+16 -2
View File
@@ -15,15 +15,24 @@ import assert from "node:assert/strict"
globalThis.self = undefined
// A page runs ELK in a worker Vite builds (`?worker`). Here the same build
// runs in-thread, as the fake worker it exports outside a worker.
// runs in-thread, as the fake worker it exports outside a worker. The first
// one's script fails to load, as on a dropped connection.
// @ts-expect-error the worker build ships without types.
const { Worker: InThread } = await import("elkjs/lib/elk-worker.min.js")
let dropped = false
function FirstDropped(this: Worker) {
if (dropped) return new InThread()
dropped = true
setTimeout(() => 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
+29 -15
View File
@@ -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<string, Point>, 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<ElkNode> }> | null =
null
let engine: Promise<ELK> | 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<ELK> {
if (engine) return engine
const worker = new ElkWorker()
const loaded = new ElkApi({ workerFactory: () => worker })
engine = new Promise<ELK>((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
}