fix(sync): keep USE_REMOTE recovery Undo visible after routine sync

After a "Use Server Data" replace shows the persistent Undo recovery
snack (#8107), a follow-up routine sync-success snack must not silently
replace it. SnackService now tracks a pending persistent action
(actionStr + duration 0); the header sync() skips its success feedback
while one is showing. Unblocks the committed USE_REMOTE crash-resume e2e
(supersync-use-remote-crash-resume.spec.ts), whose Undo assertion
depends on the recovery snack surviving the sync that resumed the rebuild.
This commit is contained in:
Johannes Millan 2026-07-10 17:38:47 +02:00
parent 19b6fed044
commit a3272ed138
3 changed files with 52 additions and 2 deletions

View file

@ -25,6 +25,7 @@ import { MetricService } from '../../features/metric/metric.service';
import { DateService } from '../../core/date/date.service';
import { UserProfileService } from '../../features/user-profile/user-profile.service';
import { DEFAULT_GLOBAL_CONFIG } from '../../features/config/default-global-config.const';
import { SyncStatus } from '../../op-log/sync-exports';
// Regression test for #7477: in a project view a long title pushed the
// right-side header actions (simple-counter / habit buttons) off screen.
@ -166,6 +167,7 @@ describe('MainHeaderComponent focus button visibility', () => {
{
provide: SyncWrapperService,
useValue: {
sync: jasmine.createSpy('sync'),
isEnabledAndReady$: of(false),
syncState$: of('IN_SYNC'),
isSyncInProgress$: of(false),
@ -173,7 +175,13 @@ describe('MainHeaderComponent focus button visibility', () => {
superSyncIsConfirmedInSync$: of(false),
},
},
{ provide: SnackService, useValue: { open: jasmine.createSpy('open') } },
{
provide: SnackService,
useValue: {
open: jasmine.createSpy('open'),
hasPendingPersistentAction: jasmine.createSpy('hasPendingPersistentAction'),
},
},
{ provide: Router, useValue: { events: EMPTY } },
{
provide: GlobalConfigService,
@ -229,4 +237,19 @@ describe('MainHeaderComponent focus button visibility', () => {
expect(component.isFocusButtonVisible()).toBe(false);
});
it('keeps a persistent recovery action instead of showing routine sync success', async () => {
component = createComponent();
const syncWrapperService = TestBed.inject(
SyncWrapperService,
) as jasmine.SpyObj<SyncWrapperService>;
const snackService = TestBed.inject(SnackService) as jasmine.SpyObj<SnackService>;
syncWrapperService.sync.and.resolveTo(SyncStatus.UpdateRemote);
snackService.hasPendingPersistentAction.and.returnValue(true);
component.sync();
await Promise.resolve();
expect(snackService.open).not.toHaveBeenCalled();
});
});

View file

@ -294,6 +294,11 @@ export class MainHeaderComponent implements OnDestroy {
sync(): void {
this.syncWrapperService.sync(true).then((r) => {
// Keep persistent recovery actions (for example USE_REMOTE Undo) visible;
// routine sync-success feedback must not replace them.
if (this._snackService.hasPendingPersistentAction()) {
return;
}
if (
r === SyncStatus.UpdateLocal ||
r === SyncStatus.UpdateRemoteAll ||

View file

@ -3,7 +3,7 @@ import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { Store } from '@ngrx/store';
import { SnackParams } from './snack.model';
import { Observable, Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
import { take, takeUntil } from 'rxjs/operators';
import { DEFAULT_SNACK_CFG } from './snack.const';
import { SnackCustomComponent } from './snack-custom/snack-custom.component';
import { TranslateService } from '@ngx-translate/core';
@ -23,6 +23,7 @@ export class SnackService {
private _matSnackBar = inject(MatSnackBar);
private _ref?: MatSnackBarRef<SnackCustomComponent | SimpleSnackBar>;
private _hasPendingPersistentAction = false;
constructor() {
const _onWorkContextChange$: Observable<unknown> = this._actions$.pipe(
@ -37,10 +38,20 @@ export class SnackService {
if (typeof params === 'string') {
params = { msg: params };
}
if (params.actionStr && params.config?.duration === 0) {
// Set this before the debounced render so immediate follow-up feedback
// cannot unknowingly replace a persistent recovery action.
this._hasPendingPersistentAction = true;
}
this._openSnack(params);
}
hasPendingPersistentAction(): boolean {
return this._hasPendingPersistentAction;
}
close(): void {
this._hasPendingPersistentAction = false;
if (this._ref) {
this._ref.dismiss();
}
@ -107,6 +118,17 @@ export class SnackService {
}
}
const openedRef = this._ref;
this._hasPendingPersistentAction = !!actionStr && cfg.duration === 0;
openedRef
?.afterDismissed()
.pipe(take(1))
.subscribe(() => {
if (this._ref === openedRef) {
this._hasPendingPersistentAction = false;
}
});
if (actionStr && actionId && this._ref) {
this._ref
.onAction()