perf(sync): lazy-load sync providers and repair utilities to reduce bundle size

Convert sync providers from eagerly instantiated array to lazy-loaded
factories using dynamic imports. Make dataRepair and migrateLegacyBackup
dynamically imported at their call sites (error/migration paths only).

- getProviderById is now async with promise-cached instantiation
- _setActiveProvider guards against redundant re-activation
- Staleness counter prevents race conditions on rapid provider switches
This commit is contained in:
Johannes Millan 2026-03-10 10:09:04 +01:00
parent 3a5093f270
commit 7d27400d03
8 changed files with 180 additions and 120 deletions

View file

@ -122,8 +122,9 @@ describe('SyncConfigService', () => {
),
},
};
// getProviderById returns synchronously, not a Promise
(providerManager.getProviderById as jasmine.Spy).and.returnValue(mockProvider);
(providerManager.getProviderById as jasmine.Spy).and.returnValue(
Promise.resolve(mockProvider),
);
const settings: SyncConfig = {
isEnabled: true,
@ -152,8 +153,10 @@ describe('SyncConfigService', () => {
});
it('should apply default values for LocalFile provider fields when no existing config', async () => {
// Mock no existing provider - getProviderById returns synchronously
(providerManager.getProviderById as jasmine.Spy).and.returnValue(null);
// Mock no existing provider
(providerManager.getProviderById as jasmine.Spy).and.returnValue(
Promise.resolve(null),
);
const settings: SyncConfig = {
isEnabled: true,
@ -191,8 +194,9 @@ describe('SyncConfigService', () => {
),
},
};
// getProviderById returns synchronously, not a Promise
(providerManager.getProviderById as jasmine.Spy).and.returnValue(mockProvider);
(providerManager.getProviderById as jasmine.Spy).and.returnValue(
Promise.resolve(mockProvider),
);
const settings: SyncConfig = {
isEnabled: true,
@ -231,8 +235,9 @@ describe('SyncConfigService', () => {
),
},
};
// getProviderById returns synchronously, not a Promise
(providerManager.getProviderById as jasmine.Spy).and.returnValue(mockProvider);
(providerManager.getProviderById as jasmine.Spy).and.returnValue(
Promise.resolve(mockProvider),
);
// Update settings without changing the provider
const settings: SyncConfig = {
@ -276,7 +281,9 @@ describe('SyncConfigService', () => {
),
},
};
(providerManager.getProviderById as jasmine.Spy).and.returnValue(mockProvider);
(providerManager.getProviderById as jasmine.Spy).and.returnValue(
Promise.resolve(mockProvider),
);
// Simulate form state after resetOnHide: true triggered
// Form only provides baseUrl, accessToken is empty string (reset by Formly)
@ -319,7 +326,9 @@ describe('SyncConfigService', () => {
),
},
};
(providerManager.getProviderById as jasmine.Spy).and.returnValue(mockProvider);
(providerManager.getProviderById as jasmine.Spy).and.returnValue(
Promise.resolve(mockProvider),
);
const settings: SyncConfig = {
isEnabled: true,
@ -360,7 +369,9 @@ describe('SyncConfigService', () => {
),
},
};
(providerManager.getProviderById as jasmine.Spy).and.returnValue(mockProvider);
(providerManager.getProviderById as jasmine.Spy).and.returnValue(
Promise.resolve(mockProvider),
);
// Form provides empty password (e.g., from resetOnHide or form state issue)
const settings: SyncConfig = {
@ -402,7 +413,9 @@ describe('SyncConfigService', () => {
),
},
};
(providerManager.getProviderById as jasmine.Spy).and.returnValue(mockProvider);
(providerManager.getProviderById as jasmine.Spy).and.returnValue(
Promise.resolve(mockProvider),
);
// Form may include isEncryptionEnabled: false (e.g., stale Formly model)
// but for SuperSync it should NOT override the saved config
@ -443,7 +456,9 @@ describe('SyncConfigService', () => {
),
},
};
(providerManager.getProviderById as jasmine.Spy).and.returnValue(mockProvider);
(providerManager.getProviderById as jasmine.Spy).and.returnValue(
Promise.resolve(mockProvider),
);
// Form provides only baseUrl, all other fields empty
const settings: SyncConfig = {
@ -488,7 +503,9 @@ describe('SyncConfigService', () => {
),
},
};
(providerManager.getProviderById as jasmine.Spy).and.returnValue(mockProvider);
(providerManager.getProviderById as jasmine.Spy).and.returnValue(
Promise.resolve(mockProvider),
);
const settings: SyncConfig = {
isEnabled: true,
@ -530,7 +547,9 @@ describe('SyncConfigService', () => {
),
},
};
(providerManager.getProviderById as jasmine.Spy).and.returnValue(mockProvider);
(providerManager.getProviderById as jasmine.Spy).and.returnValue(
Promise.resolve(mockProvider),
);
// Form provides empty path
const settings: SyncConfig = {
@ -554,13 +573,15 @@ describe('SyncConfigService', () => {
});
it('should prevent duplicate saves when settings are unchanged', async () => {
// Mock provider for the test - getProviderById returns synchronously
(providerManager.getProviderById as jasmine.Spy).and.returnValue({
id: SyncProviderId.WebDAV,
privateCfg: {
load: jasmine.createSpy('load').and.returnValue(Promise.resolve({})),
},
});
// Mock provider for the test
(providerManager.getProviderById as jasmine.Spy).and.returnValue(
Promise.resolve({
id: SyncProviderId.WebDAV,
privateCfg: {
load: jasmine.createSpy('load').and.returnValue(Promise.resolve({})),
},
}),
);
const settings: SyncConfig = {
isEnabled: true,
@ -590,12 +611,14 @@ describe('SyncConfigService', () => {
it('should deduplicate when syncSettingsForm$ emits before Formly modelChange', async () => {
// Mock provider for the test
(providerManager.getProviderById as jasmine.Spy).and.returnValue({
id: SyncProviderId.WebDAV,
privateCfg: {
load: jasmine.createSpy('load').and.returnValue(Promise.resolve({})),
},
});
(providerManager.getProviderById as jasmine.Spy).and.returnValue(
Promise.resolve({
id: SyncProviderId.WebDAV,
privateCfg: {
load: jasmine.createSpy('load').and.returnValue(Promise.resolve({})),
},
}),
);
// Simulate syncSettingsForm$ emission by pushing provider config
mockCurrentProviderPrivateCfg$.next({
@ -633,8 +656,10 @@ describe('SyncConfigService', () => {
});
it('should handle provider with no existing config', async () => {
// Mock no existing provider (e.g., initial setup) - getProviderById returns synchronously
(providerManager.getProviderById as jasmine.Spy).and.returnValue(null);
// Mock no existing provider (e.g., initial setup)
(providerManager.getProviderById as jasmine.Spy).and.returnValue(
Promise.resolve(null),
);
const settings: SyncConfig = {
isEnabled: true,
@ -682,9 +707,11 @@ describe('SyncConfigService', () => {
},
};
// Mock: No provider exists initially - getProviderById returns synchronously
// Mock: No provider exists initially
(providerManager.getActiveProvider as jasmine.Spy).and.returnValue(null);
(providerManager.getProviderById as jasmine.Spy).and.returnValue(null);
(providerManager.getProviderById as jasmine.Spy).and.returnValue(
Promise.resolve(null),
);
// User saves the form
await service.updateSettingsFromForm(initialSettings);
@ -736,9 +763,11 @@ describe('SyncConfigService', () => {
},
};
// No provider exists yet - getProviderById returns synchronously
// No provider exists yet
(providerManager.getActiveProvider as jasmine.Spy).and.returnValue(null);
(providerManager.getProviderById as jasmine.Spy).and.returnValue(null);
(providerManager.getProviderById as jasmine.Spy).and.returnValue(
Promise.resolve(null),
);
await service.updateSettingsFromForm(initialSettings);
@ -802,14 +831,16 @@ describe('SyncConfigService', () => {
},
};
// Mock existing WebDAV provider - getProviderById returns synchronously
// Mock existing WebDAV provider
const mockProvider = {
id: SyncProviderId.WebDAV,
privateCfg: {
load: jasmine.createSpy('load').and.returnValue(Promise.resolve({})),
},
};
(providerManager.getProviderById as jasmine.Spy).and.returnValue(mockProvider);
(providerManager.getProviderById as jasmine.Spy).and.returnValue(
Promise.resolve(mockProvider),
);
await service.updateSettingsFromForm(webDavSettings);
@ -841,9 +872,11 @@ describe('SyncConfigService', () => {
},
};
// Mock that there's no active provider yet (initial setup) - getProviderById returns synchronously
// Mock that there's no active provider yet (initial setup)
(providerManager.getActiveProvider as jasmine.Spy).and.returnValue(null);
(providerManager.getProviderById as jasmine.Spy).and.returnValue(null);
(providerManager.getProviderById as jasmine.Spy).and.returnValue(
Promise.resolve(null),
);
// Act: User saves the form with encryption enabled
await service.updateSettingsFromForm(newSettings);
@ -870,8 +903,10 @@ describe('SyncConfigService', () => {
},
};
// Update mocks to simulate provider is now available - getProviderById returns synchronously
(providerManager.getProviderById as jasmine.Spy).and.returnValue(mockProvider);
// Update mocks to simulate provider is now available
(providerManager.getProviderById as jasmine.Spy).and.returnValue(
Promise.resolve(mockProvider),
);
// In a real scenario, after setPrivateCfgForSyncProvider is called,
// the currentProviderPrivateCfg$ would be updated with the saved config
@ -1032,7 +1067,9 @@ describe('SyncConfigService', () => {
),
},
};
(providerManager.getProviderById as jasmine.Spy).and.returnValue(mockProvider);
(providerManager.getProviderById as jasmine.Spy).and.returnValue(
Promise.resolve(mockProvider),
);
(providerManager.getActiveProvider as jasmine.Spy).and.returnValue(mockProvider);
// Update password
@ -1064,7 +1101,9 @@ describe('SyncConfigService', () => {
),
},
};
(providerManager.getProviderById as jasmine.Spy).and.returnValue(mockProvider);
(providerManager.getProviderById as jasmine.Spy).and.returnValue(
Promise.resolve(mockProvider),
);
(providerManager.getActiveProvider as jasmine.Spy).and.returnValue(mockProvider);
// Update password
@ -1100,7 +1139,9 @@ describe('SyncConfigService', () => {
),
},
};
(providerManager.getProviderById as jasmine.Spy).and.returnValue(mockProvider);
(providerManager.getProviderById as jasmine.Spy).and.returnValue(
Promise.resolve(mockProvider),
);
(providerManager.getActiveProvider as jasmine.Spy).and.returnValue(mockProvider);
await service.updateEncryptionPassword('newpass', SyncProviderId.SuperSync);
@ -1131,7 +1172,9 @@ describe('SyncConfigService', () => {
.and.returnValue(Promise.resolve(existingConfig)),
},
};
(providerManager.getProviderById as jasmine.Spy).and.returnValue(mockProvider);
(providerManager.getProviderById as jasmine.Spy).and.returnValue(
Promise.resolve(mockProvider),
);
(providerManager.getActiveProvider as jasmine.Spy).and.returnValue(mockProvider);
// Update password
@ -1192,7 +1235,9 @@ describe('SyncConfigService', () => {
getConfig: jasmine.createSpy('getConfig'),
};
(providerManager.getProviderById as jasmine.Spy).and.returnValue(mockProvider);
(providerManager.getProviderById as jasmine.Spy).and.returnValue(
Promise.resolve(mockProvider),
);
(providerManager.getActiveProvider as jasmine.Spy).and.returnValue(mockProvider);
// Simulate form update with NO encryptKey (stale form model)
@ -1240,7 +1285,9 @@ describe('SyncConfigService', () => {
getConfig: jasmine.createSpy('getConfig'),
};
(providerManager.getProviderById as jasmine.Spy).and.returnValue(mockProvider);
(providerManager.getProviderById as jasmine.Spy).and.returnValue(
Promise.resolve(mockProvider),
);
(providerManager.getActiveProvider as jasmine.Spy).and.returnValue(mockProvider);
// Simulate form update with OLD password (stale form model from before dialog)
@ -1286,7 +1333,9 @@ describe('SyncConfigService', () => {
getConfig: jasmine.createSpy('getConfig'),
};
(providerManager.getProviderById as jasmine.Spy).and.returnValue(mockProvider);
(providerManager.getProviderById as jasmine.Spy).and.returnValue(
Promise.resolve(mockProvider),
);
(providerManager.getActiveProvider as jasmine.Spy).and.returnValue(mockProvider);
// Simulate form update with NEW password from form
@ -1327,7 +1376,9 @@ describe('SyncConfigService', () => {
getConfig: jasmine.createSpy('getConfig'),
};
(providerManager.getProviderById as jasmine.Spy).and.returnValue(mockProvider);
(providerManager.getProviderById as jasmine.Spy).and.returnValue(
Promise.resolve(mockProvider),
);
(providerManager.getActiveProvider as jasmine.Spy).and.returnValue(mockProvider);
// Simulate form update with password in settings.encryptKey (legacy path)
@ -1375,7 +1426,9 @@ describe('SyncConfigService', () => {
getConfig: jasmine.createSpy('getConfig'),
};
(providerManager.getProviderById as jasmine.Spy).and.returnValue(mockProvider);
(providerManager.getProviderById as jasmine.Spy).and.returnValue(
Promise.resolve(mockProvider),
);
(providerManager.getActiveProvider as jasmine.Spy).and.returnValue(mockProvider);
const formSettings: SyncConfig = {
@ -1426,7 +1479,9 @@ describe('SyncConfigService', () => {
getConfig: jasmine.createSpy('getConfig'),
};
(providerManager.getProviderById as jasmine.Spy).and.returnValue(mockProvider);
(providerManager.getProviderById as jasmine.Spy).and.returnValue(
Promise.resolve(mockProvider),
);
(providerManager.getActiveProvider as jasmine.Spy).and.returnValue(mockProvider);
// Step 2: Simulate stale form model arriving AFTER dialog saved password

View file

@ -81,6 +81,7 @@ describe('SyncWrapperService', () => {
},
);
mockProviderManager.clearAuthCredentials.and.returnValue(Promise.resolve());
mockProviderManager.getProviderById.and.returnValue(Promise.resolve(undefined));
mockProviderManager.getLastSyncedProviderId.and.returnValue(null);
mockProviderManager.getActiveProvider.and.returnValue({
id: SyncProviderId.SuperSync,

View file

@ -8,7 +8,6 @@ import { Operation, OpType, ActionType } from '../core/operation.types';
import { CURRENT_SCHEMA_VERSION } from '../persistence/schema-migration.service';
import { uuidv7 } from '../../util/uuid-v7';
import { loadAllData } from '../../root-store/meta/load-all-data.action';
import { dataRepair } from '../validation/data-repair';
import { isDataRepairPossible } from '../validation/is-data-repair-possible.util';
import { OpLog } from '../../core/log';
import {
@ -19,7 +18,6 @@ import {
import { CompleteBackup } from '../core/types/sync.types';
import { ArchiveDbAdapter } from '../../core/persistence/archive-db-adapter.service';
import { ArchiveModel } from '../../features/archive/archive.model';
import { isLegacyBackupData, migrateLegacyBackup } from './migrate-legacy-backup';
/**
* Service for handling backup import and export operations.
@ -91,6 +89,8 @@ export class BackupService {
}
// 2. Migrate legacy backups (pre-v14) that have the old data shape
const { isLegacyBackupData, migrateLegacyBackup } =
await import('./migrate-legacy-backup');
if (isLegacyBackupData(backupData as unknown as Record<string, unknown>)) {
OpLog.normal(
'BackupService: Detected legacy backup format, running migration...',
@ -121,6 +121,7 @@ export class BackupService {
'errors' in validationResult.typiaResult
? validationResult.typiaResult.errors
: [];
const { dataRepair } = await import('../validation/data-repair');
validatedData = dataRepair(backupData, errors).data;
} else {
throw new Error('Data validation failed and repair not possible');

View file

@ -15,7 +15,6 @@ import {
import { loadAllData } from '../../root-store/meta/load-all-data.action';
import { download } from '../../util/download';
import { isDataRepairPossible } from '../validation/is-data-repair-possible.util';
import { dataRepair } from '../validation/data-repair';
import { uuidv7 } from '../../util/uuid-v7';
import { ActionType, Operation, OpType } from '../core/operation.types';
import { CURRENT_SCHEMA_VERSION } from './schema-migration.service';
@ -220,6 +219,7 @@ export class OperationLogMigrationService {
'errors' in validationResult.typiaResult
? validationResult.typiaResult.errors
: [];
const { dataRepair } = await import('../validation/data-repair');
dataToMigrate = dataRepair(legacyData as unknown as AppDataComplete, errors).data;
// Re-validate after repair to ensure success

View file

@ -45,6 +45,9 @@ export class SyncProviderManager {
// Lazily loaded providers (cached after first load)
private _providers: SyncProviderBase<SyncProviderId>[] | null = null;
/** Counter to detect stale provider activations */
private _activeProviderSetupId = 0;
// Current active provider
private _activeProvider: SyncProviderBase<SyncProviderId> | null = null;
private _activeProviderId$ = new BehaviorSubject<SyncProviderId | null>(null);
@ -283,6 +286,12 @@ export class SyncProviderManager {
* Sets the active sync provider (loads providers lazily on first call)
*/
private _setActiveProvider(providerId: SyncProviderId | null): void {
// Skip if provider hasn't changed to avoid resetting state on unrelated config changes
if (providerId === this._activeProviderId$.getValue()) {
return;
}
const setupId = ++this._activeProviderSetupId;
this._activeProviderId$.next(providerId);
if (!providerId) {
@ -292,57 +301,37 @@ export class SyncProviderManager {
return;
}
// Load providers lazily and set the active one
this._ensureProviders()
.then((providers) => {
// Guard against stale resolution if provider changed while loading
if (this._activeProviderId$.getValue() !== providerId) {
// Clear stale config from previous provider during async load
this._currentProviderPrivateCfg$.next(null);
this.getProviderById(providerId)
.then(async (provider) => {
if (this._activeProviderSetupId !== setupId) {
return;
}
const provider = providers.find((p) => p.id === providerId);
if (provider) {
this._activeProvider = provider;
provider
.isReady()
.then((ready) => {
if (this._activeProviderId$.getValue() !== providerId) return;
this._isProviderReady$.next(ready);
})
.catch((err) => {
SyncLog.err(
'SyncProviderManager: Failed to check provider readiness:',
err,
);
if (this._activeProviderId$.getValue() === providerId) {
this._isProviderReady$.next(false);
}
});
// Emit provider config to observable
provider.privateCfg
.load()
.then((privateCfg) => {
if (this._activeProviderId$.getValue() !== providerId) return;
this._currentProviderPrivateCfg$.next({
providerId,
privateCfg,
});
})
.catch((err) =>
SyncLog.err('SyncProviderManager: Failed to load provider config:', err),
);
SyncLog.normal(`SyncProviderManager: Active provider set to ${providerId}`);
} else {
if (!provider) {
SyncLog.err(`SyncProviderManager: Provider not found: ${providerId}`);
}
})
.catch((err) => {
if (this._activeProviderId$.getValue() !== providerId) {
return;
}
SyncLog.err('SyncProviderManager: Failed to load providers:', err);
this._isProviderReady$.next(false);
this._activeProvider = provider;
const [ready, privateCfg] = await Promise.all([
provider.isReady(),
provider.privateCfg.load(),
]);
if (this._activeProviderSetupId !== setupId) {
return;
}
this._isProviderReady$.next(ready);
this._currentProviderPrivateCfg$.next({ providerId, privateCfg });
SyncLog.normal(`SyncProviderManager: Active provider set to ${providerId}`);
})
.catch((e) => {
SyncLog.err(`SyncProviderManager: Failed to load provider ${providerId}:`, e);
if (this._activeProviderSetupId === setupId) {
this._isProviderReady$.next(false);
}
});
}
}

View file

@ -2,7 +2,6 @@ import { inject, Injectable } from '@angular/core';
import { IValidation } from 'typia';
import { Action, Store } from '@ngrx/store';
import { TranslateService } from '@ngx-translate/core';
import { dataRepair } from './data-repair';
import { isDataRepairPossible } from './is-data-repair-possible.util';
import { RepairSummary } from '../core/operation.types';
import { OpLog } from '../../core/log';
@ -295,6 +294,7 @@ export class ValidateStateService {
// User confirmed - proceed with repair
try {
const typiaErrors = validationResult.typiaErrors as IValidation.IError[];
const { dataRepair } = await import('./data-repair');
const repairResult = dataRepair(state as AppDataComplete, typiaErrors);
const repairedState = repairResult.data;
const repairSummary = repairResult.repairSummary;

View file

@ -37,9 +37,13 @@ describe('ConfigPageComponent', () => {
},
{
provide: SyncProviderManager,
useValue: jasmine.createSpyObj('SyncProviderManager', ['getProviderById'], {
currentProviderPrivateCfg$: of(null),
}),
useValue: (() => {
const spy = jasmine.createSpyObj('SyncProviderManager', ['getProviderById'], {
currentProviderPrivateCfg$: of(null),
});
spy.getProviderById.and.returnValue(Promise.resolve(undefined));
return spy;
})(),
},
{
provide: GlobalConfigService,

View file

@ -397,22 +397,26 @@ export class ConfigPageComponent implements OnInit, OnDestroy {
// We need to check auth status asynchronously, so we'll set initial state
// and update it when the form is rendered
let isAuthenticated = false;
dropboxProvider?.isReady().then((ready) => {
isAuthenticated = ready;
// Update the status field text after checking
const statusField = item.fieldGroup?.find(
(f: any) => f.key === 'authStatus',
) as any;
if (statusField?.templateOptions) {
statusField.templateOptions.text = isAuthenticated
? '✓ ' +
this._translateService.instant(T.F.SYNC.FORM.DROPBOX.STATUS_CONFIGURED)
: '⚠ ' +
this._translateService.instant(
T.F.SYNC.FORM.DROPBOX.STATUS_NOT_CONFIGURED,
);
}
});
this._providerManager
.getProviderById(SyncProviderId.Dropbox)
.then(async (dropboxProvider) => {
const ready = await dropboxProvider?.isReady();
isAuthenticated = !!ready;
// Update the status field text after checking
const statusField = item.fieldGroup?.find(
(f: any) => f.key === 'authStatus',
) as any;
if (statusField?.templateOptions) {
statusField.templateOptions.text = isAuthenticated
? '✓ ' +
this._translateService.instant(T.F.SYNC.FORM.DROPBOX.STATUS_CONFIGURED)
: '⚠ ' +
this._translateService.instant(
T.F.SYNC.FORM.DROPBOX.STATUS_NOT_CONFIGURED,
);
}
})
.catch((e) => console.error('Failed to check Dropbox auth status:', e));
return {
...item,
@ -428,6 +432,9 @@ export class ConfigPageComponent implements OnInit, OnDestroy {
onClick: async (_field: unknown, _form: unknown, model: unknown) => {
try {
// Check current auth status before opening dialog
const dropboxProvider = await this._providerManager.getProviderById(
SyncProviderId.Dropbox,
);
const isCurrentlyAuth = await dropboxProvider?.isReady();
const result =
@ -491,7 +498,10 @@ export class ConfigPageComponent implements OnInit, OnDestroy {
hooks: {
// Update button text based on auth status when form initializes
onInit: async (field: any) => {
const isAuth = await dropboxProvider?.isReady();
const dropboxProv = await this._providerManager.getProviderById(
SyncProviderId.Dropbox,
);
const isAuth = await dropboxProv?.isReady();
if (field?.templateOptions && isAuth) {
field.templateOptions.text = T.F.SYNC.FORM.DROPBOX.BTN_REAUTHENTICATE;
}