mirror of
https://github.com/block/buzz.git
synced 2026-10-29 21:41:33 +00:00
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:
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user