Skip to content
Draft
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
145 changes: 74 additions & 71 deletions apps/web/src/components/sidebar/SidebarProviderUpdatePill.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,85 +38,91 @@ function latestProviderCheckedAt(
);
}

export function SidebarProviderUpdatePill() {
const navigate = useNavigate();
const providers = useAtomValue(primaryServerProvidersAtom);
const [dismissedKeys, setDismissedKeys] = useState<ReadonlySet<string>>(() => new Set());
const [renderedView, setRenderedView] = useState<ProviderUpdateSidebarPillView | null>(null);
const [pendingView, setPendingView] = useState<ProviderUpdateSidebarPillView | null>(null);
const [exitingKey, setExitingKey] = useState<string | null>(null);
const [dismissAfterExitKey, setDismissAfterExitKey] = useState<string | null>(null);
const [visibleAfterIso, setVisibleAfterIso] = useState<string | undefined>();
const effectiveVisibleAfterIso = visibleAfterIso ?? latestProviderCheckedAt(providers);
const view = getProviderUpdateSidebarPillView(providers, {
...(effectiveVisibleAfterIso !== undefined
? { visibleAfterIso: effectiveVisibleAfterIso }
: {}),
dismissedKeys,
});
interface SidebarProviderUpdatePillContentProps {
readonly providers: ReadonlyArray<ServerProvider>;
readonly onOpenProviderSettings: () => void;
}

useEffect(() => {
if (visibleAfterIso === undefined && effectiveVisibleAfterIso !== undefined) {
setVisibleAfterIso(effectiveVisibleAfterIso);
}
}, [effectiveVisibleAfterIso, visibleAfterIso]);
interface ProviderUpdatePillExitState {
readonly view: ProviderUpdateSidebarPillView;
readonly dismissAfterExitKey: string | null;
}

const openProviderSettings = useCallback(() => {
void navigate({ to: "/settings/providers" });
}, [navigate]);
const displayedView = renderedView ?? view;
function useProviderUpdatePillAutoDismiss({
displayedView,
exitingKey,
startExit,
}: {
readonly displayedView: ProviderUpdateSidebarPillView | null;
readonly exitingKey: string | null;
readonly startExit: (view: ProviderUpdateSidebarPillView, dismissKey?: string) => void;
}) {
const dismissAfterVisibleMs = displayedView?.dismissAfterVisibleMs;
const viewKey = displayedView?.key ?? null;
const showDismissProgress =
dismissAfterVisibleMs !== undefined &&
displayedView?.tone !== "loading" &&
exitingKey !== viewKey;

const startExit = useCallback(
(key: string, nextView: ProviderUpdateSidebarPillView | null, dismissKey?: string) => {
if (exitingKey === key) {
return;
}
setPendingView(nextView);
setExitingKey(key);
setDismissAfterExitKey(dismissKey ?? null);
},
[exitingKey],
);

useEffect(() => {
if (exitingKey !== null) {
return;
}
if (!renderedView) {
if (view) {
setRenderedView(view);
}
return;
}
if (!view) {
startExit(renderedView.key, null);
return;
}
if (view.key !== renderedView.key) {
startExit(renderedView.key, view);
return;
}
}, [exitingKey, renderedView, startExit, view]);

useEffect(() => {
if (!dismissAfterVisibleMs || !viewKey) {
if (!displayedView || !dismissAfterVisibleMs || !viewKey) {
return;
}
if (exitingKey === viewKey) {
return;
}
const timeoutId = window.setTimeout(() => {
startExit(viewKey, null, viewKey);
startExit(displayedView, viewKey);
}, dismissAfterVisibleMs);

return () => window.clearTimeout(timeoutId);
}, [dismissAfterVisibleMs, exitingKey, startExit, viewKey]);
}, [dismissAfterVisibleMs, displayedView, exitingKey, startExit, viewKey]);
Comment on lines 63 to +75

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Medium sidebar/SidebarProviderUpdatePill.tsx:63

The auto-dismiss timeout resets on every rerender because useProviderUpdatePillAutoDismiss now depends on the displayedView object reference, and getProviderUpdateSidebarPillView returns a fresh object each render even when the view key is unchanged. Any unrelated rerender during the dismiss window clears and restarts the timer. With repeated rerenders the success notification can stay visible indefinitely instead of dismissing after dismissAfterVisibleMs. The previous implementation relied on a stored renderedView that remained referentially stable for an unchanged key. Consider keying the effect on viewKey (and dismissAfterVisibleMs) rather than the whole view object, or memoizing the view by key so the reference stays stable.

  useEffect(() => {
    if (!displayedView || !dismissAfterVisibleMs || !viewKey) {
      return;
    }
    if (exitingKey === viewKey) {
      return;
    }
-    const displayedViewRef = displayedView;
    const timeoutId = window.setTimeout(() => {
-      startExit(displayedView, viewKey);
+      startExit(displayedViewRef, viewKey);
    }, dismissAfterVisibleMs);
 
    return () => window.clearTimeout(timeoutId);
-  }, [dismissAfterVisibleMs, displayedView, exitingKey, startExit, viewKey]);
+  }, [dismissAfterVisibleMs, viewKey, exitingKey, startExit]);
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/web/src/components/sidebar/SidebarProviderUpdatePill.tsx around lines 63-75:

The auto-dismiss timeout resets on every rerender because `useProviderUpdatePillAutoDismiss` now depends on the `displayedView` object reference, and `getProviderUpdateSidebarPillView` returns a fresh object each render even when the view `key` is unchanged. Any unrelated rerender during the dismiss window clears and restarts the timer. With repeated rerenders the success notification can stay visible indefinitely instead of dismissing after `dismissAfterVisibleMs`. The previous implementation relied on a stored `renderedView` that remained referentially stable for an unchanged key. Consider keying the effect on `viewKey` (and `dismissAfterVisibleMs`) rather than the whole view object, or memoizing the view by key so the reference stays stable.

}

export function SidebarProviderUpdatePill() {
const navigate = useNavigate();
const providers = useAtomValue(primaryServerProvidersAtom);
const openProviderSettings = useCallback(() => {
void navigate({ to: "/settings/providers" });
}, [navigate]);

if (providers.length === 0) {
return null;
}

return (
<SidebarProviderUpdatePillContent
providers={providers}
onOpenProviderSettings={openProviderSettings}
/>
);
}

export function SidebarProviderUpdatePillContent({
providers,
onOpenProviderSettings,
}: SidebarProviderUpdatePillContentProps) {
const [dismissedKeys, setDismissedKeys] = useState<ReadonlySet<string>>(() => new Set());
const [visibleAfterIso] = useState(() => latestProviderCheckedAt(providers));
const view = getProviderUpdateSidebarPillView(providers, {
...(visibleAfterIso !== undefined ? { visibleAfterIso } : {}),
dismissedKeys,
});
const [exitState, setExitState] = useState<ProviderUpdatePillExitState | null>(null);
const displayedView = exitState?.view ?? view;
const exitingKey = exitState?.view.key ?? null;
const dismissAfterVisibleMs = displayedView?.dismissAfterVisibleMs;
const viewKey = displayedView?.key ?? null;
const showDismissProgress =
dismissAfterVisibleMs !== undefined &&
displayedView?.tone !== "loading" &&
exitingKey !== viewKey;

const startExit = useCallback((exitView: ProviderUpdateSidebarPillView, dismissKey?: string) => {
setExitState((current) =>
current?.view.key === exitView.key
? current
: { view: exitView, dismissAfterExitKey: dismissKey ?? null },
);
}, []);

useProviderUpdatePillAutoDismiss({ displayedView, exitingKey, startExit });

if (!displayedView) {
return null;
Expand All @@ -138,13 +144,10 @@ export function SidebarProviderUpdatePill() {
if (!displayedView || exitingKey !== displayedView.key) {
return;
}
if (dismissAfterExitKey === displayedView.key) {
if (exitState?.dismissAfterExitKey === displayedView.key) {
setDismissedKeys((previous) => new Set(previous).add(displayedView.key));
}
setRenderedView(pendingView);
setPendingView(null);
setExitingKey(null);
setDismissAfterExitKey(null);
setExitState(null);
}}
>
{showDismissProgress ? (
Expand All @@ -169,7 +172,7 @@ export function SidebarProviderUpdatePill() {
type="button"
aria-label={displayedView.description}
className="provider-update-main relative z-[1] flex h-full flex-1 items-center gap-2 px-2 text-left"
onClick={openProviderSettings}
onClick={onOpenProviderSettings}
>
{displayedView.tone === "loading" ? (
<LoaderIcon className="size-3.5 animate-spin" />
Expand All @@ -194,7 +197,7 @@ export function SidebarProviderUpdatePill() {
type="button"
aria-label="Dismiss provider update notice"
className="relative z-[1] mr-1 inline-flex size-5 items-center justify-center rounded-md opacity-70 transition-opacity hover:opacity-100"
onClick={() => startExit(displayedView.key, null, displayedView.key)}
onClick={() => startExit(displayedView, displayedView.key)}
>
<XIcon className="size-3.5" />
</button>
Expand Down
Loading