Improve browser extension autofill suggestion performance (#1413)

This commit is contained in:
Leendert de Borst
2025-11-30 12:26:26 +01:00
committed by Leendert de Borst
parent 64d29ebcd4
commit 254f0a1212
10 changed files with 140 additions and 68 deletions

View File

@@ -9,7 +9,7 @@ import { handleClipboardCopied, handleCancelClipboardClear, handleGetClipboardCl
import { setupContextMenus } from '@/entrypoints/background/ContextMenu';
import { handleGetWebAuthnSettings, handleWebAuthnCreate, handleWebAuthnGet, handlePasskeyPopupResponse, handleGetRequestData } from '@/entrypoints/background/PasskeyHandler';
import { handleOpenPopup, handlePopupWithCredential, handleOpenPopupCreateCredential, handleToggleContextMenu } from '@/entrypoints/background/PopupMessageHandler';
import { handleCheckAuthStatus, handleClearPersistedFormValues, handleClearVault, handleCreateIdentity, handleGetCredentials, handleGetDefaultEmailDomain, handleGetDefaultIdentitySettings, handleGetEncryptionKey, handleGetEncryptionKeyDerivationParams, handleGetPasswordSettings, handleGetPersistedFormValues, handleGetVault, handlePersistFormValues, handleStoreEncryptionKey, handleStoreEncryptionKeyDerivationParams, handleStoreVault, handleSyncVault, handleUploadVault } from '@/entrypoints/background/VaultMessageHandler';
import { handleCheckAuthStatus, handleClearPersistedFormValues, handleClearVault, handleCreateIdentity, handleGetCredentials, handleGetFilteredCredentials, handleGetSearchCredentials, handleGetDefaultEmailDomain, handleGetDefaultIdentitySettings, handleGetEncryptionKey, handleGetEncryptionKeyDerivationParams, handleGetPasswordSettings, handleGetPersistedFormValues, handleGetVault, handlePersistFormValues, handleStoreEncryptionKey, handleStoreEncryptionKeyDerivationParams, handleStoreVault, handleSyncVault, handleUploadVault } from '@/entrypoints/background/VaultMessageHandler';
import { GLOBAL_CONTEXT_MENU_ENABLED_KEY } from '@/utils/Constants';
import { EncryptionKeyDerivationParams } from "@/utils/dist/shared/models/metadata";
@@ -28,6 +28,8 @@ export default defineBackground({
onMessage('GET_ENCRYPTION_KEY_DERIVATION_PARAMS', () => handleGetEncryptionKeyDerivationParams());
onMessage('GET_VAULT', () => handleGetVault());
onMessage('GET_CREDENTIALS', () => handleGetCredentials());
onMessage('GET_FILTERED_CREDENTIALS', ({ data }) => handleGetFilteredCredentials(data as { currentUrl: string, pageTitle: string, matchingMode?: string }));
onMessage('GET_SEARCH_CREDENTIALS', ({ data }) => handleGetSearchCredentials(data as { searchTerm: string }));
onMessage('GET_DEFAULT_EMAIL_DOMAIN', () => handleGetDefaultEmailDomain());
onMessage('GET_DEFAULT_IDENTITY_SETTINGS', () => handleGetDefaultIdentitySettings());

View File

@@ -4,12 +4,12 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { handleGetEncryptionKey } from '@/entrypoints/background/VaultMessageHandler';
import { extractDomain, extractRootDomain } from '@/entrypoints/contentScript/CredentialMatcher';
import {
PASSKEY_PROVIDER_ENABLED_KEY,
PASSKEY_DISABLED_SITES_KEY
} from '@/utils/Constants';
import { extractDomain, extractRootDomain } from '@/utils/credentialMatcher/CredentialMatcher';
import { EncryptionUtility } from '@/utils/EncryptionUtility';
import { PasskeyHelper } from '@/utils/passkey/PasskeyHelper';
import type {

View File

@@ -264,6 +264,101 @@ export async function handleGetCredentials(
}
}
/**
* Get credentials filtered by URL and page title for autofill performance optimization.
* Filters credentials in the background script before sending to reduce message payload size.
* Critical for large vaults (1000+ credentials) to avoid multi-second delays.
*
* @param message - Filtering parameters: currentUrl, pageTitle, matchingMode
*/
export async function handleGetFilteredCredentials(
message: { currentUrl: string, pageTitle: string, matchingMode?: string }
) : Promise<messageCredentialsResponse> {
const encryptionKey = await handleGetEncryptionKey();
if (!encryptionKey) {
return { success: false, error: await t('common.errors.vaultIsLocked') };
}
try {
const sqliteClient = await createVaultSqliteClient();
const allCredentials = sqliteClient.getAllCredentials();
// Import filtering logic
const { filterCredentials, AutofillMatchingMode } = await import('@/utils/credentialMatcher/CredentialMatcher');
// Parse matching mode from string
let matchingMode = AutofillMatchingMode.DEFAULT;
if (message.matchingMode) {
matchingMode = message.matchingMode as typeof AutofillMatchingMode[keyof typeof AutofillMatchingMode];
}
// Filter credentials in background to reduce payload size (~95% reduction)
const filteredCredentials = filterCredentials(
allCredentials,
message.currentUrl,
message.pageTitle,
matchingMode
);
return { success: true, credentials: filteredCredentials };
} catch (error) {
console.error('Error getting filtered credentials:', error);
return { success: false, error: await t('common.errors.unknownError') };
}
}
/**
* Get credentials filtered by text search query.
* Searches across entire vault (service name, username, email, URL) and returns matches.
*
* @param message - Search parameters: searchTerm
*/
export async function handleGetSearchCredentials(
message: { searchTerm: string }
) : Promise<messageCredentialsResponse> {
const encryptionKey = await handleGetEncryptionKey();
if (!encryptionKey) {
return { success: false, error: await t('common.errors.vaultIsLocked') };
}
try {
const sqliteClient = await createVaultSqliteClient();
const allCredentials = sqliteClient.getAllCredentials();
// If search term is empty, return empty array
if (!message.searchTerm || message.searchTerm.trim() === '') {
return { success: true, credentials: [] };
}
const searchTerm = message.searchTerm.toLowerCase().trim();
// Filter credentials by search term across multiple fields
const searchResults = allCredentials.filter(cred => {
const searchableFields = [
cred.ServiceName?.toLowerCase(),
cred.Username?.toLowerCase(),
cred.Alias?.Email?.toLowerCase(),
cred.ServiceUrl?.toLowerCase()
];
return searchableFields.some(field => field?.includes(searchTerm));
}).sort((a, b) => {
// Sort by service name, then username
const serviceNameComparison = (a.ServiceName ?? '').localeCompare(b.ServiceName ?? '');
if (serviceNameComparison !== 0) {
return serviceNameComparison;
}
return (a.Username ?? '').localeCompare(b.Username ?? '');
});
return { success: true, credentials: searchResults };
} catch (error) {
console.error('Error searching credentials:', error);
return { success: false, error: await t('common.errors.unknownError') };
}
}
/**
* Create an identity.
*/

View File

@@ -1,9 +1,9 @@
import { sendMessage } from 'webext-bridge/content-script';
import { filterCredentials, AutofillMatchingMode } from '@/entrypoints/contentScript/CredentialMatcher';
import { fillCredential } from '@/entrypoints/contentScript/Form';
import { DISABLED_SITES_KEY, TEMPORARY_DISABLED_SITES_KEY, GLOBAL_AUTOFILL_POPUP_ENABLED_KEY, VAULT_LOCKED_DISMISS_UNTIL_KEY, AUTOFILL_MATCHING_MODE_KEY, CUSTOM_EMAIL_HISTORY_KEY, CUSTOM_USERNAME_HISTORY_KEY } from '@/utils/Constants';
import { AutofillMatchingMode } from '@/utils/credentialMatcher/CredentialMatcher';
import { CreateIdentityGenerator } from '@/utils/dist/shared/identity-generator';
import type { Credential } from '@/utils/dist/shared/models/vault';
import { CreatePasswordGenerator, PasswordGenerator, PasswordSettings } from '@/utils/dist/shared/password-generator';
@@ -49,7 +49,14 @@ export function openAutofillPopup(input: HTMLInputElement, container: HTMLElemen
document.addEventListener('keydown', handleEnterKey);
(async () : Promise<void> => {
const response = await sendMessage('GET_CREDENTIALS', { }, 'background') as CredentialsResponse;
// Load autofill matching mode setting to send to background for filtering
const matchingMode = await storage.getItem(AUTOFILL_MATCHING_MODE_KEY) as AutofillMatchingMode ?? AutofillMatchingMode.DEFAULT;
const response = await sendMessage('GET_FILTERED_CREDENTIALS', {
currentUrl: window.location.href,
pageTitle: document.title,
matchingMode: matchingMode
}, 'background') as CredentialsResponse;
if (response.success) {
await createAutofillPopup(input, response.credentials, container);
@@ -182,22 +189,12 @@ export async function createAutofillPopup(input: HTMLInputElement, credentials:
credentialList.className = 'av-credential-list';
popup.appendChild(credentialList);
// Add initial credentials
// Add initial credentials (already filtered by background script for performance)
if (!credentials) {
credentials = [];
}
// Load autofill matching mode setting
const matchingMode = await storage.getItem(AUTOFILL_MATCHING_MODE_KEY) as AutofillMatchingMode ?? AutofillMatchingMode.DEFAULT;
const filteredCredentials = filterCredentials(
credentials,
window.location.href,
document.title,
matchingMode
);
updatePopupContent(filteredCredentials, credentialList, input, rootContainer, noMatchesText);
updatePopupContent(credentials, credentialList, input, rootContainer, noMatchesText);
// Add divider
const divider = document.createElement('div');
@@ -549,62 +546,41 @@ export async function createVaultLockedPopup(input: HTMLInputElement, rootContai
}
/**
* Handle popup search input by filtering credentials based on the search term.
* Handle popup search input - searches entire vault when user types.
* When empty, shows the initially URL-filtered credentials.
* When user types, searches ALL credentials in vault (not just the pre-filtered set).
*
* @param searchInput - The search input element
* @param initialCredentials - The initially URL-filtered credentials to show when search is empty
* @param rootContainer - The root container element
* @param searchTimeout - Timeout for debouncing search
* @param credentialList - The credential list element to update
* @param input - The input field that triggered the popup
* @param noMatchesText - Text to show when no matches found
*/
async function handleSearchInput(searchInput: HTMLInputElement, credentials: Credential[], rootContainer: HTMLElement, searchTimeout: NodeJS.Timeout | null, credentialList: HTMLElement | null, input: HTMLInputElement, noMatchesText?: string) : Promise<void> {
async function handleSearchInput(searchInput: HTMLInputElement, initialCredentials: Credential[], rootContainer: HTMLElement, searchTimeout: NodeJS.Timeout | null, credentialList: HTMLElement | null, input: HTMLInputElement, noMatchesText?: string) : Promise<void> {
if (searchTimeout) {
clearTimeout(searchTimeout);
}
const searchTerm = searchInput.value.toLowerCase();
// Ensure we have unique credentials
const uniqueCredentials = Array.from(new Map(credentials.map(cred => [cred.Id, cred])).values());
let filteredCredentials;
const searchTerm = searchInput.value.trim();
if (searchTerm === '') {
// Load autofill matching mode setting
const matchingMode = await storage.getItem(AUTOFILL_MATCHING_MODE_KEY) as AutofillMatchingMode ?? AutofillMatchingMode.DEFAULT;
// If search is empty, use original URL-based filtering
filteredCredentials = filterCredentials(
uniqueCredentials,
window.location.href,
document.title,
matchingMode
).sort((a, b) => {
// First compare by service name
const serviceNameComparison = (a.ServiceName ?? '').localeCompare(b.ServiceName ?? '');
if (serviceNameComparison !== 0) {
return serviceNameComparison;
}
// If service names are equal, compare by username/nickname
return (a.Username ?? '').localeCompare(b.Username ?? '');
});
// If search is empty, show the initially URL-filtered credentials
updatePopupContent(initialCredentials, credentialList, input, rootContainer, noMatchesText);
} else {
// Otherwise filter based on search term
filteredCredentials = uniqueCredentials.filter(cred => {
const searchableFields = [
cred.ServiceName?.toLowerCase(),
cred.Username?.toLowerCase(),
cred.Alias?.Email?.toLowerCase(),
cred.ServiceUrl?.toLowerCase()
];
return searchableFields.some(field => field?.includes(searchTerm));
}).sort((a, b) => {
// First compare by service name
const serviceNameComparison = (a.ServiceName ?? '').localeCompare(b.ServiceName ?? '');
if (serviceNameComparison !== 0) {
return serviceNameComparison;
}
// Search in full vault with search term
const response = await sendMessage('GET_SEARCH_CREDENTIALS', {
searchTerm: searchTerm
}, 'background') as CredentialsResponse;
// If service names are equal, compare by username/nickname
return (a.Username ?? '').localeCompare(b.Username ?? '');
});
if (response.success && response.credentials) {
updatePopupContent(response.credentials, credentialList, input, rootContainer, noMatchesText);
} else {
// On error, fallback to showing initial filtered credentials
updatePopupContent(initialCredentials, credentialList, input, rootContainer, noMatchesText);
}
}
// Update popup content with filtered results
updatePopupContent(filteredCredentials, credentialList, input, rootContainer, noMatchesText);
}
/**

View File

@@ -3,7 +3,6 @@ import { useTranslation } from 'react-i18next';
import { useLocation } from 'react-router-dom';
import { sendMessage } from 'webext-bridge/popup';
import { extractDomain, extractRootDomain } from '@/entrypoints/contentScript/CredentialMatcher';
import Button from '@/entrypoints/popup/components/Button';
import PasskeyBypassDialog from '@/entrypoints/popup/components/Dialogs/PasskeyBypassDialog';
import LoadingSpinner from '@/entrypoints/popup/components/LoadingSpinner';
@@ -12,6 +11,7 @@ import { useLoading } from '@/entrypoints/popup/context/LoadingContext';
import { useVaultLockRedirect } from '@/entrypoints/popup/hooks/useVaultLockRedirect';
import { PASSKEY_DISABLED_SITES_KEY } from '@/utils/Constants';
import { extractDomain, extractRootDomain } from '@/utils/credentialMatcher/CredentialMatcher';
import { PasskeyAuthenticator } from '@/utils/passkey/PasskeyAuthenticator';
import { PasskeyHelper } from '@/utils/passkey/PasskeyHelper';
import type { GetRequest, PasskeyGetCredentialResponse, PendingPasskeyGetRequest, StoredPasskeyRecord } from '@/utils/passkey/types';

View File

@@ -3,7 +3,6 @@ import { useTranslation } from 'react-i18next';
import { useLocation } from 'react-router-dom';
import { sendMessage } from 'webext-bridge/popup';
import { extractDomain, extractRootDomain } from '@/entrypoints/contentScript/CredentialMatcher';
import Alert from '@/entrypoints/popup/components/Alert';
import Button from '@/entrypoints/popup/components/Button';
import PasskeyBypassDialog from '@/entrypoints/popup/components/Dialogs/PasskeyBypassDialog';
@@ -16,6 +15,7 @@ import { useVaultLockRedirect } from '@/entrypoints/popup/hooks/useVaultLockRedi
import { useVaultMutate } from '@/entrypoints/popup/hooks/useVaultMutate';
import { PASSKEY_DISABLED_SITES_KEY } from '@/utils/Constants';
import { extractDomain, extractRootDomain } from '@/utils/credentialMatcher/CredentialMatcher';
import type { Passkey } from '@/utils/dist/shared/models/vault';
import { PasskeyAuthenticator } from '@/utils/passkey/PasskeyAuthenticator';
import { PasskeyHelper } from '@/utils/passkey/PasskeyHelper';

View File

@@ -1,7 +1,6 @@
import React, { useEffect, useState, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { AutofillMatchingMode } from '@/entrypoints/contentScript/CredentialMatcher';
import { useLoading } from '@/entrypoints/popup/context/LoadingContext';
import {
@@ -10,6 +9,7 @@ import {
TEMPORARY_DISABLED_SITES_KEY,
AUTOFILL_MATCHING_MODE_KEY
} from '@/utils/Constants';
import { AutofillMatchingMode } from '@/utils/credentialMatcher/CredentialMatcher';
import { storage, browser } from "#imports";

View File

@@ -1,13 +1,13 @@
import React, { useEffect, useState, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { extractDomain, extractRootDomain } from '@/entrypoints/contentScript/CredentialMatcher';
import { useLoading } from '@/entrypoints/popup/context/LoadingContext';
import {
PASSKEY_PROVIDER_ENABLED_KEY,
PASSKEY_DISABLED_SITES_KEY
} from '@/utils/Constants';
import { extractDomain, extractRootDomain } from '@/utils/credentialMatcher/CredentialMatcher';
import { storage, browser } from "#imports";

View File

@@ -1,9 +1,8 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { filterCredentials } from '@/utils/credentialMatcher/CredentialMatcher';
import type { Credential } from '@/utils/dist/shared/models/vault';
import { filterCredentials } from '../CredentialMatcher';
describe('CredentialMatcher - Credential URL Matching', () => {
let testCredentials: Credential[];