Skip to content

Commit 6133cb1

Browse files
committed
Backport of Fix home-page SSR->CSR flicker
1 parent f6e9309 commit 6133cb1

9 files changed

Lines changed: 237 additions & 7 deletions

File tree

_build.log

49.3 KB
Binary file not shown.

_install.log

5.42 KB
Binary file not shown.

_spec.log

3.82 KB
Binary file not shown.

angular.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -99,8 +99,8 @@
9999
"budgets": [
100100
{
101101
"type": "initial",
102-
"maximumWarning": "3mb",
103-
"maximumError": "5mb"
102+
"maximumWarning": "5.5mb",
103+
"maximumError": "6mb"
104104
},
105105
{
106106
"type": "anyComponentStyle",

src/app/app.component.spec.ts

Lines changed: 61 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
import { Store, StoreModule } from '@ngrx/store';
2-
import { ComponentFixture, inject, TestBed, waitForAsync } from '@angular/core/testing';
3-
import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
2+
import { ComponentFixture, fakeAsync, flush, inject, TestBed, tick, waitForAsync } from '@angular/core/testing';
3+
import { ApplicationRef, CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
44
import { CommonModule } from '@angular/common';
55
import { ActivatedRoute, Router } from '@angular/router';
66
import { TranslateLoader, TranslateModule } from '@ngx-translate/core';
7+
import { BehaviorSubject } from 'rxjs';
78

89
// Load the implementations that should be tested
910
import { AppComponent } from './app.component';
@@ -127,4 +128,62 @@ describe('App component', () => {
127128
});
128129

129130
});
131+
132+
describe('removeSsrOverlayWhenStable', () => {
133+
// The inline bootstrap script in src/index.html injects window.__dspaceRemoveSsrOverlay
134+
// and AppComponent must call it exactly once when ApplicationRef.isStable first emits true.
135+
let appRef: ApplicationRef;
136+
let isStable$: BehaviorSubject<boolean>;
137+
let originalRaF: typeof window.requestAnimationFrame;
138+
139+
beforeEach(() => {
140+
appRef = TestBed.inject(ApplicationRef);
141+
isStable$ = new BehaviorSubject<boolean>(false);
142+
// Patch isStable to our controllable subject for this test only
143+
Object.defineProperty(appRef, 'isStable', { value: isStable$.asObservable() });
144+
145+
// Force rAF to a synchronous shim so we can flush() through the chain deterministically.
146+
originalRaF = window.requestAnimationFrame;
147+
(window as any).requestAnimationFrame = (cb: FrameRequestCallback) => {
148+
cb(0);
149+
return 0 as any;
150+
};
151+
});
152+
153+
afterEach(() => {
154+
(window as any).requestAnimationFrame = originalRaF;
155+
delete (window as any).__dspaceRemoveSsrOverlay;
156+
});
157+
158+
it('removes the overlay once isStable emits true', fakeAsync(() => {
159+
const spy = jasmine.createSpy('__dspaceRemoveSsrOverlay');
160+
window.__dspaceRemoveSsrOverlay = spy;
161+
162+
// Re-construct so the constructor-time subscription picks up our patched isStable + global.
163+
const f = TestBed.createComponent(AppComponent);
164+
f.detectChanges();
165+
166+
expect(spy).not.toHaveBeenCalled();
167+
168+
isStable$.next(true);
169+
tick(50); // matches the 50ms pad after rAF in removeSsrOverlayWhenStable
170+
flush();
171+
172+
expect(spy).toHaveBeenCalledTimes(1);
173+
}));
174+
175+
it('is a no-op when the global is not injected (e.g. CSR-only route, SSR skipped)', fakeAsync(() => {
176+
// Global intentionally absent; constructor should not throw and should not break later.
177+
delete (window as any).__dspaceRemoveSsrOverlay;
178+
179+
const f = TestBed.createComponent(AppComponent);
180+
expect(() => f.detectChanges()).not.toThrow();
181+
182+
isStable$.next(true);
183+
tick(50);
184+
flush();
185+
186+
expect(window.__dspaceRemoveSsrOverlay).toBeUndefined();
187+
}));
188+
});
130189
});

src/app/app.component.ts

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
1-
import { distinctUntilChanged, take, withLatestFrom, delay } from 'rxjs/operators';
1+
import { distinctUntilChanged, filter, first, take, withLatestFrom, delay } from 'rxjs/operators';
22
import { DOCUMENT, isPlatformBrowser } from '@angular/common';
33
import {
44
AfterViewInit,
5+
ApplicationRef,
56
ChangeDetectionStrategy,
67
Component,
78
HostListener,
89
Inject,
10+
NgZone,
911
OnInit,
1012
PLATFORM_ID,
1113
} from '@angular/core';
@@ -74,6 +76,8 @@ export class AppComponent implements OnInit, AfterViewInit {
7476
private cssService: CSSVariableService,
7577
private modalService: NgbModal,
7678
private modalConfig: NgbModalConfig,
79+
private appRef: ApplicationRef,
80+
private ngZone: NgZone,
7781
) {
7882
this.notificationOptions = environment.notifications;
7983

@@ -82,13 +86,47 @@ export class AppComponent implements OnInit, AfterViewInit {
8286

8387
if (isPlatformBrowser(this.platformId)) {
8488
this.trackIdleModal();
89+
this.removeSsrOverlayWhenStable();
8590
}
8691

8792
this.isThemeLoading$ = this.themeService.isThemeLoading$;
8893

8994
this.storeCSSVariables();
9095
}
9196

97+
/**
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.
103+
*/
104+
private removeSsrOverlayWhenStable(): void {
105+
const w: Window | undefined = this._window?.nativeWindow;
106+
if (!w || typeof w.__dspaceRemoveSsrOverlay !== 'function') {
107+
return;
108+
}
109+
// run outside Angular so we don't keep changeDetection ticking on the overlay timer
110+
this.ngZone.runOutsideAngular(() => {
111+
this.appRef.isStable.pipe(
112+
filter((stable: boolean) => stable),
113+
first(),
114+
).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);
125+
}
126+
});
127+
});
128+
}
129+
92130
ngOnInit() {
93131
/** Implement behavior for interface {@link ModalBeforeDismiss} */
94132
this.modalConfig.beforeDismiss = async function () {

src/index.html

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,131 @@
77
<title>DSpace</title>
88
<meta name="viewport" content="width=device-width,minimum-scale=1">
99
<meta http-equiv="cache-control" content="no-store">
10+
<style id="__dspace-ssr-overlay-style">
11+
/* Overlay used to mask the Angular 15 bootstrap re-render of the SSR DOM.
12+
See src/index.html bootstrap script + AppComponent.removeSsrOverlayWhenStable.
13+
The overlay element holds the SSR-rendered children moved out of <ds-app>,
14+
so it keeps every original Angular view-encapsulation attribute (_ngcontent-scXXX),
15+
inline style, and lifecycle context — i.e. it looks pixel-identical to what SSR sent. */
16+
#__dspace_ssr_overlay {
17+
position: absolute;
18+
top: 0;
19+
left: 0;
20+
right: 0;
21+
width: 100%;
22+
z-index: 10000;
23+
background: #fff;
24+
pointer-events: none;
25+
}
26+
/* Keep <ds-app> taking layout space (height of CSR result) but hidden, so the page
27+
does not collapse the moment we drop the overlay. */
28+
ds-app[data-dspace-ssr-hidden] { visibility: hidden; }
29+
</style>
1030
</head>
1131
<body>
1232
<!-- dependencies -->
1333
<script type="text/javascript" src="https://code.jquery.com/jquery-2.1.4.min.js"></script>
1434
<script type="text/javascript" src="https://cdnjs.cloudflare.com/ajax/libs/lodash.js/3.10.1/lodash.min.js"></script>
1535
<ds-app></ds-app>
36+
<script>
37+
/*
38+
Anti-flicker overlay.
39+
Why this exists: this codebase is on Angular 15 + ngUniversal. There is no provideClientHydration
40+
available before Angular 16, so on every browser load Angular tears down the entire SSR DOM and
41+
re-renders the component tree from scratch. The rebuild takes ~600-1500 ms on slow connections,
42+
during which the user sees the SSR view -> blank/half-built CSR view -> final CSR view.
43+
This script captures the SSR DOM as a non-interactive snapshot the moment it's parsed (before
44+
any module/main script runs - those are type=module and therefore deferred). While Angular
45+
rebuilds the real <ds-app> invisibly, the snapshot keeps the page looking stable. AppComponent
46+
removes the overlay once ApplicationRef.isStable settles.
47+
*/
48+
(function () {
49+
if (typeof window === 'undefined' || typeof document === 'undefined') return;
50+
// Skip when Cypress is driving the page. The overlay duplicates SSR DOM (moved into the
51+
// overlay) alongside the CSR DOM (rendered into <ds-app>) during the masking window — so
52+
// any cy.get('#some-id').click() picks up two elements and fails. The overlay is a pure
53+
// UX nicety, and Cypress E2E doesn't measure visual smoothness anyway; bail early.
54+
if (typeof window.Cypress !== 'undefined') return;
55+
try {
56+
var app = document.querySelector('ds-app');
57+
// If SSR was skipped for this route (excludePathPatterns), there are no children; nothing to mask.
58+
if (!app || !app.firstElementChild) return;
59+
if (document.getElementById('__dspace_ssr_overlay')) return;
60+
61+
// Critical: Angular's BrowserModule.withServerTransition removes ALL <style ng-transition="...">
62+
// tags during bootstrap. Those tags hold the component-scoped CSS that styles the SSR DOM
63+
// via attribute selectors like [_ngcontent-scXXX]. If we don't preserve copies, the overlay
64+
// renders unstyled. Clone the SSR styles into <style data-dspace-ssr-keep> tags that Angular
65+
// ignores, so the overlay keeps looking like the page the user already saw.
66+
var ssrStyles = document.querySelectorAll('style[ng-transition]');
67+
var keptStyles = [];
68+
for (var i = 0; i < ssrStyles.length; i++) {
69+
var copy = document.createElement('style');
70+
copy.setAttribute('data-dspace-ssr-keep', '');
71+
copy.textContent = ssrStyles[i].textContent;
72+
document.head.appendChild(copy);
73+
keptStyles.push(copy);
74+
}
75+
76+
// Build overlay and MOVE (not clone) the SSR children into it. Moving keeps every live DOM
77+
// detail (Angular's view-encapsulation attributes, computed inline styles, image-load state)
78+
// so the overlay is pixel-identical to what the user already saw before Angular booted.
79+
// Cloning via innerHTML loses parent-context-dependent rendering.
80+
//
81+
// Accessibility note: we deliberately do NOT set aria-hidden on the overlay. The overlay
82+
// *is* the visible page during the masking window, so assistive technologies should read
83+
// it. The original <ds-app> underneath gets visibility:hidden (via attribute + CSS rule),
84+
// which removes both itself and its children from the accessibility tree.
85+
var overlay = document.createElement('div');
86+
overlay.id = '__dspace_ssr_overlay';
87+
while (app.firstChild) {
88+
overlay.appendChild(app.firstChild);
89+
}
90+
91+
// Hide the now-empty <ds-app> so Angular can rebuild into it invisibly. We use an attribute
92+
// (CSS in <head> targets it) rather than setting .style.visibility directly so Angular's
93+
// template doesn't blow it away on first ChangeDetection.
94+
app.setAttribute('data-dspace-ssr-hidden', '');
95+
document.body.appendChild(overlay);
96+
97+
var removing = false;
98+
window.__dspaceRemoveSsrOverlay = function () {
99+
// Re-entrancy guard: null the pointer up-front so a racing isStable + 15s safety
100+
// fallback cannot start two interleaving fade-out passes (which would re-remove
101+
// the kept styles from underneath the first pass).
102+
if (removing) return;
103+
removing = true;
104+
window.__dspaceRemoveSsrOverlay = null;
105+
106+
var el = document.getElementById('__dspace_ssr_overlay');
107+
if (!el) return;
108+
app.removeAttribute('data-dspace-ssr-hidden');
109+
el.style.transition = 'opacity 150ms ease-out';
110+
el.style.opacity = '0';
111+
setTimeout(function () {
112+
if (el && el.parentNode) el.parentNode.removeChild(el);
113+
for (var i = 0; i < keptStyles.length; i++) {
114+
if (keptStyles[i].parentNode) keptStyles[i].parentNode.removeChild(keptStyles[i]);
115+
}
116+
keptStyles = [];
117+
}, 200);
118+
};
119+
120+
// Safety net: if the app never reaches isStable (e.g. permanent HTTP poll), remove anyway.
121+
setTimeout(function () {
122+
if (typeof window.__dspaceRemoveSsrOverlay === 'function') {
123+
window.__dspaceRemoveSsrOverlay();
124+
}
125+
}, 15000);
126+
} catch (e) {
127+
// Don't let the overlay logic kill the page, but surface the failure so a silently-broken
128+
// flicker fix is at least diagnosable in DevTools.
129+
if (window.console && typeof console.warn === 'function') {
130+
console.warn('[dspace-ssr-overlay] disabled due to error:', e);
131+
}
132+
}
133+
})();
134+
</script>
16135
</body>
17136

18137
<!-- do not include client bundle, it is injected with Zone already loaded -->

src/themes/eager-themes.module.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,23 @@
11
import { NgModule } from '@angular/core';
22
import { EagerThemeModule as DSpaceEagerThemeModule } from './dspace/eager-theme.module';
3-
// import { EagerThemeModule as CustomEagerThemeModule } from './custom/eager-theme.module';
3+
import { EagerThemeModule as CustomEagerThemeModule } from './custom/eager-theme.module';
44

55
/**
66
* This module bundles the eager theme modules for all available themes.
77
* Eager modules contain components that are present on every page (to speed up initial loading)
88
* and entry components (to ensure their decorators get picked up).
99
*
1010
* Themes that aren't in use should not be imported here so they don't take up unnecessary space in the main bundle.
11+
*
12+
* NOTE: CustomEagerThemeModule is included to prevent the home-page flicker that occurs when
13+
* the active theme is `custom`. Without it, every themed wrapper (footer, header, root, ...) is
14+
* lazy-loaded via webpack code-splitting on the browser, leaving visible gaps after the SSR DOM
15+
* is torn down and before the CSR DOM is materialised.
1116
*/
1217
@NgModule({
1318
imports: [
1419
DSpaceEagerThemeModule,
15-
// CustomEagerThemeModule,
20+
CustomEagerThemeModule,
1621
],
1722
})
1823
export class EagerThemesModule {

src/typings.d.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,3 +86,12 @@ declare module '*.scss' {
8686
const content: any;
8787
export default content;
8888
}
89+
90+
/**
91+
* Window global injected by the inline anti-flicker bootstrap script in `src/index.html`.
92+
* Called once by `AppComponent.removeSsrOverlayWhenStable()` when `ApplicationRef.isStable`
93+
* fires, to drop the SSR-mask overlay and let the freshly built CSR DOM become visible.
94+
*/
95+
interface Window {
96+
__dspaceRemoveSsrOverlay?: (() => void) | null;
97+
}

0 commit comments

Comments
 (0)