fix(navigation): isolate data when switching dropdown menus

This commit is contained in:
Dana Medical Developer 2026-10-22 12:44:21 +00:00
parent 58c99d6286
commit a0ba86bba3
2 changed files with 95 additions and 1 deletions

View File

@ -0,0 +1,92 @@
// Regression for cross-menu data leaks. All catalog responses are fictional;
// no network calls or patient data are used.
import assert from 'node:assert/strict';
import { parseHTML } from 'linkedom';
import { createServer } from 'vite';
const { window } = parseHTML('<html><body><div id="root"></div></body></html>');
Object.assign(globalThis, { window, document: window.document, HTMLElement: window.HTMLElement, IS_REACT_ACT_ENVIRONMENT: true });
window.location = new URL('https://menu-test.invalid/');
document.oninput = null;
const { default: React, act } = await import('react');
const { createRoot } = await import('react-dom/client');
const { Simulate } = await import('react-dom/test-utils');
const { MemoryRouter } = await import('react-router-dom');
const vite = await createServer({ server: { middlewareMode: true, hmr: false }, appType: 'custom' });
const { default: Header } = await vite.ssrLoadModule('/src/components/Header.jsx');
const { api } = await vite.ssrLoadModule('/src/api.js');
const { ApiDataProvider } = await vite.ssrLoadModule('/src/context/ApiData.jsx');
const pending = [];
for (const [method, kind] of [['services', 'services'], ['specialties', 'doctors'], ['priceSections', 'prices']]) {
api[method] = ({ signal }) => new Promise((resolve, reject) => pending.push({ kind, signal, resolve, reject }));
}
const container = document.getElementById('root');
const root = createRoot(container);
const prices = [{ id: 1, name: 'Тестовая консультация', slug: 'test-price', count: 411 }];
const specialties = [{ id: 2, name: 'Тестовые педиатры', slug: 'test-kids', count: 3 }];
const services = [{ id: 3, name: 'Тестовая услуга', path: 'test-service', children: [] }];
const panel = () => container.querySelector('.mega');
const panelText = () => panel()?.textContent || '';
const links = () => [...(panel()?.querySelectorAll('.mega__item') || [])].map((link) => link.getAttribute('href'));
const trigger = (title) => [...container.querySelectorAll('.nav__item')].find((node) => node.textContent === title);
async function open(title, focus = false) {
await act(async () => Simulate[focus ? 'focus' : 'mouseEnter'](trigger(title)));
return pending.at(-1);
}
async function reply(request, data) { await act(async () => request.resolve({ data })); }
async function render(initial = {}) {
await act(async () => root.render(React.createElement(MemoryRouter, {},
React.createElement(ApiDataProvider, { value: initial }, React.createElement(Header)))));
}
try {
await render();
const priceRequest = await open('Цены');
await reply(priceRequest, prices);
assert.match(panelText(), /411 услуг/);
const doctorRequest = await open('Врачи');
assert.equal(panel().getAttribute('aria-label'), 'Меню «Врачи»');
assert.deepEqual(links(), [], 'Price sections must disappear immediately when switching to doctors');
assert.match(panelText(), /Загружаем/);
assert.equal(priceRequest.signal.aborted, true, 'Previous menu request is cancelled on switch');
await reply(doctorRequest, specialties);
assert.match(panelText(), /3 врача/);
assert.deepEqual(links(), ['/company/staff/test-kids/']);
assert.doesNotMatch(panelText(), /411|Тестовая консультация/);
const delayedPrice = await open('Цены');
assert.deepEqual(links(), [], 'Doctor specialties must not leak into the price menu');
const delayedServices = await open('Услуги');
const latestDoctor = await open('Врачи', true);
assert.equal(delayedPrice.signal.aborted, true);
assert.equal(delayedServices.signal.aborted, true);
await reply(latestDoctor, specialties);
// Deliberately resolve cancelled requests: even a transport ignoring AbortSignal
// must not overwrite a newly opened menu.
await reply(delayedPrice, prices);
await reply(delayedServices, services);
assert.deepEqual(links(), ['/company/staff/test-kids/']);
assert.match(panelText(), /3 врача/);
assert.doesNotMatch(panelText(), /411|Тестовая консультация|Тестовая услуга/);
const serviceRequest = await open('Услуги');
await reply(serviceRequest, services);
assert.deepEqual(links(), ['/services/test-service/']);
const failedPrice = await open('Цены');
await act(async () => failedPrice.reject(new Error('Fictional failure')));
assert.match(panelText(), /Не удалось загрузить список/);
assert.deepEqual(links(), [], 'An error must not leave links from another menu');
await act(async () => root.render(null));
await render({ specialties: { data: specialties, meta: {} } });
const initialPrices = await open('Цены');
await reply(initialPrices, prices);
const initialDoctors = await open('Врачи', true);
assert.deepEqual(links(), ['/company/staff/test-kids/'], 'SSR data belongs only to its own catalog');
assert.match(panelText(), /3 врача/);
await reply(initialDoctors, specialties);
console.log('PASS: hover/focus menu switching, delayed replies, cancellation, errors, and SSR data isolation');
} finally {
await act(async () => root.unmount());
await vite.close();
}

View File

@ -115,7 +115,9 @@ export default function Header() {
{openMenu && (
<div className="header__mega">
<MegaMenu menu={openMenu} onNavigate={() => setOpenMenu(null)} />
{/* Каталоги имеют разную структуру: при смене меню сбрасываем
данные и отменяем запрос предыдущего, не меняя лишь заголовок. */}
<MegaMenu key={openMenu} menu={openMenu} onNavigate={() => setOpenMenu(null)} />
</div>
)}
</div>