Skip to content

JCU/fix(ssr): stop the blank flash on load and on reload - #1493

Merged
milanmajchrak merged 2 commits into
customer/jcufrom
JCU/fix-reload-flicker
Sep 9, 2026
Merged

JCU/fix(ssr): stop the blank flash on load and on reload#1493
milanmajchrak merged 2 commits into
customer/jcufrom
JCU/fix-reload-flicker

Conversation

@milanmajchrak

@milanmajchrak milanmajchrak commented Sep 7, 2026

Copy link
Copy Markdown
Collaborator

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.yml is untouched: the crawler protection added in #1424 stays exactly as it is.

Instructions for Reviewers

List of changes in this PR:

  • index.html paints a detached clone of the server-rendered view on top and removes it once Angular has detached the node it cloned, #main-content is 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.ts redirects a GET or HEAD for /(collections|communities)/<uuid>/search with no query string to the comcol page. Both render ComcolSearchSectionComponent and the page is server-rendered, so a reload paints content; the client puts /search back in the address bar as it does today. The redirect is skipped unless the tab is excluded from SSR, the page is not, and defaultBrowseTab is 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 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.

Why the overlay is self-contained and does not call into AppComponent the way customer/mendelu does: on a production build Angular constructs AppComponent roughly 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 152 ngh attributes, but hydration then claims only ds-app and ds-root before failing on ds-themed-root: ThemedComponent builds themed wrappers with ViewContainerRef.createComponent in ngAfterViewInit and removes the projected host, so the node hydration looks for is gone. The app renders nothing at all.

How to test:

  1. Open a collection, wait for the URL to become /collections/<uuid>/search, press F5. The page paints content instead of white, and the address bar ends up back on /search.
  2. Apply a facet filter and reload. That URL is still served as the CSR shell, now with a spinner rather than a blank page.
  3. Reload the home page with CPU throttling at 4x in DevTools. No white frame.
  4. view-source: on any page contains the overlay <style> and script; #__dspace_ssr_overlay exists during bootstrap and is gone once the page settles.
  5. The script disables itself under navigator.webdriver and 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, 6
communities, 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, no User-Agent
header at all, Googlebot/2.1):

request result
bare URL, GET or HEAD 302 to /collections/<uuid>, which returns 455 kB server-rendered
?f.author=x,equals 200, 7 kB CSR shell, unchanged

/search, /community-list and the other excluded routes are untouched. With
DSPACE_COLLECTION_DEFAULTBROWSETAB=browse_title the collection tab is no longer redirected while
the 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.

page, reload at 4x CPU throttle without the overlay with it
/home 93ms + 573ms at 5.1s 93ms
/collections/<uuid>/search 101ms + 676ms at 5.0s 120ms
/communities/<uuid>/search 85ms + 560ms at 4.3s 91ms
/items/<uuid> 132ms at 2.7s 0ms
/home, first visit 705ms at 5.5s 0ms

The ~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 and
content arrives no later than it did before. /login, /community-list, a 404 and a missing
collection were checked too: no blank frames, and the overlay always leaves well before its backstop.

Checklist

  • My PR is created against the main branch of code (unless it is a backport or is fixing an issue specific to an older branch). Customer branch fix, base is customer/jcu.
  • My PR is small in size (e.g. less than 1,000 lines of code, not including comments & specs/tests), or I have provided reasons as to why that's not possible.
  • My PR passes ESLint validation using npm run lint
  • My PR doesn't introduce circular dependencies (verified via npm run check-circ-deps)
  • My PR includes TypeDoc comments for all new (or modified) public methods and classes. It also includes TypeDoc for large or complex private methods.
  • My PR passes all specs/tests and includes new/updated specs or tests based on the Code Testing Guide. No spec covers server.ts; verified on a running stack in Docker instead, see the tables above.
  • My PR aligns with Accessibility guidelines if it makes changes to the user interface. The clone is aria-hidden and inert, and the spinner honours prefers-reduced-motion.
  • My PR uses i18n (internationalization) keys instead of hardcoded English text, to allow for translations. No user-visible text added.
  • My PR includes details on how to test it. I've provided clear instructions to reviewers on how to successfully test this fix or feature.
  • If my PR includes new libraries/dependencies (in package.json), I've made sure their licenses align with the DSpace BSD License based on the Licensing of Contributions documentation. None added.
  • If my PR includes new features or configurations, I've provided basic technical documentation in the PR itself. No new configuration.
  • If my PR fixes an issue ticket, I've linked them together.

@milanmajchrak
milanmajchrak force-pushed the JCU/fix-reload-flicker branch 2 times, most recently from e812864 to 7f1d021 Compare September 8, 2026 09:16
@milanmajchrak milanmajchrak changed the title JCU/Fix the blank reload on community and collection pages, and the white flash on every SSR page JCU/fix(ssr): stop the blank flash on load and on reload Sep 8, 2026
…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>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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.

Comment thread src/index.html
Comment thread src/index.html
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
milanmajchrak merged commit 2a8a531 into customer/jcu Sep 9, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants