diff --git a/apps/web/src/app/admin-console/organizations/collections/vault.component.ts b/apps/web/src/app/admin-console/organizations/collections/vault.component.ts index bd0d77939104..386fb86890cf 100644 --- a/apps/web/src/app/admin-console/organizations/collections/vault.component.ts +++ b/apps/web/src/app/admin-console/organizations/collections/vault.component.ts @@ -25,6 +25,7 @@ import { switchMap, take, takeUntil, + tap, } from "rxjs/operators"; import { CollectionAdminService, CollectionService } from "@bitwarden/admin-console/common"; @@ -194,6 +195,8 @@ export class VaultComponent implements OnInit, OnDestroy { private searchText$ = new Subject(); protected refreshingSubject$ = new BehaviorSubject(true); + private _loadStartTime = 0; + private _elapsedS = () => ((performance.now() - this._loadStartTime) / 1000).toFixed(2) + "s"; private destroy$ = new Subject(); protected addAccessStatus$ = new BehaviorSubject(0); private vaultItemDialogRef?: DialogRef | undefined; @@ -280,8 +283,32 @@ export class VaultComponent implements OnInit, OnDestroy { this.allCollectionsWithoutUnassigned$ = this.refreshingSubject$.pipe( filter((refreshing) => refreshing), switchMap(() => combineLatest([this.organizationId$, this.userId$])), - switchMap(([orgId, userId]) => - this.collectionAdminService.collectionAdminViews$(orgId, userId), + switchMap( + ([orgId, userId]) => + // Subscribe to collectionAdminViews$ outside Angular's zone so the setTimeout + // yields in our chunked decryptMany loop don't each trigger a full CD pass. + // We subscribe directly (rather than firstValueFrom) to preserve re-emissions + // from upstream sources such as orgKeys$ — e.g. on key rotation the collections + // are re-decrypted automatically. ngZone.run() re-enters the zone exactly once + // per emission so Angular gets a single CD pass when new data is ready. + new Observable((observer) => { + const sub = this.ngZone.runOutsideAngular(() => + this.collectionAdminService + .collectionAdminViews$(orgId, userId) + .pipe(takeUntil(this.destroy$)) + .subscribe({ + next: (result) => this.ngZone.run(() => observer.next(result)), + error: (e: unknown) => this.ngZone.run(() => observer.error(e)), + complete: () => this.ngZone.run(() => observer.complete()), + }), + ); + return () => sub.unsubscribe(); + }), + ), + tap((collections) => + this.logService.debug( + `[Collections] Collections decrypted: ${collections.length} collections (+${this._elapsedS()})`, + ), ), shareReplay({ refCount: true, bufferSize: 1 }), ); @@ -304,7 +331,27 @@ export class VaultComponent implements OnInit, OnDestroy { ); this.nestedCollections$ = this.allCollections$.pipe( - map((collections) => getNestedCollectionTree(collections)), + // Run the O(N²) nestedTraverse outside Angular's zone so: + // 1. The deferred setTimeout does not trigger a spurious CD run when it fires. + // 2. The tree-build itself (potentially hundreds of ms for 8K collections) does + // not block Angular's CD scheduler. + // ngZone.run() re-enters the zone exactly once when the result is ready. + switchMap( + (collections) => + new Observable[]>((observer) => { + const cancel = this.ngZone.runOutsideAngular(() => { + const id = setTimeout(() => { + const result = getNestedCollectionTree(collections); + this.ngZone.run(() => { + observer.next(result); + observer.complete(); + }); + }, 0); + return () => clearTimeout(id); + }); + return cancel; + }), + ), shareReplay({ refCount: true, bufferSize: 1 }), ); @@ -325,7 +372,6 @@ export class VaultComponent implements OnInit, OnDestroy { if (!this.showAddAccessToggle || organization) { this.addAccessToggle(0); } - let ciphers; // Restricted providers (who are not members) do not have access org cipher endpoint below // Return early to avoid 404 response @@ -333,22 +379,49 @@ export class VaultComponent implements OnInit, OnDestroy { return []; } - // If the user can edit all ciphers for the organization then fetch them ALL. - if (organization.canEditAllCiphers) { - ciphers = await this.cipherService.getAllFromApiForOrganization(organization.id); - ciphers.forEach((c) => (c.edit = true)); - } else { - // Otherwise, only fetch ciphers they have access to (includes unassigned for admins). - ciphers = await this.cipherService.getManyFromApiForOrganization(organization.id); - } - - // Filter out restricted ciphers before indexing - ciphers = ciphers.filter( - (cipher) => !this.restrictedItemTypesService.isCipherRestricted(cipher, restricted), - ); + // Run the heavy fetch → decrypt → index pipeline outside Angular's zone. + // + // Our chunked decryption and indexing loops yield via setTimeout(0) between + // batches to keep the browser responsive. However, zone.js intercepts every + // setTimeout callback and triggers a full Angular change-detection pass. With + // Default CD strategy that can mean 40+ full-tree scans during loading — each + // one blocking the main thread for tens of milliseconds. Running outside the + // zone means the yields genuinely free the browser without triggering CD. + // Angular gets a single CD run when this promise resolves and the observable + // emits the completed cipher array. + const ciphers = await new Promise((resolve, reject) => { + void this.ngZone.runOutsideAngular(async () => { + try { + let result: CipherView[]; + + // If the user can edit all ciphers for the organization then fetch them ALL. + if (organization.canEditAllCiphers) { + result = await this.cipherService.getAllFromApiForOrganization(organization.id); + result.forEach((c) => (c.edit = true)); + } else { + // Otherwise, only fetch ciphers they have access to (includes unassigned for admins). + result = await this.cipherService.getManyFromApiForOrganization(organization.id); + } + + // Filter out restricted ciphers before indexing + result = result.filter( + (cipher) => !this.restrictedItemTypesService.isCipherRestricted(cipher, restricted), + ); + + resolve(result); + } catch (e) { + reject(e); + } + }); + }); return ciphers; }), + tap((ciphers) => + this.logService.debug( + `[Collections] Ciphers fetched & decrypted: ${ciphers.length} ciphers (+${this._elapsedS()})`, + ), + ), shareReplay({ refCount: true, bufferSize: 1 }), ); @@ -465,16 +538,15 @@ export class VaultComponent implements OnInit, OnDestroy { this.organization$, ]).pipe( filter(([collections, filter]) => collections != undefined && filter != undefined), - concatMap( + switchMap( async ([collections, filter, searchText, addAccessStatus, userId, organization]) => { if ( filter.collectionId === Unassigned || (filter.collectionId === undefined && filter.type !== undefined) ) { - return []; + return { collections: [] as CollectionAdminView[], showToggle: false }; } - this.showAddAccessToggle = false; let searchableCollectionNodes: TreeNode[] = []; if (filter.collectionId === undefined || filter.collectionId === All) { searchableCollectionNodes = collections; @@ -506,17 +578,24 @@ export class VaultComponent implements OnInit, OnDestroy { } // Add access toggle is only shown if allowAdminAccessToAllCollectionItems is false and there are unmanaged collections the user can edit - this.showAddAccessToggle = + const showToggle = !organization.allowAdminAccessToAllCollectionItems && organization.canEditUnmanagedCollections && collectionsToReturn.some((c) => c.unmanaged); - if (addAccessStatus === 1 && this.showAddAccessToggle) { + if (addAccessStatus === 1 && showToggle) { collectionsToReturn = collectionsToReturn.filter((c) => c.unmanaged); } - return collectionsToReturn; + return { collections: collectionsToReturn, showToggle }; }, ), + // Separate the side effect (updating component state) from the data computation above. + // tap is the designated operator for side effects in RxJS pipelines, making the data + // flow explicit and preventing accidental change-detection triggers mid-pipeline. + tap(({ showToggle }) => { + this.showAddAccessToggle = showToggle; + }), + map(({ collections }) => collections), takeUntil(this.destroy$), shareReplay({ refCount: true, bufferSize: 1 }), ); @@ -546,10 +625,22 @@ export class VaultComponent implements OnInit, OnDestroy { ([refreshing, processing, firstLoadComplete]) => refreshing || processing || !firstLoadComplete, ), + tap((loading) => { + if (!loading && this._loadStartTime > 0) { + this.logService.debug(`[Collections] Fully loaded — total time: ${this._elapsedS()}`); + } + }), ); } async ngOnInit() { + this.refreshingSubject$.pipe(takeUntil(this.destroy$)).subscribe((refreshing) => { + if (refreshing) { + this._loadStartTime = performance.now(); + this.logService.debug("[Collections] Load started"); + } + }); + const firstSetup$ = combineLatest([this.organization$, this.route.queryParams]).pipe( first(), switchMap(async ([organization]) => { diff --git a/apps/web/src/app/vault/components/vault-items/vault-items.component.html b/apps/web/src/app/vault/components/vault-items/vault-items.component.html index 0ad5192b722b..70d89c58db17 100644 --- a/apps/web/src/app/vault/components/vault-items/vault-items.component.html +++ b/apps/web/src/app/vault/components/vault-items/vault-items.component.html @@ -123,7 +123,7 @@ - + @if (item.collection) { { // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals // eslint-disable-next-line @angular-eslint/prefer-signals @Input() allOrganizations: Organization[] = []; + private _allCollections: CollectionView[] = []; // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals // eslint-disable-next-line @angular-eslint/prefer-signals - @Input() allCollections: CollectionView[] = []; + @Input() get allCollections(): CollectionView[] { + return this._allCollections; + } + set allCollections(value: CollectionView[] | undefined) { + this._allCollections = value ?? []; + this.refreshItems(); + } // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals // eslint-disable-next-line @angular-eslint/prefer-signals @Input() allGroups: GroupView[] = []; @@ -156,6 +163,13 @@ export class VaultItemsComponent { protected canRestoreSelected$: Observable; protected disableMenu$: Observable; private restrictedTypes: RestrictedCipherType[] = []; + /** + * A Set of collection IDs where the current user has the "manage" permission. Built once per + * refreshItems() call (O(K)) so that canManageCollection() lookups are O(1) instead of O(K) + * per cipher row, which would otherwise be O(ciphers × collections) on every change-detection + * cycle. + */ + private manageableCollectionIds = new Set(); constructor( protected cipherAuthorizationService: CipherAuthorizationService, @@ -507,12 +521,17 @@ export class VaultItemsComponent { return this.activeCollection.manage === true; } - return this.allCollections - .filter((c) => cipher.collectionIds.includes(c.id as any)) - .some((collection) => collection.manage); + // Use the pre-computed Set for an O(1) lookup instead of scanning allCollections + // (O(K) per cipher). The set is rebuilt in refreshItems() whenever inputs change. + return cipher.collectionIds.some((id) => this.manageableCollectionIds.has(id as string)); } private refreshItems() { + // Rebuild the manageable-collection lookup Set so canManageCollection() calls are O(1). + this.manageableCollectionIds = new Set( + (this.allCollections ?? []).filter((c) => c.manage).map((c) => c.id as string), + ); + const collections: VaultItem[] = this.collections.map((collection) => ({ collection })); const ciphers: VaultItem[] = this.ciphers .filter( diff --git a/libs/admin-console/src/common/collections/services/default-collection-admin.service.ts b/libs/admin-console/src/common/collections/services/default-collection-admin.service.ts index f7f3274a6486..8fb1cd3cf76e 100644 --- a/libs/admin-console/src/common/collections/services/default-collection-admin.service.ts +++ b/libs/admin-console/src/common/collections/services/default-collection-admin.service.ts @@ -134,24 +134,28 @@ export class DefaultCollectionAdminService implements CollectionAdminService { collections: CollectionResponse[] | CollectionAccessDetailsResponse[], orgKeys: Record, ): Promise { - const promises = collections.map(async (c) => { - if (isCollectionAccessDetailsResponse(c)) { - return CollectionAdminView.fromCollectionAccessDetails( - c, - this.encryptService, - orgKeys[organizationId as OrganizationId], - ); - } - - return await CollectionAdminView.fromCollectionResponse( - c, - this.encryptService, - orgKeys[organizationId as OrganizationId], + const orgKey = orgKeys[organizationId as OrganizationId]; + + // Promise.all(8K) floods the microtask queue — decrypt in chunks with event-loop + // yields so the browser can paint and handle input between each batch. + const DECRYPT_CHUNK_SIZE = 2_000; + const results: CollectionAdminView[] = []; + for (let i = 0; i < collections.length; i += DECRYPT_CHUNK_SIZE) { + const chunk = collections.slice(i, i + DECRYPT_CHUNK_SIZE); + const decrypted = await Promise.all( + chunk.map(async (c) => { + if (isCollectionAccessDetailsResponse(c)) { + return CollectionAdminView.fromCollectionAccessDetails(c, this.encryptService, orgKey); + } + return await CollectionAdminView.fromCollectionResponse(c, this.encryptService, orgKey); + }), ); - }); - - const r = await Promise.all(promises); - return r; + results.push(...decrypted); + if (i + DECRYPT_CHUNK_SIZE < collections.length) { + await new Promise((r) => setTimeout(r, 0)); + } + } + return results; } private async encrypt( diff --git a/libs/common/src/vault/services/cipher.service.ts b/libs/common/src/vault/services/cipher.service.ts index a6ab0db872a9..2775e61af2c5 100644 --- a/libs/common/src/vault/services/cipher.service.ts +++ b/libs/common/src/vault/services/cipher.service.ts @@ -777,7 +777,9 @@ export class CipherService implements CipherServiceAbstraction { const [cipherViews] = await this.cipherEncryptionService.decryptManyLegacy(ciphers, userId); - // Sort by locale (matching existing behavior) + // Yield one macrotask before the sort so the browser can paint and respond to input + // (e.g. a sidebar click) before the O(N log N) locale-sort blocks the main thread. + await new Promise((r) => setTimeout(r, 0)); cipherViews.sort(this.getLocaleSortingFunction()); return cipherViews; @@ -809,12 +811,23 @@ export class CipherService implements CipherServiceAbstraction { const ciphers = response.data.map((cr) => new Cipher(new CipherData(cr))); const key = await this.keyService.getOrgKey(organizationId); - const decCiphers: CipherView[] = await Promise.all( - ciphers.map(async (cipher) => { - return await cipher.decrypt(key); - }), - ); + // Promise.all(80K) floods the microtask queue — decrypt in chunks with event-loop + // yields so the browser can paint and handle input between each batch. + const DECRYPT_CHUNK_SIZE = 2_000; + const decCiphers: CipherView[] = []; + for (let i = 0; i < ciphers.length; i += DECRYPT_CHUNK_SIZE) { + const chunk = ciphers.slice(i, i + DECRYPT_CHUNK_SIZE); + const decrypted = await Promise.all(chunk.map((c) => c.decrypt(key))); + decCiphers.push(...decrypted); + if (i + DECRYPT_CHUNK_SIZE < ciphers.length) { + await new Promise((r) => setTimeout(r, 0)); + } + } + + // Yield one macrotask before the sort so the browser can paint and respond to input + // before the O(N log N) locale-sort blocks the main thread. + await new Promise((r) => setTimeout(r, 0)); decCiphers.sort(this.getLocaleSortingFunction()); return decCiphers; } diff --git a/libs/common/src/vault/services/default-cipher-encryption.service.ts b/libs/common/src/vault/services/default-cipher-encryption.service.ts index b4198d9e7c74..aaf7e062f085 100644 --- a/libs/common/src/vault/services/default-cipher-encryption.service.ts +++ b/libs/common/src/vault/services/default-cipher-encryption.service.ts @@ -209,6 +209,14 @@ export class DefaultCipherEncryptionService implements CipherEncryptionService { const successful: CipherView[] = []; const failed: CipherView[] = []; + // Each SDK decrypt() call resolves synchronously (WASM), so each `await` only + // queues a microtask — never yielding to the browser event loop. For large + // org vaults (80 K ciphers) this blocks painting and input for several seconds. + // Yielding via setTimeout(0) every CHUNK_SIZE ciphers converts the flood of + // microtasks into discrete macrotasks the browser event loop can schedule around. + const DECRYPT_CHUNK_SIZE = 2_000; + let processed = 0; + for (const cipher of ciphers) { try { const sdkCipherView = await ref.value.vault().ciphers().decrypt(cipher.toSdkCipher()); @@ -246,6 +254,11 @@ export class DefaultCipherEncryptionService implements CipherEncryptionService { failedView.decryptionFailure = true; failed.push(failedView); } + + processed++; + if (processed % DECRYPT_CHUNK_SIZE === 0) { + await new Promise((r) => setTimeout(r, 0)); + } } return [successful, failed] as [CipherView[], CipherView[]]; diff --git a/libs/common/src/vault/services/search.worker.ts b/libs/common/src/vault/services/search.worker.ts new file mode 100644 index 000000000000..3be6f47c1d55 --- /dev/null +++ b/libs/common/src/vault/services/search.worker.ts @@ -0,0 +1,184 @@ +/** + * Long-lived Web Worker that owns the Lunr full-text search index for the entire session. + * + * WHY LONG-LIVED: The previous design built the index in a worker then serialised it and + * sent it back so the main thread could deserialise it via `lunr.Index.load()`. For large + * vaults that deserialization takes ~2–3 s on the main thread, producing a visible freeze + * even though the build itself ran off-thread. By keeping the index alive in the worker + * and handling search queries here too, the main thread never touches a Lunr data structure. + * + * WHY STREAMING: Sending all 80 K LunrDocumentData objects in one postMessage creates a + * large structured-clone transfer buffer alongside the source array — doubling the memory + * spike on the main thread and OOM-ing for large vaults. Instead, the main thread streams + * documents in 2 K chunks (addDocuments), then signals buildIndex when done. Peak main- + * thread LunrDocumentData memory is therefore ~2 K objects, not 80 K. + * + * PROTOCOL (main thread → worker) + * { type: 'addDocuments', documents: LunrDocumentData[] } // sent N times + * (no response — fire and forget) + * + * { type: 'buildIndex' } // sent once after all chunks + * → { type: 'buildComplete' } + * → { type: 'error', error: string } + * + * { type: 'search', requestId: string, query: string, isQueryString: boolean, terms: string[] } + * → { type: 'searchResults', requestId: string, results: Array<{ref:string, score:number}> } + * → { type: 'error', error: string } + */ + +import * as lunr from "lunr"; + +/** Pre-computed, serialisable data for a single cipher document. */ +export type LunrDocumentData = { + id: string; + shortid: string; + name: string | null; + subtitle: string | null; + notes: string | null; + login: { username: string | null; uris: string[] | null } | null; + fields: string[] | null; + fields_joined: string | null; + attachments: string[] | null; + attachments_joined: string | null; + organizationid: string | null; +}; + +// --------------------------------------------------------------------------- +// Normalise-accents pipeline function — registered under the name expected by +// SearchService so a serialised index can still be loaded on the main thread +// if ever needed (e.g. unit tests that bypass the worker). +// --------------------------------------------------------------------------- +const SEARCHABLE_ACCENT_FIELDS = ["name", "login.username", "subtitle", "notes"]; + +function normalizeAccentsPipelineFunction(token: lunr.Token): lunr.Token | string { + const fields: string[] = (token as unknown as { metadata: { fields: string[] } }).metadata[ + "fields" + ]; + if (fields.every((f) => SEARCHABLE_ACCENT_FIELDS.includes(f))) { + return token + .toString() + .normalize("NFD") + .replace(/[\u0300-\u036f]/g, ""); + } + return token; +} + +lunr.Pipeline.registerFunction( + normalizeAccentsPipelineFunction as unknown as lunr.PipelineFunction, + "normalizeAccents", +); + +// --------------------------------------------------------------------------- +// Worker state — the index lives here for the duration of the session. +// --------------------------------------------------------------------------- +let currentIndex: lunr.Index | null = null; + +/** Configured builder reused across `addDocuments` chunks. Created fresh each build cycle. */ +let pendingBuilder: lunr.Builder | null = null; + +function createBuilder(): lunr.Builder { + const builder = new lunr.Builder(); + builder.pipeline.add(normalizeAccentsPipelineFunction as unknown as lunr.PipelineFunction); + builder.ref("id"); + builder.field("shortid", { boost: 100 }); + builder.field("name", { boost: 10 }); + builder.field("subtitle", { boost: 5 }); + builder.field("notes"); + // "login.username" and "login.uris" use dot-notation field names to match the main-thread + // index so query strings (e.g. `login.username:alice`) work identically in both paths. + // Without explicit extractors, Lunr reads doc["login.username"] (a literal key lookup) + // which is always undefined because the data is stored as a nested object. The extractors + // traverse doc.login.username / doc.login.uris explicitly. + builder.field("login.username", { + extractor: (doc: object) => (doc as LunrDocumentData).login?.username ?? "", + }); + builder.field("login.uris", { + boost: 2, + extractor: (doc: object) => (doc as LunrDocumentData).login?.uris ?? [], + }); + builder.field("fields"); + builder.field("fields_joined"); + builder.field("attachments"); + builder.field("attachments_joined"); + builder.field("organizationid"); + return builder; +} + +// --------------------------------------------------------------------------- +// Message handler +// --------------------------------------------------------------------------- +type InMessage = + | { type: "addDocuments"; documents: LunrDocumentData[] } + | { type: "buildIndex" } + | { + type: "search"; + requestId: string; + query: string; + isQueryString: boolean; + terms: string[]; + }; + +self.onmessage = (event: MessageEvent) => { + const msg = event.data; + + // Chunk of pre-computed cipher documents — add directly to the builder and drop the + // reference so the GC can reclaim the chunk memory before the next arrives. + if (msg.type === "addDocuments") { + if (!pendingBuilder) { + pendingBuilder = createBuilder(); + } + for (const doc of msg.documents) { + pendingBuilder.add(doc); + } + return; + } + + // All chunks delivered — build the index and signal completion. + if (msg.type === "buildIndex") { + try { + const builder = pendingBuilder ?? createBuilder(); + pendingBuilder = null; // allow GC before the expensive build() + + currentIndex = builder.build(); + + // No serializedIndex sent back — avoids a large toJSON() copy and another + // structured-clone on the main thread. The index is rebuilt on page reload. + self.postMessage({ type: "buildComplete" }); + } catch (e: unknown) { + self.postMessage({ type: "error", error: String(e) }); + } + return; + } + + if (msg.type === "search") { + const { requestId, query, isQueryString, terms } = msg; + if (!currentIndex) { + self.postMessage({ type: "searchResults", requestId, results: [] }); + return; + } + try { + let results: lunr.Index.Result[]; + if (isQueryString) { + results = currentIndex.search(query.substring(1).trim()); + } else { + const soWild = lunr.Query.wildcard.LEADING | lunr.Query.wildcard.TRAILING; + results = currentIndex.query((q) => { + terms.forEach((t) => { + q.term(t, { fields: ["name"], wildcard: soWild }); + q.term(t, { fields: ["subtitle"], wildcard: soWild }); + q.term(t, { fields: ["login.uris"], wildcard: soWild }); + q.term(t, {}); + }); + }); + } + self.postMessage({ + type: "searchResults", + requestId, + results: results.map((r) => ({ ref: r.ref, score: r.score })), + }); + } catch (e: unknown) { + self.postMessage({ type: "error", error: String(e) }); + } + return; + } +};