From fa485f7f66c5c38130c89709922e529c91da28f7 Mon Sep 17 00:00:00 2001 From: Juraj Roka <95219754+jr-rk@users.noreply.github.com> Date: Wed, 17 Jun 2026 08:14:10 +0200 Subject: [PATCH 1/5] AI backport of Fix home-page SSR->CSR flicker --- angular.json | 4 +- src/app/app.component.spec.ts | 63 +++++++++++++++- src/app/app.component.ts | 40 +++++++++- src/index.html | 119 ++++++++++++++++++++++++++++++ src/themes/eager-themes.module.ts | 9 ++- src/typings.d.ts | 9 +++ 6 files changed, 237 insertions(+), 7 deletions(-) diff --git a/angular.json b/angular.json index d828887b214..386644ac725 100644 --- a/angular.json +++ b/angular.json @@ -100,8 +100,8 @@ "budgets": [ { "type": "initial", - "maximumWarning": "3mb", - "maximumError": "5mb" + "maximumWarning": "5.5mb", + "maximumError": "6mb" }, { "type": "anyComponentStyle", diff --git a/src/app/app.component.spec.ts b/src/app/app.component.spec.ts index e921c67acea..9294f1ff1dd 100644 --- a/src/app/app.component.spec.ts +++ b/src/app/app.component.spec.ts @@ -1,9 +1,10 @@ import { Store, StoreModule } from '@ngrx/store'; -import { ComponentFixture, inject, TestBed, waitForAsync } from '@angular/core/testing'; -import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'; +import { ComponentFixture, fakeAsync, flush, inject, TestBed, tick, waitForAsync } from '@angular/core/testing'; +import { ApplicationRef, CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'; import { CommonModule } from '@angular/common'; import { ActivatedRoute, Router } from '@angular/router'; import { TranslateLoader, TranslateModule } from '@ngx-translate/core'; +import { BehaviorSubject } from 'rxjs'; // Load the implementations that should be tested import { AppComponent } from './app.component'; @@ -127,4 +128,62 @@ 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; + + beforeEach(() => { + appRef = TestBed.inject(ApplicationRef); + isStable$ = new BehaviorSubject(false); + // Patch isStable to our controllable subject for this test only + Object.defineProperty(appRef, 'isStable', { value: isStable$.asObservable() }); + + // 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; + }); + + 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(); + })); + }); }); diff --git a/src/app/app.component.ts b/src/app/app.component.ts index ba7b7382278..588481e93fe 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -1,11 +1,13 @@ -import { distinctUntilChanged, take, withLatestFrom } from 'rxjs/operators'; +import { distinctUntilChanged, filter, first, take, withLatestFrom } from 'rxjs/operators'; import { DOCUMENT, isPlatformBrowser } from '@angular/common'; import { AfterViewInit, + ApplicationRef, ChangeDetectionStrategy, Component, HostListener, Inject, + NgZone, OnInit, PLATFORM_ID, } from '@angular/core'; @@ -74,6 +76,8 @@ 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; @@ -82,6 +86,7 @@ export class AppComponent implements OnInit, AfterViewInit { if (isPlatformBrowser(this.platformId)) { this.trackIdleModal(); + this.removeSsrOverlayWhenStable(); } this.isThemeLoading$ = this.themeService.isThemeLoading$; @@ -89,6 +94,39 @@ export class AppComponent implements OnInit, AfterViewInit { this.storeCSSVariables(); } + /** + * Drops the SSR mask overlay installed by the inline bootstrap script in src/index.html as soon + * as Angular reaches its first stable state. The overlay is the only thing the user sees while + * Angular 15 rebuilds the SSR DOM; removing it too early would expose the rebuild flicker, too + * late would feel sluggish. We add a short safety pad to let the first paint settle, and there + * is also a 15s hard fallback 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 74fc0c9861b..14a782b7f5e 100644 --- a/src/index.html +++ b/src/index.html @@ -7,12 +7,131 @@ DSpace + + diff --git a/src/themes/eager-themes.module.ts b/src/themes/eager-themes.module.ts index 4a46595f358..29d46032de8 100644 --- a/src/themes/eager-themes.module.ts +++ b/src/themes/eager-themes.module.ts @@ -1,6 +1,6 @@ import { NgModule } from '@angular/core'; import { EagerThemeModule as DSpaceEagerThemeModule } from './dspace/eager-theme.module'; -// import { EagerThemeModule as CustomEagerThemeModule } from './custom/eager-theme.module'; +import { EagerThemeModule as CustomEagerThemeModule } from './custom/eager-theme.module'; /** * This module bundles the eager theme modules for all available themes. @@ -8,11 +8,16 @@ import { EagerThemeModule as DSpaceEagerThemeModule } from './dspace/eager-theme * and entry components (to ensure their decorators get picked up). * * Themes that aren't in use should not be imported here so they don't take up unnecessary space in the main bundle. + * + * NOTE: CustomEagerThemeModule is included to prevent the home-page flicker that occurs when + * the active theme is `custom`. Without it, every themed wrapper (footer, header, root, ...) is + * lazy-loaded via webpack code-splitting on the browser, leaving visible gaps after the SSR DOM + * is torn down and before the CSR DOM is materialised. */ @NgModule({ imports: [ DSpaceEagerThemeModule, - // CustomEagerThemeModule, + CustomEagerThemeModule, ], }) export class EagerThemesModule { diff --git a/src/typings.d.ts b/src/typings.d.ts index c1c86511f88..f7397dc290e 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 anti-flicker bootstrap script in `src/index.html`. + * Called once by `AppComponent.removeSsrOverlayWhenStable()` when `ApplicationRef.isStable` + * fires, to drop the SSR-mask overlay and let the freshly built CSR DOM become visible. + */ +interface Window { + __dspaceRemoveSsrOverlay?: (() => void) | null; +} From 87dc1aa39a18e43f1660f1a93070cbad1d12366f Mon Sep 17 00:00:00 2001 From: Juraj Roka <95219754+jr-rk@users.noreply.github.com> Date: Thu, 18 Jun 2026 11:03:56 +0200 Subject: [PATCH 2/5] Fix: always unhide app when removing SSR anti-flicker overlay The overlay remover bailed out via `if (!el) return;` before unhiding , so if the overlay node went missing (browser extension, race, external script) the app stayed visibility:hidden forever -> blank page, plus the kept SSR styles leaked. Unhide the app and clean up the kept styles unconditionally, before checking for the overlay node. Co-Authored-By: Claude Opus 4.8 --- src/index.html | 23 +++++++++++++++++------ 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/src/index.html b/src/index.html index 14a782b7f5e..95d5d6c0c2c 100644 --- a/src/index.html +++ b/src/index.html @@ -103,17 +103,28 @@ removing = true; window.__dspaceRemoveSsrOverlay = null; - var el = document.getElementById('__dspace_ssr_overlay'); - if (!el) return; + // Always unhide the real and drop the kept SSR styles first, even if the + // overlay node has gone missing (e.g. removed by an extension or another script). + // A bare early return here would otherwise leave the app permanently hidden. app.removeAttribute('data-dspace-ssr-hidden'); - el.style.transition = 'opacity 150ms ease-out'; - el.style.opacity = '0'; - setTimeout(function () { - if (el && el.parentNode) el.parentNode.removeChild(el); + + var removeKeptStyles = function () { for (var i = 0; i < keptStyles.length; i++) { if (keptStyles[i].parentNode) keptStyles[i].parentNode.removeChild(keptStyles[i]); } keptStyles = []; + }; + + var el = document.getElementById('__dspace_ssr_overlay'); + if (!el) { + removeKeptStyles(); + return; + } + el.style.transition = 'opacity 150ms ease-out'; + el.style.opacity = '0'; + setTimeout(function () { + if (el && el.parentNode) el.parentNode.removeChild(el); + removeKeptStyles(); }, 200); }; From 6f8b480af0bd07171c157525f4f45713936f1ca9 Mon Sep 17 00:00:00 2001 From: Juraj Roka <95219754+jr-rk@users.noreply.github.com> Date: Thu, 18 Jun 2026 16:49:44 +0200 Subject: [PATCH 3/5] Test: isolate isStable override and cover the no-rAF overlay path Make the ApplicationRef.isStable override in the removeSsrOverlayWhenStable suite configurable and restore the original descriptor in afterEach, so the patched observable can't leak onto the shared TestBed instance. Add a test for the requestAnimationFrame-absent fallback branch of the remover. Co-Authored-By: Claude Opus 4.8 --- src/app/app.component.spec.ts | 30 ++++++++++++++++++++++++++++-- 1 file changed, 28 insertions(+), 2 deletions(-) diff --git a/src/app/app.component.spec.ts b/src/app/app.component.spec.ts index 9294f1ff1dd..2228d58c39e 100644 --- a/src/app/app.component.spec.ts +++ b/src/app/app.component.spec.ts @@ -135,12 +135,16 @@ describe('App component', () => { 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 - Object.defineProperty(appRef, 'isStable', { value: isStable$.asObservable() }); + // 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; @@ -153,6 +157,12 @@ describe('App component', () => { 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(() => { @@ -185,5 +195,21 @@ describe('App component', () => { 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); + })); }); }); From f81cb56fbc3c2058a12dbd5945e4713719f74fcc Mon Sep 17 00:00:00 2001 From: Juraj Roka <95219754+jr-rk@users.noreply.github.com> Date: Sat, 20 Jun 2026 13:39:33 +0200 Subject: [PATCH 4/5] Refactor: remove SSR overlay on content-visible instead of isStable Propagates VSB-TUO's fix #1317 to this instance. The overlay was removed when ApplicationRef.isStable settled, but isStable can be delayed for seconds by post-login admin zone activity (auth work, background polling, third-party scripts) - during which the live app stays hidden under the SSR mask and the page renders but is non-interactive (dataquest-dev/dspace-customers#725). Switch removal to the same condition root.component.html uses to show real content: !isAuthenticationBlocking && !isThemeLoading. Drop the now-unused ApplicationRef injection and the 50ms pad; keep the 15s hard fallback as a catastrophic safety net. Tests and the theme-service mock updated to match. Ref: #1317 Co-Authored-By: Claude Opus 4.8 --- src/app/app.component.spec.ts | 72 ++++++++-------------- src/app/app.component.ts | 35 ++++++----- src/app/shared/mocks/theme-service.mock.ts | 7 +++ src/index.html | 8 ++- 4 files changed, 56 insertions(+), 66 deletions(-) diff --git a/src/app/app.component.spec.ts b/src/app/app.component.spec.ts index 2228d58c39e..79db5691437 100644 --- a/src/app/app.component.spec.ts +++ b/src/app/app.component.spec.ts @@ -1,6 +1,6 @@ import { Store, StoreModule } from '@ngrx/store'; -import { ComponentFixture, fakeAsync, flush, inject, TestBed, tick, waitForAsync } from '@angular/core/testing'; -import { ApplicationRef, CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'; +import { ComponentFixture, discardPeriodicTasks, fakeAsync, flush, inject, TestBed, waitForAsync } from '@angular/core/testing'; +import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'; import { CommonModule } from '@angular/common'; import { ActivatedRoute, Router } from '@angular/router'; import { TranslateLoader, TranslateModule } from '@ngx-translate/core'; @@ -31,7 +31,7 @@ import { Angulartics2DSpace } from './statistics/angulartics/dspace-provider'; import { storeModuleConfig } from './app.reducer'; import { LocaleService } from './core/locale/locale.service'; import { authReducer } from './core/auth/auth.reducer'; -import { provideMockStore } from '@ngrx/store/testing'; +import { MockStore, provideMockStore } from '@ngrx/store/testing'; import { ThemeService } from './shared/theme-support/theme.service'; import { getMockThemeService } from './shared/mocks/theme-service.mock'; import { BreadcrumbsService } from './breadcrumbs/breadcrumbs.service'; @@ -42,7 +42,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 { @@ -129,24 +129,22 @@ 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; + describe('removeSsrOverlayWhenContentVisible', () => { + // The inline bootstrap script in src/index.html injects window.__dspaceRemoveSsrOverlay. + // AppComponent should remove it once both auth blocking and theme loading are false. + let mockStore: MockStore; + let themeLoading$: BehaviorSubject; + let themeService: ThemeService; 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. + 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 } } }); + + // Force rAF to a synchronous shim so assertions are deterministic. originalRaF = window.requestAnimationFrame; (window as any).requestAnimationFrame = (cb: FrameRequestCallback) => { cb(0); @@ -157,29 +155,24 @@ describe('App component', () => { 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(() => { + it('removes the overlay once auth is unblocked and theme loading is finished', fakeAsync(() => { const spy = jasmine.createSpy('__dspaceRemoveSsrOverlay'); window.__dspaceRemoveSsrOverlay = spy; - // Re-construct so the constructor-time subscription picks up our patched isStable + global. + // Re-construct so constructor-time subscription picks up our patched streams + 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 + mockStore.setState({ core: { auth: { loading: false, blocking: false } } }); + themeLoading$.next(false); flush(); expect(spy).toHaveBeenCalledTimes(1); + discardPeriodicTasks(); })); it('is a no-op when the global is not injected (e.g. CSR-only route, SSR skipped)', fakeAsync(() => { @@ -189,27 +182,12 @@ describe('App component', () => { const f = TestBed.createComponent(AppComponent); expect(() => f.detectChanges()).not.toThrow(); - isStable$.next(true); - tick(50); + mockStore.setState({ core: { auth: { loading: false, blocking: false } } }); + themeLoading$.next(false); 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); + discardPeriodicTasks(); })); }); }); diff --git a/src/app/app.component.ts b/src/app/app.component.ts index 588481e93fe..c5f8e21f082 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -2,7 +2,6 @@ import { distinctUntilChanged, filter, first, take, withLatestFrom } from 'rxjs/ import { DOCUMENT, isPlatformBrowser } from '@angular/common'; import { AfterViewInit, - ApplicationRef, ChangeDetectionStrategy, Component, HostListener, @@ -18,7 +17,7 @@ import { Router, } from '@angular/router'; -import { BehaviorSubject, Observable } from 'rxjs'; +import { BehaviorSubject, combineLatest, Observable } from 'rxjs'; import { select, Store } from '@ngrx/store'; import { NgbModal, NgbModalConfig } from '@ng-bootstrap/ng-bootstrap'; import { TranslateService } from '@ngx-translate/core'; @@ -76,7 +75,6 @@ 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; @@ -86,7 +84,7 @@ export class AppComponent implements OnInit, AfterViewInit { if (isPlatformBrowser(this.platformId)) { this.trackIdleModal(); - this.removeSsrOverlayWhenStable(); + this.removeSsrOverlayWhenContentVisible(); } this.isThemeLoading$ = this.themeService.isThemeLoading$; @@ -95,33 +93,38 @@ export class AppComponent implements OnInit, AfterViewInit { } /** - * Drops the SSR mask overlay installed by the inline bootstrap script in src/index.html as soon - * as Angular reaches its first stable state. The overlay is the only thing the user sees while - * Angular 15 rebuilds the SSR DOM; removing it too early would expose the rebuild flicker, too - * late would feel sluggish. We add a short safety pad to let the first paint settle, and there - * is also a 15s hard fallback inside the script itself in case isStable never fires. + * Drops the SSR mask overlay installed by the inline bootstrap script in src/index.html the + * moment the real CSR content is actually visible. We do NOT wait for ApplicationRef.isStable + * (which can be delayed many seconds by ongoing zone tasks, e.g. admin-only background HTTP + * polling, periodic timers, third-party AAI/discojuice scripts). Instead we react to the same + * condition root.component.html uses to swap the fullscreen loader for the real content: + * `!isAuthenticationBlocking && !isThemeLoading`. At that exact point the routed page is + * rendered, so removing the SSR snapshot does not produce flicker. One rAF delay lets the + * change-detection result commit to the DOM before the overlay fades. */ - private removeSsrOverlayWhenStable(): void { + private removeSsrOverlayWhenContentVisible(): 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 + // run outside Angular so the subscription does not keep change detection alive this.ngZone.runOutsideAngular(() => { - this.appRef.isStable.pipe( - filter((stable: boolean) => stable), + combineLatest([ + this.store.pipe(select(isAuthenticationBlocking), distinctUntilChanged()), + this.themeService.isThemeLoading$, + ]).pipe( + filter(([blocking, themeLoading]: [boolean, boolean]) => !blocking && !themeLoading), 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)); + w.requestAnimationFrame(remove); } else { - setTimeout(remove, 50); + remove(); } }); }); diff --git a/src/app/shared/mocks/theme-service.mock.ts b/src/app/shared/mocks/theme-service.mock.ts index e3c2960e51f..095e8ec6007 100644 --- a/src/app/shared/mocks/theme-service.mock.ts +++ b/src/app/shared/mocks/theme-service.mock.ts @@ -4,11 +4,18 @@ import { ThemeConfig } from '../../../config/theme.model'; import { isNotEmpty } from '../empty.util'; export function getMockThemeService(themeName = 'base', themes?: ThemeConfig[]): ThemeService { + // getThemeName$ is a real method on ThemeService (called as getThemeName$()), + // so it must stay a spy method that returns an Observable. + // isThemeLoading$ is a real property getter on ThemeService, so it must be a + // property on the mock - not a spy method - or AsyncPipe / combineLatest will + // receive a function instead of a stream. const spy = jasmine.createSpyObj('themeService', { getThemeName: themeName, getThemeName$: observableOf(themeName), getThemeConfigFor: undefined, listenForRouteChanges: undefined, + }, { + isThemeLoading$: observableOf(false), }); if (isNotEmpty(themes)) { diff --git a/src/index.html b/src/index.html index 95d5d6c0c2c..1f92c0a32d7 100644 --- a/src/index.html +++ b/src/index.html @@ -9,7 +9,7 @@ @@ -40,18 +48,21 @@ available before Angular 16, so on every browser load Angular tears down the entire SSR DOM and re-renders the component tree from scratch. The rebuild takes ~600-1500 ms on slow connections, during which the user sees the SSR view -> blank/half-built CSR view -> final CSR view. - This script captures the SSR DOM as a non-interactive snapshot the moment it's parsed (before - any module/main script runs - those are type=module and therefore deferred). While Angular - rebuilds the real invisibly, the snapshot keeps the page looking stable. AppComponent - removes the overlay once the real CSR content becomes visible. + This script captures the SSR DOM as a snapshot the moment it's parsed (before any module/main + script runs - those are type=module and therefore deferred). While Angular rebuilds the real + underneath (visually covered by the opaque snapshot, but still interactive), the snapshot + keeps the page looking stable. AppComponent removes the overlay once the routed CSR page has + finished rendering (its DOM has settled) - see AppComponent.removeSsrOverlayWhenDomSettles. */ (function () { if (typeof window === 'undefined' || typeof document === 'undefined') return; - // Skip when Cypress is driving the page. The overlay duplicates SSR DOM (moved into the - // overlay) alongside the CSR DOM (rendered into ) during the masking window — so - // any cy.get('#some-id').click() picks up two elements and fails. The overlay is a pure - // UX nicety, and Cypress E2E doesn't measure visual smoothness anyway; bail early. + // Skip when an E2E runner is driving the page. The overlay duplicates SSR DOM (moved into the + // overlay) alongside the CSR DOM (rendered into ) during the masking window — so a + // strict-mode locator like cy.get('#x')/page.locator('#x') picks up two elements and fails. The + // overlay is a pure UX nicety and E2E doesn't measure visual smoothness, so bail early for both + // Cypress (window.Cypress) and any WebDriver-based runner (Playwright/Selenium: navigator.webdriver). if (typeof window.Cypress !== 'undefined') return; + if (typeof navigator !== 'undefined' && navigator.webdriver) return; try { var app = document.querySelector('ds-app'); // If SSR was skipped for this route (excludePathPatterns), there are no children; nothing to mask. @@ -78,53 +89,44 @@ // so the overlay is pixel-identical to what the user already saw before Angular booted. // Cloning via innerHTML loses parent-context-dependent rendering. // - // Accessibility note: we deliberately do NOT set aria-hidden on the overlay. The overlay - // *is* the visible page during the masking window, so assistive technologies should read - // it. The original underneath gets visibility:hidden (via attribute + CSS rule), - // which removes both itself and its children from the accessibility tree. + // Accessibility: the snapshot is now a purely VISUAL mask, so we mark it aria-hidden. The real + // underneath is no longer visibility:hidden — it stays in the accessibility tree and + // is the interactive surface — so assistive tech (and mouse clicks) target the live, functional + // app rather than a soon-to-be-removed duplicate snapshot. This also avoids the duplicate + // a11y nodes the old (overlay-as-a11y-surface) approach produced during masking. var overlay = document.createElement('div'); overlay.id = '__dspace_ssr_overlay'; + overlay.setAttribute('aria-hidden', 'true'); while (app.firstChild) { overlay.appendChild(app.firstChild); } - // Hide the now-empty so Angular can rebuild into it invisibly. We use an attribute - // (CSS in targets it) rather than setting .style.visibility directly so Angular's - // template doesn't blow it away on first ChangeDetection. + // Mark as being masked. NOTE: this attribute is now only a state hook — it no longer + // hides the element (the visibility:hidden CSS rule was removed) so the live app stays visible + // (covered by the opaque overlay) and, crucially, INTERACTIVE while Angular rebuilds into it. app.setAttribute('data-dspace-ssr-hidden', ''); document.body.appendChild(overlay); var removing = false; window.__dspaceRemoveSsrOverlay = function () { - // Re-entrancy guard: null the pointer up-front so a racing isStable + 15s safety + // Re-entrancy guard: null the pointer up-front so a racing DOM-settle removal + 15s safety // fallback cannot start two interleaving fade-out passes (which would re-remove // the kept styles from underneath the first pass). if (removing) return; removing = true; window.__dspaceRemoveSsrOverlay = null; - // Always unhide the real and drop the kept SSR styles first, even if the - // overlay node has gone missing (e.g. removed by an extension or another script). - // A bare early return here would otherwise leave the app permanently hidden. - app.removeAttribute('data-dspace-ssr-hidden'); - - var removeKeptStyles = function () { - for (var i = 0; i < keptStyles.length; i++) { - if (keptStyles[i].parentNode) keptStyles[i].parentNode.removeChild(keptStyles[i]); - } - keptStyles = []; - }; - var el = document.getElementById('__dspace_ssr_overlay'); - if (!el) { - removeKeptStyles(); - return; - } + if (!el) return; + app.removeAttribute('data-dspace-ssr-hidden'); el.style.transition = 'opacity 150ms ease-out'; el.style.opacity = '0'; setTimeout(function () { if (el && el.parentNode) el.parentNode.removeChild(el); - removeKeptStyles(); + for (var i = 0; i < keptStyles.length; i++) { + if (keptStyles[i].parentNode) keptStyles[i].parentNode.removeChild(keptStyles[i]); + } + keptStyles = []; }, 200); }; diff --git a/src/typings.d.ts b/src/typings.d.ts index f7397dc290e..445b5ecd2c7 100644 --- a/src/typings.d.ts +++ b/src/typings.d.ts @@ -89,8 +89,9 @@ declare module '*.scss' { /** * Window global injected by the inline anti-flicker bootstrap script in `src/index.html`. - * Called once by `AppComponent.removeSsrOverlayWhenStable()` when `ApplicationRef.isStable` - * fires, to drop the SSR-mask overlay and let the freshly built CSR DOM become visible. + * Called once by `AppComponent.removeSsrOverlayWhenContentVisible()` (via + * `removeSsrOverlayWhenDomSettles()`) once the routed CSR page's DOM has settled, to drop the + * SSR-mask overlay and let the freshly built CSR DOM become visible. */ interface Window { __dspaceRemoveSsrOverlay?: (() => void) | null;