Классические веб-приложения предполагают постоянное и стабильное подключение к сети. В реальных условиях (метро, авиаперелеты, полевые экспедиции, нестабильный мобильный интернет на складах) сетевые запросы завершаются фатальными ошибками TypeError: Failed to fetch. Для Next.js App Router это создает двойной вызов:
В современной веб-разработке стандартом надежности является парадигма Local-First & Offline Resilience. В Next.js 16 в сочетании с Progressive Web App (PWA) и современными браузерными API эта проблема решается трехуровневым контуром:
useOffline() на базе useSyncExternalStore и событий navigator.onLine / Service Worker.Вместо прямой отправки запроса в сетевой сокет все операции пользователя первично фиксируются в локальном хранилище устройства, а синхронизатор работает асинхронно:
+-----------------------------------------------------------------------------------+
| АРХИТЕКТУРА OFFLINE-FIRST И OUTBOX REPLAY В NEXT.JS 16 |
+-----------------------------------------------------------------------------------+
| ПОЛЬЗОВАТЕЛЬСКИЙ UI (REACT 19) SERVICE WORKER / INDEXEDDB NEXT.JS СЕРВЕР |
| |
| 1. Навигация /dashboard [ Service Worker Cache API ] |
| - Запрос RSC Flight ------------> | Проверка Cache API | |
| | - Cache HIT: отдать RSC | |
| | - Background fetch к сети | |
| <- Мгновенный показ страницы -----+ | |
| |
| 2. Мутация (Создание заказа) [ IndexedDB Outbox Queue ] СЕТЬ ОТКЛЮЧЕНА |
| - Запись формы | { id: uuid, | |
| - useOffline() -> true | action: "create_order", | x (нет |
| <- Оптимистичный UI (Черновик) -- | payload: {...}, | связи) |
| | status: "pending" } | |
| |
| 3. Восстановление сети [ Replay Orchestrator ] |
| - event: "online" | Вычитка очереди Outbox | |
| - ReplayWorker запущен ---------> | Отправка с Idempotency-Key | === POST ===> |
| | Разрешение конфликтов | <== 200 OK == |
| <- Статус: "Синхронизировано" --- | Очистка очереди | |
+-----------------------------------------------------------------------------------+Хук useOffline фиксирует событие offline. Пользовательский интерфейс переводится в режим автономной работы с отображением визуального индикатора.
Данные формы упаковываются в DTO с уникальным Idempotency-Key, меткой времени timestamp и версией сущности, после чего записываются в хранилище outbox_mutations.
Интерфейс мгновенно отображает созданный объект со статусом 'Ожидает синхронизации' без зависания и ошибок.
При появлении сети фоновый синхронизатор вычитывает очередь по FIFO, выполняет Server Actions и обновляет локальные идентификаторы при успехе.
Почему для полноценной офлайн-навигации в Next.js App Router недостаточно просто закэшировать исходные HTML-страницы в Service Worker?
В Next.js серверный рендеринг (SSR) не имеет доступа к объекту window.navigator. Неправильная детекция онлайн-статуса через useState(navigator.onLine) вызовет ошибку гидратации (Hydration Mismatch). Используем useSyncExternalStore:
'use client';
import { useSyncExternalStore } from 'react';
// Функция подписки на браузерные события сети
function subscribe(callback: () => void) {
window.addEventListener('online', callback);
window.addEventListener('offline', callback);
return () => {
window.removeEventListener('online', callback);
window.removeEventListener('offline', callback);
};
}
// Клиентский снимок состояния
function getClientSnapshot(): boolean {
return !navigator.onLine;
}
// Серверный снимок состояния (по умолчанию предполагаем наличие сети)
function getServerSnapshot(): boolean {
return false;
}
export function useOffline(): boolean {
// useSyncExternalStore гарантирует отсутствие Hydration Mismatch
return useSyncExternalStore(subscribe, getClientSnapshot, getServerSnapshot);
}Конфигурируем Service Worker для перехвата запросов Next.js App Router (запросы с заголовком RSC: 1 или параметром _rsc):
const CACHE_NAME = 'wpskills-rsc-cache-v1';
self.addEventListener('install', (event) => {
self.skipWaiting();
});
self.addEventListener('activate', (event) => {
event.waitUntil(clients.claim());
});
self.addEventListener('fetch', (event) => {
const url = new URL(event.request.url);
const isRscRequest = event.request.headers.get('RSC') === '1' || url.searchParams.has('_rsc');
// Стратегия Stale-While-Revalidate для RSC-запросов и страниц
if (isRscRequest || event.request.mode === 'navigate') {
event.respondWith(
caches.open(CACHE_NAME).then(async (cache) => {
const cachedResponse = await cache.match(event.request);
const networkFetchPromise = fetch(event.request)
.then((networkResponse) => {
if (networkResponse.ok) {
cache.put(event.request, networkResponse.clone());
}
return networkResponse;
})
.catch((error) => {
console.warn('[SW] Сетевой сбой, возврат из кэша:', error);
return cachedResponse;
});
// Если есть закэшированная копия, отдаем немедленно, обновляя в фоне
return cachedResponse || networkFetchPromise;
})
);
}
});Для сохранения мутаций создаем типизированный слой работы с IndexedDB:
export interface OutboxMutation {
id: string; // Уникальный UUID мутации (Idempotency Key)
actionName: string; // Например, 'create_note'
payload: Record<string, unknown>;
createdAt: number;
retryCount: number;
version: number;
}
const DB_NAME = 'wpskills_offline_db';
const STORE_NAME = 'outbox_mutations';
const DB_VERSION = 1;
function openDB(): Promise<IDBDatabase> {
return new Promise((resolve, reject) => {
const request = indexedDB.open(DB_NAME, DB_VERSION);
request.onupgradeneeded = () => {
const db = request.result;
if (!db.objectStoreNames.contains(STORE_NAME)) {
db.createObjectStore(STORE_NAME, { keyPath: 'id' });
}
};
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
// Добавление мутации в очередь
export async function enqueueMutation(mutation: Omit<OutboxMutation, 'createdAt' | 'retryCount'>): Promise<void> {
const db = await openDB();
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE_NAME, 'readwrite');
const store = tx.objectStore(STORE_NAME);
const record: OutboxMutation = {
...mutation,
createdAt: Date.now(),
retryCount: 0,
};
store.put(record);
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
}
// Получение всех ожидающих мутаций
export async function getPendingMutations(): Promise<OutboxMutation[]> {
const db = await openDB();
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE_NAME, 'readonly');
const store = tx.objectStore(STORE_NAME);
const request = store.getAll();
request.onsuccess = () => resolve(request.result || []);
request.onerror = () => reject(request.error);
});
}
// Удаление подтвержденной сервером мутации
export async function removeMutation(id: string): Promise<void> {
const db = await openDB();
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE_NAME, 'readwrite');
const store = tx.objectStore(STORE_NAME);
store.delete(id);
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
}Синхронизатор отслеживает появление сети и последовательно передает накопленные мутации в Server Actions:
'use client';
import React, { useEffect, useState, useCallback } from 'react';
import { useOffline } from '@/hooks/useOffline';
import { getPendingMutations, removeMutation, OutboxMutation } from '@/lib/offline/outboxStore';
import { replayMutationServerAction } from '@/app/actions/replay';
import { WifiOff, RefreshCw, CheckCircle2 } from 'lucide-react';
export function OfflineReplayProvider({ children }: { children: React.ReactNode }) {
const isOffline = useOffline();
const [isSyncing, setIsSyncing] = useState(false);
const [pendingCount, setPendingCount] = useState(0);
const syncOutbox = useCallback(async () => {
if (isOffline) return;
try {
const mutations = await getPendingMutations();
setPendingCount(mutations.length);
if (mutations.length === 0) return;
setIsSyncing(true);
// Сортировка по времени создания (FIFO - First In, First Out)
const sorted = mutations.sort((a, b) => a.createdAt - b.createdAt);
for (const item of sorted) {
try {
// Отправка на сервер с передачей Idempotency Key
const response = await replayMutationServerAction({
idempotencyKey: item.id,
actionName: item.actionName,
payload: item.payload,
clientVersion: item.version,
});
if (response.success || response.conflictResolved) {
await removeMutation(item.id);
}
} catch (error) {
console.error(`[Replay Error] Сбой синхронизации мутации ${item.id}:`, error);
break; // Прерываем до следующего цикла связи
}
}
} finally {
const remaining = await getPendingMutations();
setPendingCount(remaining.length);
setIsSyncing(false);
}
}, [isOffline]);
useEffect(() => {
if (!isOffline) {
syncOutbox();
}
}, [isOffline, syncOutbox]);
return (
<>
{/* Баннер статуса синхронизации */}
{(isOffline || pendingCount > 0 || isSyncing) && (
<aside
role="status"
aria-live="polite"
className="fixed bottom-4 right-4 z-50 p-4 rounded-2xl border shadow-xl backdrop-blur-md transition-all flex items-center gap-3 bg-bg-surface/90 border-border text-xs font-mono"
>
{isOffline ? (
<>
<WifiOff className="w-4 h-4 text-warning animate-pulse" />
<span className="text-warning font-semibold">Офлайн-режим (Черновики сохраняются локально)</span>
</>
) : isSyncing ? (
<>
<RefreshCw className="w-4 h-4 text-accent-cyan animate-spin" />
<span className="text-accent-cyan font-semibold">Синхронизация ({pendingCount} в очереди)...</span>
</>
) : (
<>
<CheckCircle2 className="w-4 h-4 text-success" />
<span className="text-success font-semibold">Все изменения синхронизированы</span>
</>
)}
</aside>
)}
{children}
</>
);
}Сервер проверяет номер версии записи (clientVersion vs serverVersion) в таблице базы данных.
Если запись была изменена другим пользователем, пока текущий был офлайн, активируется стратегия слияния.
Применяется Last-Write-Wins (по таймштампу) либо авто-слияние непересекающихся полей (Field-level 3-Way Merge).
Сервер возвращает подтверждение успеха. Клиент удаляет задачу из локального Outbox-хранилища.
Что является ключевым требованием к протоколу отложенной досылки (Replay), чтобы избежать случайного повторного создания одной и той же сущности (например, дублирования заказа) при многократных попытках синхронизации?
Свойство navigator.onLine недоступно на сервере Node.js при SSR. Если написать const [isOnline] = useState(navigator.onLine), то на сервере произойдет ReferenceError: navigator is not defined или возникнет рассинхронизация гидратации (Hydration Mismatch). Всегда используйте хук useSyncExternalStore со стабильным серверным снепшотом.
Спецификация W3C Cache API поддерживает только безопасные идемпотентные запросы (метод GET). Попытка вызвать cache.put(event.request, ...) для запроса с методом POST выбросит исключение TypeError: Request method 'POST' is unsupported. Мутирующие запросы должны сохраняться исключительно в IndexedDB.
Если пользователь непрерывно работает в офлайне несколько недель и генерирует тысячи мутаций с тяжелыми медиа-вложениями, IndexedDB может переполнить квоту дискового пространства браузера, что приведет к исключению QuotaExceededError. Проектируйте валидацию лимитов и сжатие вложений перед помещением в Outbox.
При использовании LWW (Last-Write-Wins) пользователь, который был офлайн 3 дня, при выходе в сеть молча перезапишет данные, над которыми эти 3 дня работали его коллеги. Для критических корпоративных данных всегда выводите диалог разрешения конфликтов при обнаружении несовпадения базовой версии документа.