URL surface parity with Angular for /popular and start-page prefill

- Drop the React-only standalone /popular route (and its e2e
  smoketest). Angular returns 404 for /ru-ru/popular; popular
  requests are surfaced inline on onlineboard/schedule start pages
  via PopularRequestsPanel (which stays). Matching the URL surface
  is a contractual requirement for the MF remote.
- Replace ?tab/?departure/?arrival/?return query-string prefill on
  the onlineboard and schedule start pages with a sessionStorage
  transient slot. Mirrors Angular's OnlineBoardFiltersStateService /
  ScheduleFiltersStateService cross-page singletons: URLs stay
  clean of query strings, the start-page form still seeds itself
  from a popular-request click, and a fresh page reload (which
  bypasses the in-memory state in Angular) lands on a pristine form.
  Same-page popular clicks remount the filter via key bump so the
  useState initializers pick up the new prefill.
This commit is contained in:
2026-04-19 16:51:31 +03:00
parent b63fd8fb6b
commit b8e595dc25
8 changed files with 211 additions and 324 deletions
@@ -9,16 +9,14 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import { OnlineBoardStartPage, buildPopularRequestQueryParams } from "./OnlineBoardStartPage.js";
import { OnlineBoardStartPage, buildOnlineBoardPrefillState } from "./OnlineBoardStartPage.js";
import type { PopularRequest } from "@/features/popular-requests/types.js";
const mockNavigate = vi.fn();
const mockSearchParams = new URLSearchParams();
vi.mock("@modern-js/runtime/router", () => ({
useNavigate: () => mockNavigate,
useParams: () => ({ lang: "ru" }),
useSearchParams: () => [mockSearchParams],
Link: ({ children, to, ...props }: { children: React.ReactNode; to: string; className?: string; [k: string]: unknown }) => (
<a href={to} {...props}>{children}</a>
),
@@ -65,88 +63,52 @@ vi.mock("@/ui/city-autocomplete/index.js", () => ({
}));
// ---------------------------------------------------------------------------
// Pure function: buildPopularRequestQueryParams
// Pure function: buildOnlineBoardPrefillState
// ---------------------------------------------------------------------------
describe("buildPopularRequestQueryParams", () => {
it("builds params for FlightNumber mode", () => {
describe("buildOnlineBoardPrefillState", () => {
it("builds state for FlightNumber mode", () => {
const request: PopularRequest = {
mode: "FlightNumber",
carrier: "SU",
flightNumber: "0654",
type: "Onlineboard",
};
const params = buildPopularRequestQueryParams(request);
expect(params.get("tab")).toBe("flight");
expect(params.get("carrier")).toBe("SU");
expect(params.get("flight")).toBe("0654");
expect(params.has("departure")).toBe(false);
expect(params.has("arrival")).toBe(false);
expect(buildOnlineBoardPrefillState(request)).toEqual({
tab: "flight",
flightNumber: "SU0654",
});
});
it("builds params for Departure mode", () => {
const request: PopularRequest = {
mode: "Departure",
departure: "LED",
type: "Onlineboard",
};
const params = buildPopularRequestQueryParams(request);
expect(params.get("tab")).toBe("route");
expect(params.get("departure")).toBe("LED");
expect(params.has("arrival")).toBe(false);
expect(params.has("carrier")).toBe(false);
it("builds state for Departure mode", () => {
expect(
buildOnlineBoardPrefillState({
mode: "Departure",
departure: "LED",
type: "Onlineboard",
}),
).toEqual({ tab: "route", departure: "LED" });
});
it("builds params for Arrival mode", () => {
const request: PopularRequest = {
mode: "Arrival",
arrival: "VKO",
type: "Onlineboard",
};
const params = buildPopularRequestQueryParams(request);
expect(params.get("tab")).toBe("route");
expect(params.get("arrival")).toBe("VKO");
expect(params.has("departure")).toBe(false);
it("builds state for Arrival mode", () => {
expect(
buildOnlineBoardPrefillState({
mode: "Arrival",
arrival: "VKO",
type: "Onlineboard",
}),
).toEqual({ tab: "route", arrival: "VKO" });
});
it("builds params for Route mode (Onlineboard)", () => {
const request: PopularRequest = {
mode: "Route",
departure: "LED",
arrival: "KRR",
type: "Onlineboard",
};
const params = buildPopularRequestQueryParams(request);
expect(params.get("tab")).toBe("route");
expect(params.get("departure")).toBe("LED");
expect(params.get("arrival")).toBe("KRR");
expect(params.has("return")).toBe(false);
});
it("builds params for Route mode (Schedule)", () => {
const request: PopularRequest = {
mode: "Route",
departure: "SVO",
arrival: "LED",
type: "Schedule",
};
const params = buildPopularRequestQueryParams(request);
expect(params.get("departure")).toBe("SVO");
expect(params.get("arrival")).toBe("LED");
expect(params.has("return")).toBe(false);
});
it("builds params for RouteWithBack mode (Schedule) with return flag", () => {
const request: PopularRequest = {
mode: "RouteWithBack",
departure: "SVO",
arrival: "LED",
type: "Schedule",
};
const params = buildPopularRequestQueryParams(request);
expect(params.get("departure")).toBe("SVO");
expect(params.get("arrival")).toBe("LED");
expect(params.get("return")).toBe("true");
it("builds state for Route mode", () => {
expect(
buildOnlineBoardPrefillState({
mode: "Route",
departure: "LED",
arrival: "KRR",
type: "Onlineboard",
}),
).toEqual({ tab: "route", departure: "LED", arrival: "KRR" });
});
});
@@ -211,14 +173,11 @@ describe("OnlineBoardStartPage", () => {
expect(screen.getByTestId("feedback-button")).toBeTruthy();
});
it("navigates on popular request click (Onlineboard type)", () => {
it("does not navigate on same-page popular click (Onlineboard type)", () => {
render(<OnlineBoardStartPage />);
const btn = screen.getByTestId("popular-click");
fireEvent.click(btn);
expect(mockNavigate).toHaveBeenCalledWith(
expect.objectContaining({
search: expect.stringContaining("tab=flight"),
}),
);
// Same-page click — the filter remounts via key bump, no nav.
expect(mockNavigate).not.toHaveBeenCalled();
});
});
@@ -12,8 +12,8 @@
* @module
*/
import { type FC, useCallback, useMemo } from "react";
import { useNavigate, useParams, useSearchParams } from "@modern-js/runtime/router";
import { type FC, useCallback, useState } from "react";
import { useNavigate, useParams } from "@modern-js/runtime/router";
import { useTranslation } from "@/i18n/provider.js";
import { PageLayout } from "@/ui/layout/PageLayout.js";
import { PageTabs } from "@/ui/layout/PageTabs.js";
@@ -21,47 +21,52 @@ import { SearchHistory } from "@/ui/layout/SearchHistory.js";
import { OnlineBoardFilter } from "./OnlineBoardFilter.js";
import { PopularRequestsPanel } from "@/features/popular-requests/components/PopularRequestsPanel.js";
import type { PopularRequest } from "@/features/popular-requests/types.js";
import {
readAndClearTransientPrefill,
writeTransientPrefill,
} from "@/shared/state/transientPrefill.js";
import "./OnlineBoardStartPage.scss";
/**
* Build URL search params for a given popular request.
*
* Schedule-type requests produce params for the schedule page;
* Onlineboard-type requests produce params for the online board filter.
*/
export function buildPopularRequestQueryParams(
request: PopularRequest,
): URLSearchParams {
const params = new URLSearchParams();
export const ONLINE_BOARD_PREFILL_SLOT = "online-board";
export const SCHEDULE_PREFILL_SLOT = "schedule";
/**
* Transient prefill state handed from a popular-request click to the
* start page form. Mirrors Angular's `OnlineBoardFiltersStateService`
* cross-page singleton: URLs stay clean of query strings while the
* start-page form still seeds itself from the click. Held in
* sessionStorage and read-then-cleared on mount, so a fresh tab or
* page reload renders an empty form (matching Angular's behavior of a
* pristine service after navigation).
*/
export interface OnlineBoardPrefillState {
tab?: "flight" | "route";
departure?: string;
arrival?: string;
flightNumber?: string;
}
export function buildOnlineBoardPrefillState(
request: PopularRequest,
): OnlineBoardPrefillState {
switch (request.mode) {
case "FlightNumber":
params.set("tab", "flight");
params.set("carrier", request.carrier);
params.set("flight", request.flightNumber);
break;
return {
tab: "flight",
flightNumber: `${request.carrier}${request.flightNumber}`,
};
case "Departure":
params.set("tab", "route");
params.set("departure", request.departure);
break;
return { tab: "route", departure: request.departure };
case "Arrival":
params.set("tab", "route");
params.set("arrival", request.arrival);
break;
return { tab: "route", arrival: request.arrival };
case "Route":
case "RouteWithBack":
if (request.type === "Onlineboard") {
params.set("tab", "route");
}
params.set("departure", request.departure);
params.set("arrival", request.arrival);
if (request.mode === "RouteWithBack" && request.type === "Schedule") {
params.set("return", "true");
}
break;
return {
tab: "route",
departure: request.departure,
arrival: request.arrival,
};
}
return params;
}
export const OnlineBoardStartPage: FC = () => {
@@ -69,53 +74,49 @@ export const OnlineBoardStartPage: FC = () => {
const navigate = useNavigate();
const routeParams = useParams<{ lang: string }>();
const lang = routeParams.lang ?? "ru";
const [searchParams] = useSearchParams();
/** Derive initial filter props from current URL query params */
const filterInitialProps = useMemo(() => {
const tab = searchParams.get("tab");
const departure = searchParams.get("departure");
const arrival = searchParams.get("arrival");
const carrier = searchParams.get("carrier");
const flight = searchParams.get("flight");
const date = searchParams.get("date");
// Read-and-clear any prefill the previous page wrote. Stored in
// useState (with a one-shot initializer) so React strict mode's
// double-render doesn't lose the value on the second pass.
const [prefill, setPrefill] = useState<OnlineBoardPrefillState>(
() =>
readAndClearTransientPrefill<OnlineBoardPrefillState>(
ONLINE_BOARD_PREFILL_SLOT,
) ?? {},
);
// Same-page popular clicks need to re-mount the filter so its
// useState initial values pick up the new prefill. Key bump does it.
const [filterKey, setFilterKey] = useState(0);
// No query params — use defaults
if (!tab && !departure && !arrival && !carrier && !flight) {
return {};
}
return {
...(tab === "flight" || tab === "route"
? { initialTab: tab as "flight" | "route" }
: {}),
...(departure ? { initialDeparture: departure } : {}),
...(arrival ? { initialArrival: arrival } : {}),
...(date ? { initialDate: date } : {}),
...(carrier && flight
? { initialFlightNumber: `${carrier}${flight}` }
: {}),
};
}, [searchParams]);
const filterInitialProps = {
...(prefill.tab ? { initialTab: prefill.tab } : {}),
...(prefill.departure ? { initialDeparture: prefill.departure } : {}),
...(prefill.arrival ? { initialArrival: prefill.arrival } : {}),
...(prefill.flightNumber ? { initialFlightNumber: prefill.flightNumber } : {}),
};
const handlePopularRequestClick = useCallback(
(request: PopularRequest) => {
const params = buildPopularRequestQueryParams(request);
// Schedule-type requests navigate to the schedule feature
if (request.type === "Schedule") {
navigate({
pathname: `/${lang}/schedule`,
search: params.toString(),
});
const state =
request.mode === "Route" || request.mode === "RouteWithBack"
? {
departure: request.departure,
arrival: request.arrival,
withReturn: request.mode === "RouteWithBack",
}
: {};
writeTransientPrefill(SCHEDULE_PREFILL_SLOT, state);
navigate(`/${lang}/schedule`);
return;
}
// Onlineboard requests stay on this page with query params
navigate({
pathname: `/${lang}/onlineboard`,
search: params.toString(),
});
// Onlineboard request — same page. Update local prefill +
// remount the filter via key bump so its useState initializers
// see the new initial* props.
setPrefill(buildOnlineBoardPrefillState(request));
setFilterKey((n) => n + 1);
},
[navigate, lang],
);
@@ -134,7 +135,7 @@ export const OnlineBoardStartPage: FC = () => {
breadcrumbs={[]}
contentLeft={
<>
<OnlineBoardFilter {...filterInitialProps} />
<OnlineBoardFilter key={filterKey} {...filterInitialProps} />
<SearchHistory />
</>
}
@@ -9,16 +9,14 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import { ScheduleStartPage, buildSchedulePopularRequestQueryParams } from "./ScheduleStartPage.js";
import { ScheduleStartPage } from "./ScheduleStartPage.js";
import type { PopularRequest } from "@/features/popular-requests/types.js";
const mockNavigate = vi.fn();
let mockSearchParams = new URLSearchParams();
vi.mock("@modern-js/runtime/router", () => ({
useNavigate: () => mockNavigate,
useParams: () => ({ lang: "ru" }),
useSearchParams: () => [mockSearchParams],
Link: ({ children, to, ...props }: { children: React.ReactNode; to: string; className?: string; [k: string]: unknown }) => (
<a href={to} {...props}>{children}</a>
),
@@ -50,14 +48,14 @@ vi.mock("@/features/popular-requests/components/PopularRequestsPanel.js", () =>
}));
vi.mock("@/features/online-board/components/OnlineBoardStartPage.js", () => ({
buildPopularRequestQueryParams: (request: PopularRequest) => {
const params = new URLSearchParams();
buildOnlineBoardPrefillState: (request: PopularRequest) => {
if (request.mode === "Departure") {
params.set("tab", "route");
params.set("departure", (request as { departure: string }).departure);
return { tab: "route", departure: request.departure };
}
return params;
return {};
},
ONLINE_BOARD_PREFILL_SLOT: "online-board",
SCHEDULE_PREFILL_SLOT: "schedule",
}));
vi.mock("@/shared/hooks/useCitySearch.js", () => ({
@@ -68,56 +66,10 @@ vi.mock("@/ui/layout/SearchHistory.js", () => ({
SearchHistory: () => <div data-testid="search-history" />,
}));
// ---------------------------------------------------------------------------
// Pure function: buildSchedulePopularRequestQueryParams
// ---------------------------------------------------------------------------
describe("buildSchedulePopularRequestQueryParams", () => {
it("builds params for Route mode with departure and arrival", () => {
const request: PopularRequest = {
mode: "Route",
departure: "SVO",
arrival: "LED",
type: "Schedule",
};
const params = buildSchedulePopularRequestQueryParams(request);
expect(params.get("departure")).toBe("SVO");
expect(params.get("arrival")).toBe("LED");
expect(params.has("return")).toBe(false);
});
it("builds params for RouteWithBack mode with return flag", () => {
const request: PopularRequest = {
mode: "RouteWithBack",
departure: "SVO",
arrival: "LED",
type: "Schedule",
};
const params = buildSchedulePopularRequestQueryParams(request);
expect(params.get("departure")).toBe("SVO");
expect(params.get("arrival")).toBe("LED");
expect(params.get("return")).toBe("true");
});
it("returns empty params for non-route modes", () => {
const request: PopularRequest = {
mode: "Arrival",
arrival: "VKO",
type: "Onlineboard",
};
const params = buildSchedulePopularRequestQueryParams(request);
expect(params.toString()).toBe("");
});
});
// ---------------------------------------------------------------------------
// Component tests
// ---------------------------------------------------------------------------
describe("ScheduleStartPage", () => {
beforeEach(() => {
vi.clearAllMocks();
mockSearchParams = new URLSearchParams();
sessionStorage.clear();
});
it("renders the start page", () => {
@@ -125,29 +77,29 @@ describe("ScheduleStartPage", () => {
expect(screen.getByTestId("schedule-start")).toBeTruthy();
});
it("navigates to schedule page on Schedule-type popular request click", () => {
it("writes prefill + navigates to schedule on Schedule-type popular click", () => {
render(<ScheduleStartPage />);
fireEvent.click(screen.getByTestId("popular-click-route"));
expect(mockNavigate).toHaveBeenCalledWith(
expect.objectContaining({
pathname: "/ru/schedule",
search: expect.stringContaining("departure=SVO"),
}),
expect(sessionStorage.getItem("afl-prefill:schedule")).toBe(
JSON.stringify({ departure: "SVO", arrival: "LED", withReturn: false }),
);
expect(mockNavigate).toHaveBeenCalledWith("/ru/schedule");
});
it("navigates to onlineboard on Onlineboard-type popular request click", () => {
it("writes prefill + navigates to onlineboard on Onlineboard-type popular click", () => {
render(<ScheduleStartPage />);
fireEvent.click(screen.getByTestId("popular-click-onlineboard"));
expect(mockNavigate).toHaveBeenCalledWith(
expect.objectContaining({
pathname: "/ru/onlineboard",
}),
expect(sessionStorage.getItem("afl-prefill:online-board")).toBe(
JSON.stringify({ tab: "route", departure: "LED" }),
);
expect(mockNavigate).toHaveBeenCalledWith("/ru/onlineboard");
});
it("initializes form from search params", () => {
mockSearchParams = new URLSearchParams("departure=SVO&arrival=LED&return=true");
it("initializes form from sessionStorage prefill", () => {
sessionStorage.setItem(
"afl-prefill:schedule",
JSON.stringify({ departure: "SVO", arrival: "LED", withReturn: true }),
);
render(<ScheduleStartPage />);
const roundTripCheckbox = screen.getByTestId("round-trip-toggle");
expect((roundTripCheckbox as HTMLInputElement).checked).toBe(true);
@@ -8,7 +8,7 @@
*/
import { type FC, useState, useCallback, type FormEvent } from "react";
import { useNavigate, useParams, useSearchParams } from "@modern-js/runtime/router";
import { useNavigate, useParams } from "@modern-js/runtime/router";
import { Calendar } from "primereact/calendar";
import { Slider, type SliderChangeEvent } from "primereact/slider";
import { AutoComplete, type AutoCompleteCompleteEvent } from "primereact/autocomplete";
@@ -19,7 +19,15 @@ import { PageTabs } from "@/ui/layout/PageTabs.js";
import { SearchHistory } from "@/ui/layout/SearchHistory.js";
import { PopularRequestsPanel } from "@/features/popular-requests/components/PopularRequestsPanel.js";
import type { PopularRequest } from "@/features/popular-requests/types.js";
import { buildPopularRequestQueryParams } from "@/features/online-board/components/OnlineBoardStartPage.js";
import {
buildOnlineBoardPrefillState,
ONLINE_BOARD_PREFILL_SLOT,
SCHEDULE_PREFILL_SLOT,
} from "@/features/online-board/components/OnlineBoardStartPage.js";
import {
readAndClearTransientPrefill,
writeTransientPrefill,
} from "@/shared/state/transientPrefill.js";
import { buildScheduleUrl } from "../url.js";
import "./ScheduleStartPage.scss";
@@ -43,23 +51,16 @@ function addDays(base: Date, days: number): Date {
}
/**
* Build URL search params for a schedule-type popular request.
*
* Only Route and RouteWithBack modes produce params; other modes
* return empty params (they belong to the online board).
* Transient prefill state handed from a popular-request click via
* sessionStorage. Mirrors Angular's `ScheduleFiltersStateService`
* cross-page singleton: URL stays clean of query strings while the
* form seeds from the click. Read-and-cleared on mount, so reload
* gives a pristine form (matching Angular).
*/
export function buildSchedulePopularRequestQueryParams(
request: PopularRequest,
): URLSearchParams {
const params = new URLSearchParams();
if (request.mode === "Route" || request.mode === "RouteWithBack") {
params.set("departure", request.departure);
params.set("arrival", request.arrival);
if (request.mode === "RouteWithBack") {
params.set("return", "true");
}
}
return params;
export interface SchedulePrefillState {
departure?: string;
arrival?: string;
withReturn?: boolean;
}
export const ScheduleStartPage: FC = () => {
@@ -67,17 +68,24 @@ export const ScheduleStartPage: FC = () => {
const { t } = useTranslation();
const routeParams = useParams<{ lang: string }>();
const lang = routeParams.lang ?? "ru";
const [searchParams] = useSearchParams();
// One-shot read of any prefill the previous page wrote.
const [prefill] = useState<SchedulePrefillState>(
() =>
readAndClearTransientPrefill<SchedulePrefillState>(
SCHEDULE_PREFILL_SLOT,
) ?? {},
);
const today = new Date();
const [departureAirport, setDepartureAirport] = useState<CitySuggestion | string>(searchParams.get("departure") ?? "");
const [arrivalAirport, setArrivalAirport] = useState<CitySuggestion | string>(searchParams.get("arrival") ?? "");
const [departureAirport, setDepartureAirport] = useState<CitySuggestion | string>(prefill.departure ?? "");
const [arrivalAirport, setArrivalAirport] = useState<CitySuggestion | string>(prefill.arrival ?? "");
const [dateFrom, setDateFrom] = useState<Date | null>(today);
const [dateTo, setDateTo] = useState<Date | null>(addDays(today, 7));
const [timeRange, setTimeRange] = useState<[number, number]>([0, 1440]);
const [directOnly, setDirectOnly] = useState(false);
const [isRoundTrip, setIsRoundTrip] = useState(searchParams.get("return") === "true");
const [isRoundTrip, setIsRoundTrip] = useState(prefill.withReturn === true);
const [returnDateFrom, setReturnDateFrom] = useState<Date | null>(addDays(today, 7));
const [returnDateTo, setReturnDateTo] = useState<Date | null>(addDays(today, 14));
const [returnTimeRange, setReturnTimeRange] = useState<[number, number]>([0, 1440]);
@@ -149,20 +157,25 @@ export const ScheduleStartPage: FC = () => {
const handlePopularRequestClick = useCallback(
(request: PopularRequest) => {
if (request.type === "Onlineboard") {
const params = buildPopularRequestQueryParams(request);
navigate({
pathname: `/${lang}/onlineboard`,
search: params.toString(),
});
writeTransientPrefill(
ONLINE_BOARD_PREFILL_SLOT,
buildOnlineBoardPrefillState(request),
);
navigate(`/${lang}/onlineboard`);
return;
}
// Schedule-type: build params and navigate to schedule with pre-fill
const params = buildSchedulePopularRequestQueryParams(request);
navigate({
pathname: `/${lang}/schedule`,
search: params.toString(),
});
// Schedule-type: only Route / RouteWithBack carry city info.
const state: SchedulePrefillState =
request.mode === "Route" || request.mode === "RouteWithBack"
? {
departure: request.departure,
arrival: request.arrival,
withReturn: request.mode === "RouteWithBack",
}
: {};
writeTransientPrefill(SCHEDULE_PREFILL_SLOT, state);
navigate(`/${lang}/schedule`);
},
[navigate, lang],
);
-57
View File
@@ -1,57 +0,0 @@
/**
* Popular Requests standalone page route.
*
* Provides a standalone view of the PopularRequestsPanel. In Angular,
* popular requests were embedded in OnlineBoard and Schedule start pages.
* This route provides a direct-access path for the MF remote and allows
* independent rendering/testing.
*
* URL: /{lang}/popular
*/
import { lazy, Suspense, useCallback } from "react";
import { useParams, useNavigate } from "@modern-js/runtime/router";
import { useTranslation } from "@/i18n/provider.js";
import type { PopularRequest } from "@/features/popular-requests/types.js";
const PopularRequestsPanel = lazy(() =>
import("@/features/popular-requests/components/PopularRequestsPanel.js").then(
(m) => ({ default: m.PopularRequestsPanel }),
),
);
export default function PopularPage(): JSX.Element {
const { t } = useTranslation();
const routeParams = useParams<{ lang: string }>();
const lang = routeParams.lang ?? "ru";
const navigate = useNavigate();
const handleRequestClick = useCallback(
(request: PopularRequest) => {
switch (request.mode) {
case "FlightNumber":
case "Arrival":
case "Departure":
void navigate(`/${lang}/onlineboard`);
return;
case "Route":
if (request.type === "Onlineboard") {
void navigate(`/${lang}/onlineboard`);
} else {
void navigate(`/${lang}/schedule`);
}
return;
case "RouteWithBack":
void navigate(`/${lang}/schedule`);
return;
}
},
[lang, navigate],
);
return (
<Suspense fallback={<div aria-busy="true">{t("SHARED.LOADING")}</div>}>
<PopularRequestsPanel onRequestClick={handleRequestClick} />
</Suspense>
);
}
+36
View File
@@ -0,0 +1,36 @@
/**
* Cross-page transient prefill store.
*
* Mirrors Angular's `OnlineBoardFiltersStateService` /
* `ScheduleFiltersStateService` — singleton state used to hand
* popular-request data from one page to another without polluting the
* URL with query strings. Reload clears it (matches Angular's behavior:
* a fresh navigation to the page bypasses the service-held state).
*
* sessionStorage is the host: same-tab persistence, gone on tab close,
* works around react-router v6's same-path navigation collapsing
* `state` updates without re-rendering useLocation consumers.
*/
const KEY_PREFIX = "afl-prefill:";
export function writeTransientPrefill<T>(slot: string, value: T): void {
if (typeof sessionStorage === "undefined") return;
try {
sessionStorage.setItem(KEY_PREFIX + slot, JSON.stringify(value));
} catch {
// ignore quota/disabled
}
}
export function readAndClearTransientPrefill<T>(slot: string): T | null {
if (typeof sessionStorage === "undefined") return null;
try {
const raw = sessionStorage.getItem(KEY_PREFIX + slot);
if (!raw) return null;
sessionStorage.removeItem(KEY_PREFIX + slot);
return JSON.parse(raw) as T;
} catch {
return null;
}
}
-17
View File
@@ -1,17 +0,0 @@
import { test, expect } from "@playwright/test";
test.describe("Popular Requests", () => {
test("/ru/popular renders", async ({ page }) => {
await page.goto("/ru/popular");
await page.waitForLoadState("domcontentloaded");
// The page should render without crashing. The PopularRequestsPanel
// is lazy-loaded, so we wait for either the panel or the loading fallback.
await expect(
page.locator("main, [aria-busy='true'], body"),
).not.toBeEmpty();
// URL should be correct
expect(page.url()).toContain("/ru/popular");
});
});
@@ -20,7 +20,7 @@ const navigateSpy = vi.fn();
vi.mock("@modern-js/runtime/router", () => ({
useNavigate: () => navigateSpy,
useParams: () => ({ lang: "ru" }),
useSearchParams: () => [new URLSearchParams()],
useLocation: () => ({ state: null, pathname: "/ru/onlineboard" }),
Link: ({ children, to, ...props }: { children: React.ReactNode; to: string; [k: string]: unknown }) => (
<a href={to} {...props}>{children}</a>
),