fix(theme): preserve in-flight preference updates

Signed-off-by: kenny lopez <klopez4212@gmail.com>
This commit is contained in:
kenny lopez
2026-08-20 08:17:14 +01:00
parent 11fa2a53c8
commit 6457c30e68
6 changed files with 190 additions and 8 deletions

View File

@@ -656,7 +656,7 @@ function ThemeSettingsCard() {
Icon,
}))}
testId="appearance-color-mode-control"
value={selectedMode}
value={activeMode}
/>
</SettingsOptionRow>

View File

@@ -7,6 +7,11 @@ import {
isNewerCommunityThemeCoordinate,
shouldSeedCommunityTheme,
} from "./communityThemeSync.ts";
import {
clearCommunityThemeOutbox,
readCommunityThemeOutbox,
writeCommunityThemeOutbox,
} from "./communityThemePreference.ts";
const preference = {
version: 1,
@@ -18,6 +23,15 @@ const preference = {
prominentActiveTab: false,
};
function localStorageStub() {
const data = new Map();
return {
getItem: (key) => data.get(key) ?? null,
setItem: (key, value) => data.set(key, String(value)),
removeItem: (key) => data.delete(key),
};
}
function installFakeTimer() {
globalThis.window ??= {};
let callback = null;
@@ -745,6 +759,87 @@ test("remote accepted after submit is republished after the stale event settles"
}
});
test("a user edit after migration cancellation survives the stale submission", async () => {
const timer = installFakeTimer();
const firstPublish = Promise.withResolvers();
const published = [];
const acknowledgements = [];
const userPreference = { ...preference, accent: "#ef4444" };
let signed = 0;
globalThis.window.localStorage = localStorageStub();
globalThis.window.__TAURI_INTERNALS__ = {
invoke(command, args) {
if (command === "nip44_encrypt_to_self") return Promise.resolve("cipher");
if (command === "sign_event") {
signed += 1;
return Promise.resolve(
JSON.stringify(
relayEvent({
id: `event-${signed}`,
content: args.content,
created_at: args.createdAt,
}),
),
);
}
throw new Error(`unexpected command: ${command}`);
},
};
mock.method(relayClient, "publishEvent", (event) => {
published.push(event);
return published.length === 1 ? firstPublish.promise : Promise.resolve();
});
try {
const manager = new CommunityThemeSyncManager("alice", (event) => {
acknowledgements.push(event);
clearCommunityThemeOutbox(
"alice",
"wss://relay.example",
event.preference,
);
});
manager.publish(preference);
timer.fire();
await waitUntil(() => published.length === 1);
const remotePreference = { ...preference, theme: "dracula" };
manager.acceptRemote({
preference: remotePreference,
createdAt: published[0].created_at + 100,
eventId: "remote-winner",
needsUpgrade: false,
});
assert.equal(manager.cancelPendingPublish(remotePreference), true);
writeCommunityThemeOutbox("alice", "wss://relay.example", userPreference);
manager.publish(userPreference);
firstPublish.resolve();
await new Promise((resolve) => setImmediate(resolve));
await waitUntil(() => timer.pending());
assert.deepEqual(manager.getPending(), userPreference);
assert.deepEqual(
readCommunityThemeOutbox("alice", "wss://relay.example"),
userPreference,
);
timer.fire();
await waitUntil(() => acknowledgements.length === 1);
assert.equal(published.length, 2);
assert.ok(published[1].created_at > published[0].created_at + 100);
assert.deepEqual(acknowledgements[0].preference, userPreference);
assert.equal(
readCommunityThemeOutbox("alice", "wss://relay.example"),
null,
);
} finally {
delete globalThis.window.__TAURI_INTERNALS__;
delete globalThis.window.localStorage;
timer.restore();
mock.reset();
}
});
test("migration self-echo during submission acknowledges without replacement", async () => {
const timer = installFakeTimer();
const publish = Promise.withResolvers();

View File

@@ -105,8 +105,12 @@ export class CommunityThemeSyncManager {
private readonly onPublished: (published: PublishedCommunityTheme) => void;
private readonly getLegacyFallback: () => CommunityThemePreference;
private publishGeneration = 0;
private publishRevision = 0;
private activeSubmittedPublish: PublishedCommunityTheme | null = null;
private replacementAfterCancel: CommunityThemePreference | null = null;
private replacementAfterCancel: {
preference: CommunityThemePreference;
publishRevision: number;
} | null = null;
constructor(
pubkey: string,
@@ -155,6 +159,7 @@ export class CommunityThemeSyncManager {
publish(preference: CommunityThemePreference): void {
if (this.destroyed) return;
this.publishRevision += 1;
this.pending = preference;
this.publishRetryAttempt = 0;
this.schedulePublish(DEBOUNCE_MS);
@@ -177,17 +182,24 @@ export class CommunityThemeSyncManager {
this.activeSubmittedPublish = null;
const preference = this.pending;
const generation = this.publishGeneration;
const publishRevision = this.publishRevision;
void this.doPublish(preference, generation).finally(() => {
this.publishInFlight = false;
this.activeSubmittedPublish = null;
if (this.replacementAfterCancel) {
this.pending = this.replacementAfterCancel;
this.replacementAfterCancel = null;
const replacement = this.replacementAfterCancel;
this.replacementAfterCancel = null;
if (
replacement &&
this.pending === null &&
replacement.publishRevision === this.publishRevision
) {
this.pending = replacement.preference;
}
if (
!this.destroyed &&
this.pending &&
!sameCommunityThemePreference(this.pending, preference) &&
(this.publishRevision !== publishRevision ||
!sameCommunityThemePreference(this.pending, preference)) &&
this.debounceTimer === null
) {
this.schedulePublish(0);
@@ -234,7 +246,12 @@ export class CommunityThemeSyncManager {
}
const submitted = this.activeSubmittedPublish !== null;
this.replacementAfterCancel =
submitted && replacementAfterSubmit ? replacementAfterSubmit : null;
submitted && replacementAfterSubmit
? {
preference: replacementAfterSubmit,
publishRevision: this.publishRevision,
}
: null;
this.publishGeneration += 1;
if (this.debounceTimer !== null) {
window.clearTimeout(this.debounceTimer);

View File

@@ -62,6 +62,7 @@ class CommunityThemeSyncManager {
int _publishRetryAttempt = 0;
bool _publishInFlight = false;
bool _publishRequestedWhileInFlight = false;
int _remoteRevision = 0;
bool _hydrationObserved = false;
bool _disposed = false;
@@ -333,6 +334,7 @@ class CommunityThemeSyncManager {
// a desktop client's glass and prominent-tab choices. Bookkeeping stays on
// the local `preference` so the outbox ack contract is unaffected.
final remote = _lastRemote?.preference;
final remoteRevision = _remoteRevision;
final outgoing = remote == null
? preference
: preference.mergeDesktopAppearanceFrom(remote);
@@ -358,6 +360,12 @@ class CommunityThemeSyncManager {
if (published == null) {
throw StateError('Signed event coordinate unavailable');
}
if (_remoteRevision != remoteRevision) {
_lastPublished = null;
_publishRetryAttempt = 0;
if (_pending == preference) _schedulePublish(Duration.zero);
return;
}
final publishedCoordinateIsStale =
_lastCreatedAt > published.createdAt ||
(_lastCreatedAt == published.createdAt &&
@@ -427,6 +435,7 @@ class CommunityThemeSyncManager {
_lastCreatedAt = remote.createdAt;
_lastEventId = remote.eventId;
_lastRemote = remote;
_remoteRevision++;
_observeHydration();
if (_pending != null) {
_lastPublished = null;

View File

@@ -175,11 +175,12 @@ void main() {
tester,
SettingsPage(
profileHeader: const SizedBox.shrink(),
invitePageBuilder: (_) => const SizedBox.shrink(),
identityRecoveryPageBuilder: (_) => const SizedBox.shrink(),
),
);
expect(find.text('Theme · This community'), findsOneWidget);
expect(find.text('Style · This community'), findsOneWidget);
expect(find.text('Color mode'), findsOneWidget);
expect(find.text('Color style'), findsOneWidget);
});

View File

@@ -304,6 +304,64 @@ void main() {
},
);
test(
're-merges desktop fields when remote advances during submission',
() async {
final firstSubmission = Completer<void>();
final relay = _FakeSignedRelay(
eventId: 'a-mobile',
firstSubmissionGate: firstSubmission.future,
);
final session = _FakeSession();
final acknowledgements = <CommunityThemePreference>[];
final manager = _manager(
session,
relay,
onPublished: acknowledgements.add,
);
await manager.initialize();
manager.publish(local);
final firstFlush = manager.flush();
await _waitUntil(() => relay.attempts == 1);
const desktop = CommunityThemePreference(
theme: 'dracula',
accent: '#ef4444',
followSystem: false,
glassBackground: false,
glassOpacity: 80,
prominentActiveTab: true,
);
session.emit(
_event(
id: 'z-desktop',
createdAt: relay.startedCreatedAts.single,
content: jsonEncode(desktop.toJson()),
),
);
firstSubmission.complete();
await firstFlush;
expect(acknowledgements, isEmpty);
expect(manager.pending, local);
await _waitUntil(() => relay.attempts == 2);
final recovered =
jsonDecode(relay.submissions[1].content) as Map<String, dynamic>;
expect(recovered['theme'], local.theme);
expect(recovered['accent'], local.accent);
expect(recovered['glassBackground'], false);
expect(recovered['glassOpacity'], 80);
expect(recovered['prominentActiveTab'], true);
expect(
relay.submittedEvents[1].createdAt,
greaterThan(relay.startedCreatedAts.first),
);
expect(acknowledgements, [local]);
expect(manager.pending, isNull);
},
);
test('remote coordinate advances pending local publish timestamp', () async {
final relay = _FakeSignedRelay();
final session = _FakeSession();
@@ -709,6 +767,7 @@ class _FakeSignedRelay implements SignedEventRelay {
final String eventId;
final Future<void>? firstSubmissionGate;
int attempts = 0;
final startedCreatedAts = <int>[];
final submissions = <_Submission>[];
final submittedEvents = <NostrEvent>[];
@override
@@ -722,6 +781,7 @@ class _FakeSignedRelay implements SignedEventRelay {
void Function(NostrEvent)? onSigned,
}) async {
attempts++;
startedCreatedAts.add(createdAt ?? 0);
if (attempts == 1 && firstSubmissionGate != null) {
await firstSubmissionGate;
}