fix(sidebar): correct equal-timestamp tie-break in stars, mutes, sort

useChannelStars, useChannelMutes, and useChannelSortPreference carried the
same inverted equal-timestamp comparator as channel sections: applyRemote kept
the largest event id, opposite the relay/database canonical order (created_at
DESC, id ASC -> lowest id wins). Two devices writing the same second could
leave the UI showing an event the relay did not store.

Apply a strictly-lower id and ignore ids >= the last applied, matching the
sections fix and the relay winner across all four 30078 sidebar surfaces. Each
hook gains a regression test: larger-then-lower id delivery at equal timestamp,
lower-id store wins (mutation-checked - reverting >= to <= fails each).

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
This commit is contained in:
Duncan
2026-08-21 20:24:10 -04:00
committed by Will Pfleger
parent 1046e88904
commit d5d83e0909
6 changed files with 297 additions and 3 deletions

View File

@@ -77,3 +77,89 @@ test("same-second mute and unmute mutations survive at capacity", async () => {
relayClient.subscribeToReconnects = originalSubscribeToReconnects;
}
});
// Equal-timestamp tie-break must match the relay's canonical winner
// (`created_at DESC, id ASC` → LOWEST id wins). Deliver the larger id first,
// then the lower id at the same timestamp; the lower id is the stored winner
// and must be applied, not rejected. Reverting applyRemote's `>=` back to `<=`
// wrongly ignores the lower id (the actual relay winner).
test("equal-timestamp tie-break applies the lower event id (relay canonical winner)", async () => {
const { act, cleanup, renderHook } = await import("@testing-library/react");
const { relayClient } = await import("@/shared/api/relayClient");
const { useChannelMutes } = await import("./useChannelMutes.ts");
const origFetch = relayClient.fetchEvents;
const origLive = relayClient.subscribeLive;
const origReconnect = relayClient.subscribeToReconnects;
const origTauri = window.__TAURI_INTERNALS__;
let live = null;
relayClient.fetchEvents = async () => [];
relayClient.subscribeLive = async (_f, cb) => {
live = cb;
return async () => {};
};
relayClient.subscribeToReconnects = () => () => {};
// Decrypt payload keyed off the event id embedded in the ciphertext so each
// delivered event yields a store muting a distinct channel we can assert on.
window.__TAURI_INTERNALS__ = {
invoke: (cmd, args) => {
if (cmd === "nip44_decrypt_from_self") {
const id = args?.ciphertext ?? "";
return Promise.resolve(
JSON.stringify({
version: 1,
channels: { [id]: { muted: true, updatedAt: 0 } },
}),
);
}
return Promise.reject(new Error(`unmocked ${cmd}`));
},
};
const pubkey = "pk-mute-tie";
const relayUrl = "wss://r.tie";
let hook = null;
try {
await act(async () => {
hook = renderHook(() => useChannelMutes(pubkey, relayUrl));
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
});
assert.ok(live, "live subscription installed");
const deliver = async (id) => {
await act(async () => {
live({
id,
pubkey,
created_at: 1000,
content: id, // decrypt echoes this into the muted channel id
kind: 30078,
tags: [["d", "channel-mutes"]],
sig: "s",
});
await Promise.resolve();
await Promise.resolve();
});
};
// Larger id first (applied), then the lower id at the same timestamp — the
// relay's canonical winner, which must NOT be rejected.
await deliver("bbbb");
await deliver("aaaa");
assert.ok(
hook.result.current.mutedChannelIds.has("aaaa"),
"lower event id (relay canonical winner) must be applied, not rejected",
);
hook.unmount();
} finally {
cleanup();
relayClient.fetchEvents = origFetch;
relayClient.subscribeLive = origLive;
relayClient.subscribeToReconnects = origReconnect;
window.__TAURI_INTERNALS__ = origTauri;
}
});

View File

@@ -73,9 +73,13 @@ export function useChannelMutes(
return (prev) => {
if (!pubkey) return prev;
if (remote.createdAt < lastAppliedRemoteTs.current) return prev;
// Equal timestamps: the relay/database break ties by `id ASC` — the
// LOWEST event id is the canonical winner. Apply a strictly-lower id and
// ignore any id >= the last applied, so the UI converges on the same
// event the relay stored rather than the largest id seen.
if (
remote.createdAt === lastAppliedRemoteTs.current &&
remote.eventId <= lastAppliedEventId.current
remote.eventId >= lastAppliedEventId.current
)
return prev;
lastAppliedRemoteTs.current = remote.createdAt;

View File

@@ -0,0 +1,110 @@
import assert from "node:assert/strict";
import { after, before, test } from "node:test";
import { JSDOM } from "jsdom";
const dom = new JSDOM("<!doctype html><html><body></body></html>", {
url: "http://localhost",
});
before(() => {
Object.assign(globalThis, {
document: dom.window.document,
HTMLElement: dom.window.HTMLElement,
IS_REACT_ACT_ENVIRONMENT: true,
window: dom.window,
});
});
after(() => dom.window.close());
// Equal-timestamp tie-break must match the relay's canonical winner
// (`created_at DESC, id ASC` → LOWEST id wins). Deliver the larger id first,
// then the lower id at the same timestamp; the lower id is the stored winner
// and its whole-blob store must replace the applied state. Reverting
// applyRemote's `>=` back to `<=` wrongly ignores the lower id (the relay winner).
test("equal-timestamp tie-break applies the lower event id (relay canonical winner)", async () => {
const { act, cleanup, renderHook } = await import("@testing-library/react");
const { relayClient } = await import("@/shared/api/relayClient");
const { useChannelSortPreference } = await import(
"./useChannelSortPreference.ts"
);
const origFetch = relayClient.fetchEvents;
const origLive = relayClient.subscribeLive;
const origReconnect = relayClient.subscribeToReconnects;
const origTauri = window.__TAURI_INTERNALS__;
let live = null;
relayClient.fetchEvents = async () => [];
relayClient.subscribeLive = async (_f, cb) => {
live = cb;
return async () => {};
};
relayClient.subscribeToReconnects = () => () => {};
// Decrypt payload keyed off the event id embedded in the ciphertext so each
// delivered event yields a store setting a distinct group's mode to "recent".
window.__TAURI_INTERNALS__ = {
invoke: (cmd, args) => {
if (cmd === "nip44_decrypt_from_self") {
const id = args?.ciphertext ?? "";
return Promise.resolve(
JSON.stringify({ version: 1, groups: { [id]: "recent" } }),
);
}
return Promise.reject(new Error(`unmocked ${cmd}`));
},
};
const pubkey = "pk-sort-tie";
const relayUrl = "wss://r.tie";
let hook = null;
try {
await act(async () => {
hook = renderHook(() => useChannelSortPreference(pubkey, relayUrl));
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
});
assert.ok(live, "live subscription installed");
const deliver = async (id) => {
await act(async () => {
live({
id,
pubkey,
created_at: 1000,
content: id, // decrypt echoes this into the group key
kind: 30078,
tags: [["d", "channel-sort"]],
sig: "s",
});
await Promise.resolve();
await Promise.resolve();
});
};
// Larger id first (applied), then the lower id at the same timestamp — the
// relay's canonical winner, whose whole-blob store must replace the state.
await deliver("bbbb");
await deliver("aaaa");
assert.equal(
hook.result.current.sortModeFor("aaaa"),
"recent",
"lower event id (relay canonical winner) must be applied, not rejected",
);
assert.equal(
hook.result.current.sortModeFor("bbbb"),
"alpha",
"larger id's store must be superseded by the lower-id whole-blob winner",
);
hook.unmount();
} finally {
cleanup();
relayClient.fetchEvents = origFetch;
relayClient.subscribeLive = origLive;
relayClient.subscribeToReconnects = origReconnect;
window.__TAURI_INTERNALS__ = origTauri;
}
});

View File

@@ -86,9 +86,13 @@ export function useChannelSortPreference(
return (prev) => {
if (!pubkey) return prev;
if (remote.createdAt < lastAppliedRemoteTs.current) return prev;
// Equal timestamps: the relay/database break ties by `id ASC` — the
// LOWEST event id is the canonical winner. Apply a strictly-lower id and
// ignore any id >= the last applied, so the UI converges on the same
// event the relay stored rather than the largest id seen.
if (
remote.createdAt === lastAppliedRemoteTs.current &&
remote.eventId <= lastAppliedEventId.current
remote.eventId >= lastAppliedEventId.current
)
return prev;
lastAppliedRemoteTs.current = remote.createdAt;

View File

@@ -77,3 +77,89 @@ test("same-second star and unstar mutations survive at capacity", async () => {
relayClient.subscribeToReconnects = originalSubscribeToReconnects;
}
});
// Equal-timestamp tie-break must match the relay's canonical winner
// (`created_at DESC, id ASC` → LOWEST id wins). Deliver the larger id first,
// then the lower id at the same timestamp; the lower id is the stored winner
// and must be applied, not rejected. Reverting applyRemote's `>=` back to `<=`
// wrongly ignores the lower id (the actual relay winner).
test("equal-timestamp tie-break applies the lower event id (relay canonical winner)", async () => {
const { act, cleanup, renderHook } = await import("@testing-library/react");
const { relayClient } = await import("@/shared/api/relayClient");
const { useChannelStars } = await import("./useChannelStars.ts");
const origFetch = relayClient.fetchEvents;
const origLive = relayClient.subscribeLive;
const origReconnect = relayClient.subscribeToReconnects;
const origTauri = window.__TAURI_INTERNALS__;
let live = null;
relayClient.fetchEvents = async () => [];
relayClient.subscribeLive = async (_f, cb) => {
live = cb;
return async () => {};
};
relayClient.subscribeToReconnects = () => () => {};
// Decrypt payload keyed off the event id embedded in the ciphertext so each
// delivered event yields a store starring a distinct channel we can assert on.
window.__TAURI_INTERNALS__ = {
invoke: (cmd, args) => {
if (cmd === "nip44_decrypt_from_self") {
const id = args?.ciphertext ?? "";
return Promise.resolve(
JSON.stringify({
version: 1,
channels: { [id]: { starred: true, updatedAt: 0 } },
}),
);
}
return Promise.reject(new Error(`unmocked ${cmd}`));
},
};
const pubkey = "pk-star-tie";
const relayUrl = "wss://r.tie";
let hook = null;
try {
await act(async () => {
hook = renderHook(() => useChannelStars(pubkey, relayUrl));
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
});
assert.ok(live, "live subscription installed");
const deliver = async (id) => {
await act(async () => {
live({
id,
pubkey,
created_at: 1000,
content: id, // decrypt echoes this into the starred channel id
kind: 30078,
tags: [["d", "channel-stars"]],
sig: "s",
});
await Promise.resolve();
await Promise.resolve();
});
};
// Larger id first (applied), then the lower id at the same timestamp — the
// relay's canonical winner, which must NOT be rejected.
await deliver("bbbb");
await deliver("aaaa");
assert.ok(
hook.result.current.starredChannelIds.has("aaaa"),
"lower event id (relay canonical winner) must be applied, not rejected",
);
hook.unmount();
} finally {
cleanup();
relayClient.fetchEvents = origFetch;
relayClient.subscribeLive = origLive;
relayClient.subscribeToReconnects = origReconnect;
window.__TAURI_INTERNALS__ = origTauri;
}
});

View File

@@ -73,9 +73,13 @@ export function useChannelStars(
return (prev) => {
if (!pubkey) return prev;
if (remote.createdAt < lastAppliedRemoteTs.current) return prev;
// Equal timestamps: the relay/database break ties by `id ASC` — the
// LOWEST event id is the canonical winner. Apply a strictly-lower id and
// ignore any id >= the last applied, so the UI converges on the same
// event the relay stored rather than the largest id seen.
if (
remote.createdAt === lastAppliedRemoteTs.current &&
remote.eventId <= lastAppliedEventId.current
remote.eventId >= lastAppliedEventId.current
)
return prev;
lastAppliedRemoteTs.current = remote.createdAt;