Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Runtime: optional context-aware snapshot capture/restoration and invoke lifecycle interfaces; reject synchronous callback reentry when callers preserve the callback context.
- Debugger bot: cancellable event processing, checked snapshot errors, and an optional history retention limit.

### Deprecated

- Runtime: the context-free machine `GetSnapshot` and `LoadSnapshot` methods. Use `instrumentation.GetSnapshotContext` and `instrumentation.LoadSnapshotContext` outside callbacks, or `instrumentation.GetSnapshotWithError(args)` inside a synchronous action. Checked capture with `GetSnapshotWithError` remains supported.

### Security

- Studio: metadata pointer writes and object merges use own data properties, preserving JSON keys without traversing inherited properties.
Expand All @@ -36,7 +40,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Studio: include SVG declarations when typechecking the Web Component against editor-core source.
- Studio: a malformed `value`/`defaultValue` definition no longer crashes the editor. The initial render falls back to an empty machine, a controlled update keeps the current graph, and both report the error through `console.error`.
- Studio: `@rendis/statepro-studio-react/styles.css` no longer contains `@tailwind` directives. The host app's Tailwind entry generates the utilities, as documented; under Tailwind 4 the directives emitted extra utilities outside any cascade layer.
- Studio: committing a gesture checkpoint created by `createHistorySnapshot` no longer clones the graph a second time.
- Studio: the ELK layout worker is reused across layouts instead of being created and initialized per request, and released after 60 seconds idle. If the worker cannot load or start (missing asset, CSP, cross-origin URL), layout falls back to the bundled engine on the main thread instead of failing.
- Studio: committing the editor's internal drag/gesture checkpoint no longer clones the graph a second time. Checkpoints from the public `createHistorySnapshot` remain caller-owned and are still copied on `commit-snapshot`.

## [3.3.1] - 2026-08-25

Expand Down
3 changes: 2 additions & 1 deletion docs/instrumentation.md
Original file line number Diff line number Diff line change
Expand Up @@ -235,7 +235,8 @@ type UniversesResume struct {

Snapshots are safe to serialize and reload using `instrumentation.LoadSnapshotContext`.
Inside a synchronous action, capture through `instrumentation.GetSnapshotWithError(args)`.
The machine's context-free snapshot methods are deprecated; callback args remain supported.
The machine's `GetSnapshot` and `LoadSnapshot` methods are deprecated; checked capture with
`GetSnapshotWithError` and callback args remain supported.

## Putting the Interfaces to Work

Expand Down
5 changes: 3 additions & 2 deletions docs/runtime.md
Original file line number Diff line number Diff line change
Expand Up @@ -260,8 +260,9 @@ The exported helpers `instrumentation.GetSnapshotContext(ctx, qm)` and
`instrumentation.LoadSnapshotContext(ctx, qm, snapshot, machineContext)` select that capability
without a type assertion at each call site. Capture falls back to checked capture and then the
legacy method for custom implementations, rejects nil snapshots, and checks cancellation before
and after capture. Legacy fallbacks cannot interrupt lock waiting or detect reentry. Context-free
machine snapshot methods are deprecated; checked action args remain supported.
and after capture. Legacy fallbacks cannot interrupt lock waiting or detect reentry. The machine's
`GetSnapshot` and `LoadSnapshot` methods are deprecated; checked capture with `GetSnapshotWithError`
and checked action args remain supported.

For a compiled application example with explicit budgets, callback-safe capture, a cooperative
invoke, and bounded shutdown, see [`ExampleNewQuantumMachineWithOptions`](../runtime_example_test.go).
Expand Down
17 changes: 12 additions & 5 deletions docs/studio-performance.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,11 +36,18 @@ element.autoLayoutWorkerUrl = "/assets/elk-worker.min.js";
```

Without a worker URL, editor-core retains its lazily imported bundled ELK implementation.
Both paths preserve the layered layout algorithm and graph anchoring. Each worker-backed
request owns one worker, uses it for internal and external layout, and terminates it on
completion or failure. Load/message errors reject promptly. A request that does not finish
within 30 seconds rejects and terminates its worker. Failed imports of the lightweight API
can be retried. Workers are not retained globally after an editor has finished a request.
Both paths preserve the layered layout algorithm and graph anchoring. One worker per URL is
reused across layout requests, including concurrent ones (ELK tags each request), so the
engine is parsed and initialized once instead of on every layout. It is released after
60 seconds without layout requests and recreated on demand.

If the worker cannot start or load (missing asset, `worker-src` CSP, a cross-origin URL,
or no `Worker` global), the request falls back to the bundled engine on the main thread
and logs a warning; that URL is not retried for the rest of the page session. A worker that
crashes after completing layouts is discarded, its in-flight requests fall back, and the next
request starts a new worker. A request that does not finish within 30 seconds rejects and
discards the worker without falling back, since rerunning a stuck graph on the main thread
would freeze the UI. ELK errors for an invalid graph are returned as-is.

Moving ELK off the UI thread does **not** reduce its download. The worker asset is about
1.61 MB before HTTP compression (about 467 kB gzip in the local build); the API chunk is
Expand Down
4 changes: 2 additions & 2 deletions experimental/machine.go
Original file line number Diff line number Diff line change
Expand Up @@ -217,8 +217,8 @@ func (qm *ExQuantumMachine) GetSnapshot() *instrumentation.MachineSnapshot {
}

// GetSnapshotWithError returns a complete snapshot or an error, never a partial snapshot.
//
// Deprecated: use GetSnapshotContext for cancellable waiting and callback reentry detection.
// It waits for the machine lock without a context; use GetSnapshotContext when the caller
// needs cancellable waiting or callback reentry detection.
func (qm *ExQuantumMachine) GetSnapshotWithError() (*instrumentation.MachineSnapshot, error) {
if qm == nil {
return nil, fmt.Errorf("quantum machine must not be nil")
Expand Down
4 changes: 3 additions & 1 deletion studio/packages/editor-core/src/StateProEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ import {
createInitialEditorHistoryState,
editorHistoryReducer,
} from "./state";
import { markHistoryOwned } from "./state/historyOwnership";
import type {
EditorAction,
EditorHistorySnapshot,
Expand Down Expand Up @@ -831,7 +832,8 @@ function StateProEditorInner({
}, [isControlled, libraryBehaviors, locale]);

const captureGestureBaseSnapshot = useCallback(() => {
gestureBaseSnapshotRef.current = createHistorySnapshot(editorState);
// Private copy, never exposed: the history can keep it without cloning again.
gestureBaseSnapshotRef.current = markHistoryOwned(createHistorySnapshot(editorState));
}, [editorState]);

const commitGestureHistoryStep = useCallback(() => {
Expand Down
157 changes: 120 additions & 37 deletions studio/packages/editor-core/src/__tests__/autoLayoutWorker.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,35 @@ const nodes: EditorNode[] = [{
data: { id: "main", name: "main", canonicalName: "main", version: "1.0.0" },
}];

const prepare = async (pending = false, failConstructor = false) => {
const workers: Array<EventTarget & { terminate: ReturnType<typeof vi.fn>; url: string }> = [];
class FakeWorker extends EventTarget {
type FakeWorker = EventTarget & { terminate: ReturnType<typeof vi.fn>; url: string };

type LayoutMode = "ok" | "pending" | "elk-error";

interface PrepareOptions {
mode?: LayoutMode;
failConstructor?: boolean;
workerThrows?: boolean;
}

const layoutGraph = (graph: { children?: Array<Record<string, unknown>> }) =>
({ ...graph, children: graph.children?.map(child => ({ ...child, x: 0, y: 0 })) });

const prepare = async ({ mode = "ok", failConstructor = false, workerThrows = false }: PrepareOptions = {}) => {
const workers: FakeWorker[] = [];
const control = { mode };
class FakeWorkerImpl extends EventTarget {
terminate = vi.fn();
constructor(public url: string) { super(); workers.push(this); }
constructor(public url: string) {
super();
if (workerThrows) throw new DOMException("cross-origin worker", "SecurityError");
workers.push(this as unknown as FakeWorker);
}
}
vi.stubGlobal("Worker", FakeWorker);
const bundled = vi.fn(() => { throw new Error("bundled ELK should not load"); });
vi.doMock("elkjs/lib/elk.bundled.js", bundled);
vi.stubGlobal("Worker", FakeWorkerImpl);
const bundledLayout = vi.fn(async (graph: { children?: Array<Record<string, unknown>> }) => layoutGraph(graph));
vi.doMock("elkjs/lib/elk.bundled.js", () => ({
default: class { layout = bundledLayout; },
}));
const construct = vi.fn();
vi.doMock("elkjs/lib/elk-api.js", () => ({
default: class {
Expand All @@ -24,74 +44,137 @@ const prepare = async (pending = false, failConstructor = false) => {
if (failConstructor) throw new Error("constructor failed");
}
async layout(graph: { children?: Array<Record<string, unknown>> }) {
if (pending) return new Promise(() => {});
return { ...graph, children: graph.children?.map(child => ({ ...child, x: 0, y: 0 })) };
if (control.mode === "pending") return new Promise(() => {});
if (control.mode === "elk-error") throw new Error("invalid graph");
return layoutGraph(graph);
}
},
}));
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
const { computeAutoLayout } = await import("../utils/autoLayout");
return { computeAutoLayout, workers, bundled, construct };
return { computeAutoLayout, workers, bundledLayout, construct, control, warn };
};

afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
vi.unstubAllGlobals();
vi.doUnmock("elkjs/lib/elk-api.js");
vi.doUnmock("elkjs/lib/elk.bundled.js");
vi.resetModules();
});

describe("Layout aislado en worker", () => {
it("usa el worker solicitado sin cargar el motor completo en el hilo principal", async () => {
const { computeAutoLayout, workers, bundled, construct } = await prepare();
describe("Layout en worker reutilizable", () => {
it("reutiliza un solo worker para layouts secuenciales y concurrentes sin cargar el motor completo", async () => {
const { computeAutoLayout, workers, bundledLayout, construct } = await prepare();
const empty: EditorNode[] = [];
expect(await computeAutoLayout(empty, [], {}, "/elk-worker.js")).toBe(empty);
expect(workers).toHaveLength(0);

await computeAutoLayout(nodes, [], {}, "/elk-worker.js");
const [a, b] = await Promise.all([
computeAutoLayout(nodes, [], {}, "/elk-worker.js"),
computeAutoLayout(nodes, [], {}, "/elk-worker.js"),
]);
expect(a[0]).toMatchObject({ x: 12, y: 24, w: 240, h: 220 });
expect(b).toEqual(a);
expect(workers).toHaveLength(3);
workers.forEach(worker => {
expect(worker.url).toBe("/elk-worker.js");
expect(worker.terminate).toHaveBeenCalledOnce();
});
expect(workers).toHaveLength(1);
expect(workers[0].url).toBe("/elk-worker.js");
expect(workers[0].terminate).not.toHaveBeenCalled();
expect(construct).toHaveBeenCalledOnce();
expect(construct).toHaveBeenCalledWith(expect.objectContaining({ algorithms: ["layered"] }));
expect(bundled).not.toHaveBeenCalled();
expect(bundledLayout).not.toHaveBeenCalled();
});

it.each(["error", "messageerror"])("rechaza %s y libera el worker en vez de quedar esperando", async event => {
const { computeAutoLayout, workers } = await prepare(true);
const result = computeAutoLayout(nodes, [], {}, "/missing-worker.js");
const rejection = expect(result).rejects.toThrow("layout worker failed");
await vi.waitFor(() => expect(workers).toHaveLength(1));
workers[0].dispatchEvent(new Event(event));
await rejection;
expect(workers[0].terminate).toHaveBeenCalledOnce();
it.each(["error", "messageerror"])(
"si el worker falla al cargar (%s) usa el motor del hilo principal y no reintenta esa URL",
async event => {
const { computeAutoLayout, workers, bundledLayout, control, warn } = await prepare({ mode: "pending" });
const result = computeAutoLayout(nodes, [], {}, "/missing-worker.js");
await vi.waitFor(() => expect(workers).toHaveLength(1));
workers[0].dispatchEvent(new Event(event));
expect((await result)[0]).toMatchObject({ x: 12, y: 24 });
expect(workers[0].terminate).toHaveBeenCalledOnce();
expect(bundledLayout).toHaveBeenCalledOnce();
expect(warn).toHaveBeenCalled();

control.mode = "ok";
await computeAutoLayout(nodes, [], {}, "/missing-worker.js");
expect(workers).toHaveLength(1);
expect(bundledLayout).toHaveBeenCalledTimes(2);
},
);

it.each([
["el constructor de Worker lanza (CSP u otro origen)", { workerThrows: true }],
["falla la construccion del motor", { failConstructor: true }],
])("usa el motor del hilo principal si %s", async (_label, options) => {
const { computeAutoLayout, workers, bundledLayout } = await prepare(options);
expect(await computeAutoLayout(nodes, [], {}, "/elk-worker.js")).toHaveLength(1);
expect(bundledLayout).toHaveBeenCalledOnce();
workers.forEach(worker => expect(worker.terminate).toHaveBeenCalledOnce());
});

it("interrumpe un worker bloqueado y permite una solicitud posterior", async () => {
const { computeAutoLayout, workers } = await prepare(true);
it("usa el motor del hilo principal cuando Worker no existe", async () => {
const { computeAutoLayout, bundledLayout } = await prepare();
vi.stubGlobal("Worker", undefined);
expect(await computeAutoLayout(nodes, [], {}, "/elk-worker.js")).toHaveLength(1);
expect(bundledLayout).toHaveBeenCalledOnce();
});

it("si el worker cae tras funcionar, recupera la solicitud en curso y crea uno nuevo despues", async () => {
const { computeAutoLayout, workers, bundledLayout, control } = await prepare();
await computeAutoLayout(nodes, [], {}, "/elk-worker.js");
control.mode = "pending";
const result = computeAutoLayout(nodes, [], {}, "/elk-worker.js");
workers[0].dispatchEvent(new Event("error"));
expect(await result).toHaveLength(1);
expect(bundledLayout).toHaveBeenCalledOnce();

control.mode = "ok";
await computeAutoLayout(nodes, [], {}, "/elk-worker.js");
expect(workers).toHaveLength(2);
expect(bundledLayout).toHaveBeenCalledOnce();
});

it("interrumpe un worker bloqueado sin congelar el hilo principal y el siguiente layout usa uno nuevo", async () => {
const { computeAutoLayout, workers, bundledLayout, control } = await prepare({ mode: "pending" });
vi.useFakeTimers();
const result = computeAutoLayout(nodes, [], {}, "/elk-worker.js");
const rejection = expect(result).rejects.toThrow("timed out");
await vi.waitFor(() => expect(workers).toHaveLength(1));
await vi.advanceTimersByTimeAsync(30_000);
await rejection;
expect(workers[0].terminate).toHaveBeenCalledOnce();
vi.useRealTimers();
vi.resetModules();
const retry = await prepare();
expect(await retry.computeAutoLayout(nodes, [], {}, "/elk-worker.js")).toHaveLength(1);
expect(retry.workers[0].terminate).toHaveBeenCalledOnce();
expect(bundledLayout).not.toHaveBeenCalled();

control.mode = "ok";
expect(await computeAutoLayout(nodes, [], {}, "/elk-worker.js")).toHaveLength(1);
expect(workers).toHaveLength(2);
expect(workers[1].terminate).not.toHaveBeenCalled();
});

it("propaga errores de ELK sin cambiar de motor y conserva el worker", async () => {
const { computeAutoLayout, workers, bundledLayout, control } = await prepare({ mode: "elk-error" });
await expect(computeAutoLayout(nodes, [], {}, "/elk-worker.js")).rejects.toThrow("invalid graph");
expect(bundledLayout).not.toHaveBeenCalled();
expect(workers[0].terminate).not.toHaveBeenCalled();

control.mode = "ok";
await computeAutoLayout(nodes, [], {}, "/elk-worker.js");
expect(workers).toHaveLength(1);
});

it("libera el worker si falla la construccion del motor", async () => {
const { computeAutoLayout, workers } = await prepare(false, true);
await expect(computeAutoLayout(nodes, [], {}, "/elk-worker.js")).rejects.toThrow("constructor failed");
it("libera el worker inactivo y crea otro en la siguiente solicitud", async () => {
const { computeAutoLayout, workers } = await prepare();
vi.useFakeTimers();
await computeAutoLayout(nodes, [], {}, "/elk-worker.js");
await vi.advanceTimersByTimeAsync(59_000);
expect(workers[0].terminate).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(1_000);
expect(workers[0].terminate).toHaveBeenCalledOnce();

await computeAutoLayout(nodes, [], {}, "/elk-worker.js");
expect(workers).toHaveLength(2);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
editorHistoryReducer,
} from "../state";
import type { EditorHistoryState } from "../state";
import { markHistoryOwned } from "../state/historyOwnership";

const applyMachineId = (
state: EditorHistoryState,
Expand Down Expand Up @@ -73,19 +74,30 @@ describe("editorHistoryReducer", () => {
expect(universe(edited.past[0].nodes).data.id).toBe(originalId);
});

it("commit-snapshot toma propiedad del checkpoint desacoplado sin clonarlo de nuevo", () => {
it("commit-snapshot copia checkpoints publicos: mutarlos despues no corrompe el undo", () => {
const initial = createInitialEditorState();
const originalId = initial.machineConfig.id;
const captured = createHistorySnapshot(initial);
const edited = applyMachineId(createInitialEditorHistoryState(initial), "edited", "silent");
const committed = editorHistoryReducer(edited, { type: "commit-snapshot", payload: captured });
expect(committed.past[0]).not.toBe(captured);

captured.machineConfig.id = "consumer-mutated";
const restored = editorHistoryReducer(committed, { type: "undo" });
expect(restored.present.machineConfig.id).toBe(originalId);
});

it("commit-snapshot toma propiedad de checkpoints internos del editor sin clonarlos", () => {
const initial = createInitialEditorState();
const owned = markHistoryOwned(createHistorySnapshot(initial));
const edited = applyMachineId(createInitialEditorHistoryState(initial), "edited", "silent");
const committed = editorHistoryReducer(edited, { type: "commit-snapshot", payload: owned });
expect(committed.past).toHaveLength(1);
expect(committed.past[0]).toBe(captured);
expect(committed.past[0]).toBe(owned);

// Plain payloads are not owned by the history and are still cloned.
const external = { ...createHistorySnapshot(initial) };
const committedExternal = editorHistoryReducer(edited, { type: "commit-snapshot", payload: external });
expect(committedExternal.past[0]).not.toBe(external);
expect(committedExternal.past[0]).toEqual(external);
// Ownership transfers once; committing the same object again copies it.
const again = editorHistoryReducer(edited, { type: "commit-snapshot", payload: owned });
expect(again.past[0]).not.toBe(owned);
});

it("permite cambios visuales sin marcar dirty-from-import cuando se indica explícitamente", () => {
Expand Down
Loading
Loading