perf(web): defer hidden banners and reuse fresh SSR data
This commit is contained in:
parent
f9f45e5eae
commit
cb387e1f71
67
web/scripts/test-page-loading.mjs
Normal file
67
web/scripts/test-page-loading.mjs
Normal 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();
|
||||
}
|
||||
@ -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 (
|
||||
<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>
|
||||
{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 && banner.mobile_image && <source media="(max-width: 767px)" srcSet={banner.mobile_image} />}
|
||||
{loadImage && <img className={banner.mobile_image ? '' : 'hero-slide__fit'} src={banner.image} alt={alt} fetchpriority={decorative ? 'auto' : 'high'} decoding="async" />}
|
||||
</picture>
|
||||
</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--deep" 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">
|
||||
{banner.badge && <p className="hero-slide__badge">{banner.badge}</p>}
|
||||
<p className="hero-slide__title">
|
||||
@ -113,6 +113,12 @@ export default function HeroSlider({ banners }) {
|
||||
const touch = useRef(null);
|
||||
|
||||
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
|
||||
? [...banners.slice(-CLONES), ...banners, ...banners.slice(0, CLONES)].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); }}
|
||||
>
|
||||
<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>
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -26,9 +26,11 @@ function normalizeAddress(address) {
|
||||
|
||||
export function SiteDataProvider({ children }) {
|
||||
const initial = useInitialApiData('site');
|
||||
const receivedAt = useInitialApiData('__receivedAt');
|
||||
const [value, setValue] = useState(initial || FALLBACK);
|
||||
|
||||
useEffect(() => {
|
||||
if (initial && receivedAt && Date.now() - receivedAt < 30_000) return undefined;
|
||||
const ctrl = new AbortController();
|
||||
const opts = { signal: ctrl.signal };
|
||||
|
||||
|
||||
@ -5,22 +5,31 @@ import { useInitialApiData } from '../context/ApiData.jsx';
|
||||
// и не перетирает результат нового.
|
||||
export function useApi(load, deps, key) {
|
||||
const initial = useInitialApiData(key);
|
||||
const receivedAt = useInitialApiData('__receivedAt');
|
||||
const [state, setState] = useState(() => initial
|
||||
? { ...initial, loading: false, error: null }
|
||||
: { data: null, meta: {}, loading: true, error: null });
|
||||
|
||||
useEffect(() => {
|
||||
// Сервер уже прислал эти публичные данные: при гидратации второй запрос не нужен.
|
||||
// Через 30 секунд при следующем открытии компонента снова идём в API.
|
||||
if (initial && receivedAt && Date.now() - receivedAt < 30_000) {
|
||||
setState({ ...initial, loading: false, error: null });
|
||||
return undefined;
|
||||
}
|
||||
const ctrl = new AbortController();
|
||||
setState((s) => ({ ...s, loading: true, error: null }));
|
||||
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) => {
|
||||
if (error.name === 'AbortError') return;
|
||||
if (ctrl.signal.aborted || error.name === 'AbortError') return;
|
||||
setState({ data: null, meta: {}, loading: false, error });
|
||||
});
|
||||
return () => ctrl.abort();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, deps);
|
||||
}, [key, ...deps]);
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
@ -17,6 +17,8 @@ import { ApiDataProvider } from './context/ApiData.jsx';
|
||||
|
||||
const root = document.getElementById('root');
|
||||
const initialData = JSON.parse(document.getElementById('page-data')?.textContent || '{}');
|
||||
// Время получения публичного SSR-снимка. Не кэшируем данные админки и расписание.
|
||||
initialData.__receivedAt = Date.now();
|
||||
const app = (
|
||||
<React.StrictMode>
|
||||
<ApiDataProvider value={initialData}>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user