1- import { distinctUntilChanged , filter , first , take , withLatestFrom } from 'rxjs/operators' ;
1+ import { debounceTime , distinctUntilChanged , filter , startWith , switchMap , take , takeUntil , withLatestFrom } from 'rxjs/operators' ;
22import { DOCUMENT , isPlatformBrowser } from '@angular/common' ;
33import {
44 AfterViewInit ,
77 HostListener ,
88 Inject ,
99 NgZone ,
10+ OnDestroy ,
1011 OnInit ,
1112 PLATFORM_ID ,
1213} from '@angular/core' ;
@@ -17,7 +18,7 @@ import {
1718 Router ,
1819} from '@angular/router' ;
1920
20- import { BehaviorSubject , combineLatest , Observable } from 'rxjs' ;
21+ import { BehaviorSubject , combineLatest , Observable , race , Subject , timer } from 'rxjs' ;
2122import { select , Store } from '@ngrx/store' ;
2223import { NgbModal , NgbModalConfig } from '@ng-bootstrap/ng-bootstrap' ;
2324import { TranslateService } from '@ngx-translate/core' ;
@@ -39,10 +40,22 @@ import { distinctNext } from './core/shared/distinct-next';
3940 styleUrls : [ './app.component.scss' ] ,
4041 changeDetection : ChangeDetectionStrategy . OnPush ,
4142} )
42- export class AppComponent implements OnInit , AfterViewInit {
43+ export class AppComponent implements OnInit , AfterViewInit , OnDestroy {
4344 notificationOptions ;
4445 models ;
4546
47+ /**
48+ * Emits on destroy to tear down the SSR-overlay-removal pipeline (gate subscription, the
49+ * MutationObserver and its debounce/cap timers all unsubscribe via `takeUntil(destroyed$)`).
50+ * AppComponent is the root component so this is mostly defensive + test hygiene.
51+ */
52+ private destroyed$ = new Subject < void > ( ) ;
53+
54+ /** SSR anti-flicker overlay (see src/index.html) removal tuning. */
55+ private readonly ssrOverlaySettleQuietMs = 600 ; // routed page is "done" after this long with no DOM change
56+ private readonly ssrOverlaySettleMaxMs = 10000 ; // backstop reveal (below index.html's 15s catastrophic net)
57+ private readonly ssrOverlayMinContentHeightPx = 200 ; // proves <ds-app> is no longer the empty shell
58+
4659 /**
4760 * Whether or not the authentication is currently blocking the UI
4861 */
@@ -93,41 +106,118 @@ export class AppComponent implements OnInit, AfterViewInit {
93106 }
94107
95108 /**
96- * Drops the SSR mask overlay installed by the inline bootstrap script in src/index.html the
97- * moment the real CSR content is actually visible. We do NOT wait for ApplicationRef.isStable
98- * (which can be delayed many seconds by ongoing zone tasks, e.g. admin-only background HTTP
99- * polling, periodic timers, third-party AAI/discojuice scripts). Instead we react to the same
100- * condition root.component.html uses to swap the fullscreen loader for the real content:
101- * `!isAuthenticationBlocking && !isThemeLoading`. At that exact point the routed page is
102- * rendered, so removing the SSR snapshot does not produce flicker. One rAF delay lets the
103- * change-detection result commit to the DOM before the overlay fades.
109+ * Drops the SSR mask overlay installed by the inline bootstrap script in src/index.html once the
110+ * routed CSR page has actually finished rendering. Picking the right moment is the whole problem,
111+ * and the two earlier attempts each fixed one symptom and reintroduced the other:
112+ *
113+ * - PR #1288 waited for `ApplicationRef.isStable`. No flicker, but isStable is held hostage by ANY
114+ * ongoing zone async — after an admin login the app keeps the zone busy (authz/widgets, periodic
115+ * polling, AAI/discojuice scripts) so isStable fires many seconds late (or hits the 15s
116+ * fallback). The inert snapshot then masks the live page -> "looks rendered but not interactive"
117+ * (issue #725).
118+ * - PR #1317 switched to the loader-swap gate `!isAuthenticationBlocking && !isThemeLoading` plus a
119+ * single requestAnimationFrame. Prompt, but that gate only un-hides `<router-outlet>`; the home
120+ * page then renders piecewise (navbar, search box, community list, recent submissions) as each
121+ * section's data arrives. Dropping the snapshot at the gate — or, as an earlier revision of this
122+ * method did, as soon as *some* content exists — exposes a half-built page that visibly pops
123+ * into place on a hard reload -> the flicker.
124+ *
125+ * The signal we actually need is "the routed page has stopped changing". So, after the gate opens,
126+ * we keep the snapshot until the live <ds-app> DOM has SETTLED (no element added/removed for a
127+ * short quiet window, with real content present). This stays decoupled from isStable (DOM-settle
128+ * ignores non-rendering background async, so admin reveals in a few seconds rather than ~15s).
129+ * See {@link routedPageReadyToReveal$}.
104130 */
105131 private removeSsrOverlayWhenContentVisible ( ) : void {
106- const w : Window | undefined = this . _window ?. nativeWindow ;
107- if ( ! w || typeof w . __dspaceRemoveSsrOverlay !== 'function' ) {
108- return ;
132+ const win : Window | undefined = this . _window ?. nativeWindow ;
133+ if ( ! win || typeof win . __dspaceRemoveSsrOverlay !== 'function' ) {
134+ return ; // SSR was skipped for this route, so no overlay was installed — nothing to remove
109135 }
110- // run outside Angular so the subscription does not keep change detection alive
136+ // Run outside Angular: a MutationObserver watching the whole app must not trigger change
137+ // detection (it would also keep ApplicationRef.isStable permanently false).
111138 this . ngZone . runOutsideAngular ( ( ) => {
112- combineLatest ( [
113- this . store . pipe ( select ( isAuthenticationBlocking ) , distinctUntilChanged ( ) ) ,
114- this . themeService . isThemeLoading$ ,
115- ] ) . pipe (
116- filter ( ( [ blocking , themeLoading ] : [ boolean , boolean ] ) => ! blocking && ! themeLoading ) ,
117- first ( ) ,
139+ this . routedPageReadyToReveal$ ( ) . pipe (
140+ takeUntil ( this . destroyed$ ) ,
118141 ) . subscribe ( ( ) => {
119- const remove = ( ) => {
120- if ( typeof w . __dspaceRemoveSsrOverlay === 'function' ) {
121- w . __dspaceRemoveSsrOverlay ( ) ;
122- }
123- } ;
124- if ( typeof w . requestAnimationFrame === 'function' ) {
125- w . requestAnimationFrame ( remove ) ;
126- } else {
127- remove ( ) ;
142+ // one frame so the freshly rendered content is painted before the snapshot fades out
143+ this . runAfterNextFrame ( win , ( ) => win . __dspaceRemoveSsrOverlay ?.( ) ) ;
144+ } ) ;
145+ } ) ;
146+ }
147+
148+ /**
149+ * Emits once when it is safe to drop the SSR snapshot: the auth/theme loader gate has opened
150+ * (same condition root.component.html uses to swap its fullscreen loader for the routed content)
151+ * AND the routed page's DOM has settled. See {@link dsAppDomSettled$}.
152+ */
153+ private routedPageReadyToReveal$ ( ) : Observable < unknown > {
154+ const loaderGateOpen$ = combineLatest ( [
155+ this . store . pipe ( select ( isAuthenticationBlocking ) , distinctUntilChanged ( ) ) ,
156+ this . themeService . isThemeLoading$ ,
157+ ] ) . pipe (
158+ filter ( ( [ authBlocking , themeLoading ] : [ boolean , boolean ] ) => ! authBlocking && ! themeLoading ) ,
159+ take ( 1 ) ,
160+ ) ;
161+ return loaderGateOpen$ . pipe (
162+ switchMap ( ( ) => this . dsAppDomSettled$ ( ) ) ,
163+ ) ;
164+ }
165+
166+ /**
167+ * Emits once when the live <ds-app> subtree stops being mutated (elements added/removed) for
168+ * `ssrOverlaySettleQuietMs` AND it holds real content — or after `ssrOverlaySettleMaxMs`, whichever
169+ * comes first. The cap guarantees a page that never goes quiet (constant background DOM updates)
170+ * still reveals; the 15s fallback in index.html remains the ultimate net.
171+ */
172+ private dsAppDomSettled$ ( ) : Observable < unknown > {
173+ const dsApp : Element | null = this . document . querySelector ( 'ds-app' ) ;
174+ if ( ! dsApp ) {
175+ return timer ( this . ssrOverlaySettleMaxMs ) ;
176+ }
177+ const elementMutations$ = new Observable < void > ( ( subscriber ) => {
178+ const observer = new MutationObserver ( ( records ) => {
179+ if ( records . some ( ( record ) => this . isElementChildListChange ( record ) ) ) {
180+ subscriber . next ( ) ;
128181 }
129182 } ) ;
183+ observer . observe ( dsApp , { childList : true , subtree : true } ) ;
184+ return ( ) => observer . disconnect ( ) ;
130185 } ) ;
186+ const settled$ = elementMutations$ . pipe (
187+ startWith ( undefined ) , // start the quiet window immediately
188+ debounceTime ( this . ssrOverlaySettleQuietMs ) , // ... reset by each render, fires once quiet
189+ filter ( ( ) => this . dsAppHasRenderedContent ( dsApp ) ) , // ... but never on the empty shell
190+ ) ;
191+ return race ( settled$ , timer ( this . ssrOverlaySettleMaxMs ) ) . pipe ( take ( 1 ) ) ;
192+ }
193+
194+ /** True once the live <ds-app> is no longer the empty shell the overlay script left behind. */
195+ private dsAppHasRenderedContent ( dsApp : Element ) : boolean {
196+ const height = dsApp . getBoundingClientRect ?.( ) . height ?? 0 ;
197+ return height >= this . ssrOverlayMinContentHeightPx && dsApp . querySelector ( '#main-content' ) !== null ;
198+ }
199+
200+ /** A childList mutation that adds or removes at least one element node (ignores text/attr noise). */
201+ private isElementChildListChange ( record : MutationRecord ) : boolean {
202+ if ( record . type !== 'childList' ) {
203+ return false ;
204+ }
205+ const changedNodes = [ ...Array . from ( record . addedNodes ) , ...Array . from ( record . removedNodes ) ] ;
206+ return changedNodes . some ( ( node ) => node . nodeType === Node . ELEMENT_NODE ) ;
207+ }
208+
209+ /** Runs `callback` after the next paint (or synchronously if requestAnimationFrame is unavailable). */
210+ private runAfterNextFrame ( win : Window , callback : ( ) => void ) : void {
211+ if ( typeof win . requestAnimationFrame === 'function' ) {
212+ win . requestAnimationFrame ( ( ) => callback ( ) ) ;
213+ } else {
214+ callback ( ) ;
215+ }
216+ }
217+
218+ ngOnDestroy ( ) : void {
219+ this . destroyed$ . next ( ) ;
220+ this . destroyed$ . complete ( ) ;
131221 }
132222
133223 ngOnInit ( ) {
0 commit comments