diff --git a/packages/editor-ui/src/components/MainHeader/CollaborationPane.vue b/packages/editor-ui/src/components/MainHeader/CollaborationPane.vue
index daabc0a1780bc..b8f5033e4de57 100644
--- a/packages/editor-ui/src/components/MainHeader/CollaborationPane.vue
+++ b/packages/editor-ui/src/components/MainHeader/CollaborationPane.vue
@@ -1,17 +1,22 @@
diff --git a/packages/editor-ui/src/components/MainHeader/WorkflowDetails.vue b/packages/editor-ui/src/components/MainHeader/WorkflowDetails.vue
index de687771d8cf4..3684f6165919f 100644
--- a/packages/editor-ui/src/components/MainHeader/WorkflowDetails.vue
+++ b/packages/editor-ui/src/components/MainHeader/WorkflowDetails.vue
@@ -676,7 +676,7 @@ function showCreateWorkflowSuccessToast(id?: string) {
-
+
{
});
});
+ describe('addBeforeUnloadHandler', () => {
+ it('should add additional handlers', () => {
+ const { addBeforeUnloadHandler, onBeforeUnload } = useBeforeUnload({ route: defaultRoute });
+ const event = new Event('beforeunload');
+ const handler = vi.fn();
+ addBeforeUnloadHandler(handler);
+ onBeforeUnload(event);
+ expect(handler).toHaveBeenCalled();
+ });
+ });
+
describe('addBeforeUnloadEventBindings', () => {
it('should add beforeunload event listener', () => {
const { addBeforeUnloadEventBindings } = useBeforeUnload({ route: defaultRoute });
diff --git a/packages/editor-ui/src/composables/useBeforeUnload.ts b/packages/editor-ui/src/composables/useBeforeUnload.ts
index 20095a217afa4..0fdc068a9d362 100644
--- a/packages/editor-ui/src/composables/useBeforeUnload.ts
+++ b/packages/editor-ui/src/composables/useBeforeUnload.ts
@@ -2,10 +2,8 @@ import { useCanvasStore } from '@/stores/canvas.store';
import { useUIStore } from '@/stores/ui.store';
import { useI18n } from '@/composables/useI18n';
import { computed, ref } from 'vue';
-import { TIME, VIEWS } from '@/constants';
+import { VIEWS } from '@/constants';
import type { useRoute } from 'vue-router';
-import { useCollaborationStore } from '@/stores/collaboration.store';
-import { useWorkflowsStore } from '@/stores/workflows.store';
/**
* Composable to handle the beforeunload event in canvas views.
@@ -17,42 +15,40 @@ import { useWorkflowsStore } from '@/stores/workflows.store';
export function useBeforeUnload({ route }: { route: ReturnType }) {
const uiStore = useUIStore();
const canvasStore = useCanvasStore();
- const collaborationStore = useCollaborationStore();
- const workflowsStore = useWorkflowsStore();
const i18n = useI18n();
const unloadTimeout = ref(null);
const isDemoRoute = computed(() => route.name === VIEWS.DEMO);
+ type Handler = () => void;
+ const handlers: Handler[] = [];
+
function onBeforeUnload(e: BeforeUnloadEvent) {
if (isDemoRoute.value || window.preventNodeViewBeforeUnload) {
return;
- } else if (uiStore.stateIsDirty) {
- // A bit hacky solution to detecting users leaving the page after prompt:
- // 1. Notify that workflow is closed straight away
- collaborationStore.notifyWorkflowClosed(workflowsStore.workflowId);
- // 2. If user decided to stay on the page we notify that the workflow is opened again
- unloadTimeout.value = setTimeout(() => {
- collaborationStore.notifyWorkflowOpened(workflowsStore.workflowId);
- }, 5 * TIME.SECOND);
+ }
+
+ handlers.forEach((handler) => handler());
+ if (uiStore.stateIsDirty) {
e.returnValue = true; //Gecko + IE
return true; //Gecko + Webkit, Safari, Chrome etc.
} else {
canvasStore.startLoading(i18n.baseText('nodeView.redirecting'));
- collaborationStore.notifyWorkflowClosed(workflowsStore.workflowId);
return;
}
}
+ function addBeforeUnloadHandler(handler: () => void) {
+ handlers.push(handler);
+ }
+
function addBeforeUnloadEventBindings() {
window.addEventListener('beforeunload', onBeforeUnload);
}
function removeBeforeUnloadEventBindings() {
- collaborationStore.notifyWorkflowClosed(workflowsStore.workflowId);
-
if (unloadTimeout.value) {
clearTimeout(unloadTimeout.value);
}
@@ -64,5 +60,6 @@ export function useBeforeUnload({ route }: { route: ReturnType
onBeforeUnload,
addBeforeUnloadEventBindings,
removeBeforeUnloadEventBindings,
+ addBeforeUnloadHandler,
};
}
diff --git a/packages/editor-ui/src/stores/collaboration.store.ts b/packages/editor-ui/src/stores/collaboration.store.ts
index e9f4afe168496..9a433bdf9e0ac 100644
--- a/packages/editor-ui/src/stores/collaboration.store.ts
+++ b/packages/editor-ui/src/stores/collaboration.store.ts
@@ -1,10 +1,14 @@
import { defineStore } from 'pinia';
import { computed, ref } from 'vue';
+import { useRoute } from 'vue-router';
+
+import { STORES, PLACEHOLDER_EMPTY_WORKFLOW_ID, TIME } from '@/constants';
+import { useBeforeUnload } from '@/composables/useBeforeUnload';
+import type { IUser } from '@/Interface';
import { useWorkflowsStore } from '@/stores/workflows.store';
import { usePushConnectionStore } from '@/stores/pushConnection.store';
-import { STORES } from '@/constants';
-import type { IUser } from '@/Interface';
import { useUsersStore } from '@/stores/users.store';
+import { useUIStore } from '@/stores/ui.store';
type ActiveUsersForWorkflows = {
[workflowId: string]: Array<{ user: IUser; lastSeen: string }>;
@@ -16,16 +20,46 @@ type ActiveUsersForWorkflows = {
*/
export const useCollaborationStore = defineStore(STORES.COLLABORATION, () => {
const pushStore = usePushConnectionStore();
- const workflowStore = useWorkflowsStore();
+ const workflowsStore = useWorkflowsStore();
const usersStore = useUsersStore();
+ const uiStore = useUIStore();
+
+ const route = useRoute();
+ const { addBeforeUnloadHandler } = useBeforeUnload({ route });
+ const unloadTimeout = ref(null);
+ addBeforeUnloadHandler(() => {
+ // Notify that workflow is closed straight away
+ notifyWorkflowClosed();
+ if (uiStore.stateIsDirty) {
+ // If user decided to stay on the page we notify that the workflow is opened again
+ unloadTimeout.value = setTimeout(() => notifyWorkflowOpened, 5 * TIME.SECOND);
+ }
+ });
const usersForWorkflows = ref({});
const pushStoreEventListenerRemovalFn = ref<(() => void) | null>(null);
const getUsersForCurrentWorkflow = computed(() => {
- return usersForWorkflows.value[workflowStore.workflowId] ?? [];
+ return usersForWorkflows.value[workflowsStore.workflowId] ?? [];
});
+ const HEARTBEAT_INTERVAL = 5 * TIME.MINUTE;
+ const heartbeatTimer = ref(null);
+
+ const startHeartbeat = () => {
+ if (heartbeatTimer.value !== null) {
+ clearInterval(heartbeatTimer.value);
+ heartbeatTimer.value = null;
+ }
+ heartbeatTimer.value = window.setInterval(notifyWorkflowOpened, HEARTBEAT_INTERVAL);
+ };
+
+ const stopHeartbeat = () => {
+ if (heartbeatTimer.value !== null) {
+ clearInterval(heartbeatTimer.value);
+ }
+ };
+
function initialize() {
if (pushStoreEventListenerRemovalFn.value) {
return;
@@ -37,6 +71,9 @@ export const useCollaborationStore = defineStore(STORES.COLLABORATION, () => {
usersForWorkflows.value[workflowId] = event.data.activeUsers;
}
});
+
+ notifyWorkflowOpened();
+ startHeartbeat();
}
function terminate() {
@@ -44,10 +81,12 @@ export const useCollaborationStore = defineStore(STORES.COLLABORATION, () => {
pushStoreEventListenerRemovalFn.value();
pushStoreEventListenerRemovalFn.value = null;
}
- }
-
- function workflowUsersUpdated(data: ActiveUsersForWorkflows) {
- usersForWorkflows.value = data;
+ notifyWorkflowClosed();
+ stopHeartbeat();
+ pushStore.clearQueue();
+ if (unloadTimeout.value) {
+ clearTimeout(unloadTimeout.value);
+ }
}
function functionRemoveCurrentUserFromActiveUsers(workflowId: string) {
@@ -61,14 +100,18 @@ export const useCollaborationStore = defineStore(STORES.COLLABORATION, () => {
);
}
- function notifyWorkflowOpened(workflowId: string) {
+ function notifyWorkflowOpened() {
+ const { workflowId } = workflowsStore;
+ if (workflowId === PLACEHOLDER_EMPTY_WORKFLOW_ID) return;
pushStore.send({
type: 'workflowOpened',
workflowId,
});
}
- function notifyWorkflowClosed(workflowId: string) {
+ function notifyWorkflowClosed() {
+ const { workflowId } = workflowsStore;
+ if (workflowId === PLACEHOLDER_EMPTY_WORKFLOW_ID) return;
pushStore.send({ type: 'workflowClosed', workflowId });
functionRemoveCurrentUserFromActiveUsers(workflowId);
@@ -78,9 +121,8 @@ export const useCollaborationStore = defineStore(STORES.COLLABORATION, () => {
usersForWorkflows,
initialize,
terminate,
- notifyWorkflowOpened,
- notifyWorkflowClosed,
- workflowUsersUpdated,
getUsersForCurrentWorkflow,
+ startHeartbeat,
+ stopHeartbeat,
};
});
diff --git a/packages/editor-ui/src/stores/pushConnection.store.ts b/packages/editor-ui/src/stores/pushConnection.store.ts
index eb6dcbc85d8ee..47594de22883c 100644
--- a/packages/editor-ui/src/stores/pushConnection.store.ts
+++ b/packages/editor-ui/src/stores/pushConnection.store.ts
@@ -150,6 +150,10 @@ export const usePushConnectionStore = defineStore(STORES.PUSH, () => {
onMessageReceivedHandlers.value.forEach((handler) => handler(receivedData));
}
+ const clearQueue = () => {
+ outgoingQueue.value = [];
+ };
+
return {
pushRef,
pushSource,
@@ -159,5 +163,6 @@ export const usePushConnectionStore = defineStore(STORES.PUSH, () => {
pushConnect,
pushDisconnect,
send,
+ clearQueue,
};
});
diff --git a/packages/editor-ui/src/views/NodeView.v2.vue b/packages/editor-ui/src/views/NodeView.v2.vue
index d66d142e7358a..127a1c10c40f5 100644
--- a/packages/editor-ui/src/views/NodeView.v2.vue
+++ b/packages/editor-ui/src/views/NodeView.v2.vue
@@ -103,7 +103,6 @@ import { createEventBus } from 'n8n-design-system';
import type { PinDataSource } from '@/composables/usePinnedData';
import { useClipboard } from '@/composables/useClipboard';
import { useBeforeUnload } from '@/composables/useBeforeUnload';
-import { useCollaborationStore } from '@/stores/collaboration.store';
import { getResourcePermissions } from '@/permissions';
import NodeViewUnfinishedWorkflowMessage from '@/components/NodeViewUnfinishedWorkflowMessage.vue';
@@ -137,7 +136,6 @@ const credentialsStore = useCredentialsStore();
const environmentsStore = useEnvironmentsStore();
const externalSecretsStore = useExternalSecretsStore();
const rootStore = useRootStore();
-const collaborationStore = useCollaborationStore();
const executionsStore = useExecutionsStore();
const canvasStore = useCanvasStore();
const npsSurveyStore = useNpsSurveyStore();
@@ -353,8 +351,6 @@ async function initializeWorkspaceForExistingWorkflow(id: string) {
await projectsStore.setProjectNavActiveIdByWorkflowHomeProject(
editableWorkflow.value.homeProject,
);
-
- collaborationStore.notifyWorkflowOpened(id);
} catch (error) {
toast.showError(error, i18n.baseText('openWorkflow.workflowNotFoundError'));
@@ -1482,7 +1478,6 @@ watch(
onBeforeMount(() => {
if (!isDemoRoute.value) {
pushConnectionStore.pushConnect();
- collaborationStore.initialize();
}
});
@@ -1537,7 +1532,6 @@ onBeforeUnmount(() => {
removeExecutionOpenedEventBindings();
unregisterCustomActions();
if (!isDemoRoute.value) {
- collaborationStore.terminate();
pushConnectionStore.pushDisconnect();
}
});
diff --git a/packages/editor-ui/src/views/NodeViewSwitcher.vue b/packages/editor-ui/src/views/NodeViewSwitcher.vue
index 190b02680af55..adcc58fc98aff 100644
--- a/packages/editor-ui/src/views/NodeViewSwitcher.vue
+++ b/packages/editor-ui/src/views/NodeViewSwitcher.vue
@@ -8,7 +8,6 @@ import { getNodeViewTab } from '@/utils/canvasUtils';
import { MAIN_HEADER_TABS, PLACEHOLDER_EMPTY_WORKFLOW_ID, VIEWS } from '@/constants';
import { useWorkflowsStore } from '@/stores/workflows.store';
import { useWorkflowHelpers } from '@/composables/useWorkflowHelpers';
-import { useCanvasOperations } from '@/composables/useCanvasOperations';
import { useSourceControlStore } from '@/stores/sourceControl.store';
import { useSettingsStore } from '@/stores/settings.store';
@@ -20,8 +19,6 @@ const router = useRouter();
const route = useRoute();
const workflowHelpers = useWorkflowHelpers({ router });
-const { resetWorkspace } = useCanvasOperations({ router });
-
const nodeViewVersion = useLocalStorage(
'NodeView.version',
settingsStore.deploymentType === 'n8n-internal' ? '2' : '1',
@@ -56,9 +53,6 @@ onBeforeRouteLeave(async (to, from, next) => {
await workflowHelpers.promptSaveUnsavedWorkflowChanges(next, {
async confirm() {
- // Make sure workflow id is empty when leaving the editor
- workflowsStore.setWorkflowId(PLACEHOLDER_EMPTY_WORKFLOW_ID);
-
if (from.name === VIEWS.NEW_WORKFLOW) {
// Replace the current route with the new workflow route
// before navigating to the new route when saving new workflow.
@@ -72,11 +66,10 @@ onBeforeRouteLeave(async (to, from, next) => {
return false;
}
- return true;
- },
- async cancel() {
+ // Make sure workflow id is empty when leaving the editor
workflowsStore.setWorkflowId(PLACEHOLDER_EMPTY_WORKFLOW_ID);
- resetWorkspace();
+
+ return true;
},
});
});