Skip to content
Open
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
5 changes: 5 additions & 0 deletions .changeset/state-management.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@openworkflowspec/diagram-editor": minor
---

Add state management, undo / redo capabilities and component API.
1 change: 1 addition & 0 deletions packages/open-workflow-diagram-editor/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@
"class-variance-authority": "catalog:",
"clsx": "catalog:",
"elkjs": "catalog:",
"fast-equals": "catalog:",
"js-yaml": "catalog:",
"radix-ui": "catalog:",
"sonner": "catalog:",
Expand Down
176 changes: 176 additions & 0 deletions packages/open-workflow-diagram-editor/src/core/structuralEqual.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
/*
* Copyright 2021-Present The Open Workflow Specification Authors

@lornakelly lornakelly Aug 13, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

dont think this should be in a hooks directory? Just in core folder instead?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@lornakelly,

Good catch!

It is fixed!

*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

import { createCustomEqual, type State } from "fast-equals";

// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------

/** Object.prototype.toString tag for plain objects and class instances. */
const OBJ_TAG = "[object Object]";
const toStringTag = Object.prototype.toString;

/**
* Returns true when `v` is a plain object or user-defined class instance —
* i.e. any non-null object whose `Object.prototype.toString` tag is
* `[object Object]`. This excludes arrays, Date, RegExp, Map, Set, typed
* arrays and other built-in types that fast-equals handles natively.
*/
function isObjectLike(v: unknown): v is Record<string, unknown> {
return v !== null && typeof v === "object" && toStringTag.call(v) === OBJ_TAG;
}

const { keys } = Object;
const hasOwnProp = Object.prototype.hasOwnProperty;

// ---------------------------------------------------------------------------
// Custom fast-equals configuration
// ---------------------------------------------------------------------------

/**
* Captures the underlying `EqualityComparator` built by fast-equals so we can
* delegate non-object-like values (arrays, dates, sets, …) to it without
* re-implementing those handlers ourselves.
*/
let _defaultCompare: (a: unknown, b: unknown, state: State<undefined>) => boolean;

/**
* Constructor-agnostic recursive comparator used as `state.equals` by
* fast-equals.
*
* **Object-like values** (any non-null object whose `toString` tag is
* `[object Object]`, i.e. plain objects and user-defined class instances):
* compared by own enumerable string keys and values, ignoring constructors and
* prototype chains. Property insertion order is irrelevant.
*
* **Array-like values** (anything where `Array.isArray` returns true, including
* SDK array subclasses such as `TaskList`): compared element-by-element via
* `innerEquals`, so constructor differences between e.g. `TaskList` and `Array`
* are also ignored.
*
* **All other value types** (dates, maps, sets, typed arrays, primitives, …):
* forwarded to `_defaultCompare`, the default fast-equals type-dispatching
* comparator.
*
* Circular references are handled via `state.cache` (a `WeakMap` that
* fast-equals provides when `circular: true` is set).
*/
function innerEquals(
a: unknown,
b: unknown,
_keyA: unknown,
_keyB: unknown,
_parentA: unknown,
_parentB: unknown,
state: State<undefined>,
): boolean {
if (isObjectLike(a) && isObjectLike(b)) {
// `state.cache` is always a WeakMap when built with `circular: true`.
const cache = state.cache as WeakMap<object, object>;
// Circular-reference guard — mirrors fast-equals' createIsCircular.
const cachedA = cache.get(a);
const cachedB = cache.get(b);
if (cachedA !== undefined && cachedB !== undefined) {
return cachedA === b && cachedB === a;
}
cache.set(a, b);
cache.set(b, a);

const keysA = keys(a);
let result = keysA.length === keys(b).length;
if (result) {
for (let i = 0; i < keysA.length; i++) {
const k = keysA[i]!;
if (!hasOwnProp.call(b, k) || !innerEquals(a[k], b[k], k, k, a, b, state)) {
result = false;
break;
}
}
}

cache.delete(a);
cache.delete(b);
return result;
}

// Handle array subclasses (e.g. SDK's TaskList vs a plain Array).
// fast-equals' internal routing comparator checks `a.constructor !== b.constructor`
// before it reaches the Array.isArray fast-path, so cross-constructor arrays
// must be handled here to avoid a false-negative.
if (Array.isArray(a) && Array.isArray(b)) {
if (a.length !== b.length) return false;
for (let i = 0; i < a.length; i++) {
if (!innerEquals(a[i], b[i], i, i, a, b, state)) return false;
}
return true;
}

return _defaultCompare(a, b, state);
}

/**
* A `createCustomEqual` instance with `circular: true` whose
* `createInternalComparator` captures `_defaultCompare` and installs
* `innerEquals` as `state.equals`. The instance is also used as the
* fallback comparator for non-object-like root values.
*/
const _fastEquals = createCustomEqual({
circular: true,
createInternalComparator: (defaultCompare) => {
_defaultCompare = defaultCompare;
return innerEquals;
},
});

// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------

/**
* Compares two values for deep structural equality, **ignoring constructor and
* class identity** for object-like values.
*
* **Behaviour**
* - A plain `{ x: 1 }` and `new Foo(1)` (where `Foo` sets `this.x = x`) are
* considered equal. Two instances of *different* classes with identical own
* enumerable properties are also considered equal.
* - Property insertion order is irrelevant.
* - Circular references are handled safely — no stack overflow.
* - Arrays, dates, sets, maps, typed arrays and primitives are compared by
* value using `fast-equals`' built-in handlers.
*
* @param a - First value to compare.
* @param b - Second value to compare.
* @returns `true` if the two values are structurally equal.
*/
export function structuralEqual(a: unknown, b: unknown): boolean {
// For object-like values and array subclasses, the `_fastEquals` entry point
// has a hard `constructor !== b.constructor → false` guard that we must bypass.
// Invoke `innerEquals` directly with a fresh state for both cases.
if ((isObjectLike(a) && isObjectLike(b)) || (Array.isArray(a) && Array.isArray(b))) {
const state: State<undefined> = {
cache: new WeakMap(),
equals: innerEquals,
meta: undefined,
strict: false,
};
return innerEquals(a, b, undefined, undefined, undefined, undefined, state);
}
// Primitives, dates, maps, sets, and other non-object-like values — delegate
// to fast-equals so we don't have to replicate its type handlers.
return _fastEquals(a, b);
}
17 changes: 16 additions & 1 deletion packages/open-workflow-diagram-editor/src/core/workflowSdk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,13 @@
* limitations under the License.
*/

import { load } from "js-yaml";
import { load, dump } from "js-yaml";
import * as sdk from "@openworkflowspec/sdk";
import { fixNodesConnections } from "./graph";
import { stripSpecAheadOfSdkErrors } from "./specWorkarounds";

export type ContentFormat = "json" | "yaml";

/**
* Sanitizes an object by removing dangerous prototype pollution keys
* and creating a new object with null prototype to prevent pollution attacks.
Expand Down Expand Up @@ -288,3 +290,16 @@ export function parseWorkflow(text: string): WorkflowParseResult {
export function buildFlatGraph(model: sdk.Specification.Workflow): sdk.FlatGraph {
return fixNodesConnections(sdk.buildFlatGraph(model));
}

export function serializeWorkflow(
model: sdk.Specification.Workflow,
format: ContentFormat,
): string {
const workflow = model instanceof sdk.Classes.Workflow ? model : new sdk.Classes.Workflow(model);
if (format === "json") return workflow.serialize("json");
// SDK bug (v1.0.3-alpha6): instance.serialize("yaml") fails because normalize()
// returns a Workflow class instance and js-yaml rejects non-plain objects.
// Workaround: serialize to JSON first to get a plain object, then dump as YAML.
// TODO: Remove this workaround once the SDK is fixed.
return dump(JSON.parse(workflow.serialize("json")));
}
Loading