mirror of
https://github.com/block/buzz.git
synced 2026-10-27 04:30:52 +00:00
perf(desktop): persist channel snapshot hash (#5684)
## Summary - persist each relay/identity's complete channel list and server hash as one integrity-checked snapshot - paint the snapshot immediately on cold boot, then revalidate with `knownHash` - fail slow-never-wrong: malformed/legacy/partial snapshots and mismatched not-modified responses force an unhashed full fetch - add sidebar boot diagnostics and deterministic unit/E2E coverage for boot, identity/relay isolation, partial writes, mismatch fallback, and community switches ## Safety invariants - channel list and hash are serialized in one localStorage document and replaced together - snapshot ownership is scoped to normalized relay URL plus identity pubkey - a not-modified response is accepted only when its hash exactly matches the hash describing the available list - any missing or impossible hash/list pairing retries `getChannels(null)` before replacing persistence ## Validation At exact commit `19ca25d23c434cc0b8893a93691aaf4c77794f60` with a clean working tree: - `cd desktop && pnpm check && pnpm typecheck` — passed (existing informational Biome findings only) - `cd desktop && pnpm test` — 4,723 passed - `cd desktop && node --import ./test-loader.mjs --experimental-strip-types --test src/features/channels/channelSnapshot.test.mjs` — 13 passed - `cd desktop && pnpm exec playwright test e2e/sidebar-snapshot.spec.ts --grep "cold boot paints" --repeat-each=5` — 5 passed - `cd desktop && pnpm exec playwright test e2e/sidebar-snapshot.spec.ts` — 8 passed - push hooks repeated desktop check/typecheck and all 4,723 unit tests successfully The Playwright suite uses injected bridge delays. Its roughly 0.5–0.6 s snapshot paint and 3.0 s snapshot-to-live readings are synthetic invariant evidence, not production desktop performance measurements. ## Measurement context The controlled current-main investigation is documented separately in `RESEARCH/DESKTOP_PERF_DEEP_DIVE_2026_08_12.md`; its raw local artifacts are `.scratch/summer-perf-deepdive-results-v2.json`, `.scratch/summer-perf-deepdive-run.log`, `.scratch/summer-perf-deepdive-run-2.log`, and `.scratch/summer-perf-deepdive-build-2.log`. Those original timings are also synthetic Chromium/mock-bridge measurements and are not presented as shipped Tauri/WKWebView or production-relay numbers. ## Latest review delta At exact tip `e8e2b1d617aac7ea008258ad9974bbf8da9cd2eb`, storage-denial reads fail open to the live fetch, hashless retries reject `channels: null` before pair/persistence updates, identity-read failure enables a hashless live fetch, and repeated consumers reuse snapshot parsing/integrity validation by storage key + raw document. The four remaining review nits are deferred as non-blocking follow-ups. Validation at this tip: sidebar snapshot E2E 30/30 serial; desktop unit suite 4,725/4,725; full push gate green (desktop check/typecheck/unit, Rust, Tauri). --------- Signed-off-by: Summer <1fdd3cc104e2911eb3b2da6f97d1b25f4a7f3550ded4492b24ff1d95acd66766@buzz.block.builderlab.xyz> Co-authored-by: Summer <1fdd3cc104e2911eb3b2da6f97d1b25f4a7f3550ded4492b24ff1d95acd66766@buzz.block.builderlab.xyz>
This commit is contained in:
@@ -77,6 +77,7 @@ export default defineConfig({
|
||||
"**/relay-connectivity.spec.ts",
|
||||
"**/unread-pill.spec.ts",
|
||||
"**/sidebar-more-unread-overlap.spec.ts",
|
||||
"**/sidebar-snapshot.spec.ts",
|
||||
"**/home-collapsed-top-chrome.spec.ts",
|
||||
"**/top-chrome-zoom-clearance.spec.ts",
|
||||
"**/thread-unread.spec.ts",
|
||||
|
||||
@@ -3,6 +3,7 @@ import test from "node:test";
|
||||
|
||||
import {
|
||||
channelSnapshotKey,
|
||||
inspectChannelSnapshot,
|
||||
readChannelSnapshot,
|
||||
removeChannelSnapshotForRelay,
|
||||
writeChannelSnapshot,
|
||||
@@ -12,6 +13,10 @@ if (typeof globalThis.window === "undefined") {
|
||||
const storage = new Map();
|
||||
globalThis.window = {
|
||||
localStorage: {
|
||||
get length() {
|
||||
return storage.size;
|
||||
},
|
||||
key: (index) => [...storage.keys()][index] ?? null,
|
||||
getItem: (key) => storage.get(key) ?? null,
|
||||
setItem: (key, value) => storage.set(key, value),
|
||||
removeItem: (key) => storage.delete(key),
|
||||
@@ -42,49 +47,214 @@ function makeChannel(overrides = {}) {
|
||||
}
|
||||
|
||||
const RELAY = "wss://relay.example.com";
|
||||
const OWNER = "ABCDEF";
|
||||
const HASH = "channels-hash-v2";
|
||||
|
||||
test.beforeEach(() => {
|
||||
removeChannelSnapshotForRelay(RELAY);
|
||||
removeChannelSnapshotForRelay("wss://other.example.com");
|
||||
});
|
||||
|
||||
test("channelSnapshotKey: normalizes trailing slash and case", () => {
|
||||
assert.equal(
|
||||
channelSnapshotKey("WSS://Relay.Example.com/"),
|
||||
channelSnapshotKey("wss://relay.example.com"),
|
||||
channelSnapshotKey("WSS://Relay.Example.com/", OWNER),
|
||||
channelSnapshotKey("wss://relay.example.com", OWNER.toLowerCase()),
|
||||
);
|
||||
});
|
||||
|
||||
test("read after write returns the persisted channels", () => {
|
||||
const channels = [makeChannel(), makeChannel({ id: "chan-2", name: "Dev" })];
|
||||
writeChannelSnapshot(RELAY, channels);
|
||||
assert.deepEqual(readChannelSnapshot(RELAY), channels);
|
||||
test("read after write returns the complete list and matching hash", () => {
|
||||
const channels = Array.from({ length: 14 }, (_, index) =>
|
||||
makeChannel({ id: `chan-${index}`, name: `Channel ${index}` }),
|
||||
);
|
||||
|
||||
writeChannelSnapshot(RELAY, OWNER, channels, HASH);
|
||||
|
||||
const snapshot = readChannelSnapshot(RELAY, OWNER.toLowerCase());
|
||||
assert.deepEqual(snapshot, { channels, hash: HASH });
|
||||
assert.equal(snapshot.channels.length, channels.length);
|
||||
|
||||
const { diagnostics } = inspectChannelSnapshot(RELAY, OWNER);
|
||||
assert.equal(diagnostics.presence, "present");
|
||||
assert.equal(diagnostics.channelCount, channels.length);
|
||||
assert.ok(diagnostics.serializedBytes > 0);
|
||||
assert.ok(diagnostics.ageMs >= 0);
|
||||
});
|
||||
|
||||
test("inspection distinguishes absent from invalid snapshots", () => {
|
||||
assert.deepEqual(inspectChannelSnapshot(RELAY, OWNER), {
|
||||
diagnostics: {
|
||||
ageMs: 0,
|
||||
channelCount: 0,
|
||||
presence: "absent",
|
||||
serializedBytes: 0,
|
||||
},
|
||||
snapshot: null,
|
||||
});
|
||||
|
||||
window.localStorage.setItem(channelSnapshotKey(RELAY, OWNER), "not-json{{{");
|
||||
const invalid = inspectChannelSnapshot(RELAY, OWNER);
|
||||
assert.equal(invalid.snapshot, null);
|
||||
assert.equal(invalid.diagnostics.presence, "invalid");
|
||||
assert.ok(invalid.diagnostics.serializedBytes > 0);
|
||||
});
|
||||
|
||||
test("inspection memoizes parsing for repeated reads of the same document", () => {
|
||||
writeChannelSnapshot(RELAY, OWNER, [makeChannel()], "memo-hash");
|
||||
const originalParse = JSON.parse;
|
||||
let parses = 0;
|
||||
JSON.parse = (...args) => {
|
||||
parses += 1;
|
||||
return originalParse(...args);
|
||||
};
|
||||
try {
|
||||
assert.notEqual(inspectChannelSnapshot(RELAY, OWNER).snapshot, null);
|
||||
assert.notEqual(inspectChannelSnapshot(RELAY, OWNER).snapshot, null);
|
||||
assert.equal(parses, 1);
|
||||
} finally {
|
||||
JSON.parse = originalParse;
|
||||
}
|
||||
});
|
||||
|
||||
test("inspection tolerates denied storage without retrying the read", () => {
|
||||
const original = window.localStorage;
|
||||
let reads = 0;
|
||||
window.localStorage = {
|
||||
get length() {
|
||||
return 0;
|
||||
},
|
||||
key: () => null,
|
||||
getItem: () => {
|
||||
reads += 1;
|
||||
throw new DOMException("storage denied", "SecurityError");
|
||||
},
|
||||
setItem: () => {},
|
||||
removeItem: () => {},
|
||||
};
|
||||
try {
|
||||
assert.deepEqual(inspectChannelSnapshot(RELAY, OWNER), {
|
||||
diagnostics: {
|
||||
ageMs: 0,
|
||||
channelCount: 0,
|
||||
presence: "absent",
|
||||
serializedBytes: 0,
|
||||
},
|
||||
snapshot: null,
|
||||
});
|
||||
assert.equal(reads, 1);
|
||||
} finally {
|
||||
window.localStorage = original;
|
||||
}
|
||||
});
|
||||
|
||||
test("write replaces list and hash in lockstep", () => {
|
||||
writeChannelSnapshot(RELAY, OWNER, [makeChannel()], "old-hash");
|
||||
const channels = [
|
||||
makeChannel({ id: "chan-2", name: "Dev" }),
|
||||
makeChannel({ id: "chan-3", name: "Design" }),
|
||||
];
|
||||
|
||||
writeChannelSnapshot(RELAY, OWNER, channels, "new-hash");
|
||||
|
||||
assert.deepEqual(readChannelSnapshot(RELAY, OWNER), {
|
||||
channels,
|
||||
hash: "new-hash",
|
||||
});
|
||||
});
|
||||
|
||||
test("read for an unknown relay returns null", () => {
|
||||
assert.equal(readChannelSnapshot("wss://never-written.example.com"), null);
|
||||
});
|
||||
|
||||
test("read returns null for malformed JSON", () => {
|
||||
window.localStorage.setItem(channelSnapshotKey(RELAY), "not-json{{{");
|
||||
assert.equal(readChannelSnapshot(RELAY), null);
|
||||
});
|
||||
|
||||
test("read returns null for a wrong-version payload", () => {
|
||||
window.localStorage.setItem(
|
||||
channelSnapshotKey(RELAY),
|
||||
JSON.stringify({ version: 2, channels: [makeChannel()] }),
|
||||
assert.equal(
|
||||
readChannelSnapshot("wss://never-written.example.com", OWNER),
|
||||
null,
|
||||
);
|
||||
assert.equal(readChannelSnapshot(RELAY), null);
|
||||
});
|
||||
|
||||
test("read returns null when channels is not an array", () => {
|
||||
test("read rejects malformed JSON", () => {
|
||||
window.localStorage.setItem(channelSnapshotKey(RELAY, OWNER), "not-json{{{");
|
||||
assert.equal(readChannelSnapshot(RELAY, OWNER), null);
|
||||
});
|
||||
|
||||
test("read rejects a legacy channel-only snapshot", () => {
|
||||
window.localStorage.setItem(
|
||||
channelSnapshotKey(RELAY),
|
||||
JSON.stringify({ version: 1, channels: "nope" }),
|
||||
channelSnapshotKey(RELAY, OWNER),
|
||||
JSON.stringify({ version: 1, channels: [makeChannel()] }),
|
||||
);
|
||||
assert.equal(readChannelSnapshot(RELAY), null);
|
||||
assert.equal(readChannelSnapshot(RELAY, OWNER), null);
|
||||
});
|
||||
|
||||
test("remove clears the snapshot for that relay", () => {
|
||||
writeChannelSnapshot(RELAY, [makeChannel()]);
|
||||
test("read rejects a snapshot whose hash and list were partially changed", () => {
|
||||
writeChannelSnapshot(RELAY, OWNER, [makeChannel()], "old-hash");
|
||||
const key = channelSnapshotKey(RELAY, OWNER);
|
||||
const stored = JSON.parse(window.localStorage.getItem(key));
|
||||
window.localStorage.setItem(
|
||||
key,
|
||||
JSON.stringify({ ...stored, hash: "newer-partial-hash" }),
|
||||
);
|
||||
|
||||
assert.equal(readChannelSnapshot(RELAY, OWNER), null);
|
||||
assert.equal(
|
||||
inspectChannelSnapshot(RELAY, OWNER).diagnostics.presence,
|
||||
"invalid",
|
||||
);
|
||||
});
|
||||
|
||||
test("read rejects a v2 snapshot with a missing hash", () => {
|
||||
window.localStorage.setItem(
|
||||
channelSnapshotKey(RELAY, OWNER),
|
||||
JSON.stringify({
|
||||
version: 2,
|
||||
updatedAt: Date.now(),
|
||||
ownerPubkey: OWNER,
|
||||
channels: [makeChannel()],
|
||||
}),
|
||||
);
|
||||
assert.equal(readChannelSnapshot(RELAY, OWNER), null);
|
||||
});
|
||||
|
||||
test("read rejects a snapshot owned by another identity", () => {
|
||||
writeChannelSnapshot(RELAY, OWNER, [makeChannel()], HASH);
|
||||
assert.equal(readChannelSnapshot(RELAY, "different-owner"), null);
|
||||
});
|
||||
|
||||
test("same-relay identities retain independent snapshots", () => {
|
||||
const ownerAChannels = [makeChannel({ id: "owner-a", name: "Owner A" })];
|
||||
const ownerBChannels = [makeChannel({ id: "owner-b", name: "Owner B" })];
|
||||
|
||||
writeChannelSnapshot(RELAY, OWNER, ownerAChannels, "owner-a-hash");
|
||||
writeChannelSnapshot(
|
||||
RELAY,
|
||||
"different-owner",
|
||||
ownerBChannels,
|
||||
"owner-b-hash",
|
||||
);
|
||||
|
||||
assert.deepEqual(readChannelSnapshot(RELAY, OWNER), {
|
||||
channels: ownerAChannels,
|
||||
hash: "owner-a-hash",
|
||||
});
|
||||
assert.deepEqual(readChannelSnapshot(RELAY, "different-owner"), {
|
||||
channels: ownerBChannels,
|
||||
hash: "owner-b-hash",
|
||||
});
|
||||
});
|
||||
|
||||
test("remove clears every identity snapshot for that relay", () => {
|
||||
writeChannelSnapshot(RELAY, OWNER, [makeChannel()], HASH);
|
||||
writeChannelSnapshot(
|
||||
RELAY,
|
||||
"different-owner",
|
||||
[makeChannel({ id: "owner-b" })],
|
||||
"owner-b-hash",
|
||||
);
|
||||
writeChannelSnapshot(
|
||||
"wss://other.example.com",
|
||||
OWNER,
|
||||
[makeChannel({ id: "other-relay" })],
|
||||
"other-relay-hash",
|
||||
);
|
||||
removeChannelSnapshotForRelay(RELAY);
|
||||
assert.equal(readChannelSnapshot(RELAY), null);
|
||||
assert.equal(readChannelSnapshot(RELAY, OWNER), null);
|
||||
assert.equal(readChannelSnapshot(RELAY, "different-owner"), null);
|
||||
assert.notEqual(readChannelSnapshot("wss://other.example.com", OWNER), null);
|
||||
});
|
||||
|
||||
test("cache write evicts disposable entries and retries at quota", () => {
|
||||
@@ -108,8 +278,11 @@ test("cache write evicts disposable entries and retries at quota", () => {
|
||||
removeItem: (key) => storage.delete(key),
|
||||
};
|
||||
try {
|
||||
writeChannelSnapshot(RELAY, [makeChannel()]);
|
||||
assert.deepEqual(readChannelSnapshot(RELAY), [makeChannel()]);
|
||||
writeChannelSnapshot(RELAY, OWNER, [makeChannel()], HASH);
|
||||
assert.deepEqual(readChannelSnapshot(RELAY, OWNER), {
|
||||
channels: [makeChannel()],
|
||||
hash: HASH,
|
||||
});
|
||||
assert.equal(storage.has("buzz-channel-messages.v1:relay:old"), false);
|
||||
assert.equal(storage.has("buzz-timeline-skeleton-shape.v1:old"), false);
|
||||
} finally {
|
||||
@@ -123,7 +296,9 @@ test("write is tolerant of storage failures", () => {
|
||||
throw new Error("quota exceeded");
|
||||
};
|
||||
try {
|
||||
assert.doesNotThrow(() => writeChannelSnapshot(RELAY, [makeChannel()]));
|
||||
assert.doesNotThrow(() =>
|
||||
writeChannelSnapshot(RELAY, OWNER, [makeChannel()], HASH),
|
||||
);
|
||||
} finally {
|
||||
window.localStorage.setItem = original;
|
||||
}
|
||||
|
||||
@@ -7,8 +7,9 @@
|
||||
* channel list per relay so the sidebar can paint instantly from the snapshot
|
||||
* while the live fetch revalidates in the background.
|
||||
*
|
||||
* Keyed per relay URL (not community id) so equivalent URL formatting maps to
|
||||
* one slot and one relay's list never bleeds into another.
|
||||
* Keyed per normalized relay URL and authoritative identity so identities that
|
||||
* share a relay retain independent snapshots without exposing either list to
|
||||
* the other.
|
||||
*/
|
||||
|
||||
import type { Channel } from "@/shared/api/types";
|
||||
@@ -17,57 +18,239 @@ import { setLocalStorageItemWithRecovery } from "@/shared/lib/localStorageQuota"
|
||||
|
||||
const STORAGE_KEY_PREFIX = "buzz-channels.v1";
|
||||
|
||||
export function channelSnapshotKey(relayUrl: string): string {
|
||||
return `${STORAGE_KEY_PREFIX}:${normalizeRelayUrl(relayUrl)}`;
|
||||
export type ChannelSnapshot = {
|
||||
channels: Channel[];
|
||||
hash: string;
|
||||
};
|
||||
|
||||
export type ChannelSnapshotDiagnostics = {
|
||||
ageMs: number;
|
||||
channelCount: number;
|
||||
presence: "absent" | "invalid" | "present";
|
||||
serializedBytes: number;
|
||||
};
|
||||
|
||||
export type ChannelSnapshotReadResult = {
|
||||
diagnostics: ChannelSnapshotDiagnostics;
|
||||
snapshot: ChannelSnapshot | null;
|
||||
};
|
||||
|
||||
type StoredChannelSnapshot = ChannelSnapshot & {
|
||||
version: 2;
|
||||
updatedAt: number;
|
||||
ownerPubkey: string;
|
||||
integrity: string;
|
||||
};
|
||||
|
||||
function channelSnapshotRelayPrefix(relayUrl: string): string {
|
||||
return `${STORAGE_KEY_PREFIX}:${normalizeRelayUrl(relayUrl)}:`;
|
||||
}
|
||||
|
||||
function parseChannelSnapshot(json: unknown): Channel[] | null {
|
||||
export function channelSnapshotKey(
|
||||
relayUrl: string,
|
||||
ownerPubkey: string,
|
||||
): string {
|
||||
return `${channelSnapshotRelayPrefix(relayUrl)}${ownerPubkey.toLowerCase()}`;
|
||||
}
|
||||
|
||||
function snapshotIntegrity(
|
||||
ownerPubkey: string,
|
||||
hash: string,
|
||||
channels: Channel[],
|
||||
): string {
|
||||
const value = JSON.stringify([ownerPubkey.toLowerCase(), hash, channels]);
|
||||
let result = 0x811c9dc5;
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
result ^= value.charCodeAt(index);
|
||||
result = Math.imul(result, 0x01000193);
|
||||
}
|
||||
return (result >>> 0).toString(16).padStart(8, "0");
|
||||
}
|
||||
|
||||
function parseChannelSnapshot(
|
||||
json: unknown,
|
||||
ownerPubkey: string,
|
||||
): ChannelSnapshot | null {
|
||||
if (typeof json !== "object" || json === null) return null;
|
||||
const obj = json as Record<string, unknown>;
|
||||
if (obj.version !== 1 || !Array.isArray(obj.channels)) return null;
|
||||
return obj.channels as Channel[];
|
||||
|
||||
// Version 1 did not record either the list hash or the owning identity. It
|
||||
// cannot safely seed version 2: painting it could expose another identity's
|
||||
// channels, and pairing it with any hash could suppress the corrective read.
|
||||
if (
|
||||
obj.version !== 2 ||
|
||||
!Array.isArray(obj.channels) ||
|
||||
typeof obj.hash !== "string" ||
|
||||
obj.hash.length === 0 ||
|
||||
typeof obj.updatedAt !== "number" ||
|
||||
!Number.isFinite(obj.updatedAt) ||
|
||||
typeof obj.ownerPubkey !== "string" ||
|
||||
obj.ownerPubkey.toLowerCase() !== ownerPubkey.toLowerCase()
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const channels = obj.channels as Channel[];
|
||||
if (
|
||||
typeof obj.integrity !== "string" ||
|
||||
obj.integrity !== snapshotIntegrity(ownerPubkey, obj.hash, channels)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { channels, hash: obj.hash };
|
||||
}
|
||||
|
||||
function serializedBytes(value: string): number {
|
||||
return new TextEncoder().encode(value).byteLength;
|
||||
}
|
||||
|
||||
type ParsedChannelSnapshotCacheEntry = {
|
||||
presence: "invalid" | "present";
|
||||
raw: string;
|
||||
serializedBytes: number;
|
||||
snapshot: ChannelSnapshot | null;
|
||||
updatedAt: number | null;
|
||||
};
|
||||
|
||||
const parsedSnapshotCache = new Map<string, ParsedChannelSnapshotCacheEntry>();
|
||||
|
||||
function snapshotResultFromRaw(
|
||||
key: string,
|
||||
raw: string,
|
||||
ownerPubkey: string,
|
||||
): ChannelSnapshotReadResult {
|
||||
let cached = parsedSnapshotCache.get(key);
|
||||
if (!cached || cached.raw !== raw) {
|
||||
let parsedJson: Record<string, unknown> | null = null;
|
||||
try {
|
||||
parsedJson = JSON.parse(raw) as Record<string, unknown>;
|
||||
} catch {
|
||||
// Malformed snapshots are invalid cache entries, never fatal reads.
|
||||
}
|
||||
const snapshot = parsedJson
|
||||
? parseChannelSnapshot(parsedJson, ownerPubkey)
|
||||
: null;
|
||||
const updatedAt =
|
||||
parsedJson &&
|
||||
typeof parsedJson.updatedAt === "number" &&
|
||||
Number.isFinite(parsedJson.updatedAt)
|
||||
? parsedJson.updatedAt
|
||||
: null;
|
||||
cached = {
|
||||
presence: snapshot ? "present" : "invalid",
|
||||
raw,
|
||||
serializedBytes: serializedBytes(raw),
|
||||
snapshot,
|
||||
updatedAt,
|
||||
};
|
||||
parsedSnapshotCache.set(key, cached);
|
||||
}
|
||||
|
||||
return {
|
||||
diagnostics: {
|
||||
ageMs:
|
||||
cached.updatedAt === null
|
||||
? 0
|
||||
: Math.max(0, Date.now() - cached.updatedAt),
|
||||
channelCount: cached.snapshot?.channels.length ?? 0,
|
||||
presence: cached.presence,
|
||||
serializedBytes: cached.serializedBytes,
|
||||
},
|
||||
snapshot: cached.snapshot,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the cached channel list for a relay, or null when absent or malformed.
|
||||
* Reads the atomic snapshot and reports why it can or cannot seed the sidebar.
|
||||
* Invalid includes malformed, legacy, hashless, partial, and wrong-owner data.
|
||||
* Parsing and integrity validation are memoized by storage key and raw value so
|
||||
* repeated hook consumers do not rescan the same multi-megabyte document.
|
||||
*/
|
||||
export function readChannelSnapshot(relayUrl: string): Channel[] | null {
|
||||
export function inspectChannelSnapshot(
|
||||
relayUrl: string,
|
||||
ownerPubkey: string,
|
||||
): ChannelSnapshotReadResult {
|
||||
const absent: ChannelSnapshotDiagnostics = {
|
||||
ageMs: 0,
|
||||
channelCount: 0,
|
||||
presence: "absent",
|
||||
serializedBytes: 0,
|
||||
};
|
||||
const key = channelSnapshotKey(relayUrl, ownerPubkey);
|
||||
let raw: string | null = null;
|
||||
|
||||
try {
|
||||
const raw = window.localStorage.getItem(channelSnapshotKey(relayUrl));
|
||||
if (!raw) return null;
|
||||
return parseChannelSnapshot(JSON.parse(raw));
|
||||
raw = window.localStorage.getItem(key);
|
||||
if (!raw) return { diagnostics: absent, snapshot: null };
|
||||
return snapshotResultFromRaw(key, raw, ownerPubkey);
|
||||
} catch {
|
||||
return null;
|
||||
// `getItem` itself can throw when WebKit denies storage access. Never retry
|
||||
// that read from the catch path: snapshot hydration is optional and must
|
||||
// degrade to a live fetch rather than escaping into the render tree.
|
||||
return {
|
||||
diagnostics: {
|
||||
...absent,
|
||||
presence: raw === null ? "absent" : "invalid",
|
||||
serializedBytes: raw === null ? 0 : serializedBytes(raw),
|
||||
},
|
||||
snapshot: null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Persists the channel list for a relay. Skips the write when unchanged so the
|
||||
* 60s background refetch does not re-serialize an identical list. Non-fatal on
|
||||
* Reads the atomic channel-list/hash snapshot for an identity and relay, or
|
||||
* null when absent, malformed, legacy, or owned by another identity.
|
||||
*/
|
||||
export function readChannelSnapshot(
|
||||
relayUrl: string,
|
||||
ownerPubkey: string,
|
||||
): ChannelSnapshot | null {
|
||||
return inspectChannelSnapshot(relayUrl, ownerPubkey).snapshot;
|
||||
}
|
||||
|
||||
/**
|
||||
* Persists the complete last successfully fetched channel list and the hash
|
||||
* that describes it as one document. Atomic replacement prevents a stale hash
|
||||
* from ever being paired with a newer list (or vice versa). Non-fatal on
|
||||
* storage failure (e.g. quota exceeded).
|
||||
*/
|
||||
export function writeChannelSnapshot(
|
||||
relayUrl: string,
|
||||
ownerPubkey: string,
|
||||
channels: Channel[],
|
||||
hash: string,
|
||||
): void {
|
||||
try {
|
||||
const key = channelSnapshotKey(relayUrl);
|
||||
if (!ownerPubkey || !hash) return;
|
||||
|
||||
const key = channelSnapshotKey(relayUrl, ownerPubkey);
|
||||
const previous = window.localStorage.getItem(key);
|
||||
if (previous) {
|
||||
try {
|
||||
const parsed = parseChannelSnapshot(JSON.parse(previous));
|
||||
if (parsed && JSON.stringify(parsed) === JSON.stringify(channels))
|
||||
const parsed = parseChannelSnapshot(JSON.parse(previous), ownerPubkey);
|
||||
if (
|
||||
parsed &&
|
||||
parsed.hash === hash &&
|
||||
JSON.stringify(parsed.channels) === JSON.stringify(channels)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// Malformed snapshots are replaced below.
|
||||
}
|
||||
}
|
||||
const serialized = JSON.stringify({
|
||||
version: 1,
|
||||
|
||||
const snapshot: StoredChannelSnapshot = {
|
||||
version: 2,
|
||||
updatedAt: Date.now(),
|
||||
ownerPubkey: ownerPubkey.toLowerCase(),
|
||||
hash,
|
||||
channels,
|
||||
});
|
||||
setLocalStorageItemWithRecovery(key, serialized);
|
||||
integrity: snapshotIntegrity(ownerPubkey, hash, channels),
|
||||
};
|
||||
setLocalStorageItemWithRecovery(key, JSON.stringify(snapshot));
|
||||
} catch {
|
||||
// Storage access failures are non-fatal.
|
||||
}
|
||||
@@ -78,7 +261,15 @@ export function writeChannelSnapshot(
|
||||
*/
|
||||
export function removeChannelSnapshotForRelay(relayUrl: string): void {
|
||||
try {
|
||||
window.localStorage.removeItem(channelSnapshotKey(relayUrl));
|
||||
const prefix = channelSnapshotRelayPrefix(relayUrl);
|
||||
const keys: string[] = [];
|
||||
for (let index = 0; index < window.localStorage.length; index += 1) {
|
||||
const key = window.localStorage.key(index);
|
||||
if (key?.startsWith(prefix)) keys.push(key);
|
||||
}
|
||||
for (const key of keys) window.localStorage.removeItem(key);
|
||||
// Also clear the superseded relay-only v1/v2 slot.
|
||||
window.localStorage.removeItem(prefix.slice(0, -1));
|
||||
} catch {
|
||||
// Storage access failures are non-fatal.
|
||||
}
|
||||
|
||||
@@ -3,7 +3,9 @@ import test from "node:test";
|
||||
|
||||
import {
|
||||
applyLastMessages,
|
||||
canFetchChannelsForIdentity,
|
||||
reconcileRefreshedCachedChannel,
|
||||
requireFullChannelList,
|
||||
upsertCachedChannel,
|
||||
upsertCachedChannelMember,
|
||||
} from "./hooks.ts";
|
||||
@@ -110,6 +112,21 @@ test("reconcileRefreshedCachedChannel_restoresOpenedDmAfterStaleRefresh", () =>
|
||||
assert.deepEqual(reconciled[0], openedDm);
|
||||
});
|
||||
|
||||
test("identity failure enables a hashless live channel fetch", () => {
|
||||
assert.equal(canFetchChannelsForIdentity(null, false), false);
|
||||
assert.equal(canFetchChannelsForIdentity("owner-pubkey", false), true);
|
||||
assert.equal(canFetchChannelsForIdentity(null, true), true);
|
||||
});
|
||||
|
||||
test("hashless retry rejects null channels before persistence", () => {
|
||||
const channels = [makeChannel("general", "General")];
|
||||
assert.strictEqual(requireFullChannelList(channels), channels);
|
||||
assert.throws(
|
||||
() => requireFullChannelList(null),
|
||||
/no list for a hashless request/,
|
||||
);
|
||||
});
|
||||
|
||||
// ── applyLastMessages ─────────────────────────────────────────────────────────
|
||||
|
||||
test("applyLastMessages_preservesReferenceWhenTimestampUnchanged", () => {
|
||||
|
||||
@@ -37,7 +37,8 @@ import { useFocusedRefetchInterval } from "@/shared/lib/useDocumentVisible";
|
||||
import { useCommunities } from "@/features/communities/useCommunities";
|
||||
import { canAddChannelMembers } from "@/features/channels/lib/channelMemberAdmission";
|
||||
import {
|
||||
readChannelSnapshot,
|
||||
inspectChannelSnapshot,
|
||||
type ChannelSnapshot,
|
||||
writeChannelSnapshot,
|
||||
} from "@/features/channels/channelSnapshot";
|
||||
|
||||
@@ -53,12 +54,10 @@ export const channelsFocusRefetchPolicy = {
|
||||
refetchOnWindowFocus: false,
|
||||
} as const;
|
||||
/**
|
||||
* Query-cache key for the channels payload hash. Stored alongside the channel
|
||||
* list so its lifecycle is tied to the channel cache — a community switch that
|
||||
* evicts the channel cache implicitly invalidates the stored hash, preventing a
|
||||
* stale hash from short-circuiting into an empty list.
|
||||
* Authoritative server list/hash pair. Presentation mutations may patch
|
||||
* `channelsQueryKey`, but may never change or be persisted with this hash.
|
||||
*/
|
||||
const channelsHashKey = ["channels", "_hash"] as const;
|
||||
const channelsSnapshotPairKey = ["channels", "_snapshot-pair"] as const;
|
||||
const channelDetailQueryKey = (channelId: string) =>
|
||||
["channels", channelId, "detail"] as const;
|
||||
const channelMembersQueryKey = (channelId: string) =>
|
||||
@@ -88,6 +87,73 @@ function sortChannels(channels: Channel[]) {
|
||||
});
|
||||
}
|
||||
|
||||
export const CHANNELS_SNAPSHOT_DIAGNOSTIC_MARK =
|
||||
"buzz:sidebar:snapshot-diagnostic";
|
||||
export const CHANNELS_FULL_SIDEBAR_PAINT_MARK =
|
||||
"buzz:sidebar:full-list-painted";
|
||||
export const CHANNELS_BOOT_TO_FULL_SIDEBAR_MEASURE =
|
||||
"buzz:sidebar:boot-to-full-list-painted";
|
||||
|
||||
const markedSnapshotKeys = new Set<string>();
|
||||
const measuredSidebarKeys = new Set<string>();
|
||||
const scheduledSidebarKeys = new Set<string>();
|
||||
|
||||
function sidebarMeasurementKey(relayUrl: string, ownerPubkey: string): string {
|
||||
return `${relayUrl}\u0000${ownerPubkey.toLowerCase()}`;
|
||||
}
|
||||
|
||||
function markSnapshotDiagnostic(
|
||||
relayUrl: string,
|
||||
ownerPubkey: string,
|
||||
diagnostics: ReturnType<typeof inspectChannelSnapshot>["diagnostics"],
|
||||
): void {
|
||||
if (typeof performance === "undefined") return;
|
||||
const key = sidebarMeasurementKey(relayUrl, ownerPubkey);
|
||||
if (markedSnapshotKeys.has(key)) return;
|
||||
markedSnapshotKeys.add(key);
|
||||
performance.mark(CHANNELS_SNAPSHOT_DIAGNOSTIC_MARK, {
|
||||
detail: { ...diagnostics, relayUrl },
|
||||
});
|
||||
console.info("[sidebar-perf] snapshot", { ...diagnostics, relayUrl });
|
||||
}
|
||||
|
||||
function measureFullSidebarPaint(
|
||||
relayUrl: string,
|
||||
ownerPubkey: string,
|
||||
channelCount: number,
|
||||
): void {
|
||||
if (typeof performance === "undefined") return;
|
||||
const key = sidebarMeasurementKey(relayUrl, ownerPubkey);
|
||||
if (measuredSidebarKeys.has(key) || scheduledSidebarKeys.has(key)) return;
|
||||
scheduledSidebarKeys.add(key);
|
||||
|
||||
// The channels have committed to the shared query cache; two animation frames
|
||||
// put the mark after React's sidebar DOM commit and the browser's next paint.
|
||||
window.requestAnimationFrame(() => {
|
||||
window.requestAnimationFrame(() => {
|
||||
scheduledSidebarKeys.delete(key);
|
||||
if (measuredSidebarKeys.has(key)) return;
|
||||
measuredSidebarKeys.add(key);
|
||||
performance.mark(CHANNELS_FULL_SIDEBAR_PAINT_MARK, {
|
||||
detail: { channelCount, relayUrl },
|
||||
});
|
||||
performance.measure(CHANNELS_BOOT_TO_FULL_SIDEBAR_MEASURE, {
|
||||
detail: { channelCount, relayUrl },
|
||||
duration: performance.now(),
|
||||
start: 0,
|
||||
});
|
||||
const measure = performance
|
||||
.getEntriesByName(CHANNELS_BOOT_TO_FULL_SIDEBAR_MEASURE)
|
||||
.at(-1);
|
||||
console.info("[sidebar-perf] full list painted", {
|
||||
channelCount,
|
||||
durationMs: measure?.duration,
|
||||
relayUrl,
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export type CachedChannelMember = {
|
||||
membershipAdded: boolean;
|
||||
name: string;
|
||||
@@ -227,69 +293,162 @@ export function applyLastMessages(
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* A failed identity read must disable persisted snapshots, not the live channel
|
||||
* request. The backend still resolves its authoritative current identity.
|
||||
*/
|
||||
export function canFetchChannelsForIdentity(
|
||||
ownerPubkey: string | null,
|
||||
identityReadFailed: boolean,
|
||||
): boolean {
|
||||
return ownerPubkey !== null || identityReadFailed;
|
||||
}
|
||||
|
||||
/** A hashless retry must return a full list before it can become authoritative. */
|
||||
export function requireFullChannelList(channels: Channel[] | null): Channel[] {
|
||||
if (channels === null) {
|
||||
throw new Error("get_channels returned no list for a hashless request");
|
||||
}
|
||||
return channels;
|
||||
}
|
||||
|
||||
export function useChannelsQuery(options?: { enabled?: boolean }) {
|
||||
const { activeCommunity } = useCommunities();
|
||||
const relayUrl = activeCommunity?.relayUrl ?? null;
|
||||
// CommunityQueryProvider remounts its QueryClient for every community. Only
|
||||
// the active identity may authorize a persisted snapshot: Community.pubkey
|
||||
// is creation-time display metadata and can be stale after identity changes.
|
||||
const identityQuery = useIdentityQuery();
|
||||
const ownerPubkey = identityQuery.data?.pubkey ?? null;
|
||||
const queryClient = useQueryClient();
|
||||
const snapshotRead = React.useMemo(
|
||||
() =>
|
||||
relayUrl && ownerPubkey
|
||||
? inspectChannelSnapshot(relayUrl, ownerPubkey)
|
||||
: null,
|
||||
[ownerPubkey, relayUrl],
|
||||
);
|
||||
const snapshot = snapshotRead?.snapshot ?? null;
|
||||
const initialSnapshotPair = React.useMemo(
|
||||
() =>
|
||||
snapshot
|
||||
? { channels: sortChannels(snapshot.channels), hash: snapshot.hash }
|
||||
: null,
|
||||
[snapshot],
|
||||
);
|
||||
React.useEffect(() => {
|
||||
if (relayUrl && ownerPubkey && snapshotRead && options?.enabled !== false) {
|
||||
markSnapshotDiagnostic(relayUrl, ownerPubkey, snapshotRead.diagnostics);
|
||||
}
|
||||
}, [options?.enabled, ownerPubkey, relayUrl, snapshotRead]);
|
||||
const refetchInterval = useFocusedRefetchInterval(
|
||||
CHANNELS_REFETCH_INTERVAL_MS,
|
||||
);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useQuery({
|
||||
enabled: options?.enabled ?? true,
|
||||
const query = useQuery({
|
||||
enabled:
|
||||
(options?.enabled ?? true) &&
|
||||
relayUrl !== null &&
|
||||
canFetchChannelsForIdentity(ownerPubkey, identityQuery.isError),
|
||||
queryKey: channelsQueryKey,
|
||||
queryFn: async () => {
|
||||
// Supply the stored hash only when the channel list is still in cache.
|
||||
// If cache is absent (community switch, eviction) we send null so the
|
||||
// Rust side never short-circuits into an empty list.
|
||||
const cachedChannels =
|
||||
queryClient.getQueryData<Channel[]>(channelsQueryKey);
|
||||
const knownHash = cachedChannels
|
||||
? (queryClient.getQueryData<string>(channelsHashKey) ?? null)
|
||||
: null;
|
||||
// Revalidation uses only an authoritative list/hash pair. The displayed
|
||||
// channels cache is intentionally ignored because successful mutations
|
||||
// patch it before the relay's list/hash has necessarily caught up.
|
||||
const cachedPair =
|
||||
queryClient.getQueryData<ChannelSnapshot>(channelsSnapshotPairKey) ??
|
||||
initialSnapshotPair;
|
||||
const knownHash = cachedPair?.hash ?? null;
|
||||
|
||||
const payload = await getChannels(knownHash);
|
||||
|
||||
// Pin the hash alongside the channel cache so it is implicitly
|
||||
// invalidated whenever the channel list is evicted.
|
||||
queryClient.setQueryData(channelsHashKey, payload.hash);
|
||||
// A not-modified response is usable only when it echoes the exact hash
|
||||
// that described the available list. Any other hash/list pairing fails
|
||||
// slow-never-wrong by retrying without a hash.
|
||||
const hasMatchingNotModifiedResponse =
|
||||
payload.channels === null &&
|
||||
knownHash !== null &&
|
||||
payload.hash === knownHash;
|
||||
const pairChannels =
|
||||
payload.channels ??
|
||||
(hasMatchingNotModifiedResponse ? cachedPair?.channels : undefined);
|
||||
|
||||
// Determine the base channel list: full payload on a normal response,
|
||||
// or the still-valid cached list on a not-modified (channels === null) response.
|
||||
const base = payload.channels ?? cachedChannels;
|
||||
|
||||
if (!base) {
|
||||
// hash-match but cache somehow empty — shouldn't happen given the
|
||||
// null-hash guard above, but handle defensively by re-fetching without
|
||||
// the stale hash so we always have a channel list to return.
|
||||
if (!pairChannels) {
|
||||
// Missing cache or a mismatched not-modified response: discard the hash
|
||||
// and fetch a complete authoritative list before updating persistence.
|
||||
const full = await getChannels(null);
|
||||
queryClient.setQueryData(channelsHashKey, full.hash);
|
||||
const sorted = sortChannels(
|
||||
applyLastMessages(full.channels ?? [], full.lastMessages),
|
||||
applyLastMessages(
|
||||
requireFullChannelList(full.channels),
|
||||
full.lastMessages,
|
||||
),
|
||||
);
|
||||
if (relayUrl) writeChannelSnapshot(relayUrl, sorted);
|
||||
const pair = { channels: sorted, hash: full.hash };
|
||||
queryClient.setQueryData(channelsSnapshotPairKey, pair);
|
||||
if (relayUrl && ownerPubkey) {
|
||||
writeChannelSnapshot(relayUrl, ownerPubkey, pair.channels, pair.hash);
|
||||
}
|
||||
return sorted;
|
||||
}
|
||||
|
||||
const sorted = sortChannels(
|
||||
applyLastMessages(base, payload.lastMessages),
|
||||
const authoritativeChannels = sortChannels(
|
||||
applyLastMessages(pairChannels, payload.lastMessages),
|
||||
);
|
||||
if (relayUrl) writeChannelSnapshot(relayUrl, sorted);
|
||||
const pair = {
|
||||
channels: authoritativeChannels,
|
||||
hash: payload.hash,
|
||||
};
|
||||
queryClient.setQueryData(channelsSnapshotPairKey, pair);
|
||||
// A matching not-modified result must merge timestamps into whatever is
|
||||
// displayed at completion time. Reading through setQueryData avoids
|
||||
// clobbering an optimistic mutation that landed while the request ran.
|
||||
const sorted =
|
||||
payload.channels === null
|
||||
? (queryClient.setQueryData<Channel[]>(
|
||||
channelsQueryKey,
|
||||
(displayedChannels) =>
|
||||
sortChannels(
|
||||
applyLastMessages(
|
||||
displayedChannels ?? authoritativeChannels,
|
||||
payload.lastMessages,
|
||||
),
|
||||
),
|
||||
) ?? authoritativeChannels)
|
||||
: authoritativeChannels;
|
||||
if (relayUrl && ownerPubkey) {
|
||||
writeChannelSnapshot(relayUrl, ownerPubkey, pair.channels, pair.hash);
|
||||
}
|
||||
return sorted;
|
||||
},
|
||||
// Paint the sidebar instantly from the last-known list for this relay, then
|
||||
// revalidate. initialDataUpdatedAt:0 marks the seed as already-stale so the
|
||||
// background refetch still fires immediately.
|
||||
initialData: relayUrl
|
||||
? () => {
|
||||
const snapshot = readChannelSnapshot(relayUrl);
|
||||
return snapshot ? sortChannels(snapshot) : undefined;
|
||||
}
|
||||
: undefined,
|
||||
// Paint the complete persisted list immediately. `initialDataUpdatedAt: 0`
|
||||
// deliberately keeps it stale so every boot still validates against the
|
||||
// relay; queryFn reads the matching hash from the same atomic document.
|
||||
initialData: initialSnapshotPair?.channels,
|
||||
initialDataUpdatedAt: 0,
|
||||
refetchInterval,
|
||||
...channelsFocusRefetchPolicy,
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
if (
|
||||
relayUrl &&
|
||||
ownerPubkey &&
|
||||
query.isSuccess &&
|
||||
query.fetchStatus === "idle" &&
|
||||
query.dataUpdatedAt > 0
|
||||
) {
|
||||
measureFullSidebarPaint(relayUrl, ownerPubkey, query.data.length);
|
||||
}
|
||||
}, [
|
||||
query.data,
|
||||
query.dataUpdatedAt,
|
||||
query.fetchStatus,
|
||||
query.isSuccess,
|
||||
ownerPubkey,
|
||||
relayUrl,
|
||||
]);
|
||||
|
||||
return query;
|
||||
}
|
||||
|
||||
export function useCreateChannelMutation() {
|
||||
|
||||
@@ -313,6 +313,10 @@ type E2eConfig = {
|
||||
/** Reject successive mock `join_channel` calls, then resume. */
|
||||
joinChannelErrors?: string[];
|
||||
channelsReadDelayMs?: number;
|
||||
/** Return not-modified for this many reads before resuming full payloads. */
|
||||
channelsNotModifiedResponses?: number;
|
||||
/** When true, a matching knownHash returns a not-modified channel payload. */
|
||||
honorChannelsKnownHash?: boolean;
|
||||
/** Number of seeded rows in the deep-history fixture. Defaults to 600. */
|
||||
deepHistoryMessageCount?: number;
|
||||
feedReadError?: string;
|
||||
@@ -5709,7 +5713,10 @@ function buildLastMessages(
|
||||
return map;
|
||||
}
|
||||
|
||||
async function handleGetChannels(config: E2eConfig | undefined) {
|
||||
async function handleGetChannels(
|
||||
payload: unknown,
|
||||
config: E2eConfig | undefined,
|
||||
) {
|
||||
const channelsReadDelayMs = config?.mock?.channelsReadDelayMs ?? 0;
|
||||
if (channelsReadDelayMs > 0) {
|
||||
await new Promise((resolve) =>
|
||||
@@ -5726,15 +5733,25 @@ async function handleGetChannels(config: E2eConfig | undefined) {
|
||||
|
||||
const identity = getIdentity(config);
|
||||
if (!identity) {
|
||||
// The hash is constant ("mock-hash") and channels are always returned in
|
||||
// full — the not-modified short-circuit is intentionally never exercised
|
||||
// here: mock channel data mutates during tests (last_message_at updates on
|
||||
// message emission) while the hash never changes, so honoring knownHash
|
||||
// would wrongly short-circuit after mutations.
|
||||
// The hash is constant ("mock-hash") and full lists remain the default:
|
||||
// mock channel data mutates during tests while the hash does not. Focused
|
||||
// snapshot specs can opt into the not-modified branch explicitly.
|
||||
const channels = listMockChannels(config);
|
||||
const hash = "mock-hash";
|
||||
const knownHash = (payload as { knownHash?: unknown } | null)?.knownHash;
|
||||
const forcedNotModified =
|
||||
(config?.mock?.channelsNotModifiedResponses ?? 0) > 0;
|
||||
if (forcedNotModified && config?.mock) {
|
||||
config.mock.channelsNotModifiedResponses =
|
||||
(config.mock.channelsNotModifiedResponses ?? 1) - 1;
|
||||
}
|
||||
return {
|
||||
hash: "mock-hash",
|
||||
channels,
|
||||
hash,
|
||||
channels:
|
||||
forcedNotModified ||
|
||||
(config?.mock?.honorChannelsKnownHash && knownHash === hash)
|
||||
? null
|
||||
: channels,
|
||||
last_messages: buildLastMessages(channels),
|
||||
};
|
||||
}
|
||||
@@ -11939,7 +11956,7 @@ export function maybeInstallE2eTauriMocks() {
|
||||
// invocation's completion. Later reads pass through and cannot join it.
|
||||
const deferred = deferNextChannelsRead;
|
||||
deferNextChannelsRead = false;
|
||||
const channels = handleGetChannels(activeConfig);
|
||||
const channels = handleGetChannels(payload, activeConfig);
|
||||
if (deferred) {
|
||||
await new Promise<void>((resolve) => {
|
||||
deferredChannelsReadResolve = resolve;
|
||||
|
||||
@@ -4,6 +4,11 @@ import { installMockBridge } from "../helpers/bridge";
|
||||
import { FEATURE_OVERRIDES_STORAGE_KEY } from "../helpers/features";
|
||||
|
||||
const RELAY_URL = "ws://localhost:3000";
|
||||
const OWNER_PUBKEY = "deadbeef".repeat(8);
|
||||
|
||||
function snapshotKey(relayUrl: string) {
|
||||
return `buzz-channels.v1:${relayUrl}:${OWNER_PUBKEY.toLowerCase()}`;
|
||||
}
|
||||
|
||||
const COMMUNITY_A = {
|
||||
id: "ws-a",
|
||||
@@ -412,33 +417,36 @@ test.describe("community rail", () => {
|
||||
await seedCommunities(page, [COMMUNITY_A, COMMUNITY_B], COMMUNITY_A.id);
|
||||
await page.goto("/");
|
||||
await expect(page.getByTestId("app-sidebar")).toBeVisible();
|
||||
const rememberedChannelId = await page.evaluate((communityId) => {
|
||||
const source = window.localStorage.getItem(
|
||||
"buzz-channels.v1:ws://localhost:3000",
|
||||
);
|
||||
if (!source) throw new Error("missing source channel snapshot");
|
||||
const snapshot = JSON.parse(source) as {
|
||||
channels: Array<{ id: string; name: string }>;
|
||||
};
|
||||
const generalChannel = snapshot.channels.find(
|
||||
(channel) => channel.name === "general",
|
||||
);
|
||||
if (!generalChannel) throw new Error("missing general channel snapshot");
|
||||
window.localStorage.setItem(
|
||||
"buzz-channels.v1:ws://localhost:3001",
|
||||
source,
|
||||
);
|
||||
window.localStorage.setItem(
|
||||
"buzz-community-destinations",
|
||||
JSON.stringify({
|
||||
[communityId]: {
|
||||
kind: "channel",
|
||||
channelId: generalChannel.id,
|
||||
},
|
||||
}),
|
||||
);
|
||||
return generalChannel.id;
|
||||
}, COMMUNITY_B.id);
|
||||
const rememberedChannelId = await page.evaluate(
|
||||
({ communityId, sourceSnapshotKey, targetSnapshotKey }) => {
|
||||
const source = window.localStorage.getItem(sourceSnapshotKey);
|
||||
if (!source) throw new Error("missing source channel snapshot");
|
||||
const snapshot = JSON.parse(source) as {
|
||||
channels: Array<{ id: string; name: string }>;
|
||||
};
|
||||
const generalChannel = snapshot.channels.find(
|
||||
(channel) => channel.name === "general",
|
||||
);
|
||||
if (!generalChannel)
|
||||
throw new Error("missing general channel snapshot");
|
||||
window.localStorage.setItem(targetSnapshotKey, source);
|
||||
window.localStorage.setItem(
|
||||
"buzz-community-destinations",
|
||||
JSON.stringify({
|
||||
[communityId]: {
|
||||
kind: "channel",
|
||||
channelId: generalChannel.id,
|
||||
},
|
||||
}),
|
||||
);
|
||||
return generalChannel.id;
|
||||
},
|
||||
{
|
||||
communityId: COMMUNITY_B.id,
|
||||
sourceSnapshotKey: snapshotKey(COMMUNITY_A.relayUrl),
|
||||
targetSnapshotKey: snapshotKey(COMMUNITY_B.relayUrl),
|
||||
},
|
||||
);
|
||||
|
||||
await page.evaluate(() => {
|
||||
const testWindow = window as typeof window & {
|
||||
@@ -478,28 +486,29 @@ test.describe("community rail", () => {
|
||||
}, COMMUNITY_B.id);
|
||||
|
||||
await page.goto("/");
|
||||
const sourceSnapshotKey = snapshotKey(COMMUNITY_A.relayUrl);
|
||||
const targetSnapshotKey = snapshotKey(COMMUNITY_B.relayUrl);
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(() =>
|
||||
window.localStorage.getItem("buzz-channels.v1:ws://localhost:3000"),
|
||||
page.evaluate(
|
||||
(key) => window.localStorage.getItem(key),
|
||||
sourceSnapshotKey,
|
||||
),
|
||||
)
|
||||
.not.toBeNull();
|
||||
await page.evaluate(() => {
|
||||
const source = window.localStorage.getItem(
|
||||
"buzz-channels.v1:ws://localhost:3000",
|
||||
);
|
||||
if (!source) throw new Error("missing source channel snapshot");
|
||||
const snapshot = JSON.parse(source);
|
||||
snapshot.channels = snapshot.channels.map(
|
||||
(channel: Record<string, unknown>, index: number) =>
|
||||
index === 0 ? { ...channel, id: "missing-channel" } : channel,
|
||||
);
|
||||
window.localStorage.setItem(
|
||||
"buzz-channels.v1:ws://localhost:3001",
|
||||
JSON.stringify(snapshot),
|
||||
);
|
||||
});
|
||||
await page.evaluate(
|
||||
({ sourceKey, targetKey }) => {
|
||||
const source = window.localStorage.getItem(sourceKey);
|
||||
if (!source) throw new Error("missing source channel snapshot");
|
||||
const snapshot = JSON.parse(source);
|
||||
snapshot.channels = snapshot.channels.map(
|
||||
(channel: Record<string, unknown>, index: number) =>
|
||||
index === 0 ? { ...channel, id: "missing-channel" } : channel,
|
||||
);
|
||||
window.localStorage.setItem(targetKey, JSON.stringify(snapshot));
|
||||
},
|
||||
{ sourceKey: sourceSnapshotKey, targetKey: targetSnapshotKey },
|
||||
);
|
||||
await page.getByTestId(`community-rail-button-${COMMUNITY_B.id}`).click();
|
||||
|
||||
await expect(page).not.toHaveURL(/#\/channels\//);
|
||||
@@ -531,10 +540,12 @@ test.describe("community rail", () => {
|
||||
}, COMMUNITY_B.id);
|
||||
await page.goto("/");
|
||||
await expect(page.getByTestId("app-sidebar")).toBeVisible();
|
||||
const sourceSnapshotKey = snapshotKey(COMMUNITY_A.relayUrl);
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(() =>
|
||||
window.localStorage.getItem("buzz-channels.v1:ws://localhost:3000"),
|
||||
page.evaluate(
|
||||
(key) => window.localStorage.getItem(key),
|
||||
sourceSnapshotKey,
|
||||
),
|
||||
)
|
||||
.not.toBeNull();
|
||||
@@ -563,20 +574,21 @@ test.describe("community rail", () => {
|
||||
};
|
||||
}),
|
||||
).toEqual({ released: 0, pending: 0 });
|
||||
await page.evaluate(() => {
|
||||
const source = window.localStorage.getItem(
|
||||
"buzz-channels.v1:ws://localhost:3000",
|
||||
);
|
||||
if (!source) throw new Error("missing source channel snapshot");
|
||||
const snapshot = JSON.parse(source);
|
||||
snapshot.channels = snapshot.channels.filter(
|
||||
(channel: { id: string }) => channel.id !== "general",
|
||||
);
|
||||
window.localStorage.setItem(
|
||||
"buzz-channels.v1:ws://localhost:3001",
|
||||
JSON.stringify(snapshot),
|
||||
);
|
||||
});
|
||||
await page.evaluate(
|
||||
({ sourceKey, targetKey }) => {
|
||||
const source = window.localStorage.getItem(sourceKey);
|
||||
if (!source) throw new Error("missing source channel snapshot");
|
||||
const snapshot = JSON.parse(source);
|
||||
snapshot.channels = snapshot.channels.filter(
|
||||
(channel: { id: string }) => channel.id !== "general",
|
||||
);
|
||||
window.localStorage.setItem(targetKey, JSON.stringify(snapshot));
|
||||
},
|
||||
{
|
||||
sourceKey: sourceSnapshotKey,
|
||||
targetKey: snapshotKey(COMMUNITY_B.relayUrl),
|
||||
},
|
||||
);
|
||||
await page.evaluate(() => {
|
||||
const config = (
|
||||
window as Window & {
|
||||
|
||||
599
desktop/tests/e2e/sidebar-snapshot.spec.ts
Normal file
599
desktop/tests/e2e/sidebar-snapshot.spec.ts
Normal file
@@ -0,0 +1,599 @@
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
|
||||
import { installMockBridge } from "../helpers/bridge";
|
||||
|
||||
const RELAY_URL = "ws://localhost:3000";
|
||||
const OTHER_RELAY_URL = "ws://localhost:3001";
|
||||
const OWNER_PUBKEY = "deadbeef".repeat(8);
|
||||
const STALE_COMMUNITY_PUBKEY = "cafebabe".repeat(8);
|
||||
const MATCHING_HASH = "mock-hash";
|
||||
const READ_DELAY_MS = 600;
|
||||
const SNAPSHOT_FRAME_DELAY_MS = 3_000;
|
||||
|
||||
function snapshotKey(relayUrl: string, ownerPubkey = OWNER_PUBKEY) {
|
||||
return `buzz-channels.v1:${relayUrl}:${ownerPubkey.toLowerCase()}`;
|
||||
}
|
||||
|
||||
function makeSnapshotChannel(index: number, prefix = "snapshot") {
|
||||
return {
|
||||
id: `${prefix}-${index}`,
|
||||
name: `${prefix}-${String(index).padStart(2, "0")}`,
|
||||
channelType: "stream",
|
||||
visibility: "open",
|
||||
description: "",
|
||||
topic: null,
|
||||
purpose: null,
|
||||
memberCount: 1,
|
||||
memberPubkeys: [OWNER_PUBKEY],
|
||||
lastMessageAt: null,
|
||||
archivedAt: null,
|
||||
participants: [],
|
||||
participantPubkeys: [],
|
||||
isMember: true,
|
||||
ttlSeconds: null,
|
||||
ttlDeadline: null,
|
||||
};
|
||||
}
|
||||
|
||||
const FULL_SNAPSHOT = Array.from({ length: 14 }, (_, index) =>
|
||||
makeSnapshotChannel(index),
|
||||
);
|
||||
const SNAPSHOT_DIAGNOSTIC_MARK = "buzz:sidebar:snapshot-diagnostic";
|
||||
const FULL_SIDEBAR_PAINT_MARK = "buzz:sidebar:full-list-painted";
|
||||
const BOOT_TO_FULL_SIDEBAR_MEASURE = "buzz:sidebar:boot-to-full-list-painted";
|
||||
|
||||
function snapshotIntegrity(
|
||||
ownerPubkey: string,
|
||||
hash: string,
|
||||
channels: ReturnType<typeof makeSnapshotChannel>[],
|
||||
) {
|
||||
const value = JSON.stringify([ownerPubkey.toLowerCase(), hash, channels]);
|
||||
let result = 0x811c9dc5;
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
result ^= value.charCodeAt(index);
|
||||
result = Math.imul(result, 0x01000193);
|
||||
}
|
||||
return (result >>> 0).toString(16).padStart(8, "0");
|
||||
}
|
||||
|
||||
type SnapshotDiagnostics = {
|
||||
ageMs: number;
|
||||
channelCount: number;
|
||||
presence: "absent" | "invalid" | "present";
|
||||
serializedBytes: number;
|
||||
};
|
||||
|
||||
async function getSnapshotDiagnostic(page: Page) {
|
||||
return page.evaluate((name) => {
|
||||
const entry = performance.getEntriesByName(name).at(-1) as
|
||||
| PerformanceMark
|
||||
| undefined;
|
||||
return entry?.detail as SnapshotDiagnostics | undefined;
|
||||
}, SNAPSHOT_DIAGNOSTIC_MARK);
|
||||
}
|
||||
|
||||
async function getFullSidebarMeasure(page: Page) {
|
||||
return page.evaluate(
|
||||
({ markName, measureName }) => ({
|
||||
markCount: performance.getEntriesByName(markName).length,
|
||||
measure: performance.getEntriesByName(measureName).at(-1)?.duration,
|
||||
}),
|
||||
{
|
||||
markName: FULL_SIDEBAR_PAINT_MARK,
|
||||
measureName: BOOT_TO_FULL_SIDEBAR_MEASURE,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async function seedSnapshot(
|
||||
page: Page,
|
||||
options: {
|
||||
channels?: ReturnType<typeof makeSnapshotChannel>[];
|
||||
hash: string;
|
||||
keyOwnerPubkey?: string;
|
||||
ownerPubkey?: string;
|
||||
relayUrl?: string;
|
||||
},
|
||||
) {
|
||||
const channels = options.channels ?? FULL_SNAPSHOT;
|
||||
const ownerPubkey = options.ownerPubkey ?? OWNER_PUBKEY;
|
||||
await page.addInitScript(
|
||||
({ channels, hash, integrity, key, ownerPubkey }) => {
|
||||
window.localStorage.setItem(
|
||||
key,
|
||||
JSON.stringify({
|
||||
version: 2,
|
||||
updatedAt: Date.now(),
|
||||
ownerPubkey,
|
||||
hash,
|
||||
channels,
|
||||
integrity,
|
||||
}),
|
||||
);
|
||||
},
|
||||
{
|
||||
channels,
|
||||
hash: options.hash,
|
||||
integrity: snapshotIntegrity(ownerPubkey, options.hash, channels),
|
||||
key: snapshotKey(
|
||||
options.relayUrl ?? RELAY_URL,
|
||||
options.keyOwnerPubkey ?? ownerPubkey,
|
||||
),
|
||||
ownerPubkey,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async function seedCommunities(page: Page, communityPubkey = OWNER_PUBKEY) {
|
||||
await page.addInitScript(
|
||||
({ communityPubkey, relayA, relayB }) => {
|
||||
const communities = [
|
||||
{
|
||||
id: "community-a",
|
||||
name: "Alpha",
|
||||
relayUrl: relayA,
|
||||
pubkey: communityPubkey,
|
||||
addedAt: "2026-01-01T00:00:00.000Z",
|
||||
},
|
||||
{
|
||||
id: "community-b",
|
||||
name: "Bravo",
|
||||
relayUrl: relayB,
|
||||
pubkey: communityPubkey,
|
||||
addedAt: "2026-01-02T00:00:00.000Z",
|
||||
},
|
||||
];
|
||||
window.localStorage.setItem(
|
||||
"buzz-communities",
|
||||
JSON.stringify(communities),
|
||||
);
|
||||
window.localStorage.setItem(
|
||||
"buzz-active-community-id",
|
||||
communities[0].id,
|
||||
);
|
||||
},
|
||||
{ communityPubkey, relayA: RELAY_URL, relayB: OTHER_RELAY_URL },
|
||||
);
|
||||
}
|
||||
|
||||
async function getChannelsPayloads(page: Page) {
|
||||
return page.evaluate(() =>
|
||||
(window.__BUZZ_E2E_COMMAND_LOG__ ?? [])
|
||||
.filter((entry) => entry.command === "get_channels")
|
||||
.map((entry) => entry.payload as { knownHash?: unknown }),
|
||||
);
|
||||
}
|
||||
|
||||
async function mutateDisplayedChannels(page: Page, channelName: string) {
|
||||
await page.evaluate((name) => {
|
||||
const queryClient = window.__BUZZ_E2E_QUERY_CLIENT__ as
|
||||
| {
|
||||
setQueryData: (
|
||||
queryKey: readonly string[],
|
||||
updater: (
|
||||
channels: ReturnType<typeof makeSnapshotChannel>[] | undefined,
|
||||
) => ReturnType<typeof makeSnapshotChannel>[],
|
||||
) => unknown;
|
||||
}
|
||||
| undefined;
|
||||
if (!queryClient) {
|
||||
throw new Error("E2E query client seam is not installed.");
|
||||
}
|
||||
queryClient.setQueryData(["channels"], (channels) => {
|
||||
const template = channels?.[0];
|
||||
if (!template) {
|
||||
throw new Error("Displayed channel cache is empty.");
|
||||
}
|
||||
return [...channels, { ...template, id: "optimistic-channel", name }];
|
||||
});
|
||||
}, channelName);
|
||||
}
|
||||
|
||||
async function readPersistedSnapshot(page: Page) {
|
||||
return page.evaluate((key) => {
|
||||
const raw = window.localStorage.getItem(key);
|
||||
if (!raw) return null;
|
||||
const snapshot = JSON.parse(raw) as {
|
||||
channels: Array<{ name: string }>;
|
||||
hash: string;
|
||||
};
|
||||
return {
|
||||
channelNames: snapshot.channels.map((channel) => channel.name),
|
||||
hash: snapshot.hash,
|
||||
};
|
||||
}, snapshotKey(RELAY_URL));
|
||||
}
|
||||
|
||||
async function trackSnapshotRows(page: Page) {
|
||||
await page.addInitScript(() => {
|
||||
const testWindow = window as Window & {
|
||||
__BUZZ_E2E_SNAPSHOT_ROWS_SEEN__?: string[];
|
||||
};
|
||||
const seen: string[] = [];
|
||||
testWindow.__BUZZ_E2E_SNAPSHOT_ROWS_SEEN__ = seen;
|
||||
const record = (node: Node) => {
|
||||
if (!(node instanceof Element)) return;
|
||||
const rows = node.matches('[data-channel-id^="snapshot-"]')
|
||||
? [node]
|
||||
: Array.from(node.querySelectorAll('[data-channel-id^="snapshot-"]'));
|
||||
for (const row of rows) {
|
||||
const id = row.getAttribute("data-channel-id");
|
||||
if (id && !seen.includes(id)) seen.push(id);
|
||||
}
|
||||
};
|
||||
new MutationObserver((records) => {
|
||||
for (const mutation of records) {
|
||||
for (const node of mutation.addedNodes) record(node);
|
||||
}
|
||||
}).observe(document, { childList: true, subtree: true });
|
||||
});
|
||||
}
|
||||
|
||||
async function getTrackedSnapshotRows(page: Page) {
|
||||
return page.evaluate(
|
||||
() =>
|
||||
(
|
||||
window as Window & {
|
||||
__BUZZ_E2E_SNAPSHOT_ROWS_SEEN__?: string[];
|
||||
}
|
||||
).__BUZZ_E2E_SNAPSHOT_ROWS_SEEN__ ?? [],
|
||||
);
|
||||
}
|
||||
|
||||
test("cold boot paints the complete snapshot, sends its hash, and revalidates", async ({
|
||||
page,
|
||||
}) => {
|
||||
await seedSnapshot(page, { hash: MATCHING_HASH });
|
||||
await installMockBridge(page, {
|
||||
channelsReadDelayMs: SNAPSHOT_FRAME_DELAY_MS,
|
||||
honorChannelsKnownHash: true,
|
||||
});
|
||||
|
||||
const navigationStartedAt = performance.now();
|
||||
await page.goto("/");
|
||||
const rows = page.locator('[data-channel-id^="snapshot-"]');
|
||||
await expect(rows).toHaveCount(FULL_SNAPSHOT.length, { timeout: 2_000 });
|
||||
const snapshotPaintedAt = performance.now();
|
||||
expect(snapshotPaintedAt - navigationStartedAt).toBeLessThan(
|
||||
SNAPSHOT_FRAME_DELAY_MS,
|
||||
);
|
||||
await expect(page.getByTestId("sidebar-loading")).toHaveCount(0);
|
||||
await expect
|
||||
.poll(() => getChannelsPayloads(page))
|
||||
.toEqual([{ knownHash: MATCHING_HASH }]);
|
||||
await expect
|
||||
.poll(() => getSnapshotDiagnostic(page))
|
||||
.toMatchObject({
|
||||
channelCount: FULL_SNAPSHOT.length,
|
||||
presence: "present",
|
||||
});
|
||||
const diagnostics = await getSnapshotDiagnostic(page);
|
||||
expect(diagnostics?.serializedBytes).toBeGreaterThan(0);
|
||||
expect(diagnostics?.ageMs).toBeGreaterThanOrEqual(0);
|
||||
|
||||
// The persisted data is deliberately stale: it paints immediately but still
|
||||
// validates. The delayed matching response leaves a deterministic snapshot
|
||||
// frame, then not-modified must not shrink the list.
|
||||
await expect(rows).toHaveCount(FULL_SNAPSHOT.length);
|
||||
await expect
|
||||
.poll(() => getFullSidebarMeasure(page))
|
||||
.toEqual({
|
||||
markCount: expect.any(Number),
|
||||
measure: expect.any(Number),
|
||||
});
|
||||
expect((await getFullSidebarMeasure(page)).markCount).toBeGreaterThan(0);
|
||||
|
||||
const liveSettledAt = performance.now();
|
||||
console.info(
|
||||
`[sidebar-snapshot] firstPaint=${Math.round(snapshotPaintedAt - navigationStartedAt)}ms snapshotToLive=${Math.round(liveSettledAt - snapshotPaintedAt)}ms rows=${FULL_SNAPSHOT.length}`,
|
||||
);
|
||||
});
|
||||
|
||||
test("matching not-modified preserves display mutations without persisting them", async ({
|
||||
page,
|
||||
}) => {
|
||||
const optimisticName = "optimistic-local-channel";
|
||||
await seedSnapshot(page, { hash: MATCHING_HASH });
|
||||
await installMockBridge(page, {
|
||||
channelsReadDelayMs: READ_DELAY_MS,
|
||||
honorChannelsKnownHash: true,
|
||||
});
|
||||
await page.goto("/");
|
||||
|
||||
await expect(page.locator('[data-channel-id^="snapshot-"]')).toHaveCount(
|
||||
FULL_SNAPSHOT.length,
|
||||
{ timeout: 500 },
|
||||
);
|
||||
await mutateDisplayedChannels(page, optimisticName);
|
||||
await expect(
|
||||
page.locator('[data-channel-id="optimistic-channel"]'),
|
||||
).toBeVisible();
|
||||
|
||||
await expect
|
||||
.poll(() => getChannelsPayloads(page))
|
||||
.toEqual([{ knownHash: MATCHING_HASH }]);
|
||||
await expect
|
||||
.poll(() => readPersistedSnapshot(page))
|
||||
.toEqual({
|
||||
channelNames: FULL_SNAPSHOT.map((channel) => channel.name),
|
||||
hash: MATCHING_HASH,
|
||||
});
|
||||
await expect(
|
||||
page.locator('[data-channel-id="optimistic-channel"]'),
|
||||
).toBeVisible();
|
||||
expect((await readPersistedSnapshot(page))?.channelNames).not.toContain(
|
||||
optimisticName,
|
||||
);
|
||||
});
|
||||
|
||||
test("first-ever boot without a snapshot sends null and shows loading", async ({
|
||||
page,
|
||||
}) => {
|
||||
await installMockBridge(page, { channelsReadDelayMs: READ_DELAY_MS });
|
||||
await page.goto("/");
|
||||
|
||||
await expect(page.getByTestId("sidebar-loading")).toBeVisible({
|
||||
timeout: 500,
|
||||
});
|
||||
await expect(page.locator('[data-channel-id^="snapshot-"]')).toHaveCount(0);
|
||||
await expect
|
||||
.poll(() => getChannelsPayloads(page))
|
||||
.toEqual([{ knownHash: null }]);
|
||||
await expect
|
||||
.poll(() => getSnapshotDiagnostic(page))
|
||||
.toMatchObject({
|
||||
channelCount: 0,
|
||||
presence: "absent",
|
||||
serializedBytes: 0,
|
||||
});
|
||||
await expect(page.getByTestId("channel-general")).toBeVisible();
|
||||
await expect
|
||||
.poll(() => getFullSidebarMeasure(page))
|
||||
.toEqual({
|
||||
markCount: expect.any(Number),
|
||||
measure: expect.any(Number),
|
||||
});
|
||||
expect((await getFullSidebarMeasure(page)).markCount).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test("a different identity's snapshot is ignored", async ({ page }) => {
|
||||
await seedSnapshot(page, {
|
||||
hash: "foreign-hash",
|
||||
keyOwnerPubkey: OWNER_PUBKEY,
|
||||
ownerPubkey: STALE_COMMUNITY_PUBKEY,
|
||||
});
|
||||
await installMockBridge(page, { channelsReadDelayMs: READ_DELAY_MS });
|
||||
await page.goto("/");
|
||||
|
||||
await expect(page.getByTestId("sidebar-loading")).toBeVisible({
|
||||
timeout: 500,
|
||||
});
|
||||
await expect(page.locator('[data-channel-id^="snapshot-"]')).toHaveCount(0);
|
||||
await expect
|
||||
.poll(() => getChannelsPayloads(page))
|
||||
.toEqual([{ knownHash: null }]);
|
||||
await expect
|
||||
.poll(() => getSnapshotDiagnostic(page))
|
||||
.toMatchObject({
|
||||
channelCount: 0,
|
||||
presence: "invalid",
|
||||
});
|
||||
await expect(page.getByTestId("channel-general")).toBeVisible();
|
||||
});
|
||||
|
||||
test("display-only community pubkey cannot expose another identity's snapshot", async ({
|
||||
page,
|
||||
}) => {
|
||||
await seedSnapshot(page, {
|
||||
hash: "stale-community-identity-hash",
|
||||
ownerPubkey: STALE_COMMUNITY_PUBKEY,
|
||||
});
|
||||
await trackSnapshotRows(page);
|
||||
await installMockBridge(
|
||||
page,
|
||||
{ channelsReadDelayMs: READ_DELAY_MS },
|
||||
{ skipCommunitySeed: true },
|
||||
);
|
||||
await seedCommunities(page, STALE_COMMUNITY_PUBKEY);
|
||||
await page.goto("/");
|
||||
|
||||
await expect(page.getByTestId("channel-general")).toBeVisible();
|
||||
expect(await getTrackedSnapshotRows(page)).toEqual([]);
|
||||
const payloads = await getChannelsPayloads(page);
|
||||
expect(payloads).toEqual([{ knownHash: null }]);
|
||||
expect(payloads).not.toContainEqual({
|
||||
knownHash: "stale-community-identity-hash",
|
||||
});
|
||||
});
|
||||
|
||||
test("partial hash/list write fails toward a full fetch", async ({ page }) => {
|
||||
await seedSnapshot(page, { hash: MATCHING_HASH });
|
||||
await page.addInitScript((key) => {
|
||||
const raw = window.localStorage.getItem(key);
|
||||
if (!raw) return;
|
||||
const snapshot = JSON.parse(raw) as { hash: string };
|
||||
window.localStorage.setItem(
|
||||
key,
|
||||
JSON.stringify({ ...snapshot, hash: "newer-partial-hash" }),
|
||||
);
|
||||
}, snapshotKey(RELAY_URL));
|
||||
await installMockBridge(page, {
|
||||
channelsReadDelayMs: READ_DELAY_MS,
|
||||
honorChannelsKnownHash: true,
|
||||
});
|
||||
await page.goto("/");
|
||||
|
||||
await expect(page.getByTestId("sidebar-loading")).toBeVisible({
|
||||
timeout: 500,
|
||||
});
|
||||
await expect(page.locator('[data-channel-id^="snapshot-"]')).toHaveCount(0);
|
||||
await expect
|
||||
.poll(() => getChannelsPayloads(page))
|
||||
.toEqual([{ knownHash: null }]);
|
||||
await expect
|
||||
.poll(() => getSnapshotDiagnostic(page))
|
||||
.toMatchObject({
|
||||
channelCount: 0,
|
||||
presence: "invalid",
|
||||
});
|
||||
await expect(page.getByTestId("channel-general")).toBeVisible();
|
||||
await expect(page.locator('[data-channel-id^="snapshot-"]')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("unexpected not-modified response retries without a hash", async ({
|
||||
page,
|
||||
}) => {
|
||||
await installMockBridge(page, {
|
||||
channelsNotModifiedResponses: 1,
|
||||
});
|
||||
await page.goto("/");
|
||||
|
||||
await expect(page.getByTestId("channel-general")).toBeVisible();
|
||||
await expect
|
||||
.poll(() => getChannelsPayloads(page))
|
||||
.toEqual([{ knownHash: null }, { knownHash: null }]);
|
||||
});
|
||||
|
||||
test("mismatched not-modified hash falls back to a full list", async ({
|
||||
page,
|
||||
}) => {
|
||||
await seedSnapshot(page, { hash: "persisted-stale-hash" });
|
||||
await installMockBridge(page, {
|
||||
channelsReadDelayMs: READ_DELAY_MS,
|
||||
channelsNotModifiedResponses: 1,
|
||||
});
|
||||
await page.goto("/");
|
||||
|
||||
const snapshotRows = page.locator('[data-channel-id^="snapshot-"]');
|
||||
await expect(snapshotRows).toHaveCount(FULL_SNAPSHOT.length, {
|
||||
timeout: 500,
|
||||
});
|
||||
await expect
|
||||
.poll(() => getChannelsPayloads(page))
|
||||
.toEqual([{ knownHash: "persisted-stale-hash" }, { knownHash: null }]);
|
||||
|
||||
// The stale snapshot may provide the immediate boot frame, but an impossible
|
||||
// not-modified hash/list pairing must never survive as the settled result.
|
||||
await expect(page.getByTestId("channel-general")).toBeVisible();
|
||||
await expect(snapshotRows).toHaveCount(0);
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate((key) => {
|
||||
const raw = window.localStorage.getItem(key);
|
||||
if (!raw) return null;
|
||||
const snapshot = JSON.parse(raw) as {
|
||||
channels: Array<{ name: string }>;
|
||||
hash: string;
|
||||
};
|
||||
return {
|
||||
hash: snapshot.hash,
|
||||
hasGeneral: snapshot.channels.some(
|
||||
(channel) => channel.name === "general",
|
||||
),
|
||||
hasSnapshotRow: snapshot.channels.some((channel) =>
|
||||
channel.name.startsWith("snapshot-"),
|
||||
),
|
||||
};
|
||||
}, snapshotKey(RELAY_URL)),
|
||||
)
|
||||
.toEqual({ hash: MATCHING_HASH, hasGeneral: true, hasSnapshotRow: false });
|
||||
});
|
||||
|
||||
test("hash mismatch replaces the snapshot with the full live list", async ({
|
||||
page,
|
||||
}) => {
|
||||
await seedSnapshot(page, { hash: "stale-hash" });
|
||||
await installMockBridge(page, {
|
||||
channelsReadDelayMs: READ_DELAY_MS,
|
||||
honorChannelsKnownHash: true,
|
||||
});
|
||||
await page.goto("/");
|
||||
|
||||
await expect(page.locator('[data-channel-id^="snapshot-"]')).toHaveCount(
|
||||
FULL_SNAPSHOT.length,
|
||||
{ timeout: 500 },
|
||||
);
|
||||
await expect
|
||||
.poll(() => getChannelsPayloads(page))
|
||||
.toEqual([{ knownHash: "stale-hash" }]);
|
||||
await expect(page.getByTestId("channel-general")).toBeVisible();
|
||||
await expect(page.locator('[data-channel-id^="snapshot-"]')).toHaveCount(0);
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate((key) => {
|
||||
const raw = window.localStorage.getItem(key);
|
||||
if (!raw) return null;
|
||||
const snapshot = JSON.parse(raw) as {
|
||||
channels: Array<{ name: string }>;
|
||||
hash: string;
|
||||
};
|
||||
return {
|
||||
hash: snapshot.hash,
|
||||
hasGeneral: snapshot.channels.some(
|
||||
(channel) => channel.name === "general",
|
||||
),
|
||||
hasSnapshotRow: snapshot.channels.some((channel) =>
|
||||
channel.name.startsWith("snapshot-"),
|
||||
),
|
||||
};
|
||||
}, snapshotKey(RELAY_URL)),
|
||||
)
|
||||
.toEqual({ hash: MATCHING_HASH, hasGeneral: true, hasSnapshotRow: false });
|
||||
});
|
||||
|
||||
test("community switch validates a stale relay snapshot and replaces it", async ({
|
||||
page,
|
||||
}) => {
|
||||
const switchedSnapshot = Array.from({ length: 6 }, (_, index) =>
|
||||
makeSnapshotChannel(index, "switched"),
|
||||
);
|
||||
await seedSnapshot(page, {
|
||||
channels: switchedSnapshot,
|
||||
hash: "stale-community-hash",
|
||||
relayUrl: OTHER_RELAY_URL,
|
||||
});
|
||||
await installMockBridge(
|
||||
page,
|
||||
{
|
||||
channelsReadDelayMs: READ_DELAY_MS,
|
||||
honorChannelsKnownHash: true,
|
||||
},
|
||||
{ skipCommunitySeed: true },
|
||||
);
|
||||
await seedCommunities(page);
|
||||
await page.goto("/");
|
||||
await expect(page.getByTestId("app-sidebar")).toBeVisible();
|
||||
|
||||
const payloadCountBeforeSwitch = (await getChannelsPayloads(page)).length;
|
||||
await page.getByTestId("community-rail-button-community-b").click();
|
||||
const switchedRows = page.locator('[data-channel-id^="switched-"]');
|
||||
await expect(switchedRows).toHaveCount(switchedSnapshot.length, {
|
||||
timeout: 500,
|
||||
});
|
||||
await expect
|
||||
.poll(async () =>
|
||||
(await getChannelsPayloads(page)).slice(payloadCountBeforeSwitch).at(0),
|
||||
)
|
||||
.toEqual({ knownHash: "stale-community-hash" });
|
||||
|
||||
await expect(page.getByTestId("channel-general")).toBeVisible();
|
||||
await expect(switchedRows).toHaveCount(0);
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate((key) => {
|
||||
const raw = window.localStorage.getItem(key);
|
||||
if (!raw) return null;
|
||||
const snapshot = JSON.parse(raw) as {
|
||||
channels: Array<{ name: string }>;
|
||||
hash: string;
|
||||
};
|
||||
return {
|
||||
hash: snapshot.hash,
|
||||
hasGeneral: snapshot.channels.some(
|
||||
(channel) => channel.name === "general",
|
||||
),
|
||||
};
|
||||
}, snapshotKey(OTHER_RELAY_URL)),
|
||||
)
|
||||
.toEqual({ hash: MATCHING_HASH, hasGeneral: true });
|
||||
});
|
||||
@@ -267,6 +267,11 @@ type MockBridgeOptions = {
|
||||
ensureStarterChannelsErrors?: string[];
|
||||
/** Reject successive mock `join_channel` calls, then resume. */
|
||||
joinChannelErrors?: string[];
|
||||
channelsReadDelayMs?: number;
|
||||
/** Return not-modified for this many reads before resuming full payloads. */
|
||||
channelsNotModifiedResponses?: number;
|
||||
/** When true, a matching knownHash returns a not-modified channel payload. */
|
||||
honorChannelsKnownHash?: boolean;
|
||||
/** Number of seeded rows in the deep-history fixture. Defaults to 600. */
|
||||
deepHistoryMessageCount?: number;
|
||||
feedReadError?: string;
|
||||
|
||||
Reference in New Issue
Block a user