fix(web): stop runaway deploy-success loop in DeployWizard

After a successful deploy the wizard hammered GET /deckies and stacked
"DEPLOYED" toasts unbounded (~1.4/s, forever). Two compounding causes:

1. The auto-close effect's dep array includes onComplete, which the parent
   passes as an inline arrow (new reference every render). onComplete calls
   refresh(), re-rendering the parent, producing a new onComplete ref,
   re-running the effect, which reschedules onComplete — a feedback loop.
2. DeployWizard stayed permanently mounted (open only toggled a child
   overlay), so its hooks kept running with lifecycleDone===true after close.

Fix both: a completedRef guard makes the auto-close fire exactly once per
deploy regardless of effect re-runs (reset in startDeploy), and the parent
now mounts the wizard only while open so closing tears down its hooks and
clears the latent "reopen re-completes stale rows" path.

Lifecycle polling itself was never the runaway — it stops cleanly at
terminal status; the bounded ~25-poll build window is expected.

Adds a regression test asserting onComplete fires once across a simulated
re-render storm.
This commit is contained in:
2026-06-13 00:19:39 -04:00
parent ab1151ee7f
commit 2a9d1989b6
3 changed files with 80 additions and 19 deletions

View File

@@ -237,21 +237,27 @@ const DeckyFleet: React.FC<FleetProps> = ({ searchQuery = '' }) => {
)}
</div>
<DeployWizard
open={showDeploy}
archetypes={archetypes}
fleetSize={deckies.length}
onClose={() => setShowDeploy(false)}
onComplete={(count) => {
setShowDeploy(false);
void refresh();
push({
text: `DEPLOYED · ${count} DECK${count === 1 ? 'Y' : 'IES'}`,
tone: 'matrix',
icon: 'check-circle',
});
}}
/>
{/* Mounted only while open so closing tears down the wizard's hooks
(lifecycle polling, the auto-close effect). Leaving it permanently
mounted kept those effects alive after close and, combined with the
inline onComplete below, drove a runaway /deckies + toast loop. */}
{showDeploy && (
<DeployWizard
open={showDeploy}
archetypes={archetypes}
fleetSize={deckies.length}
onClose={() => setShowDeploy(false)}
onComplete={(count) => {
setShowDeploy(false);
void refresh();
push({
text: `DEPLOYED · ${count} DECK${count === 1 ? 'Y' : 'IES'}`,
tone: 'matrix',
icon: 'check-circle',
});
}}
/>
)}
<IntervalEditor
key={intervalEditor?.name ?? 'closed'}

View File

@@ -1,10 +1,15 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import { describe, it, expect, vi, type Mock } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { DeployWizard } from './DeployWizard';
import api from '../../utils/api';
import type { Archetype } from './types';
vi.mock('../../utils/api', () => ({
default: { post: vi.fn(), get: vi.fn() },
}));
// ServiceConfigFields fetches the per-service schema; replace with a stub
// so the wizard tests don't need MSW handlers for that side-channel.
vi.mock('../ServiceConfigFields', async () => {
@@ -82,4 +87,45 @@ describe('DeployWizard', () => {
await user.click(screen.getByText('CANCEL'));
expect(onClose).toHaveBeenCalled();
});
it('fires onComplete exactly once after a successful deploy, even across re-renders', async () => {
// Regression: onComplete is an inline arrow in the parent (new ref every
// render) and it triggers a parent refresh -> re-render. Without the
// completedRef guard the auto-close effect re-ran on every re-render and
// rescheduled onComplete forever (runaway /deckies + toast loop).
(api.post as Mock).mockResolvedValue({
data: { lifecycle_ids: ['lc-1'], message: 'ok', mode: 'unihost' },
});
(api.get as Mock).mockResolvedValue({
data: { rows: [{
id: 'lc-1', decky_name: 'qa-01', host_uuid: null, operation: 'deploy',
status: 'succeeded', error: null,
started_at: '2026-01-01T00:00:00', updated_at: '2026-01-01T00:00:00',
completed_at: '2026-01-01T00:00:00',
}] },
});
const onComplete = vi.fn();
const user = userEvent.setup();
const props = { open: true, onClose: () => {}, archetypes, fleetSize: 0 };
// Fresh arrow each render mirrors the parent's unstable onComplete ref.
const { rerender } = render(
<DeployWizard {...props} onComplete={() => onComplete()} />,
);
await user.click(screen.getByText('Web Server'));
await user.click(screen.getByText('NEXT →'));
await user.click(screen.getByText('NEXT →'));
await user.click(screen.getByText('NEXT →'));
await user.click(screen.getByText('ESTABLISH FLEET'));
await waitFor(() => expect(onComplete).toHaveBeenCalledTimes(1), { timeout: 3000 });
// Simulate the parent re-render storm with fresh onComplete refs.
for (let i = 0; i < 5; i++) {
rerender(<DeployWizard {...props} onComplete={() => onComplete()} />);
}
await new Promise((r) => setTimeout(r, 1200));
expect(onComplete).toHaveBeenCalledTimes(1);
});
});

View File

@@ -1,5 +1,5 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import React, { useEffect, useMemo, useState } from 'react';
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { PlusCircle } from '../../icons';
import { useLifecyclePolling } from '../../hooks/useLifecyclePolling';
import api from '../../utils/api';
@@ -197,11 +197,19 @@ export const DeployWizard: React.FC<Props> = ({
);
const deployOk = lifecycleDone && deployFailures.length === 0;
// Fire onComplete exactly once per deploy. onComplete is an inline arrow in
// the parent (new ref every render) and it triggers a parent refresh, so
// without this guard the effect re-runs on every re-render and reschedules
// onComplete forever — a runaway loop of /deckies refetches + toasts. Reset
// in startDeploy so a subsequent deploy can complete again.
const completedRef = useRef(false);
// When every row reaches terminal status, auto-close on full success
// (or stay open so the operator can read failures).
useEffect(() => {
if (!lifecycleDone) return;
if (!lifecycleDone || completedRef.current) return;
if (deployFailures.length === 0) {
completedRef.current = true;
const t = window.setTimeout(() => onComplete(count), 700);
return () => window.clearTimeout(t);
}
@@ -215,6 +223,7 @@ export const DeployWizard: React.FC<Props> = ({
setDeployErr(null);
setLog([]);
setLifecycleIds([]);
completedRef.current = false;
setDeploying(true);
// Roll the per-service forms into the compact payload the server
// expects — empty values dropped, types coerced where the schema