diff --git a/eslint.config.js b/eslint.config.js index 831c24e..3044635 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -2,6 +2,7 @@ const eslint = require("@eslint/js"); const tseslint = require("typescript-eslint"); const angular = require("angular-eslint"); +const rdlabo = require("@rdlabo/eslint-plugin-rules"); module.exports = tseslint.config( { @@ -13,6 +14,15 @@ module.exports = tseslint.config( ...angular.configs.tsRecommended, ], processor: angular.processInlineTemplates, + languageOptions: { + parserOptions: { + projectService: true, + tsconfigRootDir: __dirname, + }, + }, + plugins: { + "@rdlabo/rules": rdlabo, + }, rules: { "@typescript-eslint/no-empty-function": "off", "@typescript-eslint/no-unused-vars": "off", @@ -25,6 +35,16 @@ module.exports = tseslint.config( "@angular-eslint/no-empty-lifecycle-method": "off", "@angular-eslint/directive-selector": "off", "@angular-eslint/component-selector": "off", + "@rdlabo/rules/restrict-try-block": [ + "error", + { + allowPromise: false, + allowPromiseResolve: false, + allowRxjs: false, + allowInSignal: false, + maxLines: 3, + }, + ], }, }, { diff --git a/package-lock.json b/package-lock.json index 1a5ce45..678ce9c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -50,6 +50,7 @@ "@ionic/storage-angular": "^4.0.0", "@playwright/test": "^1.57.0", "@rdlabo/capacitor-brotherprint": "^8.1.1", + "@rdlabo/eslint-plugin-rules": "^21.2.6", "angular-eslint": "21.4.0", "child_process": "^1.0.2", "dom-to-image-more": "^3.10.0", @@ -8562,6 +8563,37 @@ "@capacitor/core": ">=8.0.0" } }, + "node_modules/@rdlabo/eslint-plugin-rules": { + "version": "21.2.6", + "resolved": "https://registry.npmjs.org/@rdlabo/eslint-plugin-rules/-/eslint-plugin-rules-21.2.6.tgz", + "integrity": "sha512-KO05NpZx1dXNcwfYn0m177zNbEIppnnLe/9a/36XFiCPObqlJeN5az/3xdDyj3gOjbc6UgpFHsKsbZUFLv3ZUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ionic/angular": "> 8.0.0", + "ts-api-utils": "2.1.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@typescript-eslint/utils": ">=8.33.0 <9.0.0", + "eslint": ">=9.0.0" + } + }, + "node_modules/@rdlabo/eslint-plugin-rules/node_modules/ts-api-utils": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz", + "integrity": "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, "node_modules/@rdlabo/ionic-angular-photo-editor": { "resolved": "dist/photo-editor", "link": true diff --git a/package.json b/package.json index 17c73f8..184e462 100644 --- a/package.json +++ b/package.json @@ -80,6 +80,7 @@ "@ionic/storage-angular": "^4.0.0", "@playwright/test": "^1.57.0", "@rdlabo/capacitor-brotherprint": "^8.1.1", + "@rdlabo/eslint-plugin-rules": "^21.2.6", "angular-eslint": "21.4.0", "child_process": "^1.0.2", "dom-to-image-more": "^3.10.0", diff --git a/projects/kit/app-update/src/lib/kit-app-update.provider.spec.ts b/projects/kit/app-update/src/lib/kit-app-update.provider.spec.ts index 73e6ec1..ec38f78 100644 --- a/projects/kit/app-update/src/lib/kit-app-update.provider.spec.ts +++ b/projects/kit/app-update/src/lib/kit-app-update.provider.spec.ts @@ -80,9 +80,10 @@ describe('provideKitAppUpdate', () => { }); function setup(result: boolean | Error | Promise, isEnabled = true, isControlled = true) { - const checkForUpdate = vi.fn(() => - result instanceof Promise ? result : result instanceof Error ? Promise.reject(result) : Promise.resolve(result), - ); + const checkForUpdate = vi.fn(async () => { + if (result instanceof Error) throw result; + return result; + }); const reload = vi.fn(); TestBed.configureTestingModule({ providers: [ diff --git a/projects/kit/app-update/src/lib/kit-app-update.provider.ts b/projects/kit/app-update/src/lib/kit-app-update.provider.ts index fab770c..72fcd4a 100644 --- a/projects/kit/app-update/src/lib/kit-app-update.provider.ts +++ b/projects/kit/app-update/src/lib/kit-app-update.provider.ts @@ -22,14 +22,12 @@ export class KitAppUpdateService { if (!this.#updates.isEnabled || !this.#document.defaultView?.navigator.serviceWorker?.controller) { return; } - try { - const available = await withTimeout(this.#updates.checkForUpdate(), UPDATE_CHECK_TIMEOUT_MS); - if (available) { - this.#document.location?.reload(); - } - } catch (error) { - console.error('Angular service-worker update check failed', error); - } + const checkForUpdate = async (): Promise => withTimeout(this.#updates.checkForUpdate(), UPDATE_CHECK_TIMEOUT_MS); + await checkForUpdate() + .then((available) => { + if (available) this.#document.location?.reload(); + }) + .catch((error: unknown) => console.error('Angular service-worker update check failed', error)); } } @@ -42,9 +40,7 @@ export class KitAppUpdateService { * rolling out because code already running in older application versions cannot gain this behavior retroactively. */ export function provideKitAppUpdate(): EnvironmentProviders { - return makeEnvironmentProviders([ - provideAppInitializer(() => inject(KitAppUpdateService).initialize()), - ]); + return makeEnvironmentProviders([provideAppInitializer(() => inject(KitAppUpdateService).initialize())]); } function withTimeout(promise: Promise, timeoutMs: number): Promise { diff --git a/projects/kit/auth-firebase/social/src/kit-social.ts b/projects/kit/auth-firebase/social/src/kit-social.ts index 8a4165c..604c26a 100644 --- a/projects/kit/auth-firebase/social/src/kit-social.ts +++ b/projects/kit/auth-firebase/social/src/kit-social.ts @@ -91,6 +91,17 @@ const classifyOAuthError = (e: unknown): KitOAuthErrorCategory => { return 'other'; }; +type Settled = { status: 'fulfilled'; value: T } | { status: 'rejected'; reason: unknown }; + +/** Convert a possibly synchronously-throwing Promise producer into an explicit result. */ +const settle = (operation: () => Promise): Promise> => { + const execute = async (): Promise => operation(); + return execute().then( + (value) => ({ status: 'fulfilled', value }), + (reason: unknown) => ({ status: 'rejected', reason }), + ); +}; + /** * The shared 3-mode credential state machine (internal). * @@ -109,7 +120,7 @@ const applyOAuthCredential = async ( error: (category: KitOAuthErrorCategory, error: unknown) => void | Promise; }, ): Promise => { - try { + const result = await settle(async () => { if (mode.mode === 'new') { await signInWithCredential(auth, credential); } else { @@ -124,8 +135,9 @@ const applyOAuthCredential = async ( await linkWithCredential(user, EmailAuthProvider.credential(mode.emailLogin.email, mode.emailLogin.password)); } } - } catch (e) { - await effects.error(classifyOAuthError(e), e); + }); + if (result.status === 'rejected') { + await effects.error(classifyOAuthError(result.reason), result.reason); return false; } await effects.success(); @@ -164,27 +176,17 @@ const isFacebookCancellation = (error: unknown): boolean => { * hooks). */ export const kitFacebookLogin = async (auth: Auth, options: KitFacebookLoginOptions): Promise<{ status: boolean }> => { - try { + const execute = async (): Promise<{ status: boolean }> => { await options.before?.(); const nonce = generateNonce(); - let pluginFailed = false; - let pluginError: unknown; - const event = await FacebookLogin.login({ permissions: options.permissions, nonce }).catch((error: unknown) => { - pluginFailed = true; - pluginError = error; - return undefined; - }); + const login = await settle(() => FacebookLogin.login({ permissions: options.permissions, nonce })); await nextFrame(); - if (pluginFailed) { - if (isFacebookCancellation(pluginError)) { - return { status: false }; - } - await options.error?.('other', pluginError); - return { status: false }; - } - if (!event || !event.accessToken?.token) { + if (login.status === 'rejected') { + if (!isFacebookCancellation(login.reason)) await options.error?.('other', login.reason); return { status: false }; } + const event = login.value; + if (!event?.accessToken?.token) return { status: false }; const accessToken = event.accessToken.token; const credential: AuthCredential = Capacitor.isNativePlatform() && Capacitor.getPlatform() === 'ios' @@ -196,9 +198,8 @@ export const kitFacebookLogin = async (auth: Auth, options: KitFacebookLoginOpti error: (category, error) => options.error?.(category, error), }); return { status }; - } finally { - await options.finally?.(); - } + }; + return execute().finally(() => options.finally?.()); }; /** @@ -231,13 +232,11 @@ export const kitFacebookLogout = async (): Promise => { * Every failure path (including popup errors) is routed through `onError`. */ export const kitAppleLogin = async (auth: Auth, options: KitAppleLoginOptions): Promise<{ status: boolean }> => { - try { + const execute = async (): Promise<{ status: boolean }> => { await options.before?.(); if (Capacitor.isNativePlatform()) { const authorize = await SignInWithApple.authorize().catch(() => undefined); - if (!authorize) { - return { status: false }; - } + if (!authorize) return { status: false }; const r = authorize.response; const response: KitAppleResponse = { user: r.user ?? null, @@ -261,28 +260,27 @@ export const kitAppleLogin = async (auth: Auth, options: KitAppleLoginOptions): provider.addScope('name'); if (options.mode === 'credential') { - const user = auth.currentUser; - try { - if (!user) { - throw new Error('kit social: no signed-in user to re-authenticate'); - } + const operation = await settle(async () => { + const user = requireUser(auth); await reauthenticateWithPopup(user, provider); await linkWithCredential(user, EmailAuthProvider.credential(options.emailLogin.email, options.emailLogin.password)); - } catch (e) { - await options.error?.(classifyOAuthError(e), e); + }); + if (operation.status === 'rejected') { + await options.error?.(classifyOAuthError(operation.reason), operation.reason); return { status: false }; } await options.success?.({ response: emptyAppleResponse(), mode: 'credential' }); return { status: true }; } - let result; - try { - result = options.mode === 'new' ? await signInWithPopup(auth, provider) : await linkWithPopup(requireUser(auth), provider); - } catch (e) { - await options.error?.(classifyOAuthError(e), e); + const popup = await settle(() => + options.mode === 'new' ? signInWithPopup(auth, provider) : linkWithPopup(requireUser(auth), provider), + ); + if (popup.status === 'rejected') { + await options.error?.(classifyOAuthError(popup.reason), popup.reason); return { status: false }; } + const result = popup.value; const credential = OAuthProvider.credentialFromResult(result); const response: KitAppleResponse = { ...emptyAppleResponse(), @@ -292,9 +290,8 @@ export const kitAppleLogin = async (auth: Auth, options: KitAppleLoginOptions): }; await options.success?.({ response, mode: options.mode }); return { status: true }; - } finally { - await options.finally?.(); - } + }; + return execute().finally(() => options.finally?.()); }; const emptyAppleResponse = (): KitAppleResponse => ({ diff --git a/projects/kit/auth-firebase/src/kit-firebase-auth.ts b/projects/kit/auth-firebase/src/kit-firebase-auth.ts index 40743d0..648f557 100644 --- a/projects/kit/auth-firebase/src/kit-firebase-auth.ts +++ b/projects/kit/auth-firebase/src/kit-firebase-auth.ts @@ -58,18 +58,19 @@ export interface KitSignOutOptions { } /** Run a value-returning op through the {@link KitAuthHooks} lifecycle; resolve `null` on failure. */ -const runAuthFlow = async (op: () => Promise, hooks?: KitAuthHooks): Promise => { - try { +const runAuthFlow = (op: () => Promise, hooks?: KitAuthHooks): Promise => { + const execute = async (): Promise => { await hooks?.before?.(); const result = await op(); await hooks?.success?.(); return result; - } catch (e) { - await hooks?.error?.(e); - return null; - } finally { - await hooks?.finally?.(); - } + }; + return execute() + .catch(async (error: unknown) => { + await hooks?.error?.(error); + return null; + }) + .finally(() => hooks?.finally?.()); }; /** Run a void op through the lifecycle; resolve `true` on success, `false` on a (hooked) failure. */ @@ -113,25 +114,20 @@ export const kitSignUp = (auth: Auth, email: string, password: string, hooks?: K * App-specific cleanup (clearing stores, toasts, navigation, third-party logout) is the caller's, * done via the hooks — the kit only owns the Firebase op. `true` on success, `false` on failure. */ -export const kitSignOut = async ( - auth: Auth, - hooks?: KitAuthHooks, - options?: KitSignOutOptions, -): Promise => { - try { +export const kitSignOut = async (auth: Auth, hooks?: KitAuthHooks, options?: KitSignOutOptions): Promise => { + const execute = async (): Promise => { await hooks?.before?.(); - if (options?.expectedUser !== undefined && auth.currentUser !== options.expectedUser) { - return false; - } + if (options?.expectedUser !== undefined && auth.currentUser !== options.expectedUser) return false; await signOut(auth); await hooks?.success?.(); return true; - } catch (error) { - await hooks?.error?.(error); - return false; - } finally { - await hooks?.finally?.(); - } + }; + return execute() + .catch(async (error: unknown) => { + await hooks?.error?.(error); + return false; + }) + .finally(() => hooks?.finally?.()); }; /** Send a password-reset email. `true` on success, `false` on failure. */ @@ -258,9 +254,9 @@ export const kitAuthState = (auth: Auth): Observable => * @returns the ID token, or `null` if there is no signed-in user * @throws if the token fetch fails for a signed-in user */ -export const kitGetIdToken = (auth: Auth, forceRefresh = false): Promise => { +export const kitGetIdToken = async (auth: Auth, forceRefresh = false): Promise => { const user = auth.currentUser; - return user ? user.getIdToken(forceRefresh) : Promise.resolve(null); + return user ? user.getIdToken(forceRefresh) : null; }; /** @@ -330,11 +326,12 @@ export const kitReauthenticateThenMutate = async ( if (!user) { throw new KitReauthError('no current user'); } - try { + const reauthenticate = async (): Promise => { await reauthenticateWithCredential(user, EmailAuthProvider.credential(currentEmail, currentPassword)); - } catch (e) { - throw new KitReauthError(e); - } + }; + await reauthenticate().catch((error: unknown) => { + throw new KitReauthError(error); + }); await mutate(user); }; @@ -402,16 +399,19 @@ export const kitReauthWithRetry = async (auth: Auth, currentEmail: string, optio if (password === null) { return false; } - try { - await run(() => kitReauthenticateThenMutate(auth, currentEmail, password, options.mutate)); - return true; - } catch (e) { - if (kitIsWrongPasswordError(e)) { + const executeAttempt = async (): Promise => run(() => kitReauthenticateThenMutate(auth, currentEmail, password, options.mutate)); + const attempt = await executeAttempt().then( + () => ({ success: true as const }), + (error: unknown) => ({ success: false as const, error }), + ); + if (!attempt.success) { + if (kitIsWrongPasswordError(attempt.error)) { wrongPasswordRetry = true; continue; } - throw e instanceof KitReauthError && e.cause instanceof Error ? e.cause : e; + throw attempt.error instanceof KitReauthError && attempt.error.cause instanceof Error ? attempt.error.cause : attempt.error; } + return true; } }; diff --git a/projects/kit/live-update/src/live-update-readiness.provider.spec.ts b/projects/kit/live-update/src/live-update-readiness.provider.spec.ts index 497f34c..1fe5c63 100644 --- a/projects/kit/live-update/src/live-update-readiness.provider.spec.ts +++ b/projects/kit/live-update/src/live-update-readiness.provider.spec.ts @@ -37,7 +37,7 @@ describe('provideLiveUpdateReadiness', () => { TestBed.inject(ApplicationRef); stable.next(true); - await Promise.resolve(); + await new Promise((resolve) => queueMicrotask(resolve)); expect(ready).not.toHaveBeenCalled(); routerEvents.next(new NavigationEnd(1, '/', '/')); diff --git a/projects/kit/offline/src/lib/offline-coordinator.service.spec.ts b/projects/kit/offline/src/lib/offline-coordinator.service.spec.ts index 1a8a758..e01f1d0 100644 --- a/projects/kit/offline/src/lib/offline-coordinator.service.spec.ts +++ b/projects/kit/offline/src/lib/offline-coordinator.service.spec.ts @@ -301,6 +301,27 @@ describe('OfflineCoordinatorService', () => { expect(coordinator.isStorageReady()).toBe(false); }); + it('normalizes a synchronous repository initialization failure through onStorageUnavailable', async () => { + const cause = new Error('driver initialization threw synchronously'); + const onStorageUnavailable = vi.fn(async () => undefined); + const { coordinator, session, sync } = setup(null, { + repositoryInitialize: () => { + throw cause; + }, + onStorageUnavailable, + }); + + await expect(coordinator.initialize()).resolves.toBeUndefined(); + + const state = coordinator.storageState(); + expect(state.status).toBe('unavailable'); + if (state.status !== 'unavailable') return; + expect(state.error.cause).toBe(cause); + expect(onStorageUnavailable).toHaveBeenCalledExactlyOnceWith(state.error); + expect(session.initialize).not.toHaveBeenCalled(); + expect(sync.initialize).not.toHaveBeenCalled(); + }); + it('short-circuits repository-backed public APIs after online-only degradation', async () => { const { coordinator, session, sync } = setup(null, { repositoryInitialize: async () => { diff --git a/projects/kit/offline/src/lib/offline-coordinator.service.ts b/projects/kit/offline/src/lib/offline-coordinator.service.ts index b97e3d1..8dc1fe2 100644 --- a/projects/kit/offline/src/lib/offline-coordinator.service.ts +++ b/projects/kit/offline/src/lib/offline-coordinator.service.ts @@ -5,15 +5,14 @@ import { OfflineNetworkService } from './offline-network.service'; import { OFFLINE_REPOSITORY } from './offline-repository'; import { OfflineSessionService } from './offline-session.service'; import type { OfflineSessionManifest, OfflineSessionTransitionLease } from './offline-session.service'; -import { - OfflineStorageUnavailableError, - type OfflineStorageState, -} from './offline-storage'; +import { OfflineStorageUnavailableError, type OfflineStorageState } from './offline-storage'; import { OfflineSyncService } from './offline-sync.service'; /** User choice when logout encounters unconfirmed local mutations. */ export type OfflineLogoutAction = 'sync' | 'discard' | 'cancel'; +const settledTransition = async (): Promise => undefined; + /** Options for {@link OfflineCoordinatorService.resumeRemoteSession}. */ export interface OfflineResumeRemoteSessionOptions { readonly foregroundScopeIds?: readonly string[]; @@ -29,7 +28,7 @@ export class OfflineCoordinatorService { readonly #options = inject(OFFLINE_KIT_OPTIONS); readonly #storageState = signal({ status: 'initializing' }); #transitionRevision = 0; - #transitionTail: Promise = Promise.resolve(); + #transitionTail: Promise = settledTransition(); /** * Local storage readiness after {@link initialize}. @@ -58,17 +57,20 @@ export class OfflineCoordinatorService { */ async initialize(): Promise { const networkReady = this.#network.initialize(); - try { - await this.#repository.initialize(); - } catch (error) { - await networkReady; - const typed = this.#asStorageUnavailable(error); - this.#storageState.set({ status: 'unavailable', error: typed }); - const onUnavailable = this.#options.onStorageUnavailable; - if (!onUnavailable) throw typed; - await onUnavailable(typed); - return; - } + const initializeRepository = async (): Promise => this.#repository.initialize(); + const repositoryReady = await initializeRepository().then( + () => true, + async (error: unknown) => { + await networkReady; + const typed = this.#asStorageUnavailable(error); + this.#storageState.set({ status: 'unavailable', error: typed }); + const onUnavailable = this.#options.onStorageUnavailable; + if (!onUnavailable) throw typed; + await onUnavailable(typed); + return false; + }, + ); + if (!repositoryReady) return; await networkReady; this.#storageState.set({ status: 'ready' }); await this.#session.initialize(); @@ -81,13 +83,13 @@ export class OfflineCoordinatorService { } /** Installs a remotely verified identity without starting pull or outbox replay. */ - prepareRemoteSession( + async prepareRemoteSession( userId: OfflinePrincipalId, scopeIds: readonly string[], authSubject: string | null, authLease?: OfflineSessionTransitionLease, ): Promise { - if (this.#storageUnavailable()) return Promise.resolve(true); + if (this.#storageUnavailable()) return true; const revision = ++this.#transitionRevision; const lease = this.#lease(revision, authLease); return this.#enqueueTransition(async () => { @@ -107,8 +109,8 @@ export class OfflineCoordinatorService { /** * Activates a restored identity for local replica/outbox use without enabling transport sync. */ - activateOfflineSession(authSubject?: string | null, authLease?: OfflineSessionTransitionLease): Promise { - if (this.#storageUnavailable()) return Promise.resolve(null); + async activateOfflineSession(authSubject?: string | null, authLease?: OfflineSessionTransitionLease): Promise { + if (this.#storageUnavailable()) return null; const revision = ++this.#transitionRevision; const lease = this.#lease(revision, authLease); return this.#enqueueTransition(async () => { @@ -120,8 +122,8 @@ export class OfflineCoordinatorService { }); } - clearActiveSession(): Promise { - if (this.#storageUnavailable()) return Promise.resolve(); + async clearActiveSession(): Promise { + if (this.#storageUnavailable()) return; this.#sync.revokeSession(); this.#session.revokeAccess(); ++this.#transitionRevision; @@ -142,8 +144,8 @@ export class OfflineCoordinatorService { return this.#sync.pendingCount() === 0; } - flush(): Promise { - if (this.#storageUnavailable()) return Promise.resolve(); + async flush(): Promise { + if (this.#storageUnavailable()) return; return this.#sync.flush(); } diff --git a/projects/kit/offline/src/lib/offline-natural-key.spec.ts b/projects/kit/offline/src/lib/offline-natural-key.spec.ts index d765f7b..8a90976 100644 --- a/projects/kit/offline/src/lib/offline-natural-key.spec.ts +++ b/projects/kit/offline/src/lib/offline-natural-key.spec.ts @@ -28,16 +28,15 @@ import { naturalCommandIdentity, naturalReplicaIdentity, rematerializeTestAggreg class MemoryStorage { readonly values = new Map(); - get(key: string): Promise { - return Promise.resolve((this.values.get(key) as T | undefined) ?? null); + async get(key: string): Promise { + return (this.values.get(key) as T | undefined) ?? null; } - set(key: string, value: T): Promise { + async set(key: string, value: T): Promise { this.values.set(key, structuredClone(value)); - return Promise.resolve(value); + return value; } - remove(key: string): Promise { + async remove(key: string): Promise { this.values.delete(key); - return Promise.resolve(); } } diff --git a/projects/kit/offline/src/lib/offline-replica-mutation-coordinator.ts b/projects/kit/offline/src/lib/offline-replica-mutation-coordinator.ts index 4a1ed0f..60941df 100644 --- a/projects/kit/offline/src/lib/offline-replica-mutation-coordinator.ts +++ b/projects/kit/offline/src/lib/offline-replica-mutation-coordinator.ts @@ -20,6 +20,8 @@ import { } from './offline-repository'; import type { OfflineReplicaEntitySchema } from './offline-replica-schema'; +const settledMutation = async (): Promise => undefined; + /** * Serializes only local replica read/derive/write critical sections. Network * transport must stay outside this coordinator so synchronization never holds @@ -34,7 +36,7 @@ export class OfflineReplicaMutationCoordinator { readonly #repository = inject(OFFLINE_REPOSITORY, { optional: true }); readonly #projector = inject(OFFLINE_AGGREGATE_INTENT_PROJECTOR, { optional: true }); readonly #options = inject(OFFLINE_KIT_OPTIONS, { optional: true }); - #tail: Promise = Promise.resolve(); + #tail: Promise = settledMutation(); /** Enqueues one local replica critical section behind any in-flight mutation. */ run(operation: (repository: OfflineRepository) => Promise): Promise { diff --git a/projects/kit/offline/src/lib/offline-replica-pull.service.spec.ts b/projects/kit/offline/src/lib/offline-replica-pull.service.spec.ts index 2b51b6d..d47565a 100644 --- a/projects/kit/offline/src/lib/offline-replica-pull.service.spec.ts +++ b/projects/kit/offline/src/lib/offline-replica-pull.service.spec.ts @@ -65,19 +65,18 @@ const scope: OfflineScope = { userId: 1, scopeId: '10' }; class MemoryStorage { readonly values = new Map(); - get(key: string): Promise { - return Promise.resolve((this.values.get(key) as T | undefined) ?? null); + async get(key: string): Promise { + return (this.values.get(key) as T | undefined) ?? null; } - set(key: string, value: T): Promise { + async set(key: string, value: T): Promise { this.values.set(key, structuredClone(value)); - return Promise.resolve(value); + return value; } - remove(key: string): Promise { + async remove(key: string): Promise { this.values.delete(key); - return Promise.resolve(); } - keys(): Promise { - return Promise.resolve([...this.values.keys()]); + async keys(): Promise { + return [...this.values.keys()]; } } diff --git a/projects/kit/offline/src/lib/offline-repository.spec.ts b/projects/kit/offline/src/lib/offline-repository.spec.ts index 62dca75..bf6e5a8 100644 --- a/projects/kit/offline/src/lib/offline-repository.spec.ts +++ b/projects/kit/offline/src/lib/offline-repository.spec.ts @@ -224,19 +224,18 @@ const replicaSchemaV1HashDrift = defineOfflineReplicaSchema({ class MemoryStorage { readonly values = new Map(); - get(key: string): Promise { - return Promise.resolve((this.values.get(key) as T | undefined) ?? null); + async get(key: string): Promise { + return (this.values.get(key) as T | undefined) ?? null; } - set(key: string, value: T): Promise { + async set(key: string, value: T): Promise { this.values.set(key, structuredClone(value)); - return Promise.resolve(value); + return value; } - remove(key: string): Promise { + async remove(key: string): Promise { this.values.delete(key); - return Promise.resolve(); } - keys(): Promise { - return Promise.resolve([...this.values.keys()]); + async keys(): Promise { + return [...this.values.keys()]; } } @@ -1904,8 +1903,8 @@ describe('IonicOfflineRepository', () => { () => undefined, () => undefined, ); - await Promise.resolve(); - await Promise.resolve(); + await new Promise((resolve) => queueMicrotask(resolve)); + await new Promise((resolve) => queueMicrotask(resolve)); expect(writeFinished).toBe(false); const rowsBeforeRelease = storage.values.get('offline:replica:rows') as Record; expect(Object.values(rowsBeforeRelease)).toEqual([expect.objectContaining({ values: expect.objectContaining({ title: 'Before' }) })]); @@ -2005,8 +2004,8 @@ describe('IonicOfflineRepository', () => { await writeStarted; releaseReadyCheck(); - await Promise.resolve(); - await Promise.resolve(); + await new Promise((resolve) => queueMicrotask(resolve)); + await new Promise((resolve) => queueMicrotask(resolve)); expect(writeFinished).toBe(false); // Reader must still be waiting on the write tail — not overlapping the in-flight write. let readSettled = false; @@ -2018,7 +2017,7 @@ describe('IonicOfflineRepository', () => { readSettled = true; }, ); - await Promise.resolve(); + await new Promise((resolve) => queueMicrotask(resolve)); expect(readSettled).toBe(false); releaseWrite(); @@ -2086,7 +2085,7 @@ describe('IonicOfflineRepository', () => { }); await writeBegan; - await Promise.resolve(); + await new Promise((resolve) => queueMicrotask(resolve)); expect(storage.values.get('offline:outbox:commands')).toEqual( expect.objectContaining({ 'cmd-snap': expect.objectContaining({ state: 'pending' }), @@ -2150,13 +2149,13 @@ describe('IonicOfflineRepository', () => { () => undefined, () => undefined, ); - await Promise.resolve(); - await Promise.resolve(); + await new Promise((resolve) => queueMicrotask(resolve)); + await new Promise((resolve) => queueMicrotask(resolve)); expect(writeFinished).toBe(false); releaseA?.(); await snapshotA; - await Promise.resolve(); + await new Promise((resolve) => queueMicrotask(resolve)); expect(writeFinished).toBe(false); releaseB?.(); diff --git a/projects/kit/offline/src/lib/offline-repository.ts b/projects/kit/offline/src/lib/offline-repository.ts index ed4b67e..75cfec6 100644 --- a/projects/kit/offline/src/lib/offline-repository.ts +++ b/projects/kit/offline/src/lib/offline-repository.ts @@ -47,6 +47,8 @@ export { /** Current durable storage schema used by both web and native repositories. */ export const OFFLINE_SCHEMA_VERSION = 2; +const settledRepositoryOperation = async (): Promise => undefined; + /** User and partition scope of all local offline data. */ export interface OfflineScope { userId: OfflinePrincipalId; @@ -322,9 +324,9 @@ export class IonicOfflineRepository implements OfflineRepository { readonly #storage = inject(KitStorageService); readonly #options = inject(OFFLINE_KIT_OPTIONS); #initialization: Promise | null = null; - #writes: Promise = Promise.resolve(); + #writes: Promise = settledRepositoryOperation(); #activeReaders = 0; - #readersIdle: Promise = Promise.resolve(); + #readersIdle: Promise = settledRepositoryOperation(); #resolveReadersIdle: (() => void) | null = null; #rowIndexBuild: Promise | null = null; @@ -533,11 +535,8 @@ export class IonicOfflineRepository implements OfflineRepository { await this.#ensureRowPartitionsReady(); await this.#writes; this.#beginReaders(); - try { - return await operation(); - } finally { - this.#endReaders(); - } + const read = async (): Promise => operation(); + return read().finally(() => this.#endReaders()); } #beginReaders(): void { @@ -554,7 +553,7 @@ export class IonicOfflineRepository implements OfflineRepository { if (this.#activeReaders === 0) { this.#resolveReadersIdle?.(); this.#resolveReadersIdle = null; - this.#readersIdle = Promise.resolve(); + this.#readersIdle = settledRepositoryOperation(); } } @@ -736,7 +735,7 @@ export class IonicOfflineRepository implements OfflineRepository { targetHash, }); - try { + const commitMigration = async (): Promise => { const transformedRows: Record = {}; for (const row of Object.values(rows)) { let current: OfflineReplicaWebMigrationRow | null = this.#toWebMigrationRow(row); @@ -781,7 +780,8 @@ export class IonicOfflineRepository implements OfflineRepository { replicaSchemaHash: targetHash, }); await this.#storage.remove(REPLICA_SCHEMA_MIGRATION_KEY); - } catch (error) { + }; + await commitMigration().catch(async (error: unknown) => { await this.#recoverReplicaSchemaMigration({ originalRows, fromVersion, @@ -790,7 +790,7 @@ export class IonicOfflineRepository implements OfflineRepository { targetHash, }); throw error; - } + }); }); } diff --git a/projects/kit/offline/src/lib/offline-sync.service.spec.ts b/projects/kit/offline/src/lib/offline-sync.service.spec.ts index a22a985..1979027 100644 --- a/projects/kit/offline/src/lib/offline-sync.service.spec.ts +++ b/projects/kit/offline/src/lib/offline-sync.service.spec.ts @@ -354,6 +354,7 @@ describe('OfflineSyncService', () => { service.revokeSession(); vi.clearAllTimers(); vi.useRealTimers(); + vi.restoreAllMocks(); }); it('readCacheOnly mode rejects enqueue before creating replica or Outbox state', async () => { @@ -541,7 +542,7 @@ describe('OfflineSyncService', () => { await applying; const discard = service.discardAllPending(); - await Promise.resolve(); + await new Promise((resolve) => queueMicrotask(resolve)); expect(commands).toHaveLength(2); release(); @@ -1666,56 +1667,52 @@ describe('OfflineSyncService', () => { ['HTTP 409', () => ({ status: 409, message: 'Conflict' })], ] as const)('pre-pull fatal (%s) は1s自動retryせずpending post-pullもスキップする', async (_label, createError) => { vi.useFakeTimers(); - try { - session = { - userId: 1, - scopes: [ - { userId: 1, scopeId: '10' }, - { userId: 1, scopeId: '20' }, - ], - }; - let scope20Pulls = 0; - const postPullError = new Error('scope 20 post-send pull failed before fatal'); - const fatalError = createError(); - pull.mockImplementation(async (scope) => { - if (scope.scopeId === '20') { - scope20Pulls += 1; - // First full flush: pre-pull ok, post-send pull fails and leaves pending marker. - if (scope20Pulls === 2) throw postPullError; - } - }); - await service.enqueue( - { - scopeId: '20', - aggregateType: 'documents', - identity: { kind: 'generated', localId: `fatal-skip-post-${_label.replace(/\s+/g, '-')}` }, - operation: 'documents.create', - payload: { title: 'seed' }, - }, - { flush: false }, - ); - connected.set(true); - await expect(service.flush()).rejects.toBe(postPullError); - expect(execute).toHaveBeenCalledOnce(); - expectAwaitingPull(1); - // Drop the transient post-pull retry so this case only asserts fatal does not arm a new one. - vi.clearAllTimers(); + session = { + userId: 1, + scopes: [ + { userId: 1, scopeId: '10' }, + { userId: 1, scopeId: '20' }, + ], + }; + let scope20Pulls = 0; + const postPullError = new Error('scope 20 post-send pull failed before fatal'); + const fatalError = createError(); + pull.mockImplementation(async (scope) => { + if (scope.scopeId === '20') { + scope20Pulls += 1; + // First full flush: pre-pull ok, post-send pull fails and leaves pending marker. + if (scope20Pulls === 2) throw postPullError; + } + }); + await service.enqueue( + { + scopeId: '20', + aggregateType: 'documents', + identity: { kind: 'generated', localId: `fatal-skip-post-${_label.replace(/\s+/g, '-')}` }, + operation: 'documents.create', + payload: { title: 'seed' }, + }, + { flush: false }, + ); + connected.set(true); + await expect(service.flush()).rejects.toBe(postPullError); + expect(execute).toHaveBeenCalledOnce(); + expectAwaitingPull(1); + // Drop the transient post-pull retry so this case only asserts fatal does not arm a new one. + vi.clearAllTimers(); - pull.mockImplementation(async (scope) => { - if (scope.scopeId === '10') throw fatalError; - }); - const pullsBeforeFatal = pull.mock.calls.length; - await expect(service.flush()).rejects.toBe(fatalError); - expect(execute).toHaveBeenCalledOnce(); - expect(pull.mock.calls.slice(pullsBeforeFatal).map((call) => call[0]?.scopeId)).toEqual(['10']); - - const pullsAfterFatal = pull.mock.calls.length; - await vi.advanceTimersByTimeAsync(2_000); - expect(pull.mock.calls.length).toBe(pullsAfterFatal); - expect(handleError).not.toHaveBeenCalled(); - } finally { - vi.useRealTimers(); - } + pull.mockImplementation(async (scope) => { + if (scope.scopeId === '10') throw fatalError; + }); + const pullsBeforeFatal = pull.mock.calls.length; + await expect(service.flush()).rejects.toBe(fatalError); + expect(execute).toHaveBeenCalledOnce(); + expect(pull.mock.calls.slice(pullsBeforeFatal).map((call) => call[0]?.scopeId)).toEqual(['10']); + + const pullsAfterFatal = pull.mock.calls.length; + await vi.advanceTimersByTimeAsync(2_000); + expect(pull.mock.calls.length).toBe(pullsAfterFatal); + expect(handleError).not.toHaveBeenCalled(); }); it.each([ @@ -1726,266 +1723,248 @@ describe('OfflineSyncService', () => { ] as const)('post-send pull fatal (%s) は残りpending post-pullを止めACK保持・1s自動retryなし', async (_label, createError) => { vi.useFakeTimers(); const setTimeoutSpy = vi.spyOn(globalThis, 'setTimeout'); - try { - session = { - userId: 1, - scopes: [ - { userId: 1, scopeId: '10' }, - { userId: 1, scopeId: '20' }, - ], - }; - const fatalError = createError(); - const pullsByScope = new Map(); - pull.mockImplementation(async (scope) => { - const count = (pullsByScope.get(scope.scopeId) ?? 0) + 1; - pullsByScope.set(scope.scopeId, count); - // Second pull for a scope is the post-send pull after ACK. - if (count >= 2) throw fatalError; - }); - await service.enqueue( - { - scopeId: '10', - aggregateType: 'documents', - identity: { kind: 'generated', localId: `post-fatal-a-${_label.replace(/\s+/g, '-')}` }, - operation: 'documents.create', - payload: { title: 'a' }, - }, - { flush: false }, - ); + session = { + userId: 1, + scopes: [ + { userId: 1, scopeId: '10' }, + { userId: 1, scopeId: '20' }, + ], + }; + const fatalError = createError(); + const pullsByScope = new Map(); + pull.mockImplementation(async (scope) => { + const count = (pullsByScope.get(scope.scopeId) ?? 0) + 1; + pullsByScope.set(scope.scopeId, count); + // Second pull for a scope is the post-send pull after ACK. + if (count >= 2) throw fatalError; + }); + await service.enqueue( + { + scopeId: '10', + aggregateType: 'documents', + identity: { kind: 'generated', localId: `post-fatal-a-${_label.replace(/\s+/g, '-')}` }, + operation: 'documents.create', + payload: { title: 'a' }, + }, + { flush: false }, + ); + await service.enqueue( + { + scopeId: '20', + aggregateType: 'documents', + identity: { kind: 'generated', localId: `post-fatal-b-${_label.replace(/\s+/g, '-')}` }, + operation: 'documents.create', + payload: { title: 'b' }, + }, + { flush: false }, + ); + + setTimeoutSpy.mockClear(); + connected.set(true); + await expect(service.flush()).rejects.toBe(fatalError); + + // Both commands transported; retained until pull acknowledges commandId. + expect(execute).toHaveBeenCalledTimes(2); + expectAwaitingPull(2); + // Two pending post-pull scopes: first fatal stops the second immediately. + expect(pullsByScope.get('10')).toBeGreaterThanOrEqual(1); + expect(pullsByScope.get('20')).toBeGreaterThanOrEqual(1); + const postPullScopes = [...pullsByScope.entries()].filter(([, count]) => count >= 2).map(([scopeId]) => scopeId); + expect(postPullScopes).toHaveLength(1); + expect([...pullsByScope.values()].reduce((sum, count) => sum + count, 0)).toBe(3); + expect(new Set(commands.map((command) => command.scopeId))).toEqual(new Set(['10', '20'])); + // Fatal must not arm the 1s automatic post-pull flush retry. + expect(setTimeoutSpy.mock.calls.some(([, delay]) => delay === 1_000)).toBe(false); + + const pullsAfterFatal = pull.mock.calls.length; + // Drop unrelated scheduler/effect timers, then prove no 1s retry remained. + vi.clearAllTimers(); + await vi.advanceTimersByTimeAsync(2_000); + expect(pull.mock.calls.length).toBe(pullsAfterFatal); + expect(execute).toHaveBeenCalledTimes(2); + }); + + it('post-send pull transientの後のfatalはfatalを優先して投げ残りpendingを止める', async () => { + vi.useFakeTimers(); + const setTimeoutSpy = vi.spyOn(globalThis, 'setTimeout'); + session = { + userId: 1, + scopes: [ + { userId: 1, scopeId: '10' }, + { userId: 1, scopeId: '20' }, + { userId: 1, scopeId: '30' }, + ], + }; + const transient = new Error('post-send transient'); + const fatal = new OfflineReplicaSchemaMismatchError(1, 'abc', 2, 'def'); + const pullsByScope = new Map(); + let postPullAttempts = 0; + pull.mockImplementation(async (scope) => { + const count = (pullsByScope.get(scope.scopeId) ?? 0) + 1; + pullsByScope.set(scope.scopeId, count); + if (count < 2) return; + postPullAttempts += 1; + if (postPullAttempts === 1) throw transient; + throw fatal; + }); + for (const [scopeId, localId] of [ + ['10', 'post-prefer-a'], + ['20', 'post-prefer-b'], + ['30', 'post-prefer-c'], + ] as const) { await service.enqueue( { - scopeId: '20', + scopeId, aggregateType: 'documents', - identity: { kind: 'generated', localId: `post-fatal-b-${_label.replace(/\s+/g, '-')}` }, + identity: { kind: 'generated', localId }, operation: 'documents.create', - payload: { title: 'b' }, + payload: { title: localId }, }, { flush: false }, ); - - setTimeoutSpy.mockClear(); - connected.set(true); - await expect(service.flush()).rejects.toBe(fatalError); - - // Both commands transported; retained until pull acknowledges commandId. - expect(execute).toHaveBeenCalledTimes(2); - expectAwaitingPull(2); - // Two pending post-pull scopes: first fatal stops the second immediately. - expect(pullsByScope.get('10')).toBeGreaterThanOrEqual(1); - expect(pullsByScope.get('20')).toBeGreaterThanOrEqual(1); - const postPullScopes = [...pullsByScope.entries()].filter(([, count]) => count >= 2).map(([scopeId]) => scopeId); - expect(postPullScopes).toHaveLength(1); - expect([...pullsByScope.values()].reduce((sum, count) => sum + count, 0)).toBe(3); - expect(new Set(commands.map((command) => command.scopeId))).toEqual(new Set(['10', '20'])); - // Fatal must not arm the 1s automatic post-pull flush retry. - expect(setTimeoutSpy.mock.calls.some(([, delay]) => delay === 1_000)).toBe(false); - - const pullsAfterFatal = pull.mock.calls.length; - // Drop unrelated scheduler/effect timers, then prove no 1s retry remained. - vi.clearAllTimers(); - await vi.advanceTimersByTimeAsync(2_000); - expect(pull.mock.calls.length).toBe(pullsAfterFatal); - expect(execute).toHaveBeenCalledTimes(2); - } finally { - setTimeoutSpy.mockRestore(); - vi.useRealTimers(); } - }); - - it('post-send pull transientの後のfatalはfatalを優先して投げ残りpendingを止める', async () => { - vi.useFakeTimers(); - const setTimeoutSpy = vi.spyOn(globalThis, 'setTimeout'); - try { - session = { - userId: 1, - scopes: [ - { userId: 1, scopeId: '10' }, - { userId: 1, scopeId: '20' }, - { userId: 1, scopeId: '30' }, - ], - }; - const transient = new Error('post-send transient'); - const fatal = new OfflineReplicaSchemaMismatchError(1, 'abc', 2, 'def'); - const pullsByScope = new Map(); - let postPullAttempts = 0; - pull.mockImplementation(async (scope) => { - const count = (pullsByScope.get(scope.scopeId) ?? 0) + 1; - pullsByScope.set(scope.scopeId, count); - if (count < 2) return; - postPullAttempts += 1; - if (postPullAttempts === 1) throw transient; - throw fatal; - }); - for (const [scopeId, localId] of [ - ['10', 'post-prefer-a'], - ['20', 'post-prefer-b'], - ['30', 'post-prefer-c'], - ] as const) { - await service.enqueue( - { - scopeId, - aggregateType: 'documents', - identity: { kind: 'generated', localId }, - operation: 'documents.create', - payload: { title: localId }, - }, - { flush: false }, - ); - } - setTimeoutSpy.mockClear(); - connected.set(true); - await expect(service.flush()).rejects.toBe(fatal); - expect(execute).toHaveBeenCalledTimes(3); - expectAwaitingPull(3); - expect(postPullAttempts).toBe(2); - expect([...pullsByScope.values()].filter((count) => count >= 2)).toHaveLength(2); - expect([...pullsByScope.values()].filter((count) => count === 1)).toHaveLength(1); - expect(new Set(commands.map((command) => command.scopeId))).toEqual(new Set(['10', '20', '30'])); - expect(setTimeoutSpy.mock.calls.some(([, delay]) => delay === 1_000)).toBe(false); - - const pullsAfterFatal = pull.mock.calls.length; - vi.clearAllTimers(); - await vi.advanceTimersByTimeAsync(2_000); - expect(pull.mock.calls.length).toBe(pullsAfterFatal); - } finally { - setTimeoutSpy.mockRestore(); - vi.useRealTimers(); - } + setTimeoutSpy.mockClear(); + connected.set(true); + await expect(service.flush()).rejects.toBe(fatal); + expect(execute).toHaveBeenCalledTimes(3); + expectAwaitingPull(3); + expect(postPullAttempts).toBe(2); + expect([...pullsByScope.values()].filter((count) => count >= 2)).toHaveLength(2); + expect([...pullsByScope.values()].filter((count) => count === 1)).toHaveLength(1); + expect(new Set(commands.map((command) => command.scopeId))).toEqual(new Set(['10', '20', '30'])); + expect(setTimeoutSpy.mock.calls.some(([, delay]) => delay === 1_000)).toBe(false); + + const pullsAfterFatal = pull.mock.calls.length; + vi.clearAllTimers(); + await vi.advanceTimersByTimeAsync(2_000); + expect(pull.mock.calls.length).toBe(pullsAfterFatal); }); it('遅延した旧世代のpost-pull fatalは新世代のretry_wait timerを消さない', async () => { vi.useFakeTimers(); - try { - let releasePostPull!: (error: unknown) => void; - let postPullStarted!: () => void; - const postPullEntered = new Promise((resolve) => { - postPullStarted = resolve; - }); - const postPullGate = new Promise((_resolve, reject) => { - releasePostPull = (error) => reject(error); - }); - // Avoid unhandled rejection if the gate is abandoned mid-test. - void postPullGate.catch(() => undefined); + let releasePostPull!: (error: unknown) => void; + let postPullStarted!: () => void; + const postPullEntered = new Promise((resolve) => { + postPullStarted = resolve; + }); + const postPullGate = new Promise((_resolve, reject) => { + releasePostPull = (error) => reject(error); + }); + // Avoid unhandled rejection if the gate is abandoned mid-test. + void postPullGate.catch(() => undefined); - const pullsByScope = new Map(); - pull.mockImplementation(async (scope) => { - const count = (pullsByScope.get(`${scope.userId}:${scope.scopeId}`) ?? 0) + 1; - pullsByScope.set(`${scope.userId}:${scope.scopeId}`, count); - // Session A post-send pull stays pending until we release the fatal. - if (scope.userId === 1 && scope.scopeId === '10' && count >= 2) { - postPullStarted(); - await postPullGate; - } - }); + const pullsByScope = new Map(); + pull.mockImplementation(async (scope) => { + const count = (pullsByScope.get(`${scope.userId}:${scope.scopeId}`) ?? 0) + 1; + pullsByScope.set(`${scope.userId}:${scope.scopeId}`, count); + // Session A post-send pull stays pending until we release the fatal. + if (scope.userId === 1 && scope.scopeId === '10' && count >= 2) { + postPullStarted(); + await postPullGate; + } + }); - await service.enqueue( - { - scopeId: '10', - aggregateType: 'documents', - identity: { kind: 'generated', localId: 'stale-fatal-a' }, - operation: 'documents.create', - payload: { title: 'a' }, - }, - { flush: false }, - ); - connected.set(true); - const flushA = service.flush(); - await postPullEntered; + await service.enqueue( + { + scopeId: '10', + aggregateType: 'documents', + identity: { kind: 'generated', localId: 'stale-fatal-a' }, + operation: 'documents.create', + payload: { title: 'a' }, + }, + { flush: false }, + ); + connected.set(true); + const flushA = service.flush(); + await postPullEntered; - // Transition generation without waiting for A's deferred post-pull to settle. - service.revokeSession(); - commands = commands.filter((command) => command.userId !== 1); - rows = rows.filter((row) => row.userId !== 1); - session = { userId: 2, scopes: [{ userId: 2, scopeId: '20' }] }; - // Stay offline during session B activation so refreshSession does not start a - // background flush that would swallow the subsequent explicit flush(). - connected.set(false); - await service.refreshSession(); + // Transition generation without waiting for A's deferred post-pull to settle. + service.revokeSession(); + commands = commands.filter((command) => command.userId !== 1); + rows = rows.filter((row) => row.userId !== 1); + session = { userId: 2, scopes: [{ userId: 2, scopeId: '20' }] }; + // Stay offline during session B activation so refreshSession does not start a + // background flush that would swallow the subsequent explicit flush(). + connected.set(false); + await service.refreshSession(); - execute.mockRejectedValueOnce({ status: 500 }).mockResolvedValue({ response: null }); - await service.enqueue( - { - scopeId: '20', - aggregateType: 'documents', - identity: { kind: 'generated', localId: 'stale-fatal-b' }, - operation: 'documents.create', - payload: { title: 'b' }, - }, - { flush: false }, - ); - connected.set(true); - await service.flush(); - expect(service.pendingCommands()[0]).toMatchObject({ - state: 'retry_wait', - retryAt: expect.any(Number), + execute.mockRejectedValueOnce({ status: 500 }).mockResolvedValue({ response: null }); + await service.enqueue( + { + scopeId: '20', + aggregateType: 'documents', identity: { kind: 'generated', localId: 'stale-fatal-b' }, - }); - const executesBeforeRetry = execute.mock.calls.length; + operation: 'documents.create', + payload: { title: 'b' }, + }, + { flush: false }, + ); + connected.set(true); + await service.flush(); + expect(service.pendingCommands()[0]).toMatchObject({ + state: 'retry_wait', + retryAt: expect.any(Number), + identity: { kind: 'generated', localId: 'stale-fatal-b' }, + }); + const executesBeforeRetry = execute.mock.calls.length; - const fatal = new OfflineReplicaSchemaMismatchError(1, 'abc', 2, 'def'); - releasePostPull(fatal); - await expect(flushA).rejects.toBe(fatal); - // Stale fatal must settle/reject without clearing B's armed retry timer. - expect(service.pendingCommands()[0]).toMatchObject({ - state: 'retry_wait', - identity: { kind: 'generated', localId: 'stale-fatal-b' }, - }); + const fatal = new OfflineReplicaSchemaMismatchError(1, 'abc', 2, 'def'); + releasePostPull(fatal); + await expect(flushA).rejects.toBe(fatal); + // Stale fatal must settle/reject without clearing B's armed retry timer. + expect(service.pendingCommands()[0]).toMatchObject({ + state: 'retry_wait', + identity: { kind: 'generated', localId: 'stale-fatal-b' }, + }); - await vi.advanceTimersByTimeAsync(2_000); - await Promise.resolve(); - await Promise.resolve(); - expect(execute.mock.calls.length).toBeGreaterThan(executesBeforeRetry); - expect( - commands.some( - (command) => - command.identity.kind === 'generated' && command.identity.localId === 'stale-fatal-b' && command.state !== 'awaiting_pull', - ), - ).toBe(false); - } finally { - vi.useRealTimers(); - } + await vi.advanceTimersByTimeAsync(2_000); + await new Promise((resolve) => queueMicrotask(resolve)); + await new Promise((resolve) => queueMicrotask(resolve)); + expect(execute.mock.calls.length).toBeGreaterThan(executesBeforeRetry); + expect( + commands.some( + (command) => + command.identity.kind === 'generated' && command.identity.localId === 'stale-fatal-b' && command.state !== 'awaiting_pull', + ), + ).toBe(false); }); it('pre-pull transient失敗はscope隔離し1s自動retryをスケジュールする', async () => { vi.useFakeTimers(); - try { - session = { - userId: 1, - scopes: [ - { userId: 1, scopeId: '10' }, - { userId: 1, scopeId: '20' }, - ], - }; - const transient = new Error('scope 10 transient pre-pull'); - let scope10Pulls = 0; - pull.mockImplementation(async (scope) => { - if (scope.scopeId === '10' && ++scope10Pulls === 1) throw transient; - }); - await service.enqueue( - { - scopeId: '20', - aggregateType: 'documents', - identity: { kind: 'generated', localId: 'transient-isolated' }, - operation: 'documents.create', - payload: { title: 'b' }, - }, - { flush: false }, - ); + session = { + userId: 1, + scopes: [ + { userId: 1, scopeId: '10' }, + { userId: 1, scopeId: '20' }, + ], + }; + const transient = new Error('scope 10 transient pre-pull'); + let scope10Pulls = 0; + pull.mockImplementation(async (scope) => { + if (scope.scopeId === '10' && ++scope10Pulls === 1) throw transient; + }); + await service.enqueue( + { + scopeId: '20', + aggregateType: 'documents', + identity: { kind: 'generated', localId: 'transient-isolated' }, + operation: 'documents.create', + payload: { title: 'b' }, + }, + { flush: false }, + ); - connected.set(true); - await expect(service.flush()).rejects.toBe(transient); - expect(execute).toHaveBeenCalledOnce(); - expect(pull.mock.calls.map((call) => call[0]?.scopeId)).toEqual(expect.arrayContaining(['10', '20'])); - - const pullsBeforeRetry = pull.mock.calls.length; - await vi.advanceTimersByTimeAsync(1_000); - await Promise.resolve(); - await Promise.resolve(); - expect(pull.mock.calls.length).toBeGreaterThan(pullsBeforeRetry); - } finally { - vi.useRealTimers(); - } + connected.set(true); + await expect(service.flush()).rejects.toBe(transient); + expect(execute).toHaveBeenCalledOnce(); + expect(pull.mock.calls.map((call) => call[0]?.scopeId)).toEqual(expect.arrayContaining(['10', '20'])); + + const pullsBeforeRetry = pull.mock.calls.length; + await vi.advanceTimersByTimeAsync(1_000); + await new Promise((resolve) => queueMicrotask(resolve)); + await new Promise((resolve) => queueMicrotask(resolve)); + expect(pull.mock.calls.length).toBeGreaterThan(pullsBeforeRetry); }); it('pre-pull transientの後のfatalはfatalを優先して投げ残りscopeを止める', async () => { @@ -3312,6 +3291,28 @@ describe('OfflineSyncService', () => { await expect(service.flush()).rejects.toThrow('pull failed'); }); + it('background error reporting preserves the microtask boundary and absorbs reporter rejection', async () => { + const events: string[] = []; + const pullError = new Error('background pull failed'); + handleError.mockImplementation((error) => { + events.push(`reported:${(error as Error).message}`); + return Promise.reject(new Error('reporter failed')) as never; + }); + pull.mockRejectedValue(pullError); + + connected.set(true); + const initialization = service.initialize(); + events.push('initialize-returned'); + expect(handleError).not.toHaveBeenCalled(); + + await initialization; + await vi.waitFor(() => expect(handleError).toHaveBeenCalledWith(pullError)); + await new Promise((resolve) => queueMicrotask(resolve)); + expect(events[0]).toBe('initialize-returned'); + expect(events.slice(1)).not.toHaveLength(0); + expect(events.slice(1).every((event) => event === 'reported:background pull failed')).toBe(true); + }); + it('sending書き込み中の同一session resetでも完了後にpendingへ復旧する', async () => { let notifySendingStarted!: () => void; const sendingStarted = new Promise((resolve) => (notifySendingStarted = resolve)); @@ -3481,7 +3482,7 @@ describe('OfflineSyncService', () => { ); expect(current?.state).toBe('sending'); }); - await Promise.resolve(); + await new Promise((resolve) => queueMicrotask(resolve)); expect(cancellationEntered).toBe(false); releaseSending(); @@ -4346,24 +4347,21 @@ describe('OfflineSyncService', () => { it('tombstone read APIを持たないcustom repositoryではdelete enqueueを明示rejectする', async () => { const repository = TestBed.inject(OFFLINE_REPOSITORY) as OfflineRepository & { getReplicaRowIncludingPendingDelete?: unknown }; const getReplicaRowIncludingPendingDelete = repository.getReplicaRowIncludingPendingDelete; - try { - delete repository.getReplicaRowIncludingPendingDelete; - await expect( - service.enqueue( - { - scopeId: '10', - aggregateType: 'documents', - identity: { kind: 'generated', localId: 'missing-tombstone-api' }, - operation: 'documents.delete', - payload: {}, - replicaMutation: 'delete', - }, - { flush: false }, - ), - ).rejects.toThrow('Offline repository does not support durable replica delete tombstones.'); - } finally { - repository.getReplicaRowIncludingPendingDelete = getReplicaRowIncludingPendingDelete; - } + delete repository.getReplicaRowIncludingPendingDelete; + await expect( + service.enqueue( + { + scopeId: '10', + aggregateType: 'documents', + identity: { kind: 'generated', localId: 'missing-tombstone-api' }, + operation: 'documents.delete', + payload: {}, + replicaMutation: 'delete', + }, + { flush: false }, + ), + ).rejects.toThrow('Offline repository does not support durable replica delete tombstones.'); + repository.getReplicaRowIncludingPendingDelete = getReplicaRowIncludingPendingDelete; expect(commands).toEqual([]); expect(rows).toEqual([]); }); diff --git a/projects/kit/offline/src/lib/offline-sync.service.ts b/projects/kit/offline/src/lib/offline-sync.service.ts index e998f7c..1c403a0 100644 --- a/projects/kit/offline/src/lib/offline-sync.service.ts +++ b/projects/kit/offline/src/lib/offline-sync.service.ts @@ -865,7 +865,7 @@ export class OfflineSyncService { } #flushInBackground(): void { - void this.#beginFlush(false).catch((error) => this.#errorHandler.handleError(error)); + void this.#beginFlush(false).catch((error) => this.#reportError(error)); } flush(): Promise { @@ -927,16 +927,21 @@ export class OfflineSyncService { const pulledScopeKeys = new Set(); for (const scope of pullScopes) { if (!this.#isCurrent(generation) || !this.#network.connected()) return; - try { + const pullScope = async (): Promise => { await this.#pull.pull(scope); await this.#markScopeReconciled(scope, generation); pulledScopeKeys.add(this.#scopeKey(scope)); - } catch (error) { - prePullFailures.push(error); - if (this.#isFatalPullFailure(error)) { + }; + const pull = await pullScope().then( + () => ({ status: 'fulfilled' as const }), + (error: unknown) => ({ status: 'rejected' as const, error }), + ); + if (pull.status === 'rejected') { + prePullFailures.push(pull.error); + if (this.#isFatalPullFailure(pull.error)) { // Auth/upgrade-driven recovery only: stop remaining scopes immediately. - fatalPullFailure = fatalPullFailure ?? error; - await this.#persistFatalPullAttentions(error, scope, pullScopes, generation); + fatalPullFailure = fatalPullFailure ?? pull.error; + await this.#persistFatalPullAttentions(pull.error, scope, pullScopes, generation); break; } if (this.#pendingPullScopes.has(this.#scopeKey(scope))) { @@ -986,19 +991,23 @@ export class OfflineSyncService { const postPullScopes = [...this.#pendingPullScopes.values()]; for (const scope of postPullScopes) { if (!this.#isCurrent(generation) || !this.#network.connected()) break; - try { - // A command response may contain only the aggregate's base row. Pull - // once per dirty scope so sibling-table journal entries are visible - // before the completed Outbox state is exposed to product UI. + // A command response may contain only the aggregate's base row. Pull once per dirty scope so + // sibling-table journal entries are visible before completed Outbox state reaches product UI. + const pullScope = async (): Promise => { await this.#pull.pull(scope); await this.#markScopeReconciled(scope, generation); - } catch (error) { - if (this.#isFatalPullFailure(error)) { - fatalPullFailure = fatalPullFailure ?? error; - await this.#persistFatalPullAttentions(error, scope, postPullScopes, generation); + }; + const pull = await pullScope().then( + () => ({ status: 'fulfilled' as const }), + (error: unknown) => ({ status: 'rejected' as const, error }), + ); + if (pull.status === 'rejected') { + if (this.#isFatalPullFailure(pull.error)) { + fatalPullFailure = fatalPullFailure ?? pull.error; + await this.#persistFatalPullAttentions(pull.error, scope, postPullScopes, generation); break; } - postPullFailures.push(error); + postPullFailures.push(pull.error); } } } @@ -1131,14 +1140,18 @@ export class OfflineSyncService { if (!this.#isCurrent(generation)) return; await this.#refreshState(generation); if (!this.#isCurrent(generation)) return; - let row: OfflineReplicaRow | null; - try { - row = await this.#rowForCommand(sending); - } catch (error) { + const claimedCommand = sending; + const readRow = async (): Promise => this.#rowForCommand(claimedCommand); + const rowResult = await readRow().then( + (row) => ({ status: 'fulfilled' as const, row }), + (error: unknown) => ({ status: 'rejected' as const, error }), + ); + if (rowResult.status === 'rejected') { if (!this.#isCurrent(generation)) return; - await this.#persistFailedCommand(sending, error, generation, null, sending.serverCommitUnknown === true); - throw error; + await this.#persistFailedCommand(sending, rowResult.error, generation, null, sending.serverCommitUnknown === true); + throw rowResult.error; } + const row = rowResult.row; if (!row) { const error = new Error( `Offline replica row not found: ${sending.aggregateType}/${canonicalOfflineCommandIdentity(sending.identity)}`, @@ -1150,25 +1163,32 @@ export class OfflineSyncService { const transportCommand = await this.#markTransportStarted(sending, generation); if (!transportCommand) return; sending = transportCommand; - let result: OfflineCommandResult; - try { - result = await this.#executor.execute(sending, offlineCommandTargetFromReplicaRow(row)); - } catch (error) { + const executeCommand = async (): Promise => + this.#executor.execute(sending, offlineCommandTargetFromReplicaRow(row)); + const execution = await executeCommand().then( + (result) => ({ status: 'fulfilled' as const, result }), + (error: unknown) => ({ status: 'rejected' as const, error }), + ); + if (execution.status === 'rejected') { if (!this.#isCurrent(generation)) return; - const commitUnknown = this.#executor.provesCommandNotCommitted?.(error, sending) + const commitUnknown = this.#executor.provesCommandNotCommitted?.(execution.error, sending) ? false - : priorCommitUnknown || this.#serverCommitCouldBeUnknown(error); - await this.#persistFailedCommand(sending, error, generation, row, commitUnknown); - if (!this.#isClassifiableTransportError(error)) throw error; + : priorCommitUnknown || this.#serverCommitCouldBeUnknown(execution.error); + await this.#persistFailedCommand(sending, execution.error, generation, row, commitUnknown); + if (!this.#isClassifiableTransportError(execution.error)) throw execution.error; break; } + const result = execution.result; if (!this.#isCurrent(generation)) return; - try { - await this.#completeCommand(commands, sending, result, generation); - } catch (error) { + const completeCommand = async (): Promise => this.#completeCommand(commands, sending, result, generation); + const completion = await completeCommand().then( + () => ({ status: 'fulfilled' as const }), + (error: unknown) => ({ status: 'rejected' as const, error }), + ); + if (completion.status === 'rejected') { if (!this.#isCurrent(generation)) return; - await this.#persistFailedCommand(sending, error, generation, row, true); - throw error; + await this.#persistFailedCommand(sending, completion.error, generation, row, true); + throw completion.error; } if (this.#isCurrent(generation)) { const scope = { userId: sending.userId, scopeId: sending.scopeId }; @@ -1466,9 +1486,19 @@ export class OfflineSyncService { } #reportError(error: unknown): void { - void Promise.resolve() - .then(() => this.#errorHandler.handleError(error)) - .catch(() => undefined); + queueMicrotask(() => this.#dispatchError(error)); + } + + #dispatchError(error: unknown): void { + let result: unknown; + try { + result = (this.#errorHandler.handleError as (reportedError: unknown) => unknown)(error); + } catch { + return; + } + if (result instanceof Promise) { + void result.catch(() => undefined); + } } #assertServerRevision(revision: string | number | undefined): void { diff --git a/projects/kit/offline/src/lib/offline.interceptor.spec.ts b/projects/kit/offline/src/lib/offline.interceptor.spec.ts index c844061..556efe6 100644 --- a/projects/kit/offline/src/lib/offline.interceptor.spec.ts +++ b/projects/kit/offline/src/lib/offline.interceptor.spec.ts @@ -409,7 +409,7 @@ describe('offlineInterceptor', () => { expect(transportUnsubscribed).toBe(true); resolveLocal(new HttpResponse({ body: { value: 'cached' }, status: 200 })); - await Promise.resolve(); + await new Promise((resolve) => queueMicrotask(resolve)); expect(handleError).not.toHaveBeenCalled(); }); diff --git a/projects/kit/offline/src/lib/sqlite-offline-repository.spec.ts b/projects/kit/offline/src/lib/sqlite-offline-repository.spec.ts index 33fd1d9..1018fd2 100644 --- a/projects/kit/offline/src/lib/sqlite-offline-repository.spec.ts +++ b/projects/kit/offline/src/lib/sqlite-offline-repository.spec.ts @@ -482,7 +482,7 @@ describe('SqliteOfflineRepository community sqlite driver', () => { }); let ownerCommitted = false; let externalCommitted = false; - let external: Promise = Promise.resolve(); + let external: Promise = new Promise((resolve) => queueMicrotask(resolve)); const atomic = repository[OFFLINE_REPOSITORY_ATOMIC_MUTATION]!(async (owner) => { external = repository @@ -789,6 +789,25 @@ describe('SqliteOfflineRepository community sqlite driver', () => { expect(plugin.commitTransaction).not.toHaveBeenCalled(); }); + it('rollbackも失敗した場合は元の書き込み失敗とrollback失敗を両方公開する', async () => { + const writeError = new Error('disk full'); + const rollbackError = new Error('rollback failed'); + const repository = createRepository(); + await repository.initialize(); + plugin.execute.mockRejectedValueOnce(writeError); + plugin.rollbackTransaction.mockRejectedValueOnce(rollbackError); + + const failure = await repository.clearUser(7).then( + () => undefined, + (error: unknown) => error, + ); + + expect(failure).toBeInstanceOf(AggregateError); + expect((failure as AggregateError).errors).toEqual([writeError, rollbackError]); + expect((failure as AggregateError).cause).toBe(writeError); + expect(plugin.commitTransaction).not.toHaveBeenCalled(); + }); + describe('offline replica schema initialization', () => { it('first install creates product tables and stores metadata in one transaction', async () => { storedReplicaMetadata = null; @@ -1789,8 +1808,8 @@ describe('SqliteOfflineRepository replica rows', () => { () => undefined, () => undefined, ); - await Promise.resolve(); - await Promise.resolve(); + await new Promise((resolve) => queueMicrotask(resolve)); + await new Promise((resolve) => queueMicrotask(resolve)); expect(writeFinished).toBe(false); expect( plugin.execute.mock.calls.some(([options]) => @@ -2210,7 +2229,7 @@ describe('SqliteOfflineRepository replica rows', () => { const snapshotGate = new Promise((resolve) => { releaseSnapshot = resolve; }); - let write: Promise = Promise.resolve(); + let write: Promise = new Promise((resolve) => queueMicrotask(resolve)); const snapshot = repository.runReadSnapshot(async (reader) => { await reader.getCommands({ userId: 1, scopeId: '10' }); @@ -2234,8 +2253,8 @@ describe('SqliteOfflineRepository replica rows', () => { () => undefined, () => undefined, ); - await Promise.resolve(); - await Promise.resolve(); + await new Promise((resolve) => queueMicrotask(resolve)); + await new Promise((resolve) => queueMicrotask(resolve)); expect( plugin.execute.mock.calls.some(([options]) => String((options as { statement: string }).statement).includes('INSERT INTO offline_sync_commands'), @@ -2358,8 +2377,8 @@ describe('SqliteOfflineRepository replica rows', () => { () => undefined, () => undefined, ); - await Promise.resolve(); - await Promise.resolve(); + await new Promise((resolve) => queueMicrotask(resolve)); + await new Promise((resolve) => queueMicrotask(resolve)); expect(writeFinished).toBe(false); expect( plugin.execute.mock.calls.some(([options]) => diff --git a/projects/kit/offline/src/lib/sqlite-offline-repository.ts b/projects/kit/offline/src/lib/sqlite-offline-repository.ts index a7f6e1e..6182dfc 100644 --- a/projects/kit/offline/src/lib/sqlite-offline-repository.ts +++ b/projects/kit/offline/src/lib/sqlite-offline-repository.ts @@ -95,11 +95,13 @@ export const COMMUNITY_SQLITE = new InjectionToken * The returned lower-case hexadecimal value is suitable for * `OfflineKitOptions.createEncryptionKey` and contains no device or user identifiers. */ -export function createRandomOfflineEncryptionKey(): Promise { +export async function createRandomOfflineEncryptionKey(): Promise { const bytes = crypto.getRandomValues(new Uint8Array(32)); - return Promise.resolve(Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join('')); + return Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join(''); } +const settledSqliteOperation = async (): Promise => undefined; + /** Create the standard encrypted `@capacitor-community/sqlite` driver. */ export function createCommunitySqliteDriver(connection: CommunitySqliteConnection): CommunitySqliteDriver { const databases = new Map(); @@ -208,15 +210,15 @@ export class SqliteOfflineRepository implements OfflineRepository { readonly #options = inject(OFFLINE_KIT_OPTIONS); #databaseId: string | null = null; #initialization: Promise | null = null; - #writes: Promise = Promise.resolve(); + #writes: Promise = settledSqliteOperation(); #activeReaders = 0; - #readersIdle: Promise = Promise.resolve(); + #readersIdle: Promise = settledSqliteOperation(); #resolveReadersIdle: (() => void) | null = null; #atomicMutationRevision: number | null = null; #atomicMutationCommitted = false; - #atomicOperations: Promise = Promise.resolve(); + #atomicOperations: Promise = settledSqliteOperation(); #readSnapshotActive = false; - #atomicIdle: Promise = Promise.resolve(); + #atomicIdle: Promise = settledSqliteOperation(); #resolveAtomicIdle: (() => void) | null = null; initialize(): Promise { @@ -313,11 +315,8 @@ export class SqliteOfflineRepository implements OfflineRepository { } return this.#transaction(async () => { this.#readSnapshotActive = true; - try { - return await read(this.#reader()); - } finally { - this.#readSnapshotActive = false; - } + const executeRead = async (): Promise => read(this.#reader()); + return executeRead().finally(() => (this.#readSnapshotActive = false)); }); } @@ -328,9 +327,9 @@ export class SqliteOfflineRepository implements OfflineRepository { throw new Error('Nested offline replica atomic mutations are not supported.'); } this.#beginReaders(); - try { + const executeAtomicMutation = async (): Promise => { const databaseId = await this.#databaseConnection(); - this.#atomicOperations = Promise.resolve(); + this.#atomicOperations = settledSqliteOperation(); this.#atomicMutationCommitted = false; this.#atomicIdle = new Promise((resolve) => { this.#resolveAtomicIdle = resolve; @@ -342,13 +341,14 @@ export class SqliteOfflineRepository implements OfflineRepository { await this.#queueAtomicOperation(() => this.#atomicTransaction(databaseId, async () => undefined, false)); } return result; - } finally { + }; + return executeAtomicMutation().finally(() => { this.#atomicMutationRevision = null; this.#endReaders(); this.#resolveAtomicIdle?.(); this.#resolveAtomicIdle = null; - this.#atomicIdle = Promise.resolve(); - } + this.#atomicIdle = settledSqliteOperation(); + }); } async putCommand(command: OfflineCommand): Promise { @@ -482,35 +482,37 @@ export class SqliteOfflineRepository implements OfflineRepository { } async #open(): Promise { - try { - if (!this.#sqlite) { - throw new OfflineStorageUnavailableError('storage_unavailable', 'Native offline storage requires a community SQLite connection'); - } - const { databaseId } = await this.#sqlite.open({ - databaseName: this.#options.databaseName, - createEncryptionKey: this.#wrapCreateEncryptionKey(this.#options.createEncryptionKey), - }); - this.#databaseId = databaseId; - for (const statement of SCHEMA) await this.#execute(databaseId, statement); - const metadata = await this.#queryDatabase(databaseId, 'SELECT schema_version FROM offline_metadata WHERE id = 1'); - if (metadata.length === 0) { - await this.#execute(databaseId, 'INSERT INTO offline_metadata (id, schema_version, last_user_id) VALUES (1, ?, NULL)', [ - OFFLINE_SCHEMA_VERSION, - ]); - } else { - const storedVersion = this.#number(metadata[0]!['schema_version']); - if (storedVersion !== OFFLINE_SCHEMA_VERSION) { - throw new OfflineStorageUnavailableError( - 'core_schema_incompatible', - `Unsupported offline storage schema version ${storedVersion}; expected ${OFFLINE_SCHEMA_VERSION}. ` + - 'A lossless core schema migration is required before this database can be opened.', - ); - } - } - await this.#initializeReplicaSchema(databaseId); - } catch (error) { + await this.#openDatabase().catch((error: unknown) => { throw this.#mapInitializationError(error); + }); + } + + async #openDatabase(): Promise { + if (!this.#sqlite) { + throw new OfflineStorageUnavailableError('storage_unavailable', 'Native offline storage requires a community SQLite connection'); } + const { databaseId } = await this.#sqlite.open({ + databaseName: this.#options.databaseName, + createEncryptionKey: this.#wrapCreateEncryptionKey(this.#options.createEncryptionKey), + }); + this.#databaseId = databaseId; + for (const statement of SCHEMA) await this.#execute(databaseId, statement); + const metadata = await this.#queryDatabase(databaseId, 'SELECT schema_version FROM offline_metadata WHERE id = 1'); + if (metadata.length === 0) { + await this.#execute(databaseId, 'INSERT INTO offline_metadata (id, schema_version, last_user_id) VALUES (1, ?, NULL)', [ + OFFLINE_SCHEMA_VERSION, + ]); + } else { + const storedVersion = this.#number(metadata[0]!['schema_version']); + if (storedVersion !== OFFLINE_SCHEMA_VERSION) { + throw new OfflineStorageUnavailableError( + 'core_schema_incompatible', + `Unsupported offline storage schema version ${storedVersion}; expected ${OFFLINE_SCHEMA_VERSION}. ` + + 'A lossless core schema migration is required before this database can be opened.', + ); + } + } + await this.#initializeReplicaSchema(databaseId); } async #initializeReplicaSchema(databaseId: string): Promise { @@ -567,8 +569,8 @@ export class SqliteOfflineRepository implements OfflineRepository { #wrapCreateEncryptionKey(createEncryptionKey: (() => Promise) | undefined): (() => Promise) | undefined { if (!createEncryptionKey) return undefined; - return async () => { - try { + return () => { + const create = async (): Promise => { const encryptionKey = await createEncryptionKey(); if (!encryptionKey) { throw new OfflineStorageUnavailableError( @@ -577,14 +579,15 @@ export class SqliteOfflineRepository implements OfflineRepository { ); } return encryptionKey; - } catch (error) { + }; + return create().catch((error: unknown) => { if (error instanceof OfflineStorageUnavailableError) throw error; throw new OfflineStorageUnavailableError( 'encryption_key_unavailable', error instanceof Error ? error.message : 'Offline encryption key is unavailable.', { cause: error }, ); - } + }); }; } @@ -618,14 +621,24 @@ export class SqliteOfflineRepository implements OfflineRepository { async #nativeTransaction(databaseId: string, run: () => Promise): Promise { await this.#sqlite!.beginTransaction({ databaseId }); - try { - const result = await run(); - await this.#sqlite!.commitTransaction({ databaseId }); - return result; - } catch (error) { - await this.#sqlite!.rollbackTransaction({ databaseId }); - throw error; - } + const execute = async (): Promise => run(); + return execute() + .then(async (result) => { + await this.#sqlite!.commitTransaction({ databaseId }); + return result; + }) + .catch(async (error: unknown) => { + const rollback = await this.#sqlite!.rollbackTransaction({ databaseId }).then( + () => ({ status: 'fulfilled' as const }), + (rollbackError: unknown) => ({ status: 'rejected' as const, rollbackError }), + ); + if (rollback.status === 'rejected') { + throw new AggregateError([error, rollback.rollbackError], 'Offline SQLite transaction and rollback both failed.', { + cause: error, + }); + } + throw error; + }); } async #databaseConnection(): Promise { @@ -776,17 +789,14 @@ export class SqliteOfflineRepository implements OfflineRepository { const atomicTransaction = (run: (databaseId: string) => Promise, marksCommit = true): Promise => this.#queueAtomicOperation(() => this.#atomicTransaction(this.#databaseId!, run, marksCommit)); return { - initialize: () => Promise.resolve(), + initialize: settledSqliteOperation, ...reader, runReadSnapshot: (read) => this.#queueAtomicOperation(() => this.#nativeTransaction(this.#databaseId!, async () => { this.#readSnapshotActive = true; - try { - return await read(reader); - } finally { - this.#readSnapshotActive = false; - } + const executeRead = async () => read(reader); + return executeRead().finally(() => (this.#readSnapshotActive = false)); }), ), putCommand: (command) => atomicTransaction((databaseId) => this.#putCommand(databaseId, command)), @@ -834,11 +844,8 @@ export class SqliteOfflineRepository implements OfflineRepository { } await this.#writes; this.#beginReaders(); - try { - return await operation(); - } finally { - this.#endReaders(); - } + const read = async (): Promise => operation(); + return read().finally(() => this.#endReaders()); } #beginReaders(): void { @@ -855,7 +862,7 @@ export class SqliteOfflineRepository implements OfflineRepository { if (this.#activeReaders === 0) { this.#resolveReadersIdle?.(); this.#resolveReadersIdle = null; - this.#readersIdle = Promise.resolve(); + this.#readersIdle = settledSqliteOperation(); } } diff --git a/projects/kit/printer/src/kit-browser-pdf.spec.ts b/projects/kit/printer/src/kit-browser-pdf.spec.ts index cf320cd..c8ad56d 100644 --- a/projects/kit/printer/src/kit-browser-pdf.spec.ts +++ b/projects/kit/printer/src/kit-browser-pdf.spec.ts @@ -1,8 +1,10 @@ -import { describe, expect, it, vi } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import type { KitBrowserPdfDependencies } from './kit-browser-pdf'; import { kitDownloadPdf, kitPreviewGeneratedPdf } from './kit-browser-pdf'; +afterEach(() => vi.restoreAllMocks()); + interface BrowserPdfTestHarness { readonly dependencies: KitBrowserPdfDependencies; readonly link: HTMLAnchorElement; @@ -128,15 +130,11 @@ describe('kitDownloadPdf', () => { }, }; - try { - expect(() => kitDownloadPdf(Uint8Array.from([1]), { filename: 'label.pdf', dependencies })).not.toThrow(); - expect(harness.click).toHaveBeenCalledOnce(); - cleanup?.(); - expect(harness.remove).toHaveBeenCalledOnce(); - expect(harness.revokeObjectURL).toHaveBeenCalledWith('blob:generated-pdf'); - } finally { - setTimeoutSpy.mockRestore(); - } + expect(() => kitDownloadPdf(Uint8Array.from([1]), { filename: 'label.pdf', dependencies })).not.toThrow(); + expect(harness.click).toHaveBeenCalledOnce(); + cleanup?.(); + expect(harness.remove).toHaveBeenCalledOnce(); + expect(harness.revokeObjectURL).toHaveBeenCalledWith('blob:generated-pdf'); }); }); @@ -325,20 +323,16 @@ describe('kitPreviewGeneratedPdf', () => { }, }; - try { - await expect( - kitPreviewGeneratedPdf(async () => Uint8Array.from([1]), { - title: 'PDF generating', - pendingText: 'Please wait', - fallbackFilename: 'document.pdf', - dependencies, - }), - ).resolves.toBeUndefined(); - expect(replace).toHaveBeenCalledWith('blob:generated-pdf'); - expect(target.close).not.toHaveBeenCalled(); - } finally { - setTimeoutSpy.mockRestore(); - } + await expect( + kitPreviewGeneratedPdf(async () => Uint8Array.from([1]), { + title: 'PDF generating', + pendingText: 'Please wait', + fallbackFilename: 'document.pdf', + dependencies, + }), + ).resolves.toBeUndefined(); + expect(replace).toHaveBeenCalledWith('blob:generated-pdf'); + expect(target.close).not.toHaveBeenCalled(); }); it('downloads when navigating the prepared preview fails', async () => { diff --git a/projects/kit/printer/src/kit-browser-pdf.ts b/projects/kit/printer/src/kit-browser-pdf.ts index 620904e..6ddc46e 100644 --- a/projects/kit/printer/src/kit-browser-pdf.ts +++ b/projects/kit/printer/src/kit-browser-pdf.ts @@ -37,6 +37,14 @@ interface PdfPreview { readonly close: () => void; } +interface DownloadResource { + link?: HTMLAnchorElement; +} + +interface PreviewResource { + target: Window | null; +} + const defaultDependencies = (): KitBrowserPdfDependencies => ({ document, url: URL, @@ -52,13 +60,13 @@ const createPdfUrl = (pdfBytes: Uint8Array, dependencies: KitBrowserPdfDependenc const cleanupDelay = (value: number | undefined): number => (value !== undefined && Number.isFinite(value) && value >= 0 ? value : 60_000); -const createCleanup = (pdfUrl: string, dependencies: KitBrowserPdfDependencies, link?: HTMLAnchorElement): (() => void) => { +const createCleanup = (pdfUrl: string, dependencies: KitBrowserPdfDependencies, resource?: DownloadResource): (() => void) => { let cleaned = false; return (): void => { if (cleaned) return; cleaned = true; try { - link?.remove(); + resource?.link?.remove(); } catch { // Cleanup is best-effort and must not turn successful PDF output into a failure. } finally { @@ -71,6 +79,35 @@ const createCleanup = (pdfUrl: string, dependencies: KitBrowserPdfDependencies, }; }; +const prepareDownloadLink = ( + resource: DownloadResource, + pdfUrl: string, + filename: string, + dependencies: KitBrowserPdfDependencies, +): HTMLAnchorElement => { + const link = dependencies.document.createElement('a'); + resource.link = link; + link.href = pdfUrl; + link.download = filename; + link.style.display = 'none'; + dependencies.document.body.appendChild(link); + return link; +}; + +const openPreviewWindow = ( + resource: PreviewResource, + options: KitPreviewGeneratedPdfOptions, + dependencies: KitBrowserPdfDependencies, +): Window | null => { + const target = dependencies.document.defaultView?.open('', '_blank') ?? null; + resource.target = target; + if (!target) return null; + target.opener = null; + target.document.title = options.title; + target.document.body.textContent = options.pendingText; + return target; +}; + const scheduleCleanup = (cleanup: () => void, delay: number, dependencies: KitBrowserPdfDependencies): boolean => { try { dependencies.schedule(cleanup, delay); @@ -113,20 +150,18 @@ const closeWindow = (target: Window | null): void => { export const kitDownloadPdf = (pdfBytes: Uint8Array, options: KitDownloadPdfOptions): void => { const dependencies = options.dependencies ?? defaultDependencies(); const pdfUrl = createPdfUrl(pdfBytes, dependencies); - let link: HTMLAnchorElement | undefined; - let cleanup = createCleanup(pdfUrl, dependencies); - let cleanupScheduled = false; + const resource: DownloadResource = {}; + const cleanup = createCleanup(pdfUrl, dependencies, resource); + let link: HTMLAnchorElement; try { - link = dependencies.document.createElement('a'); - cleanup = createCleanup(pdfUrl, dependencies, link); - link.href = pdfUrl; - link.download = options.filename; - link.style.display = 'none'; - dependencies.document.body.appendChild(link); - cleanupScheduled = scheduleCleanup(cleanup, cleanupDelay(options.cleanupDelayMs), dependencies); - - // Keep this as the final synchronous output operation: the browser may start immediately. + link = prepareDownloadLink(resource, pdfUrl, options.filename, dependencies); + } catch (error) { + cleanup(); + throw error; + } + const cleanupScheduled = scheduleCleanup(cleanup, cleanupDelay(options.cleanupDelayMs), dependencies); + try { link.click(); } catch (error) { cleanup(); @@ -145,22 +180,18 @@ export const kitDownloadPdf = (pdfBytes: Uint8Array, options: KitDownloadPdfOpti */ const preparePdfPreview = (options: KitPreviewGeneratedPdfOptions): PdfPreview => { const dependencies = options.dependencies ?? defaultDependencies(); - let target: Window | null = null; + const resource: PreviewResource = { target: null }; try { - target = dependencies.document.defaultView?.open('', '_blank') ?? null; - if (target) { - target.opener = null; - target.document.title = options.title; - target.document.body.textContent = options.pendingText; - } + openPreviewWindow(resource, options, dependencies); } catch { - closeWindow(target); - target = null; + closeWindow(resource.target); + resource.target = null; } return { show: (pdfBytes): void => { + const target = resource.target; if (!target || isWindowClosed(target)) { kitDownloadPdf(pdfBytes, { filename: options.fallbackFilename, @@ -188,7 +219,7 @@ const preparePdfPreview = (options: KitPreviewGeneratedPdfOptions): PdfPreview = if (!cleanupScheduled) cleanup(); } }, - close: (): void => closeWindow(target), + close: (): void => closeWindow(resource.target), }; }; @@ -204,10 +235,9 @@ export const kitPreviewGeneratedPdf = async ( options: KitPreviewGeneratedPdfOptions, ): Promise => { const preview = preparePdfPreview(options); - try { - preview.show(await buildPdf()); - } catch (error) { + const buildAndShow = async (): Promise => preview.show(await buildPdf()); + await buildAndShow().catch((error: unknown) => { preview.close(); throw error; - } + }); }; diff --git a/projects/kit/src/lib/auth/auth-access.service.spec.ts b/projects/kit/src/lib/auth/auth-access.service.spec.ts index 658c715..d8dee62 100644 --- a/projects/kit/src/lib/auth/auth-access.service.spec.ts +++ b/projects/kit/src/lib/auth/auth-access.service.spec.ts @@ -249,7 +249,7 @@ describe('KitAuthRecoveryService', () => { recovery.initialize(); access.grantLocal(); availability.next(true); - await Promise.resolve(); + await new Promise((resolve) => queueMicrotask(resolve)); access.grantLocal(); releaseOld?.(); diff --git a/projects/kit/src/lib/auth/auth-access.service.ts b/projects/kit/src/lib/auth/auth-access.service.ts index c82a79f..2c44850 100644 --- a/projects/kit/src/lib/auth/auth-access.service.ts +++ b/projects/kit/src/lib/auth/auth-access.service.ts @@ -190,14 +190,10 @@ export class KitAuthRecoveryService { } /** Run one ordered remote recovery attempt, coalescing concurrent triggers. */ - recover(): Promise { - if (this.#destroyed) return Promise.resolve(); + async recover(): Promise { + if (this.#destroyed) return; if (this.#recovery) { - if ( - this.#access.mode === 'local' && - this.#recoveryRevision !== null && - this.#access.revision > this.#recoveryRevision - ) { + if (this.#access.mode === 'local' && this.#recoveryRevision !== null && this.#access.revision > this.#recoveryRevision) { this.#retryRequested = true; } return this.#recovery; @@ -228,7 +224,7 @@ export class KitAuthRecoveryService { const lease = this.#access.beginTransition(); let currentLease = lease; const isCurrent = (): boolean => currentLease.isCurrent(); - try { + const executeRecovery = async (): Promise => { const result = await recovery.reauthenticate(lease); if (this.#destroyed || !isCurrent() || this.#access.mode !== 'local') return; if (result === false) { @@ -236,19 +232,15 @@ export class KitAuthRecoveryService { this.#access.clear(); return; } - if ( - !(await result.activate(lease)) || - this.#destroyed || - !isCurrent() || - this.#access.mode !== 'local' - ) { + if (!(await result.activate(lease)) || this.#destroyed || !isCurrent() || this.#access.mode !== 'local') { return; } this.#clearRetry(); currentLease = this.#access.grantRemote(); if (!currentLease.isCurrent()) return; await result.resume(currentLease); - } catch (error) { + }; + await executeRecovery().catch((error: unknown) => { if (this.#destroyed || !isCurrent()) return; if (isExplicitAuthDenial(error)) { this.#clearRetry(); @@ -258,7 +250,7 @@ export class KitAuthRecoveryService { } else { this.#errorHandler.handleError(error); } - } + }); } #scheduleRetry(): void { diff --git a/projects/kit/src/lib/auth/auth-guards.spec.ts b/projects/kit/src/lib/auth/auth-guards.spec.ts index f00d729..eddf9db 100644 --- a/projects/kit/src/lib/auth/auth-guards.spec.ts +++ b/projects/kit/src/lib/auth/auth-guards.spec.ts @@ -301,6 +301,22 @@ describe('kitRequireAuthorizedGuard', () => { expect(TestBed.inject(KitAuthAccessService).mode).toBe('remote'); }); + it("'user' → classifies a synchronous phased resume failure as unavailable", async () => { + const networkError = { status: 0 }; + const onAuthorized = vi.fn(async () => ({ + activate: async () => true, + resume: () => { + throw networkError; + }, + })); + setup('user', { onAuthorized, isUnavailableError: (error) => error === networkError }); + + await expect( + runGuard(TestBed.runInInjectionContext(() => kitRequireAuthorizedGuard(routeStub, stateStub))), + ).resolves.toBe(true); + expect(TestBed.inject(KitAuthAccessService).mode).toBe('remote'); + }); + it("'anonymous' → returns true without calling any hook", async () => { const onAuthorized = vi.fn() as unknown as (s: RouterStateSnapshot) => Promise; const onUnauthenticated = vi.fn() as unknown as (s: RouterStateSnapshot) => Promise; diff --git a/projects/kit/src/lib/auth/auth-guards.ts b/projects/kit/src/lib/auth/auth-guards.ts index 3c3fab9..58e63af 100644 --- a/projects/kit/src/lib/auth/auth-guards.ts +++ b/projects/kit/src/lib/auth/auth-guards.ts @@ -280,7 +280,7 @@ export const kitRequireAuthorizedGuard: CanActivateFn = (_route, state) => { return false; }; const resolveUnavailable = async (error?: unknown): Promise => { - try { + const resolveFallback = async (): Promise => { const fallback = onUnavailable ? await onUnavailable(state, error, lease) : false; if (!lease.isCurrent()) return false; if (fallback === true) { @@ -290,30 +290,29 @@ export const kitRequireAuthorizedGuard: CanActivateFn = (_route, state) => { if (fallback === false) return redirectUnauthorized(); access.clear(); return fallback; - } catch (fallbackError) { + }; + return resolveFallback().catch((fallbackError: unknown) => { if (!lease.isCurrent()) return false; access.clear(); throw fallbackError; - } + }); }; - const resolveRemote = async ( - result: boolean | UrlTree | KitRemoteAccessRecovery, - ): Promise => { + const resolveRemote = async (result: boolean | UrlTree | KitRemoteAccessRecovery): Promise => { if (!lease.isCurrent()) return false; if (isRemoteAccessActivation(result)) { if (!(await result.activate(lease)) || !lease.isCurrent()) return false; const resumeLease = access.grantRemote(); if (!resumeLease.isCurrent()) return false; - try { - await result.resume(resumeLease); - } catch (error) { - if (!resumeLease.isCurrent()) return false; + const resume = async (): Promise => result.resume(resumeLease); + await resume().catch((error: unknown) => { + if (!resumeLease.isCurrent()) return; if (isExplicitAuthDenial(error)) { access.clear(); throw error; } if (!isUnavailableError?.(error)) throw error; - } + return; + }); return resumeLease.isCurrent(); } if (result === true) access.grantRemote(); @@ -344,11 +343,12 @@ export const kitRequireAuthorizedGuard: CanActivateFn = (_route, state) => { access.grantRemote(); return true; } - try { + const resolveAuthorized = async (): Promise => { const result = await onAuthorized(state, lease); if (!lease.isCurrent()) return false; - return await resolveRemote(result); - } catch (error) { + return resolveRemote(result); + }; + return resolveAuthorized().catch((error: unknown) => { if (!lease.isCurrent()) return false; if (isExplicitAuthDenial(error)) { access.clear(); @@ -356,7 +356,7 @@ export const kitRequireAuthorizedGuard: CanActivateFn = (_route, state) => { } if (!isUnavailableError?.(error)) throw error; return resolveUnavailable(error); - } + }); } if (data === 'anonymous') { if (!lease.isCurrent()) return false; diff --git a/projects/kit/src/lib/directives/auth-input.directive.spec.ts b/projects/kit/src/lib/directives/auth-input.directive.spec.ts index d3351ce..6ad8ca2 100644 --- a/projects/kit/src/lib/directives/auth-input.directive.spec.ts +++ b/projects/kit/src/lib/directives/auth-input.directive.spec.ts @@ -8,16 +8,14 @@ import { KIT_LAST_AUTH_EMAIL_KEY } from '../storage/kit-auth-email-store'; /** In-memory stand-in for `KitStorageService`. */ class FakeStorage { readonly map = new Map(); - get(key: string): Promise { - return Promise.resolve((this.map.get(key) ?? null) as T | null); + async get(key: string): Promise { + return (this.map.get(key) ?? null) as T | null; } - set(key: string, value: T): Promise { + async set(key: string, value: T): Promise { this.map.set(key, value); - return Promise.resolve(); } - remove(key: string): Promise { + async remove(key: string): Promise { this.map.delete(key); - return Promise.resolve(); } } @@ -46,7 +44,7 @@ const setup = async (mode: KitAuthInputMode, initialEmail = '', seed?: string) = fixture.componentInstance.model.set({ email: initialEmail }); fixture.detectChanges(); // runs ngOnInit → prefill await fixture.whenStable(); - await Promise.resolve(); + await new Promise((resolve) => queueMicrotask(resolve)); fixture.detectChanges(); return { fixture, storage, host: fixture.componentInstance }; }; @@ -85,28 +83,28 @@ describe('KitAuthInputDirective', () => { it('persists a well-formed email in "email" mode', async () => { const { fixture, storage } = await setup('email'); fireIonChange(fixture, 'new@example.com'); - await Promise.resolve(); + await new Promise((resolve) => queueMicrotask(resolve)); expect(storage.map.get(KIT_LAST_AUTH_EMAIL_KEY)).toBe('new@example.com'); }); it('persists a well-formed email in "email-remember" mode', async () => { const { fixture, storage } = await setup('email-remember'); fireIonChange(fixture, 'signup@example.com'); - await Promise.resolve(); + await new Promise((resolve) => queueMicrotask(resolve)); expect(storage.map.get(KIT_LAST_AUTH_EMAIL_KEY)).toBe('signup@example.com'); }); it('does not persist a malformed address', async () => { const { fixture, storage } = await setup('email'); fireIonChange(fixture, 'not-an-email'); - await Promise.resolve(); + await new Promise((resolve) => queueMicrotask(resolve)); expect(storage.map.has(KIT_LAST_AUTH_EMAIL_KEY)).toBe(false); }); it('is inert in "autofill" mode', async () => { const { fixture, storage } = await setup('autofill'); fireIonChange(fixture, 'x@example.com'); - await Promise.resolve(); + await new Promise((resolve) => queueMicrotask(resolve)); expect(storage.map.has(KIT_LAST_AUTH_EMAIL_KEY)).toBe(false); }); }); @@ -115,21 +113,21 @@ describe('KitAuthInputDirective', () => { it('"email" forgets when cleared to empty', async () => { const { fixture, storage } = await setup('email', 'saved@example.com', 'saved@example.com'); fireIonChange(fixture, ''); - await Promise.resolve(); + await new Promise((resolve) => queueMicrotask(resolve)); expect(storage.map.has(KIT_LAST_AUTH_EMAIL_KEY)).toBe(false); }); it('"email" forgets on a whitespace-only / invalid value', async () => { const { fixture, storage } = await setup('email', '', 'saved@example.com'); fireIonChange(fixture, ' '); - await Promise.resolve(); + await new Promise((resolve) => queueMicrotask(resolve)); expect(storage.map.has(KIT_LAST_AUTH_EMAIL_KEY)).toBe(false); }); it('"email-remember" does NOT forget when cleared (keeps a value remembered elsewhere)', async () => { const { fixture, storage } = await setup('email-remember', '', 'saved@example.com'); fireIonChange(fixture, ''); - await Promise.resolve(); + await new Promise((resolve) => queueMicrotask(resolve)); expect(storage.map.get(KIT_LAST_AUTH_EMAIL_KEY)).toBe('saved@example.com'); }); }); diff --git a/projects/kit/src/lib/http/kit-http.interceptor.spec.ts b/projects/kit/src/lib/http/kit-http.interceptor.spec.ts index 3da39c9..737aa06 100644 --- a/projects/kit/src/lib/http/kit-http.interceptor.spec.ts +++ b/projects/kit/src/lib/http/kit-http.interceptor.spec.ts @@ -58,6 +58,7 @@ function runInterceptor(req: HttpRequest, next: (r: HttpRequest { afterEach(() => { + vi.useRealTimers(); TestBed.resetTestingModule(); }); @@ -901,15 +902,11 @@ describe('kitAuthInterceptor — timeoutMs & treatAsError', () => { it('times out a hung request with a synthetic 408 after the default timeout', async () => { vi.useFakeTimers(); - try { - setupInterceptor(makeConfig()); - const next = vi.fn().mockReturnValue(new Observable(() => {})); // never emits - const result = firstValueFrom(runInterceptor(postReq, next)); - const assertion = expect(result).rejects.toMatchObject({ status: 408 }); - await vi.advanceTimersByTimeAsync(60_000); - await assertion; - } finally { - vi.useRealTimers(); - } + setupInterceptor(makeConfig()); + const next = vi.fn().mockReturnValue(new Observable(() => {})); // never emits + const result = firstValueFrom(runInterceptor(postReq, next)); + const assertion = expect(result).rejects.toMatchObject({ status: 408 }); + await vi.advanceTimersByTimeAsync(60_000); + await assertion; }); }); diff --git a/projects/kit/src/lib/http/kit-http.interceptor.ts b/projects/kit/src/lib/http/kit-http.interceptor.ts index 62c5325..c5359d1 100644 --- a/projects/kit/src/lib/http/kit-http.interceptor.ts +++ b/projects/kit/src/lib/http/kit-http.interceptor.ts @@ -396,15 +396,18 @@ const shouldRevokeAuthAccess = (config: KitHttpConfig, req: HttpRequest * * @internal */ -const tryRecoverAuthAccess = async (config: KitHttpConfig, request: HttpRequest, error: HttpErrorResponse): Promise => { +const tryRecoverAuthAccess = async ( + config: KitHttpConfig, + request: HttpRequest, + error: HttpErrorResponse, +): Promise => { if (!config.recoverAuthAccess) { return false; } - try { - return (await config.recoverAuthAccess(request, error)) === true; - } catch { - return false; - } + const recover = async (): Promise => config.recoverAuthAccess?.(request, error); + return recover() + .then((recovered) => recovered === true) + .catch(() => false); }; /** @@ -528,7 +531,7 @@ export const kitAuthInterceptor: HttpInterceptorFn = (request, next) => { } const sendAuthenticated = (outgoing: HttpRequest, allowAuthRecovery: boolean): Observable> => - from(Promise.resolve(config.getAuthHeaders(outgoing))).pipe( + from(config.getAuthHeaders(outgoing)).pipe( catchError((headerError: unknown) => { // getAuthHeaders failed → the request is never sent; classify it instead of failing silently. if (config.enforceAuthAccessMode && isExplicitAuthDenial(headerError) && shouldRevokeAuthAccess(config, outgoing, headerError)) { @@ -585,7 +588,10 @@ export const kitAuthInterceptor: HttpInterceptorFn = (request, next) => { }), catchError((error: HttpErrorResponse) => { const mayRecover = - allowAuthRecovery && isExplicitAuthDenial(error) && !outgoing.context.get(KIT_AUTH_RECOVERY_REPLAY) && config.recoverAuthAccess; + allowAuthRecovery && + isExplicitAuthDenial(error) && + !outgoing.context.get(KIT_AUTH_RECOVERY_REPLAY) && + config.recoverAuthAccess; if (mayRecover) { return from(tryRecoverAuthAccess(config, outgoing, error)).pipe( diff --git a/projects/kit/src/lib/keyboard/kit-keyboard.spec.ts b/projects/kit/src/lib/keyboard/kit-keyboard.spec.ts index 927194d..7abdd48 100644 --- a/projects/kit/src/lib/keyboard/kit-keyboard.spec.ts +++ b/projects/kit/src/lib/keyboard/kit-keyboard.spec.ts @@ -29,9 +29,9 @@ describe('kitKeyboardInit', () => { beforeEach(() => { listeners.clear(); - addListener.mockImplementation((event: KeyboardEventName, callback: KeyboardCallback) => { + addListener.mockImplementation(async (event: KeyboardEventName, callback: KeyboardCallback) => { listeners.set(event, callback); - return Promise.resolve({ remove: vi.fn().mockResolvedValue(undefined) }); + return { remove: vi.fn().mockResolvedValue(undefined) }; }); vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { callback(0); diff --git a/projects/kit/src/lib/overlay/kit-loading.controller.ts b/projects/kit/src/lib/overlay/kit-loading.controller.ts index f889c3a..0f301d8 100644 --- a/projects/kit/src/lib/overlay/kit-loading.controller.ts +++ b/projects/kit/src/lib/overlay/kit-loading.controller.ts @@ -2,6 +2,8 @@ import { inject, Injectable } from '@angular/core'; import type { LoadingOptions } from '@ionic/angular/standalone'; import { LoadingController } from '@ionic/angular/standalone'; +const settledLoadingOperation = async (): Promise => undefined; + /** * Reference-counted wrapper around Ionic's `LoadingController` that keeps at most one loading * indicator on screen across concurrent async work. @@ -50,7 +52,7 @@ export class KitLoadingController { * Serializes present/dismiss operations. Each call chains onto this promise, runs after the * previous operation has fully settled, then reads {@link #count} and acts accordingly. */ - #queue: Promise = Promise.resolve(); + #queue: Promise = settledLoadingOperation(); /** * Show the loading indicator, or join the one already on screen. @@ -59,24 +61,22 @@ export class KitLoadingController { * indicator (the `0 → 1` transition). Ignored while an indicator is already present. * @returns a Promise that resolves once the indicator is on screen (or immediately when one already is) */ - async presentLoading(options: LoadingOptions = {}): Promise { + presentLoading(options: LoadingOptions = {}): Promise { this.#count++; - try { - await this.#enqueue(async () => { - // Create only on the transition into "something is loading"; concurrent callers ride the same - // element. Re-check the count in case a dismiss already balanced this call while queued. - if (this.#count > 0 && this.#loading === null) { - const loading = await this.#loadingCtrl.create(options); - await loading.present(); - this.#loading = loading; - } - }); - } catch (error) { + return this.#enqueue(async () => { + // Create only on the transition into "something is loading"; concurrent callers ride the same + // element. Re-check the count in case a dismiss already balanced this call while queued. + if (this.#count > 0 && this.#loading === null) { + const loading = await this.#loadingCtrl.create(options); + await loading.present(); + this.#loading = loading; + } + }).catch((error: unknown) => { // Roll back the reference this call took: a failed create/present must not leave the counter // elevated, otherwise a later cycle never reaches N → 0 and the spinner stays stuck on screen. this.#count--; throw error; - } + }); } /** diff --git a/projects/kit/src/lib/overlay/kit-maintenance.controller.spec.ts b/projects/kit/src/lib/overlay/kit-maintenance.controller.spec.ts index e193be6..bee891a 100644 --- a/projects/kit/src/lib/overlay/kit-maintenance.controller.spec.ts +++ b/projects/kit/src/lib/overlay/kit-maintenance.controller.spec.ts @@ -95,7 +95,7 @@ describe('KitMaintenanceController', () => { const { controller, alert, source } = setup(); await controller.present(OPTS); source.trigger('ended'); - await Promise.resolve(); + await new Promise((resolve) => queueMicrotask(resolve)); expect(alert.dismiss).toHaveBeenCalledTimes(1); expect(source.close).toHaveBeenCalled(); }); @@ -111,9 +111,9 @@ describe('KitMaintenanceController', () => { const { controller, alertCtrl, source } = setup(first); await controller.present(OPTS); source.trigger('ended'); - await Promise.resolve(); + await new Promise((resolve) => queueMicrotask(resolve)); first.triggerDismiss(); - await Promise.resolve(); + await new Promise((resolve) => queueMicrotask(resolve)); await controller.present(OPTS); expect(alertCtrl.create).toHaveBeenCalledTimes(2); }); diff --git a/projects/kit/src/lib/overlay/kit-overlay.controller.spec.ts b/projects/kit/src/lib/overlay/kit-overlay.controller.spec.ts index 70dea30..708175d 100644 --- a/projects/kit/src/lib/overlay/kit-overlay.controller.spec.ts +++ b/projects/kit/src/lib/overlay/kit-overlay.controller.spec.ts @@ -94,6 +94,8 @@ function setup({ // --------------------------------------------------------------------------- describe('KitOverlayController', () => { afterEach(() => { + document.querySelectorAll('ion-tab-bar').forEach((element) => element.remove()); + vi.restoreAllMocks(); TestBed.resetTestingModule(); }); @@ -227,13 +229,9 @@ describe('KitOverlayController', () => { tabBar.setAttribute('slot', 'bottom'); tabBar.getBoundingClientRect = () => ({ height: 50, bottom: 800 }) as DOMRect; document.body.appendChild(tabBar); - try { - const { controller, toastCtrl } = setup(); - await controller.presentToast({ message: 'Hi' }); - expect(toastCtrl.create.mock.calls[0][0].positionAnchor).toBe(tabBar); - } finally { - document.body.removeChild(tabBar); - } + const { controller, toastCtrl } = setup(); + await controller.presentToast({ message: 'Hi' }); + expect(toastCtrl.create.mock.calls[0][0].positionAnchor).toBe(tabBar); }); it('does not anchor when ion-tab-bar has slot=top', async () => { @@ -241,13 +239,9 @@ describe('KitOverlayController', () => { tabBar.setAttribute('slot', 'top'); tabBar.getBoundingClientRect = () => ({ height: 50, bottom: 50 }) as DOMRect; document.body.appendChild(tabBar); - try { - const { controller, toastCtrl } = setup(); - await controller.presentToast({ message: 'Hi' }); - expect(toastCtrl.create.mock.calls[0][0].positionAnchor).toBeUndefined(); - } finally { - document.body.removeChild(tabBar); - } + const { controller, toastCtrl } = setup(); + await controller.presentToast({ message: 'Hi' }); + expect(toastCtrl.create.mock.calls[0][0].positionAnchor).toBeUndefined(); }); it('anchors to the bottom tab bar when both top and bottom bars are present', async () => { @@ -258,14 +252,9 @@ describe('KitOverlayController', () => { bottomTabBar.setAttribute('slot', 'bottom'); bottomTabBar.getBoundingClientRect = () => ({ height: 50, bottom: 800 }) as DOMRect; document.body.append(topTabBar, bottomTabBar); - try { - const { controller, toastCtrl } = setup(); - await controller.presentToast({ message: 'Hi' }); - expect(toastCtrl.create.mock.calls[0][0].positionAnchor).toBe(bottomTabBar); - } finally { - document.body.removeChild(topTabBar); - document.body.removeChild(bottomTabBar); - } + const { controller, toastCtrl } = setup(); + await controller.presentToast({ message: 'Hi' }); + expect(toastCtrl.create.mock.calls[0][0].positionAnchor).toBe(bottomTabBar); }); it('anchors when ion-tab-bar has no slot but sits at the viewport bottom', async () => { @@ -274,14 +263,9 @@ describe('KitOverlayController', () => { const tabBar = document.createElement('ion-tab-bar'); tabBar.getBoundingClientRect = () => ({ height: 50, bottom: innerHeight }) as DOMRect; document.body.appendChild(tabBar); - try { - const { controller, toastCtrl } = setup(); - await controller.presentToast({ message: 'Hi' }); - expect(toastCtrl.create.mock.calls[0][0].positionAnchor).toBe(tabBar); - } finally { - document.body.removeChild(tabBar); - vi.restoreAllMocks(); - } + const { controller, toastCtrl } = setup(); + await controller.presentToast({ message: 'Hi' }); + expect(toastCtrl.create.mock.calls[0][0].positionAnchor).toBe(tabBar); }); it('does not anchor when ion-tab-bar has no slot and is not at the viewport bottom', async () => { @@ -289,14 +273,9 @@ describe('KitOverlayController', () => { const tabBar = document.createElement('ion-tab-bar'); tabBar.getBoundingClientRect = () => ({ height: 50, bottom: 200 }) as DOMRect; document.body.appendChild(tabBar); - try { - const { controller, toastCtrl } = setup(); - await controller.presentToast({ message: 'Hi' }); - expect(toastCtrl.create.mock.calls[0][0].positionAnchor).toBeUndefined(); - } finally { - document.body.removeChild(tabBar); - vi.restoreAllMocks(); - } + const { controller, toastCtrl } = setup(); + await controller.presentToast({ message: 'Hi' }); + expect(toastCtrl.create.mock.calls[0][0].positionAnchor).toBeUndefined(); }); it('does not anchor when no tab bar is present', async () => { @@ -311,13 +290,9 @@ describe('KitOverlayController', () => { tabBar.getBoundingClientRect = () => ({ height: 50, bottom: 800 }) as DOMRect; document.body.appendChild(tabBar); const custom = document.createElement('div'); - try { - const { controller, toastCtrl } = setup(); - await controller.presentToast({ message: 'Hi', positionAnchor: custom }); - expect(toastCtrl.create.mock.calls[0][0].positionAnchor).toBe(custom); - } finally { - document.body.removeChild(tabBar); - } + const { controller, toastCtrl } = setup(); + await controller.presentToast({ message: 'Hi', positionAnchor: custom }); + expect(toastCtrl.create.mock.calls[0][0].positionAnchor).toBe(custom); }); it('includes the close label button from config', async () => { diff --git a/projects/kit/src/lib/overlay/kit-overlay.controller.ts b/projects/kit/src/lib/overlay/kit-overlay.controller.ts index 1d20823..ee82f6a 100644 --- a/projects/kit/src/lib/overlay/kit-overlay.controller.ts +++ b/projects/kit/src/lib/overlay/kit-overlay.controller.ts @@ -129,9 +129,7 @@ function findVisibleBottomTabBar(): HTMLElement | undefined { if (rect.height <= 0) continue; const slot = tabBar.getAttribute('slot'); - const isBottom = - slot === 'bottom' || - (slot === null && rect.bottom >= window.innerHeight - BOTTOM_TAB_BAR_VIEWPORT_MARGIN_PX); + const isBottom = slot === 'bottom' || (slot === null && rect.bottom >= window.innerHeight - BOTTOM_TAB_BAR_VIEWPORT_MARGIN_PX); if (!isBottom) continue; if (rect.bottom > bestBottom) { @@ -352,7 +350,7 @@ export class KitOverlayController { return; } this.#alertPresenting = true; - try { + const present = async (): Promise => { const alert = await this.#alertCtrl.create({ header: options.header, subHeader: options.subHeader, @@ -361,9 +359,8 @@ export class KitOverlayController { }); await alert.present(); await alert.onWillDismiss(); - } finally { - this.#alertPresenting = false; - } + }; + await present().finally(() => (this.#alertPresenting = false)); } /** @@ -391,7 +388,7 @@ export class KitOverlayController { return false; } this.#alertPresenting = true; - try { + const present = async (): Promise => { const alert = await this.#alertCtrl.create({ header: options.header, subHeader: options.subHeader, @@ -404,8 +401,7 @@ export class KitOverlayController { await alert.present(); const { role } = await alert.onWillDismiss(); return role === 'confirm'; - } finally { - this.#alertPresenting = false; - } + }; + return present().finally(() => (this.#alertPresenting = false)); } } diff --git a/projects/kit/src/lib/overlay/kit-reload-alert.controller.spec.ts b/projects/kit/src/lib/overlay/kit-reload-alert.controller.spec.ts index 3ef8cae..49a6d9b 100644 --- a/projects/kit/src/lib/overlay/kit-reload-alert.controller.spec.ts +++ b/projects/kit/src/lib/overlay/kit-reload-alert.controller.spec.ts @@ -98,7 +98,7 @@ describe('KitReloadAlertController', () => { await controller.present(OPTS); await controller.dismiss(); first.triggerDismiss(); - await Promise.resolve(); + await new Promise((resolve) => queueMicrotask(resolve)); await controller.present(OPTS); expect(alertCtrl.create).toHaveBeenCalledTimes(2); }); diff --git a/projects/kit/src/lib/realtime/kit-realtime-connection.spec.ts b/projects/kit/src/lib/realtime/kit-realtime-connection.spec.ts index 4dac0da..3b530b2 100644 --- a/projects/kit/src/lib/realtime/kit-realtime-connection.spec.ts +++ b/projects/kit/src/lib/realtime/kit-realtime-connection.spec.ts @@ -42,8 +42,8 @@ class TestConnection extends KitRealtimeConnection { pendingFailureHook: Promise | null = null; failureCalls = 0; readonly sockets: FakeWebSocket[] = []; - readonly removeAppListener = vi.fn(() => Promise.resolve()); - readonly removeNetworkListener = vi.fn(() => Promise.resolve()); + readonly removeAppListener = vi.fn(async () => undefined); + readonly removeNetworkListener = vi.fn(async () => undefined); appListenerResolver: ((handle: PluginListenerHandle) => void) | null = null; requireRemoteAccess = false; @@ -59,27 +59,24 @@ class TestConnection extends KitRealtimeConnection { return this.connectEnabled; } - protected buildSocketTargets(): Promise<{ url: string; protocols: string[] }[]> { + protected async buildSocketTargets(): Promise<{ url: string; protocols: string[] }[]> { if (this.failTargets) { - return Promise.reject(new Error('token failed')); + throw new Error('token failed'); } - return Promise.resolve( - Array.from({ length: this.targetCount }, (_, index) => ({ - url: `https://example.test/realtime/${index}`, - protocols: ['test'], - })), - ); + return Array.from({ length: this.targetCount }, (_, index) => ({ + url: `https://example.test/realtime/${index}`, + protocols: ['test'], + })); } - protected override handleConnectionFailure(): Promise { + protected override async handleConnectionFailure(): Promise { this.failureCalls += 1; if (this.pendingFailureHook) { return this.pendingFailureHook; } if (this.failFailureHook) { - return Promise.reject(new Error('storage unavailable')); + throw new Error('storage unavailable'); } - return Promise.resolve(); } protected override parseMessage(data: string): TestEvent[] { @@ -108,8 +105,8 @@ class TestConnection extends KitRealtimeConnection { }); } - protected override addNetworkStatusListener(): Promise { - return Promise.resolve({ remove: this.removeNetworkListener }); + protected override async addNetworkStatusListener(): Promise { + return { remove: this.removeNetworkListener }; } openForTest(): Promise { @@ -147,8 +144,8 @@ class TestConnection extends KitRealtimeConnection { class InheritedConstructorConnection extends KitRealtimeConnection { protected readonly shouldConnect = false; - protected buildSocketTargets(): Promise<{ url: string; protocols: string[] }[]> { - return Promise.resolve([]); + protected async buildSocketTargets(): Promise<{ url: string; protocols: string[] }[]> { + return []; } } diff --git a/projects/kit/src/lib/realtime/kit-realtime-connection.ts b/projects/kit/src/lib/realtime/kit-realtime-connection.ts index 85a95c9..570a7a7 100644 --- a/projects/kit/src/lib/realtime/kit-realtime-connection.ts +++ b/projects/kit/src/lib/realtime/kit-realtime-connection.ts @@ -184,8 +184,8 @@ export abstract class KitRealtimeConnection { } /** Hook used by authenticated clients to invalidate a token after handshake failure. */ - protected handleConnectionFailure(): Promise { - return Promise.resolve(); + protected async handleConnectionFailure(): Promise { + return; } /** Parse a text WebSocket message into one or more domain events. */ @@ -247,11 +247,9 @@ export abstract class KitRealtimeConnection { } this.listeners.push(networkHandle); })(); - try { - await this.#lifecycleRegistration; - } finally { + await this.#lifecycleRegistration.finally(() => { this.#lifecycleRegistration = null; - } + }); } /** Remove all lifecycle listeners, including listeners whose async registration has not completed yet. */ @@ -310,84 +308,82 @@ export abstract class KitRealtimeConnection { this.#clearReconnectTimer(); this.#opening = true; const generation = this.#generation; + await this.#openGeneration(generation) + .catch(() => this.#requestReconnect()) + .finally(() => { + if (generation === this.#generation) this.#opening = false; + }); + } - try { - const targets = await this.buildSocketTargets(); - if (!this.#canOpen || generation !== this.#generation) { - return; + /** Build and attach the sockets for one lifecycle generation. */ + async #openGeneration(generation: number): Promise { + const targets = await this.buildSocketTargets(); + if (!this.#canOpen || generation !== this.#generation) { + return; + } + const nextTargets = new Map(targets.map((target) => [toKitWebSocketUrl(target.url), target])); + for (const [key, socket] of this.#sockets) { + if (nextTargets.has(key)) { + continue; } - const nextTargets = new Map(targets.map((target) => [toKitWebSocketUrl(target.url), target])); - for (const [key, socket] of this.#sockets) { - if (nextTargets.has(key)) { - continue; - } - const health = this.#health.get(socket); - if (health) { - this.#removeSocket(socket, health); - } + const health = this.#health.get(socket); + if (health) { + this.#removeSocket(socket, health); } - this.#targets = nextTargets; - if (this.#sockets.size === 0) { - this.#clearPingTimer(); + } + this.#targets = nextTargets; + if (this.#sockets.size === 0) { + this.#clearPingTimer(); + } + for (const target of targets) { + const key = toKitWebSocketUrl(target.url); + if (this.#sockets.has(key)) { + continue; } - for (const target of targets) { - const key = toKitWebSocketUrl(target.url); - if (this.#sockets.has(key)) { - continue; + const socket = this.createWebSocket(key, target.protocols); + const health: SocketHealth = { + key, + lastActivityAt: 0, + openTimer: null, + watchdog: new KitRealtimeLivenessWatchdog(this.#options.livenessTimeoutMs, () => this.#connectionFailed(generation, socket)), + }; + this.#sockets.set(key, socket); + this.#health.set(socket, health); + health.openTimer = setTimeout(() => this.#connectionFailed(generation, socket), this.#options.openTimeoutMs); + + socket.onopen = () => { + if (generation !== this.#generation || this.#sockets.get(key) !== socket) { + return; } - const socket = this.createWebSocket(key, target.protocols); - const health: SocketHealth = { - key, - lastActivityAt: 0, - openTimer: null, - watchdog: new KitRealtimeLivenessWatchdog(this.#options.livenessTimeoutMs, () => this.#connectionFailed(generation, socket)), - }; - this.#sockets.set(key, socket); - this.#health.set(socket, health); - health.openTimer = setTimeout(() => this.#connectionFailed(generation, socket), this.#options.openTimeoutMs); - - socket.onopen = () => { - if (generation !== this.#generation || this.#sockets.get(key) !== socket) { - return; - } - this.#clearOpenTimer(health); - this.#markActivity(health); - this.#startPing(); - if (this.isStreamOpen) { - this.#reconnectAttempt = 0; - this.#reconnected$.next(); - } - }; - socket.onmessage = ({ data }) => { - if (generation !== this.#generation || this.#sockets.get(key) !== socket || typeof data !== 'string') { - return; - } - this.#markActivity(health); - if (data === this.#options.pong) { - return; - } - let events: TEvent[]; - try { - events = this.parseMessage(data); - } catch { - // Ignore malformed application messages while retaining the healthy socket. - return; - } - for (const event of events) { - this.#events$.next({ ...event, isSelf: event.originId === this.id }); - } - }; - socket.onerror = () => this.#connectionFailed(generation, socket); - socket.onclose = () => this.#connectionFailed(generation, socket); - } - this.#opening = false; - } catch { - this.#opening = false; - this.#requestReconnect(); - } finally { - if (generation === this.#generation) { - this.#opening = false; - } + this.#clearOpenTimer(health); + this.#markActivity(health); + this.#startPing(); + if (this.isStreamOpen) { + this.#reconnectAttempt = 0; + this.#reconnected$.next(); + } + }; + socket.onmessage = ({ data }) => { + if (generation !== this.#generation || this.#sockets.get(key) !== socket || typeof data !== 'string') { + return; + } + this.#markActivity(health); + if (data === this.#options.pong) { + return; + } + let events: TEvent[]; + try { + events = this.parseMessage(data); + } catch { + // Ignore malformed application messages while retaining the healthy socket. + return; + } + for (const event of events) { + this.#events$.next({ ...event, isSelf: event.originId === this.id }); + } + }; + socket.onerror = () => this.#connectionFailed(generation, socket); + socket.onclose = () => this.#connectionFailed(generation, socket); } } diff --git a/projects/kit/src/lib/storage/kit-auth-email-store.spec.ts b/projects/kit/src/lib/storage/kit-auth-email-store.spec.ts index 6c72b81..83e9de5 100644 --- a/projects/kit/src/lib/storage/kit-auth-email-store.spec.ts +++ b/projects/kit/src/lib/storage/kit-auth-email-store.spec.ts @@ -12,14 +12,12 @@ const fakeStore = (): KitEmailStore & { map: Map } => { const map = new Map(); return { map, - get: (key: string) => Promise.resolve((map.get(key) ?? null) as T | null), - set: (key: string, value: T) => { + get: async (key: string) => (map.get(key) ?? null) as T | null, + set: async (key: string, value: T) => { map.set(key, value); - return Promise.resolve(); }, - remove: (key: string) => { + remove: async (key: string) => { map.delete(key); - return Promise.resolve(); }, }; }; diff --git a/projects/kit/src/lib/storage/kit-clear-storage.spec.ts b/projects/kit/src/lib/storage/kit-clear-storage.spec.ts index 62fbef8..e054ff0 100644 --- a/projects/kit/src/lib/storage/kit-clear-storage.spec.ts +++ b/projects/kit/src/lib/storage/kit-clear-storage.spec.ts @@ -4,14 +4,12 @@ const fakeStore = (): KitClearableStore & { map: Map } => { const map = new Map(); return { map, - get: (key: string) => Promise.resolve((map.get(key) ?? null) as T | null), - set: (key: string, value: T) => { + get: async (key: string) => (map.get(key) ?? null) as T | null, + set: async (key: string, value: T) => { map.set(key, value); - return Promise.resolve(); }, - clear: () => { + clear: async () => { map.clear(); - return Promise.resolve(); }, }; }; diff --git a/projects/kit/src/lib/storage/kit-storage.service.spec.ts b/projects/kit/src/lib/storage/kit-storage.service.spec.ts index 47ba9af..d4ad93d 100644 --- a/projects/kit/src/lib/storage/kit-storage.service.spec.ts +++ b/projects/kit/src/lib/storage/kit-storage.service.spec.ts @@ -29,8 +29,8 @@ class FakeStorageEngine { // We do NOT vi.mock the module; instead we provide this value directly as the DI token // to avoid ESM/transform issues with the real package. class FakeStorage { - create() { - return Promise.resolve(new FakeStorageEngine()); + async create() { + return new FakeStorageEngine(); } } diff --git a/projects/kit/src/lib/utils/dom.spec.ts b/projects/kit/src/lib/utils/dom.spec.ts index de7958d..6274989 100644 --- a/projects/kit/src/lib/utils/dom.spec.ts +++ b/projects/kit/src/lib/utils/dom.spec.ts @@ -1,5 +1,7 @@ import { disableHandler } from './dom'; +const nextMicrotask = (): Promise => new Promise((resolve) => queueMicrotask(resolve)); + describe('disableHandler', () => { function clickEvent() { const button = document.createElement('button'); @@ -9,9 +11,10 @@ describe('disableHandler', () => { it('disables the button while the work runs and re-enables it after', async () => { const { button, event } = clickEvent(); let disabledDuringWork = false; - const work = Promise.resolve().then(() => { + const work = (async () => { + await nextMicrotask(); disabledDuringWork = button.disabled; - }); + })(); await disableHandler(event, work); expect(disabledDuringWork).toBe(true); expect(button.disabled).toBe(false); @@ -38,10 +41,11 @@ describe('disableHandler', () => { } as unknown as SubmitEvent; let disabledDuringWork = false; - await disableHandler( - event, - Promise.resolve().then(() => (disabledDuringWork = button.disabled)), - ); + const work = (async () => { + await nextMicrotask(); + disabledDuringWork = button.disabled; + })(); + await disableHandler(event, work); expect(preventDefault).toHaveBeenCalledOnce(); expect(disabledDuringWork).toBe(true); @@ -71,10 +75,11 @@ describe('disableHandler', () => { } as unknown as SubmitEvent; let disabledDuringWork = false; - await disableHandler( - event, - Promise.resolve().then(() => (disabledDuringWork = ionButton.disabled)), - ); + const work = (async () => { + await nextMicrotask(); + disabledDuringWork = ionButton.disabled; + })(); + await disableHandler(event, work); expect(disabledDuringWork).toBe(true); expect(ionButton.disabled).toBe(false); @@ -103,7 +108,7 @@ describe('disableHandler', () => { preventDefault: vi.fn(), } as unknown as SubmitEvent; - await disableHandler(event, Promise.resolve()); + await disableHandler(event, nextMicrotask()); expect(first.disabled).toBe(false); expect(second.disabled).toBe(true); diff --git a/projects/kit/src/lib/utils/dom.ts b/projects/kit/src/lib/utils/dom.ts index 035f28e..d6d4670 100644 --- a/projects/kit/src/lib/utils/dom.ts +++ b/projects/kit/src/lib/utils/dom.ts @@ -55,16 +55,19 @@ const getDisableTargets = (event: Event): DisableableElement[] => { * Save * ``` */ -export const disableHandler = async (event: Event, work: Promise): Promise => { +export const disableHandler = (event: Event, work: Promise): Promise => { if (event.type === 'submit') event.preventDefault(); const targets = getDisableTargets(event); const disabledStates = targets.map((target) => target.disabled); targets.forEach((target) => (target.disabled = true)); - try { - await work.catch((): undefined => undefined); - } finally { - targets.forEach((target, index) => (target.disabled = disabledStates[index])); - } + return work + .then( + () => undefined, + () => undefined, + ) + .finally(() => { + targets.forEach((target, index) => (target.disabled = disabledStates[index])); + }); };