Skip to content

Commit 7c3a324

Browse files
myieyeclaude
andauthored
Fix stale state and formatting in sync dialog (#2299)
* Refresh SyncDialog state on background sync events * Dedupe concurrent refreshStatus calls * Fix empty render when diff < smallestUnit in formatRelativeDate Intl.DurationFormat hides zero-valued fields by default, so an absDiffMs below the smallest requested unit (e.g. <1s for seconds) was emitting an empty string and the "Last sync: " label briefly showed nothing right after a fresh sync. Fall back to a forced-display zero so Intl handles plurals correctly across styles and locales. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * Silence Blazor WebView 'no pending async call' toast Benign race during page refresh: .NET completes a JS->.NET call whose JS-side registry was already torn down. Add to the existing ignore-list. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * Fix sticky 'Last sync: Never' after fast close→reopen The 500ms onClose timer wiped state to undefined; combined with the refreshStatus dedupe (a784c90) it permanently stranded the dialog without data when the user reopened during the close-animation window. Removing onClose: data persists in memory while the dialog is closed (bits-ui keeps content mounted but hidden), so the next open shows the previous values until refreshStatus overwrites them. Also moved the projectEventBus.onSync subscription below the pendingRefresh declaration: onSync invokes its callback synchronously with a cached event, which can reach refreshStatus and hit the TDZ. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * Refetch SyncDialog state when a sync arrives during in-flight refresh The pendingRefresh dedupe returned the same promise to all overlapping callers, including onSync handlers triggered by another client's push. That meant the in-flight refresh (started before the push landed) would resolve with pre-sync data and no follow-up would fire. Mark refreshStatus calls from the onSync handler as forceFresh so they queue a rerun after the in-flight refresh finishes. Other overlapping calls (cached-event-on-subscribe, post-syncLexboxToLocal) still dedupe, preserving the original round-trip savings in those flows. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 6305062 commit 7c3a324

4 files changed

Lines changed: 84 additions & 17 deletions

File tree

frontend/viewer/src/lib/components/ui/format/format-relative-date-fn.svelte.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,11 +22,14 @@ export function formatRelativeDate(
2222

2323
const targetDate = typeof value === 'string' ? new SvelteDate(value) : value;
2424
const diffMs = targetDate.getTime() - config.now.getTime();
25-
const isPast = diffMs < 0;
25+
const isPast = diffMs <= 0;
2626
const absDiffMs = Math.abs(diffMs);
2727

28-
const duration = formatDuration({milliseconds: absDiffMs}, config.smallestUnit, options, config.maxUnits);
29-
if (!duration) return config.defaultValue;
28+
const unit = config.smallestUnit ?? 'seconds';
29+
// DurationFormat omits zero fields, so diffs < smallestUnit format as "". Force-display a zero.
30+
const duration =
31+
formatDuration({milliseconds: absDiffMs}, config.smallestUnit, options, config.maxUnits) ||
32+
formatDuration({[unit]: 0}, unit, {...options, [`${unit}Display`]: 'always'});
3033

3134
return isPast ? gt`${duration} ago` : gt`in ${duration}`;
3235
}
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import {describe, expect, it} from 'vitest';
2+
import {formatRelativeDate} from './format-relative-date-fn.svelte';
3+
4+
function config(now: Date, smallestUnit: 'milliseconds' | 'seconds' | 'minutes' | 'hours' = 'seconds') {
5+
return {defaultValue: 'NEVER', now, smallestUnit};
6+
}
7+
8+
describe('formatRelativeDate', () => {
9+
it('returns defaultValue for nullish input', () => {
10+
const now = new Date();
11+
expect(formatRelativeDate(null, undefined, config(now))).toBe('NEVER');
12+
expect(formatRelativeDate(undefined, undefined, config(now))).toBe('NEVER');
13+
});
14+
15+
it('formats a past duration', () => {
16+
const now = new Date();
17+
const result = formatRelativeDate(new Date(now.getTime() - 3000), undefined, config(now));
18+
expect(result.startsWith('3 ')).toBe(true);
19+
expect(result.endsWith(' ago')).toBe(true);
20+
});
21+
22+
it('formats a future duration', () => {
23+
const now = new Date();
24+
const result = formatRelativeDate(new Date(now.getTime() + 3000), undefined, config(now));
25+
expect(result.startsWith('in 3 ')).toBe(true);
26+
});
27+
28+
it('falls back to a zero-duration string when diff is below smallestUnit', () => {
29+
const now = new Date();
30+
expect(formatRelativeDate(new Date(now.getTime() - 500), undefined, config(now))).toMatch(/^0 .+ ago$/);
31+
expect(formatRelativeDate(new Date(now.getTime() + 500), undefined, config(now))).toMatch(/^in 0 /);
32+
});
33+
34+
it('produces the correct plural for zero (style=long)', () => {
35+
const now = new Date();
36+
expect(formatRelativeDate(now, {style: 'long'}, config(now))).toContain('0 seconds');
37+
});
38+
39+
it('accepts ISO-string dates', () => {
40+
const now = new Date();
41+
const earlier = new Date(now.getTime() - 3000).toISOString();
42+
expect(formatRelativeDate(earlier, undefined, config(now)).startsWith('3 ')).toBe(true);
43+
});
44+
});

frontend/viewer/src/lib/errors/global-errors.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ function unifyErrorEvent(event: ErrorEvent | PromiseRejectionEvent): UnifiedErro
2424

2525
function shouldIgnoreError(message: string): boolean {
2626
if (message.includes('Perhaps the DotNetObjectReference instance was already disposed')) return true;
27+
// Blazor WebView completing a JS->.NET call whose JS-side registry was already torn down (e.g. page refresh).
28+
if (message.includes('There is no pending async call with ID')) return true;
2729
// Code (i.e. {expression}) inside a <MenuItem> slot, inside a portal causes this error if the portal is open while the screen is resized 🙃
2830
// It's worth noting that in Lexbox we've also seen browser extensions trigger this error
2931
if (message.includes('ResizeObserver loop completed with undelivered notifications')) return true;

frontend/viewer/src/project/SyncDialog.svelte

Lines changed: 32 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
import {useProjectContext} from '$project/project-context.svelte';
1717
import SyncStatusPrimitive from './sync/SyncStatusPrimitive.svelte';
1818
import ResponsiveDialog from '$lib/components/responsive-dialog/responsive-dialog.svelte';
19+
import {useProjectEventBus} from '$lib/services/event-bus';
1920
2021
const {
2122
syncStatus = SyncStatus.Success
@@ -24,6 +25,7 @@
2425
const projectContext = useProjectContext();
2526
const service = useSyncStatusService();
2627
const features = useFeatures();
28+
const projectEventBus = useProjectEventBus();
2729
let remoteStatus = $state<IProjectSyncStatus>();
2830
let localStatus = $state<IPendingCommits>();
2931
let server = $derived(projectContext.server);
@@ -35,26 +37,46 @@
3537
3638
watch(() => openQueryParam.current, (newValue) => {
3739
if (newValue) void onOpen();
38-
else setTimeout(onClose, 500); // don't clear contents until close animation is done
3940
});
4041
4142
export function open(): void {
4243
openQueryParam.current = true;
4344
}
4445
45-
async function onOpen(): Promise<void> {
46-
await Promise.all([
46+
let pendingRefresh: Promise<unknown> | undefined;
47+
let rerunAfterPending = false;
48+
49+
// Catch background syncs (e.g. another client pushing changes) that onOpen alone wouldn't see.
50+
// forceFresh: an in-flight refresh started before the sync may return pre-sync data, so queue a follow-up.
51+
// Must follow pendingRefresh — onSync invokes the callback synchronously with a cached event.
52+
projectEventBus.onSync(e => {
53+
if (openQueryParam.current && e.status === SyncStatus.Success) void refreshStatus({forceFresh: true});
54+
});
55+
56+
function refreshStatus(opts?: {forceFresh?: boolean}) {
57+
if (pendingRefresh) {
58+
if (opts?.forceFresh) rerunAfterPending = true;
59+
return pendingRefresh;
60+
}
61+
pendingRefresh = Promise.all([
4762
service.getLocalStatus().then(s => localStatus = s),
4863
service.getStatus().then(s => remoteStatus = s),
4964
service.getLatestSyncedCommitDate().then(s => latestSyncedCommitDate = s),
50-
service.getCurrentServer().then(s => server = s),
51-
]);
65+
]).finally(() => {
66+
pendingRefresh = undefined;
67+
if (rerunAfterPending) {
68+
rerunAfterPending = false;
69+
void refreshStatus();
70+
}
71+
});
72+
return pendingRefresh;
5273
}
5374
54-
function onClose(): void {
55-
localStatus = undefined;
56-
remoteStatus = undefined;
57-
latestSyncedCommitDate = undefined;
75+
async function onOpen(): Promise<void> {
76+
await Promise.all([
77+
refreshStatus(),
78+
service.getCurrentServer().then(s => server = s),
79+
]);
5880
}
5981
6082
async function syncLexboxToFlex() {
@@ -107,11 +129,7 @@
107129
localStatus.remote = 0;
108130
localStatus.local = 0;
109131
}
110-
await Promise.all([
111-
service.getLocalStatus().then(s => localStatus = s),
112-
service.getStatus().then(s => remoteStatus = s),
113-
service.getLatestSyncedCommitDate().then(s => latestSyncedCommitDate = s),
114-
]);
132+
await refreshStatus();
115133
}
116134
117135
function onLoginStatusChange(status: 'logged-in' | 'logged-out') {

0 commit comments

Comments
 (0)