From 5c1b90dd73800e6d964947c675bab56435a0c7f1 Mon Sep 17 00:00:00 2001 From: Juraj Roka <95219754+jr-rk@users.noreply.github.com> Date: Wed, 17 Jun 2026 10:06:39 +0200 Subject: [PATCH 1/6] Backport of Fix home-page SSR->CSR flicker --- _build.log | Bin 0 -> 48638 bytes _spec.log | Bin 0 -> 3910 bytes 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 +++ 8 files changed, 237 insertions(+), 7 deletions(-) create mode 100644 _build.log create mode 100644 _spec.log diff --git a/_build.log b/_build.log new file mode 100644 index 0000000000000000000000000000000000000000..2a4f7f5b026f651883d286f4776666d06582d95e GIT binary patch literal 48638 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 literal 0 HcmV?d00001 diff --git a/_spec.log b/_spec.log new file mode 100644 index 0000000000000000000000000000000000000000..76c8b04698bede217e7033f23fc9f454af76798a GIT binary patch literal 3910 zcmchaT~Av_5Qe9>mGTFAwL+~#)B@%kTv-Y@kVGY^3Pe>E<$@T;aq*XqO{jCxKi%to zJnsyPj|sILp+u|WbI$DSoSAptnO*<;&)Ckau%0D;Fa7@BR&CkV81ehSzP4|f?^%oA zku`0|Z?Yvu8)?HjtUO0vW2WGJ1)Bp#zAq!|TNg0T zzUQexv&FrvD;#?G{0FwHZa>)*bQF>FM~;Rtn)WpLR|k03#(wAt2k8m%>~pe3tiMA; zG3yJukI)&|Lr~I?4|~XxTho`TWGgvh%45~G8PfV5X{?c*wtYaiW?N`)Fc$1b=5sX4 zBRjd~b{pL`$ew#X7r}WJXChy-8BoRc2v1abEqIaamA$qfxF7SKu7AC02e7ok z`vBcGvYvAy`>MDO;Z=t*wqJNsrA>T2z`p9LAlh?uBd%bss-wM^du|6~=Ip6Fl|Ef66&AhVHG0W@x|vPLx3ICnwZ@F*Sv9T6WjW&I67EXXHg%OXW{=*NgC|A z-KkXnvwP|lEE!={9Y>jI@EvhQU9bU{l7ClR>bj;7__Syg_jFe7jz(3WZ2f=NofXNv zK5dfWRq$QCpd%{}oJBF|ms!tX8Pt$c}2N$Iy4!aN9Onxx-lJzIM|Q_S|MGxr!#goRcd*??5HcWS*x1Re$uBO_W8Pejnz6| z=t+G*HSz|{4sWqaD};ei{2i?cQj5GTvu2suIzAk`y)8#Azq5CNLxFw8p0F?e%Wrua+zbp7(x`=4uT$1EGD2lY)bg?8nQC}Nsc*<) z?tS`3oSn+JevF**o>`WWQylpe5EYs8n)5R4!MVpVAi6k>MaG`uqqQx=98L`wK>i zTjE9RS3Uci=LcUYg5a5aJ+YVAdrq|;+i&z2$;5>Xbd`BoR>V2~dGCv8PpLocd`GN| zHL_P{95};eZ5|!z)$TP^ak(Di)GQ<~VZF}VIy0qv*{?Y=QB%T2r+xi{5H=c$|0R+O zE_K$PeTQU`tHNHYECuE|kBD`#E9TXoVnJsntp? i>dfNLfBO3}XUxZNN_{tW9qrj`e(qbPV+`pkiuXU%jbbDK literal 0 HcmV?d00001 diff --git a/angular.json b/angular.json index bf3dd88c524..2f49e105344 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 cfaeb91a21eb4b6e41a0e82ced754e9b519be3ae Mon Sep 17 00:00:00 2001 From: Juraj Roka <95219754+jr-rk@users.noreply.github.com> Date: Thu, 18 Jun 2026 11:08:49 +0200 Subject: [PATCH 2/6] 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 a7518b78edad6d5b80f589ac99ae50249fe65dd1 Mon Sep 17 00:00:00 2001 From: Juraj Roka <95219754+jr-rk@users.noreply.github.com> Date: Thu, 18 Jun 2026 11:08:49 +0200 Subject: [PATCH 3/6] Chore: drop accidentally committed build/spec logs _build.log and _spec.log are local deploy-tooling output that should never have been tracked. Remove them and gitignore /_*.log. Co-Authored-By: Claude Opus 4.8 --- .gitignore | 3 +++ _build.log | Bin 48638 -> 0 bytes _spec.log | Bin 3910 -> 0 bytes 3 files changed, 3 insertions(+) delete mode 100644 _build.log delete mode 100644 _spec.log diff --git a/.gitignore b/.gitignore index bdab34cb367..92a4bb3dd42 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,9 @@ /tsd_typings/ npm-debug.log +# build/install/spec logs emitted by local deploy tooling +/_*.log + /build/ /coverage diff --git a/_build.log b/_build.log deleted file mode 100644 index 2a4f7f5b026f651883d286f4776666d06582d95e..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 48638 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 diff --git a/_spec.log b/_spec.log deleted file mode 100644 index 76c8b04698bede217e7033f23fc9f454af76798a..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 3910 zcmchaT~Av_5Qe9>mGTFAwL+~#)B@%kTv-Y@kVGY^3Pe>E<$@T;aq*XqO{jCxKi%to zJnsyPj|sILp+u|WbI$DSoSAptnO*<;&)Ckau%0D;Fa7@BR&CkV81ehSzP4|f?^%oA zku`0|Z?Yvu8)?HjtUO0vW2WGJ1)Bp#zAq!|TNg0T zzUQexv&FrvD;#?G{0FwHZa>)*bQF>FM~;Rtn)WpLR|k03#(wAt2k8m%>~pe3tiMA; zG3yJukI)&|Lr~I?4|~XxTho`TWGgvh%45~G8PfV5X{?c*wtYaiW?N`)Fc$1b=5sX4 zBRjd~b{pL`$ew#X7r}WJXChy-8BoRc2v1abEqIaamA$qfxF7SKu7AC02e7ok z`vBcGvYvAy`>MDO;Z=t*wqJNsrA>T2z`p9LAlh?uBd%bss-wM^du|6~=Ip6Fl|Ef66&AhVHG0W@x|vPLx3ICnwZ@F*Sv9T6WjW&I67EXXHg%OXW{=*NgC|A z-KkXnvwP|lEE!={9Y>jI@EvhQU9bU{l7ClR>bj;7__Syg_jFe7jz(3WZ2f=NofXNv zK5dfWRq$QCpd%{}oJBF|ms!tX8Pt$c}2N$Iy4!aN9Onxx-lJzIM|Q_S|MGxr!#goRcd*??5HcWS*x1Re$uBO_W8Pejnz6| z=t+G*HSz|{4sWqaD};ei{2i?cQj5GTvu2suIzAk`y)8#Azq5CNLxFw8p0F?e%Wrua+zbp7(x`=4uT$1EGD2lY)bg?8nQC}Nsc*<) z?tS`3oSn+JevF**o>`WWQylpe5EYs8n)5R4!MVpVAi6k>MaG`uqqQx=98L`wK>i zTjE9RS3Uci=LcUYg5a5aJ+YVAdrq|;+i&z2$;5>Xbd`BoR>V2~dGCv8PpLocd`GN| zHL_P{95};eZ5|!z)$TP^ak(Di)GQ<~VZF}VIy0qv*{?Y=QB%T2r+xi{5H=c$|0R+O zE_K$PeTQU`tHNHYECuE|kBD`#E9TXoVnJsntp? i>dfNLfBO3}XUxZNN_{tW9qrj`e(qbPV+`pkiuXU%jbbDK From 7d05c695d2a20e77dcef8ea5ee8ba09dd899acc8 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 4/6] 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 2d440432cdb0a238112a071f3c533263cacfd04f 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 5/6] 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 3997d175047..af3014605c0 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.config'; 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;