Next.js 16的 App Router、PWA与 Supabase SSR:移动端/作为PWA时无限加载(很可能是手动的Service Worker缓存了RSC/Flight)

前端开发 2026-07-12

应用地址: https://pharmagoli-loyalty.vercel.app/

背景
我在Vercel上使用Supabase Auth/RLS的 Next.js 16(App Router)+ React + TypeScript应用。桌面浏览器基本没问题(没有控制台错误),但在移动端(尤其是已安装的PWA)我经常遇到无限加载/空白屏幕。有时登录页在多次刷新后才出现;有时提交凭据也无效。

我没有使用 next-pwa,因为Next.js 16 Turbopack + next-pwa(webpack插件)不兼容。相反,我在 /public/sw.js 使用一个手动的service worker。

预期

  • 移动端浏览器和已安装的PWA能稳定加载
  • 登录稳定工作并正确重定向(没有无限加载)

实际

  • 移动端/PWA常常卡在无限加载/空白屏
  • 登录不稳定,需多次刷新
  • 桌面端看起来正常

中间件(Supabase SSR认证门)

我用 @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).*)'],
}

手动service worker(public/sw.js

我觉得这是罪魁祸首。它:

  • 处理导航请求(request.mode === 'navigate')时,采用网络优先+ 离线回退(不缓存)
  • 使用CacheFirst缓存 /_next/static/*
  • 使用StaleWhileRevalidate缓存图片/图标
  • 对“其他一切”则使用NetworkFirst,设定3s超时,并将响应缓存在页面缓存中
// Everything else → NetworkFirst (3s timeout) and cache into PAGES_CACHE
// (full file available if needed; this is the key part)

在移动端/PWA上,我怀疑Next.js App Router的内部请求(RSC/Flight/data、text/x-component、诸如 RSC / Next-Router-State-Tree 等头信息)被视为“其他一切”并被缓存。这可能导致跨部署出现陈旧/不匹配的负载,并引发无限加载状态。


问题

在使用手动SW时,针对 Next.js 16 App Router 应用(尤其是带认证的),“推荐的service worker策略”是什么?

具体来说:

  1. App Router的内部请求(RSC/Flight/data)是否应被视为 NetworkOnly,永不缓存?我如何在SW中可靠检测它们(RSC 头、accept: text/x-component 等等)?
  2. 缓存除了 /_next/static/* 和图标之外的任何内容是否安全?对于非静态请求,是否应完全移除“NetworkFirst+缓存”?
  3. 是否存在Supabase SSR中间件(cookies/会话)与PWA/SW之间的已知交互,可能导致移动端无限加载?

如有帮助,我可以粘贴完整的 sw.js 和一个移动网络追踪(请求是否被缓存/302循环/获取卡顿)。

/**
 * 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(
            '<!DOCTYPE html><html><body><h1>Offline</h1><p>Verifica la connessione.</p></body></html>',
            { 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[i]
          if ('focus' in client) {
            client.focus()
            if ('navigate' in client) client.navigate(targetUrl)
            return
          }
        }
        return clients.openWindow(targetUrl)
      })
  )
})

解决方案

很可能是你的service worker干扰了App Router的 RSC/Flight请求或认证重定向。

如果你使用Next.js 16(App Router),你不应缓存或拦截任何动态请求。

  • 导航请求
  • RSC/Flight请求(text/x-component、RSC、Next-Router-State-Tree头部)
  • 受认证保护的路由
  • API调用

即使在SW内部使用“NetworkFirst”或导航请求 fetch(),也可能破坏重定向处理(来自中间件的302),并导致Suspense卡顿,从而出现无限加载/空白屏,这在移动端/PWA中更常见。
对于App Router + Supabase SSR,请将service worker保持尽量简洁。

  • 仅缓存 /_next/static/*(CacheFirst)
  • 可选地缓存图片/图标
  • 让其他所有请求直接走网络(不要拦截)

在已认证的SSR应用中,将Service Worker仅作为资源缓存是最安全的做法。

站内所有文章版权归属LeftHeroAI导航站,无授权禁止任何主体转载、抄袭、复制内容,亦不得私自架设镜像站点。一经侵权,本站将通过法律途径追责。

相关文章