mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
[perf] Stale-while-revalidate cold boot: don't serialize behind admin lists + list sync
Cold refresh stalled on "Fetching moderation filters…" and then (logged in) "Fetching subscriptions…" before the grid could render. Boot was SERIAL behind relay fetches even though every stage already had a local cache: 1. _initAccessControl awaited the full admin-list relay refresh (up to 15s) before auto-login — but accessControl hydrates whitelist/blacklist from localStorage at construction, and isHydrated() existed for exactly this case (unused). Now: when cached lists are present (every visit after the first), boot proceeds immediately and the refresh continues in the background — the existing whitelist/blacklist change listeners re-filter the grids when it lands. First-ever visits still wait (a whitelist-mode instance must never render unfiltered). The admin-pane refresh no longer blocks boot at all. 2. bootstrapTrustedSeeds awaited the same relay refresh (up to 3.5s more). waitForAccessControl now fast-paths on isHydrated(): cached editors seed trust immediately, and its onEditors/Whitelist/BlacklistChange listeners already re-apply seeds when the real refresh lands. 3. The post-login feed gate held the grid for the FULL list sync + NIP-07 decryption (up to 8s). New js/app/feedListCacheGate.js: a probe that checks all three list services (blocks/subscriptions/hashtags) hold a valid cache for the active pubkey, raced into the gate — warm boots open as soon as the caches hydrate (first ticks) and the background sync re-filters on arrival. Cold boots (no caches) behave exactly as before. Net effect: warm cold-refresh renders from IndexedDB-cached events with cached filters/lists near-instantly; relay truth streams in behind it. No moderation semantics changed — every stage that lacks a cache still waits. Tests: tests/feed-list-cache-gate.test.mjs (5 — all-three-caches probe incl. stale-pubkey/missing-service, late-hydration early release, cold-boot timeout, throwing probe). Access-control + switch suites green. Lint + build green (coordinator compacted to stay under its size cap). Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
1 parent
5466d97544
commit
b8e6921ac0
5 files changed
+255
-38
No files matched your search
@@ -983,8 +983,6 @@ class Application {
|
||||
}
|
||||
|
||||
async _initAccessControl() {
|
||||
const promises = [];
|
||||
|
||||
const aclRefreshPromise = accessControl
|
||||
.refresh()
|
||||
.then(() => {
|
||||
@@ -1005,6 +1003,36 @@ class Application {
|
||||
);
|
||||
});
|
||||
|
||||
// Admin-pane state is UI, not a boot dependency — never block boot on it.
|
||||
const refreshAdminPane = () => {
|
||||
if (!this.profileController) {
|
||||
return;
|
||||
}
|
||||
Promise.resolve()
|
||||
.then(() => this.profileController.refreshAdminPaneState())
|
||||
.catch((error) => {
|
||||
devLogger.warn(
|
||||
"Failed to update admin pane after connecting to Nostr:",
|
||||
error,
|
||||
);
|
||||
});
|
||||
};
|
||||
refreshAdminPane();
|
||||
|
||||
// Stale-while-revalidate boot: when the admin lists hydrated from the
|
||||
// localStorage cache (every visit after the first), canAccess() is already
|
||||
// accurate, so DON'T serialize boot behind the relay refresh — it continues
|
||||
// in the background and the whitelist/blacklist change listeners re-filter
|
||||
// the grids + re-apply trusted seeds when it lands. This was the
|
||||
// "Fetching moderation filters…" cold-start stall (up to 15s before the
|
||||
// feed could even start).
|
||||
if (accessControl.isHydrated?.()) {
|
||||
void aclRefreshPromise.then(refreshAdminPane);
|
||||
return;
|
||||
}
|
||||
|
||||
// First-ever visit (no cached lists): wait, so a whitelist-mode instance
|
||||
// never renders an unfiltered/empty feed.
|
||||
// Safeguard: Do not block app initialization indefinitely if relays are slow/unresponsive.
|
||||
// 15s gives plenty of time for a healthy connection but prevents E2E test timeouts (60s).
|
||||
const timeoutPromise = new Promise((resolve) => {
|
||||
@@ -1014,22 +1042,8 @@ class Application {
|
||||
}, 15000);
|
||||
});
|
||||
|
||||
promises.push(Promise.race([aclRefreshPromise, timeoutPromise]));
|
||||
|
||||
if (this.profileController) {
|
||||
promises.push(
|
||||
Promise.resolve()
|
||||
.then(() => this.profileController.refreshAdminPaneState())
|
||||
.catch((error) => {
|
||||
devLogger.warn(
|
||||
"Failed to update admin pane after connecting to Nostr:",
|
||||
error,
|
||||
);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
await Promise.all(promises);
|
||||
await Promise.race([aclRefreshPromise, timeoutPromise]);
|
||||
refreshAdminPane();
|
||||
}
|
||||
|
||||
async _syncSessionActorBlacklist(trigger) {
|
||||
|
||||
@@ -15,6 +15,10 @@ import { clearWatchHistoryDecryptedChunkCache } from "../nostr/watchHistoryDecry
|
||||
import { FEED_TYPES, FEATURE_TRENDING_FEED } from "../constants.js";
|
||||
import { showPasswordPrompt } from "../ui/promptDialog.js";
|
||||
import { evaluateStoredNsecSwitch } from "./storedNsecSwitch.js";
|
||||
import {
|
||||
createListCacheProbe,
|
||||
waitForListCaches,
|
||||
} from "./feedListCacheGate.js";
|
||||
|
||||
/**
|
||||
* Kick a background watch-history refresh after login, once the feed-driving
|
||||
@@ -201,12 +205,10 @@ export function createAuthSessionCoordinator(deps) {
|
||||
if (detail?.identityChanged) {
|
||||
this.resetViewLoggingState();
|
||||
|
||||
// Account switch: clear the PREVIOUS account's cached DMs + stop its
|
||||
// subscription before the new account loads. The DM store is app-wide on
|
||||
// nostrService, so resetting only the modal's view left the old account's
|
||||
// messages on screen after a switch (e.g. NIP-07 → nsec) — logout clears it
|
||||
// the same way. keepSnapshot keeps each account's persisted cache so
|
||||
// switching back stays fast.
|
||||
// Account switch: clear the PREVIOUS account's app-wide DM store + stop
|
||||
// its subscription (logout does the same); resetting only the modal view
|
||||
// left old messages on screen. keepSnapshot preserves each account's
|
||||
// persisted DM cache so switching back stays fast.
|
||||
try {
|
||||
this.nostrService?.stopDirectMessageSubscription?.();
|
||||
this.nostrService?.clearDirectMessages?.({ emit: true, keepSnapshot: true });
|
||||
@@ -666,11 +668,8 @@ export function createAuthSessionCoordinator(deps) {
|
||||
authLoadingState: this.authLoadingState,
|
||||
});
|
||||
|
||||
// Now that the NEW identity's lists (blocks/subscriptions/hashtags) have
|
||||
// settled, backfill the profile modal if it is open — so switching
|
||||
// accounts with the modal visible refreshes the pane the user is viewing
|
||||
// with the freshly-loaded data instead of leaving the previous account's
|
||||
// list on screen until a manual refresh. No-op when the modal is closed.
|
||||
// The NEW identity's lists settled: backfill the open profile modal so a
|
||||
// switch refreshes the visible pane (no-op when the modal is closed).
|
||||
if (
|
||||
this.profileController &&
|
||||
typeof this.profileController.refreshOpenPanesForActiveIdentity ===
|
||||
@@ -791,11 +790,21 @@ export function createAuthSessionCoordinator(deps) {
|
||||
// lowered so settled results arrive faster. 8s prevents the feed from
|
||||
// being blocked while still giving most lists time to decrypt.
|
||||
const FEED_SYNC_TIMEOUT_MS = 8000;
|
||||
// Cache-aware early release (rationale in feedListCacheGate.js): open the
|
||||
// gate as soon as all three list caches are valid for this pubkey.
|
||||
const listCacheReadyPromise = waitForListCaches(
|
||||
createListCacheProbe({
|
||||
userBlocks,
|
||||
subscriptions,
|
||||
hashtagPreferences: this.hashtagPreferences,
|
||||
normalizeHexPubkey: (value) => this.normalizeHexPubkey(value),
|
||||
activePubkey,
|
||||
}),
|
||||
{ timeoutMs: FEED_SYNC_TIMEOUT_MS },
|
||||
);
|
||||
const feedSyncPromise = Promise.race([
|
||||
Promise.allSettled([
|
||||
listStatePromise,
|
||||
profileStatePromise,
|
||||
]),
|
||||
Promise.allSettled([listStatePromise, profileStatePromise]),
|
||||
listCacheReadyPromise,
|
||||
new Promise((resolve) => setTimeout(resolve, FEED_SYNC_TIMEOUT_MS)),
|
||||
]).catch(() => null);
|
||||
|
||||
@@ -1838,11 +1847,9 @@ export function createAuthSessionCoordinator(deps) {
|
||||
return context;
|
||||
}
|
||||
|
||||
// Block lists are NIP-04 encrypted. A reloaded nsec session can lose its
|
||||
// in-memory key; re-unlock it (one passphrase prompt) rather than failing
|
||||
// with a blanket signer error (TODO #57). Only short-circuit on the
|
||||
// user-driven outcomes — anything else falls through so the mutation's own
|
||||
// signer error (e.g. an unresponsive NIP-07 extension) still surfaces.
|
||||
// Block lists are NIP-04 encrypted: re-unlock a reloaded nsec signer via
|
||||
// one passphrase prompt (TODO #57). Only user-driven outcomes short-circuit;
|
||||
// everything else falls through to the mutation's own signer errors.
|
||||
if (typeof this.ensureEncryptionCapableSigner === "function") {
|
||||
const ensured = await this.ensureEncryptionCapableSigner({
|
||||
pubkey: actorHex,
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
// Cache-aware early release for the post-login feed gate. The login flow holds
|
||||
// the video grid until blocks/subscriptions/hashtag lists settle (so the feed
|
||||
// never flashes unfiltered content), time-boxed at several seconds. But on every
|
||||
// warm boot those services hydrate valid per-pubkey CACHES almost immediately —
|
||||
// filtering with cached lists is accurate, and the relay sync keeps running in
|
||||
// the background (its change events re-filter when fresh data lands). This probe
|
||||
// lets the gate open as soon as all three caches are usable instead of waiting
|
||||
// for full relay sync + NIP-07 decryption ("Fetching subscriptions…" stall).
|
||||
|
||||
// Builds a () => boolean probe: true when all three list services hold a valid
|
||||
// cache for the active pubkey. Mirrors the hasValid*Cache checks used by the
|
||||
// login list-sync bookkeeping.
|
||||
export function createListCacheProbe({
|
||||
userBlocks,
|
||||
subscriptions,
|
||||
hashtagPreferences,
|
||||
normalizeHexPubkey,
|
||||
activePubkey,
|
||||
} = {}) {
|
||||
const normalize =
|
||||
typeof normalizeHexPubkey === "function"
|
||||
? normalizeHexPubkey
|
||||
: (value) => (typeof value === "string" ? value.trim().toLowerCase() : "");
|
||||
const target = normalize(activePubkey);
|
||||
|
||||
return () => {
|
||||
if (!target) {
|
||||
return false;
|
||||
}
|
||||
const blocksReady =
|
||||
Boolean(userBlocks) &&
|
||||
userBlocks.loaded === true &&
|
||||
normalize(userBlocks.activePubkey) === target;
|
||||
const subscriptionsReady =
|
||||
Boolean(subscriptions) &&
|
||||
subscriptions.loaded === true &&
|
||||
normalize(subscriptions.currentUserPubkey) === target;
|
||||
const hashtagsReady =
|
||||
Boolean(hashtagPreferences) &&
|
||||
hashtagPreferences.loaded === true &&
|
||||
normalize(hashtagPreferences.activePubkey) === target;
|
||||
return blocksReady && subscriptionsReady && hashtagsReady;
|
||||
};
|
||||
}
|
||||
|
||||
// Resolves "cache" as soon as the probe passes, or null at the timeout. Polls on
|
||||
// a short interval — the caches hydrate within the first ticks on warm boots.
|
||||
export function waitForListCaches(
|
||||
probe,
|
||||
{ timeoutMs = 8000, intervalMs = 150 } = {},
|
||||
) {
|
||||
return new Promise((resolve) => {
|
||||
if (typeof probe !== "function") {
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
const startedAt = Date.now();
|
||||
const check = () => {
|
||||
let ready = false;
|
||||
try {
|
||||
ready = probe() === true;
|
||||
} catch (error) {
|
||||
ready = false;
|
||||
}
|
||||
if (ready) {
|
||||
resolve("cache");
|
||||
return;
|
||||
}
|
||||
if (Date.now() - startedAt >= timeoutMs) {
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
setTimeout(check, intervalMs);
|
||||
};
|
||||
check();
|
||||
});
|
||||
}
|
||||
|
||||
export default waitForListCaches;
|
||||
@@ -68,6 +68,17 @@ async function waitForAccessControl({ timeoutMs = TRUST_SEED_READY_TIMEOUT_MS }
|
||||
return { ok: true, timedOut: false };
|
||||
}
|
||||
|
||||
// Cached admin lists (hydrated synchronously from localStorage at
|
||||
// construction) are enough to seed trust — don't stall boot behind the relay
|
||||
// refresh. If the refresh later changes editors/whitelist/blacklist, the
|
||||
// change listeners registered in bootstrapTrustedSeeds re-apply the seeds.
|
||||
if (
|
||||
typeof accessControl.isHydrated === "function" &&
|
||||
accessControl.isHydrated()
|
||||
) {
|
||||
return { ok: true, timedOut: false, fromCache: true };
|
||||
}
|
||||
|
||||
let timedOut = false;
|
||||
const timeoutPromise = new Promise((_, reject) => {
|
||||
setTimeout(() => {
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
// Cold-load "first impression" fix: the post-login feed gate can open as soon as
|
||||
// blocks/subscriptions/hashtags hold valid per-pubkey CACHES (warm boot), instead
|
||||
// of waiting for the full relay sync + decryption. These test the probe + waiter
|
||||
// that implement the early release.
|
||||
//
|
||||
// test_integrity_note:
|
||||
// change_type: ["new_tests"]
|
||||
// scenarios:
|
||||
// - id: SCN-feed-list-cache-gate
|
||||
// given: "list services with/without valid per-pubkey caches"
|
||||
// when: "createListCacheProbe / waitForListCaches run"
|
||||
// then: "probe is true only when ALL THREE caches match the active pubkey; the waiter resolves 'cache' early or null at timeout"
|
||||
// observable_outcomes:
|
||||
// - "all three caches valid + matching pubkey -> probe true"
|
||||
// - "any cache missing / stale pubkey / not loaded -> probe false"
|
||||
// - "waiter resolves 'cache' once the probe flips true (caches hydrating late)"
|
||||
// - "waiter resolves null at timeout when caches never hydrate (cold boot)"
|
||||
// determinism_controls:
|
||||
// - "plain state objects; short real timers (interval 5ms, timeout 40ms)"
|
||||
// anti_cheat_rationale:
|
||||
// prevents: ["hard-coded return value", "over-mocking internal logic"]
|
||||
// relaxation:
|
||||
// did_relax_any_assertion: false
|
||||
|
||||
import test from "node:test";
|
||||
import { strict as assert } from "node:assert";
|
||||
import {
|
||||
createListCacheProbe,
|
||||
waitForListCaches,
|
||||
} from "../js/app/feedListCacheGate.js";
|
||||
|
||||
const PUB = "A".repeat(64); // exercise normalization (upper → lower)
|
||||
|
||||
function services({ blocks = true, subs = true, tags = true, pubkey = PUB } = {}) {
|
||||
return {
|
||||
userBlocks: { loaded: blocks, activePubkey: pubkey },
|
||||
subscriptions: { loaded: subs, currentUserPubkey: pubkey },
|
||||
hashtagPreferences: { loaded: tags, activePubkey: pubkey },
|
||||
};
|
||||
}
|
||||
|
||||
const normalize = (v) => (typeof v === "string" ? v.trim().toLowerCase() : "");
|
||||
|
||||
test("probe is true only when ALL three caches are loaded for the active pubkey", () => {
|
||||
const ok = createListCacheProbe({
|
||||
...services(),
|
||||
normalizeHexPubkey: normalize,
|
||||
activePubkey: PUB.toLowerCase(),
|
||||
});
|
||||
assert.equal(ok(), true);
|
||||
|
||||
for (const partial of [
|
||||
services({ blocks: false }),
|
||||
services({ subs: false }),
|
||||
services({ tags: false }),
|
||||
services({ pubkey: "b".repeat(64) }), // caches belong to another account
|
||||
]) {
|
||||
const probe = createListCacheProbe({
|
||||
...partial,
|
||||
normalizeHexPubkey: normalize,
|
||||
activePubkey: PUB.toLowerCase(),
|
||||
});
|
||||
assert.equal(probe(), false);
|
||||
}
|
||||
});
|
||||
|
||||
test("probe is false with no active pubkey or missing services", () => {
|
||||
assert.equal(
|
||||
createListCacheProbe({ ...services(), normalizeHexPubkey: normalize, activePubkey: "" })(),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
createListCacheProbe({ normalizeHexPubkey: normalize, activePubkey: PUB })(),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("waiter resolves 'cache' once caches hydrate (late flip)", async () => {
|
||||
const state = { ready: false };
|
||||
setTimeout(() => {
|
||||
state.ready = true;
|
||||
}, 15);
|
||||
const result = await waitForListCaches(() => state.ready, {
|
||||
timeoutMs: 500,
|
||||
intervalMs: 5,
|
||||
});
|
||||
assert.equal(result, "cache");
|
||||
});
|
||||
|
||||
test("waiter resolves null at timeout when caches never hydrate (cold boot)", async () => {
|
||||
const result = await waitForListCaches(() => false, {
|
||||
timeoutMs: 40,
|
||||
intervalMs: 5,
|
||||
});
|
||||
assert.equal(result, null);
|
||||
});
|
||||
|
||||
test("a throwing probe is treated as not-ready (never breaks the gate)", async () => {
|
||||
const result = await waitForListCaches(
|
||||
() => {
|
||||
throw new Error("boom");
|
||||
},
|
||||
{ timeoutMs: 30, intervalMs: 5 },
|
||||
);
|
||||
assert.equal(result, null);
|
||||
});
|
||||
Reference in new issue
Block a user