From 243ae40f35939bff613882ae8c7db8856066ed97 Mon Sep 17 00:00:00 2001 From: Juraj Roka <95219754+jr-rk@users.noreply.github.com> Date: Wed, 17 Jun 2026 14:35:14 +0200 Subject: [PATCH 1/3] Backport of Fix home-page SSR->CSR flicker --- src/app/app.component.spec.ts | 67 ++++++++++++++++++++++++++- src/app/app.component.ts | 41 +++++++++++++++++ src/index.html | 85 +++++++++++++++++++++++++++++++++++ src/typings.d.ts | 9 ++++ 4 files changed, 201 insertions(+), 1 deletion(-) diff --git a/src/app/app.component.spec.ts b/src/app/app.component.spec.ts index 159fbb38f04..9572509b7d9 100644 --- a/src/app/app.component.spec.ts +++ b/src/app/app.component.spec.ts @@ -1,9 +1,15 @@ import { CommonModule } from '@angular/common'; -import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'; +import { + ApplicationRef, + CUSTOM_ELEMENTS_SCHEMA, +} from '@angular/core'; import { ComponentFixture, + fakeAsync, + flush, inject, TestBed, + tick, waitForAsync, } from '@angular/core/testing'; import { @@ -19,6 +25,7 @@ import { TranslateLoader, TranslateModule, } from '@ngx-translate/core'; +import { BehaviorSubject } from 'rxjs'; import { APP_CONFIG } from '../config/app-config.interface'; import { environment } from '../environments/environment'; @@ -149,4 +156,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 66636c32495..6e23f7b1ede 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -5,10 +5,12 @@ import { } from '@angular/common'; import { AfterViewInit, + ApplicationRef, ChangeDetectionStrategy, Component, HostListener, Inject, + NgZone, OnInit, PLATFORM_ID, } from '@angular/core'; @@ -34,6 +36,8 @@ import { import { delay, distinctUntilChanged, + filter, + first, take, withLatestFrom, } from 'rxjs/operators'; @@ -100,11 +104,14 @@ export class AppComponent implements OnInit, AfterViewInit { private cssService: CSSVariableService, private modalService: NgbModal, private modalConfig: NgbModalConfig, + private appRef: ApplicationRef, + private ngZone: NgZone, ) { this.notificationOptions = environment.notifications; if (isPlatformBrowser(this.platformId)) { this.trackIdleModal(); + this.removeSsrOverlayWhenStable(); } this.isThemeLoading$ = this.themeService.isThemeLoading$; @@ -112,6 +119,40 @@ export class AppComponent implements OnInit, AfterViewInit { this.storeCSSVariables(); } + /** + * Drops the hydration-safe SSR freeze-frame installed by the inline bootstrap script in + * src/index.html once Angular reaches its first stable state. On DSpace 9 (Angular 18) the + * page DOES hydrate, but the theme system re-creates every `ds-themed-*` wrapper imperatively + * on the client (see ThemedComponent), so the SSR view is briefly rebuilt; the overlay clone + * masks that rebuild. We wait for the first stable paint, add a short pad, then fade it out. + * A 15s hard fallback lives inside the script itself in case isStable never fires. + */ + private removeSsrOverlayWhenStable(): void { + const w: Window | undefined = this._window?.nativeWindow; + if (!w || typeof w.__dspaceRemoveSsrOverlay !== 'function') { + return; + } + // run outside Angular so we don't keep changeDetection ticking on the overlay timer + this.ngZone.runOutsideAngular(() => { + this.appRef.isStable.pipe( + filter((stable: boolean) => stable), + first(), + ).subscribe(() => { + // one rAF + small pad to let the first stable paint commit before fading the overlay + const remove = () => { + if (typeof w.__dspaceRemoveSsrOverlay === 'function') { + w.__dspaceRemoveSsrOverlay(); + } + }; + if (typeof w.requestAnimationFrame === 'function') { + w.requestAnimationFrame(() => setTimeout(remove, 50)); + } else { + setTimeout(remove, 50); + } + }); + }); + } + ngOnInit() { /** Implement behavior for interface {@link ModalBeforeDismiss} */ this.modalConfig.beforeDismiss = async function () { diff --git a/src/index.html b/src/index.html index 565fc0439d4..67da6812951 100644 --- a/src/index.html +++ b/src/index.html @@ -7,10 +7,95 @@ DSpace + + diff --git a/src/typings.d.ts b/src/typings.d.ts index 9615cf5f67b..753233f5780 100644 --- a/src/typings.d.ts +++ b/src/typings.d.ts @@ -86,3 +86,12 @@ declare module '*.scss' { const content: any; export default content; } + +/** + * Window global injected by the inline hydration-safe anti-flicker bootstrap script in + * `src/index.html`. Called once by `AppComponent.removeSsrOverlayWhenStable()` when + * `ApplicationRef.isStable` fires, to drop the SSR freeze-frame overlay. + */ +interface Window { + __dspaceRemoveSsrOverlay?: (() => void) | null; +} From f58109ce1519322530d645d4c374c26cf8339244 Mon Sep 17 00:00:00 2001 From: Juraj Roka <95219754+jr-rk@users.noreply.github.com> Date: Thu, 18 Jun 2026 16:53:11 +0200 Subject: [PATCH 2/3] 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 9572509b7d9..628619bdc7a 100644 --- a/src/app/app.component.spec.ts +++ b/src/app/app.component.spec.ts @@ -163,12 +163,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; @@ -181,6 +185,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(() => { @@ -213,5 +223,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 92642872f6f8960d4dc49a3c1511bf9fe25f3343 Mon Sep 17 00:00:00 2001 From: Juraj Roka <95219754+jr-rk@users.noreply.github.com> Date: Thu, 18 Jun 2026 16:53:12 +0200 Subject: [PATCH 3/3] Fix: mark the SSR clone overlay inert so it can't trap keyboard focus The overlay holds a deep clone of the SSR DOM purely as a freeze-frame. It was aria-hidden and pointer-events:none, but Tab focus could still land on the dead cloned controls. Add the inert attribute to take it out of the tab order while it exists. Co-Authored-By: Claude Opus 4.8 --- src/index.html | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/index.html b/src/index.html index 67da6812951..db451d811a6 100644 --- a/src/index.html +++ b/src/index.html @@ -58,8 +58,10 @@ var overlay = document.createElement('div'); overlay.id = '__dspace_ssr_overlay'; - // It's a visual duplicate of content the real app still exposes; hide it from a11y tree. + // It's a visual duplicate of content the real app still exposes; hide it from a11y tree + // and make it inert so keyboard focus can't land on the dead cloned controls. overlay.setAttribute('aria-hidden', 'true'); + overlay.setAttribute('inert', ''); // CLONE (deep), never move: the originals stay exactly where Angular expects to hydrate them. // The clone keeps every _nghost/_ngcontent attribute, so the global component styles in