diff --git a/src/app/app.component.spec.ts b/src/app/app.component.spec.ts index 159fbb38f04..628619bdc7a 100644 --- a/src/app/app.component.spec.ts +++ b/src/app/app.component.spec.ts @@ -1,9 +1,15 @@ import { CommonModule } from '@angular/common'; -import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'; +import { + ApplicationRef, + CUSTOM_ELEMENTS_SCHEMA, +} from '@angular/core'; import { ComponentFixture, + fakeAsync, + flush, inject, TestBed, + tick, waitForAsync, } from '@angular/core/testing'; import { @@ -19,6 +25,7 @@ import { TranslateLoader, TranslateModule, } from '@ngx-translate/core'; +import { BehaviorSubject } from 'rxjs'; import { APP_CONFIG } from '../config/app-config.interface'; import { environment } from '../environments/environment'; @@ -149,4 +156,88 @@ describe('App component', () => { }); }); + + describe('removeSsrOverlayWhenStable', () => { + // The inline bootstrap script in src/index.html injects window.__dspaceRemoveSsrOverlay + // and AppComponent must call it exactly once when ApplicationRef.isStable first emits true. + let appRef: ApplicationRef; + let isStable$: BehaviorSubject; + let originalRaF: typeof window.requestAnimationFrame; + let originalIsStable: PropertyDescriptor | undefined; + + beforeEach(() => { + appRef = TestBed.inject(ApplicationRef); + isStable$ = new BehaviorSubject(false); + // Patch isStable to our controllable subject for this test only. Keep it configurable and + // remember the previous descriptor so afterEach can restore it - otherwise the override + // leaks onto the shared TestBed ApplicationRef instance and into later specs. + originalIsStable = Object.getOwnPropertyDescriptor(appRef, 'isStable'); + Object.defineProperty(appRef, 'isStable', { value: isStable$.asObservable(), configurable: true }); + + // Force rAF to a synchronous shim so we can flush() through the chain deterministically. + 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; + // Restore isStable so the patched observable cannot leak into later specs. + if (originalIsStable) { + Object.defineProperty(appRef, 'isStable', originalIsStable); + } else { + delete (appRef as any).isStable; + } + }); + + it('removes the overlay once isStable emits true', fakeAsync(() => { + const spy = jasmine.createSpy('__dspaceRemoveSsrOverlay'); + window.__dspaceRemoveSsrOverlay = spy; + + // Re-construct so the constructor-time subscription picks up our patched isStable + global. + const f = TestBed.createComponent(AppComponent); + f.detectChanges(); + + expect(spy).not.toHaveBeenCalled(); + + isStable$.next(true); + tick(50); // matches the 50ms pad after rAF in removeSsrOverlayWhenStable + flush(); + + expect(spy).toHaveBeenCalledTimes(1); + })); + + 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(); + + isStable$.next(true); + tick(50); + flush(); + + expect(window.__dspaceRemoveSsrOverlay).toBeUndefined(); + })); + + it('still removes the overlay when requestAnimationFrame is unavailable', fakeAsync(() => { + // Exercises the fallback scheduler branch in removeSsrOverlayWhenStable. + const spy = jasmine.createSpy('__dspaceRemoveSsrOverlay'); + window.__dspaceRemoveSsrOverlay = spy; + (window as any).requestAnimationFrame = undefined; + + const f = TestBed.createComponent(AppComponent); + f.detectChanges(); + + isStable$.next(true); + tick(50); + flush(); + + expect(spy).toHaveBeenCalledTimes(1); + })); + }); }); diff --git a/src/app/app.component.ts b/src/app/app.component.ts index 66636c32495..6e23f7b1ede 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -5,10 +5,12 @@ import { } from '@angular/common'; import { AfterViewInit, + ApplicationRef, ChangeDetectionStrategy, Component, HostListener, Inject, + NgZone, OnInit, PLATFORM_ID, } from '@angular/core'; @@ -34,6 +36,8 @@ import { import { delay, distinctUntilChanged, + filter, + first, take, withLatestFrom, } from 'rxjs/operators'; @@ -100,11 +104,14 @@ export class AppComponent implements OnInit, AfterViewInit { private cssService: CSSVariableService, private modalService: NgbModal, private modalConfig: NgbModalConfig, + private appRef: ApplicationRef, + private ngZone: NgZone, ) { this.notificationOptions = environment.notifications; if (isPlatformBrowser(this.platformId)) { this.trackIdleModal(); + this.removeSsrOverlayWhenStable(); } this.isThemeLoading$ = this.themeService.isThemeLoading$; @@ -112,6 +119,40 @@ export class AppComponent implements OnInit, AfterViewInit { this.storeCSSVariables(); } + /** + * Drops the hydration-safe SSR freeze-frame installed by the inline bootstrap script in + * src/index.html once Angular reaches its first stable state. On DSpace 9 (Angular 18) the + * page DOES hydrate, but the theme system re-creates every `ds-themed-*` wrapper imperatively + * on the client (see ThemedComponent), so the SSR view is briefly rebuilt; the overlay clone + * masks that rebuild. We wait for the first stable paint, add a short pad, then fade it out. + * A 15s hard fallback lives inside the script itself in case isStable never fires. + */ + private removeSsrOverlayWhenStable(): void { + const w: Window | undefined = this._window?.nativeWindow; + if (!w || typeof w.__dspaceRemoveSsrOverlay !== 'function') { + return; + } + // run outside Angular so we don't keep changeDetection ticking on the overlay timer + this.ngZone.runOutsideAngular(() => { + this.appRef.isStable.pipe( + filter((stable: boolean) => stable), + first(), + ).subscribe(() => { + // one rAF + small pad to let the first stable paint commit before fading the overlay + const remove = () => { + if (typeof w.__dspaceRemoveSsrOverlay === 'function') { + w.__dspaceRemoveSsrOverlay(); + } + }; + if (typeof w.requestAnimationFrame === 'function') { + w.requestAnimationFrame(() => setTimeout(remove, 50)); + } else { + setTimeout(remove, 50); + } + }); + }); + } + ngOnInit() { /** Implement behavior for interface {@link ModalBeforeDismiss} */ this.modalConfig.beforeDismiss = async function () { diff --git a/src/index.html b/src/index.html index 565fc0439d4..db451d811a6 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; +}