Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
93 changes: 92 additions & 1 deletion src/app/app.component.spec.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,15 @@
import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
import {
ApplicationRef,
CUSTOM_ELEMENTS_SCHEMA,
} from '@angular/core';
import {
ComponentFixture,
fakeAsync,
flush,
inject,
TestBed,
tick,
waitForAsync,
} from '@angular/core/testing';
import {
Expand All @@ -19,6 +25,7 @@ import {
TranslateLoader,
TranslateModule,
} from '@ngx-translate/core';
import { BehaviorSubject } from 'rxjs';

import { APP_CONFIG } from '../config/app-config.interface';
import { environment } from '../environments/environment';
Expand Down Expand Up @@ -149,4 +156,88 @@ 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;
let originalIsStable: PropertyDescriptor | undefined;

beforeEach(() => {
appRef = TestBed.inject(ApplicationRef);
isStable$ = new BehaviorSubject<boolean>(false);
// Patch isStable to our controllable subject for this test only. Keep it configurable and
// remember the previous descriptor so afterEach can restore it - otherwise the override
// leaks onto the shared TestBed ApplicationRef instance and into later specs.
originalIsStable = Object.getOwnPropertyDescriptor(appRef, 'isStable');
Object.defineProperty(appRef, 'isStable', { value: isStable$.asObservable(), configurable: true });

// 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;
// Restore isStable so the patched observable cannot leak into later specs.
if (originalIsStable) {
Object.defineProperty(appRef, 'isStable', originalIsStable);
} else {
delete (appRef as any).isStable;
}
});

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();
}));

it('still removes the overlay when requestAnimationFrame is unavailable', fakeAsync(() => {
// Exercises the fallback scheduler branch in removeSsrOverlayWhenStable.
const spy = jasmine.createSpy('__dspaceRemoveSsrOverlay');
window.__dspaceRemoveSsrOverlay = spy;
(window as any).requestAnimationFrame = undefined;

const f = TestBed.createComponent(AppComponent);
f.detectChanges();

isStable$.next(true);
tick(50);
flush();

expect(spy).toHaveBeenCalledTimes(1);
}));
});
});
41 changes: 41 additions & 0 deletions src/app/app.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,12 @@ import {
} from '@angular/common';
import {
AfterViewInit,
ApplicationRef,
ChangeDetectionStrategy,
Component,
HostListener,
Inject,
NgZone,
OnInit,
PLATFORM_ID,
} from '@angular/core';
Expand All @@ -34,6 +36,8 @@ import {
import {
delay,
distinctUntilChanged,
filter,
first,
take,
withLatestFrom,
} from 'rxjs/operators';
Expand Down Expand Up @@ -100,18 +104,55 @@ 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;

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

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

this.storeCSSVariables();
}

/**
* Drops the hydration-safe SSR freeze-frame installed by the inline bootstrap script in
* src/index.html once Angular reaches its first stable state. On DSpace 9 (Angular 18) the
* page DOES hydrate, but the theme system re-creates every `ds-themed-*` wrapper imperatively
* on the client (see ThemedComponent), so the SSR view is briefly rebuilt; the overlay clone
* masks that rebuild. We wait for the first stable paint, add a short pad, then fade it out.
* A 15s hard fallback lives 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);
}
});
});
}

ngOnInit() {
/** Implement behavior for interface {@link ModalBeforeDismiss} */
this.modalConfig.beforeDismiss = async function () {
Expand Down
87 changes: 87 additions & 0 deletions src/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,97 @@
<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">
/* Hydration-safe anti-flicker overlay (DSpace 9 / Angular 18).
Unlike the Angular-15 variant, this NEVER moves or mutates the SSR DOM that Angular
hydrates — it only paints a detached *clone* on top. See the bootstrap script below +
AppComponent.removeSsrOverlayWhenStable. Root cause this masks: DSpace's ThemedComponent
instantiates themed wrappers imperatively (ViewContainerRef.createComponent in
ngAfterViewInit), which Angular hydration cannot reuse, so every themed subtree is
re-created client-side. Tracked upstream: DSpace/dspace-angular#3867. */
#__dspace_ssr_overlay {
position: fixed;
inset: 0;
z-index: 10000;
background: #fff;
overflow: hidden;
pointer-events: none;
}
Comment thread
jr-rk marked this conversation as resolved.
</style>
</head>

<body>
<ds-app></ds-app>
<script>
/*
Hydration-safe anti-flicker overlay (DSpace 9 / Angular 18 + provideClientHydration).

Why a different approach than the Angular-15 instances: this app DOES hydrate, but DSpace's
theme system defeats it. Every visible wrapper is a `ds-themed-*` (ThemedComponent) that builds
its real content imperatively via ViewContainerRef.createComponent() in a client-side, async
ngAfterViewInit (dynamic import of the theme chunk), and removes the projected SSR content.
Angular hydration can't reuse imperatively-created components, and the SSR markup no longer
matches the declarative template -> node mismatch (NG0500) -> destructive re-render of the
themed subtrees -> the flash. Upstream issue: DSpace/dspace-angular#3867.

We therefore must NOT touch the DOM Angular hydrates (the 15.x overlay MOVED nodes, which would
*worsen* the mismatch here). Instead we paint a detached CLONE of the SSR view on top, let
hydration + the themed re-render happen invisibly underneath on the untouched original, and drop
the clone once ApplicationRef.isStable (AppComponent.removeSsrOverlayWhenStable), with a 15s
hard fallback.
*/
(function () {
if (typeof window === 'undefined' || typeof document === 'undefined') return;
// Cypress drives the real app; a duplicated clone would break element-uniqueness selectors.
if (typeof window.Cypress !== 'undefined') return;
try {
var app = document.querySelector('ds-app');
// No SSR content (e.g. an SSR-excluded route) -> nothing to mask.
if (!app || !app.firstElementChild) return;
if (document.getElementById('__dspace_ssr_overlay')) return;

var overlay = document.createElement('div');
overlay.id = '__dspace_ssr_overlay';
// It's a visual duplicate of content the real app still exposes; hide it from a11y tree
// and make it inert so keyboard focus can't land on the dead cloned controls.
overlay.setAttribute('aria-hidden', 'true');
overlay.setAttribute('inert', '');

// CLONE (deep), never move: the originals stay exactly where Angular expects to hydrate them.
// The clone keeps every _nghost/_ngcontent attribute, so the global component styles in <head>
// still apply and the frozen frame looks identical to the SSR paint.
overlay.appendChild(app.cloneNode(true));
document.body.appendChild(overlay);

var removing = false;
window.__dspaceRemoveSsrOverlay = function () {
// Re-entrancy guard so a racing isStable + the 15s fallback can't double-run the fade.
if (removing) return;
removing = true;
window.__dspaceRemoveSsrOverlay = null;

var el = document.getElementById('__dspace_ssr_overlay');
if (!el) return;
el.style.transition = 'opacity 150ms ease-out';
el.style.opacity = '0';
setTimeout(function () {
if (el && el.parentNode) el.parentNode.removeChild(el);
}, 200);
};

// Safety net: if isStable never fires, never trap the user behind the frozen frame.
setTimeout(function () {
if (typeof window.__dspaceRemoveSsrOverlay === 'function') {
window.__dspaceRemoveSsrOverlay();
}
}, 15000);
} catch (e) {
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 hydration-safe anti-flicker bootstrap script in
* `src/index.html`. Called once by `AppComponent.removeSsrOverlayWhenStable()` when
* `ApplicationRef.isStable` fires, to drop the SSR freeze-frame overlay.
*/
interface Window {
__dspaceRemoveSsrOverlay?: (() => void) | null;
}
Loading