Skip to content

Commit 12a2550

Browse files
milanmajchrakclaude
andcommitted
fix(ssr-overlay): make the masked page interactive + skip overlay under WebDriver (close #725)
Addresses an expert review of the DOM-settle change: DOM-settle alone shortened but did not eliminate issue #725, because the masking window still left the page non-interactive (the opaque overlay sat over a visibility:hidden <ds-app>, so clicks landed on nothing) and still produced duplicate DOM that breaks strict-mode E2E locators. Changes: - index.html: drop the `ds-app[data-dspace-ssr-hidden]{visibility:hidden}` rule and give the overlay min-height:100vh. The overlay is already opaque + pointer-events:none, so it still masks the CSR rebuild visually, but clicks now pass THROUGH it to the real, still-visible <ds-app> underneath. The page is therefore interactive the entire time it is masked -> #725 ("looks rendered but nothing is clickable") cannot recur, even if removal rides the 10s cap. The snapshot is marked aria-hidden so AT (and the duplicate-node concern) target the live app, not the snapshot. - index.html: also bypass the overlay for WebDriver runners (navigator.webdriver), mirroring the existing Cypress guard, so Playwright/Selenium see no overlay and no duplicate DOM (fixes the #725 strict-mode locator failure). - app.component.ts: exclude the admin-sidebar subtree from the DOM-settle MutationObserver (its long :enter/:leave animations would keep re-arming the quiet window and push admin logins toward the cap); add OnDestroy + takeUntil + observer/timer teardown. - Fix stale `ApplicationRef.isStable` / `removeSsrOverlayWhenStable` comments (index.html, typings.d.ts). Verified against the live build (Playwright, CPU/network throttled): - WebDriver run: overlay absent (no duplicate DOM). - webdriver spoofed false (real-user path): a navbar click WHILE the snapshot is still shown reaches the live <ds-app> (interactive under mask). - anon reveal settled, CLS after reveal = 0; admin reveal settled (reason "settled", not cap), CLS after reveal = 0. Refs: dspace-customers#725, PR #1288, PR #1317, PR #1318, PR #1321 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 9a0258b commit 12a2550

3 files changed

Lines changed: 83 additions & 28 deletions

File tree

src/app/app.component.ts

Lines changed: 44 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { distinctUntilChanged, filter, first, take, withLatestFrom, delay } from 'rxjs/operators';
1+
import { distinctUntilChanged, filter, first, take, takeUntil, withLatestFrom, delay } from 'rxjs/operators';
22
import { DOCUMENT, isPlatformBrowser } from '@angular/common';
33
import {
44
AfterViewInit,
@@ -7,6 +7,7 @@ import {
77
HostListener,
88
Inject,
99
NgZone,
10+
OnDestroy,
1011
OnInit,
1112
PLATFORM_ID,
1213
} from '@angular/core';
@@ -17,7 +18,7 @@ import {
1718
Router,
1819
} from '@angular/router';
1920

20-
import { BehaviorSubject, combineLatest, Observable } from 'rxjs';
21+
import { BehaviorSubject, combineLatest, Observable, Subject } from 'rxjs';
2122
import { select, Store } from '@ngrx/store';
2223
import { NgbModal, NgbModalConfig } from '@ng-bootstrap/ng-bootstrap';
2324
import { TranslateService } from '@ngx-translate/core';
@@ -39,10 +40,20 @@ import { distinctNext } from './core/shared/distinct-next';
3940
styleUrls: ['./app.component.scss'],
4041
changeDetection: ChangeDetectionStrategy.OnPush,
4142
})
42-
export class AppComponent implements OnInit, AfterViewInit {
43+
export class AppComponent implements OnInit, AfterViewInit, OnDestroy {
4344
notificationOptions;
4445
models;
4546

47+
/**
48+
* Emits on destroy to tear down the overlay-removal gate subscription / DOM-settle watcher.
49+
* AppComponent is the root component (lives for the app's lifetime) so this is mostly defensive
50+
* + test hygiene, but it guarantees no MutationObserver/timer outlives the component.
51+
*/
52+
private destroyed$ = new Subject<void>();
53+
54+
/** Set while the DOM-settle watcher is pending; disconnects the observer + clears its timers. */
55+
private cancelOverlaySettle: (() => void) | null = null;
56+
4657
/**
4758
* Whether or not the authentication is currently blocking the UI
4859
*/
@@ -127,6 +138,7 @@ export class AppComponent implements OnInit, AfterViewInit {
127138
this.themeService.isThemeLoading$,
128139
]).pipe(
129140
filter(([blocking, themeLoading]: [boolean, boolean]) => !blocking && !themeLoading),
141+
takeUntil(this.destroyed$),
130142
first(),
131143
).subscribe(() => {
132144
this.removeSsrOverlayWhenDomSettles(w);
@@ -177,6 +189,7 @@ export class AppComponent implements OnInit, AfterViewInit {
177189
return;
178190
}
179191
done = true;
192+
this.cancelOverlaySettle = null;
180193
if (quietTimer !== null) { clearTimeout(quietTimer); }
181194
if (capTimer !== null) { clearTimeout(capTimer); }
182195
try { observer.disconnect(); } catch (e) { /* noop */ }
@@ -203,9 +216,22 @@ export class AppComponent implements OnInit, AfterViewInit {
203216
}, SETTLE_QUIET_MS);
204217
};
205218

219+
// The admin sidebar (left chrome) runs long :enter/:leave height animations that add/remove DOM
220+
// well after the routed page has rendered; counting those would keep re-arming the quiet window
221+
// (and can push admin logins toward the cap). The sidebar is not part of the above-the-fold
222+
// content whose late pop-in users perceive as flicker, so exclude its subtree from the signal.
223+
const inAdminSidebar = (target: Node | null): boolean => {
224+
const el: Element | null = target && target.nodeType === 1
225+
? (target as Element)
226+
: (target ? target.parentElement : null);
227+
return !!(el && typeof el.closest === 'function' && el.closest('ds-themed-admin-sidebar, ds-admin-sidebar'));
228+
};
206229
const observer = new MutationObserver((mutations: MutationRecord[]) => {
207230
for (const m of mutations) {
208231
if (m.type === 'childList' && (m.addedNodes.length > 0 || m.removedNodes.length > 0)) {
232+
if (inAdminSidebar(m.target)) {
233+
continue;
234+
}
209235
let elementChanged = false;
210236
m.addedNodes.forEach((n: Node) => { if (n.nodeType === 1) { elementChanged = true; } });
211237
m.removedNodes.forEach((n: Node) => { if (n.nodeType === 1) { elementChanged = true; } });
@@ -221,9 +247,24 @@ export class AppComponent implements OnInit, AfterViewInit {
221247
observer.observe(app, { childList: true, subtree: true });
222248
}
223249
capTimer = setTimeout(finish, SETTLE_MAX_MS);
250+
// expose teardown so ngOnDestroy can cancel a still-pending settle (and for test hygiene)
251+
this.cancelOverlaySettle = () => {
252+
if (quietTimer !== null) { clearTimeout(quietTimer); }
253+
if (capTimer !== null) { clearTimeout(capTimer); }
254+
try { observer.disconnect(); } catch (e) { /* noop */ }
255+
};
224256
armQuietTimer();
225257
}
226258

259+
ngOnDestroy(): void {
260+
this.destroyed$.next();
261+
this.destroyed$.complete();
262+
if (this.cancelOverlaySettle) {
263+
this.cancelOverlaySettle();
264+
this.cancelOverlaySettle = null;
265+
}
266+
}
267+
227268
ngOnInit() {
228269
/** Implement behavior for interface {@link ModalBeforeDismiss} */
229270
this.modalConfig.beforeDismiss = async function () {

src/index.html

Lines changed: 36 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -9,23 +9,31 @@
99
<meta http-equiv="cache-control" content="no-store">
1010
<style id="__dspace-ssr-overlay-style">
1111
/* 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. */
12+
See src/index.html bootstrap script + AppComponent.removeSsrOverlayWhenContentVisible
13+
(-> removeSsrOverlayWhenDomSettles). The overlay element holds the SSR-rendered children
14+
moved out of <ds-app>, so it keeps every original Angular view-encapsulation attribute
15+
(_ngcontent-scXXX), inline style, and lifecycle context — i.e. it looks pixel-identical to
16+
what SSR sent.
17+
18+
Interactivity: the overlay is opaque (background:#fff) and on top (z-index), but
19+
pointer-events:none, AND the real <ds-app> underneath is NO LONGER visibility:hidden. So the
20+
overlay only MASKS the CSR rebuild visually — clicks pass straight through it to the live,
21+
already-built app underneath. That is what keeps the page interactive while masked and stops
22+
the masking window from reproducing issue #725 ("looks rendered but nothing is clickable").
23+
min-height:100vh guarantees the opaque mask always covers the viewport so the rebuilding app
24+
cannot peek through (the live <ds-app> only ever grows up to the snapshot's height while it
25+
rebuilds, so it stays behind the overlay until removal). */
1626
#__dspace_ssr_overlay {
1727
position: absolute;
1828
top: 0;
1929
left: 0;
2030
right: 0;
2131
width: 100%;
32+
min-height: 100vh;
2233
z-index: 10000;
2334
background: #fff;
2435
pointer-events: none;
2536
}
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; }
2937
</style>
3038
</head>
3139
<body>
@@ -40,18 +48,21 @@
4048
available before Angular 16, so on every browser load Angular tears down the entire SSR DOM and
4149
re-renders the component tree from scratch. The rebuild takes ~600-1500 ms on slow connections,
4250
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.
51+
This script captures the SSR DOM as a snapshot the moment it's parsed (before any module/main
52+
script runs - those are type=module and therefore deferred). While Angular rebuilds the real
53+
<ds-app> underneath (visually covered by the opaque snapshot, but still interactive), the snapshot
54+
keeps the page looking stable. AppComponent removes the overlay once the routed CSR page has
55+
finished rendering (its DOM has settled) - see AppComponent.removeSsrOverlayWhenDomSettles.
4756
*/
4857
(function () {
4958
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.
59+
// Skip when an E2E runner is driving the page. The overlay duplicates SSR DOM (moved into the
60+
// overlay) alongside the CSR DOM (rendered into <ds-app>) during the masking window — so a
61+
// strict-mode locator like cy.get('#x')/page.locator('#x') picks up two elements and fails. The
62+
// overlay is a pure UX nicety and E2E doesn't measure visual smoothness, so bail early for both
63+
// Cypress (window.Cypress) and any WebDriver-based runner (Playwright/Selenium: navigator.webdriver).
5464
if (typeof window.Cypress !== 'undefined') return;
65+
if (typeof navigator !== 'undefined' && navigator.webdriver) return;
5566
try {
5667
var app = document.querySelector('ds-app');
5768
// If SSR was skipped for this route (excludePathPatterns), there are no children; nothing to mask.
@@ -78,25 +89,27 @@
7889
// so the overlay is pixel-identical to what the user already saw before Angular booted.
7990
// Cloning via innerHTML loses parent-context-dependent rendering.
8091
//
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.
92+
// Accessibility: the snapshot is now a purely VISUAL mask, so we mark it aria-hidden. The real
93+
// <ds-app> underneath is no longer visibility:hidden — it stays in the accessibility tree and
94+
// is the interactive surface — so assistive tech (and mouse clicks) target the live, functional
95+
// app rather than a soon-to-be-removed duplicate snapshot. This also avoids the duplicate
96+
// a11y nodes the old (overlay-as-a11y-surface) approach produced during masking.
8597
var overlay = document.createElement('div');
8698
overlay.id = '__dspace_ssr_overlay';
99+
overlay.setAttribute('aria-hidden', 'true');
87100
while (app.firstChild) {
88101
overlay.appendChild(app.firstChild);
89102
}
90103

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.
104+
// Mark <ds-app> as being masked. NOTE: this attribute is now only a state hook — it no longer
105+
// hides the element (the visibility:hidden CSS rule was removed) so the live app stays visible
106+
// (covered by the opaque overlay) and, crucially, INTERACTIVE while Angular rebuilds into it.
94107
app.setAttribute('data-dspace-ssr-hidden', '');
95108
document.body.appendChild(overlay);
96109

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

src/typings.d.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -89,8 +89,9 @@ declare module '*.scss' {
8989

9090
/**
9191
* 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.
92+
* Called once by `AppComponent.removeSsrOverlayWhenContentVisible()` (via
93+
* `removeSsrOverlayWhenDomSettles()`) once the routed CSR page's DOM has settled, to drop the
94+
* SSR-mask overlay and let the freshly built CSR DOM become visible.
9495
*/
9596
interface Window {
9697
__dspaceRemoveSsrOverlay?: (() => void) | null;

0 commit comments

Comments
 (0)