diff --git a/web/src/routes/[username]/[workspace]/[collection]/[slug]/+page.svelte b/web/src/routes/[username]/[workspace]/[collection]/[slug]/+page.svelte index 1c444e9c..9b219bb5 100644 --- a/web/src/routes/[username]/[workspace]/[collection]/[slug]/+page.svelte +++ b/web/src/routes/[username]/[workspace]/[collection]/[slug]/+page.svelte @@ -26,6 +26,7 @@ import QuickActionsMenu from '$lib/components/common/QuickActionsMenu.svelte'; import BottomSheet from '$lib/components/common/BottomSheet.svelte'; import ContentSkeleton from '$lib/components/common/ContentSkeleton.svelte'; + import ContentError from '$lib/components/common/ContentError.svelte'; import EditCollectionModal from '$lib/components/collections/EditCollectionModal.svelte'; import ShareDialog from '$lib/components/ShareDialog.svelte'; import { copyToClipboard } from '$lib/utils/clipboard'; @@ -579,6 +580,53 @@ if (collabProvider?.synced) hasEverSynced = true; }); + // Stuck-connecting timeout — if the provider stays in + // `connecting` for >10s without ever syncing, surface the same + // ContentError UI as `offline`. Unconditional reset at the top + // of each effect run gives every fresh provider its own 10s + // grace (covers item navigation, rawMode toggle, force_refresh, + // and retry-driven rebuilds — without this, a stuck-connecting + // flag from a previous provider would carry over and the new + // provider would immediately show error UI). Only the timer + // can flip it back to true. Per Codex review round 1. + let staleConnecting = $state(false); + $effect(() => { + if (!collabProvider) return; + staleConnecting = false; + if (collabProvider.state !== 'connecting' || hasEverSynced) { + return; + } + const t = setTimeout(() => { + if (collabProvider?.state === 'connecting' && !hasEverSynced) { + staleConnecting = true; + } + }, 10_000); + return () => clearTimeout(t); + }); + + // Manual recovery from the initial-connect failure modes + // (staleConnecting / offline-while-!hasEverSynced). The template + // gate restricts retry to cases where `!hasEverSynced`, which + // means the current Y.Doc has never received a sync and therefore + // cannot hold user edits — tearing down the provider is safe. + // (Offline AFTER a successful sync keeps the editor mounted; see + // the gate comment in the template for why.) + // + // Bumps forceRefreshNonce so the collab $effect tears down the + // dead provider and rebuilds. We deliberately do NOT refetch + // items.content here — the lazy-seed on the new Y.Doc reads from + // the already-cached item.content, and the new provider's WS + // replay reconciles against canonical server state via the + // op-log. If the cursor is below MIN the server sends a real + // force_refresh which goes through onForceRefresh (which DOES + // refetch — correctly, because the server is the source of truth + // in that case). Per Codex review rounds 1 and 2 of TASK-1376. + function retryCollabSync() { + if (!item) return; + staleConnecting = false; + forceRefreshNonce += 1; + } + $effect(() => { if (!collabKey) return; const itemId = collabKey; @@ -1658,7 +1706,11 @@ {#if loading} {:else if error} -
{error}
+ {:else if item && collection} + {#if (collabProvider?.state === 'offline' && !hasEverSynced) || staleConnecting} + + {:else if collabProvider?.state === 'connecting' && !hasEverSynced} {:else} {#key `${item.id}:true:${forceRefreshNonce}`}