**TL;DR.** INP на PDP виправлено шляхом зменшення обсягу JavaScript та оптимістичних взаємодій. Використовуйте React Server Components для статичних частин. Використовуйте `useOptimistic` для зміни варіантів та додавання в кошик. Використовуйте `startTransition` для не термінових оновлень. Затримуйте пошук за допомогою `AbortController`. Витягуйте regex і уникайте spread у обробниках подій. Ціль: < 200ms p75, досяжно < 100ms.

## Що вимірює INP

Interaction to Next Paint (INP) вимірює найдовший час взаємодії до малювання на сторінці під час візиту користувача. "Взаємодія" — це клік, торкання або натискання клавіші, яке викликає JavaScript.

INP розраховується як **найгірший випадок** (виключаючи рідкісні винятки), а не середнє значення. Один повільний клік фільтра може зіпсувати ваш INP, навіть якщо 99 інших взаємодій були швидкими.

| Значення INP     | Висновок          |
| ---------------- | ----------------- |
| ≤ 200ms          | Добре             |
| 200ms – 500ms    | Потрібно покращити |
| > 500ms          | Погано            |

## Крок 1: Профілюйте, не гадайте

Використовуйте вкладку Performance у Chrome DevTools, щоб записати сесію взаємодії PDP. Шукайте:

- Довгі завдання (жовті смуги > 50ms) під час взаємодій.
- Примусові переробки (пурпурні смуги).
- Важке `attribution` для записів INP.

Або використовуйте API Long Animation Frames (LoAF) у вашому RUM:

```ts
const observer = new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    if (entry.duration > 200) {
      console.log('Довга рамка:', entry);
    }
  }
});
observer.observe({ type: 'long-animation-frame', buffered: true });
```

## Крок 2: Відкладіть некритичний JS

Найбільші виграші INP на більшості PDP приходять від видалення або відкладання скриптів, які не потрібно завантажувати терміново:

| Тип скрипту             | Рекомендована стратегія           |
| ----------------------- | --------------------------------- |
| Аналітика (GA4, Plausible) | `afterInteractive`                |
| Маркетингові пікселі     | `afterInteractive` або `lazyOnload` |
| Віджет чату              | `lazyOnload` + IntersectionObserver |
| A/B тест SDK             | Інлайн блокування (рідко) або `beforeInteractive` |
| Рекомендаційний двигун    | Серверний компонент або ліниве IO  |
| Віджет відгуків клієнтів | Ліниве завантаження IntersectionObserver |

У Next.js:

```tsx
import Script from 'next/script';

<Script src="/marketing-pixel.js" strategy="afterInteractive" />
<Script src="/chat-widget.js" strategy="lazyOnload" />
```

## Крок 3: Перетворіть на серверні компоненти

Структура PDP зазвичай:

```tsx
// PDPPage (Серверний компонент)
import { ProductGallery } from './product-gallery';      // Сервер
import { ProductInfo } from './product-info';            // Сервер
import { AddToCartButton } from './add-to-cart-button';  // Клієнт (інтерактивний)
import { VariantSelector } from './variant-selector';    // Клієнт (інтерактивний)
import { RelatedProducts } from './related-products';    // Сервер
import { ReviewSummary } from './review-summary';        // Сервер

export default async function PDP({ params }) {
  const product = await getProduct(params.slug);
  return (
    <article>
      <ProductGallery images={product.images} />
      <ProductInfo product={product} />
      <VariantSelector variants={product.variants} />
      <AddToCartButton productId={product.id} />
      <ReviewSummary reviews={product.reviews} />
      <RelatedProducts productId={product.id} />
    </article>
  );
}
```

Тільки `AddToCartButton` і `VariantSelector` є компонентами клієнта. Все інше залишається на сервері. Розмір пакету зменшується на 60–80% у порівнянні з повністю клієнтським PDP.

## Крок 4: Оптимістичний обмін варіантами

Наївний потік кліку варіанту:

1. Користувач клацає на варіант.
2. POST на `/api/variant/select` з новим ID варіанту.
3. Сервер повертає оновлену ціну/SKU/зображення.
4. Перемалювати з новим станом.

Всього: 200–500ms сприйманої затримки. Оцінка INP: погано.

Оптимістичний потік:

```tsx
'use client';
import { useOptimistic, startTransition } from 'react';

export function VariantSelector({ variants, initialSelected }) {
  const [selected, setOptimistic] = useOptimistic(initialSelected);

  function handleSelect(variant) {
    setOptimistic(variant);
    startTransition(async () => {
      await selectVariantServerAction(variant.id);
    });
  }

  return (
    <div>
      {variants.map((v) => (
        <button
          key={v.id}
          onClick={() => handleSelect(v)}
          aria-pressed={selected.id === v.id}
        >
          {v.label}
        </button>
      ))}
    </div>
  );
}
```

INP зменшується до < 100ms, оскільки оптимістичні оновлення стану відбуваються синхронно, а робота сервера виконується в переході.

## Крок 5: Галерея та мініатюра

Галерея продуктів є поширеним вбивцею INP, якщо вона терміново завантажує всі зображення. Шаблон:

```tsx
'use client';
import { startTransition, useState } from 'react';
import Image from 'next/image';

export function ProductGallery({ images }) {
  const [active, setActive] = useState(images[0]);

  function handleThumbClick(image) {
    startTransition(() => setActive(image));
  }

  return (
    <div>
      <Image
        src={active.src}
        width={800}
        height={800}
        priority
        alt={active.alt}
      />
      <div>
        {images.map((img) => (
          <button
            key={img.id}
            onClick={() => handleThumbClick(img)}
            onMouseEnter={() => preloadImage(img.src)}
          >
            <Image src={img.thumbSrc} width={80} height={80} alt="" />
          </button>
        ))}
      </div>
    </div>
  );
}
```

Попередньо завантажте зображення, на яке наведено курсор (`onMouseEnter`), щоб клік був миттєвим.

## Крок 6: Затриманий пошук

```tsx
'use client';
import { useEffect, useState } from 'react';

const MAX_SUGGESTIONS = 8;
const DEBOUNCE_MS = 200;

export function SearchAutocomplete() {
  const [query, setQuery] = useState('');
  const [results, setResults] = useState([]);

  useEffect(() => {
    if (!query) {
      setResults([]);
      return;
    }
    const controller = new AbortController();
    const timeout = setTimeout(async () => {
      try {
        const res = await fetch(`/api/search?q=${encodeURIComponent(query)}`, {
          signal: controller.signal,
        });
        const data = await res.json();
        setResults(data.slice(0, MAX_SUGGESTIONS));
      } catch (err) {
        if (err.name !== 'AbortError') throw err;
      }
    }, DEBOUNCE_MS);
    return () => {
      clearTimeout(timeout);
      controller.abort();
    };
  }, [query]);

  return (
    <>
      <input value={query} onChange={(e) => setQuery(e.target.value)} />
      <ul>
        {results.map((r) => (
          <li key={r.id}>{r.name}</li>
        ))}
      </ul>
    </>
  );
}
```

## Крок 7: Витягніть regex і уникайте spread

Погано:

```tsx
function handleClick(text) {
  if (/[<>]/.test(text)) { ... }   // regex створюється при кожному кліку
  setItems(prev => [...prev, ...newItems]); // spread у акумуляторі
}
```

Добре:

```tsx
const TAG_REGEX = /[<>]/;  // витягнуто в область модуля

function handleClick(text) {
  if (TAG_REGEX.test(text)) { ... }
  setItems(prev => prev.concat(newItems));
}
```

## Крок 8: Ліниве завантаження нижче згину

Рекомендації, відгуки, пов'язані продукти: розмістіть їх нижче згину та ліниво завантажуйте за допомогою `dynamic` + `ssr: false` для чисто клієнтських віджетів або завантажуйте при перетині:

```tsx
import dynamic from 'next/dynamic';

const HeavyReviewWidget = dynamic(() => import('./heavy-review-widget'), {
  ssr: false,
  loading: () => <ReviewSkeleton />,
});
```

Для секцій, що рендеряться на сервері, оберніть у `<Suspense>` з увімкненим PPR, щоб вони транслювалися після статичної оболонки.

## Вимірювання успіху

Після впровадження виправлень покращення INP відображаються в:

1. **Chrome DevTools** під Performance → Web Vitals overlay (синтетичний, миттєвий).
2. **Вашому власному RUM** з бібліотекою `web-vitals` (реальний користувач, майже в реальному часі).
3. **Search Console → Core Web Vitals** (реальний користувач, p75 за 28 днів, затримка 2–4 тижні).

Типовий PDP, який починав з 350ms p75 INP і слідує цьому посібнику, досягає 80–120ms p75 INP протягом 2–4 тижнів після впровадження.

## Як Ordiko за замовчуванням постачає PDP з хорошим INP

- Статичні частини PDP є RSC (без клієнтського JS).
- Вибір варіанту використовує `useOptimistic` + Серверні дії.
- Галерея використовує `startTransition` + попереднє завантаження зображень при наведенні.
- Пошук затримується на 200ms за допомогою `AbortController`.
- Кошик відображає останню відому кількість оптимістично.

Значення p75 INP за замовчуванням: 80–160ms.

## FAQ

**Чому INP набагато складніший за LCP?**
LCP в основному є проблемою CDN та оптимізації зображень з добре зрозумілими виправленнями. INP — це час виконання JavaScript під час взаємодії користувача; кожен клік може мати різне вузьке місце. Виправлення INP вимагає дисципліни на рівні компонентів для кожного інтерактивного елемента на сторінці.

**Чи автоматично виправляє INP React Server Components?**
В основному так. RSC зменшує обсяг JS, що постачається в браузер, що зменшує роботу основного потоку, що покращує INP. Але інтерактивні частини сторінки все ще потребують клієнтського JS — RSC сам по собі не виправить погано написаний клієнтський компонент, який створює regex у onClick.

**Який найбільший вбивця INP на PDP?**
Важке рендеринг на стороні клієнта віджетів рекомендацій ('Часто купують разом', 'Вам також може сподобатися'), які постачають 50–200KB JS і агресивно гідратуються при першій взаємодії. Перемістіть їх на серверні компоненти або ліниво завантажуйте за допомогою IntersectionObserver.

**Наскільки швидким може бути добре оптимізований PDP за INP?**
Справжній p75 INP нижче 100ms досяжний. PDP Ordiko за замовчуванням має 80–160ms p75 INP без ручної оптимізації. Користувацькі теми, які слідують шаблонам у цьому посібнику, можуть стабільно досягати значень нижче 100ms.