perf(web): defer hidden banners and reuse fresh SSR data

This commit is contained in:
Dana Medical Developer 2026-10-23 07:23:20 +00:00
parent f9f45e5eae
commit cb387e1f71
5 changed files with 95 additions and 9 deletions

View File

@ -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('<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://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();
}

View File

@ -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 layout = banner.layout || 'image';
const theme = THEMES.has(banner.theme) ? banner.theme : 'navy'; const theme = THEMES.has(banner.theme) ? banner.theme : 'navy';
const alt = decorative ? '' : banner.title || 'Предложение клиники DANA'; const alt = decorative ? '' : banner.title || 'Предложение клиники DANA';
@ -54,10 +54,10 @@ function Slide({ banner, decorative = false }) {
return ( return (
<div className="hero-slide hero-slide--image"> <div className="hero-slide hero-slide--image">
{/* размытая копия заполняет края, когда на телефоне баннер показан целиком */} {/* размытая копия заполняет края, когда на телефоне баннер показан целиком */}
{!banner.mobile_image && <img className="hero-slide__blur" src={banner.image} alt="" aria-hidden="true" />} {loadImage && !banner.mobile_image && <img className="hero-slide__blur" src={banner.image} alt="" aria-hidden="true" decoding="async" />}
<picture> <picture>
{banner.mobile_image && <source media="(max-width: 767px)" srcSet={banner.mobile_image} />} {loadImage && banner.mobile_image && <source media="(max-width: 767px)" srcSet={banner.mobile_image} />}
<img className={banner.mobile_image ? '' : 'hero-slide__fit'} src={banner.image} alt={alt} /> {loadImage && <img className={banner.mobile_image ? '' : 'hero-slide__fit'} src={banner.image} alt={alt} fetchpriority={decorative ? 'auto' : 'high'} decoding="async" />}
</picture> </picture>
</div> </div>
); );
@ -69,7 +69,7 @@ function Slide({ banner, decorative = false }) {
<span className="hero-slide__glow hero-slide__glow--warm" aria-hidden="true" /> <span className="hero-slide__glow hero-slide__glow--warm" aria-hidden="true" />
<span className="hero-slide__glow hero-slide__glow--deep" aria-hidden="true" /> <span className="hero-slide__glow hero-slide__glow--deep" aria-hidden="true" />
<img className="hero-slide__letters" src={lettersImage} alt="" aria-hidden="true" /> <img className="hero-slide__letters" src={lettersImage} alt="" aria-hidden="true" />
{banner.image && <img className="hero-slide__photo" src={banner.image} alt={decorative ? '' : banner.subtitle || ''} />} {loadImage && banner.image && <img className="hero-slide__photo" src={banner.image} alt={decorative ? '' : banner.subtitle || ''} fetchpriority={decorative ? 'auto' : 'high'} decoding="async" />}
<div className="hero-slide__copy"> <div className="hero-slide__copy">
{banner.badge && <p className="hero-slide__badge">{banner.badge}</p>} {banner.badge && <p className="hero-slide__badge">{banner.badge}</p>}
<p className="hero-slide__title"> <p className="hero-slide__title">
@ -113,6 +113,12 @@ export default function HeroSlider({ banners }) {
const touch = useRef(null); const touch = useRef(null);
const current = looped ? (((pos - CLONES) % count) + count) % count : 0; const current = looped ? (((pos - CLONES) % count) + count) % count : 0;
const [visited, setVisited] = useState(() => new Set());
useEffect(() => {
// Загружаем текущий баннер и двух видимых соседей. Уже загруженные сохраняем.
const ids = [current - 1, current, current + 1].map((index) => banners[(index + count) % count]?.id);
setVisited((previous) => ids.every((id) => previous.has(id)) ? previous : new Set([...previous, ...ids]));
}, [current, banners, count]);
const items = looped const items = looped
? [...banners.slice(-CLONES), ...banners, ...banners.slice(0, CLONES)].map((b, i) => ({ b, i })) ? [...banners.slice(-CLONES), ...banners, ...banners.slice(0, CLONES)].map((b, i) => ({ b, i }))
: banners.map((b, i) => ({ b, i })); : banners.map((b, i) => ({ b, i }));
@ -200,7 +206,7 @@ export default function HeroSlider({ banners }) {
onClickCapture={active ? undefined : (e) => { e.preventDefault(); e.stopPropagation(); go(i < pos ? -1 : 1); }} onClickCapture={active ? undefined : (e) => { e.preventDefault(); e.stopPropagation(); go(i < pos ? -1 : 1); }}
> >
<SlideLink href={b.link_url} tabIndex={active ? undefined : -1}> <SlideLink href={b.link_url} tabIndex={active ? undefined : -1}>
<Slide banner={b} decorative={!active} /> <Slide banner={b} decorative={!active} loadImage={Math.abs(i - pos) <= 1 || visited.has(b.id)} />
</SlideLink> </SlideLink>
</div> </div>
); );

View File

@ -26,9 +26,11 @@ function normalizeAddress(address) {
export function SiteDataProvider({ children }) { export function SiteDataProvider({ children }) {
const initial = useInitialApiData('site'); const initial = useInitialApiData('site');
const receivedAt = useInitialApiData('__receivedAt');
const [value, setValue] = useState(initial || FALLBACK); const [value, setValue] = useState(initial || FALLBACK);
useEffect(() => { useEffect(() => {
if (initial && receivedAt && Date.now() - receivedAt < 30_000) return undefined;
const ctrl = new AbortController(); const ctrl = new AbortController();
const opts = { signal: ctrl.signal }; const opts = { signal: ctrl.signal };

View File

@ -5,22 +5,31 @@ import { useInitialApiData } from '../context/ApiData.jsx';
// и не перетирает результат нового. // и не перетирает результат нового.
export function useApi(load, deps, key) { export function useApi(load, deps, key) {
const initial = useInitialApiData(key); const initial = useInitialApiData(key);
const receivedAt = useInitialApiData('__receivedAt');
const [state, setState] = useState(() => initial const [state, setState] = useState(() => initial
? { ...initial, loading: false, error: null } ? { ...initial, loading: false, error: null }
: { data: null, meta: {}, loading: true, error: null }); : { data: null, meta: {}, loading: true, error: null });
useEffect(() => { useEffect(() => {
// Сервер уже прислал эти публичные данные: при гидратации второй запрос не нужен.
// Через 30 секунд при следующем открытии компонента снова идём в API.
if (initial && receivedAt && Date.now() - receivedAt < 30_000) {
setState({ ...initial, loading: false, error: null });
return undefined;
}
const ctrl = new AbortController(); const ctrl = new AbortController();
setState((s) => ({ ...s, loading: true, error: null })); setState((s) => ({ ...s, loading: true, error: null }));
load(ctrl.signal) load(ctrl.signal)
.then(({ data, meta }) => setState({ data, meta, loading: false, error: null })) .then(({ data, meta }) => {
if (!ctrl.signal.aborted) setState({ data, meta, loading: false, error: null });
})
.catch((error) => { .catch((error) => {
if (error.name === 'AbortError') return; if (ctrl.signal.aborted || error.name === 'AbortError') return;
setState({ data: null, meta: {}, loading: false, error }); setState({ data: null, meta: {}, loading: false, error });
}); });
return () => ctrl.abort(); return () => ctrl.abort();
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, deps); }, [key, ...deps]);
return state; return state;
} }

View File

@ -17,6 +17,8 @@ import { ApiDataProvider } from './context/ApiData.jsx';
const root = document.getElementById('root'); const root = document.getElementById('root');
const initialData = JSON.parse(document.getElementById('page-data')?.textContent || '{}'); const initialData = JSON.parse(document.getElementById('page-data')?.textContent || '{}');
// Время получения публичного SSR-снимка. Не кэшируем данные админки и расписание.
initialData.__receivedAt = Date.now();
const app = ( const app = (
<React.StrictMode> <React.StrictMode>
<ApiDataProvider value={initialData}> <ApiDataProvider value={initialData}>