mirror of
https://github.com/block/buzz.git
synced 2026-10-30 22:11:36 +00:00
fix(theme): refresh appearance snapshot on edit and hold mobile edits until hydration
Second pass on the community-theme sync engine, closing four data-loss defects re-flagged in review. All are silent persistence/sync bugs; no UI or feature behavior changes. Desktop, snapshot lifetime and refresh: - The appearance snapshot that seeds no-record communities was captured once and never cleared, so after a user changed glass/opacity/prominent-tab a later empty community resurrected the stale pre-migration value and republished it over the current choice. Refresh the durable snapshot on a genuine user glass edit only; accent- or theme-only edits leave it untouched so a community's own glass can never leak into the profile-wide seed, and programmatic per-community applies never reach the persist branch. - The snapshot lived on the per-community controller instance, which remounts under a keyed provider, so a full store lost it across mounts and the next community re-captured the previous one's already-rewritten appearance. Back the capture with a module-level in-memory cache so the profile's first-seen value survives a full store for the life of the process. Mobile, merge and hydration gate: - Merging desktop-only glass from a decoded full cache trusted stale fields, so a mobile accent edit republished stale glass over current relay state. Always overlay the observed remote's desktop-only fields; mobile has no glass-editing UI, so those fields are never locally authored, only inherited. - The pre-hydration hold could strand an edit forever when the first fetch returned unavailable and the subscription stayed quiet. Add a bounded-backoff hydration recovery that re-queries and releases the gate, mirroring the subscription-retry pattern, with cleanup wired into cancelPending and dispose. Co-authored-by: kenny lopez <klopez4212@gmail.com> Reviewed-by: Mongo <mongo@buzz.block.builderlab.xyz> Signed-off-by: kenny lopez <klopez4212@gmail.com>
This commit is contained in:
@@ -14,6 +14,7 @@ import {
|
||||
markCommunityThemeMigrated,
|
||||
readCommunityThemeOutbox,
|
||||
readCommunityThemePreference,
|
||||
refreshCommunityThemeAppearanceSnapshot,
|
||||
sameCommunityThemePreference,
|
||||
writeCommunityThemeOutbox,
|
||||
writeCommunityThemePreference,
|
||||
@@ -289,6 +290,25 @@ export function CommunityThemeController() {
|
||||
communityThemeAppearanceFallback(appearanceSnapshotRef.current),
|
||||
);
|
||||
if (stored && sameCommunityThemePreference(stored, preference)) return;
|
||||
// A genuine user edit that changes glass/opacity/prominent-tab is the
|
||||
// user's current profile-wide appearance intent, so refresh the snapshot
|
||||
// that seeds no-record communities. Without this the snapshot stays frozen
|
||||
// at the pre-migration value and a later empty community resurrects it over
|
||||
// the user's current choice. Only a changed appearance refreshes it, so an
|
||||
// accent- or theme-only edit never leaks this community's glass into the
|
||||
// profile-wide seed.
|
||||
const priorScoped = scopedPreferenceRef.current;
|
||||
if (
|
||||
!priorScoped ||
|
||||
priorScoped.glassBackground !== preference.glassBackground ||
|
||||
priorScoped.glassOpacity !== preference.glassOpacity ||
|
||||
priorScoped.prominentActiveTab !== preference.prominentActiveTab
|
||||
) {
|
||||
appearanceSnapshotRef.current = refreshCommunityThemeAppearanceSnapshot(
|
||||
pubkey,
|
||||
preference,
|
||||
);
|
||||
}
|
||||
scopedPreferenceRef.current = preference;
|
||||
if (!writeCommunityThemePreference(pubkey, relayUrl, preference)) return;
|
||||
if (!writeCommunityThemeOutbox(pubkey, relayUrl, preference)) return;
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
readCommunityThemeAppearanceSnapshot,
|
||||
readCommunityThemeOutbox,
|
||||
readCommunityThemePreference,
|
||||
refreshCommunityThemeAppearanceSnapshot,
|
||||
sameCommunityThemePreference,
|
||||
writeCommunityThemeOutbox,
|
||||
writeCommunityThemePreference,
|
||||
@@ -237,6 +238,93 @@ test("a full store still yields the correct in-session appearance fallback", ()
|
||||
});
|
||||
});
|
||||
|
||||
test("a full store pins the snapshot across a controller remount", () => {
|
||||
// The controller is remounted per community under a keyed provider, so a
|
||||
// ref cannot carry the snapshot between mounts. When the store is full the
|
||||
// capture must still return the profile's first-seen value on the next
|
||||
// mount, rather than re-capturing the current (previous community's)
|
||||
// appearance. This pins that the in-memory fallback has profile lifetime.
|
||||
globalThis.window = {
|
||||
localStorage: {
|
||||
getItem: () => null,
|
||||
setItem: () => {
|
||||
throw new Error("quota exceeded");
|
||||
},
|
||||
removeItem: () => {},
|
||||
},
|
||||
};
|
||||
const firstMount = {
|
||||
...DEFAULT_COMMUNITY_THEME,
|
||||
glassBackground: true,
|
||||
glassOpacity: 80,
|
||||
prominentActiveTab: true,
|
||||
};
|
||||
const first = captureCommunityThemeAppearanceSnapshot(
|
||||
"remount-pk",
|
||||
firstMount,
|
||||
);
|
||||
assert.deepEqual(first, {
|
||||
glassBackground: true,
|
||||
glassOpacity: 80,
|
||||
prominentActiveTab: true,
|
||||
});
|
||||
|
||||
// The next mount is a different community whose applied appearance differs.
|
||||
// Capture must return the pinned first value, not this community's.
|
||||
const secondMount = {
|
||||
...DEFAULT_COMMUNITY_THEME,
|
||||
glassBackground: false,
|
||||
glassOpacity: 30,
|
||||
prominentActiveTab: false,
|
||||
};
|
||||
assert.deepEqual(
|
||||
captureCommunityThemeAppearanceSnapshot("remount-pk", secondMount),
|
||||
first,
|
||||
);
|
||||
});
|
||||
|
||||
test("a user glass edit refreshes the snapshot for no-record communities", () => {
|
||||
// The initial snapshot is frozen at the pre-migration appearance. Once the
|
||||
// user changes glass, that former value is stale: a no-record community must
|
||||
// inherit the new choice, not resurrect the old one. refresh overwrites the
|
||||
// durable snapshot so the empty-scope fallback tracks the current choice.
|
||||
globalThis.window = { localStorage: localStorageStub() };
|
||||
const preMigration = {
|
||||
...DEFAULT_COMMUNITY_THEME,
|
||||
glassBackground: true,
|
||||
glassOpacity: 80,
|
||||
prominentActiveTab: true,
|
||||
};
|
||||
const pinned = captureCommunityThemeAppearanceSnapshot(
|
||||
"edit-pk",
|
||||
preMigration,
|
||||
);
|
||||
assert.deepEqual(pinned, {
|
||||
glassBackground: true,
|
||||
glassOpacity: 80,
|
||||
prominentActiveTab: true,
|
||||
});
|
||||
|
||||
const edited = {
|
||||
...DEFAULT_COMMUNITY_THEME,
|
||||
glassBackground: false,
|
||||
glassOpacity: 40,
|
||||
prominentActiveTab: false,
|
||||
};
|
||||
const refreshed = refreshCommunityThemeAppearanceSnapshot("edit-pk", edited);
|
||||
assert.deepEqual(refreshed, {
|
||||
glassBackground: false,
|
||||
glassOpacity: 40,
|
||||
prominentActiveTab: false,
|
||||
});
|
||||
// A subsequent capture (a later community mount) sees the refreshed value,
|
||||
// so the no-record fallback carries the user's current choice.
|
||||
assert.deepEqual(
|
||||
captureCommunityThemeAppearanceSnapshot("edit-pk", preMigration),
|
||||
refreshed,
|
||||
);
|
||||
});
|
||||
|
||||
test("desktop appearance limits match the shared wire contract", () => {
|
||||
const contract = JSON.parse(
|
||||
readFileSync(
|
||||
|
||||
@@ -228,14 +228,18 @@ export function readCommunityThemeAppearanceSnapshot(
|
||||
}
|
||||
}
|
||||
|
||||
const inMemoryAppearanceSnapshots = new Map<string, CommunityThemeAppearance>();
|
||||
|
||||
/**
|
||||
* Persist the profile's pre-migration appearance the first time it is seen and
|
||||
* return the durable snapshot. Writing once fixes the value while the live
|
||||
* global appearance keys are still authoritative, so later community switches —
|
||||
* which now rewrite those keys per community — cannot corrupt the seed the
|
||||
* per-record migration inherits from. Subsequent calls return the stored
|
||||
* snapshot unchanged; a full storage falls back to the live appearance for the
|
||||
* current session without pinning a wrong value.
|
||||
* snapshot unchanged; a full storage falls back to a module-level in-memory
|
||||
* cache so the profile's first-seen value still survives the per-community
|
||||
* controller remount, rather than being re-captured from a later community's
|
||||
* already-rewritten appearance.
|
||||
*/
|
||||
export function captureCommunityThemeAppearanceSnapshot(
|
||||
pubkey: string,
|
||||
@@ -243,6 +247,8 @@ export function captureCommunityThemeAppearanceSnapshot(
|
||||
): CommunityThemeAppearance {
|
||||
const existing = readCommunityThemeAppearanceSnapshot(pubkey);
|
||||
if (existing) return existing;
|
||||
const cached = inMemoryAppearanceSnapshots.get(pubkey);
|
||||
if (cached) return cached;
|
||||
const snapshot: CommunityThemeAppearance = {
|
||||
glassBackground: appearance.glassBackground,
|
||||
glassOpacity: appearance.glassOpacity,
|
||||
@@ -254,7 +260,43 @@ export function captureCommunityThemeAppearanceSnapshot(
|
||||
JSON.stringify(snapshot),
|
||||
);
|
||||
} catch {
|
||||
// The live appearance still seeds this session even when storage is full.
|
||||
// A full store cannot durably pin the snapshot, so retain it in memory for
|
||||
// the life of this process. Without this the next community remount finds
|
||||
// neither a stored snapshot nor this value and re-captures the current
|
||||
// (previous community's) appearance as the seed.
|
||||
inMemoryAppearanceSnapshots.set(pubkey, snapshot);
|
||||
}
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
/**
|
||||
* Overwrite the profile's appearance snapshot with a later explicit user glass
|
||||
* choice. The initial snapshot is frozen at the pre-migration appearance, but
|
||||
* once the user changes glass/opacity/prominent-tab that former value is stale:
|
||||
* a no-record community would otherwise resurrect it and republish it over the
|
||||
* user's current choice. Callers refresh only on a genuine user edit, never on
|
||||
* a programmatic per-community apply, so a community's own record can never
|
||||
* leak into the profile-wide seed.
|
||||
*/
|
||||
export function refreshCommunityThemeAppearanceSnapshot(
|
||||
pubkey: string,
|
||||
appearance: CommunityThemeAppearance,
|
||||
): CommunityThemeAppearance {
|
||||
const snapshot: CommunityThemeAppearance = {
|
||||
glassBackground: appearance.glassBackground,
|
||||
glassOpacity: appearance.glassOpacity,
|
||||
prominentActiveTab: appearance.prominentActiveTab,
|
||||
};
|
||||
try {
|
||||
window.localStorage.setItem(
|
||||
communityThemeAppearanceSnapshotKey(pubkey),
|
||||
JSON.stringify(snapshot),
|
||||
);
|
||||
inMemoryAppearanceSnapshots.delete(pubkey);
|
||||
} catch {
|
||||
// Retain the refreshed value in memory when the store is full, mirroring
|
||||
// the capture path so the current choice still survives a remount.
|
||||
inMemoryAppearanceSnapshots.set(pubkey, snapshot);
|
||||
}
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
@@ -138,15 +138,16 @@ class CommunityThemePreference {
|
||||
includesGlassOpacity &&
|
||||
includesProminentActiveTab;
|
||||
|
||||
/// Fold [source]'s desktop-only appearance into this preference. Used when
|
||||
/// republishing a mobile edit that carries no desktop opinion so the relay
|
||||
/// coordinate keeps the glass and prominent-tab values a desktop client
|
||||
/// authored. A preference that already includes the fields is returned
|
||||
/// unchanged; only the fields [source] actually carries are adopted.
|
||||
/// Adopt [source]'s desktop-only appearance in place of this preference's.
|
||||
/// Mobile has no UI that authors glass or prominent-tab, so those fields are
|
||||
/// never a local opinion — they are only ever a copy of what was hydrated.
|
||||
/// A cached "full" preference therefore carries stale desktop fields once
|
||||
/// another client changes them, so republishing a mobile edit must take the
|
||||
/// desktop-only fields from the latest observed coordinate rather than trust
|
||||
/// its own cache; otherwise it silently replays stale glass over the relay.
|
||||
CommunityThemePreference mergeDesktopAppearanceFrom(
|
||||
CommunityThemePreference source,
|
||||
) {
|
||||
if (includesDesktopAppearance) return this;
|
||||
return CommunityThemePreference(
|
||||
version: version,
|
||||
theme: theme,
|
||||
|
||||
@@ -49,6 +49,7 @@ class CommunityThemeSyncManager {
|
||||
|
||||
Timer? _publishTimer;
|
||||
Timer? _subscriptionRetryTimer;
|
||||
Timer? _hydrationRecoveryTimer;
|
||||
void Function()? _unsubscribe;
|
||||
CommunityThemePreference? _pending;
|
||||
CommunityThemePreference? _lastPublished;
|
||||
@@ -57,6 +58,7 @@ class CommunityThemeSyncManager {
|
||||
RemoteCommunityTheme? _lastRemote;
|
||||
int _subscriptionEpoch = 0;
|
||||
int _subscriptionRetryAttempt = 0;
|
||||
int _hydrationRecoveryAttempt = 0;
|
||||
int _publishRetryAttempt = 0;
|
||||
bool _publishInFlight = false;
|
||||
bool _publishRequestedWhileInFlight = false;
|
||||
@@ -179,6 +181,48 @@ class CommunityThemeSyncManager {
|
||||
});
|
||||
}
|
||||
|
||||
/// Retry the history query while an edit is held by the pre-hydration gate
|
||||
/// and the coordinate is still unobserved. Without this, an initial
|
||||
/// `fetchRemote` that returns `unavailable` (a transient history failure)
|
||||
/// while the live subscription stays quiet would strand the edit forever:
|
||||
/// neither `initialize`'s absence path nor `_accept` ever runs, so
|
||||
/// `_hydrationObserved` never flips and every later [flush] returns early.
|
||||
void _scheduleHydrationRecovery() {
|
||||
if (_disposed ||
|
||||
_hydrationObserved ||
|
||||
_hydrationRecoveryTimer != null ||
|
||||
_pending == null) {
|
||||
return;
|
||||
}
|
||||
final multiplier = 1 << min(_hydrationRecoveryAttempt, 5);
|
||||
_hydrationRecoveryAttempt++;
|
||||
_hydrationRecoveryTimer = Timer(subscriptionRetryBase * multiplier, () {
|
||||
_hydrationRecoveryTimer = null;
|
||||
unawaited(_recoverHydration());
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _recoverHydration() async {
|
||||
if (_disposed || _hydrationObserved || _pending == null) return;
|
||||
final result = await fetchRemote();
|
||||
if (_disposed || _hydrationObserved) return;
|
||||
if (result.status == CommunityThemeRemoteStatus.valid) {
|
||||
_accept(result.remote!);
|
||||
} else if (result.status == CommunityThemeRemoteStatus.absent) {
|
||||
_observeHydration();
|
||||
} else {
|
||||
// Still invalid or unavailable: keep retrying with backoff until the
|
||||
// coordinate is observed or the edit is superseded/cancelled.
|
||||
_scheduleHydrationRecovery();
|
||||
}
|
||||
}
|
||||
|
||||
void _cancelHydrationRecovery() {
|
||||
_hydrationRecoveryTimer?.cancel();
|
||||
_hydrationRecoveryTimer = null;
|
||||
_hydrationRecoveryAttempt = 0;
|
||||
}
|
||||
|
||||
Future<void> _recoverLiveSubscription() async {
|
||||
if (_disposed) return;
|
||||
if (!await _startLiveSubscription()) return;
|
||||
@@ -216,16 +260,15 @@ class CommunityThemeSyncManager {
|
||||
|
||||
/// Record that the relay coordinate has been observed at least once (a valid
|
||||
/// record or a confirmed absence) and release an edit held by the
|
||||
/// pre-hydration gate in [flush]. Only an incomplete edit is ever gated, so
|
||||
/// a complete edit's own scheduled publish is left untouched.
|
||||
/// pre-hydration gate in [flush]. Mobile authors no desktop-only fields, so
|
||||
/// every edit is gated until the coordinate is known and cannot be merged
|
||||
/// before then.
|
||||
void _observeHydration() {
|
||||
if (_hydrationObserved) return;
|
||||
_hydrationObserved = true;
|
||||
_cancelHydrationRecovery();
|
||||
final pending = _pending;
|
||||
if (!_disposed &&
|
||||
pending != null &&
|
||||
!pending.includesDesktopAppearance &&
|
||||
!_publishInFlight) {
|
||||
if (!_disposed && pending != null && !_publishInFlight) {
|
||||
// Accelerate the held edit now that the coordinate is known. Any pending
|
||||
// debounce timer is cancelled by _schedulePublish. A publish in flight is
|
||||
// left to its own finally-block, which reschedules the pending edit.
|
||||
@@ -255,6 +298,7 @@ class CommunityThemeSyncManager {
|
||||
void cancelPending() {
|
||||
_publishTimer?.cancel();
|
||||
_publishTimer = null;
|
||||
_cancelHydrationRecovery();
|
||||
_pending = null;
|
||||
}
|
||||
|
||||
@@ -267,11 +311,14 @@ class CommunityThemeSyncManager {
|
||||
}
|
||||
final preference = _pending;
|
||||
if (_disposed || preference == null) return;
|
||||
// Hold an edit that carries no desktop appearance opinion until the relay
|
||||
// coordinate has been observed, so a pre-hydration edit cannot replace a
|
||||
// desktop-authored record before we have seen it. _observeHydration
|
||||
// releases it. A complete edit is never gated.
|
||||
if (!preference.includesDesktopAppearance && !_hydrationObserved) {
|
||||
// Hold every edit until the relay coordinate has been observed, so a
|
||||
// pre-hydration edit cannot replace a desktop-authored record before we
|
||||
// have seen it. Mobile never authors the desktop-only fields, so even an
|
||||
// edit that carries them (inherited from a stale full cache) must wait:
|
||||
// without an observed coordinate there is nothing to merge the current
|
||||
// desktop values from. _observeHydration releases it.
|
||||
if (!_hydrationObserved) {
|
||||
_scheduleHydrationRecovery();
|
||||
return;
|
||||
}
|
||||
if (preference == _lastPublished) {
|
||||
|
||||
@@ -353,41 +353,50 @@ void main() {
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'published coordinate rejects delayed same-second initialization result',
|
||||
() async {
|
||||
const stale = CommunityThemePreference(
|
||||
theme: 'dracula',
|
||||
accent: '#ef4444',
|
||||
followSystem: false,
|
||||
);
|
||||
final history = Completer<List<NostrEvent>>();
|
||||
final session = _FakeSession(historyFuture: history.future);
|
||||
final relay = _FakeSignedRelay(eventId: 'published-a');
|
||||
final applied = <CommunityThemePreference>[];
|
||||
final manager = _manager(
|
||||
session,
|
||||
relay,
|
||||
onRemote: (remote) => applied.add(remote.preference),
|
||||
);
|
||||
test('a delayed initialization result never overrides a held edit', () async {
|
||||
// The gate holds the edit until initialize resolves, so a publish can no
|
||||
// longer race ahead of hydration. When the delayed history arrives it is
|
||||
// observed (not applied over the pending edit), the gate releases, and
|
||||
// the held edit publishes above the observed coordinate.
|
||||
const stale = CommunityThemePreference(
|
||||
theme: 'dracula',
|
||||
accent: '#ef4444',
|
||||
followSystem: false,
|
||||
);
|
||||
final history = Completer<List<NostrEvent>>();
|
||||
final session = _FakeSession(historyFuture: history.future);
|
||||
final relay = _FakeSignedRelay(eventId: 'published-a');
|
||||
final applied = <CommunityThemePreference>[];
|
||||
final manager = _manager(
|
||||
session,
|
||||
relay,
|
||||
onRemote: (remote) => applied.add(remote.preference),
|
||||
);
|
||||
|
||||
final initializing = manager.initialize();
|
||||
manager.publish(local);
|
||||
await manager.flush();
|
||||
final createdAt = relay.submittedEvents.single.createdAt;
|
||||
history.complete([
|
||||
_event(
|
||||
id: 'published-z',
|
||||
createdAt: createdAt,
|
||||
content: jsonEncode(stale.toJson()),
|
||||
),
|
||||
]);
|
||||
await initializing;
|
||||
final initializing = manager.initialize();
|
||||
manager.publish(local);
|
||||
await manager.flush();
|
||||
// Held: initialize has not resolved, so the coordinate is unobserved.
|
||||
expect(relay.submissions, isEmpty);
|
||||
expect(manager.pending, local);
|
||||
|
||||
expect(applied, isEmpty);
|
||||
expect(manager.pending, isNull);
|
||||
},
|
||||
);
|
||||
history.complete([
|
||||
_event(
|
||||
id: 'published-z',
|
||||
createdAt: 100,
|
||||
content: jsonEncode(stale.toJson()),
|
||||
),
|
||||
]);
|
||||
await initializing;
|
||||
await _waitUntil(() => relay.submissions.isNotEmpty);
|
||||
|
||||
// The delayed stale result was observed but never applied over the
|
||||
// pending edit, and the released edit still publishes exactly once.
|
||||
expect(applied, isEmpty);
|
||||
expect(manager.pending, isNull);
|
||||
expect(relay.submissions, hasLength(1));
|
||||
expect(jsonDecode(relay.submissions.single.content)['theme'], 'buzz');
|
||||
});
|
||||
|
||||
test(
|
||||
'gated incomplete edit holds until hydration then merges remote glass',
|
||||
@@ -475,6 +484,95 @@ void main() {
|
||||
expect(published.containsKey('glassOpacity'), isFalse);
|
||||
expect(published.containsKey('prominentActiveTab'), isFalse);
|
||||
});
|
||||
|
||||
test(
|
||||
'a full cache edit republishes the observed remote glass, not the cache',
|
||||
() async {
|
||||
// Regression: a mobile cache decoded from a desktop-authored record
|
||||
// carries all three includes* flags, so it looks "complete". Mobile has
|
||||
// no UI to author glass, so those fields are stale once another client
|
||||
// changes them. The published coordinate must carry the current remote's
|
||||
// glass, never the cache's.
|
||||
const staleFullCacheEdit = CommunityThemePreference(
|
||||
theme: 'dracula',
|
||||
accent: '#ef4444',
|
||||
followSystem: false,
|
||||
glassBackground: true,
|
||||
glassOpacity: 80,
|
||||
prominentActiveTab: true,
|
||||
);
|
||||
const currentRemote = CommunityThemePreference(
|
||||
theme: 'buzz',
|
||||
accent: '#3b82f6',
|
||||
followSystem: true,
|
||||
glassBackground: false,
|
||||
glassOpacity: 40,
|
||||
prominentActiveTab: false,
|
||||
);
|
||||
final session = _FakeSession(
|
||||
history: [
|
||||
_event(
|
||||
id: 'current',
|
||||
createdAt: 100,
|
||||
content: jsonEncode(currentRemote.toJson()),
|
||||
),
|
||||
],
|
||||
);
|
||||
final relay = _FakeSignedRelay();
|
||||
final manager = _manager(session, relay);
|
||||
|
||||
await manager.initialize();
|
||||
manager.publish(staleFullCacheEdit);
|
||||
await manager.flush();
|
||||
|
||||
final published =
|
||||
jsonDecode(relay.submissions.single.content) as Map<String, dynamic>;
|
||||
expect(published['theme'], 'dracula');
|
||||
expect(published['accent'], '#ef4444');
|
||||
expect(published['glassBackground'], false);
|
||||
expect(published['glassOpacity'], 40);
|
||||
expect(published['prominentActiveTab'], false);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'a transient history failure does not strand a gated edit forever',
|
||||
() async {
|
||||
// Regression: the pre-hydration gate must not deadlock when the initial
|
||||
// fetch fails (unavailable, not absent) while the live subscription stays
|
||||
// quiet. A bounded history-retry releases the gate once the relay answers.
|
||||
const incompleteEdit = CommunityThemePreference(
|
||||
theme: 'dracula',
|
||||
accent: '#ef4444',
|
||||
followSystem: false,
|
||||
includesGlassBackground: false,
|
||||
includesGlassOpacity: false,
|
||||
includesProminentActiveTab: false,
|
||||
);
|
||||
// First fetch throws (unavailable); the retry succeeds with an absence.
|
||||
final session = _FakeSession(fetchFailuresRemaining: 1);
|
||||
final relay = _FakeSignedRelay();
|
||||
final manager = _manager(session, relay);
|
||||
|
||||
final result = await manager.initialize();
|
||||
expect(result.status, CommunityThemeRemoteStatus.unavailable);
|
||||
|
||||
manager.publish(incompleteEdit);
|
||||
await manager.flush();
|
||||
// Held: the coordinate has not been observed yet.
|
||||
expect(relay.submissions, isEmpty);
|
||||
expect(manager.pending, incompleteEdit);
|
||||
|
||||
// The scheduled recovery re-queries history, observes the absence, and
|
||||
// releases the gate so the edit finally publishes.
|
||||
await _waitUntil(() => relay.submissions.isNotEmpty);
|
||||
expect(session.fetchCalls, greaterThanOrEqualTo(2));
|
||||
final published =
|
||||
jsonDecode(relay.submissions.single.content) as Map<String, dynamic>;
|
||||
expect(published['theme'], 'dracula');
|
||||
expect(manager.pending, isNull);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
CommunityThemeSyncManager _manager(
|
||||
@@ -518,13 +616,16 @@ class _FakeSession extends RelaySessionNotifier {
|
||||
List<NostrEvent> history = const [],
|
||||
this.historyFuture,
|
||||
this.error,
|
||||
this.fetchFailuresRemaining = 0,
|
||||
this.onFetchHistory,
|
||||
}) : history = List.of(history);
|
||||
List<NostrEvent> history;
|
||||
final Future<List<NostrEvent>>? historyFuture;
|
||||
final Object? error;
|
||||
int fetchFailuresRemaining;
|
||||
final void Function()? onFetchHistory;
|
||||
int subscribeCalls = 0;
|
||||
int fetchCalls = 0;
|
||||
final List<void Function(NostrEvent)> _listeners = [];
|
||||
final List<void Function(String)> _closedCallbacks = [];
|
||||
|
||||
@@ -537,7 +638,12 @@ class _FakeSession extends RelaySessionNotifier {
|
||||
NostrFilter filter, {
|
||||
Duration timeout = const Duration(seconds: 8),
|
||||
}) async {
|
||||
fetchCalls++;
|
||||
if (error != null) throw error!;
|
||||
if (fetchFailuresRemaining > 0) {
|
||||
fetchFailuresRemaining--;
|
||||
throw StateError('history unavailable');
|
||||
}
|
||||
onFetchHistory?.call();
|
||||
if (historyFuture != null) return historyFuture!;
|
||||
return history;
|
||||
|
||||
Reference in New Issue
Block a user