fix(sync): emit settled list state for hashtags and blocks with regression tests

This commit is contained in:
thePR0M3TH3AN committed 2026-02-22 21:09:08 -05:00
1 parent c23dcc6c46
commit 3dbacd20cc
4 files changed
+366 -7

No files matched your search

+12
View File
@@ -526,6 +526,13 @@ class HashtagPreferencesService {
eventId: this.eventId,
createdAt: this.eventCreatedAt,
version: this.preferencesVersion,
loaded: this.loaded === true,
uiReady: this.uiReady === true,
dataReady: this.dataReady === true,
loadedFromCache: this.loadedFromCache === true,
backgroundLoading: this.backgroundLoading === true,
lastLoadError: this.lastLoadError || null,
activePubkey: this.activePubkey || null,
...detail,
});
} catch (error) {
@@ -589,6 +596,11 @@ class HashtagPreferencesService {
state: this.getLoadState(),
};
} finally {
this.emitChange("load-settled", {
...this.getLoadState(),
loaded: this.loaded === true,
backgroundLoading: this.backgroundLoading === true,
});
if (this.loadPromise === loadPromise) {
this.loadPromise = null;
this.loadingPubkey = null;
+7 -7
View File
@@ -705,6 +705,7 @@ class UserBlockListManager {
this.blockedPubkeys = new Set();
this._privateBlocks = new Set();
this._publicMutes = new Set();
this.activePubkey = null;
this.blockEventId = null;
this.blockEventCreatedAt = null;
this.lastPublishedCreatedAt = null;
@@ -718,7 +719,6 @@ class UserBlockListManager {
this.loadPromise = null;
this.loadingPubkey = null;
this.blockListSubscriptionKey = null;
profileCache.subscribe((event, detail) => {
if (event === "profileChanged") {
this.reset();
@@ -793,7 +793,6 @@ class UserBlockListManager {
if (!isUserBlockListEvent(event)) {
return;
}
this.loadBlocks(normalized).catch((error) => {
devLogger.warn("[UserBlockList] Failed to refresh after block list event", error);
});
@@ -864,7 +863,6 @@ class UserBlockListManager {
if (!normalized) {
return;
}
await this.loadBlocks(normalized);
}
@@ -877,7 +875,6 @@ class UserBlockListManager {
});
return this.loadPromise;
}
if (this.loadPromise && this.loadingPubkey && this.loadingPubkey !== normalized) {
devLogger.log("[UserBlockList] Waiting for existing block list load to settle.", {
previous: this.loadingPubkey,
@@ -889,10 +886,15 @@ class UserBlockListManager {
const loadPromise = this.loadBlocksInternal(normalized, options);
this.loadPromise = loadPromise;
this.loadingPubkey = normalized;
try {
return await loadPromise;
} finally {
this.emitter.emit(USER_BLOCK_EVENTS.CHANGE, {
action: "load-settled",
blockedPubkeys: Array.from(this.blockedPubkeys),
loaded: this.loaded === true,
activePubkey: this.activePubkey || null,
});
if (this.loadPromise === loadPromise) {
this.loadPromise = null;
this.loadingPubkey = null;
@@ -931,7 +933,6 @@ class UserBlockListManager {
// realistic window while still failing faster than the full 15s interactive
// timeout.
const nip07DecryptTimeoutMs = allowPermissionPrompt ? 15000 : 8000;
const emitStatus = (detail) => {
if (!detail || typeof detail !== "object") {
return;
@@ -997,7 +998,6 @@ class UserBlockListManager {
this._privateBlocks = privateSet;
this._publicMutes = publicSet;
this.blockedPubkeys = new Set([...privateSet, ...publicSet]);
this.emitter.emit(USER_BLOCK_EVENTS.CHANGE, {
action: "sync",
blockedPubkeys: Array.from(this.blockedPubkeys),
+177
View File
@@ -323,6 +323,183 @@ test(
},
);
test(
"load emits load-settled with readiness metadata for empty relay results",
{ concurrency: false },
async () => {
const pubkey = "1".repeat(64);
const changes = [];
const unsubscribe = hashtagPreferences.on("change", (detail) => {
changes.push(detail);
});
const originalFetchIncremental = nostrClient.fetchListIncrementally;
try {
relayManager.setEntries(
[{ url: "wss://relay.empty", mode: "both" }],
{ allowEmpty: false, updateClient: false },
);
nostrClient.fetchListIncrementally = async () => [];
await hashtagPreferences.load(pubkey);
const settled = changes.filter((entry) => entry?.action === "load-settled");
assert.ok(settled.length >= 1, "load should emit a final load-settled event");
const final = settled[settled.length - 1];
assert.equal(final.activePubkey, pubkey);
assert.equal(final.uiReady, true);
assert.equal(final.dataReady, true);
assert.equal(final.loaded, true);
assert.equal(final.loadedFromCache, false);
assert.equal(final.lastLoadError, null);
assert.deepEqual(final.interests, []);
assert.deepEqual(final.disinterests, []);
} finally {
unsubscribe();
nostrClient.fetchListIncrementally = originalFetchIncremental;
}
},
);
test(
"load emits load-settled with permission-required metadata when decrypt is deferred",
{ concurrency: false },
async () => {
const pubkey = "2".repeat(64);
const changes = [];
const unsubscribe = hashtagPreferences.on("change", (detail) => {
changes.push(detail);
});
const originalFetchIncremental = nostrClient.fetchListIncrementally;
const originalWindowNostr = window.nostr;
const relayUrls = ["wss://relay.permission-metadata"];
try {
relayManager.setEntries(
relayUrls.map((url) => ({ url, mode: "both" })),
{ allowEmpty: false, updateClient: false },
);
nostrClient.relays = relayUrls;
nostrClient.writeRelays = relayUrls;
nostrClient.fetchListIncrementally = async () => [
{
id: "pref-permission-metadata",
created_at: 401,
pubkey,
content: "cipher-permission-metadata",
tags: [["encrypted", "nip04"]],
},
];
window.nostr = {
enable: async () => {},
getPublicKey: async () => pubkey,
nip04: {
decrypt: async () => {
throw new Error("permission denied");
},
},
};
clearActiveSigner();
await hashtagPreferences.load(pubkey, { allowPermissionPrompt: false });
const settled = changes.filter((entry) => entry?.action === "load-settled");
assert.ok(settled.length >= 1, "load should emit load-settled even on deferred decrypt");
const final = settled[settled.length - 1];
assert.equal(final.activePubkey, pubkey);
assert.equal(final.uiReady, true);
assert.equal(typeof final.dataReady, "boolean");
assert.equal(final.dataReady, hashtagPreferences.dataReady === true);
assert.equal(typeof final.loaded, "boolean");
assert.equal(final.loaded, hashtagPreferences.loaded === true);
assert.equal(final.lastLoadError?.code, "hashtag-preferences-permission-required");
} finally {
unsubscribe();
nostrClient.fetchListIncrementally = originalFetchIncremental;
window.nostr = originalWindowNostr;
}
},
);
test(
"load emits load-settled with loadedFromCache when relay fetch is unavailable",
{ concurrency: false },
async () => {
const pubkey = "3".repeat(64);
const originalPool = nostrClient.pool;
const originalLoadFromCache = hashtagPreferences.loadFromCache;
const originalInterests = hashtagPreferences.interests;
const originalDisinterests = hashtagPreferences.disinterests;
const originalEventId = hashtagPreferences.eventId;
const originalEventCreatedAt = hashtagPreferences.eventCreatedAt;
const originalLoaded = hashtagPreferences.loaded;
const originalUiReady = hashtagPreferences.uiReady;
const originalDataReady = hashtagPreferences.dataReady;
const originalLoadedFromCache = hashtagPreferences.loadedFromCache;
const originalLastSuccessfulSyncAt = hashtagPreferences.lastSuccessfulSyncAt;
const originalPreferencesVersion = hashtagPreferences.preferencesVersion;
try {
hashtagPreferences.loadFromCache = (candidate) => {
if (candidate !== pubkey) {
return false;
}
hashtagPreferences.activePubkey = candidate;
hashtagPreferences.interests = new Set(["cachepref"]);
hashtagPreferences.disinterests = new Set();
hashtagPreferences.eventId = "cache-event";
hashtagPreferences.eventCreatedAt = 777;
hashtagPreferences.loaded = true;
hashtagPreferences.uiReady = true;
hashtagPreferences.dataReady = true;
hashtagPreferences.loadedFromCache = true;
hashtagPreferences.lastSuccessfulSyncAt = 777000;
hashtagPreferences.preferencesVersion = 1;
hashtagPreferences.emitChange("cache-load");
return true;
};
const changes = [];
const unsubscribe = hashtagPreferences.on("change", (detail) => {
changes.push(detail);
});
try {
nostrClient.pool = null;
await hashtagPreferences.load(pubkey, { allowPermissionPrompt: false });
const settled = changes.filter((entry) => entry?.action === "load-settled");
assert.ok(settled.length >= 1, "cache-backed load should emit load-settled");
const final = settled[settled.length - 1];
assert.equal(final.activePubkey, pubkey);
assert.equal(final.loadedFromCache, true);
assert.equal(final.dataReady, true);
assert.equal(final.uiReady, true);
assert.deepEqual(final.interests, ["cachepref"]);
} finally {
unsubscribe();
}
} finally {
nostrClient.pool = originalPool;
hashtagPreferences.loadFromCache = originalLoadFromCache;
hashtagPreferences.interests = originalInterests;
hashtagPreferences.disinterests = originalDisinterests;
hashtagPreferences.eventId = originalEventId;
hashtagPreferences.eventCreatedAt = originalEventCreatedAt;
hashtagPreferences.loaded = originalLoaded;
hashtagPreferences.uiReady = originalUiReady;
hashtagPreferences.dataReady = originalDataReady;
hashtagPreferences.loadedFromCache = originalLoadedFromCache;
hashtagPreferences.lastSuccessfulSyncAt = originalLastSuccessfulSyncAt;
hashtagPreferences.preferencesVersion = originalPreferencesVersion;
}
},
);
test(
"interest and disinterest lists remain exclusive",
{ concurrency: false },
+170
View File
@@ -166,6 +166,176 @@ await (async () => {
}
})();
await (async () => {
const actor = "7".repeat(64);
const UserBlockListManager = userBlocks.constructor;
const manager = new UserBlockListManager();
const originalRelayEntries = relayManager.getEntries();
const originalFetchIncremental = nostrClient.fetchListIncrementally;
const originalPool = nostrClient.pool;
const originalRelays = Array.isArray(nostrClient.relays)
? [...nostrClient.relays]
: nostrClient.relays;
const originalWriteRelays = Array.isArray(nostrClient.writeRelays)
? [...nostrClient.writeRelays]
: nostrClient.writeRelays;
const relayUrls = ["wss://relay-blocks-empty.example"];
relayManager.setEntries(
relayUrls.map((url) => ({ url, mode: "both" })),
{ allowEmpty: false, updateClient: false },
);
nostrClient.relays = relayUrls;
nostrClient.writeRelays = relayUrls;
nostrClient.pool = originalPool || { list: async () => [] };
nostrClient.fetchListIncrementally = async () => [];
const changeEvents = [];
const statusEvents = [];
const unsubscribeChange = manager.on(USER_BLOCK_EVENTS.CHANGE, (detail) => {
changeEvents.push(detail);
});
const unsubscribeStatus = manager.on(USER_BLOCK_EVENTS.STATUS, (detail) => {
statusEvents.push(detail);
});
try {
await manager.loadBlocks(actor);
const settledChange = changeEvents.filter(
(detail) => detail?.action === "load-settled",
);
assert.ok(
settledChange.length >= 1,
"loadBlocks should emit a final load-settled change event",
);
const finalChange = settledChange[settledChange.length - 1];
assert.equal(finalChange.activePubkey, actor);
assert.equal(finalChange.loaded, true);
assert.deepEqual(finalChange.blockedPubkeys, []);
const settledStatus = statusEvents.filter(
(detail) => detail?.status === "settled",
);
assert.ok(
settledStatus.length >= 1,
"loadBlocks should emit settled status metadata",
);
const finalStatus = settledStatus[settledStatus.length - 1];
assert.equal(finalStatus.status, "settled");
} finally {
unsubscribeChange?.();
unsubscribeStatus?.();
relayManager.setEntries(
originalRelayEntries,
{ allowEmpty: false, updateClient: false },
);
nostrClient.fetchListIncrementally = originalFetchIncremental;
nostrClient.pool = originalPool;
nostrClient.relays = originalRelays;
nostrClient.writeRelays = originalWriteRelays;
manager.reset();
}
})();
await (async () => {
const actor = "8".repeat(64);
const UserBlockListManager = userBlocks.constructor;
const manager = new UserBlockListManager();
const originalRelayEntries = relayManager.getEntries();
const originalFetchIncremental = nostrClient.fetchListIncrementally;
const originalPool = nostrClient.pool;
const originalRelays = Array.isArray(nostrClient.relays)
? [...nostrClient.relays]
: nostrClient.relays;
const originalWriteRelays = Array.isArray(nostrClient.writeRelays)
? [...nostrClient.writeRelays]
: nostrClient.writeRelays;
const originalNostr = window.nostr;
const originalEnsureSignerForPubkey = nostrClient.ensureActiveSignerForPubkey;
const originalSigner = getActiveSigner();
const relayUrls = ["wss://relay-blocks-permission.example"];
relayManager.setEntries(
relayUrls.map((url) => ({ url, mode: "both" })),
{ allowEmpty: false, updateClient: false },
);
nostrClient.relays = relayUrls;
nostrClient.writeRelays = relayUrls;
nostrClient.pool = originalPool || { list: async () => [] };
nostrClient.fetchListIncrementally = async () => [
{
id: "event-blocks-permission",
kind: 10000,
created_at: 505,
pubkey: actor,
content: "cipher-blocks-permission",
tags: [["encrypted", "nip04"]],
},
];
nostrClient.ensureActiveSignerForPubkey = async () => null;
clearActiveSigner();
window.nostr = undefined;
const changeEvents = [];
const statusEvents = [];
const unsubscribeChange = manager.on(USER_BLOCK_EVENTS.CHANGE, (detail) => {
changeEvents.push(detail);
});
const unsubscribeStatus = manager.on(USER_BLOCK_EVENTS.STATUS, (detail) => {
statusEvents.push(detail);
});
try {
await manager.loadBlocks(actor, { allowPermissionPrompt: false });
const settledChange = changeEvents.filter(
(detail) => detail?.action === "load-settled",
);
assert.ok(
settledChange.length >= 1,
"permission-gated loads should still emit load-settled change",
);
const finalChange = settledChange[settledChange.length - 1];
assert.equal(finalChange.activePubkey, actor);
assert.equal(finalChange.loaded, true);
const settledStatus = statusEvents.filter(
(detail) => detail?.status === "settled",
);
assert.ok(
settledStatus.length >= 1,
"permission-gated loads should still emit settled status",
);
assert.ok(
statusEvents.some(
(detail) => detail?.status === "permission-required",
),
"permission-gated load should emit permission-required status before settling",
);
} finally {
unsubscribeChange?.();
unsubscribeStatus?.();
relayManager.setEntries(
originalRelayEntries,
{ allowEmpty: false, updateClient: false },
);
nostrClient.fetchListIncrementally = originalFetchIncremental;
nostrClient.pool = originalPool;
nostrClient.relays = originalRelays;
nostrClient.writeRelays = originalWriteRelays;
nostrClient.ensureActiveSignerForPubkey = originalEnsureSignerForPubkey;
window.nostr = originalNostr;
clearActiveSigner();
if (originalSigner) {
setActiveSigner(originalSigner);
}
manager.reset();
}
})();
await new Promise((resolve) => setTimeout(resolve, 100));
await (async () => {