Skip to content
Open
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ and this project adheres to
[PR#4047](https://github.com/OpenFn/lightning/pull/4047)
- Fix error validation for nodes/edges & show better error messages on save
[PR#4061](https://github.com/OpenFn/lightning/pull/4061)
- Loading workflow screen appears when disconnected from server
[#3972](https://github.com/OpenFn/lightning/issues/3972)

## [2.15.0-pre] - 2025-11-20

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,16 @@ export function LoadingBoundary({ children }: LoadingBoundaryProps) {
// - session.settled: Y.Doc has synced AND received first update from provider
// - workflow !== null: WorkflowStore observers have populated state
// - !sessionContextLoading: SessionContext (including latestSnapshotLockVersion) is loaded
//
// Allow rendering if workflow data exists, even when not settled
// This enables viewing cached data during disconnection
const hasWorkflowData = workflow !== null;
const isInitialLoad = !hasWorkflowData;

const isReady =
session.settled && workflow !== null && !sessionContextLoading;
!sessionContextLoading &&
hasWorkflowData &&
(session.settled || !isInitialLoad);

if (!isReady) {
return (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -577,7 +577,9 @@ export function FullScreenIDE({
const handleVersionSelect = useVersionSelect();

// Check loading state but don't use early return (violates rules of hooks)
const isLoading = !currentJob || !currentJobYText || !awareness;
// Only check for job existence, not ytext/awareness
// ytext and awareness persist during disconnection for offline editing
const isLoading = !currentJob;

// If loading, render loading state at the end instead of early return
if (isLoading) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
/**
* ConnectionStatusContext - Centralized connection state management
*
* Provides connection status information to all components in the tree.
* Makes it easy to show connection indicators, offline status, and sync state.
*/

import type React from 'react';
import { createContext, useContext, useMemo } from 'react';

export interface ConnectionStatus {
/** Whether the socket is currently connected */
isConnected: boolean;

/** Whether the Y.Doc provider is synced with the server */
isSynced: boolean;

/** Timestamp of last successful sync */
lastSyncTime: Date | null;

/** Current error if any */
error: Error | null;
}

const ConnectionStatusContext = createContext<ConnectionStatus | null>(null);

export interface ConnectionStatusProviderProps {
children: React.ReactNode;
isConnected: boolean;
isSynced: boolean;
lastSyncTime: Date | null;
error: Error | null;
}

/**
* Provider component that wraps the app and provides connection status
*/
export function ConnectionStatusProvider({
children,
isConnected,
isSynced,
lastSyncTime,
error,
}: ConnectionStatusProviderProps) {
const value = useMemo(
() => ({
isConnected,
isSynced,
lastSyncTime,
error,
}),
[isConnected, isSynced, lastSyncTime, error]
);

return (
<ConnectionStatusContext.Provider value={value}>
{children}
</ConnectionStatusContext.Provider>
);
}

/**
* Hook to access connection status from any component
*
* @example
* const { isConnected, isSynced } = useConnectionStatus();
*
* if (!isConnected) {
* return <OfflineIndicator />;
* }
*/
export function useConnectionStatus(): ConnectionStatus {
const context = useContext(ConnectionStatusContext);

if (!context) {
throw new Error(
'useConnectionStatus must be used within ConnectionStatusProvider'
);
}

return context;
}
185 changes: 135 additions & 50 deletions assets/js/collaborative-editor/contexts/SessionProvider.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,21 @@
/**
* SessionProvider - Handles shared Yjs document, awareness, and Phoenix Channel concerns
* Provides common infrastructure for TodoStore and WorkflowStore
*
* Refactored to use focused hooks for better separation of concerns:
* - useProviderLifecycle: Manages provider creation/reconnection
* - ConnectionStatusProvider: Exposes connection state to components
*/

import type React from 'react';
import { createContext, useEffect, useMemo, useState } from 'react';
import {
createContext,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';

import _logger from '#/utils/logger';

Expand All @@ -14,6 +25,9 @@ import {
createSessionStore,
type SessionStoreInstance,
} from '../stores/createSessionStore';
import { ConnectionStatusProvider } from './ConnectionStatusContext';
import { useProviderLifecycle } from '../hooks/useProviderLifecycle';
import { useYDocPersistence } from '../hooks/useYDocPersistence';

const logger = _logger.ns('SessionProvider').seal();

Expand Down Expand Up @@ -46,44 +60,122 @@ export const SessionProvider = ({
// Create store instance once - stable reference
const [sessionStore] = useState(() => createSessionStore());

useEffect(() => {
if (!isConnected || !socket) return;

logger.log('Initializing Session with PhoenixChannelProvider', { version });

// Create the Yjs channel provider
// IMPORTANT: Room naming strategy for snapshots vs collaborative editing:
// - NO version param (?v not in URL) → `workflow:collaborate:${workflowId}`
// This is the "latest" room where all users collaborate in real-time
// Everyone in this room sees the same state and moves forward together
// - WITH version param (?v=22) → `workflow:collaborate:${workflowId}:v22`
// This is a separate, isolated room for viewing that specific snapshot
// Users viewing old versions don't interfere with users on latest
const roomname = version
? `workflow:collaborate:${workflowId}:v${version}`
: `workflow:collaborate:${workflowId}`;

logger.log('Creating PhoenixChannelProvider with:', {
roomname,
socketConnected: socket.isConnected(),
version,
isLatestRoom: !version,
});

// Initialize session - createSessionStore handles everything
// Pass null for userData - StoreProvider will initialize it from SessionContextStore
const joinParams = {
// Track sync state for ConnectionStatusContext
const [isSynced, setIsSynced] = useState(false);
const [lastSyncTime, setLastSyncTime] = useState<Date | null>(null);
const [connectionError, setConnectionError] = useState<Error | null>(null);

// Room naming strategy for snapshots vs collaborative editing:
// - NO version param → `workflow:collaborate:${workflowId}` (latest/collaborative)
// - WITH version param → `workflow:collaborate:${workflowId}:v${version}` (snapshot)
const roomname = useMemo(
() =>
version
? `workflow:collaborate:${workflowId}:v${version}`
: `workflow:collaborate:${workflowId}`,
[version, workflowId]
);

const joinParams = useMemo(
() => ({
project_id: projectId,
action: isNewWorkflow ? 'new' : 'edit',
}),
[projectId, isNewWorkflow]
);

// Handle roomname changes (version switching)
// When roomname changes, destroy session in cleanup to allow reinitialization
useEffect(() => {
return () => {
logger.log('Room changing - destroying session for reinitialization', {
roomname,
});
sessionStore.destroy();
};
}, [roomname, sessionStore]);

// Use Y.Doc persistence hook to manage Y.Doc lifecycle
const handleYDocInitialized = useCallback(() => {
logger.log('Y.Doc initialized', { version });
}, [version]);

const handleYDocDestroyed = useCallback(() => {
logger.log('Y.Doc destroyed (version change or unmount)', { version });
setIsSynced(false);
setLastSyncTime(null);
setConnectionError(null);
}, [version]);

useYDocPersistence({
sessionStore,
shouldInitialize: socket !== null && isConnected,
version,
onInitialized: handleYDocInitialized,
onDestroyed: handleYDocDestroyed,
});

// Use provider lifecycle hook to manage provider initialization
const handleProviderError = useCallback((error: Error | null) => {
setConnectionError(error);
}, []);

const handleProviderReady = useCallback(() => {
logger.log('Provider ready');
setIsSynced(false); // Will become true after sync
}, []);

const handleProviderReconnected = useCallback(() => {
logger.log('Provider reconnected, syncing...');
setIsSynced(false); // Will become true after sync
}, []);

useProviderLifecycle({
socket,
isConnected,
sessionStore,
roomname,
joinParams,
onError: handleProviderError,
onProviderReady: handleProviderReady,
onProviderReconnected: handleProviderReconnected,
});

// Track sync status from provider
useEffect(() => {
if (!sessionStore.provider) {
setIsSynced(false);
return;
}

const provider = sessionStore.provider;

const handleSync = (synced: boolean) => {
setIsSynced(synced);
if (synced) {
setLastSyncTime(new Date());
}
};

sessionStore.initializeSession(socket, roomname, null, {
connect: true,
joinParams,
});
// Initial sync state
handleSync(provider.synced || false);

// Listen for sync events
provider.on('sync', handleSync);

return () => {
provider.off('sync', handleSync);
};
}, [sessionStore.provider]);

// Testing and debug helpers
useEffect(() => {
// Testing helper to simulate a reconnect
window.triggerSessionReconnect = (timeout = 1000) => {
if (!socket) {
console.error('Socket not available');
return;
}
socket.disconnect(
() => {
logger.log('socket disconnected');
Expand All @@ -96,21 +188,7 @@ export const SessionProvider = ({
'Testing reconnect'
);
};

// Cleanup function
return () => {
logger.debug('PhoenixChannelProvider: cleaning up', { version });
sessionStore.destroy();
};
}, [
isConnected,
socket,
workflowId,
projectId,
isNewWorkflow,
sessionStore,
version,
]);
}, [sessionStore, socket]);

// Memoize context value to prevent unnecessary re-renders
// isNewWorkflow can change from true to false after user saves
Expand All @@ -120,8 +198,15 @@ export const SessionProvider = ({
);

return (
<SessionContext.Provider value={contextValue}>
{children}
</SessionContext.Provider>
<ConnectionStatusProvider
isConnected={isConnected}
isSynced={isSynced}
lastSyncTime={lastSyncTime}
error={connectionError}
>
<SessionContext.Provider value={contextValue}>
{children}
</SessionContext.Provider>
</ConnectionStatusProvider>
);
};
Loading