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
2 changes: 1 addition & 1 deletion .github/workflows/lint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
project: [ demo, photo-editor, scroll-header, scroll-strategies]
project: [ demo, kit, photo-editor, scroll-header, scroll-strategies]
steps:
- uses: actions/checkout@v5
- uses: actions/setup-node@v5
Expand Down
34 changes: 34 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

26 changes: 16 additions & 10 deletions projects/kit/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -127,8 +127,8 @@ export class MyPage {
readonly #overlay = inject(KitOverlayController);

async openDetail(): Promise<void> {
const result = await this.#overlay.presentModal<{ id: number }>(DetailPage, { item });
// result is the data passed to modal.dismiss()
const result = await this.#overlay.presentModal(DetailPage, { item });
// result type is inferred from `declare static modalReturn` on DetailPage
}

async confirm(): Promise<void> {
Expand All @@ -149,11 +149,12 @@ export class MyPage {
**API**

```typescript
presentModal<O>(
component: ModalOptions['component'],
componentProps?: ModalOptions['componentProps'],
options?: KitModalPresentOptions, // Omit<ModalOptions, 'component'|'componentProps'> + watchKeyboard?
): Promise<O | undefined>
presentModal<C extends ModalOptions['component']>(
component: C,
...args: ModalPresentArgs<C>, // props inferred from input() fields; options?: KitModalPresentOptions
): Promise<ModalReturnOf<C> | undefined>
// Props inferred from the component's input() fields (required/optional).
// Return type inferred from `declare static modalReturn: T` on the component (void if absent).

presentPopover<O>(
component: PopoverOptions['component'],
Expand Down Expand Up @@ -182,9 +183,14 @@ alertConfirm(options: {
**Best practice — the modal launcher pattern.** Never call `modalController.create(...)` inline in a component. Instead, each modal/popover page exports a typed launcher next to itself and every call site goes through `KitOverlayController`:

```typescript
// detail.page.ts
export const launchDetailPage = (overlay: KitOverlayController, props: DetailProps): Promise<DetailResult | undefined> =>
overlay.presentModal<DetailResult>(DetailPage, props, { backdropDismiss: false });
// detail.page.ts — component declares its return type:
export class DetailPage {
declare static modalReturn: DetailResult;
readonly item = input.required<Item>();
}

export const launchDetailPage = (overlay: KitOverlayController, props: { item: Item }): Promise<DetailResult | undefined> =>
overlay.presentModal(DetailPage, props, { backdropDismiss: false });
```

This centralizes presentation options, keeps component props and dismiss data type-safe, and makes every modal discoverable. A well-disciplined app has **zero** inline `controller.create()` calls.
Expand Down
126 changes: 126 additions & 0 deletions projects/kit/src/lib/overlay/kit-loading.controller.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
import { provideZonelessChangeDetection } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { LoadingController } from '@ionic/angular/standalone';

import { KitLoadingController } from './kit-loading.controller';

// ---------------------------------------------------------------------------
// Fake loading element factories
// ---------------------------------------------------------------------------
function fakeLoading() {
return {
present: vi.fn().mockResolvedValue(undefined),
dismiss: vi.fn().mockResolvedValue(undefined),
};
}

// A loading whose present() stays pending until we resolve it, to reproduce a dismiss that arrives
// mid-presentation.
function deferredLoading() {
let resolvePresent!: () => void;
const loading = {
present: vi.fn().mockReturnValue(new Promise<void>((r) => (resolvePresent = r))),
dismiss: vi.fn().mockResolvedValue(undefined),
};
return { loading, resolvePresent: () => resolvePresent() };
}

function setup(loading: ReturnType<typeof fakeLoading> = fakeLoading()) {
const loadingCtrl = { create: vi.fn().mockResolvedValue(loading) };

TestBed.configureTestingModule({
providers: [provideZonelessChangeDetection(), KitLoadingController, { provide: LoadingController, useValue: loadingCtrl }],
});

return { controller: TestBed.inject(KitLoadingController), loadingCtrl, loading };
}

// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe('KitLoadingController', () => {
afterEach(() => {
TestBed.resetTestingModule();
});

it('presents a single indicator and passes options through to create', async () => {
const { controller, loadingCtrl, loading } = setup();
await controller.presentLoading({ message: 'Loading…' });
expect(loadingCtrl.create).toHaveBeenCalledOnce();
expect(loadingCtrl.create).toHaveBeenCalledWith({ message: 'Loading…' });
expect(loading.present).toHaveBeenCalledOnce();
await controller.dismissLoading();
});

it('shows one indicator for concurrent presents and dismisses only after the last release', async () => {
const { controller, loadingCtrl, loading } = setup();
await controller.presentLoading();
await controller.presentLoading();
expect(loadingCtrl.create).toHaveBeenCalledOnce();

await controller.dismissLoading();
expect(loading.dismiss).not.toHaveBeenCalled(); // one reference still outstanding

await controller.dismissLoading();
expect(loading.dismiss).toHaveBeenCalledOnce();
});

it('is a no-op when dismissing while nothing is loading', async () => {
const { controller, loadingCtrl, loading } = setup();
await controller.dismissLoading();
expect(loadingCtrl.create).not.toHaveBeenCalled();
expect(loading.dismiss).not.toHaveBeenCalled();
});

it('shows nothing when a present is immediately balanced by a dismiss', async () => {
const { controller, loadingCtrl } = setup();
const p = controller.presentLoading();
const d = controller.dismissLoading();
await Promise.all([p, d]);
expect(loadingCtrl.create).not.toHaveBeenCalled();
});

it('does not orphan the indicator when dismiss arrives before present() settles', async () => {
const deferred = deferredLoading();
const { controller, loadingCtrl } = setup(deferred.loading);

const p1 = controller.presentLoading(); // create resolves, then present() is left pending
// Wait until we are genuinely mid-presentation (present() called but not settled), so the dismiss
// lands inside the create→present window — the exact race a naive ref-count would orphan.
await vi.waitFor(() => expect(deferred.loading.present).toHaveBeenCalled());

const p2 = controller.dismissLoading(); // count 0 → queued behind the still-pending present
deferred.resolvePresent(); // present() settles → present task finishes → queued dismiss runs
await Promise.all([p1, p2]);

expect(loadingCtrl.create).toHaveBeenCalledOnce();
expect(deferred.loading.present).toHaveBeenCalledOnce();
// The key assertion: the indicator is torn down after present() settled, not left orphaned.
expect(deferred.loading.dismiss).toHaveBeenCalledOnce();
});

it('creates a fresh indicator on a new cycle after full dismissal', async () => {
const { controller, loadingCtrl } = setup();
await controller.presentLoading();
await controller.dismissLoading();
await controller.presentLoading();
expect(loadingCtrl.create).toHaveBeenCalledTimes(2);
await controller.dismissLoading();
});

it('a failed present rolls back its reference and does not wedge later operations', async () => {
const { controller, loadingCtrl, loading } = setup();
loadingCtrl.create.mockRejectedValueOnce(new Error('boom'));

// The failed reference is rolled back internally — no compensating dismissLoading() is needed.
await expect(controller.presentLoading()).rejects.toThrow('boom');

loadingCtrl.create.mockResolvedValue(loading);
await controller.presentLoading();
expect(loading.present).toHaveBeenCalledOnce();

// A single dismiss tears the indicator down: proof the counter is balanced (not stuck at 1).
await controller.dismissLoading();
expect(loading.dismiss).toHaveBeenCalledOnce();
});
});
119 changes: 119 additions & 0 deletions projects/kit/src/lib/overlay/kit-loading.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import { inject, Injectable } from '@angular/core';
import type { LoadingOptions } from '@ionic/angular/standalone';
import { LoadingController } from '@ionic/angular/standalone';

/**
* Reference-counted wrapper around Ionic's `LoadingController` that keeps at most one loading
* indicator on screen across concurrent async work.
*
* @remarks
* Each {@link presentLoading} increments a counter and each {@link dismissLoading} decrements it; the
* indicator is presented on the `0 → 1` transition and dismissed on the `N → 0` transition. This
* removes the flicker / "stuck spinner" bugs that come from every service calling
* `LoadingController.create/dismiss` independently.
*
* All operations are serialized through an internal promise chain, so a `create → present` sequence
* can never interleave with a concurrent `dismiss`. That is what makes the counter race-safe: a
* dismiss that arrives while the indicator is still being presented runs *after* `present()` settles
* and therefore tears the element down instead of leaving it orphaned on screen.
*
* Always pair every `presentLoading()` with exactly one `dismissLoading()` — a `try/finally` is the
* safest shape.
*
* @example
* ```ts
* constructor(private readonly loading: KitLoadingController) {}
*
* async save(): Promise<void> {
* await this.loading.presentLoading({ message: 'Saving…' });
* try {
* await this.api.save();
* } finally {
* await this.loading.dismissLoading();
* }
* }
* ```
*/
@Injectable({
providedIn: 'root',
})
export class KitLoadingController {
readonly #loadingCtrl = inject(LoadingController);

/** Outstanding {@link presentLoading} calls not yet balanced by {@link dismissLoading}. */
#count = 0;

/** The single presented loading element, or `null` when none is on screen. */
#loading: HTMLIonLoadingElement | null = null;

/**
* 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<void> = Promise.resolve();

/**
* Show the loading indicator, or join the one already on screen.
*
* @param options - Ionic loading options; only applied by the call that actually creates the
* 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<void> {
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) {
// 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;
}
}

/**
* Release one reference; dismiss the indicator once the last reference is gone.
*
* @returns a Promise that resolves once the reference is released (and the indicator dismissed if
* this was the last one). No-ops when the counter is already at zero.
*/
async dismissLoading(): Promise<void> {
if (this.#count === 0) {
return;
}
this.#count--;
await this.#enqueue(async () => {
// Tear down only when the last consumer is gone. Because this runs after any in-flight
// present() has settled (via the queue), there is never an orphaned loading element.
if (this.#count === 0 && this.#loading !== null) {
const loading = this.#loading;
this.#loading = null;
await loading.dismiss();
}
});
}

/**
* Append `task` to the serialization chain and return its completion.
*
* @remarks
* The stored chain swallows rejections so a single failing operation cannot wedge every future
* overlay; the returned promise still rejects so the caller observes the error.
*/
#enqueue(task: () => Promise<void>): Promise<void> {
const run = this.#queue.then(task);
this.#queue = run.then(
() => undefined,
() => undefined,
);
return run;
}
}
Loading