diff --git a/src/app/app.component.spec.ts b/src/app/app.component.spec.ts index 159fbb38f04..c81310832ba 100644 --- a/src/app/app.component.spec.ts +++ b/src/app/app.component.spec.ts @@ -2,8 +2,11 @@ import { CommonModule } from '@angular/common'; import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'; import { ComponentFixture, + discardPeriodicTasks, + fakeAsync, inject, TestBed, + tick, waitForAsync, } from '@angular/core/testing'; import { @@ -14,11 +17,15 @@ import { Store, StoreModule, } from '@ngrx/store'; -import { provideMockStore } from '@ngrx/store/testing'; +import { + MockStore, + provideMockStore, +} from '@ngrx/store/testing'; import { TranslateLoader, TranslateModule, } from '@ngx-translate/core'; +import { BehaviorSubject } from 'rxjs'; import { APP_CONFIG } from '../config/app-config.interface'; import { environment } from '../environments/environment'; @@ -58,7 +65,7 @@ let comp: AppComponent; let fixture: ComponentFixture; const menuService = new MenuServiceStub(); const initialState = { - core: { auth: { loading: false } }, + core: { auth: { loading: false, blocking: false } }, }; export function getMockLocaleService(): LocaleService { @@ -149,4 +156,78 @@ describe('App component', () => { }); }); + + describe('removeSsrOverlayWhenContentVisible', () => { + // The inline bootstrap script in src/index.html injects window.__dspaceRemoveSsrOverlay. + // Once auth blocking and theme loading are both false, AppComponent waits for the DOM + // to settle (no element added/removed for the quiet window) and only then removes the overlay. + let mockStore: MockStore; + let themeLoading$: BehaviorSubject; + let themeService: ThemeService; + let originalRaF: typeof window.requestAnimationFrame; + let dsAppEl: HTMLElement; + + beforeEach(() => { + mockStore = TestBed.inject(MockStore); + themeService = TestBed.inject(ThemeService); + themeLoading$ = new BehaviorSubject(true); + (themeService as any).isThemeLoading$ = themeLoading$.asObservable(); + mockStore.setState({ core: { auth: { loading: false, blocking: true } } }); + + // A settled with real content present, so the DOM-settle watcher can resolve. + dsAppEl = document.createElement('ds-app'); + dsAppEl.setAttribute('style', 'display:block;height:800px'); + dsAppEl.innerHTML = '
home content
'; + document.body.appendChild(dsAppEl); + + // Force rAF to a synchronous shim so assertions are deterministic. + originalRaF = window.requestAnimationFrame; + (window as any).requestAnimationFrame = (cb: FrameRequestCallback) => { + cb(0); + return 0 as any; + }; + }); + + afterEach(() => { + (window as any).requestAnimationFrame = originalRaF; + delete (window as any).__dspaceRemoveSsrOverlay; + if (dsAppEl && dsAppEl.parentNode) { dsAppEl.parentNode.removeChild(dsAppEl); } + }); + + it('removes the overlay once auth/theme are ready AND the DOM has settled', fakeAsync(() => { + const spy = jasmine.createSpy('__dspaceRemoveSsrOverlay'); + window.__dspaceRemoveSsrOverlay = spy; + + // Re-construct so constructor-time subscription picks up our patched streams + global. + const f = TestBed.createComponent(AppComponent); + f.detectChanges(); + + expect(spy).not.toHaveBeenCalled(); + + mockStore.setState({ core: { auth: { loading: false, blocking: false } } }); + themeLoading$.next(false); + + // Not removed at the gate: the DOM-settle quiet window must elapse first. + expect(spy).not.toHaveBeenCalled(); + tick(700); // > SETTLE_QUIET_MS + expect(spy).toHaveBeenCalledTimes(1); + + discardPeriodicTasks(); + })); + + it('is a no-op when the global is not injected (e.g. CSR-only route, SSR skipped)', fakeAsync(() => { + // Global intentionally absent; constructor should not throw and should not break later. + delete (window as any).__dspaceRemoveSsrOverlay; + + const f = TestBed.createComponent(AppComponent); + expect(() => f.detectChanges()).not.toThrow(); + + mockStore.setState({ core: { auth: { loading: false, blocking: false } } }); + themeLoading$.next(false); + tick(700); + + expect(window.__dspaceRemoveSsrOverlay).toBeUndefined(); + discardPeriodicTasks(); + })); + }); }); diff --git a/src/app/app.component.ts b/src/app/app.component.ts index 66636c32495..d625f9a15f2 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -9,6 +9,8 @@ import { Component, HostListener, Inject, + NgZone, + OnDestroy, OnInit, PLATFORM_ID, } from '@angular/core'; @@ -29,12 +31,21 @@ import { import { TranslateService } from '@ngx-translate/core'; import { BehaviorSubject, + combineLatest, Observable, + race, + Subject, + timer, } from 'rxjs'; import { + debounceTime, delay, distinctUntilChanged, + filter, + startWith, + switchMap, take, + takeUntil, withLatestFrom, } from 'rxjs/operators'; @@ -64,10 +75,22 @@ import { ThemeService } from './shared/theme-support/theme.service'; ThemedRootComponent, ], }) -export class AppComponent implements OnInit, AfterViewInit { +export class AppComponent implements OnInit, AfterViewInit, OnDestroy { notificationOptions; models; + /** + * Emits on destroy to tear down the SSR-overlay-removal pipeline (gate subscription, the + * MutationObserver and its debounce/cap timers all unsubscribe via `takeUntil(destroyed$)`). + * AppComponent is the root component so this is mostly defensive + test hygiene. + */ + private destroyed$ = new Subject(); + + /** SSR anti-flicker overlay (see src/index.html) removal tuning. */ + private readonly ssrOverlaySettleQuietMs = 600; // routed page is "done" after this long with no DOM change + private readonly ssrOverlaySettleMaxMs = 10000; // backstop reveal (below index.html's 15s catastrophic net) + private readonly ssrOverlayMinContentHeightPx = 200; // proves is no longer the empty shell + /** * Whether or not the authentication is currently blocking the UI */ @@ -100,11 +123,13 @@ export class AppComponent implements OnInit, AfterViewInit { private cssService: CSSVariableService, private modalService: NgbModal, private modalConfig: NgbModalConfig, + private ngZone: NgZone, ) { this.notificationOptions = environment.notifications; if (isPlatformBrowser(this.platformId)) { this.trackIdleModal(); + this.removeSsrOverlayWhenContentVisible(); } this.isThemeLoading$ = this.themeService.isThemeLoading$; @@ -112,6 +137,110 @@ export class AppComponent implements OnInit, AfterViewInit { this.storeCSSVariables(); } + /** + * Drops the hydration-safe SSR freeze-frame (a detached clone painted on top; see src/index.html) + * once the routed CSR page has actually finished rendering. Earlier revisions waited for + * `ApplicationRef.isStable`, but on DSpace 9 (Angular 18) the theme system re-creates every + * `ds-themed-*` wrapper imperatively on the client, and post-login zone activity keeps isStable + * busy — so the frozen clone lingered (up to the 15s fallback) over an app that had long finished. + * + * Instead we drop the clone once the auth/theme loader gate has opened AND the live DOM + * has SETTLED (no element added/removed for a short quiet window, with real content present) — + * i.e. once the imperative themed re-render is done. This stays decoupled from isStable. + * See {@link routedPageReadyToReveal$}. + */ + private removeSsrOverlayWhenContentVisible(): void { + const win: Window | undefined = this._window?.nativeWindow; + if (!win || typeof win.__dspaceRemoveSsrOverlay !== 'function') { + return; // SSR was skipped for this route, so no overlay was installed — nothing to remove + } + // Run outside Angular: a MutationObserver watching the whole app must not trigger change + // detection (it would also keep ApplicationRef.isStable permanently false). + this.ngZone.runOutsideAngular(() => { + this.routedPageReadyToReveal$().pipe( + takeUntil(this.destroyed$), + ).subscribe(() => { + // one frame so the freshly rendered content is painted before the snapshot fades out + this.runAfterNextFrame(win, () => win.__dspaceRemoveSsrOverlay?.()); + }); + }); + } + + /** + * Emits once when it is safe to drop the SSR snapshot: the auth/theme loader gate has opened + * (same condition root.component.html uses to swap its fullscreen loader for the routed content) + * AND the routed page's DOM has settled. See {@link dsAppDomSettled$}. + */ + private routedPageReadyToReveal$(): Observable { + const loaderGateOpen$ = combineLatest([ + this.store.pipe(select(isAuthenticationBlocking), distinctUntilChanged()), + this.themeService.isThemeLoading$, + ]).pipe( + filter(([authBlocking, themeLoading]: [boolean, boolean]) => !authBlocking && !themeLoading), + take(1), + ); + return loaderGateOpen$.pipe( + switchMap(() => this.dsAppDomSettled$()), + ); + } + + /** + * Emits once when the live subtree stops being mutated (elements added/removed) for + * `ssrOverlaySettleQuietMs` AND it holds real content — or after `ssrOverlaySettleMaxMs`, whichever + * comes first. The cap guarantees a page that never goes quiet (constant background DOM updates) + * still reveals; the 15s fallback in index.html remains the ultimate net. + */ + private dsAppDomSettled$(): Observable { + const dsApp: Element | null = this.document.querySelector('ds-app'); + if (!dsApp) { + return timer(this.ssrOverlaySettleMaxMs); + } + const elementMutations$ = new Observable((subscriber) => { + const observer = new MutationObserver((records) => { + if (records.some((record) => this.isElementChildListChange(record))) { + subscriber.next(); + } + }); + observer.observe(dsApp, { childList: true, subtree: true }); + return () => observer.disconnect(); + }); + const settled$ = elementMutations$.pipe( + startWith(undefined), // start the quiet window immediately + debounceTime(this.ssrOverlaySettleQuietMs), // ... reset by each render, fires once quiet + filter(() => this.dsAppHasRenderedContent(dsApp)), // ... but never on the empty shell + ); + return race(settled$, timer(this.ssrOverlaySettleMaxMs)).pipe(take(1)); + } + + /** True once the live is no longer the empty shell the overlay script left behind. */ + private dsAppHasRenderedContent(dsApp: Element): boolean { + const height = dsApp.getBoundingClientRect?.().height ?? 0; + return height >= this.ssrOverlayMinContentHeightPx && dsApp.querySelector('#main-content') !== null; + } + + /** A childList mutation that adds or removes at least one element node (ignores text/attr noise). */ + private isElementChildListChange(record: MutationRecord): boolean { + if (record.type !== 'childList') { + return false; + } + const changedNodes = [...Array.from(record.addedNodes), ...Array.from(record.removedNodes)]; + return changedNodes.some((node) => node.nodeType === Node.ELEMENT_NODE); + } + + /** Runs `callback` after the next paint (or synchronously if requestAnimationFrame is unavailable). */ + private runAfterNextFrame(win: Window, callback: () => void): void { + if (typeof win.requestAnimationFrame === 'function') { + win.requestAnimationFrame(() => callback()); + } else { + callback(); + } + } + + ngOnDestroy(): void { + this.destroyed$.next(); + this.destroyed$.complete(); + } + ngOnInit() { /** Implement behavior for interface {@link ModalBeforeDismiss} */ this.modalConfig.beforeDismiss = async function () { diff --git a/src/app/shared/mocks/theme-service.mock.ts b/src/app/shared/mocks/theme-service.mock.ts index cefed005684..09462d9d56b 100644 --- a/src/app/shared/mocks/theme-service.mock.ts +++ b/src/app/shared/mocks/theme-service.mock.ts @@ -5,11 +5,15 @@ import { isNotEmpty } from '../empty.util'; import { ThemeService } from '../theme-support/theme.service'; export function getMockThemeService(themeName = 'base', themes?: ThemeConfig[]): ThemeService { + // isThemeLoading$ is a real property getter on ThemeService, so it must be a property on the mock + // (not a spy method) - AppComponent's overlay-removal gate reads it as a stream. const spy = jasmine.createSpyObj('themeService', { getThemeName: themeName, getThemeName$: of(themeName), getThemeConfigFor: undefined, listenForRouteChanges: undefined, + }, { + isThemeLoading$: of(false), }); if (isNotEmpty(themes)) { diff --git a/src/index.html b/src/index.html index 565fc0439d4..bbce851afdf 100644 --- a/src/index.html +++ b/src/index.html @@ -7,10 +7,97 @@ DSpace + + diff --git a/src/typings.d.ts b/src/typings.d.ts index 9615cf5f67b..753233f5780 100644 --- a/src/typings.d.ts +++ b/src/typings.d.ts @@ -86,3 +86,12 @@ declare module '*.scss' { const content: any; export default content; } + +/** + * Window global injected by the inline hydration-safe anti-flicker bootstrap script in + * `src/index.html`. Called once by `AppComponent.removeSsrOverlayWhenStable()` when + * `ApplicationRef.isStable` fires, to drop the SSR freeze-frame overlay. + */ +interface Window { + __dspaceRemoveSsrOverlay?: (() => void) | null; +}