From 8a329eb962eb98e89b41708d92b3f9298e4c21e1 Mon Sep 17 00:00:00 2001
From: Owen Jacobson <owen@grimoire.ca>
Date: Fri, 2 May 2025 02:13:19 -0400
Subject: Warn the user before navigating away, when the outbox has messages in
 it.

This is in lieu of saving the outbox. I tried that, and:

* If messages are dropped from the saved outbox before calling `api.postToChannel`, then messages "in flight" are lost when the page is reloaded unless the send succeeds after the client vanishes, as they are not re-sent when the page loads.
* If messages are dropped from the saved outbox after calling `api.postToChannel`, then messages "in flight" are duplicated when the page is reloaded and they get re-sent.

The CAP theorem is real and can hurt you.

The appropriate compensating mechanism would be a client-generated per-operation nonce, with server-side support for replaying responses by nonce if an operation already completed. That's a pretty big undertaking, and it's one we should probably do, but it's larger than I want to take on right now. Instead, we warn the user, and they can make their own decision.

Except we don't, sometimes.

When the client runs in a browser, this event handler prompts the user for confirmation before reloading, navigating away, closing the tab, or quitting. When run in a Safari app container, though, it only warns before reloading. Closing the window or quitting the app do not provoke a prompt. The warning is "best" effort. The failure mode is lost messages, which isn't particularly best.
---
 ui/routes/(app)/+layout.svelte | 38 +++++++++++++++++++++++++++++++++++++-
 1 file changed, 37 insertions(+), 1 deletion(-)

(limited to 'ui/routes/(app)/+layout.svelte')

diff --git a/ui/routes/(app)/+layout.svelte b/ui/routes/(app)/+layout.svelte
index 1ba3fa9..116767d 100644
--- a/ui/routes/(app)/+layout.svelte
+++ b/ui/routes/(app)/+layout.svelte
@@ -14,7 +14,7 @@
   let gesture = null;
 
   const { data, children } = $props();
-  const { session } = data;
+  const { session, outbox } = data;
 
   onMount(session.begin.bind(session));
   onDestroy(session.end.bind(session));
@@ -89,8 +89,44 @@
   async function createChannel(name) {
     await api.createChannel(name);
   }
+
+  function onbeforeunload(event) {
+    if (outbox.pending.length > 0) {
+      // Prompt the user that they have unsaved (unsent) messages that may be lost.
+      event.preventDefault();
+    }
+  }
 </script>
 
+<!--
+  In theory, we [should be][bfcache-why] using an ephemeral event handler for this, rather than
+  leaving it hooked up at all times. Some browsers decide whether a page is eligible for
+  back/forward caching based on whether it has a beforeunload handler (among other factors), and
+  having the event handler registered can slow down navigation on those browsers by forcing a
+  network reload when the page could have been restored from memory.
+
+  Most browsers _apparently_ no longer use that criterion, but I would have been inclined to follow
+  the advice regardless as I don't feel up to the task of cataloguing which browsers it applies to.
+  Unfortunately, it wouldn't matter if we did: SvelteKit itself registers beforeunload handlers, so
+  (at least as of this writing) any work we do to try to dynamically register or unregister
+  beforeunload handlers dynamically state would be wasted effort.
+
+  For posterity, though, the appropriate code for doing so based on outbox state looks like
+
+      $effect(() => {
+        if (outbox.pending.length > 0) {
+          window.addEventListener('beforeunload', onbeforeunload);
+        }
+
+        return () => {
+          window.removeEventListener('beforeunload', onbeforeunload);
+        };
+      });
+
+  [bfcache-why]: https://web.dev/articles/bfcache#beforeunload-caution
+-->
+<svelte:window {onbeforeunload} />
+
 <svelte:head>
   <!-- TODO: unread count? -->
   <title>pilcrow</title>
-- 
cgit v1.2.3

