refactor(editor): Add per-node-id projections and remove workflowState store (no-changelog) (#31001)

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Alex Grozav
2026-06-11 07:03:12 +00:00
committed by GitHub
co-authored by Claude Opus 4.7
parent d461471436
commit fad3ee23f6
35 changed files with 3423 additions and 5026 deletions
@@ -25,6 +25,9 @@ import {
import { useWorkflowExecutionStateStore } from '@/app/stores/workflowExecutionState.store';
import { WorkflowDocumentStoreKey } from '@/app/constants/injectionKeys';
const TEST_WORKFLOW_ID = 'test-workflow-id';
const TEST_DOCUMENT_ID = createWorkflowDocumentId(TEST_WORKFLOW_ID);
describe(useNodeDirtiness, () => {
let nodeTypeStore: ReturnType<typeof useNodeTypesStore>;
let workflowsStore: ReturnType<typeof useWorkflowsStore>;
@@ -39,8 +42,6 @@ describe(useNodeDirtiness, () => {
beforeEach(() => {
vi.useFakeTimers();
const TEST_WORKFLOW_ID = 'test-workflow-id';
const TestComponent = defineComponent({
setup() {
nodeTypeStore = useNodeTypesStore();
@@ -48,9 +49,7 @@ describe(useNodeDirtiness, () => {
workflowsStore.setWorkflowId(TEST_WORKFLOW_ID);
historyHelper = useHistoryHelper({} as RouteLocationNormalizedLoaded);
workflowDocumentStore = useWorkflowDocumentStore(
createWorkflowDocumentId(TEST_WORKFLOW_ID),
);
workflowDocumentStore = useWorkflowDocumentStore(TEST_DOCUMENT_ID);
provide(WorkflowDocumentStoreKey, shallowRef(workflowDocumentStore));
canvasOperations = useCanvasOperations();
@@ -77,7 +76,7 @@ describe(useNodeDirtiness, () => {
it('should be an empty object if no change has been made to the workflow', () => {
setupTestWorkflow('a🚨✅, b✅, c✅');
expect(useNodeDirtiness().dirtinessByName.value).toEqual({});
expect(useNodeDirtiness(TEST_DOCUMENT_ID).dirtinessByName.value).toEqual({});
});
describe('injecting a node', () => {
@@ -95,7 +94,7 @@ describe(useNodeDirtiness, () => {
await canvasOperations.addNodes([createTestNode({ name: 'c' })], { trackHistory: true });
expect(useNodeDirtiness().dirtinessByName.value).toEqual({
expect(useNodeDirtiness(TEST_DOCUMENT_ID).dirtinessByName.value).toEqual({
b: CanvasNodeDirtiness.INCOMING_CONNECTIONS_UPDATED,
});
});
@@ -111,7 +110,7 @@ describe(useNodeDirtiness, () => {
trackHistory: true,
}); // 'a' becomes new parent of 'c'
expect(useNodeDirtiness().dirtinessByName.value).toEqual({
expect(useNodeDirtiness(TEST_DOCUMENT_ID).dirtinessByName.value).toEqual({
c: CanvasNodeDirtiness.INCOMING_CONNECTIONS_UPDATED,
});
});
@@ -125,7 +124,7 @@ describe(useNodeDirtiness, () => {
trackHistory: true,
}); // 'b' has no parent node anymore
expect(useNodeDirtiness().dirtinessByName.value).toEqual({
expect(useNodeDirtiness(TEST_DOCUMENT_ID).dirtinessByName.value).toEqual({
b: CanvasNodeDirtiness.INCOMING_CONNECTIONS_UPDATED,
});
});
@@ -137,7 +136,7 @@ describe(useNodeDirtiness, () => {
canvasOperations.setNodeParameters(workflowDocumentStore.nodesByName.b.id, { foo: 1 });
expect(useNodeDirtiness().dirtinessByName.value).toEqual({
expect(useNodeDirtiness(TEST_DOCUMENT_ID).dirtinessByName.value).toEqual({
b: CanvasNodeDirtiness.PARAMETERS_UPDATED,
});
});
@@ -178,7 +177,7 @@ describe(useNodeDirtiness, () => {
}),
});
expect(useNodeDirtiness().dirtinessByName.value).toEqual({});
expect(useNodeDirtiness(TEST_DOCUMENT_ID).dirtinessByName.value).toEqual({});
});
it("should not update dirtiness if the node hasn't run yet", () => {
@@ -186,7 +185,7 @@ describe(useNodeDirtiness, () => {
canvasOperations.setNodeParameters(workflowDocumentStore.nodesByName.b.id, { foo: 1 });
expect(useNodeDirtiness().dirtinessByName.value).toEqual({});
expect(useNodeDirtiness(TEST_DOCUMENT_ID).dirtinessByName.value).toEqual({});
});
it('should not update dirtiness when the notes field is updated', () => {
@@ -198,7 +197,7 @@ describe(useNodeDirtiness, () => {
value: 'test',
});
expect(useNodeDirtiness().dirtinessByName.value).toEqual({});
expect(useNodeDirtiness(TEST_DOCUMENT_ID).dirtinessByName.value).toEqual({});
});
});
@@ -216,7 +215,7 @@ describe(useNodeDirtiness, () => {
{ trackHistory: true },
);
expect(useNodeDirtiness().dirtinessByName.value).toEqual({
expect(useNodeDirtiness(TEST_DOCUMENT_ID).dirtinessByName.value).toEqual({
c: CanvasNodeDirtiness.INCOMING_CONNECTIONS_UPDATED,
});
});
@@ -236,7 +235,7 @@ describe(useNodeDirtiness, () => {
{ trackHistory: true },
);
expect(useNodeDirtiness().dirtinessByName.value).toEqual({});
expect(useNodeDirtiness(TEST_DOCUMENT_ID).dirtinessByName.value).toEqual({});
});
});
@@ -248,7 +247,7 @@ describe(useNodeDirtiness, () => {
trackHistory: true,
});
expect(useNodeDirtiness().dirtinessByName.value).toEqual({
expect(useNodeDirtiness(TEST_DOCUMENT_ID).dirtinessByName.value).toEqual({
c: CanvasNodeDirtiness.INCOMING_CONNECTIONS_UPDATED,
});
});
@@ -260,7 +259,7 @@ describe(useNodeDirtiness, () => {
trackHistory: true,
});
expect(useNodeDirtiness().dirtinessByName.value).toEqual({});
expect(useNodeDirtiness(TEST_DOCUMENT_ID).dirtinessByName.value).toEqual({});
});
it('should restore original dirtiness after undoing a command', async () => {
@@ -270,13 +269,13 @@ describe(useNodeDirtiness, () => {
trackHistory: true,
});
expect(useNodeDirtiness().dirtinessByName.value).toEqual({
expect(useNodeDirtiness(TEST_DOCUMENT_ID).dirtinessByName.value).toEqual({
c: CanvasNodeDirtiness.INCOMING_CONNECTIONS_UPDATED,
});
await historyHelper.undo();
expect(useNodeDirtiness().dirtinessByName.value).toEqual({});
expect(useNodeDirtiness(TEST_DOCUMENT_ID).dirtinessByName.value).toEqual({});
});
});
@@ -292,7 +291,7 @@ describe(useNodeDirtiness, () => {
},
);
expect(useNodeDirtiness().dirtinessByName.value).toEqual({});
expect(useNodeDirtiness(TEST_DOCUMENT_ID).dirtinessByName.value).toEqual({});
});
it('should update dirtiness when pinned data is removed from a node with run data', async () => {
@@ -306,7 +305,7 @@ describe(useNodeDirtiness, () => {
},
);
expect(useNodeDirtiness().dirtinessByName.value).toEqual({
expect(useNodeDirtiness(TEST_DOCUMENT_ID).dirtinessByName.value).toEqual({
b: CanvasNodeDirtiness.PINNED_DATA_UPDATED,
});
});
@@ -320,7 +319,7 @@ describe(useNodeDirtiness, () => {
);
workflowDocumentStore.touchPinnedDataLastUpdatedAt('b');
expect(useNodeDirtiness().dirtinessByName.value).toEqual({
expect(useNodeDirtiness(TEST_DOCUMENT_ID).dirtinessByName.value).toEqual({
// 'd' is not marked as pinned-data-updated because it has no run data.
c: CanvasNodeDirtiness.PINNED_DATA_UPDATED,
e: CanvasNodeDirtiness.PINNED_DATA_UPDATED,
@@ -334,7 +333,7 @@ describe(useNodeDirtiness, () => {
canvasOperations.setNodeParameters(workflowDocumentStore.nodesByName.e.id, { foo: 1 });
expect(useNodeDirtiness().dirtinessByName.value).toEqual({
expect(useNodeDirtiness(TEST_DOCUMENT_ID).dirtinessByName.value).toEqual({
// 'e' itself is not marked as parameters-updated, because it has no run data.
f: CanvasNodeDirtiness.UPSTREAM_DIRTY,
b: CanvasNodeDirtiness.UPSTREAM_DIRTY,
@@ -348,7 +347,7 @@ describe(useNodeDirtiness, () => {
trackHistory: true,
});
expect(useNodeDirtiness().dirtinessByName.value).toEqual({
expect(useNodeDirtiness(TEST_DOCUMENT_ID).dirtinessByName.value).toEqual({
b: CanvasNodeDirtiness.INCOMING_CONNECTIONS_UPDATED,
});
});
@@ -360,7 +359,7 @@ describe(useNodeDirtiness, () => {
trackHistory: true,
});
expect(useNodeDirtiness().dirtinessByName.value).toEqual({
expect(useNodeDirtiness(TEST_DOCUMENT_ID).dirtinessByName.value).toEqual({
b: CanvasNodeDirtiness.INCOMING_CONNECTIONS_UPDATED,
});
});
@@ -372,7 +371,7 @@ describe(useNodeDirtiness, () => {
canvasOperations.setNodeParameters(workflowDocumentStore.nodesByName.e.id, { foo: 1 });
expect(useNodeDirtiness().dirtinessByName.value).toEqual({
expect(useNodeDirtiness(TEST_DOCUMENT_ID).dirtinessByName.value).toEqual({
c: CanvasNodeDirtiness.UPSTREAM_DIRTY,
e: CanvasNodeDirtiness.PARAMETERS_UPDATED,
});
@@ -383,7 +382,7 @@ describe(useNodeDirtiness, () => {
canvasOperations.setNodeParameters(workflowDocumentStore.nodesByName.c.id, { foo: 1 });
expect(useNodeDirtiness().dirtinessByName.value).toEqual({
expect(useNodeDirtiness(TEST_DOCUMENT_ID).dirtinessByName.value).toEqual({
c: CanvasNodeDirtiness.PARAMETERS_UPDATED,
});
});
@@ -399,13 +398,13 @@ describe(useNodeDirtiness, () => {
trackHistory: true,
}); // 'a' becomes new parent of 'c'
expect(useNodeDirtiness().dirtinessByName.value).toEqual({
expect(useNodeDirtiness(TEST_DOCUMENT_ID).dirtinessByName.value).toEqual({
c: CanvasNodeDirtiness.INCOMING_CONNECTIONS_UPDATED,
});
await canvasOperations.renameNode('c', 'd', { trackHistory: true });
expect(useNodeDirtiness().dirtinessByName.value).toEqual({
expect(useNodeDirtiness(TEST_DOCUMENT_ID).dirtinessByName.value).toEqual({
d: CanvasNodeDirtiness.INCOMING_CONNECTIONS_UPDATED,
});
});
@@ -9,14 +9,17 @@ import {
} from '@/app/models/history';
import { useHistoryStore } from '@/app/stores/history.store';
import { useWorkflowsStore } from '@/app/stores/workflows.store';
import { injectWorkflowDocumentStore } from '@/app/stores/workflowDocument.store';
import {
useWorkflowDocumentStore,
type WorkflowDocumentId,
} from '@/app/stores/workflowDocument.store';
import {
CanvasNodeDirtiness,
type CanvasNodeDirtinessType,
} from '@/features/workflows/canvas/canvas.types';
import type { INodeConnections, NodeConnectionType } from 'n8n-workflow';
import { NodeConnectionTypes } from 'n8n-workflow';
import { computed } from 'vue';
import { computed, toValue, type MaybeRefOrGetter } from 'vue';
/**
* Does the command make the given node dirty?
@@ -116,13 +119,29 @@ function findLoop(
}
/**
* Determines the subgraph that is affected by changes made after the last (partial) execution
* Determines the subgraph that is affected by changes made after the last (partial) execution.
*
* Takes the workflow document id (a value, ref, or getter) explicitly so it
* can resolve the document store directly via `useWorkflowDocumentStore(id)`
* instead of relying on `inject()`, which only resolves inside the
* `WorkflowLayout` tree. This makes the composable safe to call from
* off-layout contexts (e.g. the workflow-diff modal's `watchEffect`) without
* the try/catch fallback `useWorkflowDocumentRenderData` previously needed.
*
* Passing a reactive id lets callers bound to a swappable injected document
* store (e.g. push handlers that replace the current document while the host
* component stays mounted) re-resolve the store reactively. The internal
* computeds are built once and recompute against the active document, so the
* dirtiness tracks the live document without callers having to recreate the
* composable inside their own `computed`.
*/
export function useNodeDirtiness() {
export function useNodeDirtiness(workflowDocumentId: MaybeRefOrGetter<WorkflowDocumentId>) {
const historyStore = useHistoryStore();
const workflowsStore = useWorkflowsStore();
const workflowDocumentStore = injectWorkflowDocumentStore();
const workflowDocumentStore = computed(() =>
useWorkflowDocumentStore(toValue(workflowDocumentId)),
);
function getIncomingConnections(nodeName: string): INodeConnections {
return workflowDocumentStore.value.incomingConnectionsByNodeName(nodeName);
@@ -1,10 +1,6 @@
import { ref } from 'vue';
import { useHistoryStore } from '@/app/stores/history.store';
import {
CUSTOM_API_CALL_KEY,
EnterpriseEditionFeature,
PLACEHOLDER_FILLED_AT_EXECUTION_TIME,
} from '@/app/constants';
import { CUSTOM_API_CALL_KEY, EnterpriseEditionFeature } from '@/app/constants';
import {
NodeHelpers,
@@ -25,7 +21,6 @@ import type {
IRunData,
IBinaryKeyData,
INode,
INodePropertyOptions,
INodeCredentialsDetails,
INodeParameters,
INodeTypeNameVersion,
@@ -43,7 +38,8 @@ import type { WorkflowObjectAccessors } from '@/app/types/workflow';
import { isString } from '@/app/utils/typeGuards';
import { isObject } from '@/app/utils/objectUtils';
import { hasProxyAuth } from '@/app/utils/nodeTypesUtils';
import { getNodeSubtitle, hasProxyAuth } from '@/app/utils/nodeTypesUtils';
import { assignNodeId } from '@/app/utils/nodes/nodeTransforms';
import { useWorkflowsStore } from '@/app/stores/workflows.store';
import { useNodeTypesStore } from '@/app/stores/nodeTypes.store';
import { useCredentialsStore } from '@/features/credentials/credentials.store';
@@ -798,63 +794,6 @@ export function useNodeHelpers() {
}
}
function getNodeSubtitle(
data: INode,
nodeType: INodeTypeDescription,
workflow: WorkflowObjectAccessors,
): string | undefined {
if (!data) {
return undefined;
}
if (data.notesInFlow) {
return data.notes;
}
if (nodeType?.subtitle !== undefined) {
try {
return workflow.expression.getSimpleParameterValue(
data,
nodeType.subtitle,
'internal',
{},
undefined,
PLACEHOLDER_FILLED_AT_EXECUTION_TIME,
) as string | undefined;
} catch (e) {
return undefined;
}
}
if (data.parameters.operation !== undefined) {
const operation = data.parameters.operation as string;
if (nodeType === null) {
return operation;
}
const operationData = nodeType.properties.find((property: INodeProperties) => {
return property.name === 'operation';
});
if (operationData === undefined) {
return operation;
}
if (operationData.options === undefined) {
return operation;
}
const optionData = operationData.options.find((option) => {
return (option as INodePropertyOptions).value === data.parameters.operation;
});
if (optionData === undefined) {
return operation;
}
return optionData.name;
}
return undefined;
}
function matchCredentials(node: INodeUi) {
if (!node.credentials) {
return;
@@ -935,12 +874,6 @@ export function useNodeHelpers() {
}
}
function assignNodeId(node: INodeUi) {
const id = window.crypto.randomUUID();
node.id = id;
return id;
}
function assignWebhookId(node: INodeUi) {
const id = window.crypto.randomUUID();
node.webhookId = id;
@@ -103,7 +103,7 @@ export function useRunWorkflow(useRunWorkflowOpts: {
router: useRunWorkflowOpts.router,
});
const executionsStore = useExecutionsStore();
const { dirtinessByName } = useNodeDirtiness();
const { dirtinessByName } = useNodeDirtiness(() => workflowDocumentStore.value.documentId);
const { startChat } = useCanvasOperations();
const chatStore = useChat();
@@ -70,8 +70,10 @@ vi.mock('@/app/composables/useCanvasOperations', () => ({
useCanvasOperations: vi.fn(() => mockCanvasOperations),
}));
// Mock nodeTypesUtils
vi.mock('@/app/utils/nodeTypesUtils', () => ({
// Mock nodeTypesUtils — keep real exports (e.g. getNodeSubtitle, used by
// useNodeHelpers) and override only the auth helpers under test.
vi.mock('@/app/utils/nodeTypesUtils', async (importOriginal) => ({
...(await importOriginal<typeof import('@/app/utils/nodeTypesUtils')>()),
getMainAuthField: vi.fn(),
getAuthTypeForNodeCredential: vi.fn(),
}));
@@ -1,12 +1,24 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { setActivePinia, createPinia, getActivePinia } from 'pinia';
import { nextTick } from 'vue';
import { NodeConnectionTypes, SEND_AND_WAIT_OPERATION, WAIT_INDEFINITELY } from 'n8n-workflow';
import type { INode } from 'n8n-workflow';
import {
useExecutionDataStore,
createExecutionDataId,
getExecutionDataStoreId,
disposeExecutionDataStore,
} from '@/app/stores/executionData.store';
import { createTestWorkflowExecutionResponse } from '@/__tests__/mocks';
import {
CANVAS_EXECUTION_DATA_THROTTLE_DURATION,
FORM_NODE_TYPE,
WAIT_NODE_TYPE,
} from '@/app/constants';
import {
createTestNode,
createTestWorkflow,
createTestWorkflowExecutionResponse,
} from '@/__tests__/mocks';
import type { IExecutionResponse } from '@/features/execution/executions/executions.types';
function createTestExecution(overrides: Partial<IExecutionResponse> = {}): IExecutionResponse {
@@ -774,6 +786,447 @@ describe('executionData.store', () => {
expect(store.executionIssuesByNodeName.get('NodeA')?.value).toBe(before);
});
});
// -------------------------------------------------------------------------
// Per-node-id projections (moved here from useCanvasMapping).
// -------------------------------------------------------------------------
/**
* Builds an execution whose embedded `workflowData.nodes` snapshot provides
* the id↔name mapping the by-id projections key off, with `runData` keyed by
* node name as the server produces it.
*/
function setExecutionWithSnapshot(
store: ReturnType<typeof useExecutionDataStore>,
{
nodes,
runData = {},
lastNodeExecuted = '',
...executionOverrides
}: {
nodes: INode[];
runData?: Record<string, Array<Record<string, unknown>>>;
lastNodeExecuted?: string;
} & Partial<IExecutionResponse>,
) {
store.setExecution(
createTestExecution({
workflowData: createTestWorkflow({ nodes }),
data: {
resultData: { runData, lastNodeExecuted },
} as never,
...executionOverrides,
}),
);
}
describe('executionStatusByNodeId', () => {
const node = createTestNode({ id: 'node-1', name: 'Node 1' });
it('returns the last execution status when not canceled', () => {
const store = useExecutionDataStore(createExecutionDataId('exec-1'));
setExecutionWithSnapshot(store, {
nodes: [node],
runData: {
'Node 1': [{ executionStatus: 'success' }, { executionStatus: 'error' }],
},
});
expect(store.executionStatusByNodeId.get('node-1')?.value).toBe('error');
});
it('returns the second-to-last status when the last task is canceled and multiple tasks exist', () => {
const store = useExecutionDataStore(createExecutionDataId('exec-1'));
setExecutionWithSnapshot(store, {
nodes: [node],
runData: {
'Node 1': [{ executionStatus: 'success' }, { executionStatus: 'canceled' }],
},
});
expect(store.executionStatusByNodeId.get('node-1')?.value).toBe('success');
});
it('returns canceled when the only task is canceled', () => {
const store = useExecutionDataStore(createExecutionDataId('exec-1'));
setExecutionWithSnapshot(store, {
nodes: [node],
runData: {
'Node 1': [{ executionStatus: 'canceled' }],
},
});
expect(store.executionStatusByNodeId.get('node-1')?.value).toBe('canceled');
});
it('returns new when the node has no tasks', () => {
const store = useExecutionDataStore(createExecutionDataId('exec-1'));
setExecutionWithSnapshot(store, { nodes: [node], runData: {} });
expect(store.executionStatusByNodeId.get('node-1')?.value).toBe('new');
});
it('has no entry for nodes absent from the workflow snapshot', () => {
const store = useExecutionDataStore(createExecutionDataId('exec-1'));
setExecutionWithSnapshot(store, { nodes: [node] });
expect(store.executionStatusByNodeId.has('unknown-node')).toBe(false);
});
});
describe('executionWaitingByNodeId', () => {
const waitTill = new Date('2025-05-05T12:00:00.000Z');
function setWaitingExecution(
store: ReturnType<typeof useExecutionDataStore>,
node: INode,
overrides: Partial<IExecutionResponse> = {},
) {
setExecutionWithSnapshot(store, {
nodes: [node],
lastNodeExecuted: node.name,
finished: false,
...({ waitTill } as Partial<IExecutionResponse>),
...overrides,
});
}
it('returns the webhook message for a wait node resuming on webhook', () => {
const store = useExecutionDataStore(createExecutionDataId('exec-1'));
const node = createTestNode({
id: 'wait-1',
name: 'Wait',
type: WAIT_NODE_TYPE,
parameters: { resume: 'webhook' },
});
setWaitingExecution(store, node);
expect(store.executionWaitingByNodeId.get('wait-1')?.value).toBe(
'The node is waiting for an incoming webhook call',
);
});
it('returns the form message for a wait node resuming on form submission', () => {
const store = useExecutionDataStore(createExecutionDataId('exec-1'));
const node = createTestNode({
id: 'wait-1',
name: 'Wait',
type: WAIT_NODE_TYPE,
parameters: { resume: 'form' },
});
setWaitingExecution(store, node);
expect(store.executionWaitingByNodeId.get('wait-1')?.value).toBe(
'The node is waiting for a form submission',
);
});
it('returns the user-input message for sendAndWait operations', () => {
const store = useExecutionDataStore(createExecutionDataId('exec-1'));
const node = createTestNode({
id: 'send-1',
name: 'Send',
parameters: { operation: SEND_AND_WAIT_OPERATION },
});
setWaitingExecution(store, node);
expect(store.executionWaitingByNodeId.get('send-1')?.value).toBe(
'The node is waiting for user input',
);
});
it('returns the form message for form nodes', () => {
const store = useExecutionDataStore(createExecutionDataId('exec-1'));
const node = createTestNode({ id: 'form-1', name: 'Form', type: FORM_NODE_TYPE });
setWaitingExecution(store, node);
expect(store.executionWaitingByNodeId.get('form-1')?.value).toBe(
'The node is waiting for a form submission',
);
});
it('returns the indefinite message when waiting indefinitely', () => {
const store = useExecutionDataStore(createExecutionDataId('exec-1'));
const node = createTestNode({ id: 'wait-1', name: 'Wait', type: WAIT_NODE_TYPE });
setWaitingExecution(store, node, {
...({ waitTill: WAIT_INDEFINITELY } as Partial<IExecutionResponse>),
});
expect(store.executionWaitingByNodeId.get('wait-1')?.value).toBe(
'The node is waiting for an incoming webhook call (indefinitely)',
);
});
it('returns a dated message for a timed wait', () => {
const store = useExecutionDataStore(createExecutionDataId('exec-1'));
const node = createTestNode({ id: 'wait-1', name: 'Wait', type: WAIT_NODE_TYPE });
setWaitingExecution(store, node);
expect(store.executionWaitingByNodeId.get('wait-1')?.value).toMatch(
/^Node is waiting until /,
);
});
it('returns undefined when the execution is finished', () => {
const store = useExecutionDataStore(createExecutionDataId('exec-1'));
const node = createTestNode({ id: 'wait-1', name: 'Wait', type: WAIT_NODE_TYPE });
setWaitingExecution(store, node, { finished: true });
expect(store.executionWaitingByNodeId.get('wait-1')?.value).toBeUndefined();
});
it('returns undefined when the node is not the last node executed', () => {
const store = useExecutionDataStore(createExecutionDataId('exec-1'));
const node = createTestNode({ id: 'wait-1', name: 'Wait', type: WAIT_NODE_TYPE });
setWaitingExecution(store, node, { lastNodeExecuted: 'Another Node' } as never);
expect(store.executionWaitingByNodeId.get('wait-1')?.value).toBeUndefined();
});
it('returns undefined when the execution has no waitTill', () => {
const store = useExecutionDataStore(createExecutionDataId('exec-1'));
const node = createTestNode({ id: 'wait-1', name: 'Wait', type: WAIT_NODE_TYPE });
setExecutionWithSnapshot(store, {
nodes: [node],
lastNodeExecuted: node.name,
finished: false,
});
expect(store.executionWaitingByNodeId.get('wait-1')?.value).toBeUndefined();
});
});
describe('executionRunDataOutputMapByNodeId', () => {
// The rebuild runs behind a throttledWatch whose leading slot is consumed
// by the `immediate: true` run at store creation, so a setExecution right
// after creation lands on the trailing edge. Fake timers let tests skip
// the throttle window deterministically.
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
async function flushOutputMapRebuild() {
// Let the (pre-flush) watcher run, then fire the trailing throttle slot.
await nextTick();
vi.advanceTimersByTime(CANVAS_EXECUTION_DATA_THROTTLE_DURATION);
}
function createTask(
items: number,
overrides: Record<string, unknown> = {},
connectionType: string = NodeConnectionTypes.Main,
) {
return {
startTime: 0,
executionTime: 0,
executionIndex: 0,
source: [],
data: {
[connectionType]: [Array.from({ length: items }, () => ({ json: {} }))],
},
...overrides,
};
}
it('is empty when there is no run data', () => {
const store = useExecutionDataStore(createExecutionDataId('exec-1'));
expect(store.executionRunDataOutputMapByNodeId.size).toBe(0);
});
it('calculates iterations and total for a single node', async () => {
const store = useExecutionDataStore(createExecutionDataId('exec-1'));
const node = createTestNode({ id: 'node-1', name: 'Node 1' });
setExecutionWithSnapshot(store, {
nodes: [node],
runData: { 'Node 1': [createTask(2)] },
});
await flushOutputMapRebuild();
expect(store.executionRunDataOutputMapByNodeId.get('node-1')).toEqual({
[NodeConnectionTypes.Main]: { 0: { iterations: 1, total: 2 } },
});
});
it('aggregates multiple iterations', async () => {
const store = useExecutionDataStore(createExecutionDataId('exec-1'));
const node = createTestNode({ id: 'node-1', name: 'Node 1' });
setExecutionWithSnapshot(store, {
nodes: [node],
runData: {
'Node 1': [
createTask(1),
createTask(3, { executionIndex: 1 }),
createTask(2, { executionIndex: 2 }),
],
},
});
await flushOutputMapRebuild();
expect(store.executionRunDataOutputMapByNodeId.get('node-1')).toEqual({
[NodeConnectionTypes.Main]: { 0: { iterations: 3, total: 6 } },
});
});
it('does not count canceled iterations but still counts their data', async () => {
const store = useExecutionDataStore(createExecutionDataId('exec-1'));
const node = createTestNode({ id: 'node-1', name: 'Node 1' });
setExecutionWithSnapshot(store, {
nodes: [node],
runData: {
'Node 1': [
createTask(2, { executionStatus: 'success' }),
createTask(3, { executionStatus: 'canceled', executionIndex: 1 }),
createTask(1, { executionStatus: 'success', executionIndex: 2 }),
],
},
});
await flushOutputMapRebuild();
expect(store.executionRunDataOutputMapByNodeId.get('node-1')).toEqual({
[NodeConnectionTypes.Main]: { 0: { iterations: 2, total: 6 } },
});
});
it('reports zero iterations when all iterations are canceled', async () => {
const store = useExecutionDataStore(createExecutionDataId('exec-1'));
const node = createTestNode({ id: 'node-1', name: 'Node 1' });
setExecutionWithSnapshot(store, {
nodes: [node],
runData: {
'Node 1': [
createTask(2, { executionStatus: 'canceled' }),
createTask(1, { executionStatus: 'canceled', executionIndex: 1 }),
],
},
});
await flushOutputMapRebuild();
expect(store.executionRunDataOutputMapByNodeId.get('node-1')).toEqual({
[NodeConnectionTypes.Main]: { 0: { iterations: 0, total: 3 } },
});
});
it('populates byTarget per-target counts for non-main connections', async () => {
const store = useExecutionDataStore(createExecutionDataId('exec-1'));
const modelNode = createTestNode({ id: 'model-1', name: 'OpenAI Chat Model' });
const agent1Node = createTestNode({ id: 'agent-1', name: 'AI Agent 1' });
const agent2Node = createTestNode({ id: 'agent-2', name: 'AI Agent 2' });
setExecutionWithSnapshot(store, {
nodes: [modelNode, agent1Node, agent2Node],
runData: {
'OpenAI Chat Model': [
createTask(
2,
{ executionStatus: 'success', source: [{ previousNode: 'AI Agent 1' }] },
NodeConnectionTypes.AiLanguageModel,
),
createTask(
1,
{
executionStatus: 'success',
executionIndex: 1,
source: [{ previousNode: 'AI Agent 2' }],
},
NodeConnectionTypes.AiLanguageModel,
),
createTask(
3,
{
executionStatus: 'success',
executionIndex: 2,
source: [{ previousNode: 'AI Agent 1' }],
},
NodeConnectionTypes.AiLanguageModel,
),
],
},
});
await flushOutputMapRebuild();
const outputData =
store.executionRunDataOutputMapByNodeId.get('model-1')?.[
NodeConnectionTypes.AiLanguageModel
]?.[0];
expect(outputData?.iterations).toBe(3);
expect(outputData?.total).toBe(6);
// Agent 1 was called twice with 2 + 3 = 5 items; agent 2 once with 1.
expect(outputData?.byTarget?.['agent-1']).toEqual({ iterations: 2, total: 5 });
expect(outputData?.byTarget?.['agent-2']).toEqual({ iterations: 1, total: 1 });
});
it('counts items inside the response field for non-main connections', async () => {
const store = useExecutionDataStore(createExecutionDataId('exec-1'));
const embeddingNode = createTestNode({ id: 'embed-1', name: 'Embeddings OpenAI' });
const vectorStoreNode = createTestNode({ id: 'vector-1', name: 'Vector Store' });
setExecutionWithSnapshot(store, {
nodes: [embeddingNode, vectorStoreNode],
runData: {
'Embeddings OpenAI': [
{
startTime: 0,
executionTime: 0,
executionIndex: 0,
executionStatus: 'success',
source: [{ previousNode: 'Vector Store' }],
data: {
[NodeConnectionTypes.AiEmbedding]: [
[
{
json: {
response: [
{ embedding: [0.1, 0.2] },
{ embedding: [0.3, 0.4] },
{ embedding: [0.5, 0.6] },
],
},
},
],
],
},
},
],
},
});
await flushOutputMapRebuild();
const outputData =
store.executionRunDataOutputMapByNodeId.get('embed-1')?.[
NodeConnectionTypes.AiEmbedding
]?.[0];
// Counts the 3 items inside `response`, not the 1 wrapper item — also
// for the per-target counts.
expect(outputData?.iterations).toBe(1);
expect(outputData?.total).toBe(3);
expect(outputData?.byTarget?.['vector-1']).toEqual({ iterations: 1, total: 3 });
});
});
});
async function flushPromises() {
@@ -2,18 +2,35 @@ import { defineStore, getActivePinia } from 'pinia';
import { STORES } from '@n8n/stores';
import { computed, effectScope, inject, readonly, ref, shallowReactive } from 'vue';
import type { ComputedRef } from 'vue';
import { createEventHook } from '@vueuse/core';
import { createEventHook, throttledWatch } from '@vueuse/core';
import { structuralComputed } from '@n8n/composables/structuralComputed';
import isEqual from 'lodash/isEqual';
import type { ExecutionStatus, IRunData, IRunExecutionData, ITaskStartedData } from 'n8n-workflow';
import { useI18n } from '@n8n/i18n';
import {
NodeConnectionTypes,
SEND_AND_WAIT_OPERATION,
WAIT_INDEFINITELY,
type ExecutionStatus,
type INode,
type IRunData,
type IRunExecutionData,
type ITaskData,
type ITaskStartedData,
} from 'n8n-workflow';
import type { PushPayload } from '@n8n/api-types';
import type { NodeExecuteBefore } from '@n8n/api-types/push/execution';
import type { IExecutionResponse } from '@/features/execution/executions/executions.types';
import { ExecutionDataStoreKey } from '@/app/constants/injectionKeys';
import {
CANVAS_EXECUTION_DATA_THROTTLE_DURATION,
FORM_NODE_TYPE,
WAIT_NODE_TYPE,
} from '@/app/constants';
import { getPairedItemsMapping } from '@/app/utils/pairedItemUtils';
import { sanitizeHtml } from '@/app/utils/htmlUtils';
import { CHANGE_ACTION } from './workflowDocument/types';
import type { ChangeAction, ChangeEvent } from './workflowDocument/types';
import type { ExecutionOutputMap } from '@/app/types/executionData';
export type ExecutionDataId = string;
@@ -57,6 +74,7 @@ export function getExecutionDataStoreId(id: ExecutionDataId) {
*/
export function useExecutionDataStore(id: ExecutionDataId) {
return defineStore(getExecutionDataStoreId(id), () => {
const i18n = useI18n();
const execution = ref<IExecutionResponse | null>(null);
const executionResultDataLastUpdate = ref<number>();
const executionStartedData = ref<ExecutionStartedDataValue>();
@@ -137,6 +155,264 @@ export function useExecutionDataStore(id: ExecutionDataId) {
void onExecutionDataChange.on(reconcileExecutionIssuesFromRunData);
reconcileExecutionIssuesFromRunData();
// ---------------------------------------------------------------------
// Per-node-id projections of execution state.
//
// The store's primary key is node *name* (runData is keyed by name),
// but consumers (canvas, NDV) need by-id lookup. The execution payload
// embeds a `workflowData.nodes` snapshot, which is the authoritative
// source for the name↔id mapping at execution time. `renameExecutionDataNode`
// updates that snapshot in place, so renames propagate naturally.
//
// Each map uses the same shallowReactive<Map<id, ComputedRef<T>>>
// pattern as `executionIssuesByNodeName`: per-entry structuralComputed,
// reconciled against `workflowData.nodes` via `onExecutionDataChange`.
// ---------------------------------------------------------------------
// Lazy index of the embedded workflow snapshot, keyed by node id.
// Materializes once per execution.value change and is shared across all
// per-entry projections below — collapses id → node resolution from
// O(N) per lookup to O(1).
const executionNodeById = computed(() => {
const map = new Map<string, INode>();
const nodes = execution.value?.workflowData?.nodes;
if (nodes) for (const n of nodes) map.set(n.id, n);
return map;
});
function getExecutionNodeById(nodeId: string): INode | undefined {
return executionNodeById.value.get(nodeId);
}
function getExecutionNodeIds(): string[] {
return Array.from(executionNodeById.value.keys());
}
const byIdScopes = new Map<string, () => void>();
const executionStatusByNodeId = shallowReactive(
new Map<string, ComputedRef<ExecutionStatus>>(),
);
const executionRunDataByNodeId = shallowReactive(
new Map<string, ComputedRef<ITaskData[] | null>>(),
);
const executionWaitingByNodeId = shallowReactive(
new Map<string, ComputedRef<string | undefined>>(),
);
function computeExecutionStatus(nodeId: string): ExecutionStatus {
const node = getExecutionNodeById(nodeId);
if (!node) return 'new';
const tasks = executionRunData.value?.[node.name] ?? [];
// A canceled top-of-stack tends to mask the prior "real" status — peek
// one task back so the UI shows the meaningful state.
let status = tasks.at(-1)?.executionStatus;
if (tasks.length > 1 && status === 'canceled') {
status = tasks.at(-2)?.executionStatus;
}
return status ?? 'new';
}
function computeExecutionRunData(nodeId: string): ITaskData[] | null {
const node = getExecutionNodeById(nodeId);
if (!node) return null;
const tasks = executionRunData.value?.[node.name];
return tasks ?? null;
}
function computeExecutionWaiting(nodeId: string): string | undefined {
const node = getExecutionNodeById(nodeId);
if (!node) return undefined;
const ex = execution.value;
if (!ex || ex.finished) return undefined;
// `waitTill` exists at runtime on resumed executions but isn't declared
// on IExecutionResponse — narrow via the property check.
const waitTill = (ex as IExecutionResponse & { waitTill?: Date | string }).waitTill;
if (!waitTill) return undefined;
const lastNodeExecuted = ex.data?.resultData?.lastNodeExecuted;
if (node.name !== lastNodeExecuted) return undefined;
const resume = node.parameters?.resume as string | undefined;
if (node.type === WAIT_NODE_TYPE && (resume === 'webhook' || resume === 'form')) {
return resume === 'webhook'
? i18n.baseText('node.theNodeIsWaitingWebhookCall')
: i18n.baseText('node.theNodeIsWaitingFormCall');
}
if (node.parameters?.operation === SEND_AND_WAIT_OPERATION) {
return i18n.baseText('node.theNodeIsWaitingUserInput');
}
if (node.type === FORM_NODE_TYPE) {
return i18n.baseText('node.theNodeIsWaitingFormCall');
}
const waitDate = new Date(waitTill);
if (waitDate.getTime() === WAIT_INDEFINITELY.getTime()) {
return i18n.baseText('node.theNodeIsWaitingIndefinitelyForAnIncomingWebhookCall');
}
return i18n.baseText('node.nodeIsWaitingTill', {
interpolate: {
date: waitDate.toLocaleDateString(),
time: waitDate.toLocaleTimeString(),
},
});
}
function applyAddByIdEntry(nodeId: string) {
if (byIdScopes.has(nodeId)) return;
const scope = effectScope();
scope.run(() => {
executionStatusByNodeId.set(
nodeId,
structuralComputed(() => computeExecutionStatus(nodeId)),
);
// Plain `computed` (Object.is) rather than `structuralComputed(..., isEqual)`:
// per-task data can be megabytes for nodes with large outputs, and
// every push replaces the runData array reference with new content
// — so an isEqual gate would deep-compare megabytes and never
// short-circuit. Reference identity is the right gate here.
executionRunDataByNodeId.set(
nodeId,
computed(() => computeExecutionRunData(nodeId)),
);
executionWaitingByNodeId.set(
nodeId,
structuralComputed(() => computeExecutionWaiting(nodeId)),
);
});
byIdScopes.set(nodeId, () => scope.stop());
}
function applyRemoveByIdEntry(nodeId: string) {
byIdScopes.get(nodeId)?.();
byIdScopes.delete(nodeId);
executionStatusByNodeId.delete(nodeId);
executionRunDataByNodeId.delete(nodeId);
executionWaitingByNodeId.delete(nodeId);
}
function applyReconcileByIdEntries(nodeIds: string[]) {
const next = new Set(nodeIds);
for (const old of byIdScopes.keys()) {
if (!next.has(old)) applyRemoveByIdEntry(old);
}
for (const id of nodeIds) applyAddByIdEntry(id);
}
function reconcileByIdEntries() {
applyReconcileByIdEntries(getExecutionNodeIds());
}
void onExecutionDataChange.on(reconcileByIdEntries);
reconcileByIdEntries();
// Throttled per-node-id aggregation of run data into `ExecutionOutputMap`.
// Aggregation cost scales with task list length and item counts; rebuilding
// on every push during a fast execution would be expensive. The throttle
// batches updates; consumers (label rendering, item counters) only invalidate
// when their specific id slot changes, gated by `isEqual` per node.
const executionRunDataOutputMapByNodeId = shallowReactive(
new Map<string, ExecutionOutputMap>(),
);
function rebuildExecutionRunDataOutputMap() {
const runData = executionRunData.value;
const snapshotNodes = execution.value?.workflowData?.nodes ?? [];
if (!runData) {
for (const k of Array.from(executionRunDataOutputMapByNodeId.keys())) {
executionRunDataOutputMapByNodeId.delete(k);
}
return;
}
const nameToId = new Map(snapshotNodes.map((n) => [n.name, n.id]));
const next = new Map<string, ExecutionOutputMap>();
for (const nodeName of Object.keys(runData)) {
const nodeId = nameToId.get(nodeName);
if (!nodeId) continue;
const agg: ExecutionOutputMap = {};
const taskList = runData[nodeName] ?? [];
for (const runIteration of taskList) {
const data = runIteration.data ?? {};
for (const connectionType of Object.keys(data)) {
const connectionTypeData = data[connectionType] ?? {};
agg[connectionType] = agg[connectionType] ?? {};
for (const outputIndex of Object.keys(connectionTypeData)) {
const parsedOutputIndex = parseInt(outputIndex, 10);
const items = connectionTypeData[parsedOutputIndex] ?? [];
agg[connectionType][outputIndex] = agg[connectionType][outputIndex] ?? {
total: 0,
iterations: 0,
...(connectionType !== NodeConnectionTypes.Main ? { byTarget: {} } : {}),
};
// Non-main connections (AI tools/memory/embeddings) wrap items
// in a `response` array; the apparent itemCount is the length
// of that array, not the outer items list. Check only the first
// item assuming uniform structure.
let itemCount = items.length;
if (connectionType !== NodeConnectionTypes.Main && items.length > 0) {
const first = items[0];
if (
first?.json &&
typeof first.json === 'object' &&
'response' in first.json &&
Array.isArray((first.json as { response: unknown[] }).response)
) {
itemCount = (first.json as { response: unknown[] }).response.length;
}
}
if (runIteration.executionStatus !== 'canceled') {
agg[connectionType][outputIndex].iterations += 1;
}
agg[connectionType][outputIndex].total += itemCount;
if (connectionType !== NodeConnectionTypes.Main) {
const callingNodeName = runIteration.source?.[0]?.previousNode;
if (callingNodeName) {
const targetId = nameToId.get(callingNodeName);
if (targetId) {
const entry = agg[connectionType][outputIndex];
entry.byTarget = entry.byTarget ?? {};
entry.byTarget[targetId] = entry.byTarget[targetId] ?? {
total: 0,
iterations: 0,
};
if (runIteration.executionStatus !== 'canceled') {
entry.byTarget[targetId].iterations += 1;
}
entry.byTarget[targetId].total += itemCount;
}
}
}
}
}
}
next.set(nodeId, agg);
}
// Reconcile to the shallowReactive map for per-id reactivity. isEqual
// skips structurally-identical slots so downstream consumers don't
// invalidate when their node's aggregation hasn't actually changed.
for (const oldId of Array.from(executionRunDataOutputMapByNodeId.keys())) {
if (!next.has(oldId)) executionRunDataOutputMapByNodeId.delete(oldId);
}
for (const [nodeId, value] of next.entries()) {
const existing = executionRunDataOutputMapByNodeId.get(nodeId);
if (!existing || !isEqual(existing, value)) {
executionRunDataOutputMapByNodeId.set(nodeId, value);
}
}
}
throttledWatch(executionResultDataLastUpdate, rebuildExecutionRunDataOutputMap, {
throttle: CANVAS_EXECUTION_DATA_THROTTLE_DURATION,
immediate: true,
});
function fireChange(action: ChangeAction, nodeName?: string) {
void onExecutionDataChange.trigger({
action,
@@ -415,6 +691,10 @@ export function useExecutionDataStore(id: ExecutionDataId) {
executionRunData,
executedNode,
executionIssuesByNodeName,
executionStatusByNodeId,
executionRunDataByNodeId,
executionWaitingByNodeId,
executionRunDataOutputMapByNodeId,
executionStartedData: readonly(executionStartedData),
executionPairedItemMappings: readonly(executionPairedItemMappings),
getExecutionRunDataByNodeName,
@@ -34,12 +34,11 @@ import { useWorkflowDocumentNodeGroups } from './workflowDocument/useWorkflowDoc
import { CHANGE_ACTION } from './workflowDocument/types';
import { useUIStore } from '@/app/stores/ui.store';
import { useNodeTypesStore } from '@/app/stores/nodeTypes.store';
import { useNodeHelpers } from '@/app/composables/useNodeHelpers';
import { serializeNode } from '@/app/utils/nodes/nodeTransforms';
import { assignNodeId, serializeNode } from '@/app/utils/nodes/nodeTransforms';
import type { WorkflowObjectAccessors } from '../types';
import type { IWorkflowDb } from '@/Interface';
import type { INode, ProjectSharingData } from 'n8n-workflow';
import { deepCopy } from 'n8n-workflow';
import { deepCopy, nodeIssuesToString } from 'n8n-workflow';
import type { WorkflowData } from '@n8n/rest-api-client/api/workflows';
import type { Scope } from '@n8n/permissions';
import type { IUsedCredential } from '@/features/credentials/credentials.types';
@@ -141,7 +140,6 @@ export function useWorkflowDocumentStore(id: WorkflowDocumentId) {
const [workflowId, workflowVersion] = id.split('@');
const nodeTypesStore = useNodeTypesStore();
const nodeHelpers = useNodeHelpers();
const { cloneWorkflowObject, createWorkflowObject, ...workflowDocumentWorkflowObject } =
useWorkflowDocumentWorkflowObject({ workflowId });
@@ -157,7 +155,6 @@ export function useWorkflowDocumentStore(id: WorkflowDocumentId) {
const workflowDocumentMeta = useWorkflowDocumentMeta();
const workflowDocumentTags = useWorkflowDocumentTags();
const workflowDocumentIsArchived = useWorkflowDocumentIsArchived();
const workflowDocumentPinData = useWorkflowDocumentPinData();
const workflowDocumentScopes = useWorkflowDocumentScopes();
const workflowDocumentTimestamps = useWorkflowDocumentTimestamps();
const workflowDocumentSettings = useWorkflowDocumentSettings({
@@ -172,11 +169,16 @@ export function useWorkflowDocumentStore(id: WorkflowDocumentId) {
const { onStateDirty: onNodesStateDirty, ...workflowDocumentNodes } = useWorkflowDocumentNodes({
getNodeType: (typeName, version) => nodeTypesStore.getNodeType(typeName, version),
nodeMetadata: workflowDocumentNodeMetadata,
assignNodeId: (node) => nodeHelpers.assignNodeId(node),
assignNodeId,
syncWorkflowObject: (nodes) => workflowDocumentWorkflowObject.syncWorkflowObjectNodes(nodes),
unpinNodeData: (name) => workflowDocumentPinData.unpinNodeData(name),
workflowObject: workflowDocumentWorkflowObject.workflowObject,
});
// pinData subscribes to nodes' `onNodesChange` and clears orphan pin data
// on DELETE itself — so nodes doesn't need a reverse dep into pinData.
const workflowDocumentPinData = useWorkflowDocumentPinData({
nodesById: workflowDocumentNodes.nodesById,
onNodesChange: workflowDocumentNodes.onNodesChange,
});
const { onStateDirty: onConnectionsStateDirty, ...workflowDocumentConnections } =
useWorkflowDocumentConnections({
getNodeById: (id) => workflowDocumentNodes.getNodeById(id),
@@ -193,6 +195,9 @@ export function useWorkflowDocumentStore(id: WorkflowDocumentId) {
allNodes: workflowDocumentNodes.allNodes,
outgoingConnectionsByNodeName: workflowDocumentConnections.outgoingConnectionsByNodeName,
incomingConnectionsByNodeName: workflowDocumentConnections.incomingConnectionsByNodeName,
nodesById: workflowDocumentNodes.nodesById,
onNodesChange: workflowDocumentNodes.onNodesChange,
nodeIssuesToString,
});
const { onStateDirty: onNodeGroupsStateDirty, ...workflowDocumentNodeGroups } =
useWorkflowDocumentNodeGroups();
@@ -46,7 +46,6 @@ function createNodesDeps(
getNodeType: vi.fn().mockReturnValue(null),
assignNodeId: vi.fn().mockReturnValue(''),
syncWorkflowObject: vi.fn(),
unpinNodeData: vi.fn(),
nodeMetadata: useWorkflowDocumentNodeMetadata(),
workflowObject: obj.workflowObject,
};
@@ -46,7 +46,6 @@ function createDeps(overrides: Partial<WorkflowDocumentNodesDeps> = {}): Workflo
getNodeType: vi.fn().mockReturnValue(null),
assignNodeId: vi.fn().mockReturnValue(''),
syncWorkflowObject: vi.fn(),
unpinNodeData: vi.fn(),
nodeMetadata: useWorkflowDocumentNodeMetadata(),
workflowObject: ref(
mock<Workflow>({ getNode: () => null }),
@@ -588,32 +587,9 @@ describe('useWorkflowDocumentNodes', () => {
expect(dirtySpy).not.toHaveBeenCalled();
});
it('removeNode calls unpinNodeData', () => {
const node = createNode({ name: 'Target' });
const workflowDocumentNodes = useWorkflowDocumentNodes(deps);
workflowDocumentNodes.setNodes([node]);
workflowDocumentNodes.removeNode(node);
expect(deps.unpinNodeData).toHaveBeenCalledWith('Target');
});
it('removeNodeById calls unpinNodeData', () => {
const node = createNode({ name: 'Target' });
const workflowDocumentNodes = useWorkflowDocumentNodes(deps);
workflowDocumentNodes.setNodes([node]);
workflowDocumentNodes.removeNodeById(node.id);
expect(deps.unpinNodeData).toHaveBeenCalledWith('Target');
});
it('removeNodeById does not call unpinNodeData when node not found', () => {
const workflowDocumentNodes = useWorkflowDocumentNodes(deps);
workflowDocumentNodes.removeNodeById('nonexistent');
expect(deps.unpinNodeData).not.toHaveBeenCalled();
});
// Orphan-pin-cleanup coverage (previously a direct dep of useWorkflowDocumentNodes
// via `unpinNodeData`) now lives in useWorkflowDocumentPinData.test.ts —
// pinData subscribes to onNodesChange and handles DELETE itself.
it('removeNodeById uses empty name when node not found', () => {
const hookSpy = vi.fn();
@@ -61,7 +61,6 @@ export interface WorkflowDocumentNodesDeps {
getNodeType: (typeName: string, version?: number) => INodeTypeDescription | null;
assignNodeId: (node: INodeUi) => string;
syncWorkflowObject: (nodes: INodeUi[]) => void;
unpinNodeData: (name: string) => void;
nodeMetadata: ReturnType<typeof useWorkflowDocumentNodeMetadata>;
workflowObject: Ref<Workflow>;
}
@@ -169,7 +168,6 @@ export function useWorkflowDocumentNodes(deps: WorkflowDocumentNodesDeps) {
deps.syncWorkflowObject(nodes.value);
deps.nodeMetadata.removeNodeMetadata(node.name);
deps.unpinNodeData(node.name);
void onNodesChange.trigger({
action: CHANGE_ACTION.DELETE,
payload: { name: node.name, id: node.id },
@@ -186,7 +184,6 @@ export function useWorkflowDocumentNodes(deps: WorkflowDocumentNodesDeps) {
deps.syncWorkflowObject(nodes.value);
if (node) {
deps.nodeMetadata.removeNodeMetadata(node.name);
deps.unpinNodeData(node.name);
}
void onNodesChange.trigger({
action: CHANGE_ACTION.DELETE,
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest';
import { computed } from 'vue';
import { computed, shallowRef } from 'vue';
import { createTestNode } from '@/__tests__/mocks';
import type { INodeUi } from '@/Interface';
import type { INodeConnections } from 'n8n-workflow';
@@ -21,6 +21,9 @@ function createDeps(nodes: INodeUi[], connected = true): WorkflowDocumentNodesIs
allNodes: computed(() => nodes),
outgoingConnectionsByNodeName: connected ? hasConnections : noConnections,
incomingConnectionsByNodeName: noConnections,
nodesById: shallowRef(new Map(nodes.map((n) => [n.id, n]))),
onNodesChange: () => {},
nodeIssuesToString: () => [],
};
}
@@ -1,11 +1,23 @@
import { computed, type ComputedRef } from 'vue';
import { computed, effectScope, shallowReactive, type ComputedRef, type ShallowRef } from 'vue';
import { structuralComputed } from '@n8n/composables/structuralComputed';
import isEqual from 'lodash/isEqual';
import type { INodeUi, WorkflowValidationIssue } from '@/Interface';
import type { INodeConnections } from 'n8n-workflow';
import type { INodeConnections, INodeIssues, INode } from 'n8n-workflow';
import { CHANGE_ACTION } from './types';
import type {
NodeAddedPayload,
NodeRemovedPayload,
NodesChangeEvent,
NodesSetPayload,
} from './useWorkflowDocumentNodes';
export type WorkflowDocumentNodesIssuesDeps = {
allNodes: ComputedRef<INodeUi[]>;
outgoingConnectionsByNodeName: (nodeName: string) => INodeConnections;
incomingConnectionsByNodeName: (nodeName: string) => INodeConnections;
nodesById: ShallowRef<Map<string, INodeUi>>;
onNodesChange: (cb: (event: NodesChangeEvent) => void) => void;
nodeIssuesToString: (issues: INodeIssues, node?: INode) => string[];
};
export function useWorkflowDocumentNodesIssues(deps: WorkflowDocumentNodesIssuesDeps) {
@@ -88,12 +100,76 @@ export function useWorkflowDocumentNodesIssues(deps: WorkflowDocumentNodesIssues
return String(issue);
}
// Per-node-id validation errors map. See useWorkflowDocumentRenderData
// for an explanation of the shallowReactive + structuralComputed pattern.
const validationErrorsByNodeId = shallowReactive(new Map<string, ComputedRef<string[]>>());
const scopes = new Map<string, () => void>();
function computeValidationErrors(nodeId: string): string[] {
const node = deps.nodesById.value.get(nodeId);
if (!node?.issues) return [];
return deps.nodeIssuesToString(node.issues, node);
}
function applyAddEntry(nodeId: string) {
if (scopes.has(nodeId)) return;
const scope = effectScope();
scope.run(() => {
validationErrorsByNodeId.set(
nodeId,
structuralComputed(() => computeValidationErrors(nodeId), isEqual),
);
});
scopes.set(nodeId, () => scope.stop());
}
function applyRemoveEntry(nodeId: string) {
scopes.get(nodeId)?.();
scopes.delete(nodeId);
validationErrorsByNodeId.delete(nodeId);
}
function applyReconcileEntries(nodeIds: string[]) {
const nextIds = new Set(nodeIds);
for (const oldId of scopes.keys()) {
if (!nextIds.has(oldId)) applyRemoveEntry(oldId);
}
for (const id of nodeIds) applyAddEntry(id);
}
deps.onNodesChange((event) => {
switch (event.action) {
case CHANGE_ACTION.ADD: {
const { node } = event.payload as NodeAddedPayload;
applyAddEntry(node.id);
break;
}
case CHANGE_ACTION.DELETE: {
const payload = event.payload as NodeRemovedPayload;
if (payload.id) {
applyRemoveEntry(payload.id);
} else {
applyReconcileEntries([]);
}
break;
}
case CHANGE_ACTION.SET: {
const { nodeIds } = event.payload as NodesSetPayload;
applyReconcileEntries(nodeIds);
break;
}
}
});
applyReconcileEntries(Array.from(deps.nodesById.value.keys()));
return {
nodesWithValidationIssues,
nodesWithValidationIssuesCount,
hasNodeValidationIssues,
hasPublishBlockingIssues,
nodeValidationIssues,
validationErrorsByNodeId,
formatNodeIssueMessage,
};
}
@@ -1,13 +1,32 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { shallowRef } from 'vue';
import type { INodeExecutionData, IPinData } from 'n8n-workflow';
import type { INodeUi } from '@/Interface';
import {
useWorkflowDocumentPinData,
getPinDataSize,
pinDataToExecutionData,
} from './useWorkflowDocumentPinData';
import { CHANGE_ACTION } from './types';
import type { NodesChangeEvent } from './useWorkflowDocumentNodes';
function createPinData() {
return useWorkflowDocumentPinData();
return useWorkflowDocumentPinData({
nodesById: shallowRef(new Map<string, INodeUi>()),
onNodesChange: () => {},
});
}
function createPinDataWithNodesChange() {
const subscribers: Array<(event: NodesChangeEvent) => void> = [];
const pinData = useWorkflowDocumentPinData({
nodesById: shallowRef(new Map<string, INodeUi>()),
onNodesChange: (cb) => {
subscribers.push(cb);
},
});
const fire = (event: NodesChangeEvent) => subscribers.forEach((cb) => cb(event));
return { pinData, fire };
}
describe('useWorkflowDocumentPinData', () => {
@@ -389,4 +408,39 @@ describe('getPinDataSize', () => {
expect(twoNodes).toBeGreaterThan(singleNode);
});
describe('orphan pin cleanup on node removal', () => {
it('clears pinned data when a DELETE event names the node', () => {
const { pinData, fire } = createPinDataWithNodesChange();
pinData.pinNodeData('Target', [{ json: { x: 1 } }] as INodeExecutionData[]);
expect(pinData.pinnedDataByNodeName.value.Target).toBeDefined();
fire({ action: CHANGE_ACTION.DELETE, payload: { id: 'node-id-1', name: 'Target' } });
expect(pinData.pinnedDataByNodeName.value.Target).toBeUndefined();
});
it('leaves unrelated pinned data untouched', () => {
const { pinData, fire } = createPinDataWithNodesChange();
pinData.pinNodeData('Target', [{ json: { x: 1 } }] as INodeExecutionData[]);
pinData.pinNodeData('Other', [{ json: { y: 2 } }] as INodeExecutionData[]);
fire({ action: CHANGE_ACTION.DELETE, payload: { id: 'node-id-1', name: 'Target' } });
expect(pinData.pinnedDataByNodeName.value.Target).toBeUndefined();
expect(pinData.pinnedDataByNodeName.value.Other).toBeDefined();
});
it('does nothing when DELETE has no node name (removeAllNodes)', () => {
const { pinData, fire } = createPinDataWithNodesChange();
pinData.pinNodeData('Target', [{ json: { x: 1 } }] as INodeExecutionData[]);
fire({ action: CHANGE_ACTION.DELETE, payload: {} });
// `removeAllNodes` clears pin data via the store-level `removeAllNodes`
// orchestration, not via this subscriber — so this individual subscription
// is a no-op for the bulk case.
expect(pinData.pinnedDataByNodeName.value.Target).toBeDefined();
});
});
});
@@ -1,9 +1,24 @@
import { ref, readonly } from 'vue';
import {
computed,
effectScope,
ref,
readonly,
shallowReactive,
type ComputedRef,
type ShallowRef,
} from 'vue';
import { createEventHook } from '@vueuse/core';
import type { INodeExecutionData, IDataObject, IPinData } from 'n8n-workflow';
import type { INodeUi } from '@/Interface';
import { isJsonKeyObject, stringSizeInBytes } from '@/app/utils/typesUtils';
import { CHANGE_ACTION } from './types';
import type { ChangeAction, ChangeEvent } from './types';
import type {
NodeAddedPayload,
NodeRemovedPayload,
NodesChangeEvent,
NodesSetPayload,
} from './useWorkflowDocumentNodes';
export type PinDataNodePayload = {
nodeName: string;
@@ -66,7 +81,12 @@ export function getPinDataSize(
}, 0);
}
export function useWorkflowDocumentPinData() {
export type WorkflowDocumentPinDataDeps = {
nodesById: ShallowRef<Map<string, INodeUi>>;
onNodesChange: (cb: (event: NodesChangeEvent) => void) => void;
};
export function useWorkflowDocumentPinData(deps: WorkflowDocumentPinDataDeps) {
const pinnedDataByNodeName = ref<IPinData>({});
const onPinnedDataChange = createEventHook<PinDataChangeEvent>();
@@ -141,8 +161,88 @@ export function useWorkflowDocumentPinData() {
return pinnedDataByNodeName.value[nodeName];
}
// Per-node-id pin-data lookup. See useWorkflowDocumentRenderData for the
// general shallowReactive + per-entry computed pattern.
//
// Note: deliberately uses plain `computed` (Object.is gate) rather than
// `structuralComputed(..., isEqual)`. Pin data can reach ~10 MB per
// workflow, and every mutation replaces the inner array reference — so an
// isEqual gate would deep-compare megabytes on every change *and* never
// short-circuit (the immutable update pattern means no mutation ever
// produces a structurally-identical fresh array). Reference identity is
// the right gate here.
const pinnedDataByNodeId = shallowReactive(
new Map<string, ComputedRef<INodeExecutionData[] | undefined>>(),
);
const scopes = new Map<string, () => void>();
function computePinnedData(nodeId: string): INodeExecutionData[] | undefined {
const node = deps.nodesById.value.get(nodeId);
if (!node) return undefined;
return pinnedDataByNodeName.value[node.name];
}
function applyAddPinEntry(nodeId: string) {
if (scopes.has(nodeId)) return;
const scope = effectScope();
scope.run(() => {
pinnedDataByNodeId.set(
nodeId,
computed(() => computePinnedData(nodeId)),
);
});
scopes.set(nodeId, () => scope.stop());
}
function applyRemovePinEntry(nodeId: string) {
scopes.get(nodeId)?.();
scopes.delete(nodeId);
pinnedDataByNodeId.delete(nodeId);
}
function applyReconcilePinEntries(nodeIds: string[]) {
const nextIds = new Set(nodeIds);
for (const oldId of scopes.keys()) {
if (!nextIds.has(oldId)) applyRemovePinEntry(oldId);
}
for (const id of nodeIds) applyAddPinEntry(id);
}
deps.onNodesChange((event) => {
switch (event.action) {
case CHANGE_ACTION.ADD: {
const { node } = event.payload as NodeAddedPayload;
applyAddPinEntry(node.id);
break;
}
case CHANGE_ACTION.DELETE: {
const payload = event.payload as NodeRemovedPayload;
if (payload.id) {
applyRemovePinEntry(payload.id);
} else {
applyReconcilePinEntries([]);
}
// Orphan pin cleanup: removing a node also clears any pinned data
// keyed by its name. Previously this was a cross-cut owned by
// useWorkflowDocumentNodes via an injected `unpinNodeData` dep;
// pulling it into the pinData subscriber means nodes doesn't need
// to know about pinData at all (breaks the construction cycle).
if (payload.name) applyUnpin(payload.name);
break;
}
case CHANGE_ACTION.SET: {
const { nodeIds } = event.payload as NodesSetPayload;
applyReconcilePinEntries(nodeIds);
break;
}
}
});
applyReconcilePinEntries(Array.from(deps.nodesById.value.keys()));
return {
pinnedDataByNodeName: readonly(pinnedDataByNodeName),
pinnedDataByNodeId,
setPinData,
pinNodeData,
unpinNodeData,
@@ -1,70 +1,493 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { shallowReactive, type ComputedRef } from 'vue';
import { computed, effectScope } from 'vue';
import { setActivePinia, createPinia } from 'pinia';
import type { IPinData } from 'n8n-workflow';
import type { CanvasConnectionPort } from '@/features/workflows/canvas/canvas.types';
import type { INode } from 'n8n-workflow';
import type { INodeUi } from '@/Interface';
import {
createTestNode,
createTestWorkflow,
createTestWorkflowExecutionResponse,
} from '@/__tests__/mocks';
import { STICKY_NODE_TYPE } from '@/app/constants';
import {
useWorkflowDocumentStore,
createWorkflowDocumentId,
type WorkflowDocumentId,
} from '@/app/stores/workflowDocument.store';
import { useWorkflowExecutionStateStore } from '@/app/stores/workflowExecutionState.store';
import { useExecutionDataStore, createExecutionDataId } from '@/app/stores/executionData.store';
import { useWorkflowDocumentRenderData } from './useWorkflowDocumentRenderData';
const nodeInputsByNodeId = shallowReactive(new Map<string, ComputedRef<CanvasConnectionPort[]>>());
const nodeOutputsByNodeId = shallowReactive(new Map<string, ComputedRef<CanvasConnectionPort[]>>());
const pinnedDataByNodeName = shallowReactive<IPinData>({});
const executionIssuesByNodeName = new Map<string, ComputedRef<string[]>>();
const TEST_TRIGGER_NODE_TYPE = 'n8n-nodes-base.testTrigger';
vi.mock('@/app/stores/workflowDocument.store', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/app/stores/workflowDocument.store')>();
return {
...actual,
useWorkflowDocumentStore: vi.fn(() => ({
workflowId: 'wf-1',
nodeInputsByNodeId,
nodeOutputsByNodeId,
pinnedDataByNodeName,
})),
};
});
// External-surface mocks only — node-types and dirtiness pull in unrelated
// stores that aren't needed to exercise renderData's wiring. The actual
// workflowDocument / workflowExecutionState stores stay real so reconciliation
// off `onNodesChange` is genuinely tested.
vi.mock('@/app/composables/useNodeDirtiness', () => ({
useNodeDirtiness: vi.fn(() => ({ dirtinessByName: computed(() => ({})) })),
}));
vi.mock('@/app/stores/workflowExecutionState.store', () => ({
useWorkflowExecutionStateStore: vi.fn(() => ({
activeExecutionIssuesByNodeName: executionIssuesByNodeName,
vi.mock('@/app/stores/nodeTypes.store', () => ({
useNodeTypesStore: vi.fn(() => ({
isConfigNode: () => false,
isConfigurableNode: () => false,
isTriggerNode: (type: string) => type === 'n8n-nodes-base.testTrigger',
getNodeType: (type: string) =>
type === 'n8n-nodes-base.testTrigger'
? {
name: 'n8n-nodes-base.testTrigger',
displayName: 'Test Trigger',
description: '',
version: 1,
defaults: {},
group: ['trigger'],
inputs: [],
outputs: ['main'],
properties: [],
}
: null,
communityNodeType: () => undefined,
getAllNodeTypes: () => [],
})),
}));
describe('useWorkflowDocumentRenderData', () => {
function setupWorkflow(id: string, nodes: Array<Partial<INodeUi>> = []) {
const docId = createWorkflowDocumentId(id, 'v1');
const doc = useWorkflowDocumentStore(docId);
doc.setNodes(
nodes.map((n) =>
createTestNode({
id: n.id ?? `${n.name}-id`,
name: n.name ?? 'Node',
type: n.type ?? 'test',
...n,
} as Partial<INode>),
),
);
return { docId, doc };
}
/**
* Loads an execution into a per-execution data store (with a workflowData
* snapshot providing the id↔name mapping) and marks it active on the
* document's execution state store, so the active-execution by-id projections
* resolve through it.
*/
function setActiveExecution(
docId: WorkflowDocumentId,
nodes: INode[],
runData: Record<string, Array<Record<string, unknown>>>,
) {
const executionId = `exec-${docId}`;
const executionDataStore = useExecutionDataStore(createExecutionDataId(executionId));
executionDataStore.setExecution(
createTestWorkflowExecutionResponse({
id: executionId,
finished: false,
status: 'running',
workflowData: createTestWorkflow({ nodes }),
data: { resultData: { runData, lastNodeExecuted: '' } } as never,
}),
);
useWorkflowExecutionStateStore(docId).setActiveExecutionId(executionId);
}
// The composable is side-effectful and registers `onScopeDispose`, so it must
// run inside an `effectScope` (as it does in production). The returned `scope`
// lets a test exercise teardown via `scope.stop()`.
function createRenderData(docId: WorkflowDocumentId) {
const scope = effectScope();
const renderData = scope.run(() => useWorkflowDocumentRenderData(docId))!;
return { renderData, scope };
}
describe('useWorkflowDocumentRenderData — passthroughs', () => {
beforeEach(() => {
setActivePinia(createPinia());
vi.mocked(useWorkflowDocumentStore).mockClear();
vi.mocked(useWorkflowExecutionStateStore).mockClear();
});
it('passes through nodeInputsByNodeId and nodeOutputsByNodeId by reference', () => {
const renderData = useWorkflowDocumentRenderData(createWorkflowDocumentId('wf-1'));
it('re-exposes workflowDocument by-id projections by identity', () => {
const { docId, doc } = setupWorkflow('wf-pass', [{ id: 'a', name: 'Alpha' }]);
const { renderData } = createRenderData(docId);
expect(renderData.nodeInputsByNodeId).toBe(nodeInputsByNodeId);
expect(renderData.nodeOutputsByNodeId).toBe(nodeOutputsByNodeId);
expect(renderData.nodeInputsByNodeId).toBe(doc.nodeInputsByNodeId);
expect(renderData.nodeOutputsByNodeId).toBe(doc.nodeOutputsByNodeId);
expect(renderData.pinnedDataByNodeName).toBe(doc.pinnedDataByNodeName);
expect(renderData.pinnedDataByNodeId).toBe(doc.pinnedDataByNodeId);
expect(renderData.validationErrorsByNodeId).toBe(doc.validationErrorsByNodeId);
});
it('passes through pinnedDataByNodeName by reference', () => {
const renderData = useWorkflowDocumentRenderData(createWorkflowDocumentId('wf-1'));
it('owns per-node-id node-type derivation maps locally', () => {
const { docId } = setupWorkflow('wf-nodetype-info', [{ id: 'a', name: 'Alpha' }]);
const { renderData } = createRenderData(docId);
expect(renderData.pinnedDataByNodeName).toBe(pinnedDataByNodeName);
expect(renderData.nodeTypeDescriptionByNodeId.has('a')).toBe(true);
expect(renderData.isTriggerByNodeId.has('a')).toBe(true);
expect(renderData.subtitleByNodeId.has('a')).toBe(true);
expect(renderData.simulatedNodeTypeDescriptionByNodeId.has('a')).toBe(true);
});
});
it('exposes executionIssuesByNodeName resolved via the workflow execution state store', () => {
const renderData = useWorkflowDocumentRenderData(createWorkflowDocumentId('wf-1'));
describe('useWorkflowDocumentRenderData — fusion projections', () => {
beforeEach(() => {
setActivePinia(createPinia());
});
it('initializes fusion maps for nodes present at construction time', () => {
const { docId } = setupWorkflow('wf-fusion-init', [
{ id: 'a', name: 'Alpha' },
{ id: 'b', name: 'Beta' },
]);
const { renderData } = createRenderData(docId);
expect(renderData.tooltipByNodeId.has('a')).toBe(true);
expect(renderData.tooltipByNodeId.has('b')).toBe(true);
expect(renderData.hasIssuesByNodeId.has('a')).toBe(true);
expect(renderData.hasIssuesByNodeId.has('b')).toBe(true);
expect(renderData.renderTypeByNodeId.has('a')).toBe(true);
expect(renderData.renderTypeByNodeId.has('b')).toBe(true);
});
it('reconciles fusion maps when a node is added', () => {
const { docId, doc } = setupWorkflow('wf-fusion-add', [{ id: 'a', name: 'Alpha' }]);
const { renderData } = createRenderData(docId);
doc.addNode(createTestNode({ id: 'b', name: 'Beta', type: 'test' }));
expect(renderData.tooltipByNodeId.has('b')).toBe(true);
expect(renderData.hasIssuesByNodeId.has('b')).toBe(true);
expect(renderData.renderTypeByNodeId.has('b')).toBe(true);
});
it('reconciles fusion maps when a node is removed', () => {
const { docId, doc } = setupWorkflow('wf-fusion-remove', [
{ id: 'a', name: 'Alpha' },
{ id: 'b', name: 'Beta' },
]);
const { renderData } = createRenderData(docId);
doc.removeNodeById('a');
expect(renderData.tooltipByNodeId.has('a')).toBe(false);
expect(renderData.hasIssuesByNodeId.has('a')).toBe(false);
expect(renderData.renderTypeByNodeId.has('a')).toBe(false);
expect(renderData.tooltipByNodeId.has('b')).toBe(true);
});
expect(renderData.executionIssuesByNodeName).toBe(executionIssuesByNodeName);
it('keeps hasIssuesByNodeId false for clean nodes with no execution data', () => {
const { docId } = setupWorkflow('wf-fusion-clean', [{ id: 'a', name: 'Alpha' }]);
const { renderData } = createRenderData(docId);
expect(renderData.hasIssuesByNodeId.get('a')?.value).toBe(false);
});
it('returns undefined tooltip for non-trigger nodes', () => {
const { docId } = setupWorkflow('wf-fusion-tooltip', [{ id: 'a', name: 'Alpha' }]);
const { renderData } = createRenderData(docId);
expect(renderData.tooltipByNodeId.get('a')?.value).toBeUndefined();
});
it('returns the default render type for a generic node', () => {
const { docId } = setupWorkflow('wf-fusion-default', [{ id: 'a', name: 'Alpha' }]);
const { renderData } = createRenderData(docId);
const render = renderData.renderTypeByNodeId.get('a')?.value;
expect(render?.type).toBe('default');
});
it('returns a sticky-note render type for sticky nodes', () => {
const { docId } = setupWorkflow('wf-fusion-sticky', [
{ id: 's', name: 'Sticky', type: 'n8n-nodes-base.stickyNote' },
]);
const { renderData } = createRenderData(docId);
const render = renderData.renderTypeByNodeId.get('s')?.value;
expect(render?.type).toBe('n8n-nodes-base.stickyNote');
});
it('assigns z-index entries only for sticky notes via additionalPropertiesByNodeId', () => {
const { docId } = setupWorkflow('wf-fusion-additional', [
{ id: 's1', name: 'Sticky1', type: 'n8n-nodes-base.stickyNote' },
{ id: 'n1', name: 'Node1', type: 'test' },
]);
const { renderData } = createRenderData(docId);
const props = renderData.additionalPropertiesByNodeId.value;
expect(props.s1).toBeDefined();
expect(props.n1).toBeUndefined();
});
});
describe('useWorkflowDocumentRenderData — hasIssuesByNodeId precedence', () => {
beforeEach(() => {
setActivePinia(createPinia());
});
const alpha = () => createTestNode({ id: 'a', name: 'Alpha' });
it('reports issues when the execution status is crashed', () => {
const { docId } = setupWorkflow('wf-issues-crashed', [{ id: 'a', name: 'Alpha' }]);
setActiveExecution(docId, [alpha()], { Alpha: [{ executionStatus: 'crashed' }] });
const { renderData } = createRenderData(docId);
expect(renderData.hasIssuesByNodeId.get('a')?.value).toBe(true);
});
it('reports issues when the execution status is error', () => {
const { docId } = setupWorkflow('wf-issues-error', [{ id: 'a', name: 'Alpha' }]);
setActiveExecution(docId, [alpha()], { Alpha: [{ executionStatus: 'error' }] });
const { renderData } = createRenderData(docId);
expect(renderData.hasIssuesByNodeId.get('a')?.value).toBe(true);
});
it('reports no issues when the node has pinned data despite validation issues', () => {
const { docId, doc } = setupWorkflow('wf-issues-pinned', [
{ id: 'a', name: 'Alpha', issues: { typeUnknown: true } },
]);
doc.pinNodeData('Alpha', [{ json: {} }]);
const { renderData } = createRenderData(docId);
// The node genuinely has validation errors — pinned data must still win.
expect(renderData.validationErrorsByNodeId.get('a')?.value?.length).toBeGreaterThan(0);
expect(renderData.hasIssuesByNodeId.get('a')?.value).toBe(false);
});
it('reports issues for validation errors when there is no pinned data', () => {
const { docId } = setupWorkflow('wf-issues-validation', [
{ id: 'a', name: 'Alpha', issues: { typeUnknown: true } },
]);
const { renderData } = createRenderData(docId);
expect(renderData.hasIssuesByNodeId.get('a')?.value).toBe(true);
});
it('reports issues when the active execution reports execution errors', () => {
const { docId } = setupWorkflow('wf-issues-execution', [{ id: 'a', name: 'Alpha' }]);
setActiveExecution(docId, [alpha()], {
Alpha: [{ executionStatus: 'error', error: { message: 'boom' } }],
});
const { renderData } = createRenderData(docId);
expect(renderData.hasIssuesByNodeId.get('a')?.value).toBe(true);
});
it('reports no issues when a successful run follows an errored one', () => {
const { docId } = setupWorkflow('wf-issues-retry', [{ id: 'a', name: 'Alpha' }]);
setActiveExecution(docId, [alpha()], {
Alpha: [
{ executionStatus: 'error', error: { message: 'boom' } },
{ executionStatus: 'success' },
],
});
const { renderData } = createRenderData(docId);
expect(renderData.hasIssuesByNodeId.get('a')?.value).toBe(false);
});
});
describe('useWorkflowDocumentRenderData — trigger tooltip', () => {
beforeEach(() => {
setActivePinia(createPinia());
});
function setupRunningWorkflow(id: string, nodes: Array<Partial<INodeUi>>) {
const result = setupWorkflow(id, nodes);
// `null` marks an execution as started with its id still pending, which
// flips `isWorkflowRunning` without needing execution data.
useWorkflowExecutionStateStore(result.docId).setActiveExecutionId(null);
return result;
}
it('shows the waiting tooltip on the single active trigger while the workflow is running', () => {
const { docId } = setupRunningWorkflow('wf-tooltip-shown', [
{ id: 't', name: 'Trigger', type: TEST_TRIGGER_NODE_TYPE },
{ id: 'a', name: 'Alpha' },
]);
const { renderData } = createRenderData(docId);
expect(renderData.tooltipByNodeId.get('t')?.value).toBe(
'Waiting for you to create an event in Test',
);
});
it('does not show a tooltip when the trigger has pinned data', () => {
const { docId, doc } = setupRunningWorkflow('wf-tooltip-pinned', [
{ id: 't', name: 'Trigger', type: TEST_TRIGGER_NODE_TYPE },
]);
doc.pinNodeData('Trigger', [{ json: {} }]);
const { renderData } = createRenderData(docId);
expect(renderData.tooltipByNodeId.get('t')?.value).toBeUndefined();
});
it('does not show a tooltip when the workflow is not running', () => {
const { docId } = setupWorkflow('wf-tooltip-idle', [
{ id: 't', name: 'Trigger', type: TEST_TRIGGER_NODE_TYPE },
]);
const { renderData } = createRenderData(docId);
expect(renderData.tooltipByNodeId.get('t')?.value).toBeUndefined();
});
it('does not show a tooltip on disabled triggers', () => {
const { docId } = setupRunningWorkflow('wf-tooltip-disabled', [
{ id: 't', name: 'Trigger', type: TEST_TRIGGER_NODE_TYPE, disabled: true },
]);
const { renderData } = createRenderData(docId);
expect(renderData.tooltipByNodeId.get('t')?.value).toBeUndefined();
});
it('suppresses tooltips when multiple triggers are active and the driving trigger is unknown', () => {
const { docId } = setupRunningWorkflow('wf-tooltip-multi', [
{ id: 't1', name: 'Trigger 1', type: TEST_TRIGGER_NODE_TYPE },
{ id: 't2', name: 'Trigger 2', type: TEST_TRIGGER_NODE_TYPE },
]);
const { renderData } = createRenderData(docId);
expect(renderData.tooltipByNodeId.get('t1')?.value).toBeUndefined();
expect(renderData.tooltipByNodeId.get('t2')?.value).toBeUndefined();
});
});
describe('useWorkflowDocumentRenderData — sticky z-index ordering', () => {
beforeEach(() => {
setActivePinia(createPinia());
});
// Grid-aligned positions so the document store's snap-to-grid is a no-op
// and the overlap geometry below is exact.
function sticky(id: string, position: [number, number], size: number): Partial<INodeUi> {
return {
id,
name: `Sticky ${id}`,
type: STICKY_NODE_TYPE,
position,
parameters: { width: size, height: size },
};
}
it('assigns the base z-index to a single sticky', () => {
const { docId } = setupWorkflow('wf-sticky-single', [sticky('s1', [0, 0], 100)]);
const { renderData } = createRenderData(docId);
expect(renderData.additionalPropertiesByNodeId.value.s1).toEqual({
style: { zIndex: -100 },
});
});
it('assigns sequential z-indexes to non-overlapping stickies', () => {
const { docId } = setupWorkflow('wf-sticky-apart', [
sticky('s1', [0, 0], 100),
sticky('s2', [320, 320], 100),
]);
const { renderData } = createRenderData(docId);
const props = renderData.additionalPropertiesByNodeId.value;
expect(props.s1).toEqual({ style: { zIndex: -100 } });
expect(props.s2).toEqual({ style: { zIndex: -99 } });
});
it('raises a smaller sticky above a larger overlapping one', () => {
const { docId } = setupWorkflow('wf-sticky-overlap', [
sticky('small', [48, 48], 100),
sticky('large', [0, 0], 160),
]);
const { renderData } = createRenderData(docId);
const props = renderData.additionalPropertiesByNodeId.value;
expect(props.large).toEqual({ style: { zIndex: -100 } });
expect(props.small).toEqual({ style: { zIndex: -99 } });
});
it('orders multiple overlapping stickies by area with smaller ones on top', () => {
const { docId } = setupWorkflow('wf-sticky-multi', [
sticky('s1', [0, 0], 100),
sticky('s2', [32, 32], 50),
sticky('s3', [48, 48], 100),
]);
const { renderData } = createRenderData(docId);
const props = renderData.additionalPropertiesByNodeId.value;
expect(props.s1).toEqual({ style: { zIndex: -100 } });
expect(props.s2).toEqual({ style: { zIndex: -98 } });
expect(props.s3).toEqual({ style: { zIndex: -99 } });
});
});
describe('useWorkflowDocumentRenderData — lifecycle', () => {
beforeEach(() => {
setActivePinia(createPinia());
});
it('constructs without inject() warnings outside a component context', () => {
// Production constructs this composable in detached effect scopes (watch
// callbacks in WorkflowCanvas.vue / useWorkflowDiff.ts), where Vue's
// inject() is unavailable. Nothing in the construction path may inject.
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
try {
const { docId } = setupWorkflow('wf-inject', [{ id: 'a', name: 'Alpha' }]);
createRenderData(docId);
const warnings = warnSpy.mock.calls.flat().filter((arg) => typeof arg === 'string');
expect(warnings.join('\n')).not.toContain('inject() can only be used');
} finally {
warnSpy.mockRestore();
}
});
it('stops reconciling once its scope is disposed', () => {
const { docId, doc } = setupWorkflow('wf-teardown', [{ id: 'a', name: 'Alpha' }]);
const { renderData, scope } = createRenderData(docId);
expect(renderData.renderTypeByNodeId.has('a')).toBe(true);
scope.stop();
doc.addNode(createTestNode({ id: 'b', name: 'Beta', type: 'test' }));
// The `onNodesChange` subscription was removed on teardown, so the node
// added after disposal is not reconciled into the maps.
expect(renderData.renderTypeByNodeId.has('b')).toBe(false);
expect(renderData.tooltipByNodeId.has('b')).toBe(false);
expect(renderData.hasIssuesByNodeId.has('b')).toBe(false);
});
it('exposes active-execution projections as live getters, not captured snapshots', () => {
const { docId } = setupWorkflow('wf-fresh', [{ id: 'a', name: 'Alpha' }]);
const { renderData } = createRenderData(docId);
const executionStateStore = useWorkflowExecutionStateStore(docId);
// With no execution, the getter resolves to the store's current map.
const emptyMap = renderData.executionStatusByNodeId;
expect(emptyMap).toBe(executionStateStore.activeExecutionStatusByNodeId);
// Swapping the resolved execution changes the source map identity; the
// getter must reflect the new map rather than a value captured at setup.
executionStateStore.setActiveExecutionId('exec-fresh');
expect(renderData.executionStatusByNodeId).toBe(
executionStateStore.activeExecutionStatusByNodeId,
);
expect(renderData.executionStatusByNodeId).not.toBe(emptyMap);
});
it('exposes pinnedDataByNodeName as a live getter that computeds track across pin/unpin', () => {
const { docId, doc } = setupWorkflow('wf-pin-live', [{ id: 'a', name: 'Alpha' }]);
const { renderData } = createRenderData(docId);
it('uses the exact workflow document id when resolving execution state', () => {
const documentId = createWorkflowDocumentId('wf-1', 'ver-123');
// Mirrors the canvas pin-badge computed in CanvasNodeStatusIcons /
// CanvasNodeDefault: `!!renderData.value.pinnedDataByNodeName[name]`.
const hasPinnedData = computed(() => !!renderData.pinnedDataByNodeName.Alpha);
expect(hasPinnedData.value).toBe(false);
useWorkflowDocumentRenderData(documentId);
// Pin mutations replace the store ref's value; a snapshot captured at
// setup would keep returning the pre-pin object and the computed would
// never invalidate.
doc.pinNodeData('Alpha', [{ json: { value: 1 } }]);
expect(hasPinnedData.value).toBe(true);
expect(useWorkflowExecutionStateStore).toHaveBeenCalledWith(documentId);
doc.unpinNodeData('Alpha');
expect(hasPinnedData.value).toBe(false);
});
});
@@ -1,33 +1,522 @@
import { computed, effectScope, onScopeDispose, shallowReactive, type ComputedRef } from 'vue';
import isEqual from 'lodash/isEqual';
import { structuralComputed } from '@n8n/composables/structuralComputed';
import { useI18n } from '@n8n/i18n';
import type { INodeTypeDescription } from 'n8n-workflow';
import {
useWorkflowDocumentStore,
type WorkflowDocumentId,
} from '@/app/stores/workflowDocument.store';
import { useWorkflowExecutionStateStore } from '@/app/stores/workflowExecutionState.store';
import { useNodeTypesStore } from '@/app/stores/nodeTypes.store';
import { useNodeDirtiness } from '@/app/composables/useNodeDirtiness';
import { getNodeIconSource } from '@/app/utils/nodeIcon';
import { getNodeSubtitle, getTriggerNodeServiceName } from '@/app/utils/nodeTypesUtils';
import {
CUSTOM_API_CALL_KEY,
SIMULATE_NODE_TYPE,
SIMULATE_TRIGGER_NODE_TYPE,
STICKY_NODE_TYPE,
} from '@/app/constants';
import type { INodeUi } from '@/Interface';
import { checkOverlap } from '@/features/workflows/canvas/canvas.utils';
import type {
BoundingBox,
CanvasNode,
CanvasNodeAddNodesRender,
CanvasNodeChoicePromptRender,
CanvasNodeData,
CanvasNodeDefaultRender,
CanvasNodeStickyNoteRender,
} from '@/features/workflows/canvas/canvas.types';
import { CanvasNodeRenderType } from '@/features/workflows/canvas/canvas.types';
import { CHANGE_ACTION } from './types';
import type {
NodeAddedPayload,
NodeRemovedPayload,
NodesChangeEvent,
NodesSetPayload,
} from './useWorkflowDocumentNodes';
/**
* Canvas render data accessor for a workflow document.
*
* Thin façade that re-exposes:
* - the per-node port maps (`nodeInputsByNodeId`, `nodeOutputsByNodeId`)
* owned by `useWorkflowDocumentNodes`,
* - the pin data map (`pinnedDataByNodeName`) owned by
* `useWorkflowDocumentPinData`, and
* - the active execution's `executionIssuesByNodeName` map.
* Provides a single object combining:
* - by-node-id passthroughs from `workflowDocument` (port maps, pin data,
* validation errors) and `workflowExecutionState` (status / run data /
* output map / waiting / running / waiting-for-next / issues — each with
* active/displayed-execution fallback);
* - per-node-id derivations from the workflow document
* (`nodeTypeDescriptionByNodeId`, `isTriggerByNodeId`, `subtitleByNodeId`,
* `simulatedNodeTypeDescriptionByNodeId`);
* - **fusion projections** that combine state from both stores into the
* canvas-shaped outputs the renderer consumes: `tooltipByNodeId`,
* `hasIssuesByNodeId`, `additionalPropertiesByNodeId`,
* `renderTypeByNodeId`.
*
* Reactivity is owned by the underlying stores: the port maps are stable
* references on the workflow document store, `pinnedDataByNodeName` is a
* `shallowReactive` with per-key reactivity, and `executionIssuesByNodeName`
* resolves through a `computed` on the workflow execution state store that
* swaps between the active and displayed execution's per-execution maps.
* Per-entry maps use a `shallowReactive<Map<id, ComputedRef<T>>>` pattern:
* each entry runs in its own `effectScope` and `structuralComputed` gates
* downstream propagation. Entry lifecycle is driven by the document store's
* `onNodesChange` event, so add/remove are O(1) and updates re-evaluate
* lazily.
*
* Lifecycle: this is side-effectful (subscribes to `onNodesChange`, creates
* per-node scopes), so it must be invoked **once per document id inside an
* `effectScope` the caller owns** — never inside a re-evaluating `computed`.
* Stopping that scope runs the `onScopeDispose` teardown below. See
* `WorkflowCanvas.vue` and `useWorkflowDiff.ts` for the call pattern.
*/
export function useWorkflowDocumentRenderData(workflowDocumentId: WorkflowDocumentId) {
const workflowDocumentStore = useWorkflowDocumentStore(workflowDocumentId);
const executionStateStore = useWorkflowExecutionStateStore(workflowDocumentId);
const nodeTypesStore = useNodeTypesStore();
const i18n = useI18n();
const { dirtinessByName } = useNodeDirtiness(workflowDocumentId);
// -------------------------------------------------------------------------
// Per-entry maps reconciled off the document store's `onNodesChange`.
// -------------------------------------------------------------------------
const nodeTypeDescriptionByNodeId = shallowReactive(
new Map<string, ComputedRef<INodeTypeDescription | null>>(),
);
const isTriggerByNodeId = shallowReactive(new Map<string, ComputedRef<boolean>>());
const subtitleByNodeId = shallowReactive(new Map<string, ComputedRef<string>>());
const simulatedNodeTypeDescriptionByNodeId = shallowReactive(
new Map<string, ComputedRef<INodeTypeDescription | null>>(),
);
const tooltipByNodeId = shallowReactive(new Map<string, ComputedRef<string | undefined>>());
const hasIssuesByNodeId = shallowReactive(new Map<string, ComputedRef<boolean>>());
const renderTypeByNodeId = shallowReactive(
new Map<string, ComputedRef<CanvasNodeData['render']>>(),
);
const entryScopes = new Map<string, () => void>();
function getNode(nodeId: string): INodeUi | undefined {
return workflowDocumentStore.nodesById.get(nodeId);
}
// --- nodeTypeDescriptionByNodeId / isTriggerByNodeId / subtitleByNodeId / simulated ---
function computeNodeTypeDescription(nodeId: string): INodeTypeDescription | null {
const node = getNode(nodeId);
if (!node) return null;
return (
nodeTypesStore.getNodeType(node.type, node.typeVersion) ??
nodeTypesStore.communityNodeType(node.type)?.nodeDescription ??
null
);
}
function computeIsTrigger(nodeId: string): boolean {
const node = getNode(nodeId);
if (!node) return false;
return nodeTypesStore.isTriggerNode(node.type);
}
function computeSubtitle(nodeId: string): string {
const node = getNode(nodeId);
if (!node) return '';
try {
const nodeTypeDescription = computeNodeTypeDescription(nodeId);
if (!nodeTypeDescription) return '';
const subtitle = getNodeSubtitle(
node,
nodeTypeDescription,
workflowDocumentStore.getWorkflowObjectAccessorSnapshot(),
);
if (subtitle === undefined) return '';
// Subtitles that resolve to the "custom API call" placeholder are noise — hide them.
if (subtitle.includes(CUSTOM_API_CALL_KEY)) return '';
return subtitle;
} catch {
return '';
}
}
function computeSimulatedNodeTypeDescription(nodeId: string): INodeTypeDescription | null {
const node = getNode(nodeId);
if (!node) return null;
if (node.type !== SIMULATE_NODE_TYPE && node.type !== SIMULATE_TRIGGER_NODE_TYPE) return null;
const icon = node.parameters?.icon as string;
const iconValue = workflowDocumentStore
.getExpressionHandler()
.getSimpleParameterValue(node, icon, 'internal', {});
if (iconValue && typeof iconValue === 'string') {
return nodeTypesStore.getNodeType(iconValue) ?? null;
}
return null;
}
// --- tooltipByNodeId -----------------------------------------------------
// Original: useCanvasMapping `nodeTooltipById`. Surfaces a "waiting for
// trigger event" tooltip on the active trigger node while a workflow is
// running. Hides itself when more than one trigger is active and the
// driving trigger isn't identified yet, when the node is disabled, when
// it has pinned data, or when it has already produced/errored output.
function computeTooltip(nodeId: string): string | undefined {
if (!executionStateStore.isWorkflowRunning) return undefined;
const node = getNode(nodeId);
if (!node) return undefined;
const isTrigger = isTriggerByNodeId.get(nodeId)?.value ?? false;
const nodeTypeDescription = nodeTypeDescriptionByNodeId.get(nodeId)?.value ?? null;
if (!isTrigger || !nodeTypeDescription) return undefined;
const triggerNodeName = executionStateStore.activeExecution?.triggerNode;
// Count active (non-disabled) triggers; when ambiguous and the driving
// trigger isn't named, suppress all tooltips.
if (triggerNodeName === undefined) {
let activeTriggerCount = 0;
for (const [id] of isTriggerByNodeId) {
const n = workflowDocumentStore.nodesById.get(id);
if (!n) continue;
if (isTriggerByNodeId.get(id)?.value && !n.disabled) {
activeTriggerCount += 1;
}
}
if (activeTriggerCount !== 1) return undefined;
} else if (triggerNodeName !== node.name) {
return undefined;
}
if (node.disabled) return undefined;
const status = executionStateStore.activeExecutionStatusByNodeId.get(nodeId)?.value ?? 'new';
if (!['new', 'unknown', 'waiting'].includes(status)) return undefined;
const pinned = workflowDocumentStore.pinnedDataByNodeId.get(nodeId)?.value;
if (pinned) return undefined;
if (typeof nodeTypeDescription.eventTriggerDescription === 'string') {
const nodeName = i18n.shortNodeType(nodeTypeDescription.name);
return i18n
.nodeText(nodeTypeDescription.name)
.eventTriggerDescription(nodeName, nodeTypeDescription.eventTriggerDescription ?? '');
}
return i18n.baseText('node.waitingForYouToCreateAnEventIn', {
interpolate: {
nodeType: getTriggerNodeServiceName(nodeTypeDescription),
},
});
}
// --- hasIssuesByNodeId ---------------------------------------------------
// Original: useCanvasMapping `nodeHasIssuesById`. Decides whether the node
// renders with the "has issues" affordance. Hard-error execution statuses
// (crashed/error) win first — a pinned node that crashed still shows issues.
// Otherwise pinned data clears issues (false), then validation errors, the
// execution-issue map, and finally a last-task `.error` check.
function computeHasIssues(nodeId: string): boolean {
const node = getNode(nodeId);
if (!node) return false;
const status = executionStateStore.activeExecutionStatusByNodeId.get(nodeId)?.value ?? 'new';
if (status === 'crashed' || status === 'error') return true;
const pinned = workflowDocumentStore.pinnedDataByNodeId.get(nodeId)?.value;
if (pinned) return false;
const validationErrors =
workflowDocumentStore.validationErrorsByNodeId.get(nodeId)?.value ?? [];
if (validationErrors.length > 0) return true;
const executionIssues =
executionStateStore.activeExecutionIssuesByNodeName.get(node.name)?.value ?? [];
if (executionIssues.length > 0) return true;
const tasks = executionStateStore.activeExecutionRunDataByNodeId.get(nodeId)?.value ?? null;
return Boolean(tasks?.at(-1)?.error);
}
// --- renderTypeByNodeId --------------------------------------------------
// Original: useCanvasMapping `renderTypeByNodeId` + create*RenderType
// helpers. Returns the canvas render type/options the renderer reads.
function createStickyNoteRenderType(node: INodeUi): CanvasNodeStickyNoteRender {
return {
type: CanvasNodeRenderType.StickyNote,
options: {
width: node.parameters.width as number,
height: node.parameters.height as number,
color: node.parameters.color as number,
content: node.parameters.content as string,
},
};
}
function createAddNodesRenderType(): CanvasNodeAddNodesRender {
return { type: CanvasNodeRenderType.AddNodes, options: {} };
}
function createChoicePromptRenderType(): CanvasNodeChoicePromptRender {
return { type: CanvasNodeRenderType.ChoicePrompt, options: {} };
}
function createDefaultNodeRenderType(node: INodeUi): CanvasNodeDefaultRender {
const nodeType = nodeTypeDescriptionByNodeId.get(node.id)?.value ?? null;
const simulated = simulatedNodeTypeDescriptionByNodeId.get(node.id)?.value ?? null;
const iconSource = simulated ?? nodeType ?? node.type;
const icon = getNodeIconSource(iconSource, node, workflowDocumentStore.getExpressionHandler());
const isTrigger = isTriggerByNodeId.get(node.id)?.value ?? false;
const tooltip = tooltipByNodeId.get(node.id)?.value;
// Snapshot the workflow object accessors per-call. Reads inside the
// snapshot factory access the underlying stores' refs, so the
// surrounding structuralComputed picks up dependencies and re-evaluates
// when they change.
const workflowAccessors = workflowDocumentStore.getWorkflowObjectAccessorSnapshot();
return {
type: CanvasNodeRenderType.Default,
options: {
trigger: isTrigger,
configuration: nodeTypesStore.isConfigNode(workflowAccessors, node, node.type),
configurable: nodeTypesStore.isConfigurableNode(
workflowAccessors,
node,
node.type,
node.typeVersion,
),
tooltip,
dirtiness: dirtinessByName.value[node.name],
icon,
placeholder: node.placeholder,
},
};
}
function computeRenderType(nodeId: string): CanvasNodeData['render'] {
const node = getNode(nodeId);
if (!node) return { type: CanvasNodeRenderType.Default, options: {} };
switch (node.type) {
case `${CanvasNodeRenderType.StickyNote}`:
return createStickyNoteRenderType(node);
case `${CanvasNodeRenderType.AddNodes}`:
return createAddNodesRenderType();
case `${CanvasNodeRenderType.ChoicePrompt}`:
return createChoicePromptRenderType();
default:
return createDefaultNodeRenderType(node);
}
}
function applyAddEntry(nodeId: string) {
if (entryScopes.has(nodeId)) return;
const scope = effectScope();
scope.run(() => {
nodeTypeDescriptionByNodeId.set(
nodeId,
structuralComputed(() => computeNodeTypeDescription(nodeId), isEqual),
);
isTriggerByNodeId.set(
nodeId,
structuralComputed(() => computeIsTrigger(nodeId)),
);
subtitleByNodeId.set(
nodeId,
structuralComputed(() => computeSubtitle(nodeId)),
);
simulatedNodeTypeDescriptionByNodeId.set(
nodeId,
structuralComputed(() => computeSimulatedNodeTypeDescription(nodeId), isEqual),
);
tooltipByNodeId.set(
nodeId,
structuralComputed(() => computeTooltip(nodeId)),
);
hasIssuesByNodeId.set(
nodeId,
structuralComputed(() => computeHasIssues(nodeId)),
);
renderTypeByNodeId.set(
nodeId,
structuralComputed(() => computeRenderType(nodeId), isEqual),
);
});
entryScopes.set(nodeId, () => scope.stop());
}
function applyRemoveEntry(nodeId: string) {
entryScopes.get(nodeId)?.();
entryScopes.delete(nodeId);
nodeTypeDescriptionByNodeId.delete(nodeId);
isTriggerByNodeId.delete(nodeId);
subtitleByNodeId.delete(nodeId);
simulatedNodeTypeDescriptionByNodeId.delete(nodeId);
tooltipByNodeId.delete(nodeId);
hasIssuesByNodeId.delete(nodeId);
renderTypeByNodeId.delete(nodeId);
}
function applyReconcileEntries(nodeIds: string[]) {
const next = new Set(nodeIds);
for (const old of entryScopes.keys()) {
if (!next.has(old)) applyRemoveEntry(old);
}
for (const id of nodeIds) applyAddEntry(id);
}
let nodesChangeSubscription: { off: () => void } | undefined;
if (typeof workflowDocumentStore.onNodesChange === 'function') {
nodesChangeSubscription = workflowDocumentStore.onNodesChange((event: NodesChangeEvent) => {
switch (event.action) {
case CHANGE_ACTION.ADD: {
const { node } = event.payload as NodeAddedPayload;
applyAddEntry(node.id);
break;
}
case CHANGE_ACTION.DELETE: {
const payload = event.payload as NodeRemovedPayload;
if (payload.id) applyRemoveEntry(payload.id);
else applyReconcileEntries([]);
break;
}
case CHANGE_ACTION.SET: {
const { nodeIds } = event.payload as NodesSetPayload;
applyReconcileEntries(nodeIds);
break;
}
}
});
}
const initialIds = workflowDocumentStore.nodesById;
if (initialIds && typeof initialIds.keys === 'function') {
applyReconcileEntries(Array.from(initialIds.keys()));
}
// Teardown. This composable is invoked once per document id inside a
// caller-owned `effectScope` (see WorkflowCanvas.vue / useWorkflowDiff.ts).
// When that scope stops — on document-id change or unmount — we remove the
// `onNodesChange` subscription (otherwise the document store's event hook
// keeps a closure referencing this instance's maps alive and keeps running
// reconciliation) and stop every per-entry scope.
onScopeDispose(() => {
nodesChangeSubscription?.off();
for (const stop of entryScopes.values()) stop();
entryScopes.clear();
});
// -------------------------------------------------------------------------
// `additionalPropertiesByNodeId` — sticky-note z-index overlap resolution.
// Whole-list computation (changing one sticky can ripple to others via
// overlap), so a single `computed` rather than per-entry maps. The result
// is keyed by node id; consumers read individual slots.
// -------------------------------------------------------------------------
const additionalPropertiesByNodeId = computed(() => {
type StickyBox = BoundingBox & { id: string; area: number; zIndex: number };
const stickyZIndexBase = -100;
const stickyBoxes: StickyBox[] = [];
for (const node of workflowDocumentStore.allNodes) {
if (node.type !== STICKY_NODE_TYPE) continue;
const width = node.parameters.width as number;
const height = node.parameters.height as number;
stickyBoxes.push({
id: node.id,
x: node.position[0],
y: node.position[1],
width,
height,
area: width * height,
zIndex: stickyZIndexBase,
});
}
// Larger area first; assign baseline z-indexes in that order.
stickyBoxes.sort((a, b) => b.area - a.area);
stickyBoxes.forEach((box, idx) => {
box.zIndex = stickyZIndexBase + idx;
});
// Bump smaller overlapping stickies above their larger neighbours so
// they remain interactive when stacked.
for (let i = 0; i < stickyBoxes.length; i++) {
for (let j = i + 1; j < stickyBoxes.length; j++) {
const a = stickyBoxes[i];
const b = stickyBoxes[j];
if (!checkOverlap(a, b)) continue;
if (a.area < b.area && a.zIndex <= b.zIndex) a.zIndex = b.zIndex + 1;
else if (b.area < a.area && b.zIndex <= a.zIndex) b.zIndex = a.zIndex + 1;
}
}
const result: Record<string, Partial<CanvasNode>> = {};
for (const box of stickyBoxes) {
result[box.id] = { style: { zIndex: box.zIndex } };
}
return result;
});
return {
nodeInputsByNodeId: workflowDocumentStore.nodeInputsByNodeId,
nodeOutputsByNodeId: workflowDocumentStore.nodeOutputsByNodeId,
pinnedDataByNodeName: workflowDocumentStore.pinnedDataByNodeName,
executionIssuesByNodeName: executionStateStore.activeExecutionIssuesByNodeName,
// --- workflowDocument projections ---
// All exposed as getters, mirroring the execution-state block below.
// `pinnedDataByNodeName` requires it: pin mutations *replace* the
// underlying ref's value (immutable-update pattern), and this setup
// runs once per document id in an untracked scope — a direct capture
// would be a permanently stale snapshot. The getter re-resolves through
// the store on each access, so consumer computeds track the ref and
// invalidate on pin/unpin. The by-id maps are stable containers today,
// but getters keep the store passthroughs uniform and stay correct if
// any later becomes resolver-backed.
get nodeInputsByNodeId() {
return workflowDocumentStore.nodeInputsByNodeId;
},
get nodeOutputsByNodeId() {
return workflowDocumentStore.nodeOutputsByNodeId;
},
get pinnedDataByNodeName() {
return workflowDocumentStore.pinnedDataByNodeName;
},
get pinnedDataByNodeId() {
return workflowDocumentStore.pinnedDataByNodeId;
},
get validationErrorsByNodeId() {
return workflowDocumentStore.validationErrorsByNodeId;
},
// --- node-type derivations (inlined) ---
nodeTypeDescriptionByNodeId,
isTriggerByNodeId,
subtitleByNodeId,
simulatedNodeTypeDescriptionByNodeId,
// --- workflowExecutionState projections ---
// All exposed as getters. Setup runs once per document id, but the
// `active*` maps swap identity when the active/displayed execution
// changes, so they must re-resolve on access to stay reactive. The
// running / waiting-for-next maps are stable today, but keeping every
// execution-state passthrough a getter is uniform and stays correct if
// any later becomes resolver-backed — and a getter over a stable ref
// behaves identically to capturing it. (Consumers read these inside
// their own computeds and none destructure them.)
get executionIssuesByNodeName() {
return executionStateStore.activeExecutionIssuesByNodeName;
},
get executionStatusByNodeId() {
return executionStateStore.activeExecutionStatusByNodeId;
},
get executionRunDataByNodeId() {
return executionStateStore.activeExecutionRunDataByNodeId;
},
get executionRunDataOutputMapByNodeId() {
return executionStateStore.activeExecutionRunDataOutputMapByNodeId;
},
get executionWaitingByNodeId() {
return executionStateStore.activeExecutionWaitingByNodeId;
},
get executionRunningByNodeId() {
return executionStateStore.executionRunningByNodeId;
},
get executionWaitingForNextByNodeId() {
return executionStateStore.executionWaitingForNextByNodeId;
},
// --- multi-store fusion projections ---
tooltipByNodeId,
hasIssuesByNodeId,
renderTypeByNodeId,
additionalPropertiesByNodeId,
};
}
File diff suppressed because it is too large Load Diff
@@ -1,8 +1,18 @@
import { defineStore, getActivePinia } from 'pinia';
import { STORES } from '@n8n/stores';
import { computed, inject, readonly, ref, type ComputedRef } from 'vue';
import {
computed,
effectScope,
inject,
onScopeDispose,
readonly,
ref,
shallowReactive,
type ComputedRef,
} from 'vue';
import { createEventHook } from '@vueuse/core';
import type { ExecutionSummary, IRunExecutionData } from 'n8n-workflow';
import { structuralComputed } from '@n8n/composables/structuralComputed';
import type { ExecutionStatus, ExecutionSummary, IRunExecutionData, ITaskData } from 'n8n-workflow';
import type { NodeExecuteBefore } from '@n8n/api-types/push/execution';
import type {
IExecutionResponse,
@@ -22,8 +32,19 @@ import { useDocumentTitle } from '@/app/composables/useDocumentTitle';
import { clearPopupWindowState } from '@/features/execution/executions/executions.utils';
import { CHANGE_ACTION } from './workflowDocument/types';
import type { ChangeAction, ChangeEvent } from './workflowDocument/types';
import type {
NodeAddedPayload,
NodeRemovedPayload,
NodesChangeEvent,
NodesSetPayload,
} from './workflowDocument/useWorkflowDocumentNodes';
import type { ExecutionOutputMap } from '@/app/types/executionData';
const EMPTY_EXECUTION_ISSUES_BY_NODE_NAME = new Map<string, ComputedRef<string[]>>();
const EMPTY_EXECUTION_STATUS_BY_NODE_ID = new Map<string, ComputedRef<ExecutionStatus>>();
const EMPTY_EXECUTION_RUN_DATA_BY_NODE_ID = new Map<string, ComputedRef<ITaskData[] | null>>();
const EMPTY_EXECUTION_RUN_DATA_OUTPUT_MAP_BY_NODE_ID = new Map<string, ExecutionOutputMap>();
const EMPTY_EXECUTION_WAITING_BY_NODE_ID = new Map<string, ComputedRef<string | undefined>>();
export type WorkflowExecutionStateChangePayload = {
documentId: WorkflowDocumentId;
@@ -236,6 +257,38 @@ export function useWorkflowExecutionStateStore(id: WorkflowDocumentId) {
return EMPTY_EXECUTION_ISSUES_BY_NODE_NAME;
});
// Active/displayed/pending fallback for the per-node-id execution data
// projections. Resolves the backing execution id via
// `getResolvedActiveExecutionId()` (string id → that execution, pending
// `null` → IN_PROGRESS scaffold, else displayed id) so these stay
// consistent with `activeExecutionRunData`; falls back to an empty Map
// sentinel only when no execution is being tracked.
const activeExecutionStatusByNodeId = computed(() => {
const executionId = getResolvedActiveExecutionId();
if (!executionId) return EMPTY_EXECUTION_STATUS_BY_NODE_ID;
return useExecutionDataStore(createExecutionDataId(executionId)).executionStatusByNodeId;
});
const activeExecutionRunDataByNodeId = computed(() => {
const executionId = getResolvedActiveExecutionId();
if (!executionId) return EMPTY_EXECUTION_RUN_DATA_BY_NODE_ID;
return useExecutionDataStore(createExecutionDataId(executionId)).executionRunDataByNodeId;
});
const activeExecutionRunDataOutputMapByNodeId = computed(() => {
const executionId = getResolvedActiveExecutionId();
if (!executionId) return EMPTY_EXECUTION_RUN_DATA_OUTPUT_MAP_BY_NODE_ID;
return useExecutionDataStore(createExecutionDataId(executionId))
.executionRunDataOutputMapByNodeId;
});
const activeExecutionWaitingByNodeId = computed(() => {
const executionId = getResolvedActiveExecutionId();
if (!executionId) return EMPTY_EXECUTION_WAITING_BY_NODE_ID;
return useExecutionDataStore(createExecutionDataId(executionId)).executionWaitingByNodeId;
});
const lastSuccessfulExecution = computed(() => {
const lid = lastSuccessfulExecutionId.value;
if (!lid) return null;
@@ -255,6 +308,121 @@ export function useWorkflowExecutionStateStore(id: WorkflowDocumentId) {
return false;
});
// ---------------------------------------------------------------------
// Per-node-id "is this node mid-execution?" projections.
//
// Reconciled against the matching workflowDocument store's `onNodesChange`.
// Each per-entry structuralComputed reads the `executingNode` refs
// reactively, so add/remove calls invalidate only that entry — and only
// when the *value* changes (gated by structural equality).
// ---------------------------------------------------------------------
const documentStore = useWorkflowDocumentStore(documentId);
const executionRunningByNodeId = shallowReactive(new Map<string, ComputedRef<boolean>>());
const executionWaitingForNextByNodeId = shallowReactive(
new Map<string, ComputedRef<boolean>>(),
);
const runningScopes = new Map<string, () => void>();
function computeExecutionRunning(nodeId: string): boolean {
// `nodesById` is a top-level shallowRef inside useWorkflowDocumentNodes;
// Pinia unwraps it to a Map at the store boundary.
const node = documentStore.nodesById.get(nodeId);
if (!node) return false;
return executingNode.isNodeExecuting(node.name);
}
function computeExecutionWaitingForNext(nodeId: string): boolean {
const node = documentStore.nodesById.get(nodeId);
if (!node) return false;
return (
node.name === executingNode.lastAddedExecutingNode.value &&
executingNode.executingNode.value.length === 0 &&
isWorkflowRunning.value
);
}
function applyAddRunningEntry(nodeId: string) {
if (runningScopes.has(nodeId)) return;
const scope = effectScope();
scope.run(() => {
executionRunningByNodeId.set(
nodeId,
structuralComputed(() => computeExecutionRunning(nodeId)),
);
executionWaitingForNextByNodeId.set(
nodeId,
structuralComputed(() => computeExecutionWaitingForNext(nodeId)),
);
});
runningScopes.set(nodeId, () => scope.stop());
}
function applyRemoveRunningEntry(nodeId: string) {
runningScopes.get(nodeId)?.();
runningScopes.delete(nodeId);
executionRunningByNodeId.delete(nodeId);
executionWaitingForNextByNodeId.delete(nodeId);
}
function applyReconcileRunningEntries(nodeIds: string[]) {
const next = new Set(nodeIds);
for (const old of runningScopes.keys()) {
if (!next.has(old)) applyRemoveRunningEntry(old);
}
for (const id of nodeIds) applyAddRunningEntry(id);
}
// Subscribe lazily and defensively. Some test files mock
// `useWorkflowDocumentStore` with a partial object that lacks
// `onNodesChange` / `nodesById`. The guard keeps the dependency soft for
// tests that don't exercise the running maps; in production the document
// store always provides the full surface.
if (typeof documentStore.onNodesChange === 'function') {
documentStore.onNodesChange((event: NodesChangeEvent) => {
switch (event.action) {
case CHANGE_ACTION.ADD: {
const { node } = event.payload as NodeAddedPayload;
applyAddRunningEntry(node.id);
break;
}
case CHANGE_ACTION.DELETE: {
const payload = event.payload as NodeRemovedPayload;
if (payload.id) {
applyRemoveRunningEntry(payload.id);
} else {
applyReconcileRunningEntries([]);
}
break;
}
case CHANGE_ACTION.SET: {
const { nodeIds } = event.payload as NodesSetPayload;
applyReconcileRunningEntries(nodeIds);
break;
}
}
});
}
const initialNodesById = documentStore.nodesById;
if (initialNodesById && typeof initialNodesById.keys === 'function') {
applyReconcileRunningEntries(Array.from(initialNodesById.keys()));
}
// Scopes created from `onNodesChange` callbacks have no active parent
// (event dispatch runs outside any scope), so `$dispose()` never
// reaches them. Vue 3.5 computeds are not scope-owned and detach from
// deps once unsubscribed, so this is deterministic cleanup hygiene
// rather than leak prevention: stop the scopes and drop the per-node
// entries when the store is disposed.
onScopeDispose(() => {
for (const stop of runningScopes.values()) stop();
runningScopes.clear();
executionRunningByNodeId.clear();
executionWaitingForNextByNodeId.clear();
});
/**
* Resolves the trigger node name driving the active execution.
* Falls back to scanning runData keys for partial executions.
@@ -669,6 +837,12 @@ export function useWorkflowExecutionStateStore(id: WorkflowDocumentId) {
getPastChatMessages,
getActiveExecutionRunDataByNodeName,
activeExecutionIssuesByNodeName,
activeExecutionStatusByNodeId,
activeExecutionRunDataByNodeId,
activeExecutionRunDataOutputMapByNodeId,
activeExecutionWaitingByNodeId,
executionRunningByNodeId,
executionWaitingForNextByNodeId,
resolveExecutionTriggerNodeName,
// Write API
trackExecutionId,
@@ -0,0 +1,29 @@
/**
* Per-output aggregation of execution run data.
*
* `total` — items emitted across all iterations.
* `iterations` — number of non-canceled task iterations contributing.
* `byTarget` — present for non-main connection types (e.g. AI tools / memory /
* embeddings). Tracks the same `{ total, iterations }` per target node id, so
* label rendering can show counts per fan-out target. Keyed by node id.
*/
export type ExecutionOutputMapData = {
total: number;
iterations: number;
byTarget?: {
[targetNodeId: string]: {
total: number;
iterations: number;
};
};
};
/**
* Aggregated output stats for a single node, indexed first by connection type
* (`main`, `ai_tool`, etc.) and then by output index (as a string).
*/
export type ExecutionOutputMap = {
[connectionType: string]: {
[outputIndex: string]: ExecutionOutputMapData;
};
};
@@ -1,3 +1,4 @@
export type * from './executionData';
export type * from './externalHooks';
export type * from './pushConnection';
export type * from './workflow';
@@ -10,8 +10,10 @@ import {
MAIN_AUTH_FIELD_NAME,
MAPPING_PARAMS,
NON_ACTIVATABLE_TRIGGER_NODE_TYPES,
PLACEHOLDER_FILLED_AT_EXECUTION_TIME,
TEMPLATES_NODES_FILTER,
} from '@/app/constants';
import type { WorkflowObjectAccessors } from '@/app/types/workflow';
import { i18n as locale } from '@n8n/i18n';
import { useCredentialsStore } from '@/features/credentials/credentials.store';
import { useNodeTypesStore } from '@/app/stores/nodeTypes.store';
@@ -19,9 +21,11 @@ import { isJsonKeyObject } from '@/app/utils/typesUtils';
import {
isResourceLocatorValue,
type IDataObject,
type INode,
type INodeCredentialDescription,
type INodeExecutionData,
type INodeProperties,
type INodePropertyOptions,
type INodeTypeDescription,
type NodeParameterValueType,
type ResourceMapperField,
@@ -62,6 +66,71 @@ export function getTriggerNodeServiceName(nodeType: INodeTypeDescription): strin
return nodeType.displayName.replace(/ trigger/i, '');
}
/**
* Derives the subtitle displayed under a node's name on the canvas.
*
* Pure function: reads only its parameters — it must not access stores or
* `inject()`, because `useWorkflowDocumentRenderData` calls it from detached
* effect scopes outside component setup (watch callbacks in
* `WorkflowCanvas.vue` / `useWorkflowDiff.ts`).
*/
export function getNodeSubtitle(
data: INode,
nodeType: INodeTypeDescription,
workflow: WorkflowObjectAccessors,
): string | undefined {
if (!data) {
return undefined;
}
if (data.notesInFlow) {
return data.notes;
}
if (nodeType?.subtitle !== undefined) {
try {
return workflow.expression.getSimpleParameterValue(
data,
nodeType.subtitle,
'internal',
{},
undefined,
PLACEHOLDER_FILLED_AT_EXECUTION_TIME,
) as string | undefined;
} catch (e) {
return undefined;
}
}
if (data.parameters.operation !== undefined) {
const operation = data.parameters.operation as string;
if (nodeType === null) {
return operation;
}
const operationData = nodeType.properties.find((property: INodeProperties) => {
return property.name === 'operation';
});
if (operationData === undefined) {
return operation;
}
if (operationData.options === undefined) {
return operation;
}
const optionData = operationData.options.find((option) => {
return (option as INodePropertyOptions).value === data.parameters.operation;
});
if (optionData === undefined) {
return operation;
}
return optionData.name;
}
return undefined;
}
export function getActivatableTriggerNodes(nodes: INodeUi[]) {
return nodes.filter(
(node: INodeUi) => !node.disabled && !NON_ACTIVATABLE_TRIGGER_NODE_TYPES.includes(node.type),
@@ -17,6 +17,20 @@ import { useNodeTypesStore } from '@/app/stores/nodeTypes.store';
import { getCredentialTypeName, isCredentialOnlyNodeType } from '@/app/utils/credentialOnlyNodes';
import { hasProxyAuth } from '@/app/utils/nodeTypesUtils';
/**
* Assigns a freshly generated id to the given node and returns it.
*
* Pure with respect to application state: it touches only its argument — it
* must not access stores or `inject()`, because the workflow document store
* wires it into `useWorkflowDocumentNodes` and may itself be constructed
* outside component setup (e.g. from `useWorkflowDiff`'s watch effects).
*/
export function assignNodeId(node: INodeUi): string {
const id = window.crypto.randomUUID();
node.id = id;
return id;
}
/**
* Returns the credentials that are displayable for the given node.
*/
@@ -87,7 +87,7 @@ const workflowExecutionStateStore = computed(() =>
const telemetry = useTelemetry();
const i18n = useI18n();
const activeNode = computed(() => ndvStore.value.activeNode);
const { dirtinessByName } = useNodeDirtiness();
const { dirtinessByName } = useNodeDirtiness(() => workflowDocumentStore.value.documentId);
const uiStore = useUIStore();
// Composables
@@ -8,8 +8,8 @@ import type {
CanvasNodeHandleInjectionData,
CanvasNodeInjectionData,
ConnectStartEvent,
ExecutionOutputMapData,
} from '@/features/workflows/canvas/canvas.types';
import type { ExecutionOutputMapData } from '@/app/types/executionData';
import {
CanvasConnectionMode,
CanvasNodeRenderType,
@@ -19,6 +19,7 @@ import type { ComputedRef, Ref } from 'vue';
import type { EventBus } from '@n8n/utils/event-bus';
import type { CanvasLayoutSource } from '@/features/workflows/canvas/composables/useCanvasLayout';
import type { NodeIconSource } from '@/app/utils/nodeIcon';
import type { ExecutionOutputMap, ExecutionOutputMapData } from '@/app/types/executionData';
export const enum CanvasConnectionMode {
Input = 'inputs',
@@ -243,23 +244,6 @@ export type ConnectStartEvent = {
export type CanvasNodeMoveEvent = { id: string; position: CanvasNode['position'] };
export type ExecutionOutputMapData = {
total: number;
iterations: number;
byTarget?: {
[targetNodeId: string]: {
total: number;
iterations: number;
};
};
};
export type ExecutionOutputMap = {
[connectionType: string]: {
[outputIndex: string]: ExecutionOutputMapData;
};
};
export type BoundingBox = {
x: number;
y: number;
@@ -4,7 +4,7 @@ import type {
INodeTypeDescription,
NodeConnectionType,
} from 'n8n-workflow';
import type { Ref } from 'vue';
import { computed, shallowReactive, type Ref } from 'vue';
import type { INodeUi } from '@/Interface';
import type {
BoundingBox,
@@ -67,6 +67,47 @@ export function injectCanvasRenderData(): Ref<CanvasRenderData> {
return injectStrict(CanvasRenderDataKey);
}
/**
* Builds an empty `CanvasRenderData` object.
*
* `CanvasRenderData` is a wide projection façade — production code populates
* it via `useWorkflowDocumentRenderData(documentId)`. This helper exists for
* the two cases that can't go through that path:
* - placeholder values before the underlying workflow document is hydrated
* (e.g. the workflow-diff side panels' initial render);
* - test fixtures that only care about a few fields.
*
* Centralizing it here keeps the ~20+ consumers off the hook when new by-id
* projections land — they update one default at a time, not 20 mock literals.
*/
export function createEmptyCanvasRenderData(
overrides: Partial<CanvasRenderData> = {},
): CanvasRenderData {
return {
nodeInputsByNodeId: shallowReactive(new Map()),
nodeOutputsByNodeId: shallowReactive(new Map()),
pinnedDataByNodeName: {},
pinnedDataByNodeId: shallowReactive(new Map()),
nodeTypeDescriptionByNodeId: shallowReactive(new Map()),
isTriggerByNodeId: shallowReactive(new Map()),
subtitleByNodeId: shallowReactive(new Map()),
simulatedNodeTypeDescriptionByNodeId: shallowReactive(new Map()),
validationErrorsByNodeId: shallowReactive(new Map()),
executionIssuesByNodeName: shallowReactive(new Map()),
executionStatusByNodeId: shallowReactive(new Map()),
executionRunDataByNodeId: shallowReactive(new Map()),
executionRunDataOutputMapByNodeId: shallowReactive(new Map()),
executionWaitingByNodeId: shallowReactive(new Map()),
executionRunningByNodeId: shallowReactive(new Map()),
executionWaitingForNextByNodeId: shallowReactive(new Map()),
tooltipByNodeId: shallowReactive(new Map()),
hasIssuesByNodeId: shallowReactive(new Map()),
renderTypeByNodeId: shallowReactive(new Map()),
additionalPropertiesByNodeId: computed(() => ({})),
...overrides,
};
}
/**
* Maps multiple legacy n8n connections to VueFlow connections
*/
@@ -6,8 +6,19 @@ import { createEventBus } from '@n8n/utils/event-bus';
import type { ViewportTransform } from '@vue-flow/core';
import { getRectOfNodes, useVueFlow } from '@vue-flow/core';
import { throttledRef } from '@vueuse/core';
import { computed, ref, useCssModule, useTemplateRef } from 'vue';
import {
computed,
effectScope,
onScopeDispose,
ref,
shallowRef,
useCssModule,
useTemplateRef,
watch,
type EffectScope,
} from 'vue';
import type { CanvasEventBusEvents } from '../canvas.types';
import { createEmptyCanvasRenderData, type CanvasRenderData } from '../canvas.utils';
import { useCanvasMapping } from '../composables/useCanvasMapping';
import { mapGroupsToVueFlowNodes } from '../composables/useCanvasMapping.groups';
import Canvas from './Canvas.vue';
@@ -45,16 +56,31 @@ const props = withDefaults(
const canvasRef = useTemplateRef('canvas');
const $style = useCssModule();
const workflowDocumentStore = injectWorkflowDocumentStore();
const renderData = computed(() =>
useWorkflowDocumentRenderData(workflowDocumentStore.value.documentId),
// `useWorkflowDocumentRenderData` is side-effectful (subscribes to the document
// store and creates per-node effect scopes), so it must run once per document
// id inside a scope we own — not inside a re-evaluating `computed`. We rebuild
// it only when the document id actually changes, stopping the previous scope
// (which runs the composable's teardown). The `watch` callback runs outside
// reactive tracking, so the composable's internal reactive reads don't cause
// re-invocation.
const renderData = shallowRef<CanvasRenderData>(createEmptyCanvasRenderData());
let renderDataScope: EffectScope | undefined;
watch(
() => workflowDocumentStore.value.documentId,
(documentId) => {
renderDataScope?.stop();
renderDataScope = effectScope(true);
renderDataScope.run(() => {
renderData.value = useWorkflowDocumentRenderData(documentId);
});
},
{ immediate: true },
);
onScopeDispose(() => renderDataScope?.stop());
const { onNodesInitialized, viewport, viewportRef, getNodes, fitBounds } = useVueFlow(props.id);
const workflowObject = computed(() =>
workflowDocumentStore.value.getWorkflowObjectAccessorSnapshot(),
);
const nodes = computed(() => {
return props.showFallbackNodes
? [...workflowDocumentStore.value.allNodes, ...props.fallbackNodes]
@@ -75,7 +101,6 @@ const {
} = useCanvasMapping({
nodes,
connections,
workflowObject,
renderData,
isExperimentalNdvActive,
});
@@ -1,6 +1,9 @@
import { useVueFlow, type GraphNode, type VueFlowStore } from '@vue-flow/core';
import { computed, ref, shallowRef } from 'vue';
import type { CanvasRenderData } from '@/features/workflows/canvas/canvas.utils';
import {
createEmptyCanvasRenderData,
type CanvasRenderData,
} from '@/features/workflows/canvas/canvas.utils';
import {
createCanvasGraphEdge,
createCanvasGraphNode,
@@ -43,12 +46,7 @@ describe('useCanvasLayout', () => {
const { layout } = useCanvasLayout(
'test-canvas-id',
computed(() => false),
shallowRef<CanvasRenderData>({
nodeInputsByNodeId: new Map(),
nodeOutputsByNodeId: new Map(),
pinnedDataByNodeName: {},
executionIssuesByNodeName: new Map(),
}),
shallowRef<CanvasRenderData>(createEmptyCanvasRenderData()),
);
return { layout };
@@ -4,566 +4,51 @@
*/
import { useI18n } from '@n8n/i18n';
import { useNodeTypesStore } from '@/app/stores/nodeTypes.store';
import { useWorkflowsStore } from '@/app/stores/workflows.store';
import { injectWorkflowDocumentStore } from '@/app/stores/workflowDocument.store';
import { useWorkflowExecutionStateStore } from '@/app/stores/workflowExecutionState.store';
import type { CanvasRenderData } from '../canvas.utils';
import type { Ref } from 'vue';
import { ref, computed } from 'vue';
import { computed, ref } from 'vue';
import type {
BoundingBox,
CanvasConnection,
CanvasConnectionData,
CanvasNode,
CanvasNodeAddNodesRender,
CanvasNodeChoicePromptRender,
CanvasNodeData,
CanvasNodeDefaultRender,
CanvasNodeStickyNoteRender,
ExecutionOutputMap,
} from '../canvas.types';
import { CanvasConnectionMode, CanvasNodeRenderType } from '../canvas.types';
import {
checkOverlap,
computeNodeDisplaySize,
mapLegacyConnectionsToCanvasConnections,
parseCanvasConnectionHandleString,
} from '../canvas.utils';
import type {
ExecutionStatus,
ExecutionSummary,
IConnections,
INodeExecutionData,
INodeTypeDescription,
ITaskData,
} from 'n8n-workflow';
import { NodeConnectionTypes, SEND_AND_WAIT_OPERATION, WAIT_INDEFINITELY } from 'n8n-workflow';
import type { IConnections, ITaskData } from 'n8n-workflow';
import { NodeConnectionTypes } from 'n8n-workflow';
import type { INodeUi } from '@/Interface';
import {
CANVAS_EXECUTION_DATA_THROTTLE_DURATION,
CUSTOM_API_CALL_KEY,
FORM_NODE_TYPE,
SIMULATE_NODE_TYPE,
SIMULATE_TRIGGER_NODE_TYPE,
STICKY_NODE_TYPE,
WAIT_NODE_TYPE,
} from '@/app/constants';
import { MarkerType } from '@vue-flow/core';
import { useNodeHelpers } from '@/app/composables/useNodeHelpers';
import { getTriggerNodeServiceName } from '@/app/utils/nodeTypesUtils';
import { useNodeDirtiness } from '@/app/composables/useNodeDirtiness';
import { getNodeIconSource } from '@/app/utils/nodeIcon';
import * as workflowUtils from 'n8n-workflow/common';
import { throttledWatch } from '@vueuse/core';
import type { WorkflowObjectAccessors } from '@/app/types';
/**
* Maps workflow nodes and connections into the vue-flow canvas shape.
*
* All per-node-id projections (subtitle, validation errors, execution status,
* render type, sticky-note z-index, etc.) live on `renderData`, produced by
* `useWorkflowDocumentRenderData`. This composable is the final glue layer —
* it reads from `renderData` and `connections` to assemble `CanvasNode` and
* `CanvasConnection` objects.
*/
export function useCanvasMapping({
nodes,
connections,
workflowObject,
renderData,
isExperimentalNdvActive = ref(false),
}: {
nodes: Ref<INodeUi[]>;
connections: Ref<IConnections>;
workflowObject: Ref<WorkflowObjectAccessors>;
renderData: Ref<CanvasRenderData>;
isExperimentalNdvActive?: Ref<boolean>;
}) {
const i18n = useI18n();
const workflowsStore = useWorkflowsStore();
const workflowDocumentStore = injectWorkflowDocumentStore();
const workflowExecutionStateStore = computed(() =>
useWorkflowExecutionStateStore(workflowDocumentStore.value.documentId),
);
const nodeTypesStore = useNodeTypesStore();
const nodeHelpers = useNodeHelpers();
const { dirtinessByName } = useNodeDirtiness();
function createStickyNoteRenderType(node: INodeUi): CanvasNodeStickyNoteRender {
return {
type: CanvasNodeRenderType.StickyNote,
options: {
width: node.parameters.width as number,
height: node.parameters.height as number,
color: node.parameters.color as number,
content: node.parameters.content as string,
},
};
}
function createAddNodesRenderType(): CanvasNodeAddNodesRender {
return {
type: CanvasNodeRenderType.AddNodes,
options: {},
};
}
function createChoicePromptRenderType(): CanvasNodeChoicePromptRender {
return {
type: CanvasNodeRenderType.ChoicePrompt,
options: {},
};
}
function createDefaultNodeRenderType(node: INodeUi): CanvasNodeDefaultRender {
const nodeType = nodeTypeDescriptionByNodeId.value[node.id];
const source = simulatedNodeTypeDescriptionByNodeId.value[node.id] ?? nodeType ?? node.type;
const icon = getNodeIconSource(
source,
node,
workflowDocumentStore.value.getExpressionHandler(),
);
return {
type: CanvasNodeRenderType.Default,
options: {
trigger: isTriggerNodeById.value[node.id],
configuration: nodeTypesStore.isConfigNode(workflowObject.value, node, node.type),
configurable: nodeTypesStore.isConfigurableNode(
workflowObject.value,
node,
node.type,
node.typeVersion,
),
tooltip: nodeTooltipById.value[node.id],
dirtiness: dirtinessByName.value[node.name],
icon,
placeholder: node.placeholder,
},
};
}
const renderTypeByNodeId = computed(
() =>
nodes.value.reduce<Record<string, CanvasNodeData['render']>>((acc, node) => {
switch (node.type) {
case `${CanvasNodeRenderType.StickyNote}`:
acc[node.id] = createStickyNoteRenderType(node);
break;
case `${CanvasNodeRenderType.AddNodes}`:
acc[node.id] = createAddNodesRenderType();
break;
case `${CanvasNodeRenderType.ChoicePrompt}`:
acc[node.id] = createChoicePromptRenderType();
break;
default:
acc[node.id] = createDefaultNodeRenderType(node);
}
return acc;
}, {}) ?? {},
);
const nodeTypeDescriptionByNodeId = computed(() =>
nodes.value.reduce<Record<string, INodeTypeDescription | null>>((acc, node) => {
acc[node.id] =
nodeTypesStore.getNodeType(node.type, node.typeVersion) ??
nodeTypesStore.communityNodeType(node.type)?.nodeDescription ??
null;
return acc;
}, {}),
);
const isTriggerNodeById = computed(() =>
nodes.value.reduce<Record<string, boolean>>((acc, node) => {
acc[node.id] = nodeTypesStore.isTriggerNode(node.type);
return acc;
}, {}),
);
const nodeSubtitleById = computed(() => {
return nodes.value.reduce<Record<string, string>>((acc, node) => {
try {
const nodeTypeDescription = nodeTypeDescriptionByNodeId.value[node.id];
if (!nodeTypeDescription) {
return acc;
}
const nodeSubtitle =
nodeHelpers.getNodeSubtitle(node, nodeTypeDescription, workflowObject.value) ?? '';
if (nodeSubtitle.includes(CUSTOM_API_CALL_KEY)) {
return acc;
}
acc[node.id] = nodeSubtitle;
} catch (e) {}
return acc;
}, {});
});
const nodePinnedDataById = computed(() =>
nodes.value.reduce<Record<string, INodeExecutionData[] | undefined>>((acc, node) => {
acc[node.id] = workflowDocumentStore.value.getNodePinData(node.name);
return acc;
}, {}),
);
const nodeTooltipById = computed(() => {
if (!workflowExecutionStateStore.value.isWorkflowRunning) {
return {};
}
const activeTriggerNodeCount = nodes.value.filter(
(node) => isTriggerNodeById.value[node.id] && !node.disabled,
).length;
const triggerNodeName = workflowsStore.getWorkflowExecution?.triggerNode;
// For workflows with multiple active trigger nodes, we show a tooltip only when
// trigger node name is known
if (triggerNodeName === undefined && activeTriggerNodeCount !== 1) {
return {};
}
return nodes.value.reduce<Record<string, string | undefined>>((acc, node) => {
const nodeTypeDescription = nodeTypeDescriptionByNodeId.value[node.id];
if (nodeTypeDescription && isTriggerNodeById.value[node.id]) {
if (
!!node.disabled ||
(triggerNodeName !== undefined && triggerNodeName !== node.name) ||
!['new', 'unknown', 'waiting'].includes(nodeExecutionStatusById.value[node.id]) ||
nodePinnedDataById.value[node.id]
) {
return acc;
}
if (typeof nodeTypeDescription.eventTriggerDescription === 'string') {
const nodeName = i18n.shortNodeType(nodeTypeDescription.name);
const { eventTriggerDescription } = nodeTypeDescription;
acc[node.id] = i18n
.nodeText(nodeTypeDescription.name)
.eventTriggerDescription(nodeName, eventTriggerDescription ?? '');
} else {
acc[node.id] = i18n.baseText('node.waitingForYouToCreateAnEventIn', {
interpolate: {
nodeType: nodeTypeDescription ? getTriggerNodeServiceName(nodeTypeDescription) : '',
},
});
}
}
return acc;
}, {});
});
const nodeExecutionRunningById = computed(() =>
nodes.value.reduce<Record<string, boolean>>((acc, node) => {
acc[node.id] = workflowExecutionStateStore.value.executingNode.isNodeExecuting(node.name);
return acc;
}, {}),
);
const nodeExecutionWaitingForNextById = computed(() =>
nodes.value.reduce<Record<string, boolean>>((acc, node) => {
acc[node.id] =
node.name === workflowExecutionStateStore.value.executingNode.lastAddedExecutingNode &&
workflowExecutionStateStore.value.executingNode.executingNode.length === 0 &&
workflowExecutionStateStore.value.isWorkflowRunning;
return acc;
}, {}),
);
const nodeExecutionStatusById = computed(() =>
nodes.value.reduce<Record<string, ExecutionStatus>>((acc, node) => {
const tasks = workflowsStore.getWorkflowRunData?.[node.name] ?? [];
let lastExecutionStatus = tasks.at(-1)?.executionStatus;
if (tasks.length > 1 && lastExecutionStatus === 'canceled') {
lastExecutionStatus = tasks.at(-2)?.executionStatus;
}
acc[node.id] = lastExecutionStatus ?? 'new';
return acc;
}, {}),
);
const nodeExecutionRunDataById = computed(() =>
nodes.value.reduce<Record<string, ITaskData[] | null>>((acc, node) => {
acc[node.id] = workflowsStore.getWorkflowResultDataByNodeName(node.name);
return acc;
}, {}),
);
// Create a map for O(1) node lookups by name
const nodesByName = computed(() => new Map(nodes.value.map((n) => [n.name, n])));
const nodeExecutionRunDataOutputMapById = ref<Record<string, ExecutionOutputMap>>({});
throttledWatch(
() => workflowsStore.workflowExecutionResultDataLastUpdate,
() => {
nodeExecutionRunDataOutputMapById.value = Object.keys(nodeExecutionRunDataById.value).reduce<
Record<string, ExecutionOutputMap>
>((acc, nodeId) => {
acc[nodeId] = {};
const outputData = { iterations: 0, total: 0 };
for (const runIteration of nodeExecutionRunDataById.value[nodeId] ?? []) {
const data = runIteration.data ?? {};
for (const connectionType of Object.keys(data)) {
const connectionTypeData = data[connectionType] ?? {};
acc[nodeId][connectionType] = acc[nodeId][connectionType] ?? {};
for (const outputIndex of Object.keys(connectionTypeData)) {
const parsedOutputIndex = parseInt(outputIndex, 10);
const connectionTypeOutputIndexData = connectionTypeData[parsedOutputIndex] ?? [];
acc[nodeId][connectionType][outputIndex] = acc[nodeId][connectionType][
outputIndex
] ?? {
...outputData,
...(connectionType !== NodeConnectionTypes.Main ? { byTarget: {} } : {}),
};
// For non-main connections, check if items are wrapped in a response field
// (common for AI nodes like embeddings, tools, etc.)
// Note: We check only the first item assuming uniform structure across all items
let itemCount = connectionTypeOutputIndexData.length;
if (
connectionType !== NodeConnectionTypes.Main &&
connectionTypeOutputIndexData.length > 0
) {
const firstItem = connectionTypeOutputIndexData[0];
// AI nodes typically wrap all items uniformly in response field
if (
firstItem?.json &&
typeof firstItem.json === 'object' &&
'response' in firstItem.json &&
Array.isArray(firstItem.json.response)
) {
// Use response array length for all items (assuming uniform structure)
itemCount = firstItem.json.response.length;
}
}
if (runIteration.executionStatus !== 'canceled') {
acc[nodeId][connectionType][outputIndex].iterations += 1;
}
acc[nodeId][connectionType][outputIndex].total += itemCount;
// For non-main connections, track per-target execution counts
if (connectionType !== NodeConnectionTypes.Main) {
const callingNodeName = runIteration.source?.[0]?.previousNode;
if (callingNodeName) {
const callingNode = nodesByName.value.get(callingNodeName);
if (callingNode) {
const targetId = callingNode.id;
const outputEntry = acc[nodeId][connectionType][outputIndex];
if (outputEntry.byTarget) {
if (!outputEntry.byTarget[targetId]) {
outputEntry.byTarget[targetId] = {
total: 0,
iterations: 0,
};
}
if (runIteration.executionStatus !== 'canceled') {
outputEntry.byTarget[targetId].iterations += 1;
}
outputEntry.byTarget[targetId].total += itemCount;
}
}
}
}
}
}
}
return acc;
}, {});
},
{ throttle: CANVAS_EXECUTION_DATA_THROTTLE_DURATION, immediate: true },
);
const nodeValidationErrorsById = computed(() =>
nodes.value.reduce<Record<string, string[]>>((acc, node) => {
const validationErrors: string[] = [];
if (node?.issues !== undefined) {
validationErrors.push(...nodeHelpers.nodeIssuesToString(node.issues, node));
}
acc[node.id] = validationErrors;
return acc;
}, {}),
);
const nodeHasIssuesById = computed(() =>
nodes.value.reduce<Record<string, boolean>>((acc, node) => {
const hasExecutionErrors =
(renderData.value.executionIssuesByNodeName.get(node.name)?.value?.length ?? 0) > 0;
const hasValidationErrors = nodeValidationErrorsById.value[node.id]?.length > 0;
if (['crashed', 'error'].includes(nodeExecutionStatusById.value[node.id])) {
acc[node.id] = true;
} else if (nodePinnedDataById.value[node.id]) {
acc[node.id] = false;
} else if (hasValidationErrors) {
acc[node.id] = true;
} else if (hasExecutionErrors) {
acc[node.id] = true;
} else {
const tasks = workflowsStore.getWorkflowRunData?.[node.name] ?? [];
acc[node.id] = Boolean(tasks.at(-1)?.error);
}
return acc;
}, {}),
);
const nodeExecutionWaitingById = computed(() =>
nodes.value.reduce<Record<string, string | undefined>>((acc, node) => {
const isExecutionSummary = (execution: object): execution is ExecutionSummary =>
'waitTill' in execution;
const workflowExecution = workflowsStore.getWorkflowExecution;
const lastNodeExecuted = workflowExecution?.data?.resultData?.lastNodeExecuted;
if (workflowExecution && lastNodeExecuted && isExecutionSummary(workflowExecution)) {
if (
node.name === workflowExecution.data?.resultData?.lastNodeExecuted &&
workflowExecution?.waitTill &&
!workflowExecution?.finished
) {
if (
node &&
node.type === WAIT_NODE_TYPE &&
['webhook', 'form'].includes(node.parameters.resume as string)
) {
acc[node.id] =
node.parameters.resume === 'webhook'
? i18n.baseText('node.theNodeIsWaitingWebhookCall')
: i18n.baseText('node.theNodeIsWaitingFormCall');
return acc;
}
if (node?.parameters.operation === SEND_AND_WAIT_OPERATION) {
acc[node.id] = i18n.baseText('node.theNodeIsWaitingUserInput');
return acc;
}
if (node?.type === FORM_NODE_TYPE) {
acc[node.id] = i18n.baseText('node.theNodeIsWaitingFormCall');
return acc;
}
const waitDate = new Date(workflowExecution.waitTill);
if (waitDate.getTime() === WAIT_INDEFINITELY.getTime()) {
acc[node.id] = i18n.baseText(
'node.theNodeIsWaitingIndefinitelyForAnIncomingWebhookCall',
);
return acc;
}
acc[node.id] = i18n.baseText('node.nodeIsWaitingTill', {
interpolate: {
date: waitDate.toLocaleDateString(),
time: waitDate.toLocaleTimeString(),
},
});
}
}
return acc;
}, {}),
);
const additionalNodePropertiesById = computed(() => {
type StickyNoteBoundingBox = BoundingBox & {
id: string;
area: number;
zIndex: number;
};
const stickyNodeBaseZIndex = -100;
const stickyNodeBoundingBoxes = nodes.value.reduce<StickyNoteBoundingBox[]>((acc, node) => {
if (node.type === STICKY_NODE_TYPE) {
const x = node.position[0];
const y = node.position[1];
const width = node.parameters.width as number;
const height = node.parameters.height as number;
acc.push({
id: node.id,
x,
y,
width,
height,
area: width * height,
zIndex: stickyNodeBaseZIndex,
});
}
return acc;
}, []);
const sortedStickyNodeBoundingBoxes = stickyNodeBoundingBoxes.sort((a, b) => b.area - a.area);
sortedStickyNodeBoundingBoxes.forEach((node, index) => {
node.zIndex = stickyNodeBaseZIndex + index;
});
for (let i = 0; i < sortedStickyNodeBoundingBoxes.length; i++) {
const node1 = sortedStickyNodeBoundingBoxes[i];
for (let j = i + 1; j < sortedStickyNodeBoundingBoxes.length; j++) {
const node2 = sortedStickyNodeBoundingBoxes[j];
if (checkOverlap(node1, node2)) {
if (node1.area < node2.area && node1.zIndex <= node2.zIndex) {
// Ensure node1 (smaller area) has a higher zIndex than node2 (larger area)
node1.zIndex = node2.zIndex + 1;
} else if (node2.area < node1.area && node2.zIndex <= node1.zIndex) {
// Ensure node2 (smaller area) has a higher zIndex than node1 (larger area)
node2.zIndex = node1.zIndex + 1;
}
}
}
}
return sortedStickyNodeBoundingBoxes.reduce<Record<string, Partial<CanvasNode>>>(
(acc, node) => {
acc[node.id] = {
style: {
zIndex: node.zIndex,
},
};
return acc;
},
{},
);
});
const simulatedNodeTypeDescriptionByNodeId = computed(() => {
return nodes.value.reduce<Record<string, INodeTypeDescription | null>>((acc, node) => {
if ([SIMULATE_NODE_TYPE, SIMULATE_TRIGGER_NODE_TYPE].includes(node.type)) {
const icon = node.parameters?.icon as string;
const iconValue = workflowObject.value.expression.getSimpleParameterValue(
node,
icon,
'internal',
{},
);
if (iconValue && typeof iconValue === 'string') {
acc[node.id] = nodeTypesStore.getNodeType(iconValue);
}
}
return acc;
}, {});
});
function filterOutCanceled(tasks: ITaskData[] | null): ITaskData[] | null {
if (!tasks) {
return null;
}
if (!tasks) return null;
return tasks.filter((task) => task.executionStatus !== 'canceled');
}
@@ -571,17 +56,18 @@ export function useCanvasMapping({
// they wrap each node's actual rendered size. Sticky notes are omitted —
// their own width/height parameters are read by the group mapper directly.
const nodeDisplaySizeById = computed(() => {
const rd = renderData.value;
const dimensionsById: Record<string, { width: number; height: number }> = {};
for (const node of nodes.value) {
const render = renderTypeByNodeId.value[node.id];
const render = rd.renderTypeByNodeId.get(node.id)?.value;
if (render?.type !== CanvasNodeRenderType.Default) continue;
dimensionsById[node.id] = computeNodeDisplaySize(
node.id,
render.options,
renderData.value,
rd,
isExperimentalNdvActive.value,
);
}
@@ -592,15 +78,19 @@ export function useCanvasMapping({
const connectionsBySourceNode = connections.value;
const connectionsByDestinationNode =
workflowUtils.mapConnectionsByDestination(connectionsBySourceNode);
const rd = renderData.value;
const additionalProperties = rd.additionalPropertiesByNodeId.value;
return nodes.value.map<CanvasNode>((node) => {
const outputConnections = connectionsBySourceNode[node.name] ?? {};
const inputConnections = connectionsByDestinationNode[node.name] ?? {};
const runData = rd.executionRunDataByNodeId.get(node.id)?.value ?? null;
const data: CanvasNodeData = {
id: node.id,
name: node.name,
subtitle: nodeSubtitleById.value[node.id] ?? '',
subtitle: rd.subtitleByNodeId.get(node.id)?.value ?? '',
type: node.type,
typeVersion: node.typeVersion,
disabled: node.disabled,
@@ -609,21 +99,23 @@ export function useCanvasMapping({
[CanvasConnectionMode.Output]: outputConnections,
},
issues: {
validation: nodeValidationErrorsById.value[node.id],
visible: nodeHasIssuesById.value[node.id],
validation: rd.validationErrorsByNodeId.get(node.id)?.value ?? [],
visible: rd.hasIssuesByNodeId.get(node.id)?.value ?? false,
},
execution: {
status: nodeExecutionStatusById.value[node.id],
waiting: nodeExecutionWaitingById.value[node.id],
waitingForNext: nodeExecutionWaitingForNextById.value[node.id],
running: nodeExecutionRunningById.value[node.id],
status: rd.executionStatusByNodeId.get(node.id)?.value,
waiting: rd.executionWaitingByNodeId.get(node.id)?.value,
waitingForNext: rd.executionWaitingForNextByNodeId.get(node.id)?.value ?? false,
running: rd.executionRunningByNodeId.get(node.id)?.value ?? false,
},
runData: {
outputMap: nodeExecutionRunDataOutputMapById.value[node.id],
iterations: filterOutCanceled(nodeExecutionRunDataById.value[node.id])?.length ?? 0,
visible: !!nodeExecutionRunDataById.value[node.id],
outputMap: rd.executionRunDataOutputMapByNodeId.get(node.id),
iterations: filterOutCanceled(runData)?.length ?? 0,
visible: !!runData,
},
render: renderTypeByNodeId.value[node.id] ?? { type: 'default', options: {} },
render:
rd.renderTypeByNodeId.get(node.id)?.value ??
({ type: node.type, options: {} } as CanvasNodeData['render']),
};
return {
@@ -632,7 +124,7 @@ export function useCanvasMapping({
type: 'canvas-node',
position: { x: node.position[0], y: node.position[1] },
data,
...additionalNodePropertiesById.value[node.id],
...additionalProperties[node.id],
draggable: node.draggable,
};
});
@@ -640,63 +132,57 @@ export function useCanvasMapping({
const mappedConnections = computed<CanvasConnection[]>(() => {
return mapLegacyConnectionsToCanvasConnections(connections.value ?? [], nodes.value ?? []).map(
(connection) => {
const type = getConnectionType(connection);
const label = getConnectionLabel(connection);
const data = getConnectionData(connection);
return {
...connection,
data,
type,
label,
markerEnd: MarkerType.ArrowClosed,
};
},
(connection) => ({
...connection,
data: getConnectionData(connection),
type: 'canvas-edge',
label: getConnectionLabel(connection),
markerEnd: MarkerType.ArrowClosed,
}),
);
});
function getConnectionData(connection: CanvasConnection): CanvasConnectionData {
const rd = renderData.value;
const { type, index } = parseCanvasConnectionHandleString(connection.sourceHandle);
const runData = nodeExecutionRunDataOutputMapById.value[connection.source]?.[type]?.[index];
const outputMap = rd.executionRunDataOutputMapByNodeId.get(connection.source);
const runData = outputMap?.[type]?.[index];
const runDataTotal = runData?.total ?? 0;
const sourceTasks = nodeExecutionRunDataById.value[connection.source] ?? [];
const sourceTasks = rd.executionRunDataByNodeId.get(connection.source)?.value ?? [];
let lastSourceTask: ITaskData | undefined = sourceTasks[sourceTasks.length - 1];
if (lastSourceTask?.executionStatus === 'canceled' && sourceTasks.length > 1) {
lastSourceTask = sourceTasks[sourceTasks.length - 2];
}
const sourcePinned = rd.pinnedDataByNodeId.get(connection.source)?.value;
const sourceRunData = rd.executionRunDataByNodeId.get(connection.source)?.value;
const targetRunData = rd.executionRunDataByNodeId.get(connection.target)?.value;
const sourceRunning = rd.executionRunningByNodeId.get(connection.source)?.value ?? false;
const sourceHasIssues = rd.hasIssuesByNodeId.get(connection.source)?.value ?? false;
let status: CanvasConnectionData['status'];
if (nodeExecutionRunningById.value[connection.source] && runDataTotal === 0) {
if (sourceRunning && runDataTotal === 0) {
status = 'running';
} else if (
nodePinnedDataById.value[connection.source] &&
nodeExecutionRunDataById.value[connection.source]
) {
} else if (sourcePinned && sourceRunData) {
status = 'pinned';
} else if (nodeHasIssuesById.value[connection.source]) {
} else if (sourceHasIssues) {
status = 'error';
} else if (runDataTotal > 0 && lastSourceTask?.executionStatus !== 'canceled') {
// For non-main connections (model, memory, tool, etc.), only mark as executed
// if the target node also executed, since these are passive connections
// Non-main connections (model/memory/tool) are passive — only mark
// success when the target node also produced run data.
const isMainConnection = type === NodeConnectionTypes.Main;
const targetNodeHasAnyExecution = nodeExecutionRunDataById.value[connection.target];
if (isMainConnection || targetNodeHasAnyExecution) {
if (isMainConnection || targetRunData) {
status = 'success';
}
}
const sourceInputs = renderData.value.nodeInputsByNodeId.get(connection.source)?.value ?? [];
const targetInputs = renderData.value.nodeInputsByNodeId.get(connection.target)?.value ?? [];
const sourceInputs = rd.nodeInputsByNodeId.get(connection.source)?.value ?? [];
const targetInputs = rd.nodeInputsByNodeId.get(connection.target)?.value ?? [];
const maxConnections = [...sourceInputs, ...targetInputs]
.filter((port) => port.type === type)
.reduce<number | undefined>((acc, port) => {
if (port.maxConnections === undefined) {
return acc;
}
if (port.maxConnections === undefined) return acc;
return Math.min(acc ?? Infinity, port.maxConnections);
}, undefined);
@@ -707,76 +193,61 @@ export function useCanvasMapping({
};
}
function getConnectionType(_: CanvasConnection): string {
return 'canvas-edge';
}
function getConnectionLabel(connection: CanvasConnection): string {
const fromNode = nodesByName.value.get(connection.data?.source.node ?? '');
if (!fromNode) {
return '';
}
const rd = renderData.value;
const sourceId = connection.source;
if (nodePinnedDataById.value[fromNode.id]) {
const pinnedDataCount = nodePinnedDataById.value[fromNode.id]?.length ?? 0;
const pinned = rd.pinnedDataByNodeId.get(sourceId)?.value;
if (pinned) {
const pinnedDataCount = pinned.length;
return pinnedDataCount > 0
? i18n.baseText('ndv.output.items', {
adjustToNumber: pinnedDataCount,
interpolate: { count: String(pinnedDataCount) },
})
: '';
} else if (nodeExecutionRunDataById.value[fromNode.id]) {
const { type, index } = parseCanvasConnectionHandleString(connection.sourceHandle);
const outputData = nodeExecutionRunDataOutputMapById.value[fromNode.id]?.[type]?.[index];
// For non-main connections, use per-target data if available
const isMainConnection = type === NodeConnectionTypes.Main;
const targetHasExecutionData = nodeExecutionRunDataById.value[connection.target];
if (!isMainConnection && outputData?.byTarget) {
// Look up the target node to get per-connection counts
const targetNodeId = connection.target;
const targetData = outputData.byTarget[targetNodeId];
if (targetData && targetData.total > 0 && targetHasExecutionData) {
return i18n.baseText(
targetData.iterations > 1 ? 'ndv.output.itemsTotal' : 'ndv.output.items',
{
adjustToNumber: targetData.total,
interpolate: { count: String(targetData.total) },
},
);
}
// Target hasn't executed, show no label
return '';
}
// For main connections, use aggregate counts
const runDataTotal = outputData?.total ?? 0;
const hasMultipleRunDataIterations = (outputData?.iterations ?? 1) > 1;
return runDataTotal > 0 && (isMainConnection || targetHasExecutionData)
? i18n.baseText(
hasMultipleRunDataIterations ? 'ndv.output.itemsTotal' : 'ndv.output.items',
{
adjustToNumber: runDataTotal,
interpolate: { count: String(runDataTotal) },
},
)
: '';
}
return '';
const sourceRunData = rd.executionRunDataByNodeId.get(sourceId)?.value;
if (!sourceRunData) return '';
const { type, index } = parseCanvasConnectionHandleString(connection.sourceHandle);
const outputMap = rd.executionRunDataOutputMapByNodeId.get(sourceId);
const outputData = outputMap?.[type]?.[index];
const isMainConnection = type === NodeConnectionTypes.Main;
const targetRunData = rd.executionRunDataByNodeId.get(connection.target)?.value;
// Non-main connections (AI tool/memory/embedding) track per-target counts
// when the target has run data; otherwise stay quiet.
if (!isMainConnection && outputData?.byTarget) {
const targetData = outputData.byTarget[connection.target];
if (targetData && targetData.total > 0 && targetRunData) {
return i18n.baseText(
targetData.iterations > 1 ? 'ndv.output.itemsTotal' : 'ndv.output.items',
{
adjustToNumber: targetData.total,
interpolate: { count: String(targetData.total) },
},
);
}
return '';
}
const runDataTotal = outputData?.total ?? 0;
const hasMultipleRunDataIterations = (outputData?.iterations ?? 1) > 1;
return runDataTotal > 0 && (isMainConnection || targetRunData)
? i18n.baseText(hasMultipleRunDataIterations ? 'ndv.output.itemsTotal' : 'ndv.output.items', {
adjustToNumber: runDataTotal,
interpolate: { count: String(runDataTotal) },
})
: '';
}
return {
additionalNodePropertiesById,
nodeExecutionRunDataOutputMapById,
nodeExecutionWaitingForNextById,
nodeHasIssuesById,
nodeDisplaySizeById,
connections: mappedConnections,
nodes: mappedNodes,
connections: mappedConnections,
nodeDisplaySizeById,
};
}
@@ -4,6 +4,7 @@ import { createTestingPinia } from '@pinia/testing';
import { render } from '@testing-library/vue';
import type { EventBus } from '@n8n/utils/event-bus';
import type { CanvasEventBusEvents } from '@/features/workflows/canvas/canvas.types';
import { createEmptyCanvasRenderData } from '@/features/workflows/canvas/canvas.utils';
// Mock useVueFlow - capture onNodesInitialized callback and updateNode spy
let nodesInitializedCallback: (() => void) | null = null;
@@ -92,12 +93,7 @@ describe('SyncedWorkflowCanvas', () => {
id: 'test-canvas',
nodes: [],
connections: [],
renderData: {
nodeInputsByNodeId: new Map(),
nodeOutputsByNodeId: new Map(),
pinnedDataByNodeName: {},
executionIssuesByNodeName: new Map(),
},
renderData: createEmptyCanvasRenderData(),
},
});
expect(container).toBeTruthy();
@@ -111,12 +107,7 @@ describe('SyncedWorkflowCanvas', () => {
nodes: [],
connections: [],
applyLayout: true,
renderData: {
nodeInputsByNodeId: new Map(),
nodeOutputsByNodeId: new Map(),
pinnedDataByNodeName: {},
executionIssuesByNodeName: new Map(),
},
renderData: createEmptyCanvasRenderData(),
},
});
@@ -139,12 +130,7 @@ describe('SyncedWorkflowCanvas', () => {
nodes: [],
connections: [],
applyLayout: false,
renderData: {
nodeInputsByNodeId: new Map(),
nodeOutputsByNodeId: new Map(),
pinnedDataByNodeName: {},
executionIssuesByNodeName: new Map(),
},
renderData: createEmptyCanvasRenderData(),
},
});
@@ -163,12 +149,7 @@ describe('SyncedWorkflowCanvas', () => {
id: 'test-canvas',
nodes: [],
connections: [],
renderData: {
nodeInputsByNodeId: new Map(),
nodeOutputsByNodeId: new Map(),
pinnedDataByNodeName: {},
executionIssuesByNodeName: new Map(),
},
renderData: createEmptyCanvasRenderData(),
},
});
@@ -188,12 +169,7 @@ describe('SyncedWorkflowCanvas', () => {
nodes: [],
connections: [],
applyLayout: true,
renderData: {
nodeInputsByNodeId: new Map(),
nodeOutputsByNodeId: new Map(),
pinnedDataByNodeName: {},
executionIssuesByNodeName: new Map(),
},
renderData: createEmptyCanvasRenderData(),
},
});
@@ -3,6 +3,7 @@ import { defineComponent, h, ref } from 'vue';
import { createTestingPinia } from '@pinia/testing';
import { render, screen } from '@testing-library/vue';
import type { INodeUi } from '@/Interface';
import { createEmptyCanvasRenderData } from '@/features/workflows/canvas/canvas.utils';
interface CapturedCanvasProps {
id: string;
@@ -102,18 +103,8 @@ describe('WorkflowDiffContent', () => {
isSourceWorkflowNew: false,
nodesDiff: new Map(),
connectionsDiff: new Map(),
sourceRenderData: {
nodeInputsByNodeId: new Map(),
nodeOutputsByNodeId: new Map(),
pinnedDataByNodeName: {},
executionIssuesByNodeName: new Map(),
},
targetRenderData: {
nodeInputsByNodeId: new Map(),
nodeOutputsByNodeId: new Map(),
pinnedDataByNodeName: {},
executionIssuesByNodeName: new Map(),
},
sourceRenderData: createEmptyCanvasRenderData(),
targetRenderData: createEmptyCanvasRenderData(),
};
describe('panels', () => {
@@ -1,11 +1,8 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { ref, computed } from 'vue';
import { mapConnections, useWorkflowDiff } from './useWorkflowDiff';
import type {
CanvasConnection,
CanvasNode,
ExecutionOutputMap,
} from '@/features/workflows/canvas/canvas.types';
import type { CanvasConnection, CanvasNode } from '@/features/workflows/canvas/canvas.types';
import type { ExecutionOutputMap } from '@/app/types/executionData';
import type { INodeUi, IWorkflowDb } from '@/Interface';
import { NodeDiffStatus, type IConnections } from 'n8n-workflow';
import { useCanvasMapping } from '@/features/workflows/canvas/composables/useCanvasMapping';
@@ -18,11 +15,6 @@ vi.mock('@/app/stores/workflows.store', () => ({
}));
const mockDocumentStore = vi.hoisted(() => ({
createWorkflowObject: vi.fn().mockReturnValue({
id: 'test-workflow',
nodes: [],
connections: {},
}),
hydrate: vi.fn(),
render: {
nodeInputsByNodeId: new Map(),
@@ -36,10 +28,18 @@ const mockDocumentStore = vi.hoisted(() => ({
vi.mock('@/app/stores/workflowDocument.store', () => ({
useWorkflowDocumentStore: () => mockDocumentStore,
createWorkflowDocumentId: vi.fn().mockReturnValue('test-id'),
injectWorkflowDocumentStore: () => ({ value: mockDocumentStore }),
disposeWorkflowDocumentStore: vi.fn(),
}));
vi.mock('@/app/stores/workflowDocument/useWorkflowDocumentRenderData', async () => {
const { createEmptyCanvasRenderData } = await vi.importActual<
typeof import('@/features/workflows/canvas/canvas.utils')
>('@/features/workflows/canvas/canvas.utils');
return {
useWorkflowDocumentRenderData: vi.fn(() => createEmptyCanvasRenderData()),
};
});
vi.mock('@/app/stores/workflowExecutionState.store', () => ({
useWorkflowExecutionStateStore: () => ({
activeExecutionIssuesByNodeName: new Map(),
@@ -428,5 +428,28 @@ describe('useWorkflowDiff', () => {
// Node with existing ID should keep its ID
expect(passedNodes[1].id).toBe('existing-id');
});
it('hydrates the render-data store with the same node IDs used for canvas mapping', () => {
const nodeWithoutId = {
name: 'Node Without ID',
type: 'test-node',
typeVersion: 1,
position: [100, 100] as [number, number],
parameters: {},
} as INodeUi;
const sourceWorkflow = createMockWorkflow('source', [nodeWithoutId]);
useWorkflowDiff(sourceWorkflow, undefined);
// Canvas mapping receives nodes with generated IDs; the render-data store
// must be hydrated with those same IDs, otherwise canvas lookups
// (handles, render type, subtitle, status) miss.
const canvasNodes = mockUseCanvasMapping.mock.calls[0][0].nodes.value;
const hydratedNodes = mockDocumentStore.hydrate.mock.calls[0][0].nodes;
expect(canvasNodes[0].id).toBeDefined();
expect(hydratedNodes[0].id).toBe(canvasNodes[0].id);
});
});
});
@@ -1,19 +1,32 @@
import type { CanvasConnection, CanvasNode } from '@/features/workflows/canvas/canvas.types';
import type { INodeUi, IWorkflowDb } from '@/Interface';
import type { MaybeRefOrGetter, Ref, ComputedRef } from 'vue';
import { toValue, computed, ref, watchEffect, shallowRef, onScopeDispose } from 'vue';
import {
toValue,
computed,
ref,
watchEffect,
shallowRef,
onScopeDispose,
effectScope,
type MaybeRefOrGetter,
type Ref,
type ComputedRef,
type EffectScope,
} from 'vue';
import { useCanvasMapping } from '@/features/workflows/canvas/composables/useCanvasMapping';
import type { Workflow, IConnections, INodeTypeDescription, NodeDiff } from 'n8n-workflow';
import type { IConnections, INodeTypeDescription, NodeDiff } from 'n8n-workflow';
import { compareWorkflowsNodes, NodeDiffStatus } from 'n8n-workflow';
import { useNodeTypesStore } from '@/app/stores/nodeTypes.store';
import {
injectWorkflowDocumentStore,
useWorkflowDocumentStore,
createWorkflowDocumentId,
disposeWorkflowDocumentStore,
} from '@/app/stores/workflowDocument.store';
import { useWorkflowDocumentRenderData } from '@/app/stores/workflowDocument/useWorkflowDocumentRenderData';
import type { CanvasRenderData } from '@/features/workflows/canvas/canvas.utils';
import {
createEmptyCanvasRenderData,
type CanvasRenderData,
} from '@/features/workflows/canvas/canvas.utils';
export function mapConnections(connections: CanvasConnection[]) {
return connections.reduce(
@@ -26,14 +39,10 @@ export function mapConnections(connections: CanvasConnection[]) {
);
}
function createWorkflowRefs(
workflow: MaybeRefOrGetter<IWorkflowDb | undefined>,
createWorkflowObject: (nodes: INodeUi[], connections: IConnections) => Workflow,
) {
function createWorkflowRefs(workflow: MaybeRefOrGetter<IWorkflowDb | undefined>) {
const workflowRef = computed(() => toValue(workflow));
const workflowNodes = ref<INodeUi[]>([]);
const workflowConnections = ref<IConnections>({});
const workflowObjectRef = shallowRef<Workflow>(createWorkflowObject([], {}));
watchEffect(() => {
const workflowValue = workflowRef.value;
@@ -47,7 +56,6 @@ function createWorkflowRefs(
return node;
});
workflowObjectRef.value = createWorkflowObject(nodesWithIds, workflowValue.connections);
workflowNodes.value = nodesWithIds;
workflowConnections.value = workflowValue.connections;
}
@@ -57,7 +65,6 @@ function createWorkflowRefs(
workflowRef,
workflowNodes,
workflowConnections,
workflowObjectRef,
};
}
@@ -65,15 +72,11 @@ function createWorkflowDiff(
workflowRef: ComputedRef<IWorkflowDb | undefined>,
workflowNodes: Ref<INodeUi[]>,
workflowConnections: Ref<IConnections>,
workflowObjectRef: Ref<Workflow>,
renderData: Ref<CanvasRenderData>,
) {
// Call useCanvasMapping at setup time, not inside computed
// This is required because useCanvasMapping uses inject() internally
const { nodes, connections } = useCanvasMapping({
nodes: workflowNodes,
connections: workflowConnections,
workflowObject: workflowObjectRef,
renderData,
});
@@ -111,14 +114,16 @@ function createWorkflowDiff(
};
}
function createDiffRenderData(workflowRef: ComputedRef<IWorkflowDb | undefined>, side: string) {
const renderData = shallowRef<CanvasRenderData>({
nodeInputsByNodeId: new Map(),
nodeOutputsByNodeId: new Map(),
pinnedDataByNodeName: {},
executionIssuesByNodeName: new Map(),
});
function createDiffRenderData(
workflowRef: ComputedRef<IWorkflowDb | undefined>,
workflowNodes: Ref<INodeUi[]>,
side: string,
) {
const renderData = shallowRef<CanvasRenderData>(createEmptyCanvasRenderData());
let workflowDocumentStore: ReturnType<typeof useWorkflowDocumentStore> | null = null;
// `useWorkflowDocumentRenderData` is side-effectful; own its scope so it can
// be torn down when the diffed workflow changes or this side disposes.
let renderDataScope: EffectScope | undefined;
watchEffect(() => {
const wf = workflowRef.value;
@@ -127,16 +132,30 @@ function createDiffRenderData(workflowRef: ComputedRef<IWorkflowDb | undefined>,
if (workflowDocumentStore) {
disposeWorkflowDocumentStore(workflowDocumentStore);
}
renderDataScope?.stop();
const versionId = wf.versionId ?? `diff-${side}`;
const docId = createWorkflowDocumentId(wf.id, versionId);
workflowDocumentStore = useWorkflowDocumentStore(docId);
workflowDocumentStore.hydrate({ ...wf, versionId } as IWorkflowDb);
renderData.value = useWorkflowDocumentRenderData(docId);
// Hydrate from the same normalized nodes that feed the canvas so the
// render-data maps are keyed by the same node IDs the canvas looks up.
// Shallow-copy the nodes so the document store owns/mutates its own node
// objects (e.g. position snapping) without leaking into `workflowNodes`.
workflowDocumentStore.hydrate({
...wf,
nodes: workflowNodes.value.map((node) => ({ ...node })),
versionId,
} as IWorkflowDb);
renderDataScope = effectScope(true);
renderDataScope.run(() => {
renderData.value = useWorkflowDocumentRenderData(docId);
});
});
function dispose() {
renderDataScope?.stop();
renderDataScope = undefined;
if (workflowDocumentStore) {
disposeWorkflowDocumentStore(workflowDocumentStore);
workflowDocumentStore = null;
@@ -150,24 +169,19 @@ export const useWorkflowDiff = (
sourceWorkflow: MaybeRefOrGetter<IWorkflowDb | undefined>,
targetWorkflow: MaybeRefOrGetter<IWorkflowDb | undefined>,
) => {
const workflowDocumentStore = injectWorkflowDocumentStore();
const nodeTypesStore = useNodeTypesStore();
const sourceRefs = createWorkflowRefs(
sourceWorkflow,
workflowDocumentStore.value.createWorkflowObject,
);
const targetRefs = createWorkflowRefs(
targetWorkflow,
workflowDocumentStore.value.createWorkflowObject,
);
const sourceRefs = createWorkflowRefs(sourceWorkflow);
const targetRefs = createWorkflowRefs(targetWorkflow);
const { renderData: sourceRenderData, dispose: disposeSource } = createDiffRenderData(
sourceRefs.workflowRef,
sourceRefs.workflowNodes,
'source',
);
const { renderData: targetRenderData, dispose: disposeTarget } = createDiffRenderData(
targetRefs.workflowRef,
targetRefs.workflowNodes,
'target',
);
@@ -175,7 +189,6 @@ export const useWorkflowDiff = (
sourceRefs.workflowRef,
sourceRefs.workflowNodes,
sourceRefs.workflowConnections,
sourceRefs.workflowObjectRef,
sourceRenderData,
);
@@ -183,7 +196,6 @@ export const useWorkflowDiff = (
targetRefs.workflowRef,
targetRefs.workflowNodes,
targetRefs.workflowConnections,
targetRefs.workflowObjectRef,
targetRenderData,
);