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/5] 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/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 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/5] 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 From bb212992e1341d1069bd37d7c439750c3f4b3c94 Mon Sep 17 00:00:00 2001 From: Juraj Roka <95219754+jr-rk@users.noreply.github.com> Date: Wed, 1 Jul 2026 09:32:12 +0200 Subject: [PATCH 4/5] Backport final DOM-settle overlay mechanism to Mendelu (#1318, #1321) Mendelu was the last instance still on the original ApplicationRef.isStable trigger. isStable is held hostage by post-login/admin zone activity, so the hydration-safe clone overlay lingered over an app that had long finished (the same class of problem as dspace-customers#725 - visually here, since the clone lets clicks through, but the frozen frame still sat there for up to 15s). Port the final trigger used across the other customers: drop the clone once the auth/theme loader gate opens AND the live DOM has settled (MutationObserver + quiet window, content-height / #main-content check, 10s cap), decoupled from isStable. The Angular-18 hydration-safe CLONE index.html is unchanged (comments only); the DOM-settle trigger lives in AppComponent. Spec, theme-service mock and an OnDestroy teardown updated to match. NOTE: hand-port to the Angular-18 stack - CI validates compile + unit tests but NOT the real hydration + clone + DOM-settle timing; needs a visual check on a running Mendelu instance (authenticated hard reload) before merge. Ref: #1318, #1321 Co-Authored-By: Claude Opus 4.8 --- src/app/app.component.spec.ts | 91 ++++++------- src/app/app.component.ts | 146 +++++++++++++++++---- src/app/shared/mocks/theme-service.mock.ts | 4 + src/index.html | 10 +- 4 files changed, 165 insertions(+), 86 deletions(-) diff --git a/src/app/app.component.spec.ts b/src/app/app.component.spec.ts index 628619bdc7a..c9904af7495 100644 --- a/src/app/app.component.spec.ts +++ b/src/app/app.component.spec.ts @@ -1,12 +1,9 @@ import { CommonModule } from '@angular/common'; -import { - ApplicationRef, - CUSTOM_ELEMENTS_SCHEMA, -} from '@angular/core'; +import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'; import { ComponentFixture, + discardPeriodicTasks, fakeAsync, - flush, inject, TestBed, tick, @@ -20,7 +17,7 @@ import { Store, StoreModule, } from '@ngrx/store'; -import { provideMockStore } from '@ngrx/store/testing'; +import { MockStore, provideMockStore } from '@ngrx/store/testing'; import { TranslateLoader, TranslateModule, @@ -65,7 +62,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 { @@ -157,24 +154,30 @@ 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. + // Once auth blocking and theme loading are both false, AppComponent waits for the DOM + // to settle (no element added/removed for the quiet window) and only then removes the overlay. + let mockStore: MockStore; + let themeLoading$: BehaviorSubject; + let themeService: ThemeService; let originalRaF: typeof window.requestAnimationFrame; - let originalIsStable: PropertyDescriptor | undefined; + let dsAppEl: HTMLElement; 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 } } }); + + // A settled with real content present, so the DOM-settle watcher can resolve. + dsAppEl = document.createElement('ds-app'); + dsAppEl.setAttribute('style', 'display:block;height:800px'); + dsAppEl.innerHTML = '
home content
'; + document.body.appendChild(dsAppEl); + + // Force rAF to a synchronous shim so assertions are deterministic. originalRaF = window.requestAnimationFrame; (window as any).requestAnimationFrame = (cb: FrameRequestCallback) => { cb(0); @@ -185,29 +188,28 @@ 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; - } + if (dsAppEl && dsAppEl.parentNode) { dsAppEl.parentNode.removeChild(dsAppEl); } }); - it('removes the overlay once isStable emits true', fakeAsync(() => { + it('removes the overlay once auth/theme are ready AND the DOM has settled', 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 - flush(); + mockStore.setState({ core: { auth: { loading: false, blocking: false } } }); + themeLoading$.next(false); + // Not removed at the gate: the DOM-settle quiet window must elapse first. + expect(spy).not.toHaveBeenCalled(); + tick(700); // > SETTLE_QUIET_MS expect(spy).toHaveBeenCalledTimes(1); + + discardPeriodicTasks(); })); it('is a no-op when the global is not injected (e.g. CSR-only route, SSR skipped)', fakeAsync(() => { @@ -217,27 +219,12 @@ describe('App component', () => { const f = TestBed.createComponent(AppComponent); expect(() => f.detectChanges()).not.toThrow(); - isStable$.next(true); - tick(50); - flush(); + mockStore.setState({ core: { auth: { loading: false, blocking: false } } }); + themeLoading$.next(false); + tick(700); 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 6e23f7b1ede..d625f9a15f2 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -5,12 +5,12 @@ import { } from '@angular/common'; import { AfterViewInit, - ApplicationRef, ChangeDetectionStrategy, Component, HostListener, Inject, NgZone, + OnDestroy, OnInit, PLATFORM_ID, } from '@angular/core'; @@ -31,14 +31,21 @@ import { import { TranslateService } from '@ngx-translate/core'; import { BehaviorSubject, + combineLatest, Observable, + race, + Subject, + timer, } from 'rxjs'; import { + debounceTime, delay, distinctUntilChanged, filter, - first, + startWith, + switchMap, take, + takeUntil, withLatestFrom, } from 'rxjs/operators'; @@ -68,10 +75,22 @@ import { ThemeService } from './shared/theme-support/theme.service'; ThemedRootComponent, ], }) -export class AppComponent implements OnInit, AfterViewInit { +export class AppComponent implements OnInit, AfterViewInit, OnDestroy { notificationOptions; models; + /** + * Emits on destroy to tear down the SSR-overlay-removal pipeline (gate subscription, the + * MutationObserver and its debounce/cap timers all unsubscribe via `takeUntil(destroyed$)`). + * AppComponent is the root component so this is mostly defensive + test hygiene. + */ + private destroyed$ = new Subject(); + + /** SSR anti-flicker overlay (see src/index.html) removal tuning. */ + private readonly ssrOverlaySettleQuietMs = 600; // routed page is "done" after this long with no DOM change + private readonly ssrOverlaySettleMaxMs = 10000; // backstop reveal (below index.html's 15s catastrophic net) + private readonly ssrOverlayMinContentHeightPx = 200; // proves is no longer the empty shell + /** * Whether or not the authentication is currently blocking the UI */ @@ -104,14 +123,13 @@ 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.removeSsrOverlayWhenContentVisible(); } this.isThemeLoading$ = this.themeService.isThemeLoading$; @@ -120,37 +138,107 @@ export class AppComponent implements OnInit, AfterViewInit { } /** - * 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. + * Drops the hydration-safe SSR freeze-frame (a detached clone painted on top; see src/index.html) + * once the routed CSR page has actually finished rendering. Earlier revisions waited for + * `ApplicationRef.isStable`, but on DSpace 9 (Angular 18) the theme system re-creates every + * `ds-themed-*` wrapper imperatively on the client, and post-login zone activity keeps isStable + * busy — so the frozen clone lingered (up to the 15s fallback) over an app that had long finished. + * + * Instead we drop the clone once the auth/theme loader gate has opened AND the live DOM + * has SETTLED (no element added/removed for a short quiet window, with real content present) — + * i.e. once the imperative themed re-render is done. This stays decoupled from isStable. + * See {@link routedPageReadyToReveal$}. */ - private removeSsrOverlayWhenStable(): void { - const w: Window | undefined = this._window?.nativeWindow; - if (!w || typeof w.__dspaceRemoveSsrOverlay !== 'function') { - return; + private removeSsrOverlayWhenContentVisible(): void { + const win: Window | undefined = this._window?.nativeWindow; + if (!win || typeof win.__dspaceRemoveSsrOverlay !== 'function') { + return; // SSR was skipped for this route, so no overlay was installed — nothing to remove } - // run outside Angular so we don't keep changeDetection ticking on the overlay timer + // Run outside Angular: a MutationObserver watching the whole app must not trigger change + // detection (it would also keep ApplicationRef.isStable permanently false). this.ngZone.runOutsideAngular(() => { - this.appRef.isStable.pipe( - filter((stable: boolean) => stable), - first(), + this.routedPageReadyToReveal$().pipe( + takeUntil(this.destroyed$), ).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); + // one frame so the freshly rendered content is painted before the snapshot fades out + this.runAfterNextFrame(win, () => win.__dspaceRemoveSsrOverlay?.()); + }); + }); + } + + /** + * Emits once when it is safe to drop the SSR snapshot: the auth/theme loader gate has opened + * (same condition root.component.html uses to swap its fullscreen loader for the routed content) + * AND the routed page's DOM has settled. See {@link dsAppDomSettled$}. + */ + private routedPageReadyToReveal$(): Observable { + const loaderGateOpen$ = combineLatest([ + this.store.pipe(select(isAuthenticationBlocking), distinctUntilChanged()), + this.themeService.isThemeLoading$, + ]).pipe( + filter(([authBlocking, themeLoading]: [boolean, boolean]) => !authBlocking && !themeLoading), + take(1), + ); + return loaderGateOpen$.pipe( + switchMap(() => this.dsAppDomSettled$()), + ); + } + + /** + * Emits once when the live subtree stops being mutated (elements added/removed) for + * `ssrOverlaySettleQuietMs` AND it holds real content — or after `ssrOverlaySettleMaxMs`, whichever + * comes first. The cap guarantees a page that never goes quiet (constant background DOM updates) + * still reveals; the 15s fallback in index.html remains the ultimate net. + */ + private dsAppDomSettled$(): Observable { + const dsApp: Element | null = this.document.querySelector('ds-app'); + if (!dsApp) { + return timer(this.ssrOverlaySettleMaxMs); + } + const elementMutations$ = new Observable((subscriber) => { + const observer = new MutationObserver((records) => { + if (records.some((record) => this.isElementChildListChange(record))) { + subscriber.next(); } }); + observer.observe(dsApp, { childList: true, subtree: true }); + return () => observer.disconnect(); }); + const settled$ = elementMutations$.pipe( + startWith(undefined), // start the quiet window immediately + debounceTime(this.ssrOverlaySettleQuietMs), // ... reset by each render, fires once quiet + filter(() => this.dsAppHasRenderedContent(dsApp)), // ... but never on the empty shell + ); + return race(settled$, timer(this.ssrOverlaySettleMaxMs)).pipe(take(1)); + } + + /** True once the live is no longer the empty shell the overlay script left behind. */ + private dsAppHasRenderedContent(dsApp: Element): boolean { + const height = dsApp.getBoundingClientRect?.().height ?? 0; + return height >= this.ssrOverlayMinContentHeightPx && dsApp.querySelector('#main-content') !== null; + } + + /** A childList mutation that adds or removes at least one element node (ignores text/attr noise). */ + private isElementChildListChange(record: MutationRecord): boolean { + if (record.type !== 'childList') { + return false; + } + const changedNodes = [...Array.from(record.addedNodes), ...Array.from(record.removedNodes)]; + return changedNodes.some((node) => node.nodeType === Node.ELEMENT_NODE); + } + + /** Runs `callback` after the next paint (or synchronously if requestAnimationFrame is unavailable). */ + private runAfterNextFrame(win: Window, callback: () => void): void { + if (typeof win.requestAnimationFrame === 'function') { + win.requestAnimationFrame(() => callback()); + } else { + callback(); + } + } + + ngOnDestroy(): void { + this.destroyed$.next(); + this.destroyed$.complete(); } ngOnInit() { diff --git a/src/app/shared/mocks/theme-service.mock.ts b/src/app/shared/mocks/theme-service.mock.ts index cefed005684..09462d9d56b 100644 --- a/src/app/shared/mocks/theme-service.mock.ts +++ b/src/app/shared/mocks/theme-service.mock.ts @@ -5,11 +5,15 @@ import { isNotEmpty } from '../empty.util'; import { ThemeService } from '../theme-support/theme.service'; export function getMockThemeService(themeName = 'base', themes?: ThemeConfig[]): ThemeService { + // isThemeLoading$ is a real property getter on ThemeService, so it must be a property on the mock + // (not a spy method) - AppComponent's overlay-removal gate reads it as a stream. const spy = jasmine.createSpyObj('themeService', { getThemeName: themeName, getThemeName$: of(themeName), getThemeConfigFor: undefined, listenForRouteChanges: undefined, + }, { + isThemeLoading$: of(false), }); if (isNotEmpty(themes)) { diff --git a/src/index.html b/src/index.html index db451d811a6..bbce851afdf 100644 --- a/src/index.html +++ b/src/index.html @@ -11,7 +11,7 @@ /* Hydration-safe anti-flicker overlay (DSpace 9 / Angular 18). Unlike the Angular-15 variant, this NEVER moves or mutates the SSR DOM that Angular hydrates — it only paints a detached *clone* on top. See the bootstrap script below + - AppComponent.removeSsrOverlayWhenStable. Root cause this masks: DSpace's ThemedComponent + AppComponent.removeSsrOverlayWhenContentVisible. Root cause this masks: DSpace's ThemedComponent instantiates themed wrappers imperatively (ViewContainerRef.createComponent in ngAfterViewInit), which Angular hydration cannot reuse, so every themed subtree is re-created client-side. Tracked upstream: DSpace/dspace-angular#3867. */ @@ -43,8 +43,8 @@ We therefore must NOT touch the DOM Angular hydrates (the 15.x overlay MOVED nodes, which would *worsen* the mismatch here). Instead we paint a detached CLONE of the SSR view on top, let hydration + the themed re-render happen invisibly underneath on the untouched original, and drop - the clone once ApplicationRef.isStable (AppComponent.removeSsrOverlayWhenStable), with a 15s - hard fallback. + the clone once the routed DOM has settled (AppComponent.removeSsrOverlayWhenContentVisible), with + a 15s hard fallback. */ (function () { if (typeof window === 'undefined' || typeof document === 'undefined') return; @@ -71,7 +71,7 @@ var removing = false; window.__dspaceRemoveSsrOverlay = function () { - // Re-entrancy guard so a racing isStable + the 15s fallback can't double-run the fade. + // Re-entrancy guard so a racing DOM-settle removal + the 15s fallback can't double-run the fade. if (removing) return; removing = true; window.__dspaceRemoveSsrOverlay = null; @@ -85,7 +85,7 @@ }, 200); }; - // Safety net: if isStable never fires, never trap the user behind the frozen frame. + // Safety net: if AppComponent never removes it, never trap the user behind the frozen frame. setTimeout(function () { if (typeof window.__dspaceRemoveSsrOverlay === 'function') { window.__dspaceRemoveSsrOverlay(); From e2199229004355c88498d1916ebe29d8e3eba29d Mon Sep 17 00:00:00 2001 From: Juraj Roka <95219754+jr-rk@users.noreply.github.com> Date: Wed, 1 Jul 2026 10:12:08 +0200 Subject: [PATCH 5/5] fix: Reformatted the import into multiline style in app.component.spec.ts:20 --- src/app/app.component.spec.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/app/app.component.spec.ts b/src/app/app.component.spec.ts index c9904af7495..c81310832ba 100644 --- a/src/app/app.component.spec.ts +++ b/src/app/app.component.spec.ts @@ -17,7 +17,10 @@ import { Store, StoreModule, } from '@ngrx/store'; -import { MockStore, provideMockStore } from '@ngrx/store/testing'; +import { + MockStore, + provideMockStore, +} from '@ngrx/store/testing'; import { TranslateLoader, TranslateModule,