diff --git a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/deployments/[deploymentId]/(deployment-progress)/deployment-step.tsx b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/deployments/[deploymentId]/(deployment-progress)/deployment-step.tsx index e9f6f309f1..2ee950a307 100644 --- a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/deployments/[deploymentId]/(deployment-progress)/deployment-step.tsx +++ b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/deployments/[deploymentId]/(deployment-progress)/deployment-step.tsx @@ -4,6 +4,7 @@ import { cn } from "@/lib/utils"; import { formatCompoundDuration } from "@/lib/utils/metric-formatters"; import { Check, CircleHalfDottedClock, TriangleWarning2 } from "@unkey/icons"; import { Badge, Loading, SettingCard } from "@unkey/ui"; +import { GlowIcon } from "../../../../components/glow-icon"; type DeploymentStepProps = { icon: React.ReactNode; @@ -32,28 +33,13 @@ export function DeploymentStep({ -
-
- {icon} -
-
+ } title={
diff --git a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/deployments/components/table/components/actions/redeploy-dialog.tsx b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/deployments/components/table/components/actions/redeploy-dialog.tsx index 7cea1d65c2..4bf02e58e8 100644 --- a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/deployments/components/table/components/actions/redeploy-dialog.tsx +++ b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/deployments/components/table/components/actions/redeploy-dialog.tsx @@ -23,6 +23,7 @@ export const RedeployDialog = ({ isOpen, onClose, selectedDeployment }: Redeploy const redeploy = trpc.deploy.deployment.redeploy.useMutation({ onSuccess: async (data) => { await queryClient.invalidateQueries({ queryKey: ["deployments", projectId] }); + onClose(); router.push( `/${workspace.slug}/projects/${selectedDeployment.projectId}/deployments/${data.deploymentId}`, ); diff --git a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/settings/environment-provider.tsx b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/settings/environment-provider.tsx index f9dcc3b9d7..9d1046a09a 100644 --- a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/settings/environment-provider.tsx +++ b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/settings/environment-provider.tsx @@ -10,6 +10,7 @@ import { useProjectData } from "../data-provider"; type EnvironmentContextType = { settings: EnvironmentSettings; variant: "settings" | "onboarding"; + isSaving: boolean; }; export const EnvironmentContext = createContext(null); @@ -44,7 +45,7 @@ export const EnvironmentSettingsProvider = ({ children }: PropsWithChildren) => } return ( - + {children} ); diff --git a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/settings/page.tsx b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/settings/page.tsx index 4ad4f70021..c0854ac190 100644 --- a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/settings/page.tsx +++ b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/settings/page.tsx @@ -2,6 +2,7 @@ import { DeploymentSettings } from "./deployment-settings"; import { EnvironmentSettingsProvider } from "./environment-provider"; +import { PendingRedeployBanner } from "./pending-redeploy-banner"; export default function SettingsPage() { return ( @@ -15,6 +16,7 @@ export default function SettingsPage() {
+ ); } diff --git a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/settings/pending-redeploy-banner.tsx b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/settings/pending-redeploy-banner.tsx new file mode 100644 index 0000000000..dfb7399766 --- /dev/null +++ b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/settings/pending-redeploy-banner.tsx @@ -0,0 +1,85 @@ +"use client"; + +import { useWorkspaceNavigation } from "@/hooks/use-workspace-navigation"; +import { queryClient } from "@/lib/collections/client"; +import { useSettingsHasSaved } from "@/lib/collections/deploy/environment-settings"; +import { trpc } from "@/lib/trpc/client"; +import { Hammer2, XMark } from "@unkey/icons"; +import { Button, toast } from "@unkey/ui"; +import { useRouter } from "next/navigation"; +import { useState } from "react"; +import { GlowIcon } from "../../components/glow-icon"; +import { useProjectData } from "../data-provider"; + +export function PendingRedeployBanner() { + const [dismissed, setDismissed] = useState(false); + const { project, deployments, projectId } = useProjectData(); + const router = useRouter(); + const workspace = useWorkspaceNavigation(); + const hasSaved = useSettingsHasSaved(); + const visible = hasSaved && !dismissed; + + const currentDeployment = project?.currentDeploymentId + ? deployments.find((d) => d.id === project.currentDeploymentId) + : undefined; + + const redeploy = trpc.deploy.deployment.redeploy.useMutation({ + onSuccess: async (data) => { + if (!currentDeployment) { + return; + } + await queryClient.invalidateQueries({ queryKey: ["deployments", projectId] }); + router.push( + `/${workspace.slug}/projects/${currentDeployment.projectId}/deployments/${data.deploymentId}`, + ); + }, + onError: (error) => { + toast.error("Redeploy failed", { description: error.message }); + }, + }); + + if (!visible || !currentDeployment) { + return null; + } + + return ( +
+
+ + + } + className="w-9 h-9 shrink-0" + /> + +
+
+ Settings changed + + Redeploy to apply your latest settings to production. + +
+ +
+
+
+ ); +} diff --git a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/components/deployment-domains-card.tsx b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/components/deployment-domains-card.tsx index b99a60d215..d503908fb1 100644 --- a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/components/deployment-domains-card.tsx +++ b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/components/deployment-domains-card.tsx @@ -1,7 +1,6 @@ "use client"; import type { Domain } from "@/lib/collections"; -import { cn } from "@/lib/utils"; import { ChevronDown, Cube, Earth, Link4 } from "@unkey/icons"; import { Button, @@ -17,6 +16,7 @@ import { useProjectData } from "../(overview)/data-provider"; import { useDeployment } from "../(overview)/deployments/[deploymentId]/layout-provider"; import { SettingsGroup } from "../(overview)/settings/components/shared/settings-group"; import { getDomainPriority } from "./domain-priority"; +import { GlowIcon } from "./glow-icon"; import { TagBadge } from "./tag-badge"; export function DeploymentDomainsCard({ @@ -73,28 +73,11 @@ export function DeploymentDomainsCard({ -
-
- -
-
- ) : ( -
- -
- ) + } + glow={glow} + className="w-full h-full" + /> } title={project?.name} description={ diff --git a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/components/glow-icon.tsx b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/components/glow-icon.tsx new file mode 100644 index 0000000000..2a23a8adc0 --- /dev/null +++ b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/components/glow-icon.tsx @@ -0,0 +1,54 @@ +import { cn } from "@/lib/utils"; +import type { ReactNode } from "react"; + +type GlowIconProps = { + icon: ReactNode; + variant?: "feature" | "error"; + glow?: boolean; + transition?: boolean; + className?: string; +}; + +export function GlowIcon({ + icon, + variant = "feature", + glow = true, + transition = false, + className, +}: GlowIconProps) { + const glowColor = + variant === "error" + ? "bg-linear-to-l from-error-7 to-error-8" + : "bg-linear-to-l from-feature-8 to-info-9"; + + const glowVisible = transition + ? glow + ? "animate-pulse opacity-20" + : "opacity-0 transition-opacity duration-300" + : glow + ? "animate-pulse opacity-20" + : "hidden"; + + const iconBg = + variant === "error" + ? "bg-errorA-3 dark:text-error-11 text-error-11" + : glow + ? "dark:bg-white dark:text-black bg-black text-white shadow-md shadow-black/40" + : ""; + + return ( +
+
+
+ {icon} +
+
+ ); +} diff --git a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/new/steps/configure-deployment.tsx b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/new/steps/configure-deployment.tsx deleted file mode 100644 index 5e2e5385fd..0000000000 --- a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/new/steps/configure-deployment.tsx +++ /dev/null @@ -1,61 +0,0 @@ -"use client"; - -import { queryClient } from "@/lib/collections/client"; -import { trpc } from "@/lib/trpc/client"; -import { Button, toast, useStepWizard } from "@unkey/ui"; -import { ProjectDataProvider } from "../../[projectId]/(overview)/data-provider"; -import { DeploymentSettings } from "../../[projectId]/(overview)/settings/deployment-settings"; -import { OnboardingEnvironmentSettingsProvider } from "./onboarding-environment-provider"; - -type ConfigureDeploymentStepProps = { - projectId: string; - onDeploymentCreated: (deploymentId: string) => void; -}; - -export const ConfigureDeploymentStep = ({ - projectId, - onDeploymentCreated, -}: ConfigureDeploymentStepProps) => { - const { next, activeStepId } = useStepWizard(); - - const deploy = trpc.deploy.deployment.create.useMutation({ - onSuccess: async (data) => { - await queryClient.invalidateQueries({ queryKey: ["deployments", projectId] }); - toast.success("Deployment triggered", { - description: "Your project is being built and deployed", - }); - onDeploymentCreated(data.deploymentId); - next(); - }, - onError: (error) => { - toast.error("Deployment failed", { description: error.message }); - }, - }); - - return ( - - -
- -
- - - We'll build your image, provision infrastructure, and more. -
-
-
-
-
-
- ); -}; diff --git a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/new/steps/configure-deployment/content.tsx b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/new/steps/configure-deployment/content.tsx new file mode 100644 index 0000000000..855adc69f1 --- /dev/null +++ b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/new/steps/configure-deployment/content.tsx @@ -0,0 +1,57 @@ +"use client"; + +import { queryClient } from "@/lib/collections/client"; +import { trpc } from "@/lib/trpc/client"; +import { Button, toast, useStepWizard } from "@unkey/ui"; +import { DeploymentSettings } from "../../../[projectId]/(overview)/settings/deployment-settings"; +import { useEnvironmentSettings } from "../../../[projectId]/(overview)/settings/environment-provider"; + +type ConfigureDeploymentContentProps = { + projectId: string; + onDeploymentCreated: (deploymentId: string) => void; +}; + +export const ConfigureDeploymentContent = ({ + projectId, + onDeploymentCreated, +}: ConfigureDeploymentContentProps) => { + const { next } = useStepWizard(); + const { isSaving } = useEnvironmentSettings(); + + const deploy = trpc.deploy.deployment.create.useMutation({ + onSuccess: async (data) => { + await queryClient.invalidateQueries({ queryKey: ["deployments", projectId] }); + toast.success("Deployment triggered", { + description: "Your project is being built and deployed", + }); + onDeploymentCreated(data.deploymentId); + next(); + }, + onError: (error) => { + toast.error("Deployment failed", { description: error.message }); + }, + }); + + return ( +
+ +
+ + + We'll build your image, provision infrastructure, and more. +
+
+
+
+ ); +}; diff --git a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/new/steps/configure-deployment/environment-inner.tsx b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/new/steps/configure-deployment/environment-inner.tsx new file mode 100644 index 0000000000..667c5242fe --- /dev/null +++ b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/new/steps/configure-deployment/environment-inner.tsx @@ -0,0 +1,56 @@ +"use client"; + +import { collection } from "@/lib/collections"; +import { useSettingsIsSaving } from "@/lib/collections/deploy/environment-settings"; +import { eq, useLiveQuery } from "@tanstack/react-db"; +import { type PropsWithChildren, useMemo } from "react"; +import { EnvironmentContext } from "../../../[projectId]/(overview)/settings/environment-provider"; + +export const OnboardingEnvironmentSettingsInner = ({ + children, + prodEnvId, + environments, +}: PropsWithChildren<{ + prodEnvId: string; + environments: { id: string; slug: string }[]; +}>) => { + const otherEnvIds = useMemo( + () => environments.filter((e) => e.id !== prodEnvId).map((e) => e.id), + [environments, prodEnvId], + ); + + const { data } = useLiveQuery( + (q) => + q + .from({ s: collection.environmentSettings }) + .where(({ s }) => eq(s.environmentId, prodEnvId)), + [prodEnvId], + ); + + const settings = data.at(0); + + const isSaving = useSettingsIsSaving(); + + // Setting cannot be null at this point coz they are preloaded + if (!settings) { + return null; + } + + return ( + + {otherEnvIds.map((id) => ( + + ))} + {children} + + ); +}; + +const EnvironmentSettingsPreloader = ({ envId }: { envId: string }) => { + useLiveQuery( + (q) => + q.from({ s: collection.environmentSettings }).where(({ s }) => eq(s.environmentId, envId)), + [envId], + ); + return null; +}; diff --git a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/new/steps/configure-deployment/environment-provider.tsx b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/new/steps/configure-deployment/environment-provider.tsx new file mode 100644 index 0000000000..1c5e9478d0 --- /dev/null +++ b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/new/steps/configure-deployment/environment-provider.tsx @@ -0,0 +1,32 @@ +"use client"; + +import { type PropsWithChildren, useMemo } from "react"; +import { useProjectData } from "../../../[projectId]/(overview)/data-provider"; +import { OnboardingEnvironmentSettingsInner } from "./environment-inner"; + +/** + * Drop-in replacement for EnvironmentSettingsProvider used during onboarding. + * + * Provides the same EnvironmentContext (so useEnvironmentSettings() works). + * Returns null until environments have loaded so prodEnvId is always defined + * before any live queries run. + */ +export const OnboardingEnvironmentSettingsProvider = ({ children }: PropsWithChildren) => { + const { environments, isEnvironmentsLoading } = useProjectData(); + + const prodEnvId = useMemo( + () => (environments.find((e) => e.slug === "production") ?? environments.at(0))?.id, + [environments], + ); + + // This is actually guarded by fallback component at where we call this provider + if (isEnvironmentsLoading || !prodEnvId) { + return null; + } + + return ( + + {children} + + ); +}; diff --git a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/new/steps/configure-deployment/fallback.tsx b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/new/steps/configure-deployment/fallback.tsx new file mode 100644 index 0000000000..764ef47ba1 --- /dev/null +++ b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/new/steps/configure-deployment/fallback.tsx @@ -0,0 +1,81 @@ +"use client"; + +import { Button } from "@unkey/ui"; +import { cn } from "@unkey/ui/src/lib/utils"; +import { useProjectData } from "../../../[projectId]/(overview)/data-provider"; + +export const ConfigureDeploymentFallback = () => { + const { isEnvironmentsLoading } = useProjectData(); + if (!isEnvironmentsLoading) { + return null; + } + + const cards = [ + { titleW: "w-16", descW: "w-52", badgeW: "w-36" }, + { titleW: "w-24", descW: "w-80", badgeW: "w-7" }, + { titleW: "w-16", descW: "w-72", badgeW: "w-20" }, + ]; + + const sections = [{ titleW: "w-28" }, { titleW: "w-40" }, { titleW: "w-36" }]; + + return ( +
+
+ {/* SettingCardGroup skeleton */} +
+ {cards.map(({ titleW, descW, badgeW }, i) => ( +
+
+
+
+
+
+
+
+
+
+
+
+
+ ))} +
+ + {/* SettingsGroup collapsed headers skeleton */} + {sections.map(({ titleW }, i) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: safe to leave +
+
+
+
+
+
+
+
+
+
+
+
+ ))} +
+ +
+ + + We'll build your image, provision infrastructure, and more. +
+
+
+
+ ); +}; diff --git a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/new/steps/configure-deployment/index.tsx b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/new/steps/configure-deployment/index.tsx new file mode 100644 index 0000000000..5e6fd03f8d --- /dev/null +++ b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/new/steps/configure-deployment/index.tsx @@ -0,0 +1,28 @@ +"use client"; + +import { ProjectDataProvider } from "../../../[projectId]/(overview)/data-provider"; +import { ConfigureDeploymentContent } from "./content"; +import { OnboardingEnvironmentSettingsProvider } from "./environment-provider"; +import { ConfigureDeploymentFallback } from "./fallback"; + +type ConfigureDeploymentStepProps = { + projectId: string; + onDeploymentCreated: (deploymentId: string) => void; +}; + +export const ConfigureDeploymentStep = ({ + projectId, + onDeploymentCreated, +}: ConfigureDeploymentStepProps) => { + return ( + + + + + + + ); +}; diff --git a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/new/steps/onboarding-environment-provider.tsx b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/new/steps/onboarding-environment-provider.tsx deleted file mode 100644 index 9eb5a1d7e4..0000000000 --- a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/new/steps/onboarding-environment-provider.tsx +++ /dev/null @@ -1,145 +0,0 @@ -"use client"; -import { collection } from "@/lib/collections"; -import { - type EnvironmentSettings, - buildSettingsMutations, -} from "@/lib/collections/deploy/environment-settings"; -import { trpc } from "@/lib/trpc/client"; -import { eq, useLiveQuery } from "@tanstack/react-db"; -import { type PropsWithChildren, useEffect, useMemo, useRef } from "react"; -import { useProjectData } from "../../[projectId]/(overview)/data-provider"; -import { EnvironmentContext } from "../../[projectId]/(overview)/settings/environment-provider"; - -/** - * Drop-in replacement for EnvironmentSettingsProvider used during onboarding. - * - * Provides the same EnvironmentContext (so useEnvironmentSettings() works), - * but syncs production setting changes to every other environment so new - * projects start with consistent config. - */ -export const OnboardingEnvironmentSettingsProvider = ({ - children, - isActive, -}: PropsWithChildren<{ isActive: boolean }>) => { - const { environments } = useProjectData(); - - const prodEnvId = useMemo( - () => (environments.find((e) => e.slug === "production") ?? environments.at(0))?.id, - [environments], - ); - - const otherEnvIds = useMemo( - () => environments.filter((e) => e.id !== prodEnvId).map((e) => e.id), - [environments, prodEnvId], - ); - - const { data } = useLiveQuery( - (q) => - q - .from({ s: collection.environmentSettings }) - .where(({ s }) => eq(s.environmentId, prodEnvId)), - [prodEnvId], - ); - - const settings = data.at(0); - - const { data: availableRegions } = trpc.deploy.environmentSettings.getAvailableRegions.useQuery( - undefined, - { enabled: Boolean(prodEnvId) }, - ); - - useInitializeSettings(settings, availableRegions, isActive); - useSyncSettingsToOtherEnvironments(settings, otherEnvIds); - - if (!settings) { - return null; - } - - return ( - - {children} - - ); -}; - -// Settings are empty initially so we set all of them by default for the user. -// Later they can change it in the settings. -function useInitializeSettings( - settings: EnvironmentSettings | undefined, - availableRegions: { id: string; name: string }[] | undefined, - isActive: boolean, -) { - const hasInitializedRef = useRef(false); - - useEffect(() => { - if (!settings || !availableRegions || !isActive) { - return; - } - if (hasInitializedRef.current) { - return; - } - hasInitializedRef.current = true; - - collection.environmentSettings.update( - settings.environmentId, - { metadata: { silent: true } }, - (draft) => { - if (!draft.dockerfile) { - draft.dockerfile = "Dockerfile"; - } - if (!draft.dockerContext) { - draft.dockerContext = "."; - } - if (!draft.port) { - draft.port = 8080; - } - if (!draft.cpuMillicores) { - draft.cpuMillicores = 256; - } - if (!draft.memoryMib) { - draft.memoryMib = 256; - } - if (!draft.shutdownSignal) { - draft.shutdownSignal = "SIGTERM"; - } - if (draft.regions.length === 0) { - draft.regions = availableRegions.map((r) => ({ id: r.id, name: r.name, replicas: 1 })); - } - }, - ); - }, [settings, availableRegions, isActive]); -} - -function useSyncSettingsToOtherEnvironments( - settings: EnvironmentSettings | undefined, - otherEnvIds: string[], -) { - const settingsRef = useRef(settings); - settingsRef.current = settings; - const otherEnvIdsRef = useRef(otherEnvIds); - otherEnvIdsRef.current = otherEnvIds; - const prevSettingsRef = useRef(null); - - // biome-ignore lint/correctness/useExhaustiveDependencies: JSON.stringify used for deep comparison; values read from refs to avoid stale closures - useEffect(() => { - const current = settingsRef.current; - const envIds = otherEnvIdsRef.current; - - if (!current || envIds.length === 0) { - return; - } - - const prev = prevSettingsRef.current; - prevSettingsRef.current = current; - - if (!prev) { - return; // skip initial load - } - - const mutations = envIds.flatMap((envId) => buildSettingsMutations(envId, prev, current)); - - if (mutations.length > 0) { - Promise.all(mutations).catch(console.error); - } - }, [JSON.stringify(settings), JSON.stringify(otherEnvIds)]); -} diff --git a/web/apps/dashboard/lib/collections/deploy/environment-settings.ts b/web/apps/dashboard/lib/collections/deploy/environment-settings.ts index 6ca2f24ca6..a7ccc150a2 100644 --- a/web/apps/dashboard/lib/collections/deploy/environment-settings.ts +++ b/web/apps/dashboard/lib/collections/deploy/environment-settings.ts @@ -3,6 +3,7 @@ import type { SentinelConfig } from "@/lib/trpc/routers/deploy/environment-setti import { queryCollectionOptions } from "@tanstack/query-db-collection"; import { createCollection } from "@tanstack/react-db"; import { toast } from "@unkey/ui"; +import { useSyncExternalStore } from "react"; import { z } from "zod"; import { queryClient, trpcClient } from "../client"; import { parseEnvironmentIdFromWhere, validateEnvironmentIdInQuery } from "./utils"; @@ -286,5 +287,54 @@ async function dispatchSettingsMutations( }), }); } - await allMutations; + saveStore.pendingSaves++; + saveStore.notify(); + try { + await allMutations; + saveStore.savedCount++; + saveStore.notify(); + } finally { + saveStore.pendingSaves--; + saveStore.notify(); + } +} + +/** + * Store for tracking in-flight and completed settings saves. + * + * Grouped into a single object so the boundary is obvious and + * `dispatchSettingsMutations` has one place to update. + * Consumers subscribe via `useSyncExternalStore` — no React context needed + * because settings mutations always originate from this module. + */ +const saveStore = { + pendingSaves: 0, + savedCount: 0, + listeners: new Set<() => void>(), + notify() { + for (const cb of this.listeners) { + cb(); + } + }, + subscribe(cb: () => void): () => void { + this.listeners.add(cb); + return () => { + this.listeners.delete(cb); + }; + }, +}; + +export function useSettingsIsSaving(): boolean { + return useSyncExternalStore( + (cb) => saveStore.subscribe(cb), + () => saveStore.pendingSaves > 0, + ); +} + +/** Returns true once at least one settings save has completed in this session. */ +export function useSettingsHasSaved(): boolean { + return useSyncExternalStore( + (cb) => saveStore.subscribe(cb), + () => saveStore.savedCount > 0, + ); }