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/update-oxlint-and-fixes.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@openworkflowspec/diagram-editor": patch
---

Update oxlint and apply fixes
Original file line number Diff line number Diff line change
Expand Up @@ -533,6 +533,7 @@ function SidebarMenuSkeleton({
}) {
// Random width between 50 to 90%.
const width = React.useMemo(() => {
// oxlint-disable-next-line react/purity
return `${Math.floor(Math.random() * 40) + 50}%`;
}, []);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ function TooltipContent({
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
const [container, setContainer] = React.useState<HTMLElement>();
React.useEffect(() => {
// oxlint-disable-next-line react/set-state-in-effect
setContainer(document.querySelector<HTMLElement>(".dec-root") ?? undefined);
}, []);
return (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,47 +14,58 @@
* limitations under the License.
*/

import { useEffect, useState } from "react";
import { useCallback, useSyncExternalStore } from "react";
import { ColorMode, ResolvedColorMode } from "../types/colorMode";

const DARK_MEDIA_QUERY = "(prefers-color-scheme: dark)";

function normalizeColorMode(colorMode: string): ColorMode {
return colorMode === "light" || colorMode === "dark" || colorMode === "system" ? colorMode : "system";
return colorMode === "light" || colorMode === "dark" || colorMode === "system"
? colorMode
: "system";
}

function getSystemColorMode(): ResolvedColorMode {
function getMediaQueryList(): MediaQueryList | null {
if (typeof window !== "undefined" && typeof window.matchMedia === "function") {
return window.matchMedia(DARK_MEDIA_QUERY).matches ? "dark" : "light";
return window.matchMedia(DARK_MEDIA_QUERY);
}
return "light"; // Default to light
return null;
}

export function useResolvedColorMode(colorMode: ColorMode): ResolvedColorMode {
const normalized = normalizeColorMode(colorMode);
function getSystemColorMode(): ResolvedColorMode {
return getMediaQueryList()?.matches ? "dark" : "light";
}

const [resolvedColorMode, setResolvedColorMode] = useState<ResolvedColorMode>(
normalized === "system" ? getSystemColorMode() : normalized,
);
function getServerColorMode(): ResolvedColorMode {
return "light";
}

useEffect(() => {
if (normalized !== "system") {
setResolvedColorMode(normalized);
return;
}
function noopUnsubscribe(): void {}

setResolvedColorMode(getSystemColorMode());
export function useResolvedColorMode(colorMode: ColorMode): ResolvedColorMode {
const normalized = normalizeColorMode(colorMode);

const mediaQuery = window.matchMedia(DARK_MEDIA_QUERY);
const handler = (e: MediaQueryListEvent) => {
setResolvedColorMode(e.matches ? "dark" : "light");
};
mediaQuery.addEventListener("change", handler);
const subscribe = useCallback(
(onStoreChanges: () => void) => {
if (normalized !== "system") {
return noopUnsubscribe;
}

return () => {
mediaQuery.removeEventListener("change", handler);
};
}, [normalized]);
const mediaQuery = getMediaQueryList();
if (mediaQuery == null) {
return noopUnsubscribe;
}
mediaQuery.addEventListener("change", onStoreChanges);
return () => {
mediaQuery.removeEventListener("change", onStoreChanges);
};
},
[normalized],
);

return resolvedColorMode;
return useSyncExternalStore(
subscribe,
() => (normalized === "system" ? getSystemColorMode() : normalized),
() => (normalized === "system" ? getServerColorMode() : normalized),
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -74,20 +74,31 @@ export const Diagram = ({ divRef, colorMode = "light" }: DiagramProps) => {
// re-triggering the layout effect when they change independently (e.g. selection,
// viewport, undo/redo).
const selectedNodeIdRef = React.useRef<string | null>(selectedNodeId);
selectedNodeIdRef.current = selectedNodeId;
const pendingViewportRestoreRef = React.useRef(pendingViewportRestore);
pendingViewportRestoreRef.current = pendingViewportRestore;

const isReadOnlyRef = React.useRef(isReadOnly);
isReadOnlyRef.current = isReadOnly;
const modelRef = React.useRef(model);
modelRef.current = model;
// Function refs — callbacks change identity across renders but the post-layout
// setTimeout must always invoke the latest version without re-running layout.
const submitModelRef = React.useRef(submitModel);
submitModelRef.current = submitModel;
const clearPendingViewportRestoreRef = React.useRef(clearPendingViewportRestore);
clearPendingViewportRestoreRef.current = clearPendingViewportRestore;

// Assigned after commit rather than during render (a render must not have side effects)
React.useLayoutEffect(() => {
selectedNodeIdRef.current = selectedNodeId;
pendingViewportRestoreRef.current = pendingViewportRestore;
isReadOnlyRef.current = isReadOnly;
modelRef.current = model;
submitModelRef.current = submitModel;
clearPendingViewportRestoreRef.current = clearPendingViewportRestore;
}, [
selectedNodeId,
pendingViewportRestore,
isReadOnly,
model,
submitModel,
clearPendingViewportRestore,
]);
// True once the first layout has been committed to context — gates rendering the canvas
// so React Flow mounts with nodes already positioned and fitView fires on real content.
const [layoutReady, setLayoutReady] = React.useState(false);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -88,12 +88,16 @@ export function useWorkflowHistory(isReadOnly: boolean): UseWorkflowHistoryRetur
// closing over it — this prevents them from being recreated on every state change,
// which would retrigger layout effects and cause infinite re-render loops.
const stateRef = React.useRef(state);
stateRef.current = state;

// Keep a ref to the latest isReadOnly so callbacks don't go stale when the prop
// changes (e.g. Storybook controls toggling the isReadOnly arg).
const isReadOnlyRef = React.useRef(isReadOnly);
isReadOnlyRef.current = isReadOnly;

// Assigned after commit rather than during render (a render must not have side effects)
React.useLayoutEffect(() => {
stateRef.current = state;
isReadOnlyRef.current = isReadOnly;
}, [state, isReadOnly]);

/**
* Seeds the model from external props.content.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,13 @@ export const DiagramEditorContextProvider = React.forwardRef<

// Config state (non-history)
const [locale, setLocale] = React.useState<string>(props.locale);
const [lastPropsLocale, setLastPropsLocale] = React.useState<string>(props.locale);

if (props.locale !== lastPropsLocale) {
setLastPropsLocale(props.locale);
setLocale(props.locale);
}

const [nodes, setNodes] = React.useState([] as RF.Node[]);
const [edges, setEdges] = React.useState([] as RF.Edge[]);
const [selectedNodeId, setSelectedNodeId] = React.useState<string | null>(null);
Expand Down Expand Up @@ -90,13 +97,18 @@ export const DiagramEditorContextProvider = React.forwardRef<
// Keep a ref to the latest selectedNodeId so the effect below can read it
// synchronously without taking it as a dependency (avoids re-seeding on every click).
const selectedNodeIdRef = React.useRef<string | null>(selectedNodeId);
selectedNodeIdRef.current = selectedNodeId;

React.useEffect(() => {
selectedNodeIdRef.current = selectedNodeId;
}, [selectedNodeId]);

// Seed history from the external content prop using seedModel (bypasses isReadOnly guard).
// The real viewport is set by Diagram.tsx once layout completes in edit mode.
// In read-only mode the placeholder viewport is acceptable since fitView always runs.
React.useEffect(() => {
const { model: parsedModel, errors: parsedErrors } = parseWorkflow(props.content);
// This will be addressed in the editing feature branch as the implemntation is changing
// oxlint-disable-next-line react/set-state-in-effect
setErrors(parsedErrors);
if (parsedModel === null) {
// Content is unparseable — reset history to null so downstream consumers
Expand All @@ -121,11 +133,6 @@ export const DiagramEditorContextProvider = React.forwardRef<
[model],
);

// Sync locale state when the prop changes.
React.useEffect(() => {
setLocale(props.locale);
}, [props.locale]);

/**
* Imperative API: load a new workflow from a YAML or JSON string.
* Mirrors exactly what the props.content effect does, plus updates contentFormat.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -73,14 +73,15 @@ describe("useResolvedColorMode", () => {
expect(result.current).toBe("light");

act(() => {
matchesDark = true;
for (const listener of listeners) {
listener({ matches: true } as MediaQueryListEvent);
}
});
expect(result.current).toBe("dark");
});

it('resolves an unknown colorMode value to the system preference', () => {
it("resolves an unknown colorMode value to the system preference", () => {
matchesDark = false;
// @ts-expect-error testing runtime behavior with an invalid colorMode value
const { result } = renderHook(() => useResolvedColorMode("invalid"));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@

import * as React from "react";
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { vi, expect, afterEach, describe, it } from "vitest";
import { vi, expect, afterEach, describe, it, beforeEach } from "vitest";
import { useDiagramEditorContext } from "../../src/store/DiagramEditorContext";
import { DiagramEditorContextProvider } from "../../src/store/DiagramEditorContextProvider";
import type { DiagramEditorRef } from "../../src/diagram-editor/DiagramEditor";
Expand All @@ -26,20 +26,18 @@ import {
BASIC_VALID_WORKFLOW_YAML,
} from "../fixtures/workflows";

const renderSpy = vi.fn();

const TestComponent: React.FC = () => {
const { isReadOnly, locale, model, errors } = useDiagramEditorContext();
const renderCount = React.useRef<number>(0);

// Increments on every render cycle
renderCount.current++;
renderSpy();

return (
<div data-testid="test-wrapper">
<p data-testid="test-read-only">{`${isReadOnly}`}</p>
<p data-testid="test-locale">{`${locale}`}</p>
<p data-testid="test-model">{`${model ? model.document?.name : "null"}`}</p>
<p data-testid="test-errors">{`${errors.length}`}</p>
<p data-testid="test-render">{`${renderCount.current}`}</p>
</div>
);
};
Expand All @@ -55,6 +53,10 @@ const SelectionButton: React.FC = () => {
};

describe("DiagramEditorContextProvider Component", () => {
beforeEach(() => {
renderSpy.mockClear();
});

afterEach(() => {
vi.restoreAllMocks();
});
Expand All @@ -72,14 +74,13 @@ describe("DiagramEditorContextProvider Component", () => {

const readOnlyElement = screen.getByTestId("test-read-only");
const readOnlyLocale = screen.getByTestId("test-locale");
const renderCount = screen.getByTestId("test-render");

expect(readOnlyElement).toHaveTextContent(/true/i);
expect(readOnlyLocale).toHaveTextContent(/en/i);

// Two rendering cycles are expected:
// 1- initial render, 2- useEffect seeding history from parsedModel
expect(renderCount).toHaveTextContent(/2/i);
expect(renderSpy).toHaveBeenCalledTimes(2);
});

it("Context provider props changes shall cause internal component to reload", async () => {
Expand All @@ -105,15 +106,14 @@ describe("DiagramEditorContextProvider Component", () => {

const readOnlyElementChanged = screen.getByTestId("test-read-only");
const readOnlyLocaleChanged = screen.getByTestId("test-locale");
const renderCount = screen.getByTestId("test-render");

expect(readOnlyElementChanged).toHaveTextContent(/false/i);
expect(readOnlyLocaleChanged).toHaveTextContent(/pt/i);

// 4 rendering cycles are expected:
// 1- initial render, 2- history seed useEffect,
// 3- forced by rerender, 4- state updates from isReadOnly/locale change
expect(renderCount).toHaveTextContent(/4/i);
// 3- forced by rerender
expect(renderSpy).toHaveBeenCalledTimes(3);
});
Comment thread
lornakelly marked this conversation as resolved.

it("Context provider same props shall not cause internal component to reload", async () => {
Expand All @@ -139,14 +139,13 @@ describe("DiagramEditorContextProvider Component", () => {

const readOnlyElementChanged = screen.getByTestId("test-read-only");
const readOnlyLocaleChanged = screen.getByTestId("test-locale");
const renderCount = screen.getByTestId("test-render");

expect(readOnlyElementChanged).toHaveTextContent(/true/i);
expect(readOnlyLocaleChanged).toHaveTextContent(/en/i);

// 3 rendering cycles are expected:
// 1- initial render, 2- history seed useEffect, 3- forced by rerender (same props, no state change)
expect(renderCount).toHaveTextContent(/3/i);
expect(renderSpy).toHaveBeenCalledTimes(3);
});

it("Parses valid workflow content into model with no errors", async () => {
Expand Down
Loading