1- import { distinctUntilChanged , filter , first , take , withLatestFrom , delay } from 'rxjs/operators' ;
1+ import { debounceTime , distinctUntilChanged , filter , startWith , switchMap , take , takeUntil , withLatestFrom , delay } from 'rxjs/operators' ;
22import { DOCUMENT , isPlatformBrowser } from '@angular/common' ;
33import {
44 AfterViewInit ,
5- ApplicationRef ,
65 ChangeDetectionStrategy ,
76 Component ,
87 HostListener ,
98 Inject ,
109 NgZone ,
10+ OnDestroy ,
1111 OnInit ,
1212 PLATFORM_ID ,
1313} from '@angular/core' ;
@@ -18,7 +18,7 @@ import {
1818 Router ,
1919} from '@angular/router' ;
2020
21- import { BehaviorSubject , Observable } from 'rxjs' ;
21+ import { BehaviorSubject , combineLatest , Observable , race , Subject , timer } from 'rxjs' ;
2222import { select , Store } from '@ngrx/store' ;
2323import { NgbModal , NgbModalConfig } from '@ng-bootstrap/ng-bootstrap' ;
2424import { TranslateService } from '@ngx-translate/core' ;
@@ -40,10 +40,22 @@ import { distinctNext } from './core/shared/distinct-next';
4040 styleUrls : [ './app.component.scss' ] ,
4141 changeDetection : ChangeDetectionStrategy . OnPush ,
4242} )
43- export class AppComponent implements OnInit , AfterViewInit {
43+ export class AppComponent implements OnInit , AfterViewInit , OnDestroy {
4444 notificationOptions ;
4545 models ;
4646
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+
4759 /**
4860 * Whether or not the authentication is currently blocking the UI
4961 */
@@ -76,7 +88,6 @@ export class AppComponent implements OnInit, AfterViewInit {
7688 private cssService : CSSVariableService ,
7789 private modalService : NgbModal ,
7890 private modalConfig : NgbModalConfig ,
79- private appRef : ApplicationRef ,
8091 private ngZone : NgZone ,
8192 ) {
8293 this . notificationOptions = environment . notifications ;
@@ -86,7 +97,7 @@ export class AppComponent implements OnInit, AfterViewInit {
8697
8798 if ( isPlatformBrowser ( this . platformId ) ) {
8899 this . trackIdleModal ( ) ;
89- this . removeSsrOverlayWhenStable ( ) ;
100+ this . removeSsrOverlayWhenContentVisible ( ) ;
90101 }
91102
92103 this . isThemeLoading$ = this . themeService . isThemeLoading$ ;
@@ -95,36 +106,118 @@ export class AppComponent implements OnInit, AfterViewInit {
95106 }
96107
97108 /**
98- * Drops the SSR mask overlay installed by the inline bootstrap script in src/index.html as soon
99- * as Angular reaches its first stable state. The overlay is the only thing the user sees while
100- * Angular 15 rebuilds the SSR DOM; removing it too early would expose the rebuild flicker, too
101- * late would feel sluggish. We add a short safety pad to let the first paint settle, and there
102- * is also a 15s hard fallback inside the script itself in case isStable never fires.
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$}.
103130 */
104- private removeSsrOverlayWhenStable ( ) : void {
105- const w : Window | undefined = this . _window ?. nativeWindow ;
106- if ( ! w || typeof w . __dspaceRemoveSsrOverlay !== 'function' ) {
107- return ;
131+ private removeSsrOverlayWhenContentVisible ( ) : void {
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
108135 }
109- // run outside Angular so we don't keep changeDetection ticking on the overlay timer
136+ // Run outside Angular: a MutationObserver watching the whole app must not trigger change
137+ // detection (it would also keep ApplicationRef.isStable permanently false).
110138 this . ngZone . runOutsideAngular ( ( ) => {
111- this . appRef . isStable . pipe (
112- filter ( ( stable : boolean ) => stable ) ,
113- first ( ) ,
139+ this . routedPageReadyToReveal$ ( ) . pipe (
140+ takeUntil ( this . destroyed$ ) ,
114141 ) . subscribe ( ( ) => {
115- // one rAF + small pad to let the first stable paint commit before fading the overlay
116- const remove = ( ) => {
117- if ( typeof w . __dspaceRemoveSsrOverlay === 'function' ) {
118- w . __dspaceRemoveSsrOverlay ( ) ;
119- }
120- } ;
121- if ( typeof w . requestAnimationFrame === 'function' ) {
122- w . requestAnimationFrame ( ( ) => setTimeout ( remove , 50 ) ) ;
123- } else {
124- setTimeout ( remove , 50 ) ;
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 ( ) ;
125181 }
126182 } ) ;
183+ observer . observe ( dsApp , { childList : true , subtree : true } ) ;
184+ return ( ) => observer . disconnect ( ) ;
127185 } ) ;
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 ( ) ;
128221 }
129222
130223 ngOnInit ( ) {
0 commit comments