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 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>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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 };
|
||||||
|
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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}>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user