Skip to content

Commit 7f1d021

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. On a route excluded from SSR there is nothing to clone, so it shows a spinner in place of the white page; the spinner is held back 400ms, so a bootstrap that finishes quickly 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>
1 parent d0cc2bc commit 7f1d021

1 file changed

Lines changed: 161 additions & 0 deletions

File tree

src/index.html

Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,171 @@
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+
#__dspace_ssr_overlay.__dspace_ssr_booting {
20+
display: flex;
21+
align-items: center;
22+
justify-content: center;
23+
}
24+
#__dspace_ssr_overlay .__dspace_ssr_spinner {
25+
width: 3rem;
26+
height: 3rem;
27+
border: .25rem solid rgba(0, 0, 0, .1);
28+
border-top-color: rgba(0, 0, 0, .35);
29+
border-radius: 50%;
30+
/* held back, so a bootstrap that finishes quickly never shows a spinner at all */
31+
opacity: 0;
32+
animation: __dspace_ssr_spin .8s linear infinite, __dspace_ssr_fade .2s ease-in .4s forwards;
33+
}
34+
@keyframes __dspace_ssr_spin {
35+
to { transform: rotate(360deg); }
36+
}
37+
@keyframes __dspace_ssr_fade {
38+
to { opacity: 1; }
39+
}
40+
@media (prefers-reduced-motion: reduce) {
41+
#__dspace_ssr_overlay .__dspace_ssr_spinner {
42+
animation: __dspace_ssr_fade .2s ease-in .4s forwards;
43+
}
44+
}
45+
</style>
1046
</head>
1147

1248
<body>
1349
<ds-app></ds-app>
50+
<script>
51+
/*
52+
Covers the stretch of bootstrap where the visitor would otherwise be looking at a blank page.
53+
54+
On a server-rendered route the browser paints the page, then clears <ds-app> and renders the whole
55+
tree again: provideClientHydration() is registered in the browser config only, never in the server
56+
one, so platform-server emits no hydration annotations. Upstream issue:
57+
DSpace/dspace-angular#3867. We paint a detached clone on top rather than moving the server-rendered
58+
nodes, so nothing Angular touches is disturbed.
59+
60+
On a route in ssr.excludePathPatterns there is no paint to hold, so we show the same kind of loader
61+
the app puts up itself once it is running.
62+
63+
Either way the overlay goes once <ds-app> has stopped changing and shows a visible #main-content.
64+
Self-contained on purpose: an earlier version drove the removal from AppComponent and never got
65+
there, because Angular constructs it long after this script runs.
66+
*/
67+
(function () {
68+
if (typeof window === 'undefined' || typeof document === 'undefined') return;
69+
// Cypress and Playwright drive the real app; a cloned copy breaks element-uniqueness selectors.
70+
if (typeof window.Cypress !== 'undefined') return;
71+
if (typeof navigator !== 'undefined' && navigator.webdriver) return;
72+
73+
var QUIET_MS = 600; // the rebuild is done after this long with no element added or removed
74+
var BACKSTOP_MS = 15000; // reveal regardless, so a page that never goes quiet is not held hostage
75+
var MIN_HEIGHT_PX = 200; // proves <ds-app> is no longer the empty shell
76+
77+
try {
78+
var app = document.querySelector('ds-app');
79+
if (!app) return;
80+
if (document.getElementById('__dspace_ssr_overlay')) return;
81+
82+
var overlay = document.createElement('div');
83+
overlay.id = '__dspace_ssr_overlay';
84+
// A visual duplicate of content the live app still exposes: keep it out of the a11y tree and
85+
// out of the tab order so focus cannot land on the dead cloned controls.
86+
overlay.setAttribute('aria-hidden', 'true');
87+
overlay.setAttribute('inert', '');
88+
89+
// The node Angular throws away. While it is still in the document the client has not started
90+
// rebuilding yet, and every readiness check below would be answering about the server's paint.
91+
var ssrRoot = app.firstElementChild;
92+
93+
if (ssrRoot) {
94+
// Clone, never move. The clone keeps its _nghost/_ngcontent attributes, so the component
95+
// styles already in <head> still apply and the frozen frame looks like the server-rendered
96+
// paint.
97+
overlay.appendChild(app.cloneNode(true));
98+
} else {
99+
// An SSR-excluded route: there is no paint to hold, so stand in for the loader the app puts
100+
// up once it is running rather than leaving a white page for the whole bootstrap.
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+
// Exposed so an e2e run or a later change can drop the clone on demand.
125+
window.__dspaceRemoveSsrOverlay = remove;
126+
127+
// root.component keeps .outer-wrapper display:none behind its fullscreen loader, so a visible
128+
// #main-content is what tells us the routed page, and not the loader, is on screen.
129+
var contentIsVisible = function () {
130+
var main = app.querySelector('#main-content');
131+
return !!main && main.offsetParent !== null &&
132+
app.getBoundingClientRect().height >= MIN_HEIGHT_PX;
133+
};
134+
135+
var onQuiet = function () {
136+
// Not once the client is done, but once it has thrown the server's paint away and put its own
137+
// up. Without the first half the overlay leaves during the quiet stretch before Angular
138+
// starts, and the wipe it exists to cover happens in plain sight.
139+
if (ssrRoot && app.contains(ssrRoot)) return;
140+
if (!contentIsVisible()) return; // wait for the next mutation
141+
// one frame, so the rebuilt content is painted before the clone fades out
142+
if (typeof window.requestAnimationFrame === 'function') {
143+
window.requestAnimationFrame(remove);
144+
} else {
145+
remove();
146+
}
147+
};
148+
149+
var restartQuietTimer = function () {
150+
if (quietTimer) clearTimeout(quietTimer);
151+
quietTimer = setTimeout(onQuiet, QUIET_MS);
152+
};
153+
154+
observer = new MutationObserver(function (records) {
155+
for (var i = 0; i < records.length; i++) {
156+
var r = records[i];
157+
if (r.type !== 'childList') continue;
158+
var nodes = [].slice.call(r.addedNodes).concat([].slice.call(r.removedNodes));
159+
for (var j = 0; j < nodes.length; j++) {
160+
if (nodes[j].nodeType === 1) { restartQuietTimer(); return; }
161+
}
162+
}
163+
});
164+
observer.observe(app, { childList: true, subtree: true });
165+
restartQuietTimer();
166+
167+
setTimeout(remove, BACKSTOP_MS);
168+
} catch (e) {
169+
if (window.console && typeof console.warn === 'function') {
170+
console.warn('[dspace-ssr-overlay] disabled due to error:', e);
171+
}
172+
}
173+
})();
174+
</script>
14175
</body>
15176

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

0 commit comments

Comments
 (0)