JCU/fix(ssr): stop the blank flash on load and on reload - #1493
Merged
Conversation
milanmajchrak
force-pushed
the
JCU/fix-reload-flicker
branch
2 times, most recently
from
September 8, 2026 09:16
e812864 to
7f1d021
Compare
…ongs 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>
milanmajchrak
force-pushed
the
JCU/fix-reload-flicker
branch
from
September 8, 2026 12:53
7f1d021 to
830911a
Compare
There was a problem hiding this comment.
🟡 Changes recommended
The overlay mishandles scrolling and can remain permanently visible when setup fails.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
Prevents blank flashes during Angular bootstrap and restores SSR rendering for bare community/collection search tabs.
Changes:
- Adds an SSR-content overlay with a CSR fallback spinner.
- Redirects eligible bare search-tab requests to SSR-rendered parent pages.
- Corrects SSR crawler-protection documentation.
File summaries
| File | Description |
|---|---|
src/index.html |
Adds bootstrap overlay and spinner logic. |
server.ts |
Adds conditional comcol search redirects. |
src/robots.txt.ejs |
Updates SSR exclusion documentation. |
Review details
- Files reviewed: 3/3 changed files
- Comments generated: 2
- Review effort level: Balanced
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
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>
milanmajchrak
force-pushed
the
JCU/fix-reload-flicker
branch
from
September 9, 2026 05:54
830911a to
924996f
Compare
12 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
References
Description
Two separate blanks. Every server-rendered page clears itself and renders again a moment after it paints, and reloading a community or collection returned an empty CSR shell, because the client had moved the address bar onto a path that is excluded from SSR.
config.ymlis untouched: the crawler protection added in #1424 stays exactly as it is.Instructions for Reviewers
List of changes in this PR:
index.htmlpaints a detached clone of the server-rendered view on top and removes it once Angular has detached the node it cloned,#main-contentis visible and the DOM has been quiet for 600ms, with a 15s backstop. The clone is built after the first paint, so it does not delay it, and it is positioned absolutely so it scrolls with the document and holds the page height while<ds-app>is empty. On a route excluded from SSR there is nothing to clone, so a spinner fills the gap instead of a white page; it is not held to the quiet rule, since there is no paint to protect, and goes as soon as there is content behind it.server.tsredirects aGETorHEADfor/(collections|communities)/<uuid>/searchwith no query string to the comcol page. Both renderComcolSearchSectionComponentand the page is server-rendered, so a reload paints content; the client puts/searchback in the address bar as it does today. The redirect is skipped unless the tab is excluded from SSR, the page is not, anddefaultBrowseTabis the search tab, so it turns itself off wherever the two URLs are not the same view.robots.txt.ejs: the comment saying the SSR-side rule is not configured has been wrong since JCU/perf(ssr): exclude crawler-heavy routes from SSR and size the bot page cache #1424.Why the query string is the dividing line. Opening a community rewrites the address bar to
/communities/<uuid>/search, so the landing page was server-rendered but F5 on it was not. What is expensive is the Discovery facet parameter space, effectively unbounded, and that is the mendelu-504 vector from #1402. Those URLs carry a query, so they are not redirected and stay excluded. The bare tab URL is one render per comcol and the bot cache absorbs repeats.Doing this in
ComcolPageBrowseByComponentinstead, by not redirecting to the default tab, looks tidier and does not work:getSearchLink()returnscurrentPath(router)for an in-place search, so every facet link would move to/collections/<uuid>?f.…, which no pattern matches, and the trap reopens.Why the overlay is self-contained and does not call into
AppComponentthe way customer/mendelu does: on a production build Angular constructsAppComponentroughly 5s after the inline script runs, so that arrangement only ever hit its fallback timer and left the clone up for 15s.Enabling hydration on the server instead is not an option here. With
provideClientHydration()added to the server config the SSR output does gain 152nghattributes, but hydration then claims onlyds-appandds-rootbefore failing onds-themed-root:ThemedComponentbuilds themed wrappers withViewContainerRef.createComponentinngAfterViewInitand removes the projected host, so the node hydration looks for is gone. The app renders nothing at all.How to test:
/collections/<uuid>/search, press F5. The page paints content instead of white, and the address bar ends up back on/search.view-source:on any page contains the overlay<style>and script;#__dspace_ssr_overlayexists during bootstrap and is gone once the page settles.navigator.webdriverand Cypress, so Playwright and Cypress runs see the old behaviour by design.Verified on a DSpace 9.3 stack in Docker (
docker/docker-compose-rest.yml+db.entities.yml, 6communities, 20 collections, 494 items) with this branch's production build running under PM2, the
same way the deployed instances run it.
Requests to
/collections/<uuid>/search, identical under three user agents (Chrome, noUser-Agentheader at all,
Googlebot/2.1):302to/collections/<uuid>, which returns 455 kB server-rendered?f.author=x,equals200, 7 kB CSR shell, unchanged/search,/community-listand the other excluded routes are untouched. WithDSPACE_COLLECTION_DEFAULTBROWSETAB=browse_titlethe collection tab is no longer redirected whilethe community one, still defaulting to search, is.
White frames, measured from a CDP screencast at 720x450 and counted in pixels: a frame counts as
blank at 30% of the settled ink, and a flash is blank pixels after the document has already painted.
/home/collections/<uuid>/search/communities/<uuid>/search/items/<uuid>/home, first visitThe ~90-120ms that remains is the browser's own gap between releasing the old page and painting the
new one; it is there with and without this change. The second stretch is the rebuild, and that is
what goes. Three repeats of the first two rows agree within 20ms. Unthrottled the flash is 101-196ms
without the overlay and 0ms with it on every one of those pages.
On
/search?query=test, which stays excluded from SSR, the spinner is up for 0.9s unthrottled andcontent arrives no later than it did before.
/login,/community-list, a 404 and a missingcollection were checked too: no blank frames, and the overlay always leaves well before its backstop.
Checklist
mainbranch of code (unless it is a backport or is fixing an issue specific to an older branch). Customer branch fix, base iscustomer/jcu.npm run lintnpm run check-circ-deps)server.ts; verified on a running stack in Docker instead, see the tables above.aria-hiddenandinert, and the spinner honoursprefers-reduced-motion.package.json), I've made sure their licenses align with the DSpace BSD License based on the Licensing of Contributions documentation. None added.