Skip to content

Commit f257900

Browse files
fix: Resolve race condition between palette fetch and node upload in the app (WB-340) (#51)
* fix: Resolve race condition between palette fetch and node upload in the app (WB-340) * fix(sdk): add refreshNodesErrorsIfNeeded unit-tests --------- Co-authored-by: Piotr Blaszczyk <piotr.blaszczyk@synergycodes.com>
1 parent f58669a commit f257900

5 files changed

Lines changed: 130 additions & 1 deletion

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@workflowbuilder/sdk': patch
3+
---
4+
5+
Simultaneous loading of nodes and the palette can result in a race condition. If nodes are loaded before the palette, new errors (for example, for fields that were not evaluated earlier) are skipped, and the node only shows a “!” indicator when the user selects it.
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
// Pins the WB-340 fix: when nodes load before the palette, they are validated
2+
// against an empty definition set and their errors are dropped. Once the palette
3+
// arrives, `refreshNodesErrorsIfNeeded` must re-run validation and repair the
4+
// stored nodes, while staying a no-op when nothing actually changed.
5+
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
6+
7+
import { setCustomPaletteNodes } from '../../../data/palette';
8+
import type { WorkflowBuilderNode } from '../../../node/node-data';
9+
import { mockNodeDelay } from '../../../utils/validation/get-node-errors.mock';
10+
import { resetWorkflowStore, useStore } from '../../store';
11+
import { refreshNodesErrorsIfNeeded } from './actions';
12+
13+
// Mirrors the definition the consumer's palette would supply for `delay` nodes;
14+
// `description` is required, so a node missing it must surface a validation error.
15+
const delayDefinition = {
16+
type: 'delay',
17+
label: 'Delay',
18+
description: 'Pause the workflow',
19+
icon: 'Timer',
20+
defaultPropertiesData: mockNodeDelay.data.properties,
21+
schema: {
22+
type: 'object',
23+
properties: {
24+
label: { type: 'string' },
25+
description: { type: 'string' },
26+
status: { type: 'string' },
27+
duration: { type: 'object' },
28+
errors: { type: 'array' },
29+
type: { type: 'string' },
30+
},
31+
required: ['label', 'description'],
32+
},
33+
uischema: { type: 'VerticalLayout', elements: [] },
34+
};
35+
36+
/** `mockNodeDelay` stripped of its required `description`, with stale (empty) errors. */
37+
function invalidNode(): WorkflowBuilderNode {
38+
const { description: _description, ...properties } = mockNodeDelay.data.properties;
39+
return {
40+
...mockNodeDelay,
41+
data: { ...mockNodeDelay.data, properties: { ...properties, errors: [] } },
42+
};
43+
}
44+
45+
describe('refreshNodesErrorsIfNeeded', () => {
46+
beforeAll(() => {
47+
setCustomPaletteNodes([delayDefinition as never]);
48+
});
49+
50+
afterAll(() => {
51+
setCustomPaletteNodes(null);
52+
});
53+
54+
beforeEach(() => {
55+
resetWorkflowStore();
56+
});
57+
58+
it('repairs errors that were skipped while the palette was unavailable', () => {
59+
// Simulates the race: the node was validated before the palette existed, so
60+
// its `errors` are empty despite being invalid.
61+
useStore.setState({ nodes: [invalidNode()] });
62+
63+
refreshNodesErrorsIfNeeded();
64+
65+
const errors = useStore.getState().nodes[0].data.properties.errors;
66+
expect(errors).toHaveLength(1);
67+
expect(errors).toEqual(expect.arrayContaining([expect.objectContaining({ keyword: 'required' })]));
68+
});
69+
70+
it('leaves the nodes array untouched when no errors change', () => {
71+
// `mockNodeDelay` is valid against the definition, so re-validation is a no-op.
72+
useStore.setState({ nodes: [mockNodeDelay] });
73+
const before = useStore.getState().nodes;
74+
75+
refreshNodesErrorsIfNeeded();
76+
77+
// Same reference: the deep-equal guard must skip the `setState` entirely.
78+
expect(useStore.getState().nodes).toBe(before);
79+
});
80+
81+
it('does nothing when the store has no nodes', () => {
82+
const before = useStore.getState().nodes;
83+
84+
refreshNodesErrorsIfNeeded();
85+
86+
expect(useStore.getState().nodes).toBe(before);
87+
});
88+
});

packages/sdk/src/store/slices/diagram-slice/actions.ts

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
// About actions: apps/demo/src/app/store/README.md
2+
import { isDeepEqual } from 'remeda';
3+
24
import {
35
migrateLegacyHandleIdsOnEdges,
46
migrateLegacyHandleIdsOnNodes,
@@ -8,7 +10,7 @@ import type { VariableDefinition } from '../../../features/variables/types';
810
import type { LayoutDirection } from '../../../node/common';
911
import type { WorkflowBuilderEdge, WorkflowBuilderNode } from '../../../node/node-data';
1012
import type { IntegrationDataFormat } from '../../../types/integration';
11-
import { getNodeWithErrors } from '../../../utils/validation/get-node-errors';
13+
import { getNodeWithErrors, getNodesWithErrors } from '../../../utils/validation/get-node-errors';
1214
import { useStore } from '../../store';
1315
import { skipDynamicValuesInEdges, skipDynamicValuesInNodes } from './utils/dynamic-values';
1416

@@ -122,6 +124,27 @@ export function getStoreSingleSelected() {
122124
return selectSingleSelectedElement(state);
123125
}
124126

127+
/**
128+
* Revalidates all nodes in the store and updates their JSON schema validation errors if needed.
129+
*
130+
* Compares current nodes with revalidated ones and only updates state when errors change,
131+
* avoiding unnecessary re-renders.
132+
*
133+
* @category Store
134+
*/
135+
export function refreshNodesErrorsIfNeeded() {
136+
const stateNodes = useStore.getState().nodes;
137+
const stateNodesWithRefreshedErrors = getNodesWithErrors(stateNodes);
138+
139+
if (isDeepEqual(stateNodes, stateNodesWithRefreshedErrors)) {
140+
return;
141+
}
142+
143+
useStore.setState({
144+
nodes: stateNodesWithRefreshedErrors,
145+
});
146+
}
147+
125148
export function saveVariableDefinition(definition: VariableDefinition) {
126149
useStore.setState((state) => ({
127150
globalVariables: {

packages/sdk/src/store/slices/palette/palette-slice.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
StatusType,
88
} from '../../../node/common';
99
import type { GetDiagramState, SetDiagramState } from '../../store';
10+
import { refreshNodesErrorsIfNeeded } from '../diagram-slice/actions';
1011

1112
export type PaletteState = {
1213
isSidebarExpanded: boolean;
@@ -40,6 +41,14 @@ export function usePaletteSlice(set: SetDiagramState, get: GetDiagramState): Pal
4041
data: getPaletteData(),
4142
fetchDataStatus: StatusType.Success,
4243
});
44+
45+
// Defer to the next macrotask: a concurrent node load (its own store
46+
// update) may still be pending, and nodes that arrived before the palette
47+
// were validated against an empty definition set, so their errors were
48+
// dropped. Re-validating after the queue drains repairs them (WB-340).
49+
// A macrotask (not `queueMicrotask`) is deliberate — we must yield past
50+
// those pending state updates, not just the current microtask queue.
51+
setTimeout(() => refreshNodesErrorsIfNeeded(), 0);
4352
},
4453
getNodeDefinition: (nodeType) => {
4554
const { data } = get();

packages/sdk/src/utils/validation/get-node-errors.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,3 +42,7 @@ export function getNodeWithErrors(node: WorkflowBuilderNode) {
4242
},
4343
};
4444
}
45+
46+
export function getNodesWithErrors(nodes: WorkflowBuilderNode[]): WorkflowBuilderNode[] {
47+
return nodes.map(getNodeWithErrors);
48+
}

0 commit comments

Comments
 (0)