Skip to content

Commit e812864

Browse files
milanmajchrakclaude
andcommitted
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 during bootstrap and drops it once <ds-app> has stopped changing and shows a visible #main-content, with a 10s backstop. Cloning rather than moving means the DOM Angular works on is untouched. 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>
1 parent e4e1e8e commit e812864

1 file changed

Lines changed: 114 additions & 0 deletions

File tree

src/index.html

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,124 @@
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+
position: fixed;
13+
inset: 0;
14+
z-index: 10000;
15+
background: #fff;
16+
overflow: hidden;
17+
pointer-events: none;
18+
}
19+
</style>
1020
</head>
1121

1222
<body>
1323
<ds-app></ds-app>
24+
<script>
25+
/*
26+
Keeps the server-rendered page on screen while Angular rebuilds it underneath.
27+
28+
provideClientHydration() is registered in the browser config only, never in the server one, so
29+
platform-server emits no hydration annotations and the browser clears <ds-app> and renders the
30+
whole tree again. Upstream issue: DSpace/dspace-angular#3867.
31+
32+
We paint a detached clone on top instead of moving the server-rendered nodes, so nothing Angular
33+
touches is disturbed, and remove it once <ds-app> has stopped changing and shows a visible
34+
#main-content. Deliberately self-contained: an earlier version drove the removal from
35+
AppComponent and never got there, because Angular constructs it long after this script runs.
36+
*/
37+
(function () {
38+
if (typeof window === 'undefined' || typeof document === 'undefined') return;
39+
// Cypress and Playwright drive the real app; a cloned copy breaks element-uniqueness selectors.
40+
if (typeof window.Cypress !== 'undefined') return;
41+
if (typeof navigator !== 'undefined' && navigator.webdriver) return;
42+
43+
var QUIET_MS = 600; // the rebuild is done after this long with no element added or removed
44+
var BACKSTOP_MS = 10000; // reveal regardless, so a page that never goes quiet is not held hostage
45+
var MIN_HEIGHT_PX = 200; // proves <ds-app> is no longer the empty shell
46+
47+
try {
48+
var app = document.querySelector('ds-app');
49+
// No server-rendered content, e.g. an SSR-excluded route, so there is nothing to cover.
50+
if (!app || !app.firstElementChild) return;
51+
if (document.getElementById('__dspace_ssr_overlay')) return;
52+
53+
var overlay = document.createElement('div');
54+
overlay.id = '__dspace_ssr_overlay';
55+
// A visual duplicate of content the live app still exposes: keep it out of the a11y tree and
56+
// out of the tab order so focus cannot land on the dead cloned controls.
57+
overlay.setAttribute('aria-hidden', 'true');
58+
overlay.setAttribute('inert', '');
59+
60+
// Clone, never move. The clone keeps its _nghost/_ngcontent attributes, so the component styles
61+
// already in <head> still apply and the frozen frame looks like the server-rendered paint.
62+
overlay.appendChild(app.cloneNode(true));
63+
document.body.appendChild(overlay);
64+
65+
var quietTimer = null;
66+
var observer = null;
67+
68+
var remove = function () {
69+
if (!overlay) return;
70+
var el = overlay;
71+
overlay = null;
72+
if (observer) observer.disconnect();
73+
if (quietTimer) clearTimeout(quietTimer);
74+
window.__dspaceRemoveSsrOverlay = null;
75+
el.style.transition = 'opacity 150ms ease-out';
76+
el.style.opacity = '0';
77+
setTimeout(function () {
78+
if (el.parentNode) el.parentNode.removeChild(el);
79+
}, 200);
80+
};
81+
// Exposed so an e2e run or a later change can drop the clone on demand.
82+
window.__dspaceRemoveSsrOverlay = remove;
83+
84+
// root.component keeps .outer-wrapper display:none behind its fullscreen loader, so a visible
85+
// #main-content is what tells us the routed page, and not the loader, is on screen.
86+
var contentIsVisible = function () {
87+
var main = app.querySelector('#main-content');
88+
return !!main && main.offsetParent !== null &&
89+
app.getBoundingClientRect().height >= MIN_HEIGHT_PX;
90+
};
91+
92+
var onQuiet = function () {
93+
if (!contentIsVisible()) return; // wait for the next mutation
94+
// one frame, so the rebuilt content is painted before the clone fades out
95+
if (typeof window.requestAnimationFrame === 'function') {
96+
window.requestAnimationFrame(remove);
97+
} else {
98+
remove();
99+
}
100+
};
101+
102+
var restartQuietTimer = function () {
103+
if (quietTimer) clearTimeout(quietTimer);
104+
quietTimer = setTimeout(onQuiet, QUIET_MS);
105+
};
106+
107+
observer = new MutationObserver(function (records) {
108+
for (var i = 0; i < records.length; i++) {
109+
var r = records[i];
110+
if (r.type !== 'childList') continue;
111+
var nodes = [].slice.call(r.addedNodes).concat([].slice.call(r.removedNodes));
112+
for (var j = 0; j < nodes.length; j++) {
113+
if (nodes[j].nodeType === 1) { restartQuietTimer(); return; }
114+
}
115+
}
116+
});
117+
observer.observe(app, { childList: true, subtree: true });
118+
restartQuietTimer();
119+
120+
setTimeout(remove, BACKSTOP_MS);
121+
} catch (e) {
122+
if (window.console && typeof console.warn === 'function') {
123+
console.warn('[dspace-ssr-overlay] disabled due to error:', e);
124+
}
125+
}
126+
})();
127+
</script>
14128
</body>
15129

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

0 commit comments

Comments
 (0)