Browser Tab and State Management#
Dify's frontend handles two distinct state-management concerns when users switch tabs or navigate away: workflow draft preservation (canvas editor) and conversation ID / chat history persistence (embedded chatbots and ChatFlow apps). Each has its own event listener strategy, storage mechanism, and known failure modes.
Workflow Page: visibilitychange Handling#
The workflow canvas registers both a visibilitychange listener and a beforeunload listener in web/app/components/workflow/index.tsx.
The single callback handleSyncWorkflowDraftWhenPageClose branches on visibility state:
document.visibilityState | Action (Single-User) | Action (Collaboration Mode) |
|---|---|---|
hidden | Calls syncWorkflowDraftWhenPageClose() immediately β saves unsaved edits | Emits graph_view_state(false) event to backend, then saves |
visible | Checks workflowStore for isListening or status === WorkflowRunningStatus.Running; if active, skips refresh; otherwise calls handleRefreshWorkflowDraft() after 500 ms | Emits graph_view_state(true) event; restores canvas from CRDT document instead of database if connected |
The beforeunload listener is a secondary safety net that fires syncWorkflowDraftWhenPageClose() on tab close.
Collaboration Mode: Draft Save Guards#
In collaboration mode, draft saves on page close are gated by guards in syncWorkflowDraftWhenPageClose (from use-nodes-sync-draft.ts) to prevent stale snapshots from overwriting the collaborative document. The save proceeds if either of the following conditions is satisfied (PR #38997, PR #39579):
canFlushGraphOnPageClose()returnstrue: The session is the elected leader with an established collaboration connection.canUseLocalDraftFallback()returnstrue: Collaboration is enabled but the socket has never connected, or the fallback has been activated automatically.
The fallback condition was added in PR #39579 to fix a bug where unsaved edits were silently dropped when collaboration was enabled but the socket never established a connection. Without a connection, leader election never occurs, so the canFlushGraphOnPageClose() guard can never be satisfied. The canUseLocalDraftFallback() method returns true when:
localDraftFallbackActiveistrue(set automatically when an initial connection error occurs to a default WebSocket URL before any successful connection), OR- The socket URL is a default URL (
ws://localhostorws://localhost:5001) ANDisConnected()is false ANDhasEstablishedConnectionis false.
The localDraftFallbackActive flag is automatically set when an initial connection error occurs to a default WebSocket URL before any successful connection has been established (PR #40191). This enables self-hosted deployments that don't configure a WebSocket service to gracefully fall back to local editing mode instead of being blocked by connection attempts. When activated, the system disconnects the WebSocket and stops reconnecting for the rest of the session, allowing users to edit workflows and save drafts via HTTP without requiring a functioning WebSocket service.
If collaboration is enabled and the socket disconnects after a successful connection, the fallback is not triggered β the session must recover through collaboration before saving. Custom WebSocket URLs never trigger automatic fallback; only the built-in default URLs (ws://localhost, ws://localhost:5001) are eligible.
The same two-condition guard is applied in syncWorkflowDraftOnUnmount (workflow unmount handler in index.tsx), with the fallback check performed first: if collaboration is enabled and canUseLocalDraftFallback() returns true, the unmount save calls syncWorkflowDraftWhenPageClose() instead of the normal handleSyncWorkflowDraft path.
Collaboration Mode: graph_view_state Event#
In collaboration mode, the visibilitychange handler emits a graph_view_state event to the backend whenever the tab becomes visible (true) or hidden (false). This event:
- Informs leader election: The backend tracks which tabs are active and prefers visible tabs as the collaboration leader. A hidden tab's canvas is frozen (browser pauses
requestAnimationFrame), so its CRDTβcanvas sync stops while remote edits continue arriving. Saving from a frozen tab would persist stale snapshots. - Triggers leader demotion: When the current leader tab becomes hidden and other visible tabs exist, the backend demotes it and promotes a visible session to prevent stale draft saves.
- Sequenced updates: Each
graph_view_stateevent carries a monotonically increasing sequence number to prevent stale events from overwriting newer visibility state after network delays or reconnection.
The event is emitted in both directions:
- On hide: Before the draft is saved, so the backend can immediately consider this tab ineligible for leadership.
- On show: After checking for active runs (to avoid disrupting in-progress workflows), and before canvas restoration.
When a hidden tab is promoted to leader (e.g., it's the only remaining session), it remains leader until a visible tab joins or the room becomes empty.
Collaboration Mode: Canvas Restoration from CRDT#
When a collaboration-connected tab becomes visible, the canvas is restored from the CRDT document state rather than re-importing the database draft. This is critical because:
- The CRDT document remains live in background tabs, receiving remote edits via WebSocket even while the canvas is frozen.
- The database draft may hold a stale snapshot that this tab (or another frozen tab) saved while hidden.
- Re-importing the database would broadcast a rollback to all connected users, overwriting their recent work.
The CRDT remains the authoritative source as long as the tab stays connected. If the CRDT document is untrusted (e.g., after a disconnect/reconnect cycle), the collaboration manager requests a snapshot from the current leader via graph_resync_request.
Fixed limitation (issue #38555): Earlier versions only checked for Running or isListening states when guarding the canvas refresh. If a run completed while the tab was hidden, the status was no longer Running when the user returned, so handleRefreshWorkflowDraft() fired and wiped displayed results. This regression (v1.13.3+) was fixed in PR #40775 by preserving the terminal single-run status after page restoration, keeping completed last-run results authoritative when a workflow canvas refresh clears transient node status. The partial fix shipped in PR #31354.
Conversation ID Persistence (Dual-Storage Architecture)#
Both the embedded chatbot and chat-with-history components use a dual-storage approach for conversation IDs. The system maintains two separate stores:
- Tab-scoped storage (
sessionStorage, key:tabConversationIdInfo) β holds the active conversation ID for the current browser tab - Cross-tab fallback (
localStorage, key:conversationIdInfo) β stores the last conversation ID, shared across all tabs
Both stores use a nested object structure keyed by appId β userId β conversationId, managed via the foxact library's createSessionStorageState and createLocalStorageState. The hook useConversationSelection provides the unified read/write interface used across:
web/app/components/base/chat/embedded-chatbot/hooks.tsxβcurrentConversationId,handleConversationIdInfoChange,removeConversationIdInfoweb/app/components/base/chat/chat-with-history/hooks.tsxβ equivalent functions
Tab Isolation and Precedence#
The dual-storage mechanism prevents cross-tab interference when users open Dify in multiple browser tabs simultaneously (PR #40709):
- Each tab owns its active conversation via
sessionStorage, scoped to that tab's lifetime. - Storage updates from other tabs (via
localStorage) do not switch or remount the active chat in the current tab. - On initial load, the tab seeds its
sessionStoragefrom thelocalStoragefallback, then becomes independent. - Conversation changes in a tab update both stores: the tab's own
sessionStorageand the sharedlocalStorage(for use by future tabs).
This prevents scenarios where opening a new conversation in one tab would cause an active chat in another tab to suddenly switch or remount.
Stale closure bug (v1.13.0βv1.15.0+): handleConversationIdInfoChange originally spread conversationIdInfo directly from the closure instead of using a functional update. When React batched state updates, the spread captured a stale snapshot, so the old conversation_id survived a reset. PR #33375 fixed this by switching to setConversationIdInfo(prev => ...) and removing conversationIdInfo from the dependency array. The bug is tracked in issue #38403 and remains reproducible on versions that haven't picked up the fix.
Stale localStorage on deleted conversation: If a conversation is deleted server-side, the persisted conversation_id causes a 404 loop on next load. PR #34945 adds a useEffect in both hooks that detects the 404 error, calls removeConversationIdInfo(), and clears the stale entry. It also sets retry: false on the SWR fetch to prevent infinite retries.
Tab-Switch API Call Behavior#
SWR (used for chat list fetches in web/service/use-share.ts) by default re-fetches on tab focus (revalidateOnFocus) and network reconnect (revalidateOnReconnect). This caused unwanted API "recall" bursts when switching tabs.
PR #23301 disables both flags for all conversation list queries:
{ revalidateOnFocus: false, revalidateOnReconnect: false }
It also gates the SWR key on appId being available (appId ? [...] : null) to prevent fetches before the app is initialized.
Separate concern β stale chat history: Even with revalidateOnFocus: false, the chat list cache could become stale when a user returns to a conversation they viewed earlier. PR #30389 sets staleTime: 0 in the React Query configuration for useShareChatList, triggering a background revalidation on remount while still rendering cached data instantly (stale-while-revalidate pattern).
Key Files and Entry Points#
| File | Responsibility |
|---|---|
web/app/components/workflow/index.tsx (lines 385β447) | visibilitychange / beforeunload handlers; draft sync + conditional refresh |
web/app/components/base/chat/storage.ts | useConversationSelection β dual-storage hook with tab-scoped (sessionStorage) and cross-tab (localStorage) conversation ID stores |
web/app/components/base/chat/constants.ts | CONVERSATION_ID_INFO (localStorage) and TAB_CONVERSATION_ID_INFO (sessionStorage) keys |
web/app/components/base/chat/embedded-chatbot/hooks.tsx | Embedded chatbot conversation selection via useConversationSelection, 404 cleanup |
web/app/components/base/chat/chat-with-history/hooks.tsx | Chat-with-history conversation selection via useConversationSelection, 404 cleanup |
web/service/use-share.ts | SWR/React Query config: revalidateOnFocus: false, staleTime: 0 |
Related PRs (chronological)#
- PR #23301 β disable SWR focus revalidation on tab switch
- PR #30389 β
staleTime: 0for chat list freshness on remount - PR #31354 β guard workflow refresh against active runs on tab return
- PR #33375 β fix stale closure in
handleConversationIdInfoChange - PR #34945 β clear stale
conversation_idfrom localStorage on 404 - PR #38997 β prevent hidden-tab collaboration leader from saving stale drafts via
graph_view_stateevent - PR #39579 β add
canUseLocalDraftFallback()to allow saves when collaboration socket never connects - PR #40191 β activate local draft fallback automatically when default WebSocket URL fails to connect
- PR #40709 β scope conversation IDs to browser tabs via dual-storage (sessionStorage + localStorage) to prevent cross-tab interference
- PR #40775 β preserve completed last-run results when returning to tab after visibility change
Open Issues#
- #38403 β Embedded chatbot reset still uses old
conversation_id(stale closure, v1.13.0+)