לדלג לתוכן

11.5 PWA פתרון

פתרון - PWA

פתרון תרגיל 1 - מניפסט מלא

// public/manifest.json
{
  "name": "מנהל משימות - Task Manager",
  "short_name": "משימות",
  "description": "אפליקציה לניהול משימות יומיות, תכנון פרויקטים ומעקב אחר התקדמות",
  "start_url": "/",
  "display": "standalone",
  "orientation": "portrait",
  "background_color": "#ffffff",
  "theme_color": "#1565c0",
  "dir": "rtl",
  "lang": "he",
  "categories": ["productivity", "utilities"],
  "icons": [
    { "src": "/icons/icon-72.png", "sizes": "72x72", "type": "image/png" },
    { "src": "/icons/icon-96.png", "sizes": "96x96", "type": "image/png" },
    { "src": "/icons/icon-128.png", "sizes": "128x128", "type": "image/png" },
    { "src": "/icons/icon-144.png", "sizes": "144x144", "type": "image/png" },
    { "src": "/icons/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" },
    { "src": "/icons/icon-192-maskable.png", "sizes": "192x192", "type": "image/png", "purpose": "maskable" },
    { "src": "/icons/icon-384.png", "sizes": "384x384", "type": "image/png" },
    { "src": "/icons/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" },
    { "src": "/icons/icon-512-maskable.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
  ],
  "screenshots": [
    {
      "src": "/screenshots/desktop-dashboard.png",
      "sizes": "1280x720",
      "type": "image/png",
      "form_factor": "wide",
      "label": "לוח הבקרה - תצוגת מחשב"
    },
    {
      "src": "/screenshots/mobile-tasks.png",
      "sizes": "375x812",
      "type": "image/png",
      "form_factor": "narrow",
      "label": "רשימת משימות - תצוגת מובייל"
    }
  ],
  "shortcuts": [
    {
      "name": "משימה חדשה",
      "short_name": "חדש",
      "description": "יצירת משימה חדשה",
      "url": "/tasks/new",
      "icons": [{ "src": "/icons/shortcut-new.png", "sizes": "96x96" }]
    },
    {
      "name": "משימות היום",
      "short_name": "היום",
      "description": "הצגת משימות להיום",
      "url": "/tasks/today",
      "icons": [{ "src": "/icons/shortcut-today.png", "sizes": "96x96" }]
    }
  ]
}
// app/layout.tsx
import { type Metadata, type Viewport } from 'next';

export const viewport: Viewport = {
  themeColor: '#1565c0',
  width: 'device-width',
  initialScale: 1,
  viewportFit: 'cover',
};

export const metadata: Metadata = {
  title: 'מנהל משימות',
  description: 'אפליקציה לניהול משימות יומיות',
  manifest: '/manifest.json',
  appleWebApp: {
    capable: true,
    statusBarStyle: 'black-translucent',
    title: 'משימות',
    startupImage: '/icons/apple-splash.png',
  },
  other: {
    'mobile-web-app-capable': 'yes',
  },
};

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="he" dir="rtl">
      <head>
        <link rel="apple-touch-icon" href="/icons/icon-192.png" />
      </head>
      <body>{children}</body>
    </html>
  );
}

פתרון תרגיל 2 - Service Worker בסיסי

// public/sw.js
const CACHE_VERSION = 'v2';
const STATIC_CACHE = `static-${CACHE_VERSION}`;
const DYNAMIC_CACHE = `dynamic-${CACHE_VERSION}`;
const IMAGE_CACHE = `images-${CACHE_VERSION}`;

const STATIC_ASSETS = [
  '/',
  '/offline',
  '/manifest.json',
  '/icons/icon-192.png',
  '/icons/icon-512.png',
];

// התקנה
self.addEventListener('install', (event) => {
  event.waitUntil(
    caches.open(STATIC_CACHE).then((cache) => cache.addAll(STATIC_ASSETS))
  );
  self.skipWaiting();
});

// הפעלה - ניקוי cache ישן
self.addEventListener('activate', (event) => {
  event.waitUntil(
    caches.keys().then((keys) =>
      Promise.all(
        keys
          .filter((key) => !key.endsWith(CACHE_VERSION))
          .map((key) => caches.delete(key))
      )
    )
  );
  self.clients.claim();
});

// Fetch - אסטרטגיה לפי סוג
self.addEventListener('fetch', (event) => {
  const { request } = event;
  const url = new URL(request.url);

  // תמונות - Cache First
  if (request.destination === 'image') {
    event.respondWith(cacheFirst(request, IMAGE_CACHE));
    return;
  }

  // API - Network First
  if (url.pathname.startsWith('/api/')) {
    event.respondWith(networkFirst(request, DYNAMIC_CACHE));
    return;
  }

  // פונטים - Stale While Revalidate
  if (request.destination === 'font' || url.hostname.includes('fonts')) {
    event.respondWith(staleWhileRevalidate(request, STATIC_CACHE));
    return;
  }

  // דפי HTML - Network First עם fallback לאופליין
  if (request.mode === 'navigate') {
    event.respondWith(
      networkFirst(request, DYNAMIC_CACHE).catch(() =>
        caches.match('/offline')
      )
    );
    return;
  }

  // ברירת מחדל - Network First
  event.respondWith(networkFirst(request, DYNAMIC_CACHE));
});

// אסטרטגיות
async function cacheFirst(request, cacheName) {
  const cached = await caches.match(request);
  if (cached) return cached;

  try {
    const response = await fetch(request);
    if (response.ok) {
      const cache = await caches.open(cacheName);
      cache.put(request, response.clone());
    }
    return response;
  } catch {
    return new Response('', { status: 404 });
  }
}

async function networkFirst(request, cacheName) {
  try {
    const response = await fetch(request);
    if (response.ok && request.method === 'GET') {
      const cache = await caches.open(cacheName);
      cache.put(request, response.clone());
    }
    return response;
  } catch {
    const cached = await caches.match(request);
    if (cached) return cached;
    if (request.mode === 'navigate') {
      return caches.match('/offline');
    }
    return new Response(JSON.stringify({ error: 'Offline' }), {
      status: 503,
      headers: { 'Content-Type': 'application/json' },
    });
  }
}

async function staleWhileRevalidate(request, cacheName) {
  const cache = await caches.open(cacheName);
  const cached = await cache.match(request);

  const fetchPromise = fetch(request)
    .then((response) => {
      if (response.ok) {
        cache.put(request, response.clone());
      }
      return response;
    })
    .catch(() => null);

  return cached || (await fetchPromise) || new Response('', { status: 404 });
}

פתרון תרגיל 3 - זיהוי מצב רשת ותמיכה אופליין

// hooks/useOnlineStatus.ts
import { useState, useEffect } from 'react';

function useOnlineStatus() {
  const [isOnline, setIsOnline] = useState(
    typeof navigator !== 'undefined' ? navigator.onLine : true
  );

  useEffect(() => {
    const goOnline = () => setIsOnline(true);
    const goOffline = () => setIsOnline(false);

    window.addEventListener('online', goOnline);
    window.addEventListener('offline', goOffline);

    return () => {
      window.removeEventListener('online', goOnline);
      window.removeEventListener('offline', goOffline);
    };
  }, []);

  return isOnline;
}

export default useOnlineStatus;
// components/OfflineBanner.tsx
'use client';

import useOnlineStatus from '@/hooks/useOnlineStatus';

function OfflineBanner() {
  const isOnline = useOnlineStatus();

  if (isOnline) return null;

  return (
    <div
      role="alert"
      style={{
        position: 'fixed',
        bottom: 0,
        left: 0,
        right: 0,
        padding: '12px 24px',
        backgroundColor: '#424242',
        color: 'white',
        textAlign: 'center',
        zIndex: 9999,
      }}
    >
      אתה במצב אופליין. שינויים יישמרו ויסונכרנו כשהחיבור יחזור.
    </div>
  );
}

export default OfflineBanner;
// lib/actionQueue.ts

interface QueuedAction {
  id: string;
  type: string;
  url: string;
  method: string;
  body: unknown;
  timestamp: number;
}

const QUEUE_KEY = 'offline-action-queue';

export function getQueue(): QueuedAction[] {
  if (typeof window === 'undefined') return [];
  const stored = localStorage.getItem(QUEUE_KEY);
  return stored ? JSON.parse(stored) : [];
}

function saveQueue(queue: QueuedAction[]) {
  localStorage.setItem(QUEUE_KEY, JSON.stringify(queue));
}

export function enqueue(action: Omit<QueuedAction, 'id' | 'timestamp'>) {
  const queue = getQueue();
  queue.push({
    ...action,
    id: crypto.randomUUID(),
    timestamp: Date.now(),
  });
  saveQueue(queue);
}

export function dequeue(id: string) {
  const queue = getQueue().filter((a) => a.id !== id);
  saveQueue(queue);
}

export async function processQueue() {
  const queue = getQueue();
  if (queue.length === 0) return;

  for (const action of queue) {
    try {
      const response = await fetch(action.url, {
        method: action.method,
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(action.body),
      });

      if (response.ok) {
        dequeue(action.id);
      }
    } catch {
      // עדיין אופליין - נמשיך לנסות אחר כך
      break;
    }
  }
}

// Hook לסנכרון אוטומטי
import { useEffect } from 'react';
import useOnlineStatus from '@/hooks/useOnlineStatus';

export function useAutoSync() {
  const isOnline = useOnlineStatus();

  useEffect(() => {
    if (isOnline) {
      processQueue();
    }
  }, [isOnline]);
}
// app/offline/page.tsx
export default function OfflinePage() {
  return (
    <div style={{ textAlign: 'center', padding: '64px 24px', maxWidth: '500px', margin: '0 auto' }}>
      <div style={{ fontSize: '4rem', marginBottom: '24px' }}>X</div>
      <h1>אין חיבור לאינטרנט</h1>
      <p style={{ color: '#666', marginBottom: '24px' }}>
        נראה שאתה לא מחובר לרשת כרגע. האפליקציה תמשיך לעבוד במצב מוגבל -
        שינויים שתבצע יישמרו ויסונכרנו כשהחיבור יחזור.
      </p>
      <button
        onClick={() => window.location.reload()}
        style={{
          padding: '12px 32px',
          backgroundColor: '#1565c0',
          color: 'white',
          border: 'none',
          borderRadius: '8px',
          fontSize: '1rem',
          cursor: 'pointer',
        }}
      >
        נסה להתחבר שוב
      </button>
    </div>
  );
}

פתרון תרגיל 4 - כפתור התקנה מותאם

'use client';

import { useState, useEffect, useCallback } from 'react';

interface BeforeInstallPromptEvent extends Event {
  prompt: () => Promise<void>;
  userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }>;
}

function useInstallPrompt() {
  const [promptEvent, setPromptEvent] = useState<BeforeInstallPromptEvent | null>(null);
  const [isInstalled, setIsInstalled] = useState(false);
  const [isDismissed, setIsDismissed] = useState(false);
  const [isIOS, setIsIOS] = useState(false);

  useEffect(() => {
    // בדיקה אם כבר מותקן
    if (window.matchMedia('(display-mode: standalone)').matches) {
      setIsInstalled(true);
      return;
    }

    // בדיקה אם iOS
    const isIOSDevice = /iPad|iPhone|iPod/.test(navigator.userAgent);
    setIsIOS(isIOSDevice);

    // בדיקה אם דחה לאחרונה
    const dismissedAt = localStorage.getItem('install-dismissed-at');
    if (dismissedAt) {
      const daysSince = (Date.now() - parseInt(dismissedAt)) / (1000 * 60 * 60 * 24);
      if (daysSince < 7) {
        setIsDismissed(true);
      }
    }

    const handler = (e: Event) => {
      e.preventDefault();
      setPromptEvent(e as BeforeInstallPromptEvent);
    };

    window.addEventListener('beforeinstallprompt', handler);
    window.addEventListener('appinstalled', () => setIsInstalled(true));

    return () => window.removeEventListener('beforeinstallprompt', handler);
  }, []);

  const install = useCallback(async () => {
    if (!promptEvent) return;
    await promptEvent.prompt();
    const { outcome } = await promptEvent.userChoice;
    if (outcome === 'accepted') {
      setIsInstalled(true);
    }
    setPromptEvent(null);
  }, [promptEvent]);

  const dismiss = useCallback(() => {
    setIsDismissed(true);
    localStorage.setItem('install-dismissed-at', Date.now().toString());
  }, []);

  const canInstall = !isInstalled && !isDismissed && (!!promptEvent || isIOS);

  return { canInstall, isInstalled, isIOS, install, dismiss };
}

function InstallBanner() {
  const { canInstall, isInstalled, isIOS, install, dismiss } = useInstallPrompt();
  const [showModal, setShowModal] = useState(false);

  if (isInstalled) {
    return null;
  }

  if (!canInstall) return null;

  return (
    <>
      {/* Banner */}
      <div style={{
        padding: '16px 24px',
        backgroundColor: '#e3f2fd',
        borderBottom: '1px solid #bbdefb',
        display: 'flex',
        justifyContent: 'space-between',
        alignItems: 'center',
      }}>
        <div>
          <strong>התקינו את האפליקציה על המכשיר</strong>
          <p style={{ margin: '4px 0 0', fontSize: '0.9em', color: '#555' }}>
            גישה מהירה, עבודה אופליין וביצועים משופרים
          </p>
        </div>
        <div style={{ display: 'flex', gap: '8px' }}>
          <button onClick={() => setShowModal(true)} style={{
            padding: '8px 16px', fontSize: '0.9em', border: 'none',
            background: 'none', color: '#1565c0', cursor: 'pointer',
          }}>
            פרטים נוספים
          </button>
          {isIOS ? (
            <button onClick={() => setShowModal(true)} style={{
              padding: '8px 20px', backgroundColor: '#1565c0', color: 'white',
              border: 'none', borderRadius: '6px', cursor: 'pointer',
            }}>
              כיצד להתקין
            </button>
          ) : (
            <button onClick={install} style={{
              padding: '8px 20px', backgroundColor: '#1565c0', color: 'white',
              border: 'none', borderRadius: '6px', cursor: 'pointer',
            }}>
              התקן
            </button>
          )}
          <button onClick={dismiss} aria-label="סגור" style={{
            padding: '8px', border: 'none', background: 'none', cursor: 'pointer',
          }}>
            X
          </button>
        </div>
      </div>

      {/* מודל */}
      {showModal && (
        <div style={{
          position: 'fixed', inset: 0, backgroundColor: 'rgba(0,0,0,0.5)',
          display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 10000,
        }}>
          <div style={{
            background: 'white', borderRadius: '12px', padding: '32px',
            maxWidth: '450px', width: '90%',
          }}>
            <h2>למה להתקין?</h2>
            <ul style={{ padding: '0 20px', lineHeight: '2' }}>
              <li>גישה מהירה מהמסך הראשי</li>
              <li>עובדת גם ללא אינטרנט</li>
              <li>ביצועים מהירים יותר</li>
              <li>התראות על עדכונים חשובים</li>
              <li>לא תופסת מקום בזיכרון</li>
            </ul>

            {isIOS && (
              <div style={{ backgroundColor: '#f5f5f5', padding: '16px', borderRadius: '8px', marginTop: '16px' }}>
                <h3>הוראות התקנה ב-iPhone/iPad:</h3>
                <ol style={{ paddingInlineStart: '20px', lineHeight: '2' }}>
                  <li>לחצו על כפתור השיתוף (Share) בתחתית המסך</li>
                  <li>גללו ובחרו "Add to Home Screen"</li>
                  <li>לחצו "Add" בפינה העליונה</li>
                </ol>
              </div>
            )}

            <div style={{ display: 'flex', gap: '8px', marginTop: '24px', justifyContent: 'flex-end' }}>
              <button onClick={() => setShowModal(false)} style={{
                padding: '10px 20px', border: '1px solid #ccc', borderRadius: '6px',
                background: 'white', cursor: 'pointer',
              }}>
                סגור
              </button>
              {!isIOS && (
                <button onClick={() => { install(); setShowModal(false); }} style={{
                  padding: '10px 20px', backgroundColor: '#1565c0', color: 'white',
                  border: 'none', borderRadius: '6px', cursor: 'pointer',
                }}>
                  התקן עכשיו
                </button>
              )}
            </div>
          </div>
        </div>
      )}
    </>
  );
}

export default InstallBanner;

פתרון תרגיל 5 - הגדרת next-pwa

npm install next-pwa
// next.config.js
const withPWA = require('next-pwa')({
  dest: 'public',
  register: true,
  skipWaiting: true,
  disable: process.env.NODE_ENV === 'development',
  fallbacks: {
    document: '/offline',
  },
  runtimeCaching: [
    // Google Fonts - Cache First, שנה
    {
      urlPattern: /^https:\/\/fonts\.(?:gstatic|googleapis)\.com\/.*/i,
      handler: 'CacheFirst',
      options: {
        cacheName: 'google-fonts',
        expiration: {
          maxEntries: 20,
          maxAgeSeconds: 365 * 24 * 60 * 60,
        },
        cacheableResponse: {
          statuses: [0, 200],
        },
      },
    },
    // תמונות - Cache First, 30 יום, מקסימום 100
    {
      urlPattern: /\.(?:png|jpg|jpeg|svg|gif|webp|avif|ico)$/i,
      handler: 'CacheFirst',
      options: {
        cacheName: 'images',
        expiration: {
          maxEntries: 100,
          maxAgeSeconds: 30 * 24 * 60 * 60,
        },
        cacheableResponse: {
          statuses: [0, 200],
        },
      },
    },
    // API - Network First, 10 דקות
    {
      urlPattern: /^https?:\/\/.*\/api\/.*/i,
      handler: 'NetworkFirst',
      options: {
        cacheName: 'api-cache',
        expiration: {
          maxEntries: 50,
          maxAgeSeconds: 10 * 60,
        },
        networkTimeoutSeconds: 10,
        cacheableResponse: {
          statuses: [0, 200],
        },
      },
    },
    // דפי HTML - Stale While Revalidate
    {
      urlPattern: /^https?:\/\/.*$/i,
      handler: 'StaleWhileRevalidate',
      options: {
        cacheName: 'pages',
        expiration: {
          maxEntries: 50,
          maxAgeSeconds: 24 * 60 * 60,
        },
        cacheableResponse: {
          statuses: [0, 200],
        },
      },
    },
  ],
});

/** @type {import('next').NextConfig} */
const nextConfig = {
  reactStrictMode: true,
};

module.exports = withPWA(nextConfig);

פתרון תרגיל 6 - עדכון אפליקציה

// hooks/useServiceWorkerUpdate.ts
'use client';

import { useState, useEffect, useCallback } from 'react';

function useServiceWorkerUpdate() {
  const [hasUpdate, setHasUpdate] = useState(false);
  const [registration, setRegistration] = useState<ServiceWorkerRegistration | null>(null);

  useEffect(() => {
    if (!('serviceWorker' in navigator)) return;

    navigator.serviceWorker.ready.then((reg) => {
      setRegistration(reg);

      // בדיקת עדכון בכל ביקור
      reg.update();

      reg.addEventListener('updatefound', () => {
        const newWorker = reg.installing;
        if (!newWorker) return;

        newWorker.addEventListener('statechange', () => {
          if (
            newWorker.state === 'installed' &&
            navigator.serviceWorker.controller
          ) {
            setHasUpdate(true);
          }
        });
      });
    });

    // בדיקה תקופתית - כל שעה
    const interval = setInterval(() => {
      registration?.update();
    }, 60 * 60 * 1000);

    return () => clearInterval(interval);
  }, [registration]);

  const applyUpdate = useCallback(() => {
    if (!registration?.waiting) return;

    registration.waiting.postMessage({ type: 'SKIP_WAITING' });

    navigator.serviceWorker.addEventListener('controllerchange', () => {
      window.location.reload();
    });
  }, [registration]);

  const dismissUpdate = useCallback(() => {
    setHasUpdate(false);
  }, []);

  return { hasUpdate, applyUpdate, dismissUpdate };
}

export default useServiceWorkerUpdate;
// components/UpdateBanner.tsx
'use client';

import { useState } from 'react';
import useServiceWorkerUpdate from '@/hooks/useServiceWorkerUpdate';

function UpdateBanner() {
  const { hasUpdate, applyUpdate, dismissUpdate } = useServiceWorkerUpdate();
  const [showBadge, setShowBadge] = useState(false);

  if (!hasUpdate && !showBadge) return null;

  // Badge קטן אחרי דחייה
  if (showBadge && !hasUpdate) {
    return (
      <button
        onClick={() => {
          setShowBadge(false);
          applyUpdate();
        }}
        aria-label="עדכון זמין"
        style={{
          position: 'fixed',
          bottom: '20px',
          insetInlineEnd: '20px',
          width: '48px',
          height: '48px',
          borderRadius: '50%',
          backgroundColor: '#ff9800',
          color: 'white',
          border: 'none',
          fontSize: '1.2em',
          cursor: 'pointer',
          boxShadow: '0 2px 8px rgba(0,0,0,0.3)',
          zIndex: 9999,
        }}
      >
        !
      </button>
    );
  }

  return (
    <div
      role="alert"
      style={{
        position: 'fixed',
        bottom: '20px',
        left: '50%',
        transform: 'translateX(-50%)',
        backgroundColor: '#323232',
        color: 'white',
        padding: '12px 24px',
        borderRadius: '8px',
        display: 'flex',
        alignItems: 'center',
        gap: '16px',
        boxShadow: '0 4px 12px rgba(0,0,0,0.3)',
        zIndex: 9999,
      }}
    >
      <span>גרסה חדשה זמינה</span>
      <button
        onClick={applyUpdate}
        style={{
          padding: '6px 16px',
          backgroundColor: '#4caf50',
          color: 'white',
          border: 'none',
          borderRadius: '4px',
          cursor: 'pointer',
        }}
      >
        עדכן עכשיו
      </button>
      <button
        onClick={() => {
          dismissUpdate();
          setShowBadge(true);
        }}
        style={{
          padding: '6px 16px',
          backgroundColor: 'transparent',
          color: '#aaa',
          border: '1px solid #555',
          borderRadius: '4px',
          cursor: 'pointer',
        }}
      >
        אחר כך
      </button>
    </div>
  );
}

export default UpdateBanner;
// בתוך ה-Service Worker - טיפול בהודעת SKIP_WAITING
// public/sw.js
self.addEventListener('message', (event) => {
  if (event.data?.type === 'SKIP_WAITING') {
    self.skipWaiting();
  }
});

תשובות לשאלות

1. שלושת התנאים להתקנת PWA:

הדפדפן דורש: (א) קובץ manifest.json תקין עם name, icons, start_url ו-display, (ב) Service Worker רשום ופעיל עם handler ל-fetch, (ג) האתר מוגש דרך HTTPS (או localhost בפיתוח). בנוסף, המשתמש צריך לבקר באתר מספר פעמים לפני שהדפדפן יציע התקנה.

2. אסטרטגיות מטמון:

  • Cache First - בודק קודם ב-cache, רק אם אין שם פונה לרשת. מתאים לתוכן שלא משתנה: תמונות, פונטים, קבצי CSS/JS עם hash.
  • Network First - פונה קודם לרשת, אם נכשל מחזיר מה-cache. מתאים לתוכן דינמי: בקשות API, דפי HTML שמתעדכנים.
  • Stale While Revalidate - מחזיר מיד מה-cache (מהיר) ובמקביל מבקש מהרשת ומעדכן את ה-cache. מתאים לתוכן שצריך להיות מהיר אך גם עדכני: פידים, דפי תוכן.

3. Service Worker מול Web Worker:

Service Worker פועל כ-proxy בין הדפדפן לרשת - הוא מיירט בקשות, מנהל cache ומטפל בהתראות push. הוא נשאר פעיל גם כשהדף סגור. Web Worker לעומת זאת מיועד לביצוע חישובים כבדים ברקע מבלי לחסום את ה-UI. הוא חי ומת עם הדף שיצר אותו.

4. אייקון maskable:

אייקון maskable מאפשר למערכת ההפעלה לחתוך (mask) את האייקון לצורות שונות - עיגול ב-Android, מרובע מעוגל ב-iOS, וכו'. ללא maskable, האייקון יוצג עם רקע לבן סביבו ויראה לא מקצועי. 512x512 נדרש כי מערכות הפעלה משתמשות בגודל הגדול ביותר ומקטינות לפי הצורך, ואייקון קטן מדי ייראה מטושטש.

5. עדכון Service Worker:

כשיש גרסה חדשה, הדפדפן מוריד את ה-SW החדש ומתקין אותו, אך הוא ממתין (waiting) עד שכל הלשוניות עם ה-SW הישן ייסגרו. הדרך המומלצת: (א) זיהוי שיש גרסה חדשה (אירוע updatefound), (ב) הצגת הודעה למשתמש, (ג) כשהמשתמש מאשר - שליחת הודעת SKIP_WAITING ל-SW החדש, (ד) רענון הדף כשה-controller משתנה. חשוב לא לעשות skipWaiting אוטומטי כי זה עלול לשבור דפים שכבר טעונו עם הגרסה הישנה.