fix(site-finder): remount analysis tree on cad change (#2445-B4)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 7s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Successful in 1m0s
CI / openapi-codegen-check (pull_request) Successful in 1m59s

AnalysisPageContent не имел key, привязанного к cad, поэтому клиентская
навигация между участками под тем же [cad]-роутом переиспользовала инстанс —
результат useCreateConcept §7, выбранный конкурент/фильтры §3 и thread
ChatPanel утекали от предыдущего участка. key={cad} форсит чистый remount;
cad-keyed TanStack-данные не затронуты (перечитываются из кэша, без refetch-
водопада). Паттерн уже используется в compare/page.tsx.

Усилен regression-тест 2: rerender() с изменённым key (пиннит сам механизм)
вместо unmount()+render() (сбрасывал бы state безусловно).

Refs #2445
This commit is contained in:
bot-backend 2026-07-06 00:11:25 +05:00
parent 82e0b8b0a3
commit 3711a960bf
2 changed files with 185 additions and 1 deletions

View file

@ -0,0 +1,172 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { describe, expect, it, vi } from "vitest";
import { AnalysisPageContent } from "../AnalysisPageContent";
// ── Regression test: epic #2445 item B4 ──────────────────────────────────────
//
// Bug: AnalysisPageContent (and its §7 Section7Concept child) was rendered
// without a React `key` tied to `cad`. Client-side nav between two different
// parcels under the SAME /site-finder/analysis/[cad] route template (e.g.
// RecentParcels / CompareTable → plain next/link to a different cad) reused
// the same component instance — instance-local state (§7's useCreateConcept
// mutation result, which has no cache key: it lives only in
// concept.data/isSuccess/isError) kept showing the OLD parcel's computed
// concept until the user manually re-ran it.
//
// Fix: page.tsx now renders <AnalysisPageContent key={cad} cad={cad} /> so a
// parcel change forces a clean remount. This test exercises that exact
// pattern (rendering AnalysisPageContent keyed by cad, same as page.tsx) and
// asserts:
// 1. Same cad, re-render → local state in a §7-like child is PRESERVED
// (normal interaction, e.g. horizon change, must not lose progress).
// 2. Different cad → local state RESETS (remount), instead of leaking the
// previous parcel's concept into the new parcel's view.
//
// The real §7Concept/useCreateConcept + all sibling sections pull in Leaflet/
// ECharts/TanStack Query fetches — irrelevant to the remount contract under
// test — so every section is mocked with a light stub. The Section7Concept
// stub mirrors the real bug shape: a plain useState "concept result" with NO
// cad-keyed cache, set via a button click (stand-in for the mutation firing).
vi.mock("@/lib/site-finder-api", async () => {
const actual = await vi.importActual<typeof import("@/lib/site-finder-api")>(
"@/lib/site-finder-api",
);
return {
...actual,
useParcelAnalyzeQuery: (cad: string) => ({
data: { district: null, egrn: null, gate_verdict: null, cad },
isLoading: false,
isFetching: false,
error: null,
}),
};
});
vi.mock("@/components/site-finder/ChatDock", () => ({
ChatDock: () => null,
}));
vi.mock("@/components/site-finder/GateVerdictBanner", () => ({
GateVerdictBanner: () => null,
}));
vi.mock("@/components/site-finder/HorizonSelector", () => ({
HorizonSelector: () => null,
}));
vi.mock("@/components/site-finder/analysis/Section1ParcelInfo", () => ({
Section1ParcelInfo: () => null,
}));
vi.mock("@/components/site-finder/analysis/Section2NetworksUtilities", () => ({
Section2NetworksUtilities: () => null,
}));
vi.mock(
"@/components/site-finder/analysis/Section3SettingsAndCompetitors",
() => ({ Section3SettingsAndCompetitors: () => null }),
);
vi.mock("@/components/site-finder/analysis/Section4Estimate", () => ({
Section4Estimate: () => null,
}));
vi.mock("@/components/site-finder/analysis/Section5Atmosphere", () => ({
Section5Atmosphere: () => null,
}));
vi.mock("@/components/site-finder/analysis/Section6Forecast", () => ({
Section6Forecast: () => null,
}));
vi.mock("@/components/site-finder/analysis/SectionAlternatives", () => ({
SectionAlternatives: () => null,
}));
// Stub §7: a plain useState "concept" with no cad-keyed cache — the exact
// shape of the real useCreateConcept()-backed bug. A button click simulates
// the mutation firing and settling.
vi.mock("@/components/site-finder/analysis/Section7Concept", () => {
const React = require("react") as typeof import("react");
return {
Section7Concept: () => {
const [concept, setConcept] = React.useState<string | null>(null);
return (
<div>
<button type="button" onClick={() => setConcept("computed-concept")}>
Сгенерировать концепцию
</button>
<div data-testid="concept-state">{concept ?? "empty"}</div>
</div>
);
},
};
});
// AnalysisPageContent calls useQueryClient() directly (horizon-change
// invalidation) even though useParcelAnalyzeQuery is mocked above, so every
// render needs a real QueryClientProvider ancestor.
function renderKeyed(cad: string) {
const client = new QueryClient();
return render(
<QueryClientProvider client={client}>
<AnalysisPageContent key={cad} cad={cad} />
</QueryClientProvider>,
);
}
describe("AnalysisPageContent — key={cad} remount contract (#2445 B4)", () => {
it("preserves §7-like local state across a re-render with the SAME cad", () => {
const client = new QueryClient();
const cad = "66:41:0204016:10";
const { rerender } = render(
<QueryClientProvider client={client}>
<AnalysisPageContent key={cad} cad={cad} />
</QueryClientProvider>,
);
fireEvent.click(
screen.getByRole("button", { name: "Сгенерировать концепцию" }),
);
expect(screen.getByTestId("concept-state").textContent).toBe(
"computed-concept",
);
// Same cad → same key → React must NOT remount; in-progress state stays
// (e.g. a horizon change re-renders the same tree — must not lose it).
rerender(
<QueryClientProvider client={client}>
<AnalysisPageContent key={cad} cad={cad} />
</QueryClientProvider>,
);
expect(screen.getByTestId("concept-state").textContent).toBe(
"computed-concept",
);
});
it("resets §7-like local state when navigating to a DIFFERENT cad", () => {
// Single render tree that we rerender() with a CHANGED key — this pins the
// `key={cad}` mechanism itself. A fresh unmount()+render() would reset state
// unconditionally (even with no key), so it would pass on a reverted fix and
// wouldn't guard the regression. With rerender() + changed key, React keeps
// the tree mounted and only remounts because the key differs — exactly what
// page.tsx's `key={cad}` does on client-side nav. Drop the key in page.tsx
// and the OLD instance is reused, leaking "computed-concept" → this fails.
const client = new QueryClient();
const { rerender } = render(
<QueryClientProvider client={client}>
<AnalysisPageContent key="66:41:0204016:10" cad="66:41:0204016:10" />
</QueryClientProvider>,
);
fireEvent.click(
screen.getByRole("button", { name: "Сгенерировать концепцию" }),
);
expect(screen.getByTestId("concept-state").textContent).toBe(
"computed-concept",
);
// Client-side nav to a different parcel: same tree position, different key.
rerender(
<QueryClientProvider client={client}>
<AnalysisPageContent key="66:41:0702033:20" cad="66:41:0702033:20" />
</QueryClientProvider>,
);
expect(screen.getByTestId("concept-state").textContent).toBe("empty");
});
});

View file

@ -47,7 +47,19 @@ export default async function AnalysisPage({ params }: PageProps) {
</div>
}
>
<AnalysisPageContent cad={cad} />
{/* key={cad}: client-side nav between two parcels under this same route
template (RecentParcels / CompareTable link straight to /analysis/
CAD_B) does NOT remount by default App Router reuses the instance,
so instance-local state (§7's useCreateConcept mutation result +
autoRun refs, §3's selected competitor/filters, the chat thread in
ChatDock/ChatPanel) would keep showing the OLD parcel's values until
manually re-run/reselected. Forcing a remount on cad change resets
all of it cleanly. Queries under this tree (parcel-analyze,
parcel-forecast, ) are TanStack Query and keyed by cad, so a
remount just re-subscribes to the (already cached) query result
no extra fetch waterfall. Same cad same key React does NOT
remount (a normal re-render, e.g. horizon change, keeps state). */}
<AnalysisPageContent key={cad} cad={cad} />
</Suspense>
);
}