From c6d18f75a5b1a8ec6eefdf1cb5f09356958dad12 Mon Sep 17 00:00:00 2001 From: rdlabo Date: Thu, 3 Sep 2026 09:17:12 +0900 Subject: [PATCH 1/2] feat(kit): add confirmed service worker updates --- .../src/lib/kit-app-update.provider.spec.ts | 144 ++++++++++++++- .../src/lib/kit-app-update.provider.ts | 166 +++++++++++++++--- projects/kit/docs/optional-features.md | 27 ++- .../overlay/kit-overlay.controller.spec.ts | 28 ++- .../src/lib/overlay/kit-overlay.controller.ts | 21 +++ scripts/test-package-consumer.mjs | 15 +- 6 files changed, 368 insertions(+), 33 deletions(-) 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 6d2e38a..b7ce108 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 @@ -1,5 +1,5 @@ import { DOCUMENT } from '@angular/common'; -import { provideZonelessChangeDetection } from '@angular/core'; +import { inject, provideZonelessChangeDetection } from '@angular/core'; import { TestBed } from '@angular/core/testing'; import { SwUpdate } from '@angular/service-worker'; import type { UnrecoverableStateEvent, VersionEvent } from '@angular/service-worker'; @@ -80,6 +80,20 @@ describe('provideKitAppUpdate', () => { expect(checkForUpdate).toHaveBeenCalledOnce(); }); + it('requires a prompt only when the confirm strategy is selected', () => { + // @ts-expect-error Runtime guard retained for untyped JavaScript consumers. + expect(() => provideKitAppUpdate({ strategy: 'confirm' })).toThrowError( + 'provideKitAppUpdate confirm strategy requires promptForUpdate', + ); + expect(() => + provideKitAppUpdate({ strategy: 'background', promptForUpdate: async () => false } as unknown as Parameters< + typeof provideKitAppUpdate + >[0]), + ).toThrowError('provideKitAppUpdate promptForUpdate is only valid with the confirm strategy'); + expect(() => provideKitAppUpdate({ strategy: 'background' })).not.toThrow(); + expect(() => provideKitAppUpdate()).not.toThrow(); + }); + it('background strategy does not block bootstrap while the update check is pending', () => { const { checkForUpdate, reload } = setupBackground(new Promise(() => undefined)); @@ -105,6 +119,100 @@ describe('provideKitAppUpdate', () => { expect(reload).not.toHaveBeenCalled(); }); + it('waits until the first render before prompting for a ready update', async () => { + const promptForUpdate = vi.fn(async () => true); + const { service, reload, versionUpdates$ } = setupConfirm(new Promise(() => undefined), { promptForUpdate }); + + versionUpdates$.next({ + type: 'VERSION_READY', + currentVersion: { hash: 'current' }, + latestVersion: { hash: 'latest' }, + }); + + expect(promptForUpdate).not.toHaveBeenCalled(); + expect(reload).not.toHaveBeenCalled(); + + service.markInteractive(); + + await vi.waitFor(() => expect(reload).toHaveBeenCalledOnce()); + expect(promptForUpdate).toHaveBeenCalledOnce(); + }); + + it('keeps the current application running when the user defers an update', async () => { + const promptForUpdate = vi.fn(async () => false); + const { service, reload } = setupConfirm(true, { promptForUpdate }); + + service.markInteractive(); + + await vi.waitFor(() => expect(promptForUpdate).toHaveBeenCalledOnce()); + expect(reload).not.toHaveBeenCalled(); + }); + + it('retries when another UI prevents the update prompt from being presented', async () => { + vi.useFakeTimers(); + const promptForUpdate = vi.fn().mockResolvedValueOnce(undefined).mockResolvedValueOnce(false); + const { service, reload } = setupConfirm(true, { promptForUpdate }); + + service.markInteractive(); + await vi.advanceTimersByTimeAsync(0); + + expect(promptForUpdate).toHaveBeenCalledOnce(); + await vi.advanceTimersByTimeAsync(999); + expect(promptForUpdate).toHaveBeenCalledOnce(); + await vi.advanceTimersByTimeAsync(1); + + expect(promptForUpdate).toHaveBeenCalledTimes(2); + expect(reload).not.toHaveBeenCalled(); + }); + + it('prompts only once when the version event and update check report the same update', async () => { + const promptForUpdate = vi.fn(async () => false); + const { service, versionUpdates$ } = setupConfirm(true, { promptForUpdate }); + service.markInteractive(); + + versionUpdates$.next({ + type: 'VERSION_READY', + currentVersion: { hash: 'current' }, + latestVersion: { hash: 'latest' }, + }); + + await vi.waitFor(() => expect(promptForUpdate).toHaveBeenCalledOnce()); + }); + + it('runs the update prompt in the application injection context', async () => { + const promptForUpdate = vi.fn(async () => Boolean(inject(DOCUMENT))); + const { service, reload } = setupConfirm(true, { promptForUpdate }); + + service.markInteractive(); + + await vi.waitFor(() => expect(reload).toHaveBeenCalledOnce()); + }); + + it('keeps the current application running when the update prompt fails', async () => { + const error = new Error('overlay unavailable'); + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined); + const promptForUpdate = vi.fn(async () => { + throw error; + }); + const { service, reload } = setupConfirm(true, { promptForUpdate }); + + service.markInteractive(); + + await vi.waitFor(() => expect(consoleError).toHaveBeenCalledWith('Angular service-worker update prompt failed', error)); + expect(reload).not.toHaveBeenCalled(); + }); + + it('does not check or prompt when Angular service workers are disabled', () => { + const promptForUpdate = vi.fn(async () => true); + const { service, checkForUpdate, reload } = setupConfirm(true, { isEnabled: false, promptForUpdate }); + + service.markInteractive(); + + expect(checkForUpdate).not.toHaveBeenCalled(); + expect(promptForUpdate).not.toHaveBeenCalled(); + expect(reload).not.toHaveBeenCalled(); + }); + it('does not clear a recovery guard in the page that started navigation', () => { const unrecoverable$ = new Subject(); const storage = new Map(); @@ -196,14 +304,33 @@ function setup(result: boolean | Error | Promise, isEnabled = true, isC return { service: TestBed.inject(KitAppUpdateService), checkForUpdate, reload }; } -interface BackgroundSetupOptions { +interface NonBlockingSetupOptions { unrecoverable$?: Subject; stored?: Map; storage?: { getItem(key: string): string | null; setItem(key: string, value: string): unknown; removeItem(key: string): unknown }; href?: string; + isEnabled?: boolean; + isControlled?: boolean; } -function setupBackground(result: boolean | Promise, options: BackgroundSetupOptions = {}) { +interface ConfirmSetupOptions extends NonBlockingSetupOptions { + promptForUpdate: () => Promise; +} + +function setupConfirm(result: boolean | Promise, options: ConfirmSetupOptions) { + const { promptForUpdate, ...setupOptions } = options; + return setupNonBlocking(result, setupOptions, provideKitAppUpdate({ strategy: 'confirm', promptForUpdate })); +} + +function setupBackground(result: boolean | Promise, options: NonBlockingSetupOptions = {}) { + return setupNonBlocking(result, options, provideKitAppUpdate({ strategy: 'background' })); +} + +function setupNonBlocking( + result: boolean | Promise, + options: NonBlockingSetupOptions, + updateProvider: ReturnType, +) { TestBed.resetTestingModule(); const unrecoverable$ = options.unrecoverable$ ?? new Subject(); const stored = options.stored ?? new Map(); @@ -220,16 +347,21 @@ function setupBackground(result: boolean | Promise, options: Background TestBed.configureTestingModule({ providers: [ provideZonelessChangeDetection(), - provideKitAppUpdate({ strategy: 'background' }), + updateProvider, { provide: SwUpdate, - useValue: { isEnabled: true, checkForUpdate, versionUpdates: versionUpdates$, unrecoverable: unrecoverable$ }, + useValue: { + isEnabled: options.isEnabled ?? true, + checkForUpdate, + versionUpdates: versionUpdates$, + unrecoverable: unrecoverable$, + }, }, { provide: DOCUMENT, useValue: { defaultView: { - navigator: { serviceWorker: { controller: {} } }, + navigator: { serviceWorker: { controller: (options.isControlled ?? true) ? {} : null } }, history: { state: historyState, replaceState }, sessionStorage: options.storage ?? { getItem: (key: string) => stored.get(key) ?? null, 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 0fd96df..81fee81 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 @@ -1,20 +1,47 @@ import { DOCUMENT } from '@angular/common'; import type { EnvironmentProviders } from '@angular/core'; -import { Injectable, afterNextRender, inject, makeEnvironmentProviders, provideAppInitializer } from '@angular/core'; +import { + EnvironmentInjector, + Injectable, + afterNextRender, + inject, + makeEnvironmentProviders, + provideAppInitializer, + runInInjectionContext, +} from '@angular/core'; import { SwUpdate } from '@angular/service-worker'; import type { UnrecoverableStateEvent, VersionEvent } from '@angular/service-worker'; import { filter, take } from 'rxjs'; const UPDATE_CHECK_TIMEOUT_MS = 10_000; +const UPDATE_PROMPT_RETRY_MS = 1_000; const UNRECOVERABLE_RELOAD_KEY = 'kit_sw_unrecoverable_reload'; -/** Configuration for Angular service-worker application updates. */ +/** Asks whether a downloaded service-worker update should be applied now. `undefined` requests a later retry. */ +export type KitAppUpdatePrompt = () => Promise; + +/** Existing configuration for startup-blocking or background service-worker updates. */ export interface KitAppUpdateOptions { - /** `blocking` preserves startup safety; `background` never delays bootstrap and reloads only before interaction. */ strategy?: 'blocking' | 'background'; } -/** Checks for a complete Angular service-worker update before users can interact with the application. */ +/** Uses a non-blocking update check and asks the user before reloading. */ +export interface KitConfirmAppUpdateOptions { + strategy: 'confirm'; + /** + * Runs after the first render when a complete update is ready. + * + * Resolve `true` to reload, `false` when the user explicitly defers, or `undefined` when UI could not be presented and + * should be retried. The provider supplies an Angular injection context only for the callback's synchronous execution, + * so resolve injected dependencies before crossing an async boundary. + */ + promptForUpdate: KitAppUpdatePrompt; +} + +/** Configuration accepted by {@link provideKitAppUpdate}. */ +export type KitAppUpdateProviderOptions = (KitAppUpdateOptions & { promptForUpdate?: never }) | KitConfirmAppUpdateOptions; + +/** Coordinates complete Angular service-worker updates using blocking, background, or user-confirmed application. */ @Injectable({ providedIn: 'root' }) export class KitAppUpdateService { readonly #document = inject(DOCUMENT); @@ -23,6 +50,11 @@ export class KitAppUpdateService { #backgroundStarted = false; #interactive = false; #reloading = false; + #updateReady = false; + #prompting = false; + #prompted = false; + #promptRetryScheduled = false; + #promptForUpdate: KitAppUpdatePrompt | undefined; /** Runs one startup update check and reloads directly into a downloaded version when one is available. */ initialize(): Promise { @@ -44,16 +76,26 @@ export class KitAppUpdateService { /** Starts an update check that never blocks bootstrap or reloads after the application becomes interactive. */ startBackground(): void { + this.#startNonBlocking(); + } + + /** Starts a non-blocking update check and asks after the first render before applying a ready update. */ + startConfirm(promptForUpdate: KitAppUpdatePrompt): void { + this.#startNonBlocking(promptForUpdate); + } + + #startNonBlocking(promptForUpdate?: KitAppUpdatePrompt): void { if (this.#backgroundStarted || !this.#canCheckForUpdate()) { return; } this.#backgroundStarted = true; + this.#promptForUpdate = promptForUpdate; this.#updates.versionUpdates .pipe( filter((event: VersionEvent) => event.type === 'VERSION_READY'), take(1), ) - .subscribe(() => this.#reloadBeforeInteraction()); + .subscribe(() => this.#handleUpdateReady()); this.#updates.unrecoverable.pipe(take(1)).subscribe((event: UnrecoverableStateEvent) => { console.error('Angular service-worker state is unrecoverable', event.reason); this.#recoverBeforeInteraction(event.reason); @@ -62,7 +104,7 @@ export class KitAppUpdateService { .checkForUpdate() .then((available) => { if (available) { - this.#reloadBeforeInteraction(); + this.#handleUpdateReady(); } }) .catch((error: unknown) => console.error('Angular service-worker update check failed', error)); @@ -74,6 +116,7 @@ export class KitAppUpdateService { if (!this.#reloading) { this.#clearRecoveryBypass(); } + this.#promptWhenReady(); } #canCheckForUpdate(): boolean { @@ -88,6 +131,65 @@ export class KitAppUpdateService { this.#document.location?.reload(); } + #handleUpdateReady(): void { + if (!this.#promptForUpdate) { + this.#reloadBeforeInteraction(); + return; + } + this.#updateReady = true; + this.#promptWhenReady(); + } + + #promptWhenReady(): void { + const promptForUpdate = this.#promptForUpdate; + if ( + !this.#interactive || + !this.#updateReady || + !promptForUpdate || + this.#prompting || + this.#prompted || + this.#promptRetryScheduled || + this.#reloading + ) { + return; + } + this.#prompting = true; + void runUpdatePrompt(promptForUpdate) + .then((confirmed) => { + this.#prompting = false; + if (confirmed === undefined) { + this.#schedulePromptRetry(); + return; + } + this.#prompted = true; + this.#applyPromptResult(confirmed); + }) + .catch((error: unknown) => { + this.#prompting = false; + this.#prompted = true; + console.error('Angular service-worker update prompt failed', error); + }); + } + + #schedulePromptRetry(): void { + if (this.#promptRetryScheduled || this.#prompted || this.#reloading) { + return; + } + this.#promptRetryScheduled = true; + globalThis.setTimeout(() => { + this.#promptRetryScheduled = false; + this.#promptWhenReady(); + }, UPDATE_PROMPT_RETRY_MS); + } + + #applyPromptResult(confirmed: boolean): void { + if (!confirmed || this.#reloading) { + return; + } + this.#reloading = true; + this.#document.location?.reload(); + } + #recoverBeforeInteraction(reason: string): void { const location = this.#document.location; if (this.#interactive || this.#reloading || !location) { @@ -169,23 +271,47 @@ function clearFailure(storage: Storage | undefined): void { } /** - * Provides a startup check that reloads into the latest complete web application version. + * Provides blocking, background, or user-confirmed adoption of complete Angular service-worker updates. * * @remarks - * The check finishes before application bootstrap so a delayed update cannot discard user input. It times out rather - * than preventing offline startup. API deployments must remain backward compatible while a newly adopted updater is - * rolling out because code already running in older application versions cannot gain this behavior retroactively. + * The default `blocking` strategy checks before bootstrap and times out rather than preventing offline startup. The + * existing `background` strategy never delays bootstrap and reloads only before the first render. The opt-in `confirm` + * strategy also checks in the background, but waits until after the first render and reloads only with user approval. + * API deployments must remain backward compatible while an updater is rolling out because already-running older code + * cannot gain this behavior retroactively. */ -export function provideKitAppUpdate(options: KitAppUpdateOptions = {}): EnvironmentProviders { - const initializer = - options.strategy === 'background' - ? provideAppInitializer(() => { - const updates = inject(KitAppUpdateService); - updates.startBackground(); - afterNextRender(() => updates.markInteractive()); - }) - : provideAppInitializer(() => inject(KitAppUpdateService).initialize()); - return makeEnvironmentProviders([initializer]); +export function provideKitAppUpdate(options: KitAppUpdateProviderOptions = {}): EnvironmentProviders { + if (options.strategy === 'confirm') { + const promptForUpdate = options.promptForUpdate; + if (typeof promptForUpdate !== 'function') { + throw new Error('provideKitAppUpdate confirm strategy requires promptForUpdate'); + } + return makeEnvironmentProviders([ + provideAppInitializer(() => { + const updates = inject(KitAppUpdateService); + const injector = inject(EnvironmentInjector); + updates.startConfirm(() => runInInjectionContext(injector, promptForUpdate)); + afterNextRender(() => updates.markInteractive()); + }), + ]); + } + if (options.promptForUpdate !== undefined) { + throw new Error('provideKitAppUpdate promptForUpdate is only valid with the confirm strategy'); + } + if (options.strategy === 'background') { + return makeEnvironmentProviders([ + provideAppInitializer(() => { + const updates = inject(KitAppUpdateService); + updates.startBackground(); + afterNextRender(() => updates.markInteractive()); + }), + ]); + } + return makeEnvironmentProviders([provideAppInitializer(() => inject(KitAppUpdateService).initialize())]); +} + +async function runUpdatePrompt(promptForUpdate: KitAppUpdatePrompt): Promise { + return promptForUpdate(); } function withTimeout(promise: Promise, timeoutMs: number): Promise { diff --git a/projects/kit/docs/optional-features.md b/projects/kit/docs/optional-features.md index 8221ef7..0d730dc 100644 --- a/projects/kit/docs/optional-features.md +++ b/projects/kit/docs/optional-features.md @@ -11,10 +11,29 @@ Applications that prefetch every executable application chunk can opt into a non provideKitAppUpdate({ strategy: 'background' }); ``` -The background strategy reloads only before Angular completes its first render. A later update is left for the next natural page -load so user input is not discarded. It never calls `activateUpdate()`, which could mix a running shell with lazy chunks from -another version. An unrecoverable startup generation is retried once with `ngsw-bypass`; the current history state and an -offline-safe loop guard are retained. +The existing background strategy reloads only before Angular completes its first render. A later update is left for the next +natural page load so user input is not discarded. + +Applications can select the separate `confirm` strategy to offer an immediate, user-controlled update. The prompt runs after +the first render and in an Angular injection context. Resolve dependencies synchronously before crossing an async boundary. +Resolving `true` reloads into the complete downloaded version, `false` leaves it for the next natural page load, and `undefined` +retries later because the prompt could not be presented (for example, while another alert is active). + +```ts +provideKitAppUpdate({ + strategy: 'confirm', + promptForUpdate: () => + inject(KitOverlayController).tryAlertConfirm({ + header: 'An update is available', + message: 'Update now? The application will reload.', + okText: 'Update', + }), +}); +``` + +Neither non-blocking strategy calls `activateUpdate()`, which could mix a running shell with lazy chunks from another version. +An unrecoverable startup generation is retried once with `ngsw-bypass`; the current history state and an offline-safe loop guard +are retained. ## Theme and review 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 414c2a1..b1103ce 100644 --- a/projects/kit/src/lib/overlay/kit-overlay.controller.spec.ts +++ b/projects/kit/src/lib/overlay/kit-overlay.controller.spec.ts @@ -53,16 +53,18 @@ function setup({ popoverOverlay = fakeOverlay(), toastOverlay = fakeOverlay(), alertOverlay = fakeOverlay(), + activeAlert, }: { modalOverlay?: ReturnType; popoverOverlay?: ReturnType; toastOverlay?: ReturnType; alertOverlay?: ReturnType; + activeAlert?: ReturnType; } = {}) { const modalCtrl = { create: vi.fn().mockResolvedValue(modalOverlay) }; const popoverCtrl = { create: vi.fn().mockResolvedValue(popoverOverlay) }; const toastCtrl = { create: vi.fn().mockResolvedValue(toastOverlay) }; - const alertCtrl = { create: vi.fn().mockResolvedValue(alertOverlay) }; + const alertCtrl = { create: vi.fn().mockResolvedValue(alertOverlay), getTop: vi.fn().mockResolvedValue(activeAlert) }; TestBed.configureTestingModule({ providers: [ @@ -193,6 +195,30 @@ describe('KitOverlayController', () => { await p; }); + it('reports when tryAlertConfirm could not present because another alert is active', async () => { + const first = deferredAlert(); + const { controller, alertCtrl } = setup({ alertOverlay: first.overlay }); + const p = controller.alertConfirm({ header: 'H', message: 'M', okText: 'OK' }); + + const blocked = await controller.tryAlertConfirm({ header: 'H2', message: 'M2', okText: 'OK' }); + + expect(blocked).toBeUndefined(); + expect(alertCtrl.create).toHaveBeenCalledOnce(); + first.dismiss(); + await p; + }); + + it('reports an alert presented outside KitOverlayController instead of stacking over it', async () => { + const externalAlert = fakeOverlay(); + const { controller, alertCtrl } = setup({ activeAlert: externalAlert }); + + const blocked = await controller.tryAlertConfirm({ header: 'H', message: 'M', okText: 'OK' }); + + expect(blocked).toBeUndefined(); + expect(alertCtrl.getTop).toHaveBeenCalledOnce(); + expect(alertCtrl.create).not.toHaveBeenCalled(); + }); + it('allows a new alert after the previous one dismisses', async () => { const first = deferredAlert(); const { controller, alertCtrl } = setup({ alertOverlay: first.overlay }); diff --git a/projects/kit/src/lib/overlay/kit-overlay.controller.ts b/projects/kit/src/lib/overlay/kit-overlay.controller.ts index 72bdecf..4a6462e 100644 --- a/projects/kit/src/lib/overlay/kit-overlay.controller.ts +++ b/projects/kit/src/lib/overlay/kit-overlay.controller.ts @@ -390,6 +390,27 @@ export class KitOverlayController { if (this.#alertPresenting) { return false; } + return this.#presentAlertConfirm(options); + } + + /** + * Try to present a confirmation alert without stacking it over another alert. + * + * @returns `true` for confirmation, `false` for explicit cancellation or backdrop dismissal, and `undefined` when + * another alert prevented this one from being presented. + */ + async tryAlertConfirm(options: KitAlertConfirmOptions): Promise { + if (this.#alertPresenting) { + return undefined; + } + const activeAlert = await this.#alertCtrl.getTop(); + if (activeAlert || this.#alertPresenting) { + return undefined; + } + return this.#presentAlertConfirm(options); + } + + async #presentAlertConfirm(options: KitAlertConfirmOptions): Promise { this.#alertPresenting = true; const present = async (): Promise => { const alert = await this.#alertCtrl.create({ diff --git a/scripts/test-package-consumer.mjs b/scripts/test-package-consumer.mjs index 7c76418..d8faeaf 100644 --- a/scripts/test-package-consumer.mjs +++ b/scripts/test-package-consumer.mjs @@ -106,6 +106,7 @@ try { writeFileSync( join(temporaryDirectory, 'consumer.ts'), `import { type KitAuthInputMode } from '@rdlabo/ionic-angular-kit'; +import { provideKitAppUpdate, type KitAppUpdateOptions, type KitAppUpdateProviderOptions } from '@rdlabo/ionic-angular-kit/app-update'; import { KitIonicFormField, provideKitIonicSignalForms } from '@rdlabo/ionic-angular-kit/forms'; import { providePhotoEditor, type PhotoEditorProps, type PhotoEditorResult, type PhotoViewerProps, type PhotoViewerResult } from '@rdlabo/ionic-angular-photo-editor'; import { PhotoEditorPage } from '@rdlabo/ionic-angular-photo-editor/editor'; @@ -122,8 +123,18 @@ const viewerProps: PhotoViewerProps = { imageUrls: [], toolbarColorScheme: 'ligh const editorResult: PhotoEditorResult = { action: 'save', value: editorProps.value }; const viewerResult: PhotoViewerResult = { action: 'delete', index: 0, value: '' }; const photoProviders = providePhotoEditor({ maxSize: 1000, labels: { camera: 'Camera' } }); -const symbols = [KitIonicFormField, provideKitIonicSignalForms, providePhotoEditor, PhotoEditorPage, createTuiImageEditor, PhotoFileService, loadCapacitorPhotoCamera, PhotoViewerPage, ScrollHeaderDirective, CdkDynamicSizeVirtualScroll]; -void [mode, viewerProps, editorResult, viewerResult, photoProviders, symbols, calculateItemCountForPixelDistance([{ itemSize: 10 }], 5)]; +const backgroundUpdate: KitAppUpdateOptions = { strategy: 'background' }; +interface ConsumerUpdateOptions extends KitAppUpdateOptions { consumerLabel?: string } +class ConsumerUpdateClass implements KitAppUpdateOptions { readonly strategy = 'background' as const; } +const extendedBackgroundUpdate: ConsumerUpdateOptions = { strategy: 'background', consumerLabel: 'consumer' }; +const confirmUpdate: KitAppUpdateProviderOptions = { strategy: 'confirm', promptForUpdate: async () => true }; +// @ts-expect-error confirm requires a prompt +const invalidConfirmUpdate: KitAppUpdateProviderOptions = { strategy: 'confirm' }; +// @ts-expect-error only confirm accepts a prompt +const invalidBackgroundUpdate: KitAppUpdateProviderOptions = { strategy: 'background', promptForUpdate: async () => false }; +const updateProviders = [provideKitAppUpdate(backgroundUpdate), provideKitAppUpdate(extendedBackgroundUpdate), provideKitAppUpdate(new ConsumerUpdateClass()), provideKitAppUpdate(confirmUpdate)]; +const symbols = [KitIonicFormField, provideKitIonicSignalForms, provideKitAppUpdate, providePhotoEditor, PhotoEditorPage, createTuiImageEditor, PhotoFileService, loadCapacitorPhotoCamera, PhotoViewerPage, ScrollHeaderDirective, CdkDynamicSizeVirtualScroll]; +void [mode, viewerProps, editorResult, viewerResult, photoProviders, invalidConfirmUpdate, invalidBackgroundUpdate, updateProviders, symbols, calculateItemCountForPixelDistance([{ itemSize: 10 }], 5)]; `, ); writeFileSync( From 29e1e168fa1f985bb0e951c0a6f46cd243a35f4b Mon Sep 17 00:00:00 2001 From: rdlabo Date: Thu, 3 Sep 2026 09:40:01 +0900 Subject: [PATCH 2/2] docs(kit): clarify update prompt timing --- projects/kit/docs/optional-features.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/projects/kit/docs/optional-features.md b/projects/kit/docs/optional-features.md index 0d730dc..3a10e68 100644 --- a/projects/kit/docs/optional-features.md +++ b/projects/kit/docs/optional-features.md @@ -24,9 +24,9 @@ provideKitAppUpdate({ strategy: 'confirm', promptForUpdate: () => inject(KitOverlayController).tryAlertConfirm({ - header: 'An update is available', - message: 'Update now? The application will reload.', - okText: 'Update', + header: 'The latest version is ready', + message: 'Reload now to update?', + okText: 'Reload and update', }), }); ```