fix(sidebar): converge stale-at-open state across devices (sections/sort/stars/mutes) (#7805)

Adds bounded-backoff recovery reads to all four sidebar sync hooks
(sections, sort, stars and mutes). A stale relay read at app open now
converges without a user edit or a reconnect.

## What this does

**Shared recovery hook (`useStaleReaderRecovery`).** All four hooks use
one retry effect. It reads immediately on mount, then at 5, 10, 30 and
60 s, staying at 60 s after that. A change of `visibilityState` to
`visible` triggers an immediate read. Reads are single-flight. The
effect restarts whenever `pubkey` or `relayUrl` changes, so a hook that
stays mounted while switching relays drops the old relay's in-flight
reads.

**Guards that stop a recovery read from rolling back a local edit:**

1. **Skip the read while an edit is pending.** If an edit is pending
when a tick starts, the tick skips its read and just schedules the next
one. Without this, the read could return the pre-edit blob after the
edit published and pending cleared. At that point neither apply-time
guard could reject it.
2. **Pending check inside the state updater.** `hasPending()` and the
effect lifetime are re-read inside `setStore(prev => ...)`. That way the
check runs when React executes the updater, not when the response
arrives.
3. **Local-mutation revision.** Each hook counts its local edits. The
count is snapshotted when a read starts and checked again at apply time.
If an edit landed while the read was in flight, the response is
discarded.
4. **Head advanced only after a successful cache write.** A failed
`write*Store()` leaves the same head retryable on the next tick.

**Ownership-aware pending clear.** Each sync manager's
`doPublish(store)` now clears `pendingStore` only while it still holds
that same `store`, on both the identical-payload early return and the
success path. Before, an older publish that finished after a newer edit
had replaced `pendingStore` wiped the newer edit's pending marker, so a
recovery read before the newer debounce could apply the older relay head
over it. `destroy()` still clears unconditionally.

## Scope

Reader-side recovery plus the ownership-aware pending clear. No
outboxes, arbitration or replay changes. It applies to
`useChannelSections`, `useChannelSortPreference`, `useChannelStars` and
`useChannelMutes`.

Related: [#7806](https://github.com/block/buzz/pull/7806)

---------

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
Co-authored-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
This commit is contained in:
Will Pfleger
2026-09-28 14:48:16 -04:00
committed by GitHub
parent f7ff4294fa
commit 52c4c08c9e
29 changed files with 2754 additions and 2285 deletions

View File

@@ -42,6 +42,7 @@ Date.now = () => fakeNow;
const { RelayClient } = await import("./relayClientSession.ts");
const { invokeTauri } = await import("./tauri.ts");
const { PublishCanceledError } = await import("./relayEventPublisher.ts");
const { activateRateLimit, isRateLimited, resetRateLimitGate } = await import(
"./relayRateLimitGate.ts"
);
@@ -351,3 +352,25 @@ test("HTTP failure does not clear an existing WS backoff", async () => {
await deliver(client, ["OK", event.id, true, ""]);
assert.equal(await published, event);
});
test("isCurrent forwarded by publishEvent cancels a rate-limited send", async () => {
reset();
activateRateLimit(4);
const client = connectedClient();
const event = { id: "7".repeat(64), kind: 30078 };
let current = true;
const published = client.publishEvent(
event,
"timed out",
"send failed",
() => current,
);
await Promise.resolve();
assert.equal(sendAttempts.length, 0, "waits behind the gate");
current = false;
resetRateLimitGate();
await assert.rejects(published, PublishCanceledError);
assert.equal(sendAttempts.length, 0, "nothing reached the native send");
assert.equal(client.pendingEvents.size, 0);
assert.equal(pendingTimers.size, 0, "no publication timeout armed");
});

View File

@@ -842,6 +842,7 @@ export class RelayClient {
event: RelayEvent,
timeoutMessage: string,
sendErrorMessage: string,
isCurrent?: () => boolean,
) {
return publishSessionEvent(
{
@@ -859,6 +860,7 @@ export class RelayClient {
event,
timeoutMessage,
sendErrorMessage,
isCurrent,
);
}

View File

@@ -13,18 +13,32 @@ type PublishSession = {
recoverSocketFailure: (error: unknown, fallback: string) => Error;
};
/** Publish once, with one reconnect retry, without crossing session ownership. */
/** Typed so no relay rejection text can be mistaken for a caller's cancel. */
export class PublishCanceledError extends Error {
constructor() {
super("Relay publish canceled by its caller.");
this.name = "PublishCanceledError";
}
}
/**
* Publish once, with one reconnect retry, without crossing session ownership.
* `isCurrent`, when given, is checked immediately before every socket send; a
* false result rejects with `PublishCanceledError` and sends nothing.
*/
export async function publishSessionEvent(
session: PublishSession,
event: RelayEvent,
timeoutMessage: string,
sendErrorMessage: string,
isCurrent?: () => boolean,
): Promise<RelayEvent> {
const publishOwnership = session.ownership();
await waitForRateLimit();
if (publishOwnership !== session.ownership()) {
throw new Error("Relay disconnected for community switch.");
}
if (isCurrent?.() === false) throw new PublishCanceledError();
const publishGeneration = session.generation();
return new Promise<RelayEvent>((resolve, reject) => {
@@ -65,6 +79,12 @@ export async function publishSessionEvent(
"Relay publish was superseded by a session change.",
);
}
if (isCurrent?.() === false) {
window.clearTimeout(timeout);
session.pendingEvents.delete(event.id);
reject(new PublishCanceledError());
return;
}
await session.send(["EVENT", event], retryGeneration);
} catch (retryError) {
if (session.pendingEvents.get(event.id) !== pendingEvent) return;