Skip to content

LF-5104 Create and configure custom service worker that can be extended to cache any number of endpoints#4005

Merged
SayakaOno merged 39 commits intointegrationfrom
LF-5104-create-and-configure-custom-service-worker-that-can-be-extended-to-cache-any-number-of-endpoints
Jan 28, 2026
Merged

LF-5104 Create and configure custom service worker that can be extended to cache any number of endpoints#4005
SayakaOno merged 39 commits intointegrationfrom
LF-5104-create-and-configure-custom-service-worker-that-can-be-extended-to-cache-any-number-of-endpoints

Conversation

@kathyavini
Copy link
Copy Markdown
Collaborator

@kathyavini kathyavini commented Jan 22, 2026

Description

This PR adds workbox-background-sync queuing to all the routes matching /task/. The functionality has three parts which I'll elaborate on below.

The custom service worker

(I'm still a complete novice when it comes to service workers, so I hope this explanation is accurate and clear! Please do tell me if I'm missing something 🙏)

Our new custom servicer worker replaces the sw.js that used to be created automatically by VitePWA.

At the bottom of the file we import workbox-background-sync as a plugin, and register the routes we want queued. Defining routes to register can be done without using a custom service worker at all (i.e. using VitePWA's generate-sw strategy as shown in the POC branch in comments here), but we are using the custom service worker in order to completely replace the default onSync handler with our modified version.

Our onSync handler for each queue, in addition to running through the entries in the normal way, also uses the service worker-native method client.postMessage() to share informative messages about each request as it's synced. It's just for this message-posting functionality that we are using a custom service worker at all.

If you have any interest in debugging in the service worker to look around and see what else is available within the workbox-background-sync onSync callback, I'll leave additional notes below specifically about service worker testing. The workbox-background-sync queue items can additionally always be manually inspected within IndexDB.

Note: I couldn't find any documention on how to format an onSync handler -- the code links to the actual class for the workbox Queue, because this was the file referenced by the AI model (I think o4-mini?) who located it when I was building the POC branch, and I haven't found anything better since. The workbox-background-sync documentation site seems too minimal to me; it does mention queue.shiftRequest() and queue.unshiftRequest() methods, but doesn't have anything in terms of putting it all together!

The Listener Component

The <ServiceWorkerListener /> component listens for the messages posted with client.postMessage():

const swContainer = navigator.serviceWorker;

if (swContainer) {
  swContainer.addEventListener('message', handleServiceWorkerMessage);
}

and then handles them according to rules set up for each endpoint within syncConfig.

The syncConfig is organized by area of the application (= the route), and then each area is defined with: a) the snackbar to display on success, b) the snackbar(s) to display on API error responses according to their status code, c) any side effects to run on onSuccess(), and finally the refresh action which is always getTasks() for this part of the application. The side effects come from their respective sagas and were thankfully quite minimal!

If the queue item cannot be cleared (i.e. the response is another ERR_NETWORK) the SYNC_ITEM_FAILURE message is emitted and the listener shows the "network error" snackbar. These items are not cleared from workbox-background-sync queue, but rather will be re-tried with a timing logic that the browser controls. For local testing, you can see this logical branch by shutting down the API.

Saga

The code changes in the saga are the least important 🙂 The saga has absolutely nothing to do with requests being added to the queue; once the workbox-background-sync plugin is active, network errors result in requests being added to the queue at the browser level -- completely outside the scope of our app. The code in the saga therefore just 1) shows the snackbar and 2) applies the optimistic update in Redux where applicable. There is a dedicated optimistic update ticket (linked in comments) to refine that part.

Jira link: https://lite-farm.atlassian.net/browse/LF-5104 & https://lite-farm.atlassian.net/browse/LF-5121 & https://lite-farm.atlassian.net/browse/LF-5115

Type of change

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to not work as expected)
  • This change requires a documentation update

How Has This Been Tested?

  • Passes test case
  • UI components visually reviewed on desktop view
  • UI components visually reviewed on mobile view
  • Other (please explain)

To test the service worker functionality, it is unfortunately always necessary to do pnpm build and pnpm preview (which makes testing code changes painfully slow!) To test the offline behaviour, make sure that "Network request blocking" is checked within the Chrome dev tools under Settings > Preferences > Network

To debugger in the service worker file, you will need to open a dedicated dev tools instance by selecting inspect next to the service worker here:

chrome://inspect/#service-workers

Checklist:

  • I have commented my code, particularly in hard-to-understand areas
  • My changes generate no new warnings
  • I have added tests that prove my fix is effective or that my feature works
  • New and existing unit tests pass locally with my changes
  • The precommit and linting ran successfully
  • I have added or updated language tags for text that's part of the UI
  • I have ordered translation keys alphabetically (optional: run pnpm i18n to help with this)
  • I have added the GNU General Public License to all new files

This is copied from the POC branch except I had added it to offlineDetector on that branch (where it doesn't go; I was just being quick)
I'm not sure I'll stick with it, but this was what was tested before
Pass status and throw generic Error to avoid importing from _private
…ervice-worker-that-can-be-extended-to-cache-any-number-of-endpoints
…ervice-worker-that-can-be-extended-to-cache-any-number-of-endpoints
@kathyavini kathyavini self-assigned this Jan 22, 2026
@kathyavini kathyavini requested review from a team as code owners January 22, 2026 01:08
} catch (e) {
console.log(e);
yield put(enqueueErrorSnackbar(i18n.t('message:ASSIGN_TASK.ERROR')));
if (e.code === 'ERR_NETWORK') {
Copy link
Copy Markdown
Collaborator Author

@kathyavini kathyavini Jan 22, 2026

Choose a reason for hiding this comment

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

Our API going down (as it does, from time to time on release!) will also trigger ERR_NETWORK and therefore add that request to the workbox-background-sync queue. If this happens while the user is online, workbox-background-sync will also sync the queue immediately, which results in the SYNC_ITEM_FAILURE logical branch to be hit right away and two snackbars appearing:

Screenshot 2026-01-21 at 5 04 47 PM

I don't think this is ideal UX. We can't help the request being added to the queue in cases of API failure, but I think I would lean towards only showing the to-be-synced message when the user is offline? Maybe also with a service worker check like in the listener?

if (!navigator.onLine) {
  yield put(enqueuePersistentSuccessSnackbar(i18n.t('message:TASK.UPDATE.SYNC.ONLINE')));
}

This would mean just the network error snackbar showing. I'm not sure that's ideal UX either though, so I would love to discuss next week and resolve on the snackbar ticket.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I'd still prefer not to show the to-be-synced message when the user is online 😂 But if we need to compromise, if (!navigator.onLine) seems reasonable!

Copy link
Copy Markdown
Collaborator Author

@kathyavini kathyavini Jan 27, 2026

Choose a reason for hiding this comment

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

I'd still prefer not to show the to-be-synced message when the user is online

Yeah, I think you're right; this has to be the way!

But if we need to compromise, if (!navigator.onLine) seems reasonable!

Sorry I might be missing something here... is there another way beyond the offline check? Or do you mean you would prefer to hide even the failure sync snackbar?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Sorry for the confusion! I meant the offline check would be a good compromise if we need one; I didn't think about other options.

By the way, did you think about using isOffline state in the Redux store instead of navigator.onLine? I'm so obsessed decision making process in this PR 😂

Copy link
Copy Markdown
Collaborator Author

@kathyavini kathyavini Jan 27, 2026

Choose a reason for hiding this comment

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

By the way, did you think about using isOffline state in the Redux store

Totally; I went back and forth and asked AI 😂 Actually I went against the recommendation which was to use Redux state! Because I liked the second and third pros so much:

Pros of navigator.onLine:

✅ Directness - No dependencies, no indirection
✅ Guaranteed fresh - Always current at the moment of check
✅ Simpler - No extra imports or selectors needed

But for completeness here were the cons which I ignored

❌ Inconsistency - Bypasses your existing offline system
❌ Duplication - Logic exists in two places
❌ Testability - Harder to mock navigator.onLine

It's not a super strong preference though; I'm also okay with

const isOffline = yield select(isOfflineSelector);

if (isOffline) {

Do you like it better to have the single source of truth?

Edit: actually today I have decided I like it! Will update. (And sadly this is pretty representative of my decision-making process: ask AI, look at the two versions of the code, decide based on my feeling in that moment 😬)

Comment thread packages/webapp/src/sw.js
// 1. Immediately take control of the page
self.skipWaiting();
clientsClaim();

Copy link
Copy Markdown
Collaborator Author

@kathyavini kathyavini Jan 22, 2026

Choose a reason for hiding this comment

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

The first several lines of this file (up to the createOnSyncHandler()) take care of the same basic behaviour / static asset caching that the VitePWA-generated service worker did.

@kathyavini kathyavini marked this pull request as ready for review January 22, 2026 22:28
Requests are already pre-filtered by method since it's passed as the third argument to registerRoute
Copy link
Copy Markdown
Collaborator

@SayakaOno SayakaOno left a comment

Choose a reason for hiding this comment

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

Looks great! I really appreciate the detailed explanation and comments 😌
Most of my comments are just to strengthen my understanding.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Do you think this would work as a hook? Now that I look at OfflineDetector, I think it should've been a hook too...

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Hmm I just noticed OfflineDetector is even in the hooks folder already! I totally missed that!

I think I had this weird idea that everything in App.jsx was supposed to be a component in the tree, probably because of <OfflineDetector /> 😂 But yeah these should definitely be hooks; they don't render! 🙏

Update: I don't know if this was a factor with <OfflineDetector /> (shouldn't matter unless the code was different once), but I see now one reason to use a component instead of a hook is to place the logic within the Suspense boundary. The service worker listener calls useTranslation() and actually does crash as a hook without moving the <Suspense />:

Image

To fix the crash I moved the <Suspense /> up to main.jsx... it was a top-level Suspense in App anyway, so that should be equivalent, and it will leave open the option of calling more hooks in App in the future. But please let me know if that seems sound to you!

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Moving up the Suspense sounds good to me, although I haven't really worked with it myself...
The component approach seems like a good secret weapon to have; it was good learning, thank you!! 🙏

Comment on lines +23 to +28
type SyncArea =
| 'tasks.create'
| 'tasks.complete'
| 'tasks.abandon'
| 'tasks.update' // Generic fallback; use for patching date and assignee as well
| 'tasks.delete';
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I'm curious why you went with a type over enum. My go to is enum, so I'm just trying to learn pros and cons!

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Sure! My go-to is union of string literals, so I'd love to hear the reverse side too (why enums). I like the string literals just for developer experience reasons:

  • I find lowercase easier to read quickly than all caps
  • the code usually gets longer when having to include the enum name alongside each variable
  • (not important here, but I think about this when it comes to props) I don't love having to import the enum into a new file in order to get the type checking and autocomplete

But that's about it. And I think I would prefer an enum too if the string name on its own didn't make sense without a grouping structure. Here the string matches the format of the sw exactly, so I felt it would be the opposite (kind of obfuscating to put into an enum).

I'm curious to learn why you lean towards enums in general!

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

My mental model is pretty simple, I basically write enums the way I'd write const objects in JS, and I thought I had to use them for... no good reason 🤷‍♀️ I might have never even considered using union types! But I agree with all three points you mentioned about the downsides of enums, and I can't really find cons of using union types... Sorry, you're not learning anything new from me here 😅 I'll share if I come across good guidelines for when to use each one!

Comment thread packages/webapp/src/sw.js
client.postMessage({
type: 'SYNC_ITEM_FAILURE',
payload: {
area,
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Could we check the URL here to send the specific area (eg., tasks.complete, tasks.abandon) instead of resolving it later? I'm just wondering if that's an option.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

It's definitely an option! The only reason I put the parse in the listener, is that I feel a little more comfortable beefing up/extending the application code, rather than the service worker code. I don't have a super grounded reason for that preference, other than I'm just more familiar with working in the application context over the sw context, and it's easier to debug.

Does it read awkwardly to have the listener parse the URL?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Not at all! That makes sense, the difference between service worker vs App context feels like a big difference, and debugging is what I didn't think of. Thank you 😄

} catch (e) {
console.log(e);
yield put(enqueueErrorSnackbar(i18n.t('message:ASSIGN_TASK.ERROR')));
if (e.code === 'ERR_NETWORK') {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I'd still prefer not to show the to-be-synced message when the user is online 😂 But if we need to compromise, if (!navigator.onLine) seems reasonable!

Copy link
Copy Markdown
Collaborator Author

@kathyavini kathyavini left a comment

Choose a reason for hiding this comment

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

Thanks @SayakaOno! I converted both "components" to hooks and I added the offline check to the to-be-synched when online snackbars; I'll make sure to ask Loïc about the error one tomorrow.

I’ve added some context to the other questions below -- please let me know if those explanations/decisions make sense 🙏

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Hmm I just noticed OfflineDetector is even in the hooks folder already! I totally missed that!

I think I had this weird idea that everything in App.jsx was supposed to be a component in the tree, probably because of <OfflineDetector /> 😂 But yeah these should definitely be hooks; they don't render! 🙏

Update: I don't know if this was a factor with <OfflineDetector /> (shouldn't matter unless the code was different once), but I see now one reason to use a component instead of a hook is to place the logic within the Suspense boundary. The service worker listener calls useTranslation() and actually does crash as a hook without moving the <Suspense />:

Image

To fix the crash I moved the <Suspense /> up to main.jsx... it was a top-level Suspense in App anyway, so that should be equivalent, and it will leave open the option of calling more hooks in App in the future. But please let me know if that seems sound to you!

Comment on lines +23 to +28
type SyncArea =
| 'tasks.create'
| 'tasks.complete'
| 'tasks.abandon'
| 'tasks.update' // Generic fallback; use for patching date and assignee as well
| 'tasks.delete';
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Sure! My go-to is union of string literals, so I'd love to hear the reverse side too (why enums). I like the string literals just for developer experience reasons:

  • I find lowercase easier to read quickly than all caps
  • the code usually gets longer when having to include the enum name alongside each variable
  • (not important here, but I think about this when it comes to props) I don't love having to import the enum into a new file in order to get the type checking and autocomplete

But that's about it. And I think I would prefer an enum too if the string name on its own didn't make sense without a grouping structure. Here the string matches the format of the sw exactly, so I felt it would be the opposite (kind of obfuscating to put into an enum).

I'm curious to learn why you lean towards enums in general!

Comment thread packages/webapp/src/sw.js
client.postMessage({
type: 'SYNC_ITEM_FAILURE',
payload: {
area,
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

It's definitely an option! The only reason I put the parse in the listener, is that I feel a little more comfortable beefing up/extending the application code, rather than the service worker code. I don't have a super grounded reason for that preference, other than I'm just more familiar with working in the application context over the sw context, and it's easier to debug.

Does it read awkwardly to have the listener parse the URL?

} catch (e) {
console.log(e);
yield put(enqueueErrorSnackbar(i18n.t('message:ASSIGN_TASK.ERROR')));
if (e.code === 'ERR_NETWORK') {
Copy link
Copy Markdown
Collaborator Author

@kathyavini kathyavini Jan 27, 2026

Choose a reason for hiding this comment

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

I'd still prefer not to show the to-be-synced message when the user is online

Yeah, I think you're right; this has to be the way!

But if we need to compromise, if (!navigator.onLine) seems reasonable!

Sorry I might be missing something here... is there another way beyond the offline check? Or do you mean you would prefer to hide even the failure sync snackbar?

@kathyavini kathyavini requested a review from SayakaOno January 27, 2026 19:22
"SYNC": {
"FAILED": "Task changes failed to save",
"LOCATION_DELETED": "Task failed to save: location has been retired",
"NETWORK_ERROR": "Unable to reach server. Will retry automatically",
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

"Sorry we are down momentarily, but will retry automatically"
"Sorry we are down momentarily, but will retry your task update/creation automatically"

@kathyavini kathyavini marked this pull request as draft January 28, 2026 18:14
@kathyavini
Copy link
Copy Markdown
Collaborator Author

kathyavini commented Jan 28, 2026

@SayakaOno I think everything that we discussed this morning has been covered and it should now be good for re-review 🙏 Thank you for your help!

Two small notes:

  1. The snackbar messages were adjusted ever so slightly because I was checking with AI for grammatical issues, but they are definitely in the basic form we decided on with Loïc today.
  2. I tested the new manual queue locally in Firefox and it DOES WORK!! 🎉 I'm so relieved 😅 It looks like the Firefox network tab "offline" is pretty much useless for this testing, and there isn't a service worker "offline" like Chrome has, but the File > Work Offline option seems to be exactly what we need. That behaved like Chrome's service worker offline checkbox.

Of course it would still be really good to test this all on beta with devices actually disconnected!

@kathyavini kathyavini marked this pull request as ready for review January 28, 2026 20:41
Comment on lines +205 to +208
if (swContainer) {
swContainer.addEventListener('message', handleServiceWorkerMessage);
window.addEventListener('online', replayQueue);
}
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This looks like a duplicate, should it be removed?

Other changes look good to me! Thank you so much for catching that the Background Sync API isn't supported in all browsers!

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Oooh thank you for catching that ❤️

Copy link
Copy Markdown
Collaborator

@SayakaOno SayakaOno left a comment

Choose a reason for hiding this comment

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

The code looks great! Let's test it on beta!

FYI - I couldn't get it working locally on Firefox... File > Work Offline showed the offline bar, but API requests still work 😅

@SayakaOno SayakaOno added this pull request to the merge queue Jan 28, 2026
Merged via the queue into integration with commit dd6c6a6 Jan 28, 2026
4 of 5 checks passed
@SayakaOno SayakaOno deleted the LF-5104-create-and-configure-custom-service-worker-that-can-be-extended-to-cache-any-number-of-endpoints branch January 28, 2026 22:35
@kathyavini
Copy link
Copy Markdown
Collaborator Author

kathyavini commented Jan 28, 2026

FYI - I couldn't get it working locally on Firefox... File > Work Offline showed the offline bar, but API requests still work 😅

😳 Wait you're totally right. OK I was messing around in Firefox for a while so it might have been some other combination 😂

Buuuut... just saw a synced snackbar on beta 😁😮‍💨

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

Labels

enhancement New feature or request new translations New translations to be sent to CrowdIn are present

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants