From 6d4ccaf14dbd3c85107dda48b8d48ae806e44dbe Mon Sep 17 00:00:00 2001 From: martincupela Date: Thu, 9 Apr 2026 15:17:28 +0200 Subject: [PATCH 1/2] fix(dialog): keep DialogManagerProvider mounted on first render and stabilize manager identity --- .../__tests__/DialogManagerContext.test.tsx | 16 +++++ src/context/DialogManagerContext.tsx | 62 +++++++++++-------- 2 files changed, 53 insertions(+), 25 deletions(-) diff --git a/src/components/Dialog/__tests__/DialogManagerContext.test.tsx b/src/components/Dialog/__tests__/DialogManagerContext.test.tsx index 66edd0f87..ca0ec9d61 100644 --- a/src/components/Dialog/__tests__/DialogManagerContext.test.tsx +++ b/src/components/Dialog/__tests__/DialogManagerContext.test.tsx @@ -1,5 +1,7 @@ import React from 'react'; import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { vi } from 'vitest'; import { DialogManagerProvider, useDialogManager, @@ -7,6 +9,10 @@ import { import { useDialogIsOpen, useOpenedDialogCount } from '../hooks'; +vi.mock('../../../components/Dialog/service/DialogPortal', () => ({ + DialogPortalDestination: () => null, +})); + const TEST_IDS = { CLOSE_DIALOG: 'close-dialog', DIALOG_COUNT: 'dialog-count', @@ -89,6 +95,16 @@ describe('DialogManagerContext', () => { expect(screen.getByTestId(TEST_IDS.DIALOG_COUNT).textContent).toBe('0'); }); + it('renders children during SSR when id is provided', () => { + const html = renderToStaticMarkup( + +
server-rendered-child
+
, + ); + + expect(html).toContain('server-rendered-child'); + }); + it('provides dialog manager to non-child components', () => { render( diff --git a/src/context/DialogManagerContext.tsx b/src/context/DialogManagerContext.tsx index 74a49fabc..1f015d197 100644 --- a/src/context/DialogManagerContext.tsx +++ b/src/context/DialogManagerContext.tsx @@ -15,27 +15,12 @@ type DialogManagerId = string; type DialogManagersState = Record; const dialogManagersRegistry: StateStore = new StateStore({}); +const pendingDialogManagersById: Partial> = {}; +const dialogManagerMountCountsById: Partial> = {}; const getDialogManager = (id: string): DialogManager | undefined => dialogManagersRegistry.getLatestValue()[id]; -const getOrCreateDialogManager = ({ - closeOnClickOutside, - id, -}: { - closeOnClickOutside?: boolean; - id: string; -}) => { - let manager = getDialogManager(id); - if (!manager) { - manager = new DialogManager({ closeOnClickOutside, id }); - dialogManagersRegistry.partialNext({ [id]: manager }); - } else if (typeof closeOnClickOutside === 'boolean') { - manager.closeOnClickOutside = closeOnClickOutside; - } - return manager; -}; - const removeDialogManager = (id: string) => { if (!getDialogManager(id)) return; dialogManagersRegistry.partialNext({ [id]: undefined }); @@ -67,23 +52,50 @@ export const DialogManagerProvider = ({ closeOnClickOutside, id, }: DialogManagerProviderProps) => { - const [dialogManager, setDialogManager] = useState(() => { - if (id) return getDialogManager(id) ?? null; + const [dialogManager, setDialogManager] = useState(() => { + if (id) { + const manager = + getDialogManager(id) ?? + pendingDialogManagersById[id] ?? + new DialogManager({ closeOnClickOutside, id }); + + pendingDialogManagersById[id] = manager; + return manager; + } return new DialogManager({ closeOnClickOutside }); // will not be included in the registry }); useEffect(() => { if (!id) return; - setDialogManager(getOrCreateDialogManager({ closeOnClickOutside, id })); + const manager = + getDialogManager(id) ?? + pendingDialogManagersById[id] ?? + new DialogManager({ closeOnClickOutside, id }); + + if (typeof closeOnClickOutside === 'boolean') { + manager.closeOnClickOutside = closeOnClickOutside; + } + + if (!getDialogManager(id)) { + dialogManagersRegistry.partialNext({ [id]: manager }); + } + delete pendingDialogManagersById[id]; + + setDialogManager((prev) => (prev === manager ? prev : manager)); + dialogManagerMountCountsById[id] = (dialogManagerMountCountsById[id] ?? 0) + 1; + return () => { + const nextMountCount = (dialogManagerMountCountsById[id] ?? 1) - 1; + if (nextMountCount > 0) { + dialogManagerMountCountsById[id] = nextMountCount; + return; + } + + delete dialogManagerMountCountsById[id]; removeDialogManager(id); - setDialogManager(null); }; }, [closeOnClickOutside, id]); - // temporarily do not render until a new dialog manager is created - if (!dialogManager) return null; - return ( {children} @@ -172,7 +184,7 @@ export const useDialogManager = ({ if (!managerInPrevState || managerInNewState?.id !== managerInPrevState.id) { setDialogManagerContext((prevState) => { - if (prevState?.dialogManager.id === managerInNewState?.id) return prevState; + if (prevState?.dialogManager === managerInNewState) return prevState; // fixme: need to handle the possibility that the dialogManager is undefined return { dialogManager: From c86caafef68e0276f9836df2f747072595dc7b5f Mon Sep 17 00:00:00 2001 From: martincupela Date: Thu, 9 Apr 2026 15:41:51 +0200 Subject: [PATCH 2/2] fix(ssr): provide getServerSnapshot for external-store hooks and stabilize dialog manager lifecycle --- src/components/ChannelList/hooks/useSelectedChannelState.ts | 2 +- src/store/hooks/useStateStore.ts | 6 +++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/components/ChannelList/hooks/useSelectedChannelState.ts b/src/components/ChannelList/hooks/useSelectedChannelState.ts index 53ff0c104..ea4c2343f 100644 --- a/src/components/ChannelList/hooks/useSelectedChannelState.ts +++ b/src/components/ChannelList/hooks/useSelectedChannelState.ts @@ -45,5 +45,5 @@ export function useSelectedChannelState({ return selector(channel); }, [channel, selector]); - return useSyncExternalStore(subscribe, getSnapshot); + return useSyncExternalStore(subscribe, getSnapshot, getSnapshot); } diff --git a/src/store/hooks/useStateStore.ts b/src/store/hooks/useStateStore.ts index cc7a326d4..7844a03e7 100644 --- a/src/store/hooks/useStateStore.ts +++ b/src/store/hooks/useStateStore.ts @@ -59,7 +59,11 @@ export function useStateStore< }; }, [store, selector]); - const state = useSyncExternalStore(wrappedSubscription, wrappedSnapshot); + const state = useSyncExternalStore( + wrappedSubscription, + wrappedSnapshot, + wrappedSnapshot, + ); return state; }