From 3711a960bf875a42a74686168eb1ddfcd4a723e0 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Mon, 6 Jul 2026 00:11:25 +0500 Subject: [PATCH] fix(site-finder): remount analysis tree on cad change (#2445-B4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../AnalysisPageContent.remount.test.tsx | 172 ++++++++++++++++++ .../app/site-finder/analysis/[cad]/page.tsx | 14 +- 2 files changed, 185 insertions(+), 1 deletion(-) create mode 100644 frontend/src/app/site-finder/analysis/[cad]/__tests__/AnalysisPageContent.remount.test.tsx 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). */} + ); } -- 2.45.3