Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 49 additions & 1 deletion apps/web/next.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,55 @@ const path = require("path");
const withPWA = require("next-pwa")({
dest: "public",
// Raise the max size to precache large chunks:
maximumFileSizeToCacheInBytes: 8 * 1024 * 1024 // 8MB
maximumFileSizeToCacheInBytes: 8 * 1024 * 1024, // 8MB
// Disable default caching behavior and use custom strategies
disable: false,
// Use a custom worker to handle cache updates
register: true,
skipWaiting: true,
// Add custom service worker source
swSrc: "public/custom-sw.js",
// Customize runtime caching
runtimeCaching: [
// Network-first strategy for JavaScript chunks to prevent stale chunk errors
{
urlPattern: /^https?:\/\/[^/]+\/_next\/static\/chunks\/.+\.js$/,
handler: "NetworkFirst",
options: {
cacheName: "js-chunks",
networkTimeoutSeconds: 10,
expiration: {
maxEntries: 100,
maxAgeSeconds: 24 * 60 * 60 // 24 hours
}
}
},
// Network-first for app routes
{
urlPattern: /^https?:\/\/[^/]+\/_next\/static\/.+$/,
handler: "NetworkFirst",
options: {
cacheName: "next-static-resources",
networkTimeoutSeconds: 10,
expiration: {
maxEntries: 200,
maxAgeSeconds: 24 * 60 * 60 // 24 hours
}
}
},
// Cache-first for other static assets
{
urlPattern: /\.(?:jpg|jpeg|gif|png|svg|ico|webp)$/i,
handler: "CacheFirst",
options: {
cacheName: "static-image-assets",
expiration: {
maxEntries: 64,
maxAgeSeconds: 24 * 60 * 60 // 24 hours
}
}
}
]
});
const appPackage = require("./package.json");
const { v4 } = require("uuid");
Expand Down
41 changes: 41 additions & 0 deletions apps/web/public/custom-sw.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
// Custom service worker event handlers to be injected into the generated service worker

// Listen for messages from the client
self.addEventListener('message', (event) => {
if (event.data && event.data.type === 'SKIP_WAITING') {
console.log('[SW] Received SKIP_WAITING message, activating new service worker');
self.skipWaiting();
}
});

// When the service worker is activated, claim all clients and clean up old caches
self.addEventListener('activate', (event) => {
console.log('[SW] Service worker activated');

event.waitUntil(
Promise.all([
// Claim all clients immediately
self.clients.claim(),

// Clean up old caches
caches.keys().then((cacheNames) => {
return Promise.all(
cacheNames.map((cacheName) => {
// Delete all caches - next-pwa will recreate them with the new content
console.log(`[SW] Deleting cache: ${cacheName}`);
return caches.delete(cacheName);
})
);
})
]).then(() => {
console.log('[SW] All old caches cleared, clients claimed');
})
);
});

// Install event - skip waiting immediately
self.addEventListener('install', (event) => {
console.log('[SW] Service worker installing');
// Skip the waiting phase and activate immediately
self.skipWaiting();
});
114 changes: 114 additions & 0 deletions apps/web/src/app/client-init.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,9 @@ export function ClientInit() {
if (activeUsername) {
setActiveUser(activeUsername);
}

// Setup global error handler for ChunkLoadError
setupChunkLoadErrorHandler();
});

useEffect(() => {
Expand All @@ -43,3 +46,114 @@ export function ClientInit() {

return <></>;
}

/**
* Sets up a global error handler to catch ChunkLoadError exceptions
* which occur when the user has a stale service worker or cached HTML
* that references JavaScript chunks from a previous deployment.
*
* When detected, we reload the page once to fetch the new version.
*/
function setupChunkLoadErrorHandler() {
if (typeof window === "undefined") return;

const RELOAD_KEY = "chunk_reload_attempted";
const RELOAD_EXPIRY = 30000; // 30 seconds

window.addEventListener("error", (event) => {
const error = event.error;

// Check if this is a ChunkLoadError
const isChunkLoadError =
error?.name === "ChunkLoadError" ||
error?.message?.includes("Loading chunk") ||
error?.message?.includes("Failed to fetch dynamically imported module");

if (isChunkLoadError) {
console.error("[ChunkLoadError] Detected chunk load failure:", error);

// Check if we've recently tried to reload
const lastReloadAttempt = sessionStorage.getItem(RELOAD_KEY);
const now = Date.now();

if (lastReloadAttempt) {
const timeSinceReload = now - parseInt(lastReloadAttempt, 10);
if (timeSinceReload < RELOAD_EXPIRY) {
console.warn(
`[ChunkLoadError] Recently reloaded ${timeSinceReload}ms ago, not reloading again`
);
return;
}
}

// Mark that we're about to reload
sessionStorage.setItem(RELOAD_KEY, now.toString());

console.log("[ChunkLoadError] Reloading page to fetch new chunks...");

// Clear all caches and reload
if ("caches" in window) {
caches.keys().then((names) => {
names.forEach((name) => {
console.log(`[ChunkLoadError] Deleting cache: ${name}`);
caches.delete(name);
});
}).finally(() => {
// Force a hard reload to bypass any cache
window.location.reload();
});
} else {
// If Cache API not available, just reload
window.location.reload();
}

// Prevent the error from propagating
event.preventDefault();
}
});

// Also listen for unhandled promise rejections (dynamic imports)
window.addEventListener("unhandledrejection", (event) => {
const error = event.reason;

const isChunkLoadError =
error?.name === "ChunkLoadError" ||
error?.message?.includes("Loading chunk") ||
error?.message?.includes("Failed to fetch dynamically imported module");

if (isChunkLoadError) {
console.error("[ChunkLoadError] Detected chunk load failure in promise:", error);

const lastReloadAttempt = sessionStorage.getItem(RELOAD_KEY);
const now = Date.now();

if (lastReloadAttempt) {
const timeSinceReload = now - parseInt(lastReloadAttempt, 10);
if (timeSinceReload < RELOAD_EXPIRY) {
console.warn(
`[ChunkLoadError] Recently reloaded ${timeSinceReload}ms ago, not reloading again`
);
return;
}
}

sessionStorage.setItem(RELOAD_KEY, now.toString());
console.log("[ChunkLoadError] Reloading page to fetch new chunks...");

if ("caches" in window) {
caches.keys().then((names) => {
names.forEach((name) => {
console.log(`[ChunkLoadError] Deleting cache: ${name}`);
caches.delete(name);
});
}).finally(() => {
window.location.reload();
});
} else {
window.location.reload();
}

event.preventDefault();
}
});
}
2 changes: 2 additions & 0 deletions apps/web/src/app/client-providers.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import "@/polyfills";
import { ClientInit } from "@/app/client-init";
import { ServiceWorkerManager } from "@/app/service-worker-manager";
import { EcencyConfigManager } from "@/config";
import { getQueryClient } from "@/core/react-query";
import { Announcements } from "@/features/announcement";
Expand All @@ -26,6 +27,7 @@ export function ClientProviders(props: PropsWithChildren) {
>
<UIManager>
<ClientInit />
<ServiceWorkerManager />
<EcencyConfigManager.Conditional
condition={({ visionFeatures }) => visionFeatures.userActivityTracking.enabled}
>
Expand Down
88 changes: 88 additions & 0 deletions apps/web/src/app/service-worker-manager.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
"use client";

import { useEffect } from "react";

/**
* ServiceWorkerManager component handles service worker lifecycle and updates.
* It ensures that when a new deployment happens, the old service worker is replaced
* and the page is reloaded to fetch fresh assets.
*/
export function ServiceWorkerManager() {
useEffect(() => {
if (typeof window === "undefined" || !("serviceWorker" in navigator)) {
return;
}

let refreshing = false;

// Listen for the controlling service worker changing and reload the page
navigator.serviceWorker.addEventListener("controllerchange", () => {
if (refreshing) return;
refreshing = true;
console.log("[SW] Controller changed, reloading page to get new assets");
window.location.reload();
});

// Function to check for service worker updates
const checkForUpdates = async () => {
try {
const registration = await navigator.serviceWorker.getRegistration();
if (!registration) {
console.log("[SW] No service worker registration found");
return;
}

// Check for updates
await registration.update();

// If there's a waiting service worker, activate it immediately
if (registration.waiting) {
console.log("[SW] New service worker waiting, activating...");
registration.waiting.postMessage({ type: "SKIP_WAITING" });
}

// Listen for new service workers installing
registration.addEventListener("updatefound", () => {
const newWorker = registration.installing;
if (!newWorker) return;

console.log("[SW] New service worker installing");

newWorker.addEventListener("statechange", () => {
if (newWorker.state === "installed" && navigator.serviceWorker.controller) {
// New service worker is installed and ready
console.log("[SW] New service worker installed, will activate on next page load");

// Tell the service worker to skip waiting and activate immediately
newWorker.postMessage({ type: "SKIP_WAITING" });
}
});
});
} catch (error) {
console.error("[SW] Error checking for updates:", error);
}
};

// Check for updates immediately
checkForUpdates();

// Check for updates every 60 seconds
const intervalId = setInterval(checkForUpdates, 60000);

// Also check when the page becomes visible again
const handleVisibilityChange = () => {
if (!document.hidden) {
checkForUpdates();
}
};

document.addEventListener("visibilitychange", handleVisibilityChange);

return () => {
clearInterval(intervalId);
document.removeEventListener("visibilitychange", handleVisibilityChange);
};
}, []);

return null;
}