Контекст
У меня есть приложение Next.js 16 (App Router) + React + TypeScript на Vercel, использующее Supabase Auth/RLS. Браузер для настольных компьютеров в основном работает нормально (нет ошибок консоли), но на мобильном устройстве (и особенно с установленным PWA) я часто получаю бесконечную загрузку/пустой экран. Иногда страница входа появляется только после нескольких обновлений; иногда отправка учетных данных ничего не дает.
Я не использую next-pwa, поскольку Next.js 16 Turbopack + next-pwa (плагин веб-пакета) несовместимы. Вместо этого я использую ручной сервисный работник в /public/sw.js.
Ожидается
- Мобильный браузер и установленный PWA загружаются надежно
- Вход работает надежно и перенаправляется правильно (без бесконечной загрузки)
- Мобильные устройства/PWA часто зависают на бесконечной загрузке или пустом экране
- Вход ненадежен до нескольких обновлений
- Рабочий стол выглядит нормально
Я защищаю закрытые маршруты с помощью промежуточного программного обеспечения @supabase/ssr с помощью getSession():
import { createServerClient } from '@supabase/ssr'
import { NextRequest, NextResponse } from 'next/server'
const publicRoutes = ['/auth', '/api/auth', '/offline']
export async function middleware(request: NextRequest) {
const { pathname } = request.nextUrl
if (publicRoutes.some(route => pathname.startsWith(route))) return NextResponse.next()
let response = NextResponse.next({ request: { headers: request.headers } })
const supabase = createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
getAll: () => request.cookies.getAll(),
setAll(cookiesToSet) {
cookiesToSet.forEach(({ name, value, options }) => {
request.cookies.set({ name, value, ...options })
response.cookies.set({ name, value, ...options })
})
},
},
}
)
const { data: { session }, error } = await supabase.auth.getSession()
if (!session || error) {
const loginUrl = new URL('/auth/login', request.url)
loginUrl.searchParams.set('redirect', pathname)
return NextResponse.redirect(loginUrl)
}
return response
}
export const config = {
matcher: ['/((?!_next/static|_next/image|favicon\\.ico|manifest\\.json|icons|sw\\.js|workbox-.*\\.js|fallback-.*\\.js).*)'],
}
Работник ручного обслуживания (public/sw.js)
Я думаю, что виноват именно он. Он:
- управляет навигацией (request.mode === 'navigate') с сетью + автономный резерв (без кэширования)
- кэширует /_next/static/* с помощью CacheFirst
- кэширует изображения/значки с помощью StaleWhileRevalidate
- для «всего остального», с которым он делает NetworkFirst Тайм-аут 3 с, и ответ кэшируется в кеше страниц.
// (full file available if needed; this is the key part)
Я подозреваю, что на мобильных устройствах/PWA внутренние запросы Next.js App Router (RSC/Flight/data, text/x-comComponent, заголовки типа RSC / Next-Router-State-Tree) обрабатываются как «все остальное» и кэшируются. Это может привести к устаревшим/несогласованным полезным нагрузкам при развертывании и привести к бесконечной загрузке.
Вопрос
Какова рекомендуемая стратегия сервисного работника для приложений Next.js 16 App Router (особенно с аутентификацией) при использовании ПО вручную?
В частности:
- Должны ли внутренние запросы App Router (RSC/Flight/data) рассматриваться как NetworkOnly и никогда не кэшироваться? Как мне их достоверно обнаружить в ПО(RSCзаголовок , принять: text/x-comComponent и т. д.)?
- Безопасно ли кэшировать что-либо, кроме /_next/static/* и значков? Должен ли я полностью удалить «NetworkFirst+cache» для нестатических запросов?
- Известно ли взаимодействие между промежуточным программным обеспечением Supabase SSR (файлы cookie/сеанс) и PWA/ПО, которое может вызвать бесконечную загрузку мобильных устройств?
/**
* Pharmagoli Loyalty — Service Worker
*
* Handles caching strategies, offline fallback, and push notifications.
* This is a manual SW that replaces the broken next-pwa generated one
* (next-pwa does not work with Turbopack / Next.js 16).
*
* Strategies:
* - Navigation requests → NetworkFirst (fresh pages, offline fallback)
* - Supabase / API calls → NetworkOnly (never cache)
* - _next/static/* → CacheFirst (immutable, hashed filenames)
* - Images / icons → StaleWhileRevalidate
* - Everything else → NetworkOnly (no cache)
*/
const CACHE_VERSION = 'v4'
const PAGES_CACHE = 'pharmagoli-pages-' + CACHE_VERSION
const STATIC_CACHE = 'pharmagoli-static-' + CACHE_VERSION
const ASSETS_CACHE = 'pharmagoli-assets-' + CACHE_VERSION
var EXPECTED_CACHES = [PAGES_CACHE, STATIC_CACHE, ASSETS_CACHE]
// ── Install ─────────────────────────────────────────────────────────────────
self.addEventListener('install', function (event) {
// Activate immediately — don't wait for the old SW to release clients
self.skipWaiting()
// Pre-cache the offline fallback page
event.waitUntil(
caches.open(PAGES_CACHE).then(function (cache) {
return cache.add('/offline')
})
)
})
// ── Activate ────────────────────────────────────────────────────────────────
self.addEventListener('activate', function (event) {
event.waitUntil(
Promise.all([
// Take control of all open tabs/windows immediately
self.clients.claim(),
// Delete ALL old caches — including stale next-pwa caches
// (workbox-precache-*, next-static, next-image, etc.)
caches.keys().then(function (keys) {
return Promise.all(
keys
.filter(function (key) { return EXPECTED_CACHES.indexOf(key) === -1 })
.map(function (key) { return caches.delete(key) })
)
}),
])
)
})
// ── Fetch ───────────────────────────────────────────────────────────────────
self.addEventListener('fetch', function (event) {
var request = event.request
var url = new URL(request.url)
// Only handle GET requests
if (request.method !== 'GET') return
// Ignore requests that browsers may send but SW cannot safely handle
if (request.cache === 'only-if-cached' && request.mode !== 'same-origin') return
// ── NetworkOnly: Supabase API (auth, REST, RPC, realtime) ──
if (url.hostname.indexOf('supabase.co') !== -1) return
// ── NetworkOnly: internal Next.js API routes ──
if (url.pathname.indexOf('/api/') === 0) return
// ── Navigation requests (page loads) → Network with offline fallback ──
// We do NOT cache navigation responses to avoid issues with redirects
// (SW navigation requests use redirect:"manual" which produces opaque
// redirect responses that cannot be cached or reused).
// Only provide offline fallback when the network is completely down.
if (request.mode === 'navigate') {
event.respondWith(
fetch(event.request).catch(function () {
return caches.match('/offline').then(function (offlinePage) {
return offlinePage || new Response(
'Offline
Verifica la connessione.
',
{ status: 200, headers: { 'Content-Type': 'text/html' } }
)
})
})
)
return
}
// ── Static assets (_next/static/) → CacheFirst (immutable hashed URLs) ──
if (url.pathname.indexOf('/_next/static/') === 0) {
event.respondWith(
caches.match(request).then(function (cached) {
if (cached) return cached
return fetch(request).then(function (response) {
if (response.ok) {
var clone = response.clone()
caches.open(STATIC_CACHE).then(function (cache) { cache.put(request, clone) })
}
return response
}).catch(function () {
return new Response('', { status: 408 })
})
})
)
return
}
// ── Images & icons → StaleWhileRevalidate ──
if (url.pathname.indexOf('/_next/image') === 0 || url.pathname.indexOf('/icons/') === 0) {
event.respondWith(
caches.match(request).then(function (cached) {
var networkFetch = fetch(request)
.then(function (response) {
if (response.ok) {
var clone = response.clone()
caches.open(ASSETS_CACHE).then(function (cache) { cache.put(request, clone) })
}
return response
})
.catch(function () { return cached })
return cached || networkFetch
})
)
return
}
// ── Everything else → NetworkOnly (no cache) ──
event.respondWith(
fetch(request).catch(function () {
return new Response('Offline', { status: 503 })
})
)
})
// ── Push received ───────────────────────────────────────────────────────────
self.addEventListener('push', function (event) {
if (!event.data) return
var payload
try {
payload = event.data.json()
} catch (e) {
payload = { title: 'Pharmagoli', body: event.data.text(), url: '/customer/dashboard' }
}
var title = payload.title || 'Pharmagoli'
var body = payload.body || ''
var url = payload.url || '/customer/dashboard'
var data = payload.data || {}
event.waitUntil(
self.registration.showNotification(title, {
body: body,
icon: '/icons/icon-192.png',
badge: '/icons/icon-192.png',
data: { url: url, ...data },
vibrate: [200, 100, 200],
requireInteraction: false,
})
)
})
// ── Notification click → open / focus app ───────────────────────────────────
self.addEventListener('notificationclick', function (event) {
event.notification.close()
var targetUrl = (event.notification.data && event.notification.data.url) || '/customer/dashboard'
event.waitUntil(
clients
.matchAll({ type: 'window', includeUncontrolled: true })
.then(function (clientList) {
for (var i = 0; i < clientList.length; i++) {
var client = clientList
if ('focus' in client) {
client.focus()
if ('navigate' in client) client.navigate(targetUrl)
return
}
}
return clients.openWindow(targetUrl)
})
)
})
Подробнее здесь: https://stackoverflow.com/questions/798 ... pwa-manual
Мобильная версия