@@ -94,30 +94,33 @@ export class AppComponent implements OnInit, AfterViewInit {
9494
9595 /**
9696 * Drops the SSR mask overlay installed by the inline bootstrap script in src/index.html once the
97- * real CSR content has actually been painted. This trigger has to thread a needle between the two
98- * earlier approaches, each of which fixed one symptom and reintroduced the other:
97+ * routed CSR page has actually finished rendering. Picking the right moment is the whole problem,
98+ * and the two earlier attempts each fixed one symptom and reintroduced the other:
9999 *
100- * - PR #1288 waited for `ApplicationRef.isStable`. That guaranteed the content was painted (no
101- * flicker) but isStable is held hostage by ANY ongoing zone async — after an admin login the
102- * app keeps the zone busy (authz/widgets, periodic polling, AAI/discojuice scripts) so isStable
103- * fires many seconds late (or never, hitting the 15s fallback). The inert snapshot then masks
104- * the live, already-rendered page -> "looks rendered but not interactive" (issue #725).
105- * - PR #1317 switched to the loader-swap gate `!isAuthenticationBlocking && !isThemeLoading` plus
106- * a single requestAnimationFrame. That fires promptly (fixing #725) but the gate only un-hides
107- * `<router-outlet>`; Angular has NOT yet rendered the routed page at that instant and one rAF
108- * runs before the browser paints, so the snapshot is dropped over an empty <ds-app> for a frame
109- * or two -> the flicker came back.
100+ * - PR #1288 waited for `ApplicationRef.isStable`. No flicker, but isStable is held hostage by ANY
101+ * ongoing zone async — after an admin login the app keeps the zone busy (authz/widgets, periodic
102+ * polling, AAI/discojuice scripts) so isStable fires many seconds late (or hits the 15s
103+ * fallback). The inert snapshot then masks the live page -> "looks rendered but not interactive"
104+ * (issue #725).
105+ * - PR #1317 switched to the loader-swap gate `!isAuthenticationBlocking && !isThemeLoading` plus a
106+ * single requestAnimationFrame. Prompt, but that gate only un-hides `<router-outlet>`; the home
107+ * page then renders piecewise (navbar, search box, community list, recent submissions) as each
108+ * section's data arrives. Dropping the snapshot at the gate — or, as an earlier revision of this
109+ * method did, as soon as *some* content exists — exposes a half-built page that visibly pops
110+ * into place on a hard reload -> the flicker.
110111 *
111- * We keep #1317's decoupling from isStable (so background async can never delay us) but, after the
112- * gate opens, we wait across animation frames until the real <ds-app> is actually laid out before
113- * removing the snapshot. See {@link removeSsrOverlayAfterContentPainted}.
112+ * The signal we actually need is "the routed page has stopped changing". So, after the gate opens,
113+ * we keep the snapshot until the real <ds-app> DOM has SETTLED: no element added or removed for a
114+ * short quiet window. This stays decoupled from isStable (DOM-settle ignores non-rendering
115+ * background async, so admin reveals in a few seconds rather than ~15s) while waiting for the page
116+ * the user is actually looking at to be fully built. See {@link removeSsrOverlayWhenDomSettles}.
114117 */
115118 private removeSsrOverlayWhenContentVisible ( ) : void {
116119 const w : Window | undefined = this . _window ?. nativeWindow ;
117120 if ( ! w || typeof w . __dspaceRemoveSsrOverlay !== 'function' ) {
118121 return ;
119122 }
120- // run outside Angular so the subscription does not keep change detection alive
123+ // run outside Angular so the subscription/observer does not keep change detection alive
121124 this . ngZone . runOutsideAngular ( ( ) => {
122125 combineLatest ( [
123126 this . store . pipe ( select ( isAuthenticationBlocking ) , distinctUntilChanged ( ) ) ,
@@ -126,65 +129,99 @@ export class AppComponent implements OnInit, AfterViewInit {
126129 filter ( ( [ blocking , themeLoading ] : [ boolean , boolean ] ) => ! blocking && ! themeLoading ) ,
127130 first ( ) ,
128131 ) . subscribe ( ( ) => {
129- this . removeSsrOverlayAfterContentPainted ( w ) ;
132+ this . removeSsrOverlayWhenDomSettles ( w ) ;
130133 } ) ;
131134 } ) ;
132135 }
133136
134137 /**
135- * Waits until the routed CSR view has been committed to the DOM and painted, then removes the SSR
136- * snapshot overlay. "Painted" is approximated by the real <ds-app> reaching a non-trivial height
137- * AND containing its `#main- content` host (i.e. it is no longer the empty shell the overlay script
138- * left behind). We poll this cheap layout signal once per animation frame, capped at MAX_FRAMES so
139- * that — unlike isStable in #1288 — nothing can hold the overlay open indefinitely; the 15s hard
140- * fallback in index.html stays as the catastrophic-error safety net.
138+ * Removes the SSR snapshot overlay once the real <ds-app> subtree has stopped mutating for
139+ * SETTLE_QUIET_MS (the routed page finished rendering its sections), requiring a minimum amount of
140+ * content first so we never settle on the empty shell the overlay script left behind. A
141+ * MutationObserver tracks element add/remove inside <ds-app>; every such change rearms the quiet
142+ * timer. Capped at SETTLE_MAX_MS so a page that never goes quiet (e.g. constant background DOM
143+ * updates) still reveals; the 15s fallback in index.html remains the ultimate net.
141144 */
142- private removeSsrOverlayAfterContentPainted ( w : Window ) : void {
145+ private removeSsrOverlayWhenDomSettles ( w : Window ) : void {
143146 const doc : Document = this . document ;
144- const raf : ( ( cb : FrameRequestCallback ) => number ) | null =
145- typeof w . requestAnimationFrame === 'function' ? w . requestAnimationFrame . bind ( w ) : null ;
147+ const SETTLE_QUIET_MS = 600 ; // no DOM change for this long => the routed page has finished rendering
148+ const SETTLE_MAX_MS = 10000 ; // hard cap so a never-quiet page still reveals (below index.html's 15s net)
149+ const MIN_CONTENT_HEIGHT = 200 ; // px: proves <ds-app> is no longer the empty shell
150+
151+ const app : Element | null = doc . querySelector ( 'ds-app' ) ;
146152 const remove = ( ) => {
147153 if ( typeof w . __dspaceRemoveSsrOverlay === 'function' ) {
148154 w . __dspaceRemoveSsrOverlay ( ) ;
149155 }
150156 } ;
151- const MAX_FRAMES = 180 ; // ~3s @60fps safety cap; the routed shell normally paints within a few frames
152- const MIN_CONTENT_HEIGHT = 200 ; // px: enough to prove the real <ds-app> is no longer the empty shell
153- let frames = 0 ;
154- const contentPainted = ( ) : boolean => {
155- const app : Element | null = doc . querySelector ( 'ds-app' ) ;
156- if ( ! app ) {
157+ const hasMinContent = ( ) : boolean => {
158+ const el : Element | null = doc . querySelector ( 'ds-app' ) ;
159+ if ( ! el ) {
157160 return false ;
158161 }
159162 let height = 0 ;
160163 try {
161- height = app . getBoundingClientRect ( ) . height ;
164+ height = el . getBoundingClientRect ( ) . height ;
162165 } catch ( e ) {
163166 height = 0 ;
164167 }
165- return height >= MIN_CONTENT_HEIGHT && app . querySelector ( '#main-content' ) !== null ;
168+ return height >= MIN_CONTENT_HEIGHT && el . querySelector ( '#main-content' ) !== null ;
166169 } ;
167- const tick = ( ) => {
168- if ( contentPainted ( ) || ++ frames >= MAX_FRAMES ) {
169- // one more frame so the painted content is committed to screen before the snapshot fades
170- if ( raf ) {
171- raf ( remove ) ;
172- } else {
173- remove ( ) ;
174- }
170+
171+ let done = false ;
172+ let quietTimer : ReturnType < typeof setTimeout > | null = null ;
173+ let capTimer : ReturnType < typeof setTimeout > | null = null ;
174+
175+ const finish = ( ) => {
176+ if ( done ) {
175177 return ;
176178 }
177- if ( raf ) {
178- raf ( tick ) ;
179+ done = true ;
180+ if ( quietTimer !== null ) { clearTimeout ( quietTimer ) ; }
181+ if ( capTimer !== null ) { clearTimeout ( capTimer ) ; }
182+ try { observer . disconnect ( ) ; } catch ( e ) { /* noop */ }
183+ // one rAF so the final rendered frame is committed to screen before the snapshot fades
184+ if ( typeof w . requestAnimationFrame === 'function' ) {
185+ w . requestAnimationFrame ( remove ) ;
179186 } else {
180- setTimeout ( tick , 16 ) ;
187+ remove ( ) ;
181188 }
182189 } ;
183- if ( raf ) {
184- raf ( tick ) ;
185- } else {
186- setTimeout ( tick , 16 ) ;
190+
191+ const armQuietTimer = ( ) => {
192+ if ( done ) {
193+ return ;
194+ }
195+ if ( quietTimer !== null ) { clearTimeout ( quietTimer ) ; }
196+ quietTimer = setTimeout ( ( ) => {
197+ // DOM has been quiet for SETTLE_QUIET_MS; reveal once real content is there, else keep waiting
198+ if ( hasMinContent ( ) ) {
199+ finish ( ) ;
200+ } else {
201+ armQuietTimer ( ) ;
202+ }
203+ } , SETTLE_QUIET_MS ) ;
204+ } ;
205+
206+ const observer = new MutationObserver ( ( mutations : MutationRecord [ ] ) => {
207+ for ( const m of mutations ) {
208+ if ( m . type === 'childList' && ( m . addedNodes . length > 0 || m . removedNodes . length > 0 ) ) {
209+ let elementChanged = false ;
210+ m . addedNodes . forEach ( ( n : Node ) => { if ( n . nodeType === 1 ) { elementChanged = true ; } } ) ;
211+ m . removedNodes . forEach ( ( n : Node ) => { if ( n . nodeType === 1 ) { elementChanged = true ; } } ) ;
212+ if ( elementChanged ) {
213+ armQuietTimer ( ) ; // a section rendered -> reset the quiet window
214+ return ;
215+ }
216+ }
217+ }
218+ } ) ;
219+
220+ if ( app ) {
221+ observer . observe ( app , { childList : true , subtree : true } ) ;
187222 }
223+ capTimer = setTimeout ( finish , SETTLE_MAX_MS ) ;
224+ armQuietTimer ( ) ;
188225 }
189226
190227 ngOnInit ( ) {
0 commit comments