diff --git a/web/scripts/test-page-loading.mjs b/web/scripts/test-page-loading.mjs new file mode 100644 index 0000000..e4c310c --- /dev/null +++ b/web/scripts/test-page-loading.mjs @@ -0,0 +1,67 @@ +// Public fixtures only. No network or production mutations. +import assert from 'node:assert/strict'; +import { parseHTML } from 'linkedom'; +import { createServer } from 'vite'; +const { window } = parseHTML('
'); +Object.assign(globalThis, { window, document: window.document, HTMLElement: window.HTMLElement, IS_REACT_ACT_ENVIRONMENT: true }); +window.location = new URL('https://test.invalid/'); +const { default: React, act } = await import('react'); +const { createRoot } = await import('react-dom/client'); +const { renderToString } = await import('react-dom/server'); +const { MemoryRouter } = await import('react-router-dom'); +const vite = await createServer({ server: { middlewareMode: true, hmr: false }, appType: 'custom' }); +const { useApi } = await vite.ssrLoadModule('/src/lib/useApi.js'); +const { ApiDataProvider } = await vite.ssrLoadModule('/src/context/ApiData.jsx'); +const { SiteDataProvider } = await vite.ssrLoadModule('/src/context/SiteData.jsx'); +const { default: HeroSlider } = await vite.ssrLoadModule('/src/components/HeroSlider.jsx'); +const { api } = await vite.ssrLoadModule('/src/api.js'); +let calls = 0; +let siteCalls = 0; +api.branches = api.settings = async () => { siteCalls++; return { data: [] }; }; +const pending = []; +const load = (signal) => { calls++; return new Promise((resolve) => pending.push({ signal, resolve })); }; +function Probe({ query }) { + const result = useApi(load, [query], query); + return React.createElement('output', {}, result.data || 'loading'); +} +const container = document.getElementById('root'); +const root = createRoot(container); +const snapshot = { first: { data: 'SSR first' }, second: { data: 'SSR second' }, site: { phone: 'test' }, __receivedAt: Date.now() }; +async function mount(query, initial = snapshot, key = 'same') { + await act(async () => root.render(React.createElement(React.StrictMode, {}, + React.createElement(ApiDataProvider, { value: initial }, React.createElement(SiteDataProvider, {}, + React.createElement(Probe, { key, query })))))); +} +const banners = Array.from({ length: 6 }, (_, id) => ({ id, image: `/test-${id}.webp`, title: `Banner ${id}` })); +const images = () => [...container.querySelectorAll('.hero-slide img[src]')].map((node) => node.getAttribute('src')); +try { + await mount('first'); + assert.equal(calls, 0, 'Hydration including StrictMode must not repeat catalog request'); + assert.equal(siteCalls, 0, 'No redundant branch/settings requests'); + assert.equal(container.querySelector('output').textContent, 'SSR first'); + await mount('second'); + assert.equal(calls, 0); + assert.equal(container.querySelector('output').textContent, 'SSR second'); + await mount('uncached'); + assert.equal(calls, 1, 'Client-only route still loads'); + await mount('new-query'); + await act(async () => pending.at(-2).resolve({ data: 'STALE' })); + assert.doesNotMatch(container.textContent, /STALE/); + await act(async () => pending.at(-1).resolve({ data: 'Fresh query' })); + assert.match(container.textContent, /Fresh query/); + await mount('first', { ...snapshot, __receivedAt: Date.now() - 31_000 }, 'expired'); + assert.ok(calls >= 3, 'Old SSR snapshot must revalidate'); + for (const request of pending) await act(async () => request.resolve({ data: 'Refreshed' })); + const ssr = renderToString(React.createElement(MemoryRouter, {}, React.createElement(HeroSlider, { banners }))); + assert.equal(new Set([...ssr.matchAll(/src="(\/test-[^"]+)"/g)].map((m) => m[1])).size, 3, 'SSR exposes only current/neighbor images'); + await act(async () => root.render(React.createElement(MemoryRouter, {}, React.createElement(HeroSlider, { banners })))); + assert.deepEqual([...new Set(images())].sort(), ['/test-0.webp', '/test-1.webp', '/test-5.webp']); + assert.equal(container.querySelector('.hero__item--active picture img').getAttribute('fetchpriority'), 'high'); + const next = container.querySelector('[aria-label="Следующий слайд"]'); + for (let i = 0; i < 6; i++) await act(async () => next.dispatchEvent(new window.Event('click', { bubbles: true }))); + assert.equal(new Set(images()).size, 6, 'All banners load on demand as the visitor browses'); + console.log('PASS: SSR catalog/site requests 0, uncached/expired queries load, stale replies ignored; initial banner images 6 → 3, all slides reachable'); +} finally { + await act(async () => root.unmount()); + await vite.close(); +} diff --git a/web/src/components/HeroSlider.jsx b/web/src/components/HeroSlider.jsx index 1447bac..29adda4 100644 --- a/web/src/components/HeroSlider.jsx +++ b/web/src/components/HeroSlider.jsx @@ -45,7 +45,7 @@ function Chips({ chips }) { } // Один слайд. Три вида: готовая картинка, текст на цветном фоне и слайд с фото врача. -function Slide({ banner, decorative = false }) { +function Slide({ banner, decorative = false, loadImage = true }) { const layout = banner.layout || 'image'; const theme = THEMES.has(banner.theme) ? banner.theme : 'navy'; const alt = decorative ? '' : banner.title || 'Предложение клиники DANA'; @@ -54,10 +54,10 @@ function Slide({ banner, decorative = false }) { return (
{/* размытая копия заполняет края, когда на телефоне баннер показан целиком */} - {!banner.mobile_image && } + {loadImage && !banner.mobile_image && } - {banner.mobile_image && } - {alt} + {loadImage && banner.mobile_image && } + {loadImage && {alt}}
); @@ -69,7 +69,7 @@ function Slide({ banner, decorative = false }) {