diff --git a/frontend/src/app/site-finder/analysis/[cad]/__tests__/AnalysisPageContent.remount.test.tsx b/frontend/src/app/site-finder/analysis/[cad]/__tests__/AnalysisPageContent.remount.test.tsx
new file mode 100644
index 00000000..7a04bc53
--- /dev/null
+++ b/frontend/src/app/site-finder/analysis/[cad]/__tests__/AnalysisPageContent.remount.test.tsx
@@ -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 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(
+ "@/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(null);
+ return (
+
+
+
{concept ?? "empty"}
+
+ );
+ },
+ };
+});
+
+// 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(
+
+
+ ,
+ );
+}
+
+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(
+
+
+ ,
+ );
+
+ 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(
+
+
+ ,
+ );
+ 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(
+
+
+ ,
+ );
+
+ 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(
+
+
+ ,
+ );
+
+ expect(screen.getByTestId("concept-state").textContent).toBe("empty");
+ });
+});
diff --git a/frontend/src/app/site-finder/analysis/[cad]/page.tsx b/frontend/src/app/site-finder/analysis/[cad]/page.tsx
index 6a82c8da..7b0e73f7 100644
--- a/frontend/src/app/site-finder/analysis/[cad]/page.tsx
+++ b/frontend/src/app/site-finder/analysis/[cad]/page.tsx
@@ -47,7 +47,19 @@ export default async function AnalysisPage({ params }: PageProps) {
}
>
-
+ {/* 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). */}
+
);
}