Skip to content
Binary file added _build.log
Binary file not shown.
Binary file added _spec.log
Binary file not shown.
63 changes: 61 additions & 2 deletions src/app/app.component.spec.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { Store, StoreModule } from '@ngrx/store';
import { ComponentFixture, inject, TestBed, waitForAsync } from '@angular/core/testing';
import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
import { ComponentFixture, fakeAsync, flush, inject, TestBed, tick, waitForAsync } from '@angular/core/testing';
import { ApplicationRef, CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ActivatedRoute, Router } from '@angular/router';
import { TranslateLoader, TranslateModule } from '@ngx-translate/core';
import { BehaviorSubject } from 'rxjs';

// Load the implementations that should be tested
import { AppComponent } from './app.component';
Expand Down Expand Up @@ -127,4 +128,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<boolean>;
let originalRaF: typeof window.requestAnimationFrame;

beforeEach(() => {
appRef = TestBed.inject(ApplicationRef);
isStable$ = new BehaviorSubject<boolean>(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;
});
Comment thread
jr-rk marked this conversation as resolved.
Outdated
Comment thread
jr-rk marked this conversation as resolved.
Outdated

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();
}));
});
});
40 changes: 39 additions & 1 deletion src/app/app.component.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import { distinctUntilChanged, take, withLatestFrom } from 'rxjs/operators';
import { distinctUntilChanged, filter, first, take, withLatestFrom } from 'rxjs/operators';
import { DOCUMENT, isPlatformBrowser } from '@angular/common';
import {
AfterViewInit,
ApplicationRef,
ChangeDetectionStrategy,
Component,
HostListener,
Inject,
NgZone,
OnInit,
PLATFORM_ID,
} from '@angular/core';
Expand Down Expand Up @@ -74,6 +76,8 @@ 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;

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

if (isPlatformBrowser(this.platformId)) {
this.trackIdleModal();
this.removeSsrOverlayWhenStable();
}

this.isThemeLoading$ = this.themeService.isThemeLoading$;

this.storeCSSVariables();
}

/**
* Drops the SSR mask overlay installed by the inline bootstrap script in src/index.html as soon
* as Angular reaches its first stable state. The overlay is the only thing the user sees while
* Angular 15 rebuilds the SSR DOM; removing it too early would expose the rebuild flicker, too
* late would feel sluggish. We add a short safety pad to let the first paint settle, and there
* is also a 15s hard fallback 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);
}
});
});
Comment thread
jr-rk marked this conversation as resolved.
}

ngOnInit() {
/** Implement behavior for interface {@link ModalBeforeDismiss} */
this.modalConfig.beforeDismiss = async function () {
Expand Down
119 changes: 119 additions & 0 deletions src/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,131 @@
<title>DSpace</title>
<meta name="viewport" content="width=device-width,minimum-scale=1">
<meta http-equiv="cache-control" content="no-store">
<style id="__dspace-ssr-overlay-style">
/* Overlay used to mask the Angular 15 bootstrap re-render of the SSR DOM.
See src/index.html bootstrap script + AppComponent.removeSsrOverlayWhenStable.
The overlay element holds the SSR-rendered children moved out of <ds-app>,
so it keeps every original Angular view-encapsulation attribute (_ngcontent-scXXX),
Comment thread
jr-rk marked this conversation as resolved.
Outdated
inline style, and lifecycle context — i.e. it looks pixel-identical to what SSR sent. */
#__dspace_ssr_overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
width: 100%;
z-index: 10000;
background: #fff;
pointer-events: none;
}
/* Keep <ds-app> taking layout space (height of CSR result) but hidden, so the page
does not collapse the moment we drop the overlay. */
ds-app[data-dspace-ssr-hidden] { visibility: hidden; }
</style>
</head>
<body>
<!-- dependencies -->
<script type="text/javascript" src="https://code.jquery.com/jquery-2.1.4.min.js"></script>
<script type="text/javascript" src="https://cdnjs.cloudflare.com/ajax/libs/lodash.js/3.10.1/lodash.min.js"></script>
<ds-app></ds-app>
<script>
/*
Anti-flicker overlay.
Why this exists: this codebase is on Angular 15 + ngUniversal. There is no provideClientHydration
available before Angular 16, so on every browser load Angular tears down the entire SSR DOM and
re-renders the component tree from scratch. The rebuild takes ~600-1500 ms on slow connections,
during which the user sees the SSR view -> blank/half-built CSR view -> final CSR view.
This script captures the SSR DOM as a non-interactive snapshot the moment it's parsed (before
any module/main script runs - those are type=module and therefore deferred). While Angular
rebuilds the real <ds-app> invisibly, the snapshot keeps the page looking stable. AppComponent
removes the overlay once ApplicationRef.isStable settles.
*/
(function () {
if (typeof window === 'undefined' || typeof document === 'undefined') return;
// Skip when Cypress is driving the page. The overlay duplicates SSR DOM (moved into the
// overlay) alongside the CSR DOM (rendered into <ds-app>) during the masking window — so
// any cy.get('#some-id').click() picks up two elements and fails. The overlay is a pure
// UX nicety, and Cypress E2E doesn't measure visual smoothness anyway; bail early.
if (typeof window.Cypress !== 'undefined') return;
try {
var app = document.querySelector('ds-app');
// If SSR was skipped for this route (excludePathPatterns), there are no children; nothing to mask.
if (!app || !app.firstElementChild) return;
if (document.getElementById('__dspace_ssr_overlay')) return;

// Critical: Angular's BrowserModule.withServerTransition removes ALL <style ng-transition="...">
// tags during bootstrap. Those tags hold the component-scoped CSS that styles the SSR DOM
// via attribute selectors like [_ngcontent-scXXX]. If we don't preserve copies, the overlay
// renders unstyled. Clone the SSR styles into <style data-dspace-ssr-keep> tags that Angular
// ignores, so the overlay keeps looking like the page the user already saw.
var ssrStyles = document.querySelectorAll('style[ng-transition]');
var keptStyles = [];
for (var i = 0; i < ssrStyles.length; i++) {
var copy = document.createElement('style');
copy.setAttribute('data-dspace-ssr-keep', '');
copy.textContent = ssrStyles[i].textContent;
document.head.appendChild(copy);
keptStyles.push(copy);
}

// Build overlay and MOVE (not clone) the SSR children into it. Moving keeps every live DOM
// detail (Angular's view-encapsulation attributes, computed inline styles, image-load state)
// so the overlay is pixel-identical to what the user already saw before Angular booted.
// Cloning via innerHTML loses parent-context-dependent rendering.
//
// Accessibility note: we deliberately do NOT set aria-hidden on the overlay. The overlay
// *is* the visible page during the masking window, so assistive technologies should read
// it. The original <ds-app> underneath gets visibility:hidden (via attribute + CSS rule),
// which removes both itself and its children from the accessibility tree.
var overlay = document.createElement('div');
overlay.id = '__dspace_ssr_overlay';
while (app.firstChild) {
overlay.appendChild(app.firstChild);
}

// Hide the now-empty <ds-app> so Angular can rebuild into it invisibly. We use an attribute
// (CSS in <head> targets it) rather than setting .style.visibility directly so Angular's
// template doesn't blow it away on first ChangeDetection.
app.setAttribute('data-dspace-ssr-hidden', '');
document.body.appendChild(overlay);

var removing = false;
window.__dspaceRemoveSsrOverlay = function () {
// Re-entrancy guard: null the pointer up-front so a racing isStable + 15s safety
// fallback cannot start two interleaving fade-out passes (which would re-remove
// the kept styles from underneath the first pass).
if (removing) return;
removing = true;
window.__dspaceRemoveSsrOverlay = null;

var el = document.getElementById('__dspace_ssr_overlay');
if (!el) return;
app.removeAttribute('data-dspace-ssr-hidden');
el.style.transition = 'opacity 150ms ease-out';
el.style.opacity = '0';
setTimeout(function () {
if (el && el.parentNode) el.parentNode.removeChild(el);
for (var i = 0; i < keptStyles.length; i++) {
if (keptStyles[i].parentNode) keptStyles[i].parentNode.removeChild(keptStyles[i]);
}
keptStyles = [];
}, 200);
};

// Safety net: if the app never reaches isStable (e.g. permanent HTTP poll), remove anyway.
setTimeout(function () {
if (typeof window.__dspaceRemoveSsrOverlay === 'function') {
window.__dspaceRemoveSsrOverlay();
}
}, 15000);
} catch (e) {
// Don't let the overlay logic kill the page, but surface the failure so a silently-broken
// flicker fix is at least diagnosable in DevTools.
if (window.console && typeof console.warn === 'function') {
console.warn('[dspace-ssr-overlay] disabled due to error:', e);
}
}
})();
</script>
</body>

<!-- do not include client bundle, it is injected with Zone already loaded -->
Expand Down
9 changes: 9 additions & 0 deletions src/typings.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,3 +86,12 @@ declare module '*.scss' {
const content: any;
export default content;
}

/**
* Window global injected by the inline anti-flicker bootstrap script in `src/index.html`.
* Called once by `AppComponent.removeSsrOverlayWhenStable()` when `ApplicationRef.isStable`
* fires, to drop the SSR-mask overlay and let the freshly built CSR DOM become visible.
*/
interface Window {
__dspaceRemoveSsrOverlay?: (() => void) | null;
}
Loading