perf(editor): Improve canvas rendering performance (#8022)

## Summary
- Refactor usage of `setSuspendDrawing`, removing it from loops and only
calling it after batch operations are done
- Batch adding of nodes to improve copy/paste and workflow load
performance
- Cache i18n calls
- Debounce connections dragging handler if there are more than 20 nodes


## Related tickets and issues
> Include links to **Linear ticket** or Github issue or Community forum
post. Important in order to close *automatically* and provide context to
reviewers.
- https://community.n8n.io/t/slow-ui-in-big-scenarios/33830/8

---------

Signed-off-by: Oleg Ivaniv <me@olegivaniv.com>
This commit is contained in:
oleg 2023-12-14 12:01:00 +01:00 committed by GitHub
parent e3c363d72c
commit b780436a6b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 97 additions and 70 deletions

View file

@ -637,8 +637,7 @@ export default defineComponent({
// so we only update it when necessary (when node is mounted and when it's opened and closed (isActive)) // so we only update it when necessary (when node is mounted and when it's opened and closed (isActive))
try { try {
const nodeSubtitle = const nodeSubtitle =
this.nodeHelpers.getNodeSubtitle(this.data, this.nodeType, this.getCurrentWorkflow()) || this.nodeHelpers.getNodeSubtitle(this.data, this.nodeType, this.workflow) || '';
'';
this.nodeSubtitle = nodeSubtitle.includes(CUSTOM_API_CALL_KEY) ? '' : nodeSubtitle; this.nodeSubtitle = nodeSubtitle.includes(CUSTOM_API_CALL_KEY) ? '' : nodeSubtitle;
} catch (e) { } catch (e) {

View file

@ -23,6 +23,8 @@ export const i18nInstance = createI18n({
}); });
export class I18nClass { export class I18nClass {
private baseTextCache = new Map<string, string>();
private get i18n() { private get i18n() {
return i18nInstance.global; return i18nInstance.global;
} }
@ -50,11 +52,25 @@ export class I18nClass {
key: BaseTextKey, key: BaseTextKey,
options?: { adjustToNumber?: number; interpolate?: { [key: string]: string } }, options?: { adjustToNumber?: number; interpolate?: { [key: string]: string } },
): string { ): string {
if (options?.adjustToNumber !== undefined) { // Create a unique cache key
return this.i18n.tc(key, options.adjustToNumber, options?.interpolate).toString(); const cacheKey = `${key}-${JSON.stringify(options)}`;
// Check if the result is already cached
if (this.baseTextCache.has(cacheKey)) {
return this.baseTextCache.get(cacheKey) ?? key;
} }
return this.i18n.t(key, options?.interpolate).toString(); let result: string;
if (options?.adjustToNumber !== undefined) {
result = this.i18n.tc(key, options.adjustToNumber, options?.interpolate ?? {}).toString();
} else {
result = this.i18n.t(key, options?.interpolate ?? {}).toString();
}
// Store the result in the cache
this.baseTextCache.set(cacheKey, result);
return result;
} }
/** /**

View file

@ -70,7 +70,6 @@ export const register = () => {
container.appendChild(unconnectedGroup); container.appendChild(unconnectedGroup);
container.appendChild(defaultGroup); container.appendChild(defaultGroup);
endpointInstance.setupOverlays();
endpointInstance.setVisible(false); endpointInstance.setVisible(false);
return container; return container;

View file

@ -33,11 +33,6 @@ export class N8nAddInputEndpoint extends EndpointRepresentation<ComputedN8nAddIn
type = N8nAddInputEndpoint.type; type = N8nAddInputEndpoint.type;
setupOverlays() {
this.endpoint.instance.setSuspendDrawing(true);
this.endpoint.instance.setSuspendDrawing(false);
}
bindEvents() { bindEvents() {
this.instance.bind(EVENT_ENDPOINT_CLICK, this.fireClickEvent); this.instance.bind(EVENT_ENDPOINT_CLICK, this.fireClickEvent);
} }

View file

@ -49,7 +49,6 @@ export class N8nPlusEndpoint extends EndpointRepresentation<ComputedN8nPlusEndpo
setupOverlays() { setupOverlays() {
this.clearOverlays(); this.clearOverlays();
this.endpoint.instance.setSuspendDrawing(true);
this.stalkOverlay = this.endpoint.addOverlay({ this.stalkOverlay = this.endpoint.addOverlay({
type: 'Custom', type: 'Custom',
options: { options: {
@ -78,7 +77,6 @@ export class N8nPlusEndpoint extends EndpointRepresentation<ComputedN8nPlusEndpo
}, },
}, },
}); });
this.endpoint.instance.setSuspendDrawing(false);
} }
bindEvents() { bindEvents() {
@ -151,18 +149,14 @@ export class N8nPlusEndpoint extends EndpointRepresentation<ComputedN8nPlusEndpo
} }
setIsVisible(visible: boolean) { setIsVisible(visible: boolean) {
this.instance.setSuspendDrawing(true);
Object.keys(this.endpoint.getOverlays()).forEach((overlay) => { Object.keys(this.endpoint.getOverlays()).forEach((overlay) => {
this.endpoint.getOverlays()[overlay].setVisible(visible); this.endpoint.getOverlays()[overlay].setVisible(visible);
}); });
this.setVisible(visible); this.setVisible(visible);
// Re-trigger the success state if label is set // Re-trigger the success state if label is set
if (visible && this.label) { if (visible && this.label) {
this.setSuccessOutput(this.label); this.setSuccessOutput(this.label);
} }
this.instance.setSuspendDrawing(false);
} }
setSuccessOutput(label: string) { setSuccessOutput(label: string) {

View file

@ -894,8 +894,6 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, {
}, },
}; };
} }
this.workflowExecutionPairedItemMappings = getPairedItemsMapping(this.workflowExecutionData);
}, },
resetAllNodesIssues(): boolean { resetAllNodesIssues(): boolean {
@ -1130,7 +1128,6 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, {
}; };
} }
this.workflowExecutionData.data!.resultData.runData[pushData.nodeName].push(pushData.data); this.workflowExecutionData.data!.resultData.runData[pushData.nodeName].push(pushData.data);
this.workflowExecutionPairedItemMappings = getPairedItemsMapping(this.workflowExecutionData);
}, },
clearNodeExecutionData(nodeName: string): void { clearNodeExecutionData(nodeName: string): void {
if (!this.workflowExecutionData?.data) { if (!this.workflowExecutionData?.data) {

View file

@ -75,6 +75,7 @@
@removeNode="(name) => removeNode(name, true)" @removeNode="(name) => removeNode(name, true)"
:key="`${stickyData.id}_sticky`" :key="`${stickyData.id}_sticky`"
:name="stickyData.name" :name="stickyData.name"
:workflow="currentWorkflowObject"
:isReadOnly="isReadOnlyRoute || readOnlyEnv" :isReadOnly="isReadOnlyRoute || readOnlyEnv"
:instance="instance" :instance="instance"
:isActive="!!activeNode && activeNode.name === stickyData.name" :isActive="!!activeNode && activeNode.name === stickyData.name"
@ -732,6 +733,7 @@ export default defineComponent({
showTriggerMissingTooltip: false, showTriggerMissingTooltip: false,
workflowData: null as INewWorkflowData | null, workflowData: null as INewWorkflowData | null,
activeConnection: null as null | Connection, activeConnection: null as null | Connection,
isInsertingNodes: false,
isProductionExecutionPreview: false, isProductionExecutionPreview: false,
enterTimer: undefined as undefined | ReturnType<typeof setTimeout>, enterTimer: undefined as undefined | ReturnType<typeof setTimeout>,
exitTimer: undefined as undefined | ReturnType<typeof setTimeout>, exitTimer: undefined as undefined | ReturnType<typeof setTimeout>,
@ -2735,15 +2737,6 @@ export default defineComponent({
}); });
}, },
); );
setTimeout(() => {
NodeViewUtils.addConnectionTestData(
info.source,
info.target,
info.connection?.connector?.hasOwnProperty('canvas')
? (info.connection.connector.canvas as HTMLElement)
: undefined,
);
}, 0);
const endpointArrow = NodeViewUtils.getOverlay( const endpointArrow = NodeViewUtils.getOverlay(
info.connection, info.connection,
@ -2763,14 +2756,44 @@ export default defineComponent({
if (!this.suspendRecordingDetachedConnections) { if (!this.suspendRecordingDetachedConnections) {
this.historyStore.pushCommandToUndo(new AddConnectionCommand(connectionData)); this.historyStore.pushCommandToUndo(new AddConnectionCommand(connectionData));
} }
// When we add multiple nodes, this event could be fired hundreds of times for large workflows.
// And because the updateNodesInputIssues() method is quite expensive, we only call it if not in insert mode
if (!this.isInsertingNodes) {
this.nodeHelpers.updateNodesInputIssues(); this.nodeHelpers.updateNodesInputIssues();
this.resetEndpointsErrors(); this.resetEndpointsErrors();
setTimeout(() => {
NodeViewUtils.addConnectionTestData(
info.source,
info.target,
info.connection?.connector?.hasOwnProperty('canvas')
? (info.connection.connector.canvas as HTMLElement)
: undefined,
);
}, 0);
}
} }
} catch (e) { } catch (e) {
console.error(e); console.error(e);
} }
}, },
addConectionsTestData() {
this.instance.connections.forEach((connection) => {
NodeViewUtils.addConnectionTestData(
connection.source,
connection.target,
connection?.connector?.hasOwnProperty('canvas')
? (connection?.connector.canvas as HTMLElement)
: undefined,
);
});
},
onDragMove() { onDragMove() {
const totalNodes = this.nodes.length;
void this.callDebounced('updateConnectionsOverlays', {
debounceTime: totalNodes > 20 ? 200 : 0,
});
},
updateConnectionsOverlays() {
this.instance?.connections.forEach((connection) => { this.instance?.connections.forEach((connection) => {
NodeViewUtils.showOrHideItemsLabel(connection); NodeViewUtils.showOrHideItemsLabel(connection);
NodeViewUtils.showOrHideMidpointArrow(connection); NodeViewUtils.showOrHideMidpointArrow(connection);
@ -3899,7 +3922,7 @@ export default defineComponent({
if (!nodes?.length) { if (!nodes?.length) {
return; return;
} }
this.isInsertingNodes = true;
// Before proceeding we must check if all nodes contain the `properties` attribute. // Before proceeding we must check if all nodes contain the `properties` attribute.
// Nodes are loaded without this information so we must make sure that all nodes // Nodes are loaded without this information so we must make sure that all nodes
// being added have this information. // being added have this information.
@ -3957,60 +3980,64 @@ export default defineComponent({
// check and match credentials, apply new format if old is used // check and match credentials, apply new format if old is used
this.matchCredentials(node); this.matchCredentials(node);
this.workflowsStore.addNode(node); this.workflowsStore.addNode(node);
if (trackHistory) { if (trackHistory) {
this.historyStore.pushCommandToUndo(new AddNodeCommand(node)); this.historyStore.pushCommandToUndo(new AddNodeCommand(node));
} }
}); });
// Wait for the node to be rendered // Wait for the nodes to be rendered
await this.$nextTick(); await this.$nextTick();
// Suspend drawing
this.instance?.setSuspendDrawing(true); this.instance?.setSuspendDrawing(true);
// Load the connections if (connections) {
if (connections !== undefined) { await this.addConnections(connections);
let connectionData;
for (const sourceNode of Object.keys(connections)) {
for (const type of Object.keys(connections[sourceNode])) {
for (
let sourceIndex = 0;
sourceIndex < connections[sourceNode][type].length;
sourceIndex++
) {
const outwardConnections = connections[sourceNode][type][sourceIndex];
if (!outwardConnections) {
continue;
} }
outwardConnections.forEach((targetData) => {
connectionData = [
{
node: sourceNode,
type,
index: sourceIndex,
},
{
node: targetData.node,
type: targetData.type,
index: targetData.index,
},
] as [IConnection, IConnection];
this.__addConnection(connectionData);
});
}
}
}
}
// Add the node issues at the end as the node-connections are required // Add the node issues at the end as the node-connections are required
void this.nodeHelpers.refreshNodeIssues(); this.nodeHelpers.refreshNodeIssues();
this.nodeHelpers.updateNodesInputIssues();
this.resetEndpointsErrors();
this.isInsertingNodes = false;
// Now it can draw again // Now it can draw again
this.instance?.setSuspendDrawing(false, true); this.instance?.setSuspendDrawing(false, true);
}, },
async addConnections(connections: IConnections) {
const batchedConnectionData: Array<[IConnection, IConnection]> = [];
for (const sourceNode in connections) {
for (const type in connections[sourceNode]) {
connections[sourceNode][type].forEach((outwardConnections, sourceIndex) => {
if (outwardConnections) {
outwardConnections.forEach((targetData) => {
batchedConnectionData.push([
{ node: sourceNode, type, index: sourceIndex },
{ node: targetData.node, type: targetData.type, index: targetData.index },
]);
});
}
});
}
}
// Process the connections in batches
await this.processConnectionBatch(batchedConnectionData);
setTimeout(this.addConectionsTestData, 0);
},
async processConnectionBatch(batchedConnectionData: Array<[IConnection, IConnection]>) {
const batchSize = 100;
for (let i = 0; i < batchedConnectionData.length; i += batchSize) {
const batch = batchedConnectionData.slice(i, i + batchSize);
batch.forEach((connectionData) => {
this.__addConnection(connectionData);
});
}
},
async addNodesToWorkflow(data: IWorkflowDataUpdate): Promise<IWorkflowDataUpdate> { async addNodesToWorkflow(data: IWorkflowDataUpdate): Promise<IWorkflowDataUpdate> {
// Because nodes with the same name maybe already exist, it could // Because nodes with the same name maybe already exist, it could
// be needed that they have to be renamed. Also could it be possible // be needed that they have to be renamed. Also could it be possible