mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-09-08 01:06:41 +08:00
fix(pwa): coordinate service worker lifecycle (#569)
This commit is contained in:
@@ -62,6 +62,12 @@ export async function retryMoviePilotIdentityVerification(
|
||||
return false
|
||||
}
|
||||
|
||||
/** 删除当前 origin 的全部 Cache Storage;调用方必须先确认该 dev origin 由 MoviePilot Worker 管理。 */
|
||||
export async function deleteCurrentOriginCaches(cacheStorage: Pick<CacheStorage, 'delete' | 'keys'>): Promise<void> {
|
||||
const cacheNames = await cacheStorage.keys()
|
||||
await Promise.all(cacheNames.map(cacheName => cacheStorage.delete(cacheName)))
|
||||
}
|
||||
|
||||
/** 清理完成后只允许返回当前 origin,避免开发中间页形成开放重定向。 */
|
||||
export function resolveDevCleanupReturnUrl(requested: string | null, origin: string): URL {
|
||||
if (!requested) return new URL('/', origin)
|
||||
@@ -78,6 +84,7 @@ export function createDevServiceWorkerCleanupPlugin(): Plugin {
|
||||
const identityTimeoutMs = JSON.stringify(moviePilotIdentityTimeoutMs)
|
||||
const identityAttempts = JSON.stringify(moviePilotIdentityAttempts)
|
||||
const retryIdentityVerification = retryMoviePilotIdentityVerification.toString()
|
||||
const deleteOriginCaches = deleteCurrentOriginCaches.toString()
|
||||
const entryScriptUrl = JSON.stringify(devEntryScriptUrl)
|
||||
const cleanupPath = JSON.stringify(DEV_SW_CLEANUP_PATH)
|
||||
const cleanupAttemptKeyPrefix = JSON.stringify('moviepilot:dev-sw-cleanup')
|
||||
@@ -85,6 +92,7 @@ export function createDevServiceWorkerCleanupPlugin(): Plugin {
|
||||
const redirectScript = `
|
||||
(() => {
|
||||
const entryScriptUrl = ${entryScriptUrl}
|
||||
const deleteCurrentOriginCaches = ${deleteOriginCaches}
|
||||
let appStarted = false
|
||||
const startApp = () => {
|
||||
if (appStarted) return
|
||||
@@ -150,8 +158,15 @@ export function createDevServiceWorkerCleanupPlugin(): Plugin {
|
||||
|
||||
// unregister 不会立即解除当前 document 的 controller;应用模块加载前需再导航一次以脱离旧 Worker。
|
||||
if (cleanupState === 'complete') {
|
||||
void (async () => {
|
||||
// 旧 Worker 可能在注销后的首次导航中重新创建缓存;脱离控制后再清理一次。
|
||||
if ('caches' in window) await deleteCurrentOriginCaches(caches)
|
||||
sessionStorage.removeItem(cleanupAttemptKey)
|
||||
location.reload()
|
||||
})().catch(error => {
|
||||
console.error('[PWA] Failed to finish stale development cache cleanup', error)
|
||||
document.body.textContent = 'Failed to finish stale development cache cleanup. Reload to retry.'
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -180,6 +195,7 @@ export function createDevServiceWorkerCleanupPlugin(): Plugin {
|
||||
const identityTimeoutMs = ${identityTimeoutMs}
|
||||
const identityAttempts = ${identityAttempts}
|
||||
const retryIdentityVerification = ${retryIdentityVerification}
|
||||
const deleteCurrentOriginCaches = ${deleteOriginCaches}
|
||||
const appScope = new URL('./', location.href)
|
||||
const cleanupAttemptKey = ${cleanupAttemptKeyPrefix} + ':' + encodeURIComponent(appScope.pathname)
|
||||
const resolveReturnUrl = () => {
|
||||
@@ -227,6 +243,9 @@ export function createDevServiceWorkerCleanupPlugin(): Plugin {
|
||||
if (!managedRegistrations.length) throw new Error('MoviePilot Service Worker identity verification failed')
|
||||
await Promise.allSettled(managedRegistrations.map(registration => registration.unregister()))
|
||||
|
||||
// Vite dev server 使用独立 origin;仅在确认 MoviePilot Worker 后清除其遗留模块响应。
|
||||
if ('caches' in window) await deleteCurrentOriginCaches(caches)
|
||||
|
||||
// 回跳入口后由 head-prepend 脚本完成第二次导航,避免同一 client 继续复用旧模块响应。
|
||||
sessionStorage.setItem(cleanupAttemptKey, 'complete')
|
||||
location.replace(returnUrl.href)
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { useVersionChecker } from '@/composables/useVersionChecker'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
toastInfo: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('vue-toastification', () => ({
|
||||
useToast: () => ({ info: mocks.toastInfo }),
|
||||
}))
|
||||
|
||||
describe('useVersionChecker', () => {
|
||||
beforeEach(() => {
|
||||
mocks.toastInfo.mockClear()
|
||||
})
|
||||
|
||||
it('没有可用 Service Worker 时保留版本不一致的清缓存兜底', async () => {
|
||||
const { checkVersion } = useVersionChecker()
|
||||
|
||||
await checkVersion('version-that-never-matches-the-build')
|
||||
|
||||
expect(mocks.toastInfo).toHaveBeenCalledOnce()
|
||||
expect(mocks.toastInfo).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
props: expect.objectContaining({
|
||||
message: expect.any(String),
|
||||
onRefresh: expect.any(Function),
|
||||
refreshText: expect.any(String),
|
||||
}),
|
||||
}),
|
||||
expect.objectContaining({
|
||||
closeButton: false,
|
||||
closeOnClick: false,
|
||||
draggable: false,
|
||||
timeout: false,
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -3,11 +3,57 @@ import { useToast } from 'vue-toastification'
|
||||
import { Workbox } from 'workbox-window'
|
||||
import i18n from '@/plugins/i18n'
|
||||
import VersionUpdateToast from '@/components/toast/VersionUpdateToast.vue'
|
||||
import {
|
||||
createServiceWorkerCoordinator,
|
||||
resolveServiceWorkerRegistration,
|
||||
type ServiceWorkerRegistrationConfig,
|
||||
} from '@/utils/serviceWorkerCoordinator'
|
||||
|
||||
// 全局状态
|
||||
const currentVersion = ref(__APP_VERSION__)
|
||||
let isUpdateToastShown = false
|
||||
let wb: Workbox | null = null
|
||||
|
||||
const serviceWorkerRegistration =
|
||||
'serviceWorker' in navigator
|
||||
? resolveServiceWorkerRegistration(
|
||||
import.meta.env.BASE_URL,
|
||||
document.baseURI,
|
||||
import.meta.env.DEV,
|
||||
__PWA_DEVELOPMENT__,
|
||||
)
|
||||
: null
|
||||
|
||||
/** 显示全局唯一的版本更新通知。 */
|
||||
function showUpdateNotification(message: string, refreshText?: string, onRefresh?: () => void): void {
|
||||
if (isUpdateToastShown) return
|
||||
isUpdateToastShown = true
|
||||
const component = h(VersionUpdateToast, {
|
||||
message,
|
||||
refreshText,
|
||||
onRefresh,
|
||||
})
|
||||
|
||||
useToast().info(component, {
|
||||
timeout: false,
|
||||
closeButton: false,
|
||||
closeOnClick: false,
|
||||
draggable: false,
|
||||
})
|
||||
}
|
||||
|
||||
const serviceWorkerCoordinator = createServiceWorkerCoordinator({
|
||||
registration: serviceWorkerRegistration,
|
||||
createClient: (registration: ServiceWorkerRegistrationConfig) =>
|
||||
new Workbox(registration.scriptUrl, {
|
||||
scope: registration.scope,
|
||||
type: registration.type,
|
||||
}),
|
||||
onUpdateActivated: () => {
|
||||
console.log('[VersionChecker] Service Worker 更新已就绪,等待用户刷新')
|
||||
showUpdateNotification(i18n.global.t('common.swUpdateReady'), i18n.global.t('common.refresh'), reloadPage)
|
||||
},
|
||||
onError: error => console.error('[VersionChecker] Service Worker 注册失败:', error),
|
||||
})
|
||||
|
||||
/**
|
||||
* 普通刷新页面
|
||||
@@ -62,16 +108,20 @@ export const clearCacheAndReload = async (): Promise<void> => {
|
||||
const reloadTimer = window.setTimeout(reload, 3000)
|
||||
|
||||
try {
|
||||
await Promise.race([
|
||||
clearCachesAndServiceWorker(),
|
||||
new Promise(resolve => window.setTimeout(resolve, 2500)),
|
||||
])
|
||||
await Promise.race([clearCachesAndServiceWorker(), new Promise(resolve => window.setTimeout(resolve, 2500))])
|
||||
} finally {
|
||||
window.clearTimeout(reloadTimer)
|
||||
reload()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化应用唯一的 Service Worker coordinator。
|
||||
* 普通 dev 不注册;production 与显式 dev:pwa 复用同一个注册 Promise。
|
||||
*/
|
||||
export const initializeServiceWorker = (): Promise<ServiceWorkerRegistration | undefined> =>
|
||||
serviceWorkerCoordinator.initialize()
|
||||
|
||||
/**
|
||||
* 版本检查 Composable
|
||||
*
|
||||
@@ -81,49 +131,6 @@ export const clearCacheAndReload = async (): Promise<void> => {
|
||||
* - 显示持久化更新通知
|
||||
*/
|
||||
export function useVersionChecker() {
|
||||
const toast = useToast()
|
||||
|
||||
/**
|
||||
* 显示版本更新通知
|
||||
* @param message 通知消息文本
|
||||
* @param refreshText 按钮文本,不传则不显示按钮
|
||||
* @param onRefresh 按钮点击事件
|
||||
*/
|
||||
const showUpdateNotification = (message: string, refreshText?: string, onRefresh?: () => void): void => {
|
||||
if (isUpdateToastShown) return
|
||||
isUpdateToastShown = true
|
||||
const component = h(VersionUpdateToast, {
|
||||
message,
|
||||
refreshText,
|
||||
onRefresh,
|
||||
})
|
||||
|
||||
toast.info(component, {
|
||||
timeout: false, // 不自动消失
|
||||
closeButton: false,
|
||||
closeOnClick: false,
|
||||
draggable: false,
|
||||
})
|
||||
}
|
||||
|
||||
// 初始化 Workbox
|
||||
if (!wb && 'serviceWorker' in navigator) {
|
||||
wb = new Workbox('/service-worker.js')
|
||||
|
||||
// Service Worker 激活事件 (install -> activate)
|
||||
wb.addEventListener('activated', event => {
|
||||
// 只有在更新时才显示通知
|
||||
if (event.isUpdate) {
|
||||
console.log('[VersionChecker] Service Worker 更新已就绪,等待用户刷新')
|
||||
|
||||
showUpdateNotification(i18n.global.t('common.swUpdateReady'), i18n.global.t('common.refresh'), reloadPage)
|
||||
}
|
||||
})
|
||||
|
||||
// 注册 Service Worker
|
||||
wb.register()
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查版本并在需要时显示更新通知
|
||||
* @param latestVersion 服务端返回的最新版本号
|
||||
@@ -141,33 +148,26 @@ export function useVersionChecker() {
|
||||
console.log(`[VersionChecker] 检测到版本不一致: ${currentVersion.value} -> ${latestVersion}`)
|
||||
|
||||
// 尝试触发 Service Worker 更新检查
|
||||
if ('serviceWorker' in navigator && navigator.serviceWorker.controller) {
|
||||
const registration = await initializeServiceWorker()
|
||||
if (registration && navigator.serviceWorker.controller) {
|
||||
try {
|
||||
const registration = await navigator.serviceWorker.getRegistration()
|
||||
if (registration) {
|
||||
console.log('[VersionChecker] 触发 Service Worker 更新检查...')
|
||||
|
||||
// 标记是否发现更新
|
||||
let updateFound = false
|
||||
const onUpdateFound = () => {
|
||||
updateFound = true
|
||||
}
|
||||
|
||||
// 监听 updatefound 事件
|
||||
registration.addEventListener('updatefound', onUpdateFound, { once: true })
|
||||
|
||||
// 等待检查完成
|
||||
await registration.update()
|
||||
await serviceWorkerCoordinator.update()
|
||||
|
||||
// 检查是否有更新正在进行
|
||||
// 如果发现更新,或者正在安装/等待中,则直接返回(交由 SW activated 事件处理)
|
||||
// 更新生命周期由同一个 Workbox 实例继续监听,避免检查和提示使用不同 owner。
|
||||
if (updateFound || registration.installing || registration.waiting) {
|
||||
console.log('[VersionChecker] Service Worker 更新中...')
|
||||
return
|
||||
}
|
||||
|
||||
console.log('[VersionChecker] SW 无更新,但版本号不一致,可能是缓存问题')
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('[VersionChecker] Service Worker 更新检查失败:', error)
|
||||
// 失败继续向下执行,显示通知
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
emitAgentAssistantToastBubble,
|
||||
type AgentAssistantBubbleVariant,
|
||||
} from '@/utils/agentAssistantBubble'
|
||||
import { initializeServiceWorker } from '@/composables/useVersionChecker'
|
||||
|
||||
// 5. 注册自定义组件
|
||||
import DialogCloseBtn from '@/@core/components/DialogCloseBtn.vue'
|
||||
@@ -178,6 +179,9 @@ app
|
||||
.use(ConfirmDialog)
|
||||
.use(i18n)
|
||||
|
||||
// UI 通知依赖安装完成后立即绑定更新监听并启动唯一的 Service Worker 注册。
|
||||
void initializeServiceWorker()
|
||||
|
||||
app.mount('#app')
|
||||
|
||||
// 图标全集很大,延后到首屏挂载后的空闲时间加载,避免阻塞登录页首次渲染。
|
||||
|
||||
Vendored
+1
@@ -2,6 +2,7 @@
|
||||
declare global {
|
||||
const __APP_VERSION__: string
|
||||
const __BUILD_TIME__: string
|
||||
const __PWA_DEVELOPMENT__: boolean
|
||||
|
||||
interface Navigator {
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
import {
|
||||
createServiceWorkerCoordinator,
|
||||
resolveServiceWorkerRegistration,
|
||||
type ServiceWorkerClient,
|
||||
} from '@/utils/serviceWorkerCoordinator'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
describe('Service Worker coordinator', () => {
|
||||
it('普通 dev 不注册,dev:pwa 使用模块 Worker', () => {
|
||||
const documentBaseUrl = 'http://localhost:5174/moviepilot/#/dashboard'
|
||||
|
||||
expect(resolveServiceWorkerRegistration('./', documentBaseUrl, true, false)).toBeNull()
|
||||
expect(resolveServiceWorkerRegistration('./', documentBaseUrl, true, true)).toEqual({
|
||||
scriptUrl: 'http://localhost:5174/moviepilot/dev-sw.js?dev-sw',
|
||||
scope: '/moviepilot/',
|
||||
type: 'module',
|
||||
})
|
||||
})
|
||||
|
||||
it('production 按应用 base 注册 classic Worker', () => {
|
||||
expect(
|
||||
resolveServiceWorkerRegistration('/moviepilot/', 'https://example.com/ignored/#/dashboard', false, false),
|
||||
).toEqual({
|
||||
scriptUrl: 'https://example.com/moviepilot/service-worker.js',
|
||||
scope: '/moviepilot/',
|
||||
type: 'classic',
|
||||
})
|
||||
})
|
||||
|
||||
it('监听先于注册,重复初始化和更新复用同一个客户端', async () => {
|
||||
const events: string[] = []
|
||||
let activatedListener: ((event: { isExternal?: boolean; isUpdate?: boolean }) => void) | undefined
|
||||
const registration = {} as ServiceWorkerRegistration
|
||||
const client: ServiceWorkerClient = {
|
||||
addEventListener: vi.fn((_type, listener) => {
|
||||
events.push('listen')
|
||||
activatedListener = listener
|
||||
}),
|
||||
register: vi.fn(async () => {
|
||||
events.push('register')
|
||||
return registration
|
||||
}),
|
||||
update: vi.fn(async () => {
|
||||
events.push('update')
|
||||
}),
|
||||
}
|
||||
const createClient = vi.fn(() => client)
|
||||
const onUpdateActivated = vi.fn()
|
||||
const coordinator = createServiceWorkerCoordinator({
|
||||
registration: {
|
||||
scriptUrl: 'https://example.com/moviepilot/service-worker.js',
|
||||
scope: '/moviepilot/',
|
||||
type: 'classic',
|
||||
},
|
||||
createClient,
|
||||
onUpdateActivated,
|
||||
getController: () => null,
|
||||
})
|
||||
|
||||
const firstInitialization = coordinator.initialize()
|
||||
const secondInitialization = coordinator.initialize()
|
||||
|
||||
expect(firstInitialization).toBe(secondInitialization)
|
||||
await expect(firstInitialization).resolves.toBe(registration)
|
||||
expect(createClient).toHaveBeenCalledOnce()
|
||||
expect(client.register).toHaveBeenCalledOnce()
|
||||
expect(events).toEqual(['listen', 'register'])
|
||||
|
||||
activatedListener?.({ isUpdate: false })
|
||||
expect(onUpdateActivated).not.toHaveBeenCalled()
|
||||
activatedListener?.({ isExternal: true })
|
||||
expect(onUpdateActivated).toHaveBeenCalledOnce()
|
||||
activatedListener?.({ isUpdate: true })
|
||||
expect(onUpdateActivated).toHaveBeenCalledOnce()
|
||||
|
||||
await coordinator.update()
|
||||
expect(createClient).toHaveBeenCalledOnce()
|
||||
expect(client.register).toHaveBeenCalledOnce()
|
||||
expect(client.update).toHaveBeenCalledOnce()
|
||||
expect(events).toEqual(['listen', 'register', 'update'])
|
||||
})
|
||||
|
||||
it('注册期间快速激活时通过 controller 变化补发一次更新通知', async () => {
|
||||
const previousController = {} as ServiceWorker
|
||||
const updatedWorker = {} as ServiceWorker
|
||||
let activatedListener: ((event: { isExternal?: boolean; isUpdate?: boolean }) => void) | undefined
|
||||
const client: ServiceWorkerClient = {
|
||||
addEventListener: vi.fn((_type, listener) => {
|
||||
activatedListener = listener
|
||||
}),
|
||||
register: vi.fn(async () => ({ active: updatedWorker }) as ServiceWorkerRegistration),
|
||||
update: vi.fn(),
|
||||
}
|
||||
const onUpdateActivated = vi.fn()
|
||||
const coordinator = createServiceWorkerCoordinator({
|
||||
registration: {
|
||||
scriptUrl: 'https://example.com/service-worker.js',
|
||||
scope: '/',
|
||||
type: 'classic',
|
||||
},
|
||||
createClient: () => client,
|
||||
onUpdateActivated,
|
||||
getController: () => previousController,
|
||||
})
|
||||
|
||||
await coordinator.initialize()
|
||||
activatedListener?.({ isUpdate: true })
|
||||
|
||||
expect(onUpdateActivated).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('注册失败时只记录一次且不阻断应用启动', async () => {
|
||||
const error = new Error('register failed')
|
||||
const onError = vi.fn()
|
||||
const client: ServiceWorkerClient = {
|
||||
addEventListener: vi.fn(),
|
||||
register: vi.fn().mockRejectedValue(error),
|
||||
update: vi.fn(),
|
||||
}
|
||||
const coordinator = createServiceWorkerCoordinator({
|
||||
registration: {
|
||||
scriptUrl: 'https://example.com/service-worker.js',
|
||||
scope: '/',
|
||||
type: 'classic',
|
||||
},
|
||||
createClient: () => client,
|
||||
onUpdateActivated: vi.fn(),
|
||||
getController: () => null,
|
||||
onError,
|
||||
})
|
||||
|
||||
await expect(coordinator.initialize()).resolves.toBeUndefined()
|
||||
await expect(coordinator.initialize()).resolves.toBeUndefined()
|
||||
expect(client.register).toHaveBeenCalledOnce()
|
||||
expect(onError).toHaveBeenCalledOnce()
|
||||
expect(onError).toHaveBeenCalledWith(error)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,104 @@
|
||||
/** Service Worker 在当前运行模式下的注册参数。 */
|
||||
export interface ServiceWorkerRegistrationConfig {
|
||||
/** Worker 脚本的同源绝对地址。 */
|
||||
scriptUrl: string
|
||||
/** Worker 允许控制的应用路径。 */
|
||||
scope: string
|
||||
/** development Worker 使用 ESM,production Worker 保持 classic。 */
|
||||
type: WorkerType
|
||||
}
|
||||
|
||||
/** Workbox coordinator 所需的最小客户端契约。 */
|
||||
export interface ServiceWorkerClient {
|
||||
addEventListener(type: 'activated', listener: (event: { isExternal?: boolean; isUpdate?: boolean }) => void): void
|
||||
register(): Promise<ServiceWorkerRegistration | undefined>
|
||||
update(): Promise<void>
|
||||
}
|
||||
|
||||
interface ServiceWorkerCoordinatorOptions {
|
||||
/** 普通 dev 返回 null;production 与显式 dev:pwa 返回实际注册参数。 */
|
||||
registration: ServiceWorkerRegistrationConfig | null
|
||||
/** 创建 Workbox 客户端,由 coordinator 保证全生命周期只调用一次。 */
|
||||
createClient: (registration: ServiceWorkerRegistrationConfig) => ServiceWorkerClient
|
||||
/** 已有 Worker 完成升级激活时通知 UI。 */
|
||||
onUpdateActivated: () => void
|
||||
/** 返回当前页面 controller,用于补偿注册期间完成的快速激活。 */
|
||||
getController?: () => ServiceWorker | null
|
||||
/** 注册失败不阻断应用启动,但必须保留诊断信息。 */
|
||||
onError?: (error: unknown) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据 Vite base 和运行模式解析唯一的 Worker URL、scope 与脚本类型。
|
||||
*/
|
||||
export function resolveServiceWorkerRegistration(
|
||||
baseUrl: string,
|
||||
documentBaseUrl: string,
|
||||
isDevelopment: boolean,
|
||||
pwaDevelopmentEnabled: boolean,
|
||||
): ServiceWorkerRegistrationConfig | null {
|
||||
if (isDevelopment && !pwaDevelopmentEnabled) return null
|
||||
|
||||
const appBaseUrl = new URL(baseUrl, documentBaseUrl)
|
||||
const workerScript = isDevelopment ? 'dev-sw.js?dev-sw' : 'service-worker.js'
|
||||
|
||||
return {
|
||||
scriptUrl: new URL(workerScript, appBaseUrl).href,
|
||||
scope: appBaseUrl.pathname,
|
||||
type: isDevelopment ? 'module' : 'classic',
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建单 owner 的注册协调器;监听器先绑定,随后只执行一次 register。
|
||||
*/
|
||||
export function createServiceWorkerCoordinator(options: ServiceWorkerCoordinatorOptions) {
|
||||
let client: ServiceWorkerClient | null = null
|
||||
let registrationPromise: Promise<ServiceWorkerRegistration | undefined> | null = null
|
||||
let updateNotified = false
|
||||
|
||||
const notifyUpdateActivated = (): void => {
|
||||
if (updateNotified) return
|
||||
updateNotified = true
|
||||
options.onUpdateActivated()
|
||||
}
|
||||
|
||||
const initialize = (): Promise<ServiceWorkerRegistration | undefined> => {
|
||||
if (!options.registration) return Promise.resolve(undefined)
|
||||
if (registrationPromise) return registrationPromise
|
||||
|
||||
const controllerBeforeRegistration = options.getController
|
||||
? options.getController()
|
||||
: navigator.serviceWorker.controller
|
||||
client = options.createClient(options.registration)
|
||||
client.addEventListener('activated', event => {
|
||||
// 其他页签或注册 60 秒后发现的更新会被 Workbox 标为 external,仍代表当前页面需要刷新。
|
||||
if (event.isUpdate || event.isExternal) notifyUpdateActivated()
|
||||
})
|
||||
registrationPromise = client
|
||||
.register()
|
||||
.then(registration => {
|
||||
// skipWaiting + clients.claim 可能在 Workbox 接上底层监听前完成;active 已变化时仍需通知用户刷新。
|
||||
if (controllerBeforeRegistration && registration?.active !== controllerBeforeRegistration) {
|
||||
notifyUpdateActivated()
|
||||
}
|
||||
return registration
|
||||
})
|
||||
.catch(error => {
|
||||
options.onError?.(error)
|
||||
return undefined
|
||||
})
|
||||
|
||||
return registrationPromise
|
||||
}
|
||||
|
||||
const update = async (): Promise<void> => {
|
||||
await initialize()
|
||||
await client?.update()
|
||||
}
|
||||
|
||||
return {
|
||||
initialize,
|
||||
update,
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import type { IndexHtmlTransformContext, IndexHtmlTransformResult, ViteDevServer
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
createDevServiceWorkerCleanupPlugin,
|
||||
deleteCurrentOriginCaches,
|
||||
DEV_SW_CLEANUP_PATH,
|
||||
isManagedServiceWorkerRegistration,
|
||||
isMoviePilotServiceWorkerIdentityResponse,
|
||||
@@ -67,6 +68,19 @@ describe('PWA 开发模式', () => {
|
||||
expect(alwaysFails).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('删除当前 dev origin 的全部 Cache Storage', async () => {
|
||||
const keys = vi.fn().mockResolvedValue(['precache', 'static-resources', 'api-cache'])
|
||||
const deleteCache = vi.fn().mockResolvedValue(true)
|
||||
|
||||
await deleteCurrentOriginCaches({ keys, delete: deleteCache })
|
||||
|
||||
expect(keys).toHaveBeenCalledOnce()
|
||||
expect(deleteCache).toHaveBeenCalledTimes(3)
|
||||
expect(deleteCache).toHaveBeenNthCalledWith(1, 'precache')
|
||||
expect(deleteCache).toHaveBeenNthCalledWith(2, 'static-resources')
|
||||
expect(deleteCache).toHaveBeenNthCalledWith(3, 'api-cache')
|
||||
})
|
||||
|
||||
it('清理完成后只返回当前 origin', () => {
|
||||
const origin = 'http://localhost:5173'
|
||||
|
||||
@@ -112,8 +126,11 @@ describe('PWA 开发模式', () => {
|
||||
expect(scriptContent).toContain("cleanupState === 'complete'")
|
||||
expect(scriptContent).toContain("sessionStorage.setItem(cleanupAttemptKey, 'pending')")
|
||||
expect(scriptContent).toContain('encodeURIComponent(appScope.pathname)')
|
||||
expect(scriptContent).toContain('deleteCurrentOriginCaches(caches)')
|
||||
expect(scriptContent).toContain('location.reload()')
|
||||
expect(scriptContent).not.toContain('caches.delete')
|
||||
expect(scriptContent.indexOf('deleteCurrentOriginCaches(caches)')).toBeLessThan(
|
||||
scriptContent.indexOf('sessionStorage.removeItem(cleanupAttemptKey)'),
|
||||
)
|
||||
})
|
||||
|
||||
it('开发入口标签缺失时立即失败,避免普通 dev 静默白屏', () => {
|
||||
@@ -158,8 +175,17 @@ describe('PWA 开发模式', () => {
|
||||
expect(response.end).toHaveBeenCalledWith(
|
||||
expect.stringContaining("sessionStorage.setItem(cleanupAttemptKey, 'complete')"),
|
||||
)
|
||||
expect(response.end).not.toHaveBeenCalledWith(expect.stringContaining('caches.delete'))
|
||||
expect(response.end).toHaveBeenCalledWith(expect.stringContaining('deleteCurrentOriginCaches(caches)'))
|
||||
expect(response.end).not.toHaveBeenCalledWith(expect.stringContaining('localStorage.clear'))
|
||||
|
||||
const cleanupDocument = vi.mocked(response.end).mock.calls[0]?.[0]
|
||||
if (typeof cleanupDocument !== 'string') throw new TypeError('Expected cleanup document')
|
||||
expect(cleanupDocument.indexOf("sessionStorage.getItem(cleanupAttemptKey) !== 'pending'")).toBeLessThan(
|
||||
cleanupDocument.indexOf('deleteCurrentOriginCaches(caches)'),
|
||||
)
|
||||
expect(cleanupDocument.indexOf('if (!managedRegistrations.length) throw new Error')).toBeLessThan(
|
||||
cleanupDocument.indexOf('deleteCurrentOriginCaches(caches)'),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
+2
-2
@@ -70,8 +70,7 @@ export default defineConfig(({ command, mode, isPreview }) => ({
|
||||
}),
|
||||
!isTestMode(mode) &&
|
||||
VitePWA({
|
||||
injectRegister: 'script',
|
||||
registerType: 'autoUpdate',
|
||||
injectRegister: false,
|
||||
strategies: 'injectManifest',
|
||||
srcDir: 'src',
|
||||
filename: 'service-worker.ts',
|
||||
@@ -211,6 +210,7 @@ export default defineConfig(({ command, mode, isPreview }) => ({
|
||||
'process.env': {},
|
||||
'__APP_VERSION__': JSON.stringify(`v${packageJson.version}`),
|
||||
'__BUILD_TIME__': JSON.stringify(buildTime),
|
||||
'__PWA_DEVELOPMENT__': JSON.stringify(isPwaDevelopmentEnabled(mode, process.env.npm_lifecycle_event)),
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
|
||||
Reference in New Issue
Block a user