Skip to content

Commit 2a8a531

Browse files
JCU/fix(ssr): stop the blank flash on load and on reload (#1493)
* JCU/fix(ssr): serve a reloaded comcol search tab from the page it belongs to Opening a community or collection moves the address bar to its /search tab, and #1424 put that path in ssr.excludePathPatterns. Reloading there returned the 1113-byte CSR shell, so the visitor got a white page for 0.6s on a fast desktop and up to 3.4s on a slow one. Both URLs render ComcolSearchSectionComponent and the comcol page itself is server-rendered, so a GET for the bare tab URL is redirected to it; the client puts /search back in the address bar as it did before. A URL carrying a query is left alone, which is the point of the exclusion: what saturated SSR on mendelu (#1402) is the Discovery facet parameter space, and those URLs all carry f. parameters. The redirect turns itself off if the tab stops being excluded, if the comcol page ever becomes excluded, or if defaultBrowseTab is not the search tab, since then the comcol page is a different view and the reload would land somewhere the visitor did not ask for. Doing this in ComcolPageBrowseByComponent instead, by not redirecting to the default tab, looks tidier and does not work: getSearchLink() returns currentPath(router) for an in-place search, so every facet link would move to /collections/<uuid>?f. , which no pattern matches, and the trap reopens. Also fixes the robots.txt comment claiming the SSR-side rule is not configured, wrong since #1424. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * JCU/fix(ssr): keep the rendered page on screen while Angular rebuilds it Every server-rendered route flashes white on load and on reload: the browser paints the SSR page, then clears <ds-app> and renders the whole tree again. provideClientHydration() is registered in the browser config only, never in the server one, so platform-server emits no hydration annotations (ngh= count is 0 on every DSpace 9 instance) and Angular calls selectRootElement with preserveContent false. Upstream issue: DSpace#3867. index.html paints a detached clone of the server-rendered view on top and drops it once Angular has detached the node it cloned, #main-content is visible again and the DOM has been quiet for 600ms. Waiting for that detach matters: keyed on quiet alone the clone leaves during the long pause before Angular starts, and the wipe happens in plain sight. Cloning rather than moving means the DOM Angular works on is untouched, and the clone is built after the first paint so it does not delay it. On a route in ssr.excludePathPatterns there is nothing to clone, so a spinner fills the gap instead of a white page. It is not held to the same rule: there is no paint to protect, so it goes as soon as something is behind it. The spinner itself is held back 400ms, so a fast bootstrap never shows one. Kept self-contained rather than driving the removal from AppComponent, as customer/mendelu does: measured on a production build, Angular constructs AppComponent around 5s after the script runs, so that version only ever hit its fallback timer and left the clone up for 15s. Enabling hydration on the server is not a substitute. Tried and measured: annotations appear, but hydration claims only ds-app and ds-root before dying on ds-themed-root, because ThemedComponent builds themed wrappers with ViewContainerRef.createComponent in ngAfterViewInit and then removes the projected host. The app then renders nothing at all. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 28388bb commit 2a8a531

3 files changed

Lines changed: 207 additions & 1 deletion

File tree

server.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -221,10 +221,45 @@ export function app() {
221221
return server;
222222
}
223223

224+
/**
225+
* The comcol page moves the address bar to its /search tab, which ssr.excludePathPatterns skips, so
226+
* a reload there returned the CSR shell. Both URLs render ComcolSearchSectionComponent, so the bare
227+
* URL goes back to the page. A query is left alone: those are the facet URLs the exclusion is for.
228+
*
229+
* @param req current request
230+
* @returns the path to redirect to, or null to handle the request normally
231+
*/
232+
function comcolSearchTabRedirect(req): string {
233+
if (!environment.ssr.enabled || (req.method !== 'GET' && req.method !== 'HEAD') || req.originalUrl.includes('?')) {
234+
return null;
235+
}
236+
const match = /^(\/(collections|communities)\/[0-9a-f-]{36})\/search\/?$/i.exec(req.path);
237+
if (match === null) {
238+
return null;
239+
}
240+
// Only holds while search is the default tab; with another one the comcol page is a different
241+
// view and the redirect would move the visitor off the tab they reloaded.
242+
const page = match[2].toLowerCase() === 'collections' ? environment.collection : environment.community;
243+
if (page.defaultBrowseTab !== 'search') {
244+
return null;
245+
}
246+
const patterns = environment.ssr.excludePathPatterns;
247+
// Nothing to gain when the tab is server-rendered anyway, or when the page itself is not.
248+
if (!isExcludedFromSsr(req.path, patterns) || isExcludedFromSsr(match[1], patterns)) {
249+
return null;
250+
}
251+
return req.baseUrl + match[1];
252+
}
253+
224254
/*
225255
* The callback function to serve server side angular
226256
*/
227257
function ngApp(req, res, next) {
258+
const comcolPath = comcolSearchTabRedirect(req);
259+
if (comcolPath !== null) {
260+
res.redirect(302, comcolPath);
261+
return;
262+
}
228263
if (environment.ssr.enabled && req.method === 'GET' && (req.path === '/' || !isExcludedFromSsr(req.path, environment.ssr.excludePathPatterns))) {
229264
// Render the page to user via SSR (server side rendering)
230265
serverSideRender(req, res, next);

src/index.html

Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,181 @@
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+
#__dspace_ssr_overlay {
12+
/* absolute, not fixed: the copy has to scroll with the document, and holding the page's height
13+
while <ds-app> is empty keeps the scrollbar and the restored scroll position intact */
14+
position: absolute;
15+
top: 0;
16+
left: 0;
17+
right: 0;
18+
min-height: 100vh;
19+
z-index: 10000;
20+
background: #fff;
21+
overflow: hidden;
22+
pointer-events: none;
23+
}
24+
#__dspace_ssr_overlay.__dspace_ssr_booting {
25+
display: flex;
26+
align-items: center;
27+
justify-content: center;
28+
}
29+
#__dspace_ssr_overlay .__dspace_ssr_spinner {
30+
width: 3rem;
31+
height: 3rem;
32+
border: .25rem solid rgba(0, 0, 0, .1);
33+
border-top-color: rgba(0, 0, 0, .35);
34+
border-radius: 50%;
35+
/* held back, so a bootstrap that finishes quickly never shows a spinner at all */
36+
opacity: 0;
37+
animation: __dspace_ssr_spin .8s linear infinite, __dspace_ssr_fade .2s ease-in .4s forwards;
38+
}
39+
@keyframes __dspace_ssr_spin {
40+
to { transform: rotate(360deg); }
41+
}
42+
@keyframes __dspace_ssr_fade {
43+
to { opacity: 1; }
44+
}
45+
@media (prefers-reduced-motion: reduce) {
46+
#__dspace_ssr_overlay .__dspace_ssr_spinner {
47+
animation: __dspace_ssr_fade .2s ease-in .4s forwards;
48+
}
49+
}
50+
</style>
1051
</head>
1152

1253
<body>
1354
<ds-app></ds-app>
55+
<script>
56+
/*
57+
The browser paints the SSR page, then Angular clears <ds-app> and renders it again, because
58+
provideClientHydration() is registered in the browser config only. Upstream: DSpace/dspace-angular#3867.
59+
A detached clone of that paint covers the rebuild; on an SSR-excluded route there is nothing to
60+
clone, so a spinner stands in. Self-contained because AppComponent is constructed far too late to
61+
drive it.
62+
*/
63+
(function () {
64+
if (typeof window === 'undefined' || typeof document === 'undefined') return;
65+
// Cypress and Playwright drive the real app; a cloned copy breaks element-uniqueness selectors.
66+
if (typeof window.Cypress !== 'undefined') return;
67+
if (typeof navigator !== 'undefined' && navigator.webdriver) return;
68+
69+
var QUIET_MS = 600; // the rebuild is done after this long with no element added or removed
70+
var BACKSTOP_MS = 15000; // reveal regardless, so a page that never goes quiet is not held hostage
71+
var MIN_HEIGHT_PX = 200; // proves <ds-app> is no longer the empty shell
72+
73+
// After the first paint: cloning the server-rendered tree is not free, and the wipe it guards
74+
// against is seconds away.
75+
if (typeof window.requestAnimationFrame === 'function') {
76+
window.requestAnimationFrame(function () { setTimeout(start, 0); });
77+
} else {
78+
setTimeout(start, 0);
79+
}
80+
81+
function start() {
82+
try {
83+
var app = document.querySelector('ds-app');
84+
if (!app) return;
85+
if (document.getElementById('__dspace_ssr_overlay')) return;
86+
87+
var overlay = document.createElement('div');
88+
overlay.id = '__dspace_ssr_overlay';
89+
// A duplicate of content the live app still exposes: keep it out of the a11y tree and tab order.
90+
overlay.setAttribute('aria-hidden', 'true');
91+
overlay.setAttribute('inert', '');
92+
93+
// The node Angular throws away. While it is attached, the rebuild has not started.
94+
var ssrRoot = app.firstElementChild;
95+
96+
if (ssrRoot) {
97+
// Clone, never move: the copy keeps its _nghost/_ngcontent attributes, so it still has styles.
98+
overlay.appendChild(app.cloneNode(true));
99+
} else {
100+
// Nothing to hold: stand in for the loader the app shows once it is up.
101+
overlay.className = '__dspace_ssr_booting';
102+
var spinner = document.createElement('div');
103+
spinner.className = '__dspace_ssr_spinner';
104+
overlay.appendChild(spinner);
105+
}
106+
document.body.appendChild(overlay);
107+
108+
var quietTimer = null;
109+
var observer = null;
110+
111+
var remove = function () {
112+
if (!overlay) return;
113+
var el = overlay;
114+
overlay = null;
115+
if (observer) observer.disconnect();
116+
if (quietTimer) clearTimeout(quietTimer);
117+
window.__dspaceRemoveSsrOverlay = null;
118+
el.style.transition = 'opacity 150ms ease-out';
119+
el.style.opacity = '0';
120+
setTimeout(function () {
121+
if (el.parentNode) el.parentNode.removeChild(el);
122+
}, 200);
123+
};
124+
window.__dspaceRemoveSsrOverlay = remove; // so an e2e run can drop it on demand
125+
126+
// root.component hides .outer-wrapper behind its loader, so a visible #main-content means the
127+
// routed page is on screen.
128+
var contentIsVisible = function () {
129+
var main = app.querySelector('#main-content');
130+
return !!main && main.offsetParent !== null &&
131+
app.getBoundingClientRect().height >= MIN_HEIGHT_PX;
132+
};
133+
134+
var maybeRemove = function () {
135+
// Without the first half the clone leaves during the quiet stretch before Angular starts, and
136+
// the wipe it exists to cover happens in plain sight.
137+
if (ssrRoot && app.contains(ssrRoot)) return;
138+
if (!contentIsVisible()) return;
139+
// one frame, so the new content is painted before the overlay fades out
140+
if (typeof window.requestAnimationFrame === 'function') {
141+
window.requestAnimationFrame(remove);
142+
} else {
143+
remove();
144+
}
145+
};
146+
147+
var restartQuietTimer = function () {
148+
if (quietTimer) clearTimeout(quietTimer);
149+
quietTimer = setTimeout(maybeRemove, QUIET_MS);
150+
};
151+
152+
// The clone has to outlast the whole rebuild, so it waits for the DOM to go quiet. The spinner
153+
// is only filling a void, so it leaves as soon as there is something behind it.
154+
var onMutation = ssrRoot ? restartQuietTimer : maybeRemove;
155+
156+
observer = new MutationObserver(function (records) {
157+
for (var i = 0; i < records.length; i++) {
158+
var r = records[i];
159+
if (r.type !== 'childList') continue;
160+
var nodes = [].slice.call(r.addedNodes).concat([].slice.call(r.removedNodes));
161+
for (var j = 0; j < nodes.length; j++) {
162+
if (nodes[j].nodeType === 1) { onMutation(); return; }
163+
}
164+
}
165+
});
166+
observer.observe(app, { childList: true, subtree: true });
167+
onMutation();
168+
169+
setTimeout(remove, BACKSTOP_MS);
170+
} catch (e) {
171+
// A throw between appending the overlay and arming the backstop would otherwise leave it up.
172+
try {
173+
if (observer) observer.disconnect();
174+
if (quietTimer) clearTimeout(quietTimer);
175+
if (overlay && overlay.parentNode) overlay.parentNode.removeChild(overlay);
176+
window.__dspaceRemoveSsrOverlay = null;
177+
} catch (ignored) { /* nothing left to do */ }
178+
if (window.console && typeof console.warn === 'function') {
179+
console.warn('[dspace-ssr-overlay] disabled due to error:', e);
180+
}
181+
}
182+
}
183+
})();
184+
</script>
14185
</body>
15186

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

src/robots.txt.ejs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ Disallow: /communities/*/search
3131
Disallow: /*?f.
3232
Disallow: /*&f.
3333
# NOTE: robots.txt is advisory only; it does not stop crawlers that ignore it.
34-
# The SSR-side enforcement (ssr.excludePathPatterns) is not configured here.
34+
# ssr.excludePathPatterns in config.yml enforces the facet part server-side, for any user agent.
3535

3636
# Heavy Discovery facet queries on browse pages
3737
Disallow: /collections/*?f

0 commit comments

Comments
 (0)