diff --git a/apps/browser/src/tools/popup/send-v2/send-v2.component.html b/apps/browser/src/tools/popup/send-v2/send-v2.component.html
index 0bcbd47a145..47ecd7564dc 100644
--- a/apps/browser/src/tools/popup/send-v2/send-v2.component.html
+++ b/apps/browser/src/tools/popup/send-v2/send-v2.component.html
@@ -47,8 +47,8 @@
@if (showSkeletonsLoaders$ | async) {
-
+
-
+
}
diff --git a/apps/browser/src/tools/popup/send-v2/send-v2.component.ts b/apps/browser/src/tools/popup/send-v2/send-v2.component.ts
index 43a1119deca..e3baba53c42 100644
--- a/apps/browser/src/tools/popup/send-v2/send-v2.component.ts
+++ b/apps/browser/src/tools/popup/send-v2/send-v2.component.ts
@@ -15,6 +15,8 @@ import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { SendType } from "@bitwarden/common/tools/send/enums/send-type";
import { PremiumUpgradePromptService } from "@bitwarden/common/vault/abstractions/premium-upgrade-prompt.service";
+import { SearchService } from "@bitwarden/common/vault/abstractions/search.service";
+import { skeletonLoadingDelay } from "@bitwarden/common/vault/utils/skeleton-loading.operator";
import {
ButtonModule,
CalloutModule,
@@ -95,8 +97,16 @@ export class SendV2Component implements OnDestroy {
/** Skeleton Loading State */
protected showSkeletonsLoaders$ = combineLatest([
this.sendsLoading$,
+ this.searchService.isSendSearching$,
this.skeletonFeatureFlag$,
- ]).pipe(map(([loading, skeletonsEnabled]) => loading && skeletonsEnabled));
+ ]).pipe(
+ map(
+ ([loading, cipherSearching, skeletonsEnabled]) =>
+ (loading || cipherSearching) && skeletonsEnabled,
+ ),
+ distinctUntilChanged(),
+ skeletonLoadingDelay(),
+ );
protected title: string = "allSends";
protected noItemIcon = NoSendsIcon;
@@ -110,6 +120,7 @@ export class SendV2Component implements OnDestroy {
private policyService: PolicyService,
private accountService: AccountService,
private configService: ConfigService,
+ private searchService: SearchService,
) {
combineLatest([
this.sendItemsService.emptyList$,
diff --git a/apps/browser/src/vault/popup/components/vault-fade-in-out/vault-fade-in-out.component.html b/apps/browser/src/vault/popup/components/vault-fade-in-out/vault-fade-in-out.component.html
new file mode 100644
index 00000000000..6dbc7430638
--- /dev/null
+++ b/apps/browser/src/vault/popup/components/vault-fade-in-out/vault-fade-in-out.component.html
@@ -0,0 +1 @@
+
diff --git a/apps/browser/src/vault/popup/components/vault-fade-in-out/vault-fade-in-out.component.ts b/apps/browser/src/vault/popup/components/vault-fade-in-out/vault-fade-in-out.component.ts
new file mode 100644
index 00000000000..a30a447833b
--- /dev/null
+++ b/apps/browser/src/vault/popup/components/vault-fade-in-out/vault-fade-in-out.component.ts
@@ -0,0 +1,20 @@
+import { animate, style, transition, trigger } from "@angular/animations";
+import { ChangeDetectionStrategy, Component, HostBinding } from "@angular/core";
+
+@Component({
+ selector: "vault-fade-in-out",
+ templateUrl: "./vault-fade-in-out.component.html",
+ animations: [
+ trigger("fadeInOut", [
+ transition(":enter", [
+ style({ opacity: 0 }),
+ animate("100ms ease-in", style({ opacity: 1 })),
+ ]),
+ transition(":leave", [animate("300ms ease-out", style({ opacity: 0 }))]),
+ ]),
+ ],
+ changeDetection: ChangeDetectionStrategy.OnPush,
+})
+export class VaultFadeInOutComponent {
+ @HostBinding("@fadeInOut") fadeInOut = true;
+}
diff --git a/apps/browser/src/vault/popup/components/vault-v2/vault-v2.component.html b/apps/browser/src/vault/popup/components/vault-v2/vault-v2.component.html
index faaa6a40e98..7a5a99c8100 100644
--- a/apps/browser/src/vault/popup/components/vault-v2/vault-v2.component.html
+++ b/apps/browser/src/vault/popup/components/vault-v2/vault-v2.component.html
@@ -8,20 +8,32 @@
-
+
+
+
+
+ @if (skeletonFeatureFlag$ | async) {
+
+
+
+ } @else {
+
+ }
-
-
-
-
-
+
+
+
+
+
+
+
+
+ @if (skeletonFeatureFlag$ | async) {
+
+
+
+ } @else {
+
+ }
@if (showSkeletonsLoaders$ | async) {
diff --git a/apps/browser/src/vault/popup/components/vault-v2/vault-v2.component.spec.ts b/apps/browser/src/vault/popup/components/vault-v2/vault-v2.component.spec.ts
index 563ec5f9709..5563cd3033b 100644
--- a/apps/browser/src/vault/popup/components/vault-v2/vault-v2.component.spec.ts
+++ b/apps/browser/src/vault/popup/components/vault-v2/vault-v2.component.spec.ts
@@ -23,6 +23,7 @@ import { ConfigService } from "@bitwarden/common/platform/abstractions/config/co
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
+import { SearchService } from "@bitwarden/common/vault/abstractions/search.service";
import { RestrictedItemTypesService } from "@bitwarden/common/vault/services/restricted-item-types.service";
import { TaskService } from "@bitwarden/common/vault/tasks";
import { DialogService } from "@bitwarden/components";
@@ -259,6 +260,10 @@ describe("VaultV2Component", () => {
getFeatureFlag$: (_: string) => of(false),
},
},
+ {
+ provide: SearchService,
+ useValue: { isCipherSearching$: of(false) },
+ },
],
schemas: [NO_ERRORS_SCHEMA],
}).compileComponents();
diff --git a/apps/browser/src/vault/popup/components/vault-v2/vault-v2.component.ts b/apps/browser/src/vault/popup/components/vault-v2/vault-v2.component.ts
index 499e9b76757..471e6e70601 100644
--- a/apps/browser/src/vault/popup/components/vault-v2/vault-v2.component.ts
+++ b/apps/browser/src/vault/popup/components/vault-v2/vault-v2.component.ts
@@ -32,8 +32,10 @@ import { ConfigService } from "@bitwarden/common/platform/abstractions/config/co
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { CipherId, CollectionId, OrganizationId, UserId } from "@bitwarden/common/types/guid";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
+import { SearchService } from "@bitwarden/common/vault/abstractions/search.service";
import { CipherType } from "@bitwarden/common/vault/enums";
import { UnionOfValues } from "@bitwarden/common/vault/types/union-of-values";
+import { skeletonLoadingDelay } from "@bitwarden/common/vault/utils/skeleton-loading.operator";
import {
ButtonModule,
DialogService,
@@ -54,6 +56,7 @@ import { VaultPopupListFiltersService } from "../../services/vault-popup-list-fi
import { VaultPopupLoadingService } from "../../services/vault-popup-loading.service";
import { VaultPopupScrollPositionService } from "../../services/vault-popup-scroll-position.service";
import { AtRiskPasswordCalloutComponent } from "../at-risk-callout/at-risk-password-callout.component";
+import { VaultFadeInOutComponent } from "../vault-fade-in-out/vault-fade-in-out.component";
import { VaultFadeInOutSkeletonComponent } from "../vault-fade-in-out-skeleton/vault-fade-in-out-skeleton.component";
import { VaultLoadingSkeletonComponent } from "../vault-loading-skeleton/vault-loading-skeleton.component";
@@ -100,6 +103,7 @@ type VaultState = UnionOfValues;
TypographyModule,
VaultLoadingSkeletonComponent,
VaultFadeInOutSkeletonComponent,
+ VaultFadeInOutComponent,
],
})
export class VaultV2Component implements OnInit, AfterViewInit, OnDestroy {
@@ -129,7 +133,7 @@ export class VaultV2Component implements OnInit, AfterViewInit, OnDestroy {
}),
);
- private skeletonFeatureFlag$ = this.configService.getFeatureFlag$(
+ protected skeletonFeatureFlag$ = this.configService.getFeatureFlag$(
FeatureFlag.VaultLoadingSkeletons,
);
@@ -183,9 +187,18 @@ export class VaultV2Component implements OnInit, AfterViewInit, OnDestroy {
map(([loading, skeletonsEnabled]) => loading && !skeletonsEnabled),
);
- /** When true, show skeleton loading state */
- protected showSkeletonsLoaders$ = combineLatest([this.loading$, this.skeletonFeatureFlag$]).pipe(
- map(([loading, skeletonsEnabled]) => loading && skeletonsEnabled),
+ /** When true, show skeleton loading state with debouncing to prevent flicker */
+ protected showSkeletonsLoaders$ = combineLatest([
+ this.loading$,
+ this.searchService.isCipherSearching$,
+ this.skeletonFeatureFlag$,
+ ]).pipe(
+ map(
+ ([loading, cipherSearching, skeletonsEnabled]) =>
+ (loading || cipherSearching) && skeletonsEnabled,
+ ),
+ distinctUntilChanged(),
+ skeletonLoadingDelay(),
);
protected newItemItemValues$: Observable =
@@ -228,6 +241,7 @@ export class VaultV2Component implements OnInit, AfterViewInit, OnDestroy {
private liveAnnouncer: LiveAnnouncer,
private i18nService: I18nService,
private configService: ConfigService,
+ private searchService: SearchService,
) {
combineLatest([
this.vaultPopupItemsService.emptyVault$,
diff --git a/libs/common/src/vault/abstractions/search.service.ts b/libs/common/src/vault/abstractions/search.service.ts
index 233dee9ec75..29575ec3af9 100644
--- a/libs/common/src/vault/abstractions/search.service.ts
+++ b/libs/common/src/vault/abstractions/search.service.ts
@@ -6,6 +6,9 @@ import { CipherView } from "../models/view/cipher.view";
import { CipherViewLike } from "../utils/cipher-view-like-utils";
export abstract class SearchService {
+ abstract isCipherSearching$: Observable;
+ abstract isSendSearching$: Observable;
+
abstract indexedEntityId$(userId: UserId): Observable;
abstract clearIndex(userId: UserId): Promise;
diff --git a/libs/common/src/vault/services/search.service.ts b/libs/common/src/vault/services/search.service.ts
index 80fddda42d5..0b34bd3863f 100644
--- a/libs/common/src/vault/services/search.service.ts
+++ b/libs/common/src/vault/services/search.service.ts
@@ -1,7 +1,7 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import * as lunr from "lunr";
-import { Observable, firstValueFrom, map } from "rxjs";
+import { BehaviorSubject, Observable, firstValueFrom, map } from "rxjs";
import { Jsonify } from "type-fest";
import { perUserCache$ } from "@bitwarden/common/vault/utils/observable-utilities";
@@ -81,6 +81,12 @@ export class SearchService implements SearchServiceAbstraction {
private readonly defaultSearchableMinLength: number = 2;
private searchableMinLength: number = this.defaultSearchableMinLength;
+ private _isCipherSearching$ = new BehaviorSubject(false);
+ isCipherSearching$: Observable = this._isCipherSearching$.asObservable();
+
+ private _isSendSearching$ = new BehaviorSubject(false);
+ isSendSearching$: Observable = this._isSendSearching$.asObservable();
+
constructor(
private logService: LogService,
private i18nService: I18nService,
@@ -223,6 +229,7 @@ export class SearchService implements SearchServiceAbstraction {
filter: ((cipher: C) => boolean) | ((cipher: C) => boolean)[] = null,
ciphers: C[],
): Promise {
+ this._isCipherSearching$.next(true);
const results: C[] = [];
const searchStartTime = performance.now();
if (query != null) {
@@ -243,6 +250,7 @@ export class SearchService implements SearchServiceAbstraction {
}
if (!(await this.isSearchable(userId, query))) {
+ this._isCipherSearching$.next(false);
return ciphers;
}
@@ -258,6 +266,7 @@ export class SearchService implements SearchServiceAbstraction {
// Fall back to basic search if index is not available
const basicResults = this.searchCiphersBasic(ciphers, query);
this.logService.measure(searchStartTime, "Vault", "SearchService", "basic search complete");
+ this._isCipherSearching$.next(false);
return basicResults;
}
@@ -293,6 +302,7 @@ export class SearchService implements SearchServiceAbstraction {
});
}
this.logService.measure(searchStartTime, "Vault", "SearchService", "search complete");
+ this._isCipherSearching$.next(false);
return results;
}
@@ -335,8 +345,10 @@ export class SearchService implements SearchServiceAbstraction {
}
searchSends(sends: SendView[], query: string) {
+ this._isSendSearching$.next(true);
query = SearchService.normalizeSearchQuery(query.trim().toLocaleLowerCase());
if (query === null) {
+ this._isSendSearching$.next(false);
return sends;
}
const sendsMatched: SendView[] = [];
@@ -359,6 +371,7 @@ export class SearchService implements SearchServiceAbstraction {
lowPriorityMatched.push(s);
}
});
+ this._isSendSearching$.next(false);
return sendsMatched.concat(lowPriorityMatched);
}
diff --git a/libs/common/src/vault/utils/skeleton-loading.operator.spec.ts b/libs/common/src/vault/utils/skeleton-loading.operator.spec.ts
new file mode 100644
index 00000000000..3ba790f64cb
--- /dev/null
+++ b/libs/common/src/vault/utils/skeleton-loading.operator.spec.ts
@@ -0,0 +1,109 @@
+import { BehaviorSubject } from "rxjs";
+
+import { skeletonLoadingDelay } from "./skeleton-loading.operator";
+
+describe("skeletonLoadingDelay", () => {
+ beforeEach(() => {
+ jest.useFakeTimers();
+ });
+
+ afterEach(() => {
+ jest.clearAllTimers();
+ jest.useRealTimers();
+ });
+
+ it("returns false immediately when starting with false", () => {
+ const source$ = new BehaviorSubject(false);
+ const results: boolean[] = [];
+
+ source$.pipe(skeletonLoadingDelay()).subscribe((value) => results.push(value));
+
+ expect(results).toEqual([false]);
+ });
+
+ it("waits 1 second before returning true when starting with true", () => {
+ const source$ = new BehaviorSubject(true);
+ const results: boolean[] = [];
+
+ source$.pipe(skeletonLoadingDelay()).subscribe((value) => results.push(value));
+
+ expect(results).toEqual([]);
+
+ jest.advanceTimersByTime(999);
+ expect(results).toEqual([]);
+
+ jest.advanceTimersByTime(1);
+ expect(results).toEqual([true]);
+ });
+
+ it("cancels if source becomes false before show delay completes", () => {
+ const source$ = new BehaviorSubject(true);
+ const results: boolean[] = [];
+
+ source$.pipe(skeletonLoadingDelay()).subscribe((value) => results.push(value));
+
+ jest.advanceTimersByTime(500);
+ source$.next(false);
+
+ expect(results).toEqual([false]);
+
+ jest.advanceTimersByTime(1000);
+ expect(results).toEqual([false]);
+ });
+
+ it("delays hiding if minimum display time has not elapsed", () => {
+ const source$ = new BehaviorSubject(true);
+ const results: boolean[] = [];
+
+ source$.pipe(skeletonLoadingDelay()).subscribe((value) => results.push(value));
+
+ jest.advanceTimersByTime(1000);
+ expect(results).toEqual([true]);
+
+ source$.next(false);
+
+ expect(results).toEqual([true]);
+
+ jest.advanceTimersByTime(1000);
+ expect(results).toEqual([true, false]);
+ });
+
+ it("handles rapid true->false->true transitions", () => {
+ const source$ = new BehaviorSubject(true);
+ const results: boolean[] = [];
+
+ source$.pipe(skeletonLoadingDelay()).subscribe((value) => results.push(value));
+
+ jest.advanceTimersByTime(500);
+ expect(results).toEqual([]);
+
+ source$.next(false);
+ expect(results).toEqual([false]);
+
+ source$.next(true);
+
+ jest.advanceTimersByTime(999);
+ expect(results).toEqual([false]);
+
+ jest.advanceTimersByTime(1);
+ expect(results).toEqual([false, true]);
+ });
+
+ it("allows for custom timings", () => {
+ const source$ = new BehaviorSubject(true);
+ const results: boolean[] = [];
+
+ source$.pipe(skeletonLoadingDelay(1000, 2000)).subscribe((value) => results.push(value));
+
+ jest.advanceTimersByTime(1000);
+ expect(results).toEqual([true]);
+
+ source$.next(false);
+
+ jest.advanceTimersByTime(1999);
+ expect(results).toEqual([true]);
+
+ jest.advanceTimersByTime(1);
+ expect(results).toEqual([true, false]);
+ });
+});
diff --git a/libs/common/src/vault/utils/skeleton-loading.operator.ts b/libs/common/src/vault/utils/skeleton-loading.operator.ts
new file mode 100644
index 00000000000..b9ff28f64b5
--- /dev/null
+++ b/libs/common/src/vault/utils/skeleton-loading.operator.ts
@@ -0,0 +1,59 @@
+import { defer, Observable, of, timer } from "rxjs";
+import { map, switchMap, tap } from "rxjs/operators";
+
+/**
+ * RxJS operator that adds skeleton loading delay behavior.
+ *
+ * - Waits 1 second before showing (prevents flashing for quick loads)
+ * - Ensures skeleton stays visible for at least 1 second once shown regardless of the source observable emissions
+ * - After the minimum display time, if the source is still true, continues to emit true until the source becomes false
+ * - False can only be emitted either:
+ * - Immediately when the source emits false before the skeleton is shown
+ * - After the minimum display time has passed once the skeleton is shown
+ */
+export function skeletonLoadingDelay(
+ showDelay = 1000,
+ minDisplayTime = 1000,
+): (source: Observable) => Observable {
+ return (source: Observable) => {
+ return defer(() => {
+ let skeletonShownAt: number | null = null;
+
+ return source.pipe(
+ switchMap((shouldShow): Observable => {
+ if (shouldShow) {
+ if (skeletonShownAt !== null) {
+ return of(true); // Already shown, continue showing
+ }
+
+ // Wait for delay, then mark the skeleton as shown and emit true
+ return timer(showDelay).pipe(
+ tap(() => {
+ skeletonShownAt = Date.now();
+ }),
+ map(() => true),
+ );
+ } else {
+ if (skeletonShownAt === null) {
+ // Skeleton not shown yet, can emit false immediately
+ return of(false);
+ }
+
+ // Skeleton shown, ensure minimum display time has passed
+ const elapsedTime = Date.now() - skeletonShownAt;
+ const remainingTime = Math.max(0, minDisplayTime - elapsedTime);
+
+ // Wait for remaining time to ensure minimum display time
+ return timer(remainingTime).pipe(
+ tap(() => {
+ // Reset the shown timestamp
+ skeletonShownAt = null;
+ }),
+ map(() => false),
+ );
+ }
+ }),
+ );
+ });
+ };
+}