| import { StrictMode } from 'react'; | |
| import { createRoot } from 'react-dom/client'; | |
| import App from './app'; | |
| import { AppErrorBoundary } from './components/ui/app-error-boundary'; | |
| import { isMobileDevice, isPwaStandalone } from './lib/mobile-detect'; | |
| // Defensive fallback: index.html's inline script already applies data-pwa="standalone" | |
| // before first paint. This re-applies it in case the inline script failed (e.g. | |
| // CSP restrictions or unexpected errors). setAttribute is a no-op if already set. | |
| if (isPwaStandalone) { | |
| document.documentElement.setAttribute('data-pwa', 'standalone'); | |
| } | |
| // Register service worker for PWA support (web mode only) | |
| // Registers immediately (not deferred to load event) so the SW can intercept | |
| // and cache JS/CSS bundle requests during the current page load. When the SW is | |
| // registered inside a 'load' listener, all assets have already downloaded before | |
| // the SW installs, so they can't be cached until warmAssetCache runs later. | |
| // Registering early allows the SW to serve bundles from cache on the NEXT visit. | |
| // | |
| // Note: The SW itself does NOT call skipWaiting() on install, so a newly | |
| // registered SW won't disrupt a live page — it waits for SKIP_WAITING from the | |
| // main thread or for all old-SW tabs to close before activating. | |
| if ('serviceWorker' in navigator && !window.location.protocol.startsWith('file')) { | |
| navigator.serviceWorker | |
| .register('/sw.js', { | |
| // Check for updates on every page load for PWA freshness | |
| updateViaCache: 'none', | |
| }) | |
| .then((registration) => { | |
| // Check for service worker updates periodically | |
| // Mobile: every 60 minutes (saves battery/bandwidth) | |
| // Desktop: every 30 minutes | |
| const updateInterval = isMobileDevice ? 60 * 60 * 1000 : 30 * 60 * 1000; | |
| setInterval(() => { | |
| registration.update().catch(() => { | |
| // Update check failed silently - will try again next interval | |
| }); | |
| }, updateInterval); | |
| // When a new service worker is found, DON'T activate it immediately. | |
| // Instead, wait until the user navigates away or refreshes. This prevents | |
| // the brief reload/flash that occurs when skipWaiting() + clients.claim() | |
| // swaps the SW under a live page (especially noticeable when switching back | |
| // to the PWA on mobile). | |
| // | |
| // The new SW will naturally activate when all tabs using the old SW are closed. | |
| // For urgent updates, we send SKIP_WAITING on fresh page loads (see below). | |
| registration.addEventListener('updatefound', () => { | |
| const newWorker = registration.installing; | |
| if (newWorker) { | |
| newWorker.addEventListener('statechange', () => { | |
| if (newWorker.state === 'installed' && navigator.serviceWorker.controller) { | |
| // A new SW is waiting — show an update banner so long-lived | |
| // sessions can immediately pick up new deployments. | |
| console.debug('[SW] New service worker installed and waiting to activate'); | |
| showUpdateNotification(registration); | |
| } | |
| if (newWorker.state === 'activated') { | |
| // New service worker is active - clean up old immutable cache entries | |
| newWorker.postMessage({ type: 'CACHE_CLEANUP' }); | |
| } | |
| }); | |
| } | |
| }); | |
| // On fresh page loads (not tab-switch-back), if there's a waiting SW, | |
| // tell it to activate now. This is safe because the page is freshly loaded | |
| // and won't flash. This ensures updates are picked up within one page visit. | |
| if (registration.waiting) { | |
| registration.waiting.postMessage({ type: 'SKIP_WAITING' }); | |
| } | |
| // Notify the service worker about mobile mode. | |
| // This enables stale-while-revalidate caching for API responses, | |
| // preventing blank screens caused by failed/slow API fetches on mobile. | |
| if (isMobileDevice && registration.active) { | |
| registration.active.postMessage({ | |
| type: 'SET_MOBILE_MODE', | |
| enabled: true, | |
| }); | |
| } | |
| // Also listen for the SW becoming active (in case it wasn't ready above) | |
| navigator.serviceWorker.addEventListener('controllerchange', () => { | |
| if (isMobileDevice && navigator.serviceWorker.controller) { | |
| navigator.serviceWorker.controller.postMessage({ | |
| type: 'SET_MOBILE_MODE', | |
| enabled: true, | |
| }); | |
| } | |
| }); | |
| // Warm the SW's immutable cache with all page assets during idle time. | |
| // This ensures that when the PWA is evicted from memory and reopened, | |
| // all JS/CSS can be served instantly from SW cache instead of re-downloading. | |
| warmAssetCache(registration); | |
| // Request persistent storage to protect caches from OS eviction. | |
| // Without this, iOS Safari can purge Cache Storage under memory pressure | |
| // or after 7 days of inactivity, forcing a full network reload on next visit. | |
| // This is a best-effort request — the browser may deny it, but it's a no-op | |
| // on browsers that don't support it (no error thrown). | |
| if (navigator.storage?.persist) { | |
| navigator.storage | |
| .persist() | |
| .then((granted) => { | |
| if (granted) { | |
| console.debug('[SW] Persistent storage granted — caches protected from eviction'); | |
| } | |
| }) | |
| .catch(() => { | |
| // Silently ignore — persistent storage is a nice-to-have | |
| }); | |
| } | |
| }) | |
| .catch(() => { | |
| // Service worker registration failed; app still works without it | |
| }); | |
| } | |
| /** | |
| * Show a user-visible notification when a new service worker version is detected. | |
| * The notification offers a "Reload" action that sends SKIP_WAITING to the waiting | |
| * SW and reloads the page once the new SW activates. This ensures long-lived | |
| * sessions can immediately pick up new deployments. | |
| */ | |
| function showUpdateNotification(registration: ServiceWorkerRegistration): void { | |
| // Create a simple DOM-based notification (avoids depending on React rendering) | |
| const banner = document.createElement('div'); | |
| banner.setAttribute('role', 'alert'); | |
| // Read theme-aware colors from CSS custom properties with sensible fallbacks | |
| // so the banner matches the current dark/light theme. | |
| const rootStyle = getComputedStyle(document.documentElement); | |
| const bgColor = rootStyle.getPropertyValue('--background').trim() || '#1a1a2e'; | |
| const fgColor = rootStyle.getPropertyValue('--foreground').trim() || '#e0e0e0'; | |
| const accentColor = rootStyle.getPropertyValue('--primary').trim() || '#6366f1'; | |
| const mutedColor = rootStyle.getPropertyValue('--muted-foreground').trim() || '#888'; | |
| banner.style.cssText = | |
| 'position:fixed;bottom:24px;left:50%;transform:translateX(-50%);z-index:99999;' + | |
| `background:hsl(${bgColor});color:hsl(${fgColor});padding:12px 20px;border-radius:10px;` + | |
| 'display:flex;align-items:center;gap:12px;font-size:14px;' + | |
| 'box-shadow:0 4px 24px rgba(0,0,0,0.3);font-family:system-ui,sans-serif;'; | |
| banner.innerHTML = | |
| '<span>A new version is available.</span>' + | |
| `<button id="sw-update-btn" style="background:hsl(${accentColor});color:hsl(${bgColor});border:none;` + | |
| 'padding:6px 14px;border-radius:6px;cursor:pointer;font-size:13px;font-weight:500;">Reload</button>' + | |
| `<button id="sw-dismiss-btn" style="background:transparent;color:hsl(${mutedColor});border:none;` + | |
| 'padding:4px 8px;cursor:pointer;font-size:18px;line-height:1;" aria-label="Dismiss">×</button>'; | |
| document.body.appendChild(banner); | |
| // Listen for controllerchange to reload after the new SW activates. | |
| // Named handler so it can be cleaned up when the banner is dismissed or after reload. | |
| let reloading = false; | |
| const onControllerChange = () => { | |
| if (!reloading) { | |
| reloading = true; | |
| navigator.serviceWorker.removeEventListener('controllerchange', onControllerChange); | |
| window.location.reload(); | |
| } | |
| }; | |
| navigator.serviceWorker.addEventListener('controllerchange', onControllerChange); | |
| banner.querySelector('#sw-update-btn')?.addEventListener('click', () => { | |
| // Send SKIP_WAITING to the waiting SW — it will call skipWaiting() and | |
| // the controllerchange listener above will reload the page. | |
| registration.waiting?.postMessage({ type: 'SKIP_WAITING' }); | |
| const btn = banner.querySelector('#sw-update-btn') as HTMLButtonElement | null; | |
| if (btn) { | |
| btn.textContent = 'Updating…'; | |
| btn.disabled = true; | |
| } | |
| }); | |
| banner.querySelector('#sw-dismiss-btn')?.addEventListener('click', () => { | |
| navigator.serviceWorker.removeEventListener('controllerchange', onControllerChange); | |
| banner.remove(); | |
| }); | |
| } | |
| /** | |
| * Warm the service worker's immutable cache with all critical page assets. | |
| * Collects URLs from modulepreload, prefetch, stylesheet, and script tags | |
| * and sends them to the SW via PRECACHE_ASSETS for background caching. | |
| * | |
| * This is critical for PWA cold-start performance: when iOS/Android evicts | |
| * the PWA from memory, reopening it needs assets from SW cache. The SW's | |
| * fetch handler caches assets on first access, but on the very first visit | |
| * the SW isn't active yet when assets load. This function bridges that gap | |
| * by explicitly telling the SW "cache these URLs" after registration. | |
| */ | |
| function warmAssetCache(registration: ServiceWorkerRegistration): void { | |
| const idleCallback = | |
| typeof requestIdleCallback !== 'undefined' | |
| ? requestIdleCallback | |
| : (cb: () => void) => setTimeout(cb, 2000); | |
| // CRITICAL_ASSETS are precached at SW install time (see sw.js install handler). | |
| // This function is a complementary backup: it collects asset URLs from the live | |
| // DOM (which includes any assets the browser already fetched on this visit) and | |
| // sends them to the SW for caching via PRECACHE_ASSETS. This covers: | |
| // - Assets that were fetched before the SW was active on the first visit | |
| // - Any assets the install-time precaching missed due to transient failures | |
| // | |
| // No delay needed — warmAssetCache is called after the SW registers (which is | |
| // already deferred until renderer.tsx module evaluation, post-parse). Asset URLs | |
| // are already in the DOM at that point and the SW processes PRECACHE_ASSETS | |
| // asynchronously without blocking the render path. | |
| const doWarm = () => { | |
| const assetUrls: string[] = []; | |
| // Collect ALL asset URLs from the page that should be in the SW cache: | |
| // 1. modulepreload links (critical vendor chunks: react, tanstack, radix, state) | |
| // 2. prefetch links (deferred chunks: icons, reactflow, xterm, codemirror, markdown) | |
| // 3. stylesheet links (main CSS bundle) | |
| // 4. script tags with asset paths (entry point JS) | |
| document.querySelectorAll('link[rel="modulepreload"], link[rel="prefetch"]').forEach((link) => { | |
| const href = (link as HTMLLinkElement).href; | |
| if (href && href.includes('/assets/')) assetUrls.push(href); | |
| }); | |
| document.querySelectorAll('link[rel="stylesheet"]').forEach((link) => { | |
| const href = (link as HTMLLinkElement).href; | |
| if (href && href.includes('/assets/')) assetUrls.push(href); | |
| }); | |
| document.querySelectorAll('script[src*="/assets/"]').forEach((script) => { | |
| const src = (script as HTMLScriptElement).src; | |
| if (src) assetUrls.push(src); | |
| }); | |
| // Send all discovered URLs to the SW for background caching. | |
| // The SW's PRECACHE_ASSETS handler checks cache.match() first, so URLs | |
| // already in the immutable cache won't be re-fetched. | |
| // | |
| // Target the active SW if available; otherwise fall back to the installing/waiting | |
| // SW. On first visit, the SW may still be in 'installing' state when this runs, | |
| // but it can still receive messages and process them once it activates. | |
| const target = registration.active || registration.waiting || registration.installing; | |
| if (assetUrls.length > 0 && target) { | |
| target.postMessage({ | |
| type: 'PRECACHE_ASSETS', | |
| urls: assetUrls, | |
| }); | |
| } | |
| }; | |
| idleCallback(doWarm); | |
| } | |
| // Render the app - prioritize First Contentful Paint | |
| // AppErrorBoundary catches uncaught React errors and shows a friendly error screen | |
| // instead of TanStack Router's default "Something went wrong!" overlay. | |
| createRoot(document.getElementById('app')!).render( | |
| <StrictMode> | |
| <AppErrorBoundary> | |
| <App /> | |
| </AppErrorBoundary> | |
| </StrictMode> | |
| ); | |