Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,6 @@ const { ready } = vi.hoisted(() => ({ ready: vi.fn() }));
vi.mock('@capawesome/capacitor-live-update', () => ({ LiveUpdate: { ready } }));

describe('provideLiveUpdateReadiness', () => {
const flushFrame = () => new Promise<void>((resolve) => requestAnimationFrame(() => setTimeout(resolve)));

afterEach(() => {
ready.mockReset();
vi.restoreAllMocks();
Expand All @@ -22,6 +20,10 @@ describe('provideLiveUpdateReadiness', () => {
const stable = new ReplaySubject<boolean>(1);
const routerEvents = new Subject<unknown>();
vi.spyOn(Capacitor, 'isNativePlatform').mockReturnValue(true);
vi.spyOn(globalThis, 'requestAnimationFrame').mockImplementation((callback) => {
callback(0);
return 1;
});
ready.mockResolvedValue({
previousBundleId: null,
currentBundleId: null,
Expand All @@ -41,8 +43,7 @@ describe('provideLiveUpdateReadiness', () => {
expect(ready).not.toHaveBeenCalled();

routerEvents.next(new NavigationEnd(1, '/', '/'));
await flushFrame();
expect(ready).toHaveBeenCalledOnce();
await vi.waitFor(() => expect(ready).toHaveBeenCalledOnce());
});

it('does not initialize Live Update on the web', () => {
Expand Down
9 changes: 6 additions & 3 deletions projects/kit/offline/src/lib/offline-auth-bridge.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { provideZonelessChangeDetection, signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import type { RouterStateSnapshot } from '@angular/router';
import type { KitAuthAccessLease, KitRemoteAccessRecovery } from '@rdlabo/ionic-angular-kit';
import { describe, expect, it, vi } from 'vitest';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { firstValueFrom, of } from 'rxjs';
import { createOfflineAuthBridge, type OfflineAuthExchangeContext, type OfflineRemoteIdentity } from './offline-auth-bridge';
import type { OfflineCoordinatorService } from './offline-coordinator.service';
Expand Down Expand Up @@ -68,6 +68,8 @@ function setupBridge(
}

describe('createOfflineAuthBridge', () => {
afterEach(() => TestBed.resetTestingModule());

it('orders exchange, prepareRemoteSession, grant, and resumeRemoteSession', async () => {
const { bridge, offline, order } = setupBridge();
const { lease } = createLease();
Expand Down Expand Up @@ -199,8 +201,9 @@ describe('createOfflineAuthBridge', () => {
}),
);

await expect(firstValueFrom(bridge.remoteRecovery!.availability())).resolves.toBe(false);
TestBed.resetTestingModule();
const availability = firstValueFrom(bridge.remoteRecovery!.availability());
TestBed.flushEffects();
await expect(availability).resolves.toBe(false);
});

it('uses a custom availability observable when supplied', () => {
Expand Down
117 changes: 116 additions & 1 deletion projects/kit/src/lib/utils/dom.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,125 @@ describe('disableHandler', () => {

it('re-enables the button even when the work rejects', async () => {
const { button, event } = clickEvent();
await disableHandler(event, Promise.reject(new Error('boom')));
const result: Promise<void> = disableHandler(event, Promise.reject(new Error('boom')));
await expect(result).resolves.toBeUndefined();
expect(button.disabled).toBe(false);
});

it('restores the button when reading a foreign thenable throws', async () => {
const { button, event } = clickEvent();
const work = Object.defineProperty({}, 'then', {
get: () => {
throw new Error('invalid thenable');
},
}) as PromiseLike<void>;

await expect(disableHandler(event, work)).resolves.toBeUndefined();
expect(button.disabled).toBe(false);
});

it('keeps the button disabled until overlapping work has settled', async () => {
const { button, event } = clickEvent();
let finishFirst!: () => void;
let finishSecond!: () => void;
const first = new Promise<void>((resolve) => (finishFirst = resolve));
const second = new Promise<void>((resolve) => (finishSecond = resolve));

const firstResult = disableHandler(event, first);
const secondResult = disableHandler(event, second);
finishFirst();
await firstResult;

expect(button.disabled).toBe(true);

finishSecond();
await secondResult;
expect(button.disabled).toBe(false);
});

it('restores an initially disabled button after overlapping work settles in reverse order', async () => {
const { button, event } = clickEvent();
button.disabled = true;
let finishFirst!: () => void;
let rejectSecond!: (reason: unknown) => void;
const first = new Promise<void>((resolve) => (finishFirst = resolve));
const second = new Promise<void>((_, reject) => (rejectSecond = reject));

const firstResult = disableHandler(event, first);
const secondResult = disableHandler(event, second);
rejectSecond(new Error('second failed'));
await secondResult;

expect(button.disabled).toBe(true);

finishFirst();
await firstResult;
expect(button.disabled).toBe(true);
});

it('accepts work whose type can be either synchronous or asynchronous', async () => {
const { event } = clickEvent();
const invoke = (work: void | PromiseLike<void>): void | Promise<void> => disableHandler(event, work);

await invoke(nextMicrotask());
});

it('supports thenables without requiring a finally method', async () => {
const { button, event } = clickEvent();
const pending = nextMicrotask();
const work: PromiseLike<void> = {
then: pending.then.bind(pending),
};

const result = disableHandler(event, work);

expect(button.disabled).toBe(true);
await result;
expect(button.disabled).toBe(false);
});

it('accepts synchronous click work without changing the disabled state', () => {
const { button, event } = clickEvent();

const result: void = disableHandler(event, undefined);

expect(result).toBeUndefined();
expect(button.disabled).toBe(false);
});

it('uses the current target when a nested element is clicked', async () => {
const button = document.createElement('button');
const icon = document.createElement('span');
button.appendChild(icon);
const event = { target: icon, currentTarget: button } as unknown as Event;

const result = disableHandler(event, nextMicrotask());
expect(button.disabled).toBe(true);
await result;
expect(button.disabled).toBe(false);
});

it('restores a detached target after work settles', async () => {
const { button, event } = clickEvent();
document.body.appendChild(button);
const result = disableHandler(event, nextMicrotask());
button.remove();

await result;
expect(button.disabled).toBe(false);
});

it('prevents form navigation for synchronous submit work', () => {
const form = document.createElement('form');
const preventDefault = vi.fn();
const event = { type: 'submit', target: form, currentTarget: form, preventDefault } as unknown as SubmitEvent;

const result = disableHandler(event, undefined);

expect(result).toBeUndefined();
expect(preventDefault).toHaveBeenCalledOnce();
});

it('prevents form navigation and disables a native submitter', async () => {
const form = document.createElement('form');
const button = document.createElement('button');
Expand Down
52 changes: 38 additions & 14 deletions projects/kit/src/lib/utils/dom.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
type DisableableElement = HTMLElement & { disabled: boolean };
interface DisableState {
count: number;
original: boolean;
}

const disableStates = new WeakMap<DisableableElement, DisableState>();

const isDisableable = (element: EventTarget | null): element is DisableableElement =>
element instanceof HTMLElement && 'disabled' in element;
Expand Down Expand Up @@ -36,6 +42,25 @@ const getDisableTargets = (event: Event): DisableableElement[] => {
return [...new Set(targets)];
};

const acquireDisableTarget = (target: DisableableElement): void => {
const state = disableStates.get(target);
if (state) {
state.count += 1;
return;
}
disableStates.set(target, { count: 1, original: target.disabled });
target.disabled = true;
};

const releaseDisableTarget = (target: DisableableElement): void => {
const state = disableStates.get(target);
if (!state) return;
state.count -= 1;
if (state.count > 0) return;
target.disabled = state.original;
disableStates.delete(target);
};

/**
* Disable the controls that triggered an event while an async operation runs.
*
Expand All @@ -47,27 +72,26 @@ const getDisableTargets = (event: Event): DisableableElement[] => {
* controls always recover; handle errors inside `work` when the caller needs to react.
*
* @param event - The click or submit event that triggered the operation.
* @param work - The async operation to run while the controls are disabled.
* @returns A Promise that resolves once the work has settled and the controls have been restored.
* @param work - The operation result. Promise-like work keeps controls disabled until it settles;
* void work still shares this entry point and submit prevention without a synthetic async boundary.
* @returns A Promise for asynchronous work, otherwise void.
* @example
* ```html
* <form #formRef (submit)="helper.disableHandler($event, save())"></form>
* <ion-button type="submit" [form]="formRef">Save</ion-button>
* ```
*/
export const disableHandler = (event: Event, work: Promise<void | boolean>): Promise<void> => {
export function disableHandler(event: Event, work: void): void;
export function disableHandler(event: Event, work: PromiseLike<unknown>): Promise<void>;
export function disableHandler(event: Event, work: void | PromiseLike<unknown>): void | Promise<void>;
export function disableHandler(event: Event, work: void | PromiseLike<unknown>): void | Promise<void> {
if (event.type === 'submit') event.preventDefault();

if (work === undefined) return;

const targets = getDisableTargets(event);
const disabledStates = targets.map((target) => target.disabled);
targets.forEach((target) => (target.disabled = true));
targets.forEach(acquireDisableTarget);

return work
.then(
() => undefined,
() => undefined,
)
.finally(() => {
targets.forEach((target, index) => (target.disabled = disabledStates[index]));
});
};
const restore = () => targets.forEach(releaseDisableTarget);
return new Promise<unknown>((resolve) => resolve(work)).then(restore, restore);
}
2 changes: 2 additions & 0 deletions projects/photo-editor/editor/src/lib/photo-editor.page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ import { dictionaryForEditor, initializeEditorIcons } from './internals';
})
/** Ionic modal page for cropping, rotating, filtering, and saving a photo. */
export class PhotoEditorPage implements OnDestroy, ViewDidEnter, ViewDidLeave {
declare static modalReturn: PhotoEditorResult;

protected readonly modalCtrl = inject(ModalController);
readonly #config = inject(PHOTO_EDITOR_CONFIG);

Expand Down
2 changes: 2 additions & 0 deletions projects/photo-editor/viewer/src/lib/photo-viewer.page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ import { dictionaryForViewer, initializeViewerIcons, ionComponents } from './int
})
/** Ionic modal page for zooming, browsing, and optionally deleting photos. */
export class PhotoViewerPage implements OnInit, OnDestroy {
declare static modalReturn: PhotoViewerResult;

readonly imageUrls = input.required<string[]>();
readonly index = input<number, NumberInput>(0, {
transform: coerceNumberProperty,
Expand Down