Skip to content

Add offline support with service worker and offline page - #27

Open
linus-jansson wants to merge 1 commit into
mainfrom
claude/friendly-hamilton-14s62h
Open

Add offline support with service worker and offline page#27
linus-jansson wants to merge 1 commit into
mainfrom
claude/friendly-hamilton-14s62h

Conversation

@linus-jansson

@linus-jansson linus-jansson commented Jun 10, 2026

Copy link
Copy Markdown
Owner

Summary

This PR adds offline support to the application by implementing a service worker that caches the offline page and serves it when network requests fail.

Key Changes

  • Service Worker (public/sw.js): Implements offline functionality with:

    • Installation phase that caches the offline page
    • Activation phase that cleans up old cache versions
    • Fetch interception that serves the cached offline page when network requests fail
  • Offline Page (public/offline.html): A user-friendly offline fallback page featuring:

    • Responsive design with dark theme matching the app aesthetic
    • Decorative blob background using CSS gradients
    • Clear messaging about the connection loss
    • "Try again" button to reload the page
  • Service Worker Registration (components/ServiceWorkerRegistration.tsx): A client-side React component that registers the service worker on app initialization

  • Layout Integration (app/layout.tsx): Integrated the ServiceWorkerRegistration component into the root layout to ensure the service worker is registered on all pages

Implementation Details

  • The service worker only intercepts navigation requests (event.request.mode === "navigate") to avoid caching API calls or assets
  • Uses a cache-first strategy for the offline page with network fallback
  • Implements cache versioning (limpan-v1) for easy cache invalidation in future updates
  • The offline page uses modern CSS features (viewport units, clamp, radial gradients) for a polished appearance

https://claude.ai/code/session_019bgQ3Du3hfaf2v4WDUmnCv

Summary by CodeRabbit

  • New Features
    • Added offline support to enhance app reliability and user experience. When internet connectivity is unavailable, users will see a dedicated offline page and can access previously cached content. The app resumes normal operation automatically when connectivity is restored.

Registers a service worker that caches /offline.html on install and
serves it when navigation requests fail due to no internet connection.

https://claude.ai/code/session_019bgQ3Du3hfaf2v4WDUmnCv
@vercel

vercel Bot commented Jun 10, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
limpan-dev Ready Ready Preview, Comment Jun 10, 2026 2:56pm

@coderabbitai

coderabbitai Bot commented Jun 10, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

This PR adds offline support to the application. A React component registers a service worker that intercepts navigation requests, attempts network fetches, and serves a pre-cached offline page on network failure. The offline page displays a user-friendly message with a reload button.

Changes

Offline Support Feature

Layer / File(s) Summary
Service Worker lifecycle and fetch interception
public/sw.js
Service worker installs with versioned cache (limpan-v1), pre-caches /offline.html, immediately activates via skipWaiting(), cleans old caches on activate, and intercepts navigation-only fetch requests with network-first strategy fallback to cached offline page.
Offline fallback user interface
public/offline.html
Static HTML page with centered layout, inline CSS styling, decorative blob icon, "You're offline" message, descriptive text, and "Try again" button that reloads the page via location.reload().
Service worker registration and layout integration
components/ServiceWorkerRegistration.tsx, app/layout.tsx
Client component registers /sw.js on mount after checking navigator.serviceWorker support via useEffect with empty dependency array; component is imported and rendered in root layout body.

Estimated code review effort

🎯 2 (Simple) | ⏱️ ~12 minutes

Poem

🐰 A service worker hops into place,
Caching pages across the space,
When the network goes gray and grim,
Offline pages keep the sight bright!
Try again makes users smile. 🌐✨

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The PR title accurately and concisely summarizes the main change: adding offline support through a service worker and offline page, which aligns with all modifications across the changeset.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch claude/friendly-hamilton-14s62h

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 6

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@components/ServiceWorkerRegistration.tsx`:
- Around line 5-13: ServiceWorkerRegistration currently calls
navigator.serviceWorker.register("/sw.js") without handling promise rejections
or update events; wrap the registration in async handling (either make the
effect callback async or handle the returned promise) to add a .catch(...) that
logs registration errors (include the error object) and add logic on the
registration object (e.g., registration.onupdatefound or
registration.installing.onstatechange) to detect and log updates; update
references: ServiceWorkerRegistration and the
navigator.serviceWorker.register(...) call so failures are caught and update
events are observed for better diagnostics.

In `@public/offline.html`:
- Line 86: The decorative emoji in the span with class "icon" should be hidden
from assistive tech; update the span.icon element to include aria-hidden="true"
(and optionally focusable="false") so screen readers ignore the emoji while
keeping visual presentation intact.
- Line 11: The CSS rule uses min-height: 100svh which lacks support in older
browsers; add a fallback by declaring min-height: 100vh before the min-height:
100svh declaration in the same selector (i.e., keep the existing min-height:
100svh but precede it with min-height: 100vh) so browsers that don't understand
100svh will use 100vh while newer browsers will override with 100svh; update the
CSS in public/offline.html where min-height: 100svh is set.

In `@public/sw.js`:
- Around line 22-30: The fetch handler currently responds with
cache.match(OFFLINE_URL) which can be undefined; update the service worker fetch
listener (self.addEventListener("fetch", ...)) so that event.respondWith uses a
Promise that falls back to a constructed Response when cache.match(OFFLINE_URL)
yields no result: after opening CACHE_NAME and attempting
cache.match(OFFLINE_URL), detect undefined and return a simple offline Response
(e.g., a short HTML/text payload with 503 status and appropriate headers) so
event.respondWith never receives undefined; ensure this logic is applied where
fetch(event.request).catch(...) is used and references CACHE_NAME and
OFFLINE_URL.
- Around line 4-9: The install handler calls self.skipWaiting() synchronously,
which can activate the SW before the offline page is cached; update the
"install" event listener so skipWaiting() is chained to the cache population
promise (the caches.open(CACHE_NAME).then(...)) — i.e., call skipWaiting() only
after cache.add(OFFLINE_URL) resolves (use the existing event.waitUntil promise
chain in the install listener) to guarantee OFFLINE_URL is stored before
activation.
- Around line 11-20: In the activate listener
(self.addEventListener("activate")), chain self.clients.claim() into the promise
passed to event.waitUntil so it runs after the cache cleanup completes: extend
the Promise.all(...) chain returned from caches.keys().then(...) to call
self.clients.claim() (or return a Promise that resolves after caches.delete
calls and clients.claim()), ensuring the code paths around CACHE_NAME cleanup
(caches.keys, Promise.all) complete before invoking self.clients.claim().
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 04d11f17-03fd-4f1a-a47d-8303aeaa12da

📥 Commits

Reviewing files that changed from the base of the PR and between d239cdd and ce0fa72.

📒 Files selected for processing (4)
  • app/layout.tsx
  • components/ServiceWorkerRegistration.tsx
  • public/offline.html
  • public/sw.js

Comment on lines +5 to +13
export function ServiceWorkerRegistration() {
useEffect(() => {
if ("serviceWorker" in navigator) {
navigator.serviceWorker.register("/sw.js");
}
}, []);

return null;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Add error handling for service worker registration.

navigator.serviceWorker.register() returns a promise that can reject if the script is invalid, unreachable, or blocked by permissions. Without .catch() or try/catch, registration failures result in unhandled rejections logged to the console with no actionable feedback for debugging or monitoring.

🛡️ Proposed fix with error handling and update detection
 export function ServiceWorkerRegistration() {
   useEffect(() => {
     if ("serviceWorker" in navigator) {
-      navigator.serviceWorker.register("/sw.js");
+      navigator.serviceWorker
+        .register("/sw.js")
+        .then((registration) => {
+          console.log("Service Worker registered:", registration.scope);
+        })
+        .catch((error) => {
+          console.error("Service Worker registration failed:", error);
+        });
     }
   }, []);
 
   return null;
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@components/ServiceWorkerRegistration.tsx` around lines 5 - 13,
ServiceWorkerRegistration currently calls
navigator.serviceWorker.register("/sw.js") without handling promise rejections
or update events; wrap the registration in async handling (either make the
effect callback async or handle the returned promise) to add a .catch(...) that
logs registration errors (include the error object) and add logic on the
registration object (e.g., registration.onupdatefound or
registration.installing.onstatechange) to detect and log updates; update
references: ServiceWorkerRegistration and the
navigator.serviceWorker.register(...) call so failures are caught and update
events are observed for better diagnostics.

Comment thread public/offline.html
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }

body {
min-height: 100svh;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Add 100vh fallback for older browsers.

100svh is not supported in Safari < 15.4 or Chrome < 108. Older browsers will ignore the rule, leaving min-height unset and the layout potentially not filling the viewport.

♻️ Proposed fix
       body {
+        min-height: 100vh;
         min-height: 100svh;
         display: flex;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@public/offline.html` at line 11, The CSS rule uses min-height: 100svh which
lacks support in older browsers; add a fallback by declaring min-height: 100vh
before the min-height: 100svh declaration in the same selector (i.e., keep the
existing min-height: 100svh but precede it with min-height: 100vh) so browsers
that don't understand 100svh will use 100vh while newer browsers will override
with 100svh; update the CSS in public/offline.html where min-height: 100svh is
set.

Comment thread public/offline.html
<body>
<div class="blob"></div>
<div class="content">
<span class="icon">📡</span>

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Mark decorative emoji as aria-hidden.

Screen readers may announce the emoji's Unicode description inconsistently. Since the icon is purely decorative (the heading and text already convey the offline state), hide it from assistive technologies.

♿ Proposed fix
-      <span class="icon">📡</span>
+      <span class="icon" aria-hidden="true">📡</span>
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<span class="icon">📡</span>
<span class="icon" aria-hidden="true">📡</span>
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@public/offline.html` at line 86, The decorative emoji in the span with class
"icon" should be hidden from assistive tech; update the span.icon element to
include aria-hidden="true" (and optionally focusable="false") so screen readers
ignore the emoji while keeping visual presentation intact.

Comment thread public/sw.js
Comment on lines +4 to +9
self.addEventListener("install", (event) => {
event.waitUntil(
caches.open(CACHE_NAME).then((cache) => cache.add(OFFLINE_URL))
);
self.skipWaiting();
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical | ⚡ Quick win

Critical race condition: skipWaiting() must wait for cache to populate.

skipWaiting() is called synchronously outside the install promise chain, so the service worker can activate before /offline.html is cached. This breaks the offline fallback: if a navigation request fails before caching completes, the fetch handler will attempt to serve a non-existent cached response.

🔒 Proposed fix

Chain skipWaiting() to ensure it runs only after the offline page is cached:

 self.addEventListener("install", (event) => {
   event.waitUntil(
-    caches.open(CACHE_NAME).then((cache) => cache.add(OFFLINE_URL))
+    caches.open(CACHE_NAME)
+      .then((cache) => cache.add(OFFLINE_URL))
+      .then(() => self.skipWaiting())
   );
-  self.skipWaiting();
 });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
self.addEventListener("install", (event) => {
event.waitUntil(
caches.open(CACHE_NAME).then((cache) => cache.add(OFFLINE_URL))
);
self.skipWaiting();
});
self.addEventListener("install", (event) => {
event.waitUntil(
caches.open(CACHE_NAME)
.then((cache) => cache.add(OFFLINE_URL))
.then(() => self.skipWaiting())
);
});
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@public/sw.js` around lines 4 - 9, The install handler calls
self.skipWaiting() synchronously, which can activate the SW before the offline
page is cached; update the "install" event listener so skipWaiting() is chained
to the cache population promise (the caches.open(CACHE_NAME).then(...)) — i.e.,
call skipWaiting() only after cache.add(OFFLINE_URL) resolves (use the existing
event.waitUntil promise chain in the install listener) to guarantee OFFLINE_URL
is stored before activation.

Comment thread public/sw.js
Comment on lines +11 to +20
self.addEventListener("activate", (event) => {
event.waitUntil(
caches
.keys()
.then((keys) =>
Promise.all(keys.filter((k) => k !== CACHE_NAME).map((k) => caches.delete(k)))
)
);
self.clients.claim();
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Chain clients.claim() inside the activation promise.

clients.claim() is called synchronously outside the waitUntil promise, so it may execute before stale caches are deleted. While less critical than the install race, this violates the service worker lifecycle contract and could cause the worker to claim clients while still serving outdated cache entries.

♻️ Proposed fix
 self.addEventListener("activate", (event) => {
   event.waitUntil(
     caches
       .keys()
       .then((keys) =>
         Promise.all(keys.filter((k) => k !== CACHE_NAME).map((k) => caches.delete(k)))
       )
+      .then(() => self.clients.claim())
   );
-  self.clients.claim();
 });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
self.addEventListener("activate", (event) => {
event.waitUntil(
caches
.keys()
.then((keys) =>
Promise.all(keys.filter((k) => k !== CACHE_NAME).map((k) => caches.delete(k)))
)
);
self.clients.claim();
});
self.addEventListener("activate", (event) => {
event.waitUntil(
caches
.keys()
.then((keys) =>
Promise.all(keys.filter((k) => k !== CACHE_NAME).map((k) => caches.delete(k)))
)
.then(() => self.clients.claim())
);
});
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@public/sw.js` around lines 11 - 20, In the activate listener
(self.addEventListener("activate")), chain self.clients.claim() into the promise
passed to event.waitUntil so it runs after the cache cleanup completes: extend
the Promise.all(...) chain returned from caches.keys().then(...) to call
self.clients.claim() (or return a Promise that resolves after caches.delete
calls and clients.claim()), ensuring the code paths around CACHE_NAME cleanup
(caches.keys, Promise.all) complete before invoking self.clients.claim().

Comment thread public/sw.js
Comment on lines +22 to +30
self.addEventListener("fetch", (event) => {
if (event.request.mode !== "navigate") return;

event.respondWith(
fetch(event.request).catch(() =>
caches.open(CACHE_NAME).then((cache) => cache.match(OFFLINE_URL))
)
);
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Add defensive fallback when offline page is unavailable.

If cache.match(OFFLINE_URL) returns undefined (e.g., due to the activation race or cache eviction), respondWith receives undefined and the browser displays a generic network error instead of a graceful offline message. While fixing the activation race (lines 4–9) prevents the root cause, adding a fallback response hardens the handler against cache failures.

🛡️ Proposed fix with fallback response
 self.addEventListener("fetch", (event) => {
   if (event.request.mode !== "navigate") return;
 
   event.respondWith(
-    fetch(event.request).catch(() =>
-      caches.open(CACHE_NAME).then((cache) => cache.match(OFFLINE_URL))
+    fetch(event.request).catch(() =>
+      caches.open(CACHE_NAME).then((cache) => 
+        cache.match(OFFLINE_URL).then((response) => 
+          response || new Response("Offline", {
+            status: 503,
+            statusText: "Service Unavailable",
+            headers: new Headers({ "Content-Type": "text/plain" })
+          })
+        )
+      )
     )
   );
 });
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@public/sw.js` around lines 22 - 30, The fetch handler currently responds with
cache.match(OFFLINE_URL) which can be undefined; update the service worker fetch
listener (self.addEventListener("fetch", ...)) so that event.respondWith uses a
Promise that falls back to a constructed Response when cache.match(OFFLINE_URL)
yields no result: after opening CACHE_NAME and attempting
cache.match(OFFLINE_URL), detect undefined and return a simple offline Response
(e.g., a short HTML/text payload with 503 status and appropriate headers) so
event.respondWith never receives undefined; ensure this logic is applied where
fetch(event.request).catch(...) is used and references CACHE_NAME and
OFFLINE_URL.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants