From 59ba94dd202adc455f38f32298a9b43aba91d48d Mon Sep 17 00:00:00 2001 From: ogzhanolguncu Date: Thu, 12 Mar 2026 21:12:55 +0300 Subject: [PATCH 1/7] refactor: make sure we disable deploy button during mutation --- .../settings/environment-provider.tsx | 3 +- .../new/steps/configure-deployment.tsx | 66 ++++++++++++------- .../steps/onboarding-environment-provider.tsx | 13 +++- .../deploy/environment-settings.ts | 24 ++++++- 4 files changed, 77 insertions(+), 29 deletions(-) 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/new/steps/configure-deployment.tsx b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/new/steps/configure-deployment.tsx index 5e2e5385fd..12541e6982 100644 --- 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 @@ -5,6 +5,7 @@ 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 { useEnvironmentSettings } from "../../[projectId]/(overview)/settings/environment-provider"; import { OnboardingEnvironmentSettingsProvider } from "./onboarding-environment-provider"; type ConfigureDeploymentStepProps = { @@ -16,7 +17,26 @@ export const ConfigureDeploymentStep = ({ projectId, onDeploymentCreated, }: ConfigureDeploymentStepProps) => { - const { next, activeStepId } = useStepWizard(); + const { activeStepId } = useStepWizard(); + + return ( + + + + + + ); +}; + +const ConfigureDeploymentContent = ({ + projectId, + onDeploymentCreated, +}: ConfigureDeploymentStepProps) => { + const { next } = useStepWizard(); + const { isSaving } = useEnvironmentSettings(); const deploy = trpc.deploy.deployment.create.useMutation({ onSuccess: async (data) => { @@ -33,29 +53,25 @@ export const ConfigureDeploymentStep = ({ }); return ( - - -
- -
- - - We'll build your image, provision infrastructure, and more. -
-
-
-
-
-
+
+ +
+ + + We'll build your image, provision infrastructure, and more. +
+
+
+
); }; 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 index 9eb5a1d7e4..0f7da31ad3 100644 --- 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 @@ -3,10 +3,11 @@ import { collection } from "@/lib/collections"; import { type EnvironmentSettings, buildSettingsMutations, + subscribeToSettingsSaving, } 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 { type PropsWithChildren, useEffect, useMemo, useRef, useState } from "react"; import { useProjectData } from "../../[projectId]/(overview)/data-provider"; import { EnvironmentContext } from "../../[projectId]/(overview)/settings/environment-provider"; @@ -48,6 +49,14 @@ export const OnboardingEnvironmentSettingsProvider = ({ { enabled: Boolean(prodEnvId) }, ); + const [isSaving, setIsSaving] = useState(false); + + useEffect(() => { + // Returns the unsubscribe function, which React calls on unmount to remove + // the subscriber from _saveSubscribers and prevent stale callbacks. + return subscribeToSettingsSaving(setIsSaving); + }, []); + useInitializeSettings(settings, availableRegions, isActive); useSyncSettingsToOtherEnvironments(settings, otherEnvIds); @@ -56,7 +65,7 @@ export const OnboardingEnvironmentSettingsProvider = ({ } return ( - + {children} ); diff --git a/web/apps/dashboard/lib/collections/deploy/environment-settings.ts b/web/apps/dashboard/lib/collections/deploy/environment-settings.ts index 9bb28312cc..18b98c971c 100644 --- a/web/apps/dashboard/lib/collections/deploy/environment-settings.ts +++ b/web/apps/dashboard/lib/collections/deploy/environment-settings.ts @@ -47,6 +47,21 @@ const schema = z.object({ sentinelConfig: sentinelConfigSchema, }); +let _pendingSaves = 0; +const _saveSubscribers = new Set<(isSaving: boolean) => void>(); + +function _notifySaveSubscribers() { + const isSaving = _pendingSaves > 0; + for (const cb of _saveSubscribers) cb(isSaving); +} + +export function subscribeToSettingsSaving(cb: (isSaving: boolean) => void): () => void { + _saveSubscribers.add(cb); + return () => { + _saveSubscribers.delete(cb); + }; +} + /** * Environment settings collection - flattened build + runtime settings. * @@ -273,5 +288,12 @@ async function dispatchSettingsMutations( }), }); } - await allMutations; + _pendingSaves++; + _notifySaveSubscribers(); + try { + await allMutations; + } finally { + _pendingSaves--; + _notifySaveSubscribers(); + } } From 829839fdd131315690a0e9764dca1c78e6a8d512 Mon Sep 17 00:00:00 2001 From: ogzhanolguncu Date: Thu, 12 Mar 2026 22:19:22 +0300 Subject: [PATCH 2/7] refactor: organize files --- .../content.tsx} | 30 +--- .../environment-inner.tsx | 62 +++++++ .../environment-provider.tsx | 37 +++++ .../steps/configure-deployment/fallback.tsx | 76 +++++++++ .../new/steps/configure-deployment/index.tsx | 28 ++++ .../steps/onboarding-environment-provider.tsx | 154 ------------------ .../lib/trpc/routers/deploy/project/create.ts | 15 ++ 7 files changed, 223 insertions(+), 179 deletions(-) rename web/apps/dashboard/app/(app)/[workspaceSlug]/projects/new/steps/{configure-deployment.tsx => configure-deployment/content.tsx} (60%) create mode 100644 web/apps/dashboard/app/(app)/[workspaceSlug]/projects/new/steps/configure-deployment/environment-inner.tsx create mode 100644 web/apps/dashboard/app/(app)/[workspaceSlug]/projects/new/steps/configure-deployment/environment-provider.tsx create mode 100644 web/apps/dashboard/app/(app)/[workspaceSlug]/projects/new/steps/configure-deployment/fallback.tsx create mode 100644 web/apps/dashboard/app/(app)/[workspaceSlug]/projects/new/steps/configure-deployment/index.tsx delete mode 100644 web/apps/dashboard/app/(app)/[workspaceSlug]/projects/new/steps/onboarding-environment-provider.tsx 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/content.tsx similarity index 60% rename from web/apps/dashboard/app/(app)/[workspaceSlug]/projects/new/steps/configure-deployment.tsx rename to web/apps/dashboard/app/(app)/[workspaceSlug]/projects/new/steps/configure-deployment/content.tsx index 12541e6982..855adc69f1 100644 --- 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/content.tsx @@ -3,38 +3,18 @@ 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 { useEnvironmentSettings } from "../../[projectId]/(overview)/settings/environment-provider"; -import { OnboardingEnvironmentSettingsProvider } from "./onboarding-environment-provider"; +import { DeploymentSettings } from "../../../[projectId]/(overview)/settings/deployment-settings"; +import { useEnvironmentSettings } from "../../../[projectId]/(overview)/settings/environment-provider"; -type ConfigureDeploymentStepProps = { +type ConfigureDeploymentContentProps = { projectId: string; onDeploymentCreated: (deploymentId: string) => void; }; -export const ConfigureDeploymentStep = ({ +export const ConfigureDeploymentContent = ({ projectId, onDeploymentCreated, -}: ConfigureDeploymentStepProps) => { - const { activeStepId } = useStepWizard(); - - return ( - - - - - - ); -}; - -const ConfigureDeploymentContent = ({ - projectId, - onDeploymentCreated, -}: ConfigureDeploymentStepProps) => { +}: ConfigureDeploymentContentProps) => { const { next } = useStepWizard(); const { isSaving } = useEnvironmentSettings(); 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..dbd973cf0c --- /dev/null +++ b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/new/steps/configure-deployment/environment-inner.tsx @@ -0,0 +1,62 @@ +"use client"; + +import { collection } from "@/lib/collections"; +import { + subscribeToSettingsSaving, +} from "@/lib/collections/deploy/environment-settings"; +import { eq, useLiveQuery } from "@tanstack/react-db"; +import { type PropsWithChildren, useEffect, useMemo, useState } 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, setIsSaving] = useState(false); + + useEffect(() => { + return subscribeToSettingsSaving(setIsSaving); + }, []); + + // 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..40393736b2 --- /dev/null +++ b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/new/steps/configure-deployment/environment-provider.tsx @@ -0,0 +1,37 @@ +"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..261733cdf8 --- /dev/null +++ b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/new/steps/configure-deployment/fallback.tsx @@ -0,0 +1,76 @@ +"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) => ( +
+
+
+
+
+
+
+
+
+
+
+
+ ))} +
+ +
+ + + 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..125d5e3bda --- /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 { OnboardingEnvironmentSettingsProvider } from "./environment-provider"; +import { ConfigureDeploymentContent } from "./content"; +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 0f7da31ad3..0000000000 --- a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/new/steps/onboarding-environment-provider.tsx +++ /dev/null @@ -1,154 +0,0 @@ -"use client"; -import { collection } from "@/lib/collections"; -import { - type EnvironmentSettings, - buildSettingsMutations, - subscribeToSettingsSaving, -} 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, useState } 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) }, - ); - - const [isSaving, setIsSaving] = useState(false); - - useEffect(() => { - // Returns the unsubscribe function, which React calls on unmount to remove - // the subscriber from _saveSubscribers and prevent stale callbacks. - return subscribeToSettingsSaving(setIsSaving); - }, []); - - 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/trpc/routers/deploy/project/create.ts b/web/apps/dashboard/lib/trpc/routers/deploy/project/create.ts index cda3951afc..d4c158b58f 100644 --- a/web/apps/dashboard/lib/trpc/routers/deploy/project/create.ts +++ b/web/apps/dashboard/lib/trpc/routers/deploy/project/create.ts @@ -156,6 +156,21 @@ export const createProject = workspaceProcedure updatedAt: Date.now(), }, ]); + + const regions = await tx.query.regions.findMany({ columns: { id: true } }); + await tx.insert(schema.appRegionalSettings).values( + [prodEnvId, previewEnvId].flatMap((environmentId) => + regions.map((r) => ({ + workspaceId: ctx.workspace.id, + appId, + environmentId, + regionId: r.id, + replicas: 1, + createdAt: Date.now(), + updatedAt: Date.now(), + })), + ), + ); }); return { From 32afe94db4a921c1388cf6edb4607cf6d2bae711 Mon Sep 17 00:00:00 2001 From: ogzhanolguncu Date: Thu, 12 Mar 2026 23:18:30 +0300 Subject: [PATCH 3/7] feat: trigger redeploy from settings when there is change --- .../(deployment-progress)/deployment-step.tsx | 30 ++---- .../components/actions/redeploy-dialog.tsx | 12 ++- .../[projectId]/(overview)/settings/page.tsx | 2 + .../settings/pending-redeploy-banner.tsx | 96 +++++++++++++++++++ .../components/deployment-domains-card.tsx | 29 ++---- .../[projectId]/components/glow-icon.tsx | 54 +++++++++++ .../environment-inner.tsx | 4 +- .../environment-provider.tsx | 9 +- .../steps/configure-deployment/fallback.tsx | 7 +- .../new/steps/configure-deployment/index.tsx | 2 +- .../deploy/environment-settings.ts | 18 +++- 11 files changed, 200 insertions(+), 63 deletions(-) create mode 100644 web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/settings/pending-redeploy-banner.tsx create mode 100644 web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/components/glow-icon.tsx 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 6e9bd4e8a0..8c5906a057 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; @@ -31,28 +32,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..178e4a6f89 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 @@ -21,11 +21,13 @@ export const RedeployDialog = ({ isOpen, onClose, selectedDeployment }: Redeploy const { projectId } = useProjectData(); const redeploy = trpc.deploy.deployment.redeploy.useMutation({ - onSuccess: async (data) => { - await queryClient.invalidateQueries({ queryKey: ["deployments", projectId] }); - router.push( - `/${workspace.slug}/projects/${selectedDeployment.projectId}/deployments/${data.deploymentId}`, - ); + onSuccess: (data) => { + queryClient.invalidateQueries({ queryKey: ["deployments", projectId] }).then(() => { + router.push( + `/${workspace.slug}/projects/${selectedDeployment.projectId}/deployments/${data.deploymentId}`, + ); + }); + onClose(); }, onError: (error) => { toast.error("Redeploy failed", { 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..99068835fe --- /dev/null +++ b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/settings/pending-redeploy-banner.tsx @@ -0,0 +1,96 @@ +"use client"; + +import { useWorkspaceNavigation } from "@/hooks/use-workspace-navigation"; +import { queryClient } from "@/lib/collections/client"; +import { subscribeToSettingsSaved } 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 { useEffect, useRef, useState } from "react"; +import { GlowIcon } from "../../components/glow-icon"; +import { useProjectData } from "../data-provider"; + +export function PendingRedeployBanner() { + const [visible, setVisible] = useState(false); + const { project, deployments, projectId } = useProjectData(); + const router = useRouter(); + const workspace = useWorkspaceNavigation(); + + useEffect(() => { + return subscribeToSettingsSaved(() => { + setVisible(true); + }); + }, []); + + const currentDeployment = project?.currentDeploymentId + ? deployments.find((d) => d.id === project.currentDeploymentId) + : undefined; + + const currentDeploymentRef = useRef(currentDeployment); + useEffect(() => { + currentDeploymentRef.current = currentDeployment; + }, [currentDeployment]); + + const redeploy = trpc.deploy.deployment.redeploy.useMutation({ + onSuccess: (data) => { + const dep = currentDeploymentRef.current; + if (!dep) { + return; + } + queryClient.invalidateQueries({ queryKey: ["deployments", projectId] }).then(() => { + router.push( + `/${workspace.slug}/projects/${dep.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 5c341fa8cc..8af8c95c6a 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, @@ -16,6 +15,7 @@ import { type ReactNode, useState } from "react"; import { useProjectData } from "../(overview)/data-provider"; import { useDeployment } from "../(overview)/deployments/[deploymentId]/layout-provider"; import { SettingsGroup } from "../(overview)/settings/components/shared/settings-group"; +import { GlowIcon } from "./glow-icon"; export function DeploymentDomainsCard({ emptyState, @@ -62,28 +62,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/environment-inner.tsx b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/new/steps/configure-deployment/environment-inner.tsx index dbd973cf0c..c492c130c9 100644 --- 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 @@ -1,9 +1,7 @@ "use client"; import { collection } from "@/lib/collections"; -import { - subscribeToSettingsSaving, -} from "@/lib/collections/deploy/environment-settings"; +import { subscribeToSettingsSaving } from "@/lib/collections/deploy/environment-settings"; import { eq, useLiveQuery } from "@tanstack/react-db"; import { type PropsWithChildren, useEffect, useMemo, useState } from "react"; import { EnvironmentContext } from "../../../[projectId]/(overview)/settings/environment-provider"; 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 index 40393736b2..1c5e9478d0 100644 --- 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 @@ -11,9 +11,7 @@ import { OnboardingEnvironmentSettingsInner } from "./environment-inner"; * Returns null until environments have loaded so prodEnvId is always defined * before any live queries run. */ -export const OnboardingEnvironmentSettingsProvider = ({ - children, -}: PropsWithChildren) => { +export const OnboardingEnvironmentSettingsProvider = ({ children }: PropsWithChildren) => { const { environments, isEnvironmentsLoading } = useProjectData(); const prodEnvId = useMemo( @@ -27,10 +25,7 @@ export const OnboardingEnvironmentSettingsProvider = ({ } 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 index 261733cdf8..764ef47ba1 100644 --- 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 @@ -25,6 +25,7 @@ export const ConfigureDeploymentFallback = () => {
{cards.map(({ titleW, descW, badgeW }, i) => (
@@ -37,7 +38,10 @@ export const ConfigureDeploymentFallback = () => {
@@ -47,6 +51,7 @@ export const ConfigureDeploymentFallback = () => { {/* SettingsGroup collapsed headers skeleton */} {sections.map(({ titleW }, i) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: safe to leave
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 index 125d5e3bda..5e6fd03f8d 100644 --- 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 @@ -1,8 +1,8 @@ "use client"; import { ProjectDataProvider } from "../../../[projectId]/(overview)/data-provider"; -import { OnboardingEnvironmentSettingsProvider } from "./environment-provider"; import { ConfigureDeploymentContent } from "./content"; +import { OnboardingEnvironmentSettingsProvider } from "./environment-provider"; import { ConfigureDeploymentFallback } from "./fallback"; type ConfigureDeploymentStepProps = { diff --git a/web/apps/dashboard/lib/collections/deploy/environment-settings.ts b/web/apps/dashboard/lib/collections/deploy/environment-settings.ts index 18b98c971c..0f2015ec5a 100644 --- a/web/apps/dashboard/lib/collections/deploy/environment-settings.ts +++ b/web/apps/dashboard/lib/collections/deploy/environment-settings.ts @@ -52,7 +52,9 @@ const _saveSubscribers = new Set<(isSaving: boolean) => void>(); function _notifySaveSubscribers() { const isSaving = _pendingSaves > 0; - for (const cb of _saveSubscribers) cb(isSaving); + for (const cb of _saveSubscribers) { + cb(isSaving); + } } export function subscribeToSettingsSaving(cb: (isSaving: boolean) => void): () => void { @@ -62,6 +64,16 @@ export function subscribeToSettingsSaving(cb: (isSaving: boolean) => void): () = }; } +let _savedCount = 0; +const _savedSubscribers = new Set<() => void>(); + +export function subscribeToSettingsSaved(cb: () => void): () => void { + _savedSubscribers.add(cb); + return () => { + _savedSubscribers.delete(cb); + }; +} + /** * Environment settings collection - flattened build + runtime settings. * @@ -292,6 +304,10 @@ async function dispatchSettingsMutations( _notifySaveSubscribers(); try { await allMutations; + _savedCount++; + for (const cb of _savedSubscribers) { + cb(); + } } finally { _pendingSaves--; _notifySaveSubscribers(); From c54fdd7bec2e0143f9352ac76c494b6f3ef58fae Mon Sep 17 00:00:00 2001 From: ogzhanolguncu Date: Fri, 13 Mar 2026 13:01:47 +0300 Subject: [PATCH 4/7] fix: rabbit commetns --- .../components/actions/redeploy-dialog.tsx | 11 +++++----- .../settings/pending-redeploy-banner.tsx | 21 +++++++------------ 2 files changed, 12 insertions(+), 20 deletions(-) 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 178e4a6f89..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 @@ -21,13 +21,12 @@ export const RedeployDialog = ({ isOpen, onClose, selectedDeployment }: Redeploy const { projectId } = useProjectData(); const redeploy = trpc.deploy.deployment.redeploy.useMutation({ - onSuccess: (data) => { - queryClient.invalidateQueries({ queryKey: ["deployments", projectId] }).then(() => { - router.push( - `/${workspace.slug}/projects/${selectedDeployment.projectId}/deployments/${data.deploymentId}`, - ); - }); + onSuccess: async (data) => { + await queryClient.invalidateQueries({ queryKey: ["deployments", projectId] }); onClose(); + router.push( + `/${workspace.slug}/projects/${selectedDeployment.projectId}/deployments/${data.deploymentId}`, + ); }, onError: (error) => { toast.error("Redeploy failed", { 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 index 99068835fe..1e9edd4ead 100644 --- 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 @@ -7,7 +7,7 @@ import { trpc } from "@/lib/trpc/client"; import { Hammer2, XMark } from "@unkey/icons"; import { Button, toast } from "@unkey/ui"; import { useRouter } from "next/navigation"; -import { useEffect, useRef, useState } from "react"; +import { useEffect, useState } from "react"; import { GlowIcon } from "../../components/glow-icon"; import { useProjectData } from "../data-provider"; @@ -27,22 +27,15 @@ export function PendingRedeployBanner() { ? deployments.find((d) => d.id === project.currentDeploymentId) : undefined; - const currentDeploymentRef = useRef(currentDeployment); - useEffect(() => { - currentDeploymentRef.current = currentDeployment; - }, [currentDeployment]); - const redeploy = trpc.deploy.deployment.redeploy.useMutation({ - onSuccess: (data) => { - const dep = currentDeploymentRef.current; - if (!dep) { + onSuccess: async (data) => { + if (!currentDeployment) { return; } - queryClient.invalidateQueries({ queryKey: ["deployments", projectId] }).then(() => { - router.push( - `/${workspace.slug}/projects/${dep.projectId}/deployments/${data.deploymentId}`, - ); - }); + 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 }); From e3df5e98514c36559ca336e55707ed102a822da0 Mon Sep 17 00:00:00 2001 From: ogzhanolguncu Date: Fri, 13 Mar 2026 13:21:46 +0300 Subject: [PATCH 5/7] refactor: simplify use of subscription --- .../settings/pending-redeploy-banner.tsx | 16 ++-- .../environment-inner.tsx | 10 +-- .../deploy/environment-settings.ts | 80 +++++++++++-------- 3 files changed, 54 insertions(+), 52 deletions(-) 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 index 1e9edd4ead..dfb7399766 100644 --- 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 @@ -2,26 +2,22 @@ import { useWorkspaceNavigation } from "@/hooks/use-workspace-navigation"; import { queryClient } from "@/lib/collections/client"; -import { subscribeToSettingsSaved } from "@/lib/collections/deploy/environment-settings"; +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 { useEffect, useState } from "react"; +import { useState } from "react"; import { GlowIcon } from "../../components/glow-icon"; import { useProjectData } from "../data-provider"; export function PendingRedeployBanner() { - const [visible, setVisible] = useState(false); + const [dismissed, setDismissed] = useState(false); const { project, deployments, projectId } = useProjectData(); const router = useRouter(); const workspace = useWorkspaceNavigation(); - - useEffect(() => { - return subscribeToSettingsSaved(() => { - setVisible(true); - }); - }, []); + const hasSaved = useSettingsHasSaved(); + const visible = hasSaved && !dismissed; const currentDeployment = project?.currentDeploymentId ? deployments.find((d) => d.id === project.currentDeploymentId) @@ -51,7 +47,7 @@ export function PendingRedeployBanner() {