Skip to content

refactor(deployment): send deployment names to the api instead of localstorage - #3928

Open
stalniy wants to merge 1 commit into
feat/deployment-show-api-name-browserfrom
feat/deployment-show-api-name-browser-2
Open

refactor(deployment): send deployment names to the api instead of localstorage#3928
stalniy wants to merge 1 commit into
feat/deployment-show-api-name-browserfrom
feat/deployment-show-api-name-browser-2

Conversation

@stalniy

@stalniy stalniy commented Sep 11, 2026

Copy link
Copy Markdown
Contributor

Why

The name a user types was being recorded twice: #3918 routed both writes at the console API — the create request and a PATCH rename — but left the wallet-scoped localStorage write in place beside them. Two writers of one name is the drift this work set out to remove: the local copy is only ever consulted for deployments named before that change, and keeping it fed means a rename made on another device leaves a stale name sitting in this browser.

Part of CON-954 — https://linear.app/ovrclk/issue/CON-954/show-the-apis-deployment-name-when-this-browser-has-none

Slice 2 of 3, stacked on feat/deployment-show-api-name-browser. Covers AC4.

What

The second copy is gone. Nothing name-shaped is written to this browser any more — by the configure flow or by the rename dialog.

before after
POST /v1/deployments carries the name unchanged
PATCH /v1/deployments/{dseq} carries the name unchanged
this browser's record { owner, manifest, name } { owner, manifest }

The manifest cache is untouched — it is what lets an in-progress deployment resume after a reload.

The hook that owned the write

useDeploymentName now resolves a name and nothing else. The useEffect that wrote deploymentLocalStorage.update(settingsId, dseq, { name }) when a dseq first appeared is gone, and with it the hook's useServices dependency, two refs and the jotai read:

export function useDeploymentName({ initialName, dseq }: UseDeploymentNameInput, dependencies = DEPENDENCIES): DeploymentName {
  const [typedName, setTypedName] = useState(() => (initialName ?? "").slice(0, MAX_DEPLOYMENT_NAME_LENGTH));
  const resolvedName = dependencies.useResolvedDeploymentName(dseq);
  return { name: typedName || resolvedName || "", typedName, setName: setTypedName };
}

The rename dialog loses its mirror write the same way, and with it the try/catch that mirror needed for a full or blocked store. Its success path is now the invalidation alone (DeploymentNameModal.spec.tsx):

it("records nothing in this browser, because the api is now where the name lives", async () => {
  const { deploymentLocalStorage, onSaved } = setup({});

  await rename("my-app");

  await waitFor(() => expect(onSaved).toHaveBeenCalled());
  expect(deploymentLocalStorage.update).not.toHaveBeenCalled();
});

The matching guard on the create path sits next to the manifest cache, where a name write would plausibly reappear (useDeploymentFlow.spec.tsx):

it("records no name in this browser, because the create request is what carries it", () => {
  const { result, deploymentLocalStorage } = renderFlow({ createDeployment });

  act(() => result.current.actions.requestQuotes("SDL_AT_CREATE", "my-app"));

  expect(deploymentLocalStorage.update).not.toHaveBeenCalledWith(expect.anything(), expect.anything(), expect.objectContaining({ name: expect.anything() }));
});

What this costs until slice 3

⚠️ Five surfaces show no name for a deployment created or renamed from here on. The deployments list, home's active deployments, alert rows, the billing usage breakdown and provider lease rows all resolve a name through useLocalNotes.getDeploymentName, which reads this browser's record alone. It now answers null for those deployments.

#3918 said these writes would go after those surfaces read the API. The plan sequences it the other way: the write is removed here, and the remaining slice adds the batch names endpoint and moves all five onto it. Either order leaves one slice where the surfaces disagree — this one is the direction the plan approved, and the stack is meant to land together. Deployments named before this change are unaffected: their records still exist and nothing here deletes them.

What a person sees

The same walkthrough run against both versions of the code — real components and hooks in a DOM, a real DeploymentStorageService over an in-memory Storage, only the network boundary stubbed. Full reproducible version under Demo.

### the branch this PR stacks on
the user types "checkout-api" into the deployment pane and requests quotes
  POST /v1/deployments <- {"sdl":"...","name":"checkout-api","deposit":0.5}
  this browser's record for the new deployment: {"...4211337.data":{"owner":"akash1demo","manifest":"...","name":"checkout-api"}}
  the name a surface reading this browser alone finds: "checkout-api"
the user opens the rename dialog and saves "payments-api"
  PATCH /v1/deployments/{dseq} <- {"dseq":"4211337","data":{"name":"payments-api"}}
  this browser's record afterwards: {"...4211337.data":{"owner":"akash1demo","manifest":"...","name":"payments-api"}}
  the name a surface reading this browser alone finds: "payments-api"

### this PR
the user types "checkout-api" into the deployment pane and requests quotes
  POST /v1/deployments <- {"sdl":"...","name":"checkout-api","deposit":0.5}
  this browser's record for the new deployment: {"...4211337.data":{"owner":"akash1demo","manifest":"..."}}
  the name a surface reading this browser alone finds: null
the user opens the rename dialog and saves "payments-api"
  PATCH /v1/deployments/{dseq} <- {"dseq":"4211337","data":{"name":"payments-api"}}
  this browser's record afterwards: {"...4211337.data":{"owner":"akash1demo","manifest":"..."}}
  the name a surface reading this browser alone finds: null

The requests are identical in both runs. What changed is the line underneath, and the one after it is the cost.

Tests

Five specs that asserted the removed write are deleted — four in useDeploymentName.spec.tsx ("writes the name to the settings-scoped record when a dseq is first assigned", "does not write before a dseq exists", "does not write when the session resumed already carrying a dseq", "defers the write until settingsId is available instead of dropping it") and one in DeploymentNameModal.spec.tsx ("still refreshes and closes when this browser cannot record the new name"). They assert behaviour that no longer exists; the two guards above replace them.

Verification

apps/deploy-web, 144 changes:

  • npm testpassed
  • npm run lint -- --quietpassed
  • npx tsc --noEmit — 85 errors, all present at the merge base, none in changed files

Demo

Executable walkthrough — re-runnable with uvx showboat verify

CON-954 slice 2 — a deployment name that leaves the browser

2026-09-11T20:21:12Z by Showboat 0.6.1

git log --format="%s" -1 && git diff "$(git merge-base HEAD feat/deployment-show-api-name-browser)" HEAD --stat -- "apps/deploy-web/src/components/deployments/ConfigureDeployment/useDeploymentName/useDeploymentName.ts" "apps/deploy-web/src/components/LocalNoteManager/DeploymentNameModal.tsx"
refactor(deployment): send deployment names to the api instead of localstorage
 .../LocalNoteManager/DeploymentNameModal.tsx       | 17 ++-------------
 .../useDeploymentName/useDeploymentName.ts         | 24 +++-------------------
 2 files changed, 5 insertions(+), 36 deletions(-)
git diff "$(git merge-base HEAD feat/deployment-show-api-name-browser)" HEAD -- "apps/deploy-web/src/components/deployments/ConfigureDeployment/useDeploymentName/useDeploymentName.ts" "apps/deploy-web/src/components/LocalNoteManager/DeploymentNameModal.tsx"
diff --git a/apps/deploy-web/src/components/LocalNoteManager/DeploymentNameModal.tsx b/apps/deploy-web/src/components/LocalNoteManager/DeploymentNameModal.tsx
index 9b3e81c64..46d40dd46 100644
--- a/apps/deploy-web/src/components/LocalNoteManager/DeploymentNameModal.tsx
+++ b/apps/deploy-web/src/components/LocalNoteManager/DeploymentNameModal.tsx
@@ -4,14 +4,12 @@ import { useForm } from "react-hook-form";
 import { Form, FormField, FormInput, Popup, Snackbar } from "@akashnetwork/ui/components";
 import { zodResolver } from "@hookform/resolvers/zod";
 import { useQueryClient } from "@tanstack/react-query";
-import { useAtom } from "jotai";
 import { useSnackbar } from "notistack";
 import { z } from "zod";
 
 import { MAX_DEPLOYMENT_NAME_LENGTH } from "@src/config/deploy.config";
 import { useServices } from "@src/context/ServicesProvider";
 import { useResolvedDeploymentName } from "@src/hooks/useResolvedDeploymentName/useResolvedDeploymentName";
-import { settingsIdAtom } from "@src/store/settingsStore";
 
 export const DEPENDENCIES = { useSnackbar, useQueryClient, useResolvedDeploymentName };
 
@@ -31,8 +29,7 @@ type Props = {
 };
 
 export const DeploymentNameModal: React.FC<Props> = ({ dseq, onClose, onSaved, dependencies: d = DEPENDENCIES }) => {
-  const { api, deploymentLocalStorage } = useServices();
-  const [address] = useAtom(settingsIdAtom);
+  const { api } = useServices();
   const formRef = useRef<HTMLFormElement | null>(null);
   const { enqueueSnackbar } = d.useSnackbar();
   const queryClient = d.useQueryClient();
@@ -70,23 +67,13 @@ export const DeploymentNameModal: React.FC<Props> = ({ dseq, onClose, onSaved, d
     formRef.current?.dispatchEvent(new Event("submit", { cancelable: true, bubbles: true }));
   };
 
-  /** The deployments list still resolves names from this browser alone, so the record is kept in step until it reads the api too — and a full or blocked store must not strand a rename the api has already accepted. */
-  function recordNameInThisBrowser(name: string) {
-    try {
-      deploymentLocalStorage.update(address, dseq, { name });
-    } catch {
-      return;
-    }
-  }
-
   function onSubmit({ name }: z.infer<typeof formSchema>) {
     if (!dseq || renameDeployment.isPending) return;
 
     renameDeployment.mutate(
       { dseq: String(dseq), data: { name } },
       {
-        onSuccess: function recordRename() {
-          recordNameInThisBrowser(name);
+        onSuccess: function reportRenameSaved() {
           queryClient.invalidateQueries({ queryKey: api.v1.getDeployment.getKey({ dseq: String(dseq) }) });
           enqueueSnackbar(<Snackbar title="Success!" iconVariant="success" />, { variant: "success", autoHideDuration: 1000 });
           onSaved();
diff --git a/apps/deploy-web/src/components/deployments/ConfigureDeployment/useDeploymentName/useDeploymentName.ts b/apps/deploy-web/src/components/deployments/ConfigureDeployment/useDeploymentName/useDeploymentName.ts
index aab3798c9..a755838bf 100644
--- a/apps/deploy-web/src/components/deployments/ConfigureDeployment/useDeploymentName/useDeploymentName.ts
+++ b/apps/deploy-web/src/components/deployments/ConfigureDeployment/useDeploymentName/useDeploymentName.ts
@@ -1,12 +1,9 @@
-import { useEffect, useRef, useState } from "react";
-import { useAtomValue } from "jotai";
+import { useState } from "react";
 
 import { MAX_DEPLOYMENT_NAME_LENGTH } from "@src/config/deploy.config";
-import { useServices } from "@src/context/ServicesProvider";
 import { useResolvedDeploymentName } from "@src/hooks/useResolvedDeploymentName/useResolvedDeploymentName";
-import { settingsIdAtom } from "@src/store/settingsStore";
 
-export const DEPENDENCIES = { useServices, useResolvedDeploymentName };
+export const DEPENDENCIES = { useResolvedDeploymentName };
 
 export interface DeploymentName {
   /** The name to show: the one typed in this session, and the api's own only where this session has none. */
@@ -23,24 +20,9 @@ interface UseDeploymentNameInput {
   dseq: string | null;
 }
 
-/** Owns the configure session's deployment name: the api's own once the deployment exists, the typed one before that, and the write of the typed one to the wallet-scoped local record `settingsId` keys. */
+/** Owns the configure session's deployment name: the api's own once the deployment exists, and the typed one before that. */
 export function useDeploymentName({ initialName, dseq }: UseDeploymentNameInput, dependencies = DEPENDENCIES): DeploymentName {
-  const { deploymentLocalStorage } = dependencies.useServices();
-  const settingsId = useAtomValue(settingsIdAtom);
   const [typedName, setTypedName] = useState(() => (initialName ?? "").slice(0, MAX_DEPLOYMENT_NAME_LENGTH));
-  const nameRef = useRef(typedName);
-  nameRef.current = typedName;
   const resolvedName = dependencies.useResolvedDeploymentName(dseq);
-  /** Seeded with the mounting `dseq`, so a session resumed already carrying one is treated as written and never clobbers a name edited since on the deployment page. */
-  const writtenDseqRef = useRef(dseq);
-  useEffect(
-    function persistNameOnCreate() {
-      if (dseq && settingsId && dseq !== writtenDseqRef.current) {
-        writtenDseqRef.current = dseq;
-        deploymentLocalStorage.update(settingsId, dseq, { name: nameRef.current });
-      }
-    },
-    [dseq, settingsId, deploymentLocalStorage]
-  );
   return { name: typedName || resolvedName || "", typedName, setName: setTypedName };
 }
set -o pipefail
cd "$(git rev-parse --show-toplevel)/apps/deploy-web"
BASE=$(git merge-base HEAD feat/deployment-show-api-name-browser)
SOURCES="src/components/deployments/ConfigureDeployment/useDeploymentName/useDeploymentName.ts src/components/LocalNoteManager/DeploymentNameModal.tsx"

cat > src/con954-demo2.spec.tsx <<'TSX_EOF'
import { appendFileSync, writeFileSync } from "node:fs";
import type { PropsWithChildren } from "react";
import type { NetworkStore } from "@akashnetwork/network-store";
import { createStore, Provider as JotaiStoreProvider } from "jotai";
import { describe, expect, it, vi } from "vitest";
import { mock, mockDeep } from "vitest-mock-extended";

import { DeploymentNameModal } from "@src/components/LocalNoteManager/DeploymentNameModal";
import { useLocalNotes } from "@src/components/LocalNoteManager/useLocalNotes";
import { DeploymentNameField } from "@src/components/deployments/ConfigureDeployment/DeploymentPane/DeploymentNameField/DeploymentNameField";
import type { DEPENDENCIES as FLOW_DEPENDENCIES } from "@src/components/deployments/ConfigureDeployment/useDeploymentFlow/useDeploymentFlow";
import { useDeploymentFlow } from "@src/components/deployments/ConfigureDeployment/useDeploymentFlow/useDeploymentFlow";
import { useDeploymentName } from "@src/components/deployments/ConfigureDeployment/useDeploymentName/useDeploymentName";
import type { AppDIContainer } from "@src/context/ServicesProvider/ServicesProvider";
import { DeploymentStorageService } from "@src/services/deployment-storage/deployment-storage.service";
import { settingsIdAtom } from "@src/store/settingsStore";

import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { TestContainerProvider } from "@tests/unit/TestContainerProvider";

const DSEQ = "4211337";
const WALLET = "akash1demo";
const SDL = "version: '2.0'\nservices:\n  web:\n  postgres:";

const TRANSCRIPT = "/tmp/con954-demo2.out";
writeFileSync(TRANSCRIPT, "");
const say = (line: string) => appendFileSync(TRANSCRIPT, `${line}\n`);

describe("CON-954 slice 2", () => {
  it("scene 1 — requesting quotes for a deployment the user named", async () => {
    const { storage, storageService } = realBrowserStorage();
    const sent: Array<{ data: Record<string, unknown> }> = [];
    const api = mockDeep<AppDIContainer["api"]>();
    api.v1.createDeployment.useMutation.mockReturnValue(
      mock<ReturnType<typeof api.v1.createDeployment.useMutation>>({
        mutate: ((variables: { data: Record<string, unknown> }, options: { onSuccess: (r: unknown) => void }) => {
          sent.push(variables);
          options.onSuccess({ data: { dseq: DSEQ, manifest: "manifest" } });
        }) as never
      })
    );
    const services = { api, deploymentLocalStorage: storageService, analyticsService: mock<{ track: () => void }>() };

    function ConfigurePane() {
      const flow = useDeploymentFlow({ intent: { sdlStrategy: "edit", bidStrategy: "select", dseq: undefined, vm: false } }, flowDependencies(services));
      const { name, typedName, setName } = useDeploymentName({ initialName: undefined, dseq: flow.dseq });
      return (
        <>
          <DeploymentNameField value={name} onChange={setName} disabled={!!flow.dseq} />
          <button type="button" onClick={() => flow.actions.requestQuotes(SDL, typedName)}>
            Request quotes
          </button>
        </>
      );
    }

    render(withWallet(<ConfigurePane />, { storageService }));

    await userEvent.type(screen.getByRole("textbox", { name: "Deployment name" }), "checkout-api");
    say('the user types "checkout-api" into the deployment pane and requests quotes');
    await userEvent.click(screen.getByRole("button", { name: "Request quotes" }));
    await waitFor(() => expect(sent).toHaveLength(1));

    say(`  POST /v1/deployments <- ${JSON.stringify(sent[0].data)}`);
    say(`  this browser's record for the new deployment: ${JSON.stringify(storage.entries())}`);
    say(`  the name a surface reading this browser alone finds: ${JSON.stringify(localNameFor(storageService))}`);
  });

  it("scene 2 — renaming that deployment from the detail page", async () => {
    const { storage, storageService } = realBrowserStorage();
    storageService.update(WALLET, DSEQ, { manifest: SDL });
    const patched: unknown[] = [];
    const invalidated: unknown[] = [];
    const api = mockDeep<AppDIContainer["api"]>();
    api.v1.getDeployment.getKey.mockImplementation(request => ["v1", "getDeployment", { dseq: request?.dseq ?? "" }]);
    api.v1.patchDeployment.useMutation.mockReturnValue(
      mock<ReturnType<typeof api.v1.patchDeployment.useMutation>>({
        mutate: ((variables: unknown, options: { onSuccess?: () => void }) => {
          patched.push(variables);
          options.onSuccess?.();
        }) as never,
        isPending: false
      })
    );
    const queryClient = mock<ReturnType<typeof FLOW_DEPENDENCIES.useQueryClient>>();
    queryClient.invalidateQueries.mockImplementation(((filters: unknown) => invalidated.push(filters)) as never);
    const saved: boolean[] = [];

    render(
      withWallet(
        <DeploymentNameModal
          dseq={DSEQ}
          onClose={vi.fn()}
          onSaved={() => saved.push(true)}
          dependencies={{
            useSnackbar: () => ({ enqueueSnackbar: vi.fn(), closeSnackbar: vi.fn() }),
            useQueryClient: () => queryClient,
            useResolvedDeploymentName: () => "checkout-api"
          }}
        />,
        { api, storageService }
      )
    );

    say('the user opens the rename dialog and saves "payments-api"');
    const field = screen.getByRole("textbox", { name: "Name" });
    await userEvent.clear(field);
    await userEvent.type(field, "payments-api");
    await userEvent.click(screen.getByRole("button", { name: "Save" }));
    await waitFor(() => expect(saved).toHaveLength(1));

    say(`  PATCH /v1/deployments/{dseq} <- ${JSON.stringify(patched[0])}`);
    say(`  refreshes the query the heading reads: ${JSON.stringify(invalidated[0])}`);
    say(`  this browser's record afterwards: ${JSON.stringify(storage.entries())}`);
    say(`  the name a surface reading this browser alone finds: ${JSON.stringify(localNameFor(storageService))}`);
  });

  function localNameFor(storageService: DeploymentStorageService) {
    const { result } = renderLocalNotes(storageService);
    return result();
  }

  function renderLocalNotes(storageService: DeploymentStorageService) {
    let found: string | null = null;
    function LocalNameReader() {
      found = useLocalNotes().getDeploymentName(DSEQ);
      return null;
    }
    render(withWallet(<LocalNameReader />, { storageService }));
    return { result: () => found };
  }

  function withWallet(children: PropsWithChildren["children"], overrides?: { api?: AppDIContainer["api"]; storageService?: DeploymentStorageService }) {
    const store = createStore();
    store.set(settingsIdAtom, WALLET);
    return (
      <JotaiStoreProvider store={store}>
        <TestContainerProvider
          services={{
            ...(overrides?.api ? { api: () => overrides.api as AppDIContainer["api"] } : {}),
            ...(overrides?.storageService ? { deploymentLocalStorage: () => overrides.storageService as DeploymentStorageService } : {})
          }}
        >
          {children}
        </TestContainerProvider>
      </JotaiStoreProvider>
    );
  }

  function flowDependencies(services: unknown): typeof FLOW_DEPENDENCIES {
    return {
      useServices: (() => services) as never,
      useListBids: (() => ({ data: { data: [] }, isLoading: false, isError: false })) as never,
      useRouter: (() => mock<ReturnType<typeof FLOW_DEPENDENCIES.useRouter>>({ replace: vi.fn() as never })) as never,
      useQueryClient: (() => mock<ReturnType<typeof FLOW_DEPENDENCIES.useQueryClient>>()) as never,
      manifestFromSdl: () => "manifest",
      deploymentResourcesFromSdl: () => ({ gpuAmount: 0, cpuAmount: 0, memoryAmount: 0, storageAmount: 0 })
    };
  }

  function realBrowserStorage() {
    const items = new Map<string, string>();
    const storage = {
      getItem: (key: string) => items.get(key) ?? null,
      setItem: (key: string, value: string) => void items.set(key, value),
      removeItem: (key: string) => void items.delete(key),
      entries: () => Object.fromEntries([...items].map(([key, value]) => [key, JSON.parse(value)]))
    };
    return { storage, storageService: new DeploymentStorageService(storage as unknown as Storage, mock<NetworkStore>({ selectedNetworkId: "sandbox" })) };
  }
});
TSX_EOF

restore() {
  git checkout HEAD -- $SOURCES
  rm -f src/con954-demo2.spec.tsx /tmp/con954-demo2.out /tmp/con954-vitest2.log
}
trap restore EXIT

walkthrough() {
  rm -f /tmp/con954-demo2.out
  if NODE_ENV=test DEPLOYMENT_ENV=staging npx vitest run src/con954-demo2.spec.tsx --silent > /tmp/con954-vitest2.log 2>&1; then
    cat /tmp/con954-demo2.out
  else
    cat /tmp/con954-vitest2.log
    return 1
  fi
}

echo "### the branch this PR stacks on"
git checkout "$BASE" -- $SOURCES
walkthrough || exit 1
git checkout HEAD -- $SOURCES
echo
echo "### this PR"
walkthrough || exit 1
### the branch this PR stacks on
the user types "checkout-api" into the deployment pane and requests quotes
  POST /v1/deployments <- {"sdl":"version: '2.0'\nservices:\n  web:\n  postgres:","name":"checkout-api","deposit":0.5}
  this browser's record for the new deployment: {"sandbox/akash1demo/deployments/4211337.data":{"owner":"akash1demo","manifest":"version: '2.0'\nservices:\n  web:\n  postgres:","name":"checkout-api"}}
  the name a surface reading this browser alone finds: "checkout-api"
the user opens the rename dialog and saves "payments-api"
  PATCH /v1/deployments/{dseq} <- {"dseq":"4211337","data":{"name":"payments-api"}}
  refreshes the query the heading reads: {"queryKey":["v1","getDeployment",{"dseq":"4211337"}]}
  this browser's record afterwards: {"sandbox/akash1demo/deployments/4211337.data":{"owner":"akash1demo","manifest":"version: '2.0'\nservices:\n  web:\n  postgres:","name":"payments-api"}}
  the name a surface reading this browser alone finds: "payments-api"

### this PR
the user types "checkout-api" into the deployment pane and requests quotes
  POST /v1/deployments <- {"sdl":"version: '2.0'\nservices:\n  web:\n  postgres:","name":"checkout-api","deposit":0.5}
  this browser's record for the new deployment: {"sandbox/akash1demo/deployments/4211337.data":{"owner":"akash1demo","manifest":"version: '2.0'\nservices:\n  web:\n  postgres:"}}
  the name a surface reading this browser alone finds: null
the user opens the rename dialog and saves "payments-api"
  PATCH /v1/deployments/{dseq} <- {"dseq":"4211337","data":{"name":"payments-api"}}
  refreshes the query the heading reads: {"queryKey":["v1","getDeployment",{"dseq":"4211337"}]}
  this browser's record afterwards: {"sandbox/akash1demo/deployments/4211337.data":{"owner":"akash1demo","manifest":"version: '2.0'\nservices:\n  web:\n  postgres:"}}
  the name a surface reading this browser alone finds: null

…alstorage

The create request and the rename now both carry the name to the api, so
the wallet-scoped local record was being written alongside as a second
copy of the same fact. Two writers of one name is exactly the drift this
work set out to remove: the local copy is only ever consulted for
deployments named before this change, and keeping it fed means a rename
made on another device leaves a stale name behind in this browser.

Drop both writes. The configure session's hook now owns the name and
nothing else, and the rename dialog reports the api's answer without
mirroring it, which also takes the storage guard the mirror needed with
it.

The surfaces that still resolve a name from the local record alone — the
deployments list, home, alerts, billing usage and provider lease rows —
therefore show no name for a deployment created or renamed from here on,
until they read the api too.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@stalniy
stalniy requested a review from a team as a code owner September 11, 2026 20:26
@coderabbitai

coderabbitai Bot commented Sep 11, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

  • Run on-demand review

On-demand reviews are free for the next 9 days. After that, they cost $0.25 per reviewed file.

Or wait 59 minutes for your next included review.

Check out review usage here.

View limit details

Limit details: You’ve used all 2 included reviews currently available.

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Essentials

Run ID: 5d72c8b2-1087-47b9-b20d-c9e2dd6f9f7e

📥 Commits

Reviewing files that changed from the base of the PR and between 1f86aa2 and 4c5e997.

📒 Files selected for processing (5)
  • apps/deploy-web/src/components/LocalNoteManager/DeploymentNameModal.spec.tsx
  • apps/deploy-web/src/components/LocalNoteManager/DeploymentNameModal.tsx
  • apps/deploy-web/src/components/deployments/ConfigureDeployment/useDeploymentFlow/useDeploymentFlow.spec.tsx
  • apps/deploy-web/src/components/deployments/ConfigureDeployment/useDeploymentName/useDeploymentName.spec.tsx
  • apps/deploy-web/src/components/deployments/ConfigureDeployment/useDeploymentName/useDeploymentName.ts

Comment @coderabbitai help to get the list of available commands.

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Beyond the inline finding on the rename path, I checked the create path in useDeploymentFlow.ts (unchanged by this PR) for the same class of issue — it was already not writing name to local storage before this change, so the new guard test in useDeploymentFlow.spec.tsx is confirming existing behavior, not masking a second regression.

Extended reasoning...

The confirmed inline finding (stale local-storage name surviving a rename because the mirror write was removed without a corresponding invalidation/clear) is a real, plausible regression worth a human's attention, so I did not approve. I additionally traced the create-request path in useDeploymentFlow.ts, which this PR does not modify: cacheDeployedSdl only ever wrote owner/manifest, never name, so the newly added useDeploymentFlow.spec.tsx test is a regression guard against future reintroduction rather than evidence of an existing gap there. That check is narrow enough not to warrant restating in the inline findings, but is worth surfacing since it explains why only the rename path (not the create path) is implicated.

Comment on lines +76 to 79
onSuccess: function reportRenameSaved() {
queryClient.invalidateQueries({ queryKey: api.v1.getDeployment.getKey({ dseq: String(dseq) }) });
enqueueSnackbar(<Snackbar title="Success!" iconVariant="success" />, { variant: "success", autoHideDuration: 1000 });
onSaved();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Removing the localStorage mirror write on rename means any deployment that already has a name cached in this browser (named before this change, or by an earlier session) keeps its OLD name there forever after a rename — worse than the PR's documented 'shows no name' case, since the 5 surfaces reading only local storage (deployment list, home, alerts, billing, provider leases via useLocalNotes.getDeploymentName) now display a stale, incorrect name instead of nothing. Fix: either have those 5 surfaces read the API-resolved name (per the plan's slice 3) before this write is removed, or invalidate/clear the local record on successful rename so it falls back to null rather than a wrong value.

Extended reasoning...

User has an old deployment whose local record already has name:"old-name" (written by pre-slice-2 code). They open DeploymentNameModal, PATCH succeeds renaming it to "new-name" on the API; onSuccess (reportRenameSaved, lines 76-80) now only invalidates the getDeployment query and calls onSaved — it never touches deploymentLocalStorage. The local record's name field stays "old-name". DeploymentList.tsx, HomeContainer.tsx, AlertsListContainer.tsx, useAccountBalanceOverview.ts and LeaseRow.tsx all call useLocalNotes().getDeploymentName(dseq), which reads deploymentLocalStorage directly (useLocalNotes.ts lines 25-31) with no API fallback, so they keep showing "old-name" indefinitely even though the API and the rename dialog itself (via useResolvedDeploymentName) now show "new-name". The PR's own claim 'nothing here deletes them' undersells this: the record isn't deleted, but it silently diverges from the truth after every rename, which is a worse UX than the missing-name case the PR explicitly accepts.

Verification: normal — acknowledged in diff, but the note's claim does not hold for this subset. On the base branch, DeploymentNameModal's onSuccess ran recordNameInThisBrowser(name) -> deploymentLocalStorage.update(address, dseq, { name }), so renaming a deployment that already had a local record kept that record in sync; the five surfaces reading useLocalNotes.getDeploymentName (useLocalNotes.ts:27-28,…

@stalniy
stalniy added this pull request to stack #3930 September 12, 2026 01:28
@stalniy

stalniy commented Sep 12, 2026

Copy link
Copy Markdown
Contributor Author

@baktun14 this one has not been finished. it consists from 5 PRs but only 2 were implemented:

"slices": [
    {
      "name": "feat(deployment): prefer the api's deployment name over this browser's",
      "summary": "New useResolvedDeploymentName hook implementing the API-then-localStorage precedence over the getDeployment query useDeploymentDefinition already runs; useDeploymentDefinition delegates its `name` to it, so the detail header resolves through the API with no extra request. The bid screening field reads the resolved name once a dseq exists, which also fixes a session resumed in a browser that holds no draft. Covers the read half of AC1/AC2/AC3 on the detail page and the bid screening page.",
      "estimatedChanges": 270
    },
    {
      "name": "refactor(deployment): send deployment names to the api instead of localstorage",
      "summary": "requestQuotes carries the name into the createDeployment payload, omitting it when blank so the min(1) schema cannot 400 the create; ConfigureDeploymentHeader gains the name from the form and the auto flow keeps calling without one. useDeploymentName loses its localStorage write. The rename modal calls PATCH /v1/deployments/{dseq} with a bare name, invalidates the getDeployment key, requires a non-empty value and stops writing localStorage — covering both the detail-page pencil and the list row's Edit name item. The legacy /new-deployment flow is left untouched. Covers AC4.",
      "estimatedChanges": 390
    },
    {
      "name": "feat(deployment): list the names of a user's deployments",
      "summary": "apps/api gains a read-only GET /v1/deployment-names?skip&limit returning { dseq, name } for the authenticated user, built on a findNamesByUserId sibling to the existing findNamesByDseqs and CASL-filtered the same way. Regenerates apps/api/swagger/openapi.json and the console-api-types schema and operations table. The enabler for the list and the four secondary surfaces, since no existing endpoint returns names in bulk for closed deployments.",
      "estimatedChanges": 450
    },
    {
      "name": "feat(deployment): show the api's deployment name in the deployments list",
      "summary": "New useDeploymentNames hook over the names endpoint, keyed per address, resolving API name then localStorage name; DeploymentList maps its rows through it so both the Active and Closed tabs and the search-by-name path use API names. Completes AC1/AC2/AC3 on the deployments list.",
      "estimatedChanges": 210
    },
    {
      "name": "feat(deployment): show the api's deployment name on home, alerts, billing and lease rows",
      "summary": "Moves the four surfaces that read useLocalNotes.getDeploymentName only — home active deployments, alert rows, the billing usage breakdown and provider lease rows — onto the same batch names hook, each keeping its own placeholder. Required rather than optional: without it, every deployment created after slice 2 goes permanently unnamed on those screens.",
      "estimatedChanges": 200
    }
    ```

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant