From cdfca50352caa908f97ace55f63956ef7e27a0e9 Mon Sep 17 00:00:00 2001 From: Kamruzzaman Date: Mon, 1 Jun 2026 14:24:08 +0600 Subject: [PATCH 1/3] feat(theme): enhance ThemeProvider with global theme management and fallback context - Introduced global theme registry to manage active themes across disconnected React roots, particularly for WordPress environments. - Added functions to get, set, and subscribe to global theme changes. - Updated ThemeProvider to track the active theme globally. - Enhanced useThemeOptional hook to provide a fallback to the most recently registered global theme or default light theme if no context is available. - Refactored defaultCssVariables to be exported for use in portals outside ThemeProvider. --- src/components/ui/alert-dialog.tsx | 46 +++++++---- src/components/ui/combobox.tsx | 59 ++++++++------ src/components/ui/dialog.tsx | 71 +++++++++-------- src/components/ui/dropdown-menu.tsx | 55 +++++++------ src/components/ui/modal.tsx | 12 ++- src/components/ui/popover.tsx | 55 +++++++------ src/components/ui/select.tsx | 65 +++++++++------- src/components/ui/sheet.tsx | 67 ++++++++-------- src/components/ui/sonner.tsx | 11 ++- src/components/ui/tooltip.tsx | 59 ++++++++------ src/providers/theme-provider.tsx | 115 ++++++++++++++++++---------- 11 files changed, 366 insertions(+), 249 deletions(-) diff --git a/src/components/ui/alert-dialog.tsx b/src/components/ui/alert-dialog.tsx index e232f0b..c525cd6 100644 --- a/src/components/ui/alert-dialog.tsx +++ b/src/components/ui/alert-dialog.tsx @@ -1,9 +1,11 @@ +"use client"; + import { AlertDialog as AlertDialogPrimitive } from "@base-ui/react/alert-dialog"; import * as React from "react"; import { cn } from "@/lib/utils"; import { Button } from "./button"; -import { useThemeOptional, defaultCssVariables } from "@/providers"; +import { useThemeOptional } from "@/providers"; function AlertDialog({ ...props }: AlertDialogPrimitive.Root.Props) { return ; @@ -15,9 +17,14 @@ function AlertDialogTrigger({ ...props }: AlertDialogPrimitive.Trigger.Props) { ); } -function AlertDialogPortal({ ...props }: AlertDialogPrimitive.Portal.Props) { +function AlertDialogPortal({ + children, + ...props +}: AlertDialogPrimitive.Portal.Props) { return ( - + + {children} + ); } @@ -40,25 +47,30 @@ function AlertDialogOverlay({ function AlertDialogContent({ className, size = "default", + children, ...props }: AlertDialogPrimitive.Popup.Props & { size?: "default" | "sm"; }) { - const theme = useThemeOptional(); - const mode = theme?.mode ?? 'light'; - const cssVariables = theme?.cssVariables ?? defaultCssVariables; + const { resolvedMode, cssVariables, className: themeClassName } = useThemeOptional(); + return ( - - - + +
+ + + + {children} + +
); } diff --git a/src/components/ui/combobox.tsx b/src/components/ui/combobox.tsx index 58ab06f..a99867c 100644 --- a/src/components/ui/combobox.tsx +++ b/src/components/ui/combobox.tsx @@ -1,3 +1,5 @@ +"use client"; + import { Combobox as ComboboxPrimitive } from "@base-ui/react"; import { CheckIcon, ChevronDownIcon, XIcon } from "lucide-react"; import * as React from "react"; @@ -103,6 +105,17 @@ const ComboboxInput = React.forwardRef< ); ComboboxInput.displayName = "ComboboxInput"; +function ComboboxPortal({ + children, + ...props +}: ComboboxPrimitive.Portal.Props) { + return ( + + {children} + + ); +} + function ComboboxContent({ className, side = "bottom", @@ -116,30 +129,30 @@ function ComboboxContent({ ComboboxPrimitive.Positioner.Props, "side" | "align" | "sideOffset" | "alignOffset" | "anchor" >) { - const theme = useThemeOptional(); - const mode = theme?.mode ?? 'light'; - const cssVariables = theme?.cssVariables ?? defaultCssVariables; + const { resolvedMode, cssVariables, className: themeClassName } = useThemeOptional(); return ( - - - - - + +
+ + + +
+
); } diff --git a/src/components/ui/dialog.tsx b/src/components/ui/dialog.tsx index 88a2dc0..514ccf7 100644 --- a/src/components/ui/dialog.tsx +++ b/src/components/ui/dialog.tsx @@ -20,8 +20,15 @@ function DialogClose({ ...props }: DialogPrimitive.Close.Props) { return } -function DialogPortal({ ...props }: DialogPrimitive.Portal.Props) { - return +function DialogPortal({ + children, + ...props +}: DialogPrimitive.Portal.Props) { + return ( + + {children} + + ) } function DialogOverlay({ @@ -48,37 +55,37 @@ function DialogContent({ }: DialogPrimitive.Popup.Props & { showCloseButton?: boolean }) { - const theme = useThemeOptional() - const mode = theme?.mode ?? "light" - const cssVariables = theme?.cssVariables ?? defaultCssVariables + const { resolvedMode, cssVariables, className: themeClassName } = useThemeOptional() return ( - - - - {children} - {showCloseButton && ( - - } - > - - Close - - )} - + +
+ + + {children} + {showCloseButton && ( + + } + > + + Close + + )} + +
) } diff --git a/src/components/ui/dropdown-menu.tsx b/src/components/ui/dropdown-menu.tsx index ec6214e..72f0044 100644 --- a/src/components/ui/dropdown-menu.tsx +++ b/src/components/ui/dropdown-menu.tsx @@ -1,3 +1,5 @@ +"use client"; + import { Menu as MenuPrimitive } from "@base-ui/react/menu"; import { CheckIcon, ChevronRightIcon } from "lucide-react"; import * as React from "react"; @@ -9,8 +11,15 @@ function DropdownMenu({ ...props }: MenuPrimitive.Root.Props) { return ; } -function DropdownMenuPortal({ ...props }: MenuPrimitive.Portal.Props) { - return ; +function DropdownMenuPortal({ + children, + ...props +}: MenuPrimitive.Portal.Props) { + return ( + + {children} + + ); } function DropdownMenuTrigger({ ...props }: MenuPrimitive.Trigger.Props) { @@ -29,28 +38,28 @@ function DropdownMenuContent({ MenuPrimitive.Positioner.Props, "align" | "alignOffset" | "side" | "sideOffset" >) { - const theme = useThemeOptional(); - const mode = theme?.mode ?? 'light'; - const cssVariables = theme?.cssVariables ?? defaultCssVariables; + const { resolvedMode, cssVariables, className: themeClassName } = useThemeOptional(); return ( - - - - - + +
+ + + +
+
); } diff --git a/src/components/ui/modal.tsx b/src/components/ui/modal.tsx index a957f46..9687fa1 100644 --- a/src/components/ui/modal.tsx +++ b/src/components/ui/modal.tsx @@ -296,9 +296,7 @@ export function Modal({ className, size = "default", }: ModalProps) { - const theme = useThemeOptional(); - const mode = theme?.mode ?? 'light'; - const cssVariables = theme?.cssVariables ?? defaultCssVariables; + const { resolvedMode, cssVariables, className: themeClassName } = useThemeOptional(); const [portalContainer, setPortalContainer] = useState( null, ); @@ -314,7 +312,7 @@ export function Modal({ const container = document.createElement("div"); container.setAttribute("data-pui-modal-root", "true"); - container.className = cn("pui-root", mode, theme?.className); + container.className = cn("pui-root", resolvedMode, themeClassName); Object.assign(container.style, cssVariables); document.body.appendChild(container); @@ -331,15 +329,15 @@ export function Modal({ previousActiveElement.current.focus(); } }; - }, [open, mode, cssVariables, theme?.className]); + }, [open, resolvedMode, cssVariables, themeClassName]); // Keep portal container class and CSS variables in sync with theme useEffect(() => { if (portalContainer) { - portalContainer.className = cn("pui-root", mode, theme?.className); + portalContainer.className = cn("pui-root", resolvedMode, themeClassName); Object.assign(portalContainer.style, cssVariables); } - }, [mode, cssVariables, portalContainer, theme?.className]); + }, [resolvedMode, cssVariables, portalContainer, themeClassName]); // Handle escape key useEffect(() => { diff --git a/src/components/ui/popover.tsx b/src/components/ui/popover.tsx index 8904be5..671f212 100644 --- a/src/components/ui/popover.tsx +++ b/src/components/ui/popover.tsx @@ -1,3 +1,5 @@ +"use client"; + import * as React from "react" import { Popover as PopoverPrimitive } from "@base-ui/react/popover" @@ -12,8 +14,15 @@ function PopoverTrigger({ ...props }: PopoverPrimitive.Trigger.Props) { return ; } -function PopoverPortal({ ...props }: PopoverPrimitive.Portal.Props) { - return ; +function PopoverPortal({ + children, + ...props +}: PopoverPrimitive.Portal.Props) { + return ( + + {children} + + ); } function PopoverContent({ @@ -28,28 +37,28 @@ function PopoverContent({ PopoverPrimitive.Positioner.Props, "align" | "alignOffset" | "side" | "sideOffset" >) { - const theme = useThemeOptional(); - const mode = theme?.mode ?? 'light'; - const cssVariables = theme?.cssVariables ?? defaultCssVariables; + const { resolvedMode, cssVariables, className: themeClassName } = useThemeOptional(); return ( - - - - - + +
+ + + +
+
) } diff --git a/src/components/ui/select.tsx b/src/components/ui/select.tsx index 046e9ae..3757c70 100644 --- a/src/components/ui/select.tsx +++ b/src/components/ui/select.tsx @@ -1,3 +1,5 @@ +"use client"; + import { Select as SelectPrimitive } from "@base-ui/react/select"; import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"; import * as React from "react"; @@ -7,6 +9,17 @@ import { useThemeOptional, defaultCssVariables } from "@/providers"; const Select = SelectPrimitive.Root; +function SelectPortal({ + children, + ...props +}: SelectPrimitive.Portal.Props) { + return ( + + {children} + + ); +} + function SelectGroup({ className, ...props }: SelectPrimitive.Group.Props) { return ( ) { - const theme = useThemeOptional(); - const mode = theme?.mode ?? 'light'; - const cssVariables = theme?.cssVariables ?? defaultCssVariables; + const { resolvedMode, cssVariables, className: themeClassName } = useThemeOptional(); return ( - - - +
+ - - {children} - - - - + + + {children} + + + +
+ ); } diff --git a/src/components/ui/sheet.tsx b/src/components/ui/sheet.tsx index 07ec8c3..98c5719 100644 --- a/src/components/ui/sheet.tsx +++ b/src/components/ui/sheet.tsx @@ -20,8 +20,15 @@ function SheetClose({ ...props }: SheetPrimitive.Close.Props) { return } -function SheetPortal({ ...props }: SheetPrimitive.Portal.Props) { - return +function SheetPortal({ + children, + ...props +}: SheetPrimitive.Portal.Props) { + return ( + + {children} + + ); } function SheetOverlay({ className, ...props }: SheetPrimitive.Backdrop.Props) { @@ -44,37 +51,37 @@ function SheetContent({ side?: "top" | "right" | "bottom" | "left" showCloseButton?: boolean }) { - const theme = useThemeOptional(); - const mode = theme?.mode ?? "light"; - const cssVariables = theme?.cssVariables ?? defaultCssVariables; + const { resolvedMode, cssVariables, className: themeClassName } = useThemeOptional(); return ( - - - - {children} - {showCloseButton && ( - +
+ + + {children} + {showCloseButton && ( + + } + > + - } - > - - Close - - )} - + Close + + )} + +
) } diff --git a/src/components/ui/sonner.tsx b/src/components/ui/sonner.tsx index 6bf4a8d..6388381 100644 --- a/src/components/ui/sonner.tsx +++ b/src/components/ui/sonner.tsx @@ -1,3 +1,5 @@ +"use client"; + import { CircleCheckIcon, InfoIcon, @@ -6,16 +8,16 @@ import { TriangleAlertIcon, } from "lucide-react"; import { useThemeOptional } from "@/providers"; +import { cn } from "@/lib/utils"; import { Toaster as Sonner, type ToasterProps } from "sonner"; const Toaster = ({ ...props }: ToasterProps) => { - const theme = useThemeOptional(); - const mode = theme?.mode ?? "system"; + const { resolvedMode, cssVariables, className: themeClassName } = useThemeOptional(); return ( , info: , @@ -25,6 +27,7 @@ const Toaster = ({ ...props }: ToasterProps) => { }} style={ { + ...cssVariables, "--normal-bg": "var(--popover)", "--normal-text": "var(--popover-foreground)", "--normal-border": "var(--border)", diff --git a/src/components/ui/tooltip.tsx b/src/components/ui/tooltip.tsx index 0d45811..53949ef 100644 --- a/src/components/ui/tooltip.tsx +++ b/src/components/ui/tooltip.tsx @@ -1,3 +1,5 @@ +"use client"; + import { Tooltip as TooltipPrimitive } from "@base-ui/react/tooltip" import { cn } from "@/lib/utils" @@ -28,6 +30,17 @@ function TooltipTrigger({ ...props }: TooltipPrimitive.Trigger.Props) { return } +function TooltipPortal({ + children, + ...props +}: TooltipPrimitive.Portal.Props) { + return ( + + {children} + + ); +} + interface TooltipContentProps extends TooltipPrimitive.Popup.Props { portalClassName?: string; positionerClassName?: string; @@ -50,31 +63,31 @@ function TooltipContent({ TooltipPrimitive.Positioner.Props, "align" | "alignOffset" | "side" | "sideOffset" >) { - const theme = useThemeOptional(); - const mode = theme?.mode ?? 'light'; - const cssVariables = theme?.cssVariables ?? defaultCssVariables; + const { resolvedMode, cssVariables, className: themeClassName } = useThemeOptional(); return ( - - - +
+ - {children} - - - - + + {children} + + + +
+ ) } diff --git a/src/providers/theme-provider.tsx b/src/providers/theme-provider.tsx index 1ddc566..e127062 100644 --- a/src/providers/theme-provider.tsx +++ b/src/providers/theme-provider.tsx @@ -386,26 +386,56 @@ const defaultDarkTokens: ThemeTokens = { "shadow-2xl": "0 1px 3px 0px hsl(0 0% 0% / 0.25)", }; +/** + * Global registry to store the active theme context for use across disconnected React roots. + * This is particularly useful in WordPress environments where multiple bundles or roots may exist. + * We use window to ensure the state is shared even if the library is bundled multiple times. + */ +const GLOBAL_THEME_KEY = "__PUI_THEME__"; + +function getGlobalTheme(): ThemeContextValue | null { + if (typeof window === "undefined") return null; + return (window as any)[GLOBAL_THEME_KEY] || null; +} + +const themeListeners = new Set<(context: ThemeContextValue) => void>(); + +function subscribeToGlobalTheme(listener: (context: ThemeContextValue) => void) { + themeListeners.add(listener); + return () => { + themeListeners.delete(listener); + }; +} + +function setGlobalTheme(context: ThemeContextValue) { + if (typeof window !== "undefined") { + (window as any)[GLOBAL_THEME_KEY] = context; + } + themeListeners.forEach((listener) => listener(context)); +} + +/** + * Pre-computed default light CSS variables for use in portals outside ThemeProvider. + */ +export const defaultCssVariables: Record = + tokensToCssVariables(defaultLightTokens); + +/** + * Default context value used when components are rendered outside a ThemeProvider + * and no global theme has been registered yet. + */ +const DEFAULT_CONTEXT: ThemeContextValue = { + pluginId: "pui-default", + mode: "light", + setMode: () => {}, + tokens: defaultLightTokens, + resolvedMode: "light", + cssVariables: defaultCssVariables, + className: "", +}; + /** * ThemeProvider component for scoped theming across plugins - * - * @example - * ```tsx - * import { ThemeProvider } from '@wedevs/plugin-ui'; - * - * const dokanTokens = { - * primary: 'oklch(0.5410 0.2120 265.7540)', // Purple - * radius: '0.375rem', - * }; - * - * function DokanApp() { - * return ( - * - * - * - * ); - * } - * ``` */ export function ThemeProvider({ pluginId, @@ -503,13 +533,18 @@ export function ThemeProvider({ [pluginId, mode, setMode, mergedTokens, resolvedMode, cssVariables, className], ); + // Track active theme globally + useEffect(() => { + setGlobalTheme(contextValue); + }, [contextValue]); + return (
@@ -521,19 +556,6 @@ export function ThemeProvider({ /** * Hook to access theme context - * - * @example - * ```tsx - * function MyComponent() { - * const { mode, setMode, tokens, pluginId } = useTheme(); - * - * return ( - * - * ); - * } - * ``` */ export function useTheme(): ThemeContextValue { const context = useContext(ThemeContext); @@ -546,16 +568,27 @@ export function useTheme(): ThemeContextValue { } /** - * Hook to check if component is within a ThemeProvider + * Hook to check if component is within a ThemeProvider. + * If not, falls back to the most recently registered global theme, + * or the default light theme if none exists. */ -export function useThemeOptional(): ThemeContextValue | null { - return useContext(ThemeContext); -} +export function useThemeOptional(): ThemeContextValue { + const context = useContext(ThemeContext); + const [fallbackContext, setFallbackContext] = useState( + getGlobalTheme() ?? DEFAULT_CONTEXT, + ); -/** - * Pre-computed default light CSS variables for use in portals outside ThemeProvider. - */ -export const defaultCssVariables: Record = - tokensToCssVariables(defaultLightTokens); + useEffect(() => { + // If we have a local context, we don't need the global fallback + if (context) return; + + // Subscribe to global theme changes + return subscribeToGlobalTheme((newContext) => { + setFallbackContext(newContext); + }); + }, [context]); + + return context ?? fallbackContext; +} export default ThemeProvider; From ed86a40d311b022e09618e79e160d7ee7e801539 Mon Sep 17 00:00:00 2001 From: Kamruzzaman Date: Thu, 4 Jun 2026 09:10:19 +0600 Subject: [PATCH 2/3] chore(theme): drop unused defaultCssVariables imports, type window global --- src/components/ui/combobox.tsx | 2 +- src/components/ui/dialog.tsx | 2 +- src/components/ui/dropdown-menu.tsx | 2 +- src/components/ui/modal.tsx | 2 +- src/components/ui/popover.tsx | 2 +- src/components/ui/select.tsx | 2 +- src/components/ui/sheet.tsx | 2 +- src/components/ui/tooltip.tsx | 2 +- src/providers/theme-provider.tsx | 8 ++++++-- 9 files changed, 14 insertions(+), 10 deletions(-) diff --git a/src/components/ui/combobox.tsx b/src/components/ui/combobox.tsx index a99867c..5d93f7a 100644 --- a/src/components/ui/combobox.tsx +++ b/src/components/ui/combobox.tsx @@ -7,7 +7,7 @@ import * as React from "react"; import { cn } from "@/lib/utils"; import { Button } from "./button"; import { Input } from "./input"; -import { useThemeOptional, defaultCssVariables } from "@/providers"; +import { useThemeOptional } from "@/providers"; const Combobox = ComboboxPrimitive.Root; diff --git a/src/components/ui/dialog.tsx b/src/components/ui/dialog.tsx index 514ccf7..bf4d15a 100644 --- a/src/components/ui/dialog.tsx +++ b/src/components/ui/dialog.tsx @@ -6,7 +6,7 @@ import { Dialog as DialogPrimitive } from "@base-ui/react/dialog" import { cn } from "@/lib/utils" import { Button } from "@/components/ui/button" import { XIcon } from "lucide-react" -import { useThemeOptional, defaultCssVariables } from "@/providers" +import { useThemeOptional } from "@/providers" function Dialog({ ...props }: DialogPrimitive.Root.Props) { return diff --git a/src/components/ui/dropdown-menu.tsx b/src/components/ui/dropdown-menu.tsx index 72f0044..b1f587f 100644 --- a/src/components/ui/dropdown-menu.tsx +++ b/src/components/ui/dropdown-menu.tsx @@ -5,7 +5,7 @@ import { CheckIcon, ChevronRightIcon } from "lucide-react"; import * as React from "react"; import { cn } from "@/lib/utils"; -import { useThemeOptional, defaultCssVariables } from "@/providers"; +import { useThemeOptional } from "@/providers"; function DropdownMenu({ ...props }: MenuPrimitive.Root.Props) { return ; diff --git a/src/components/ui/modal.tsx b/src/components/ui/modal.tsx index 9687fa1..7d18cf1 100644 --- a/src/components/ui/modal.tsx +++ b/src/components/ui/modal.tsx @@ -8,7 +8,7 @@ import { } from "react"; import { createPortal } from "react-dom"; import { cn } from "@/lib/utils"; -import { useThemeOptional, defaultCssVariables } from "@/providers"; +import { useThemeOptional } from "@/providers"; /* ============================================ Modal Overlay diff --git a/src/components/ui/popover.tsx b/src/components/ui/popover.tsx index 671f212..a30cd4a 100644 --- a/src/components/ui/popover.tsx +++ b/src/components/ui/popover.tsx @@ -4,7 +4,7 @@ import * as React from "react" import { Popover as PopoverPrimitive } from "@base-ui/react/popover" import { cn } from "@/lib/utils" -import { useThemeOptional, defaultCssVariables } from "@/providers"; +import { useThemeOptional } from "@/providers"; function Popover({ ...props }: PopoverPrimitive.Root.Props) { return ; diff --git a/src/components/ui/select.tsx b/src/components/ui/select.tsx index 3757c70..91e0231 100644 --- a/src/components/ui/select.tsx +++ b/src/components/ui/select.tsx @@ -5,7 +5,7 @@ import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"; import * as React from "react"; import { cn } from "@/lib/utils"; -import { useThemeOptional, defaultCssVariables } from "@/providers"; +import { useThemeOptional } from "@/providers"; const Select = SelectPrimitive.Root; diff --git a/src/components/ui/sheet.tsx b/src/components/ui/sheet.tsx index 98c5719..a5cf067 100644 --- a/src/components/ui/sheet.tsx +++ b/src/components/ui/sheet.tsx @@ -5,7 +5,7 @@ import { Dialog as SheetPrimitive } from "@base-ui/react/dialog" import { cn } from "@/lib/utils" import { Button } from "@/components/ui/button" -import { useThemeOptional, defaultCssVariables } from "@/providers" +import { useThemeOptional } from "@/providers" import { XIcon } from "lucide-react" function Sheet({ ...props }: SheetPrimitive.Root.Props) { diff --git a/src/components/ui/tooltip.tsx b/src/components/ui/tooltip.tsx index 53949ef..51294ad 100644 --- a/src/components/ui/tooltip.tsx +++ b/src/components/ui/tooltip.tsx @@ -3,7 +3,7 @@ import { Tooltip as TooltipPrimitive } from "@base-ui/react/tooltip" import { cn } from "@/lib/utils" -import { useThemeOptional, defaultCssVariables } from "@/providers"; +import { useThemeOptional } from "@/providers"; function TooltipProvider({ delay = 0, diff --git a/src/providers/theme-provider.tsx b/src/providers/theme-provider.tsx index e127062..2b1ff5c 100644 --- a/src/providers/theme-provider.tsx +++ b/src/providers/theme-provider.tsx @@ -393,9 +393,13 @@ const defaultDarkTokens: ThemeTokens = { */ const GLOBAL_THEME_KEY = "__PUI_THEME__"; +type ThemeWindow = Window & { + [GLOBAL_THEME_KEY]?: ThemeContextValue | null; +}; + function getGlobalTheme(): ThemeContextValue | null { if (typeof window === "undefined") return null; - return (window as any)[GLOBAL_THEME_KEY] || null; + return (window as ThemeWindow)[GLOBAL_THEME_KEY] || null; } const themeListeners = new Set<(context: ThemeContextValue) => void>(); @@ -409,7 +413,7 @@ function subscribeToGlobalTheme(listener: (context: ThemeContextValue) => void) function setGlobalTheme(context: ThemeContextValue) { if (typeof window !== "undefined") { - (window as any)[GLOBAL_THEME_KEY] = context; + (window as ThemeWindow)[GLOBAL_THEME_KEY] = context; } themeListeners.forEach((listener) => listener(context)); } From 20cee6f7b18117c8c9f773ac1ce9dbff1cefc69d Mon Sep 17 00:00:00 2001 From: Kamruzzaman Date: Thu, 4 Jun 2026 10:28:36 +0600 Subject: [PATCH 3/3] =?UTF-8?q?fix(theme):=20window-backed=20theme=20regis?= =?UTF-8?q?try=20=E2=80=94=20multi-plugin=20safe,=20self-healing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the single-slot global fallback with a per-instance registry keyed on window so disconnected/orphan roots resolve a deterministic theme: - most-recently mounted/updated provider wins; unregister on unmount so the fallback self-heals instead of going stale - entries + listeners stored on window → real cross-bundle sharing - shape guard tolerates an older version's bare-context value (no crash) --- src/providers/theme-provider.tsx | 116 +++++++++++++++++++++++++------ 1 file changed, 93 insertions(+), 23 deletions(-) diff --git a/src/providers/theme-provider.tsx b/src/providers/theme-provider.tsx index 2b1ff5c..ead1853 100644 --- a/src/providers/theme-provider.tsx +++ b/src/providers/theme-provider.tsx @@ -387,35 +387,97 @@ const defaultDarkTokens: ThemeTokens = { }; /** - * Global registry to store the active theme context for use across disconnected React roots. - * This is particularly useful in WordPress environments where multiple bundles or roots may exist. - * We use window to ensure the state is shared even if the library is bundled multiple times. + * Global registry of every mounted ThemeProvider, used as a fallback theme for + * components rendered in a disconnected React root (no ThemeProvider ancestor) — + * common in WordPress where multiple bundles/roots coexist. + * + * Notes on multi-plugin / multi-instance safety: + * - Components *inside* a ThemeProvider (including portaled ones) already get the + * correct theme via React context — context flows through `createPortal`. This + * registry is only consulted when there is no provider in the React tree. + * - The registry (entries + listeners) lives on `window`, so it is shared even + * when the library is bundled multiple times by different plugins. + * - Entries are keyed per provider instance and ordered, so the "active" fallback + * is the most-recently mounted/updated provider, and it self-heals when that + * provider unmounts (falls back to the next most recent, or the default light + * theme when none remain). This avoids the stale / last-writer-wins problems of + * a single shared slot when two plugins mount providers on the same page. */ const GLOBAL_THEME_KEY = "__PUI_THEME__"; +interface ThemeRegistry { + entries: Map; + listeners: Set<(context: ThemeContextValue | null) => void>; +} + type ThemeWindow = Window & { - [GLOBAL_THEME_KEY]?: ThemeContextValue | null; + [GLOBAL_THEME_KEY]?: ThemeRegistry; }; +function isThemeRegistry(value: unknown): value is ThemeRegistry { + return ( + !!value && + (value as ThemeRegistry).entries instanceof Map && + (value as ThemeRegistry).listeners instanceof Set + ); +} + +function getRegistry(): ThemeRegistry { + // On the server there is no shared window; return an ephemeral registry. + // (Effects that register/subscribe never run during SSR, so this is only + // ever read as "empty" via getGlobalTheme's lazy initializer.) + if (typeof window === "undefined") { + return { entries: new Map(), listeners: new Set() }; + } + const w = window as ThemeWindow; + // Guard the shape: an older library version (pre-registry) stored a bare + // ThemeContextValue at this key. If another plugin on the page ships that + // version, don't crash trying to read `.entries` off it — re-initialize. + if (!isThemeRegistry(w[GLOBAL_THEME_KEY])) { + w[GLOBAL_THEME_KEY] = { entries: new Map(), listeners: new Set() }; + } + return w[GLOBAL_THEME_KEY] as ThemeRegistry; +} + +/** The most-recently registered (mounted/updated) provider's context, or null. */ function getGlobalTheme(): ThemeContextValue | null { - if (typeof window === "undefined") return null; - return (window as ThemeWindow)[GLOBAL_THEME_KEY] || null; + const { entries } = getRegistry(); + let latest: ThemeContextValue | null = null; + for (const value of entries.values()) latest = value; + return latest; } -const themeListeners = new Set<(context: ThemeContextValue) => void>(); +function notifyGlobalThemeListeners() { + const { listeners } = getRegistry(); + const current = getGlobalTheme(); + listeners.forEach((listener) => listener(current)); +} -function subscribeToGlobalTheme(listener: (context: ThemeContextValue) => void) { - themeListeners.add(listener); - return () => { - themeListeners.delete(listener); - }; +/** Register/refresh a provider instance and make it the active fallback. */ +function registerGlobalTheme(id: symbol, context: ThemeContextValue) { + const { entries } = getRegistry(); + // Re-insert so the latest update moves to the end (becomes "current"). + entries.delete(id); + entries.set(id, context); + notifyGlobalThemeListeners(); } -function setGlobalTheme(context: ThemeContextValue) { - if (typeof window !== "undefined") { - (window as ThemeWindow)[GLOBAL_THEME_KEY] = context; +/** Remove a provider instance (on unmount); active fallback falls back. */ +function unregisterGlobalTheme(id: symbol) { + const { entries } = getRegistry(); + if (entries.delete(id)) { + notifyGlobalThemeListeners(); } - themeListeners.forEach((listener) => listener(context)); +} + +function subscribeToGlobalTheme( + listener: (context: ThemeContextValue | null) => void, +) { + const { listeners } = getRegistry(); + listeners.add(listener); + return () => { + listeners.delete(listener); + }; } /** @@ -537,10 +599,16 @@ export function ThemeProvider({ [pluginId, mode, setMode, mergedTokens, resolvedMode, cssVariables, className], ); - // Track active theme globally + // Stable per-instance id so multiple providers (same or different plugin) + // each get their own slot in the global registry. + const [instanceId] = useState(() => Symbol(pluginId)); + + // Publish this provider to the global registry so disconnected roots can fall + // back to it; unregister on unmount so the fallback never goes stale. useEffect(() => { - setGlobalTheme(contextValue); - }, [contextValue]); + registerGlobalTheme(instanceId, contextValue); + return () => unregisterGlobalTheme(instanceId); + }, [instanceId, contextValue]); return ( @@ -579,16 +647,18 @@ export function useTheme(): ThemeContextValue { export function useThemeOptional(): ThemeContextValue { const context = useContext(ThemeContext); const [fallbackContext, setFallbackContext] = useState( - getGlobalTheme() ?? DEFAULT_CONTEXT, + () => getGlobalTheme() ?? DEFAULT_CONTEXT, ); useEffect(() => { - // If we have a local context, we don't need the global fallback + // If we have a local context, we don't need the global fallback. if (context) return; - // Subscribe to global theme changes + // Sync immediately in case a provider registered between render and effect, + // then subscribe to subsequent global theme changes. + setFallbackContext(getGlobalTheme() ?? DEFAULT_CONTEXT); return subscribeToGlobalTheme((newContext) => { - setFallbackContext(newContext); + setFallbackContext(newContext ?? DEFAULT_CONTEXT); }); }, [context]);