From f28d03323b0bc582cd0e1d369b5d54cad2902ddb Mon Sep 17 00:00:00 2001 From: Juraj Roka <95219754+jr-rk@users.noreply.github.com> Date: Wed, 17 Jun 2026 10:11:30 +0200 Subject: [PATCH 1/8] Backport of Fix home-page SSR->CSR flicker --- _build.log | Bin 0 -> 48574 bytes _spec.log | Bin 0 -> 3910 bytes src/app/app.component.spec.ts | 63 +++++++++++++++++- src/app/app.component.ts | 40 +++++++++++- src/index.html | 119 ++++++++++++++++++++++++++++++++++ src/typings.d.ts | 9 +++ 6 files changed, 228 insertions(+), 3 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..7b314b7aa316df694f443a6b2e3f5aa17909ce19 GIT binary patch literal 48574 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 literal 0 HcmV?d00001 diff --git a/_spec.log b/_spec.log new file mode 100644 index 0000000000000000000000000000000000000000..9692037a36d56276124766d94b3cc5e25d7bcdc0 GIT binary patch literal 3910 zcmchaU2juI5Qe9>mG}d_T1Bnoh{pLO39cd$OrRBksxhdo5EsEV#>76h>=RP=qJO&A z{dnG)Y;#D0YZQT2=j`$B%#U7z$jS)%E>DTkvKD2$1?YKW{;5;v8!e8?vpn>cXO_X^(;v(4#duiV?A92pmPw(0RQQGGB z7}`E-g~vqJRdzijt_F;m{lu3t?LyZB>d|fOHl4o5tqasss{ZMwR0!1e1q?SD+BaV1C+{Se>AlVv`o~U4JJo;MatKq8+ zR_?-o$9Bns1{OXAiyxtRVJF})al2ML_a+v#;H4w*_`lu6`ZhAQcsAfvo@QG8DxFe# z3O(VL$m^0ZM$#%{0D`Io1J*~*(Lk$R&)(IKwqxg#9Rnn*vZ#(qv(W#rvKrBIxl$?r z=hxITqNGHsDvmtU<{ZU}s$iQ~N`9_lY3P(fKwetZE8FiR4$);M>$n6)Dd&!MC_}FwTVqd zej?T<$oa+d@2x#Yvl04@Sd6DwspEWOJQ-bI*&;k6C#ra< zx@F1xj#1RLs-0EMIhm$Q1zLUBZ0{*Wc=zp^K8CDLu?`KsGR6_KmfwB3H$ zB7ElKDWp`bRbg73IH3kbwNbp!@Ub*5yxJdgQ&8oro}Tdx+=W_!v=tut@~XFbZo?~N z58OK0C=Q_^KfXo&Mda1^RR^s)yG?XBa>)g}%5n9EQ@2umr{(+d>NdLk5MU*vjVkXVh{68yTg1~NkDq1z%Jp(nq~HuVGL;a%!bRO>br zi8`Ejloi#Td;F^Qj7{f!WKZZbw~?gIvIb2JIqJqbYs6dC@J|pB7P2GyRbhYeed`NF zR^TFCTV(GOhPv0U)EBKOrnaC}1WB^A%yE=^UqspD zMne&j{TH`pA$jT*N)=i--0XxvOZ8s3=(ewa5W+@7_CIIkgh!RNuy0tY@vPxtc_}d0 zeMHqDx?<-37zw&Fi9g?|E@UjK!=cAx+_Xdc{9aVT&5-=`+&PLr{q67b+%X?`Es9E{ RN}|laLp9O8a>^`w{{?=mVgUdE literal 0 HcmV?d00001 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/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 a92545a4e2bdd03111554c8385f5c9ce99d3c30c Mon Sep 17 00:00:00 2001 From: Juraj Roka <95219754+jr-rk@users.noreply.github.com> Date: Thu, 18 Jun 2026 11:09:35 +0200 Subject: [PATCH 2/8] 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 742c4f5db6b40b803f9a9145402dad053f5a10d8 Mon Sep 17 00:00:00 2001 From: Juraj Roka <95219754+jr-rk@users.noreply.github.com> Date: Thu, 18 Jun 2026 11:09:35 +0200 Subject: [PATCH 3/8] 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 48574 -> 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 7b314b7aa316df694f443a6b2e3f5aa17909ce19..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 48574 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 diff --git a/_spec.log b/_spec.log deleted file mode 100644 index 9692037a36d56276124766d94b3cc5e25d7bcdc0..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 3910 zcmchaU2juI5Qe9>mG}d_T1Bnoh{pLO39cd$OrRBksxhdo5EsEV#>76h>=RP=qJO&A z{dnG)Y;#D0YZQT2=j`$B%#U7z$jS)%E>DTkvKD2$1?YKW{;5;v8!e8?vpn>cXO_X^(;v(4#duiV?A92pmPw(0RQQGGB z7}`E-g~vqJRdzijt_F;m{lu3t?LyZB>d|fOHl4o5tqasss{ZMwR0!1e1q?SD+BaV1C+{Se>AlVv`o~U4JJo;MatKq8+ zR_?-o$9Bns1{OXAiyxtRVJF})al2ML_a+v#;H4w*_`lu6`ZhAQcsAfvo@QG8DxFe# z3O(VL$m^0ZM$#%{0D`Io1J*~*(Lk$R&)(IKwqxg#9Rnn*vZ#(qv(W#rvKrBIxl$?r z=hxITqNGHsDvmtU<{ZU}s$iQ~N`9_lY3P(fKwetZE8FiR4$);M>$n6)Dd&!MC_}FwTVqd zej?T<$oa+d@2x#Yvl04@Sd6DwspEWOJQ-bI*&;k6C#ra< zx@F1xj#1RLs-0EMIhm$Q1zLUBZ0{*Wc=zp^K8CDLu?`KsGR6_KmfwB3H$ zB7ElKDWp`bRbg73IH3kbwNbp!@Ub*5yxJdgQ&8oro}Tdx+=W_!v=tut@~XFbZo?~N z58OK0C=Q_^KfXo&Mda1^RR^s)yG?XBa>)g}%5n9EQ@2umr{(+d>NdLk5MU*vjVkXVh{68yTg1~NkDq1z%Jp(nq~HuVGL;a%!bRO>br zi8`Ejloi#Td;F^Qj7{f!WKZZbw~?gIvIb2JIqJqbYs6dC@J|pB7P2GyRbhYeed`NF zR^TFCTV(GOhPv0U)EBKOrnaC}1WB^A%yE=^UqspD zMne&j{TH`pA$jT*N)=i--0XxvOZ8s3=(ewa5W+@7_CIIkgh!RNuy0tY@vPxtc_}d0 zeMHqDx?<-37zw&Fi9g?|E@UjK!=cAx+_Xdc{9aVT&5-=`+&PLr{q67b+%X?`Es9E{ RN}|laLp9O8a>^`w{{?=mVgUdE From 15a3754df2cca14bd7c84582e96ec624eddecb9e 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/8] 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 621f5c107f7ef1926c07ef90c2475925f624ca41 Mon Sep 17 00:00:00 2001 From: Juraj Roka <95219754+jr-rk@users.noreply.github.com> Date: Fri, 19 Jun 2026 19:11:00 +0200 Subject: [PATCH 5/8] Refactor: align eager-themes.module.ts with the other backport instances Bind the custom eager theme to the CustomEagerThemeModule alias used by the other customer backports so this file is byte-identical across instances. The same ./custom/eager-theme.module is still imported eagerly - no runtime, build, or bundle-size change; the custom theme stays eager (which also keeps the untyped-item theming working, ref DSpace/dspace-angular#1897). Co-Authored-By: Claude Opus 4.8 --- src/themes/eager-themes.module.ts | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/src/themes/eager-themes.module.ts b/src/themes/eager-themes.module.ts index 84b6c438922..29d46032de8 100644 --- a/src/themes/eager-themes.module.ts +++ b/src/themes/eager-themes.module.ts @@ -1,7 +1,6 @@ import { NgModule } from '@angular/core'; import { EagerThemeModule as DSpaceEagerThemeModule } from './dspace/eager-theme.module'; -import { EagerThemeModule } from './custom/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. @@ -9,14 +8,16 @@ import { EagerThemeModule } from './custom/eager-theme.module'; * 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, - // Uncomment this because the `untyped-item` theming is not working when it is commented out. - // Issue: https://github.com/DSpace/dspace-angular/issues/1897 - // Useful info in PR: https://github.com/DSpace/dspace-angular/pull/2262#issuecomment-1557146081 - EagerThemeModule, + CustomEagerThemeModule, ], }) export class EagerThemesModule { From 972393678608e76afe002fd60b29c2f0c8f4ed4d Mon Sep 17 00:00:00 2001 From: Juraj Roka <95219754+jr-rk@users.noreply.github.com> Date: Sat, 20 Jun 2026 11:17:03 +0200 Subject: [PATCH 6/8] Build: raise initial bundle budget to 5.5mb/6mb to match the other backports Aligns ZCU-PUB's initial budget with the value the root fix (#1287) and the other customer backports use, so the budget block is identical across instances. The custom theme is already eager here, so this only widens the headroom; the build already passes under the previous 5mb error ceiling. Co-Authored-By: Claude Opus 4.8 --- angular.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/angular.json b/angular.json index 356e5ca27ef..3107cf8c5c7 100644 --- a/angular.json +++ b/angular.json @@ -105,8 +105,8 @@ "budgets": [ { "type": "initial", - "maximumWarning": "3mb", - "maximumError": "5mb" + "maximumWarning": "5.5mb", + "maximumError": "6mb" }, { "type": "anyComponentStyle", From fa5812f31776b60a0c039d1f87f78176cb68c5e8 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 7/8] 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;