From fdbe2a589babad14cf94b7fd6a5f0f358ae297e3 Mon Sep 17 00:00:00 2001 From: Yuhuai Liu Date: Thu, 6 Aug 2026 12:31:51 -0400 Subject: [PATCH] fix(maintenance-mode-page): start detection upon app startup --- src/app/app.component.html | 7 ++++++ src/app/app.component.scss | 6 +++++ src/app/app.component.spec.ts | 24 +++++++++++++++++++ src/app/app.component.ts | 10 +++++++- .../components/layout/layout.component.html | 7 ------ .../components/layout/layout.component.scss | 7 ------ .../components/layout/layout.component.ts | 4 ---- .../core/services/maintenance-mode.service.ts | 12 ++++++++++ .../maintenance-mode.service.mock.ts | 6 +++-- 9 files changed, 62 insertions(+), 21 deletions(-) diff --git a/src/app/app.component.html b/src/app/app.component.html index 4997c5280..3e72ea9b0 100644 --- a/src/app/app.component.html +++ b/src/app/app.component.html @@ -1,3 +1,10 @@ + +@if (isMaintenanceMode()) { +
+

{{ 'maintenance.title' | translate }}

+

{{ 'maintenance.message' | translate }}

+
+} diff --git a/src/app/app.component.scss b/src/app/app.component.scss index e69de29bb..62deb123b 100644 --- a/src/app/app.component.scss +++ b/src/app/app.component.scss @@ -0,0 +1,6 @@ +.maintenance-overlay { + position: fixed; + inset: 0; + z-index: 2000; + background: var(--white); +} diff --git a/src/app/app.component.spec.ts b/src/app/app.component.spec.ts index cd2f088b2..cc1f1679b 100644 --- a/src/app/app.component.spec.ts +++ b/src/app/app.component.spec.ts @@ -9,6 +9,7 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { NavigationEnd, ResolveStart, Router } from '@angular/router'; import { ENVIRONMENT } from '@core/provider/environment.provider'; +import { MaintenanceModeService } from '@core/services/maintenance-mode.service'; import { GetCurrentUser } from '@core/store/user'; import { GetEmails, UserEmailsSelectors } from '@core/store/user-emails'; import { AccountEmailModel } from '@osf/shared/models/emails/account-email.model'; @@ -17,6 +18,10 @@ import { CustomDialogService } from '@osf/shared/services/custom-dialog.service' import { provideOSFCore } from '@testing/osf.testing.provider'; import { CustomDialogServiceMockBuilder } from '@testing/providers/custom-dialog-provider.mock'; import { LoaderServiceMock, provideLoaderServiceMock } from '@testing/providers/loader-service.mock'; +import { + MaintenanceModeServiceMock, + MaintenanceModeServiceMockType, +} from '@testing/providers/maintenance-mode.service.mock'; import { RouterMockBuilder, RouterMockType } from '@testing/providers/router-provider.mock'; import { BaseSetupOverrides, mergeSignalOverrides, provideMockStore } from '@testing/providers/store-provider.mock'; @@ -36,6 +41,7 @@ describe('AppComponent', () => { let loaderServiceMock: LoaderServiceMock; let customDialogServiceMock: ReturnType; let gtmServiceMock: { pushTag: Mock }; + let maintenanceModeServiceMock: MaintenanceModeServiceMockType; const unverifiedEmail: AccountEmailModel = { id: 'email-1', @@ -50,6 +56,7 @@ describe('AppComponent', () => { isBrowser?: boolean; unverifiedEmails?: AccountEmailModel[]; googleTagManagerId?: string; + isMaintenanceMode?: boolean; } function setup(overrides: SetupOverrides = {}) { @@ -58,6 +65,7 @@ describe('AppComponent', () => { loaderServiceMock = new LoaderServiceMock(); customDialogServiceMock = CustomDialogServiceMockBuilder.create().withDefaultOpen().build(); gtmServiceMock = { pushTag: vi.fn() }; + maintenanceModeServiceMock = MaintenanceModeServiceMock.simple(overrides.isMaintenanceMode ?? false); TestBed.configureTestingModule({ imports: [AppComponent, ...MockComponents(ToastComponent, FullScreenLoaderComponent)], @@ -67,6 +75,7 @@ describe('AppComponent', () => { MockProvider(Router, routerMock), MockProvider(CustomDialogService, customDialogServiceMock), MockProvider(GoogleTagManagerService, gtmServiceMock), + MockProvider(MaintenanceModeService, maintenanceModeServiceMock), MockProvider(PLATFORM_ID, overrides.isBrowser === false ? 'server' : 'browser'), provideMockStore({ signals: mergeSignalOverrides( @@ -149,6 +158,21 @@ describe('AppComponent', () => { }); }); + it('should probe maintenance mode once on init in browser', () => { + setup(); + expect(maintenanceModeServiceMock.checkOnce).toHaveBeenCalledTimes(1); + }); + + it('should render the maintenance overlay when maintenance mode is active', () => { + setup({ isMaintenanceMode: true }); + expect(fixture.nativeElement.querySelector('.maintenance-overlay')).toBeTruthy(); + }); + + it('should not render the maintenance overlay when maintenance mode is inactive', () => { + setup(); + expect(fixture.nativeElement.querySelector('.maintenance-overlay')).toBeFalsy(); + }); + it('should not subscribe to router events on server', () => { setup({ isBrowser: false }); routerBuilder.emit(new ResolveStart(1, '/next', '/next', {} as any)); diff --git a/src/app/app.component.ts b/src/app/app.component.ts index b71780b08..932d7bd31 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -1,5 +1,7 @@ import { createDispatchMap, select } from '@ngxs/store'; +import { TranslatePipe } from '@ngx-translate/core'; + import { switchMap, timer } from 'rxjs'; import { isPlatformBrowser } from '@angular/common'; @@ -8,6 +10,7 @@ import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { NavigationCancel, NavigationEnd, NavigationError, ResolveStart, Router, RouterOutlet } from '@angular/router'; import { ENVIRONMENT } from '@core/provider/environment.provider'; +import { MaintenanceModeService } from '@core/services/maintenance-mode.service'; import { GetCurrentUser } from '@core/store/user'; import { GetEmails, UserEmailsSelectors } from '@core/store/user-emails'; @@ -21,7 +24,7 @@ import { GoogleTagManagerService } from 'angular-google-tag-manager'; @Component({ selector: 'osf-root', - imports: [RouterOutlet, ToastComponent, FullScreenLoaderComponent], + imports: [RouterOutlet, ToastComponent, FullScreenLoaderComponent, TranslatePipe], templateUrl: './app.component.html', styleUrl: './app.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, @@ -36,8 +39,10 @@ export class AppComponent implements OnInit { private readonly platformId = inject(PLATFORM_ID); private readonly isBrowser = isPlatformBrowser(this.platformId); private readonly loaderService = inject(LoaderService); + private readonly maintenanceModeService = inject(MaintenanceModeService); unverifiedEmails = select(UserEmailsSelectors.getUnverifiedEmails); + isMaintenanceMode = this.maintenanceModeService.isActive; constructor() { effect(() => { @@ -57,6 +62,9 @@ export class AppComponent implements OnInit { .subscribe(); if (this.isBrowser) { + // `GetCurrentUser` short-circuits on a cached user, so it cannot be relied on to surface an outage. + this.maintenanceModeService.checkOnce(); + this.router.events.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((event) => { if (event instanceof ResolveStart) { this.loaderService.show(); diff --git a/src/app/core/components/layout/layout.component.html b/src/app/core/components/layout/layout.component.html index 5f872d40f..c587f4ef4 100644 --- a/src/app/core/components/layout/layout.component.html +++ b/src/app/core/components/layout/layout.component.html @@ -19,13 +19,6 @@ - - @if (isMaintenanceMode()) { -
-

{{ 'maintenance.title' | translate }}

-

{{ 'maintenance.message' | translate }}

-
- } { + if (isMaintenance) { + this.activate(); + } + }); + } + activate(): void { this._isActive.set(true); if (this.pollingSubscription) { diff --git a/src/testing/providers/maintenance-mode.service.mock.ts b/src/testing/providers/maintenance-mode.service.mock.ts index 7db5cdb40..fcea0a289 100644 --- a/src/testing/providers/maintenance-mode.service.mock.ts +++ b/src/testing/providers/maintenance-mode.service.mock.ts @@ -7,15 +7,17 @@ import { MaintenanceModeService } from '@core/services/maintenance-mode.service' export type MaintenanceModeServiceMockType = Partial & { activate: Mock<() => void>; deactivate: Mock<() => void>; + checkOnce: Mock<() => void>; isActive: Signal; }; export const MaintenanceModeServiceMock = { - simple() { + simple(isActive = false) { return { activate: vi.fn(), deactivate: vi.fn(), - isActive: signal(false).asReadonly(), + checkOnce: vi.fn(), + isActive: signal(isActive).asReadonly(), } as MaintenanceModeServiceMockType; }, };