diff --git a/packages/toolbar/src/core/services/DevServerClient.ts b/packages/toolbar/src/core/services/DevServerClient.ts index 73a6dc10..cb47219d 100644 --- a/packages/toolbar/src/core/services/DevServerClient.ts +++ b/packages/toolbar/src/core/services/DevServerClient.ts @@ -6,6 +6,7 @@ export interface DevServerProjectResponse { flagsState: Record; overrides: Record; sourceEnvironmentKey: string; + context?: any; } export interface FlagState { @@ -89,4 +90,29 @@ export class DevServerClient { throw error; } } + + async updateProjectContext(context: any): Promise { + const url = `${this.baseUrl}/dev/projects/${this.projectKey}`; + + try { + const response = await fetch(url, { + method: 'PATCH', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ context }), + }); + + if (!response.ok) { + throw new Error(`Failed to update project context: ${response.status} ${response.statusText}`); + } + + return await response.json(); + } catch (error) { + if (error instanceof TypeError) { + throw new Error(`Failed to connect to dev server at ${this.baseUrl}`); + } + throw error; + } + } } diff --git a/packages/toolbar/src/core/tests/DevServerProvider.test.tsx b/packages/toolbar/src/core/tests/DevServerProvider.test.tsx index 64aa5dd4..372d4df4 100644 --- a/packages/toolbar/src/core/tests/DevServerProvider.test.tsx +++ b/packages/toolbar/src/core/tests/DevServerProvider.test.tsx @@ -18,6 +18,13 @@ const mockDevServerClientInstance = { }), setOverride: vi.fn(), clearOverride: vi.fn(), + updateProjectContext: vi.fn().mockResolvedValue({ + sourceEnvironmentKey: 'test-environment', + flagsState: {}, + overrides: {}, + availableVariations: {}, + _lastSyncedFromSource: Date.now(), + }), }; const mockFlagStateManagerInstance = { @@ -56,6 +63,13 @@ const mockGetProjects = vi.fn().mockResolvedValue([{ key: 'test-project', name: const mockProjectKey = { current: 'test-project' }; const mockGetProjectFlags = vi.fn().mockResolvedValue({ items: [] }); +// Mock context management functions with stable references +const mockContextsArray: any[] = []; // Stable reference to avoid triggering useCallback changes +const mockSetContext = vi.fn().mockResolvedValue(undefined); +const mockAddContext = vi.fn(); +const mockUpdateContext = vi.fn(); +const mockActiveContext = { current: null as any }; + // Mock the api module which exports all API-related context vi.mock('../ui/Toolbar/context/api', () => ({ useProjectContext: () => ({ @@ -109,6 +123,23 @@ vi.mock('../ui/Toolbar/context/api/ApiProvider', () => ({ }), })); +vi.mock('../ui/Toolbar/context/api/ContextsProvider', () => ({ + ContextsProvider: ({ children }: { children: React.ReactNode }) => <>{children}, + useContextsContext: () => ({ + contexts: mockContextsArray, // Use stable reference + filter: '', + setFilter: vi.fn(), + addContext: mockAddContext, + removeContext: vi.fn(), + updateContext: mockUpdateContext, + setContext: mockSetContext, + activeContext: mockActiveContext.current, + isAddFormOpen: false, + setIsAddFormOpen: vi.fn(), + clearContexts: vi.fn(), + }), +})); + // Test component that consumes the context function TestConsumer() { const { state, refresh } = useDevServerContext(); @@ -146,9 +177,22 @@ describe('DevServerProvider - Integration Flows', () => { availableVariations: {}, _lastSyncedFromSource: Date.now(), }); + mockDevServerClientInstance.updateProjectContext.mockResolvedValue({ + sourceEnvironmentKey: 'test-environment', + flagsState: {}, + overrides: {}, + availableVariations: {}, + _lastSyncedFromSource: Date.now(), + }); mockFlagStateManagerInstance.getEnhancedFlags.mockResolvedValue({}); mockFlagStateManagerInstance.subscribe.mockReturnValue(() => {}); + + // Reset context mocks + mockSetContext.mockResolvedValue(undefined); + mockAddContext.mockClear(); + mockUpdateContext.mockClear(); + mockActiveContext.current = null; }); afterEach(() => { @@ -453,4 +497,272 @@ describe('DevServerProvider - Integration Flows', () => { expect(mockDevServerClientInstance.getProjectData).toHaveBeenCalled(); }); }); + + describe('Context Synchronization - Bidirectional Sync', () => { + test('syncs context to dev server when active context changes in toolbar', async () => { + // GIVEN: Developer has toolbar connected to dev server + const testContext = { kind: 'user', key: 'test-user-123', name: 'Test User' }; + mockActiveContext.current = null; + + render( + + + , + ); + + // WHEN: Toolbar initializes and connects + await waitFor(() => { + const connectionStatus = screen.getByTestId('connection-status'); + return connectionStatus.textContent === 'connected'; + }); + + // Clear any calls from initialization + mockDevServerClientInstance.updateProjectContext.mockClear(); + + // AND: Active context changes in the toolbar + mockActiveContext.current = testContext; + + // Trigger re-render to simulate context change + render( + + + , + ); + + // THEN: Context is synced to dev server via updateProjectContext + await waitFor(() => { + return mockDevServerClientInstance.updateProjectContext.mock.calls.length > 0; + }); + + expect(mockDevServerClientInstance.updateProjectContext).toHaveBeenCalledWith(testContext); + }); + + test('syncs context from dev server to toolbar when dev server context changes', async () => { + // GIVEN: Developer has toolbar connected with initial context + const initialContext = { kind: 'user', key: 'user-1', name: 'User One' }; + const updatedContext = { kind: 'user', key: 'user-2', name: 'User Two' }; + + let callCount = 0; + mockDevServerClientInstance.getProjectData.mockImplementation(async () => { + callCount++; + return { + sourceEnvironmentKey: 'test-environment', + flagsState: {}, + overrides: {}, + availableVariations: {}, + _lastSyncedFromSource: Date.now(), + context: callCount === 1 ? initialContext : updatedContext, + }; + }); + + render( + + + , + ); + + // WHEN: Toolbar connects and gets initial context + await waitFor(() => { + const connectionStatus = screen.getByTestId('connection-status'); + return connectionStatus.textContent === 'connected'; + }); + + // Verify initial context was set + await waitFor(() => { + return mockSetContext.mock.calls.some((call) => call[0].key === 'user-1'); + }); + + const initialSetContextCalls = mockSetContext.mock.calls.length; + + // AND: Dev server context changes on next poll + // Wait for next poll cycle + await new Promise((resolve) => setTimeout(resolve, 150)); + + // THEN: Updated context is synced to toolbar via setContext + await waitFor(() => { + return mockSetContext.mock.calls.length > initialSetContextCalls; + }); + + const lastCall = mockSetContext.mock.calls[mockSetContext.mock.calls.length - 1]; + expect(lastCall[0]).toEqual(updatedContext); + }); + + test('adds new context when dev server context is not in stored contexts', async () => { + // GIVEN: Developer has toolbar connected with no stored contexts + const newContext = { kind: 'organization', key: 'org-123', name: 'Test Org' }; + + mockDevServerClientInstance.getProjectData.mockResolvedValue({ + sourceEnvironmentKey: 'test-environment', + flagsState: {}, + overrides: {}, + availableVariations: {}, + _lastSyncedFromSource: Date.now(), + context: newContext, + }); + + render( + + + , + ); + + // WHEN: Toolbar connects and receives context from dev server + await waitFor(() => { + const connectionStatus = screen.getByTestId('connection-status'); + return connectionStatus.textContent === 'connected'; + }); + + // THEN: New context is added to stored contexts via addContext + await waitFor(() => { + return mockAddContext.mock.calls.length > 0; + }); + + expect(mockAddContext).toHaveBeenCalledWith(newContext); + }); + + test('updates existing context when dev server context has same kind+key but different properties', async () => { + // GIVEN: Developer has existing context in stored contexts + const existingContext = { kind: 'user', key: 'user-1', name: 'Old Name', email: 'old@example.com' }; + const updatedContext = { kind: 'user', key: 'user-1', name: 'New Name', email: 'new@example.com' }; + + // Mock that context already exists + mockContextsArray.length = 0; + mockContextsArray.push(existingContext); + + mockDevServerClientInstance.getProjectData.mockResolvedValue({ + sourceEnvironmentKey: 'test-environment', + flagsState: {}, + overrides: {}, + availableVariations: {}, + _lastSyncedFromSource: Date.now(), + context: updatedContext, + }); + + render( + + + , + ); + + // WHEN: Toolbar receives updated context from dev server with same kind+key + await waitFor(() => { + const connectionStatus = screen.getByTestId('connection-status'); + return connectionStatus.textContent === 'connected'; + }); + + // THEN: Existing context is updated via updateContext (not added as duplicate) + await waitFor(() => { + return mockUpdateContext.mock.calls.length > 0; + }); + + expect(mockUpdateContext).toHaveBeenCalled(); + // Should not add a duplicate + expect(mockAddContext).not.toHaveBeenCalled(); + }); + + test('does not sync context to dev server when context has not changed', async () => { + // GIVEN: Developer has toolbar connected with active context + const unchangedContext = { kind: 'user', key: 'user-1', name: 'User One' }; + mockActiveContext.current = unchangedContext; + + render( + + + , + ); + + await waitFor(() => { + const connectionStatus = screen.getByTestId('connection-status'); + return connectionStatus.textContent === 'connected'; + }); + + const initialUpdateCalls = mockDevServerClientInstance.updateProjectContext.mock.calls.length; + + // WHEN: Same context is set again (no actual change) + // Wait a bit to ensure no additional calls are made + await new Promise((resolve) => setTimeout(resolve, 100)); + + // THEN: updateProjectContext is not called again (optimization working) + expect(mockDevServerClientInstance.updateProjectContext.mock.calls.length).toBe(initialUpdateCalls); + }); + + test('prevents infinite sync loop between toolbar and dev server', async () => { + // GIVEN: Developer has toolbar connected + const testContext = { kind: 'user', key: 'test-user', name: 'Test User' }; + + let getProjectDataCallCount = 0; + mockDevServerClientInstance.getProjectData.mockImplementation(async () => { + getProjectDataCallCount++; + return { + sourceEnvironmentKey: 'test-environment', + flagsState: {}, + overrides: {}, + availableVariations: {}, + _lastSyncedFromSource: Date.now(), + context: testContext, + }; + }); + + render( + + + , + ); + + // WHEN: Context is synced from dev server to toolbar + await waitFor(() => { + const connectionStatus = screen.getByTestId('connection-status'); + return connectionStatus.textContent === 'connected'; + }); + + await waitFor(() => { + return mockSetContext.mock.calls.length > 0; + }); + + const updateContextCallsBefore = mockDevServerClientInstance.updateProjectContext.mock.calls.length; + + // Wait for multiple poll cycles + await new Promise((resolve) => setTimeout(resolve, 300)); + + // THEN: updateProjectContext is not called repeatedly (loop prevention working) + // Should not have excessive calls indicating a loop + const updateContextCallsAfter = mockDevServerClientInstance.updateProjectContext.mock.calls.length; + expect(updateContextCallsAfter - updateContextCallsBefore).toBeLessThan(2); + }); + }); }); diff --git a/packages/toolbar/src/core/ui/Toolbar/context/DevServerProvider.tsx b/packages/toolbar/src/core/ui/Toolbar/context/DevServerProvider.tsx index 88c39230..5ae32740 100644 --- a/packages/toolbar/src/core/ui/Toolbar/context/DevServerProvider.tsx +++ b/packages/toolbar/src/core/ui/Toolbar/context/DevServerProvider.tsx @@ -1,10 +1,12 @@ -import { createContext, useContext, useState, useEffect, useMemo, useCallback } from 'react'; +import { createContext, useContext, useState, useEffect, useMemo, useCallback, useRef } from 'react'; import type { FC, ReactNode } from 'react'; import { DevServerClient } from '../../../services/DevServerClient'; import { FlagStateManager } from '../../../services/FlagStateManager'; import { LdToolbarConfig, ToolbarState } from '../../../types/devServer'; import { useFlagsContext } from './api/FlagsProvider'; import { useApi, useProjectContext } from './api'; +import { useContextsContext } from './api/ContextsProvider'; +import { areContextsEqual, generateContextId, getContextKey, getContextKind } from '../utils/context'; interface DevServerContextValue { state: ToolbarState; @@ -12,6 +14,7 @@ interface DevServerContextValue { clearOverride: (flagKey: string) => Promise; clearAllOverrides: () => Promise; refresh: () => Promise; + devServerClient: DevServerClient | null; } const DevServerContext = createContext(null); @@ -33,6 +36,8 @@ export const DevServerProvider: FC = ({ children, config const { getProjectFlags } = useFlagsContext(); const { projectKey, getProjects } = useProjectContext(); const { apiReady } = useApi(); + const contextsApi = useContextsContext(); + const { setContext, activeContext, updateContext: updateStoredContext, addContext } = contextsApi; const [toolbarState, setToolbarState] = useState(() => { return { @@ -48,6 +53,12 @@ export const DevServerProvider: FC = ({ children, config // Track the last sync timestamp from dev server to detect changes const [lastDevServerSync, setLastDevServerSync] = useState(0); + // Track the last known dev server context + const lastDevServerContextRef = useRef(null); + + // Track when we're updating from dev server to prevent sync loops + const isUpdatingFromDevServerRef = useRef(false); + const devServerClient = useMemo(() => { // Only create devServerClient if devServerUrl is provided (dev-server mode) if (config.devServerUrl) { @@ -93,6 +104,59 @@ export const DevServerProvider: FC = ({ children, config return true; }, [flagStateManager, handleError]); + // Helper: Add or update context in stored contexts (compared by kind+key) + const addOrUpdateStoredContext = useCallback( + (context: any) => { + const contextKind = getContextKind(context); + const contextKey = getContextKey(context); + const contexts = contextsApi.contexts; + + const existingContextIndex = contexts.findIndex((c) => { + return getContextKind(c) === contextKind && getContextKey(c) === contextKey; + }); + + if (existingContextIndex >= 0) { + // Update existing context + const existingContextId = generateContextId(contexts[existingContextIndex]); + updateStoredContext(existingContextId, context); + } else { + // Add new context + addContext(context); + } + }, + [contextsApi.contexts, updateStoredContext, addContext], + ); + + // Watch for context changes in toolbar and sync to dev server + useEffect(() => { + if (!devServerClient || !projectKey || !activeContext) { + return; + } + + // Skip if we're currently updating from dev server (prevents loop) + if (isUpdatingFromDevServerRef.current) { + return; + } + + // Skip if context hasn't changed from what dev server already has + if (areContextsEqual(lastDevServerContextRef.current, activeContext)) { + return; + } + + // Update dev server with new context + const syncToDevServer = async () => { + try { + await devServerClient.updateProjectContext(activeContext); + // Update our local reference + lastDevServerContextRef.current = activeContext; + } catch (error) { + console.error('Failed to sync context to dev server:', error); + } + }; + + syncToDevServer(); + }, [activeContext, devServerClient, projectKey]); + // Helper: Load and sync flags from dev server and API // Only fetches from API if dev server data has changed (based on _lastSyncedFromSource timestamp) // Set forceApiRefresh=true to always fetch from API (useful for manual refresh) @@ -114,6 +178,29 @@ export const DevServerProvider: FC = ({ children, config // Check if dev server data has changed since last sync const devServerDataChanged = projectData._lastSyncedFromSource !== lastDevServerSync; + // Check if dev server context has changed and update toolbar + if (projectData.context) { + const contextChanged = !areContextsEqual(lastDevServerContextRef.current, projectData.context); + if (contextChanged) { + isUpdatingFromDevServerRef.current = true; + lastDevServerContextRef.current = projectData.context; + + // Add or update context in stored contexts before activating it + addOrUpdateStoredContext(projectData.context); + + try { + await setContext(projectData.context); + } catch (error) { + console.error('Failed to update toolbar context from dev server:', error); + } finally { + // Reset flag after a short delay to ensure state updates settle + setTimeout(() => { + isUpdatingFromDevServerRef.current = false; + }, 100); + } + } + } + // Only fetch API flags if: // 1. This is the first sync (lastDevServerSync === 0), OR // 2. Dev server data has changed (_lastSyncedFromSource timestamp changed), OR @@ -137,7 +224,16 @@ export const DevServerProvider: FC = ({ children, config isLoading: false, })); }, - [devServerClient, flagStateManager, projectKey, getProjectFlags, lastDevServerSync, apiReady], + [ + devServerClient, + flagStateManager, + projectKey, + getProjectFlags, + lastDevServerSync, + apiReady, + setContext, + addOrUpdateStoredContext, + ], ); const initializeProjectSelection = useCallback(async () => { @@ -148,7 +244,7 @@ export const DevServerProvider: FC = ({ children, config // Get available projects await getProjects(); - }, [devServerClient, projectKey, getProjects]); + }, [devServerClient, getProjects]); useEffect(() => { const setupProjectConnection = async () => { @@ -344,8 +440,9 @@ export const DevServerProvider: FC = ({ children, config clearOverride, clearAllOverrides, refresh, + devServerClient, }), - [toolbarState, setOverride, clearOverride, clearAllOverrides, refresh], + [toolbarState, setOverride, clearOverride, clearAllOverrides, refresh, devServerClient], ); return {children}; diff --git a/packages/toolbar/src/core/ui/Toolbar/context/api/ContextsProvider.tsx b/packages/toolbar/src/core/ui/Toolbar/context/api/ContextsProvider.tsx index c1aa700b..3950ae1e 100644 --- a/packages/toolbar/src/core/ui/Toolbar/context/api/ContextsProvider.tsx +++ b/packages/toolbar/src/core/ui/Toolbar/context/api/ContextsProvider.tsx @@ -96,41 +96,6 @@ export const ContextsProvider = ({ children }: { children: React.ReactNode }) => [activeContext, analytics], ); - // Update a context in the list (contextId is the hash from generateContextId) - const updateContext = useCallback( - (contextId: string, newContext: LDContext) => { - setStoredContexts((prev) => { - const oldContext = prev.find((c) => generateContextId(c) === contextId); - const updated = prev.map((c) => { - if (generateContextId(c) === contextId) { - return newContext; - } - return c; - }); - saveContexts(updated); - - // If the updated context is the active context, update it - const activeContextId = generateContextId(activeContext); - if (activeContext && activeContextId === contextId) { - setActiveContext(newContext); - saveActiveContext(newContext); - } - - // Track analytics - if (oldContext) { - const oldKind = getContextKind(oldContext); - const newKind = getContextKind(newContext); - const oldKey = getContextKey(oldContext) || getContextDisplayName(oldContext); - const newKey = getContextKey(newContext) || getContextDisplayName(newContext); - analytics.trackContextUpdated(oldKind, oldKey, newKind, newKey); - } - - return updated; - }); - }, - [activeContext, analytics], - ); - // Set the current context and update the host application's LD Client via identify const setContext = useCallback( async (context: LDContext) => { @@ -171,6 +136,43 @@ export const ContextsProvider = ({ children }: { children: React.ReactNode }) => [ldClient, analytics, activeContext], ); + // Update a context in the list (contextId is the hash from generateContextId) + const updateContext = useCallback( + (contextId: string, newContext: LDContext) => { + setStoredContexts((prev) => { + const oldContext = prev.find((c) => generateContextId(c) === contextId); + const updated = prev.map((c) => { + if (generateContextId(c) === contextId) { + return newContext; + } + return c; + }); + saveContexts(updated); + + // If the updated context is the active context, update it + const activeContextId = generateContextId(activeContext); + if (activeContext && activeContextId === contextId) { + // Use setContext to properly update the LD client and sync with dev server + setContext(newContext).catch((error) => { + console.error('Failed to update active context:', error); + }); + } + + // Track analytics + if (oldContext) { + const oldKind = getContextKind(oldContext); + const newKind = getContextKind(newContext); + const oldKey = getContextKey(oldContext) || getContextDisplayName(oldContext); + const newKey = getContextKey(newContext) || getContextDisplayName(newContext); + analytics.trackContextUpdated(oldKind, oldKey, newKind, newKey); + } + + return updated; + }); + }, + [activeContext, analytics, setContext], + ); + // Restore saved active context on mount when LD client is available useEffect(() => { if (!ldClient || hasRestoredContextRef.current) {