From 13d73fa11a40ccc0d1cfbdd8b0df0532a8e77441 Mon Sep 17 00:00:00 2001 From: milanmajchrak Date: Thu, 25 Jun 2026 12:44:33 +0200 Subject: [PATCH] fix(ssr-overlay): render admin-sidebar gutter via CSS var so logged-in reload doesn't shift MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to #1321 (the anti-flicker SSR overlay, merged into `customer/vsb-tuo`). ## Problem With the overlay in place, an authenticated user's hard reload still showed the whole page jump right by the admin-sidebar width when the SSR snapshot was removed. (Anonymous users were already smooth — they have no sidebar.) ## Root cause (independent of the overlay; the overlay just makes it a visible "reveal") The `.outer-wrapper` left gutter came from the `@slideSidebarPadding` Angular animation, whose width is read via `cssService.getVariable('--ds-admin-sidebar-*')`. That store is populated only in the browser (`AppComponent.storeCSSVariables` -> `getComputedStyle`); on the server it stays empty, `skipWhile(!val)` blocks forever, and the animation renders `outer-wrapper { padding-left: 0 }`. The browser then resolves the real width (e.g. 55px), so the authenticated page (and the SSR snapshot, which is just that server HTML) jumps right on reveal. ## Fix Drive the gutter from a CSS class (`ds-admin-sidebar-{hidden,unpinned,pinned}`, set from a small `sidebarPaddingState$`) whose `padding-left` is `var(--ds-admin-sidebar-fixed-element-width)` / `--ds-admin-sidebar-total-width`. CSS resolves those custom properties identically on the server (the snapshot) and the browser (the live app), so they always match — no hardcoded pixel width, fully theme-overridable and viewport/media-query aware. The `@slideSidebarPadding` animation (which can't take a `var()` value and is stripped to empty on SSR) is no longer used here; the unused import/registration is dropped from the base and custom-theme root components. Non-admins are unaffected ('hidden' -> 0). The pin/unpin slide is preserved via `transition: padding-left`, but GATED behind a `ds-admin-sidebar-animate` class enabled only after the first browser paint (`gutterTransitionEnabled`). This stops the initial SSR->CSR gutter resolution — which happens behind the overlay — from animating and leaking a 300ms slide right as the overlay is removed (the overlay's settle detector watches DOM mutations, not style changes). Addresses review feedback (3 independent SSR/DSpace reviewers). ## Verification (authenticated admin, real UA, throttled hard reload, local SSR build) - SSR HTML renders `
` — a class, no inline px, and no `ds-admin-sidebar-animate` (transition correctly off on the server / initial paint). - snapshot vs settled `#main-content`: desktop 55/55 -> shift 0px; mobile (375px) 55/55 -> shift 0px; the live app gains `ds-admin-sidebar-animate` only after first paint. Refs: dspace-customers#725, #1321 Co-Authored-By: Claude Opus 4.8 --- src/app/root/root.component.html | 7 ++- src/app/root/root.component.scss | 28 ++++++++++++ src/app/root/root.component.ts | 45 +++++++++++++++++--- src/themes/custom/app/root/root.component.ts | 2 - 4 files changed, 71 insertions(+), 11 deletions(-) diff --git a/src/app/root/root.component.html b/src/app/root/root.component.html index 5f107a73497..033b60a8203 100644 --- a/src/app/root/root.component.html +++ b/src/app/root/root.component.html @@ -2,10 +2,9 @@ {{ 'root.skip-to-content' | translate }} -
+
diff --git a/src/app/root/root.component.scss b/src/app/root/root.component.scss index 9eb198417ad..71811df3cda 100644 --- a/src/app/root/root.component.scss +++ b/src/app/root/root.component.scss @@ -14,3 +14,31 @@ top: 0; } } + +// Admin-sidebar left gutter. Driven by the `ds-admin-sidebar-*` class set in root.component.html (from +// sidebarPaddingState$) rather than the @slideSidebarPadding Angular animation. The animation needed a +// concrete width from the browser-only CSS-variable store, so on the server it rendered padding-left:0 +// and the authenticated page jumped right when the anti-flicker SSR snapshot was removed. Resolving the +// gutter from the `--ds-admin-sidebar-*` custom properties in CSS instead renders identically on the +// server (snapshot) and the browser (live app) — no hardcoded px, theme- and viewport-aware — and the +// transition keeps the pin/unpin slide. 'hidden' (no admin sidebar) keeps the default padding-left: 0. +.outer-wrapper { + // padding-left:0 (no admin sidebar); explicit for self-documentation. + &.ds-admin-sidebar-hidden { + padding-left: 0; + } + + &.ds-admin-sidebar-unpinned { + padding-left: var(--ds-admin-sidebar-fixed-element-width); + } + + &.ds-admin-sidebar-pinned { + padding-left: var(--ds-admin-sidebar-total-width); + } + + // Slide only genuine pin/unpin toggles. The class is added after first paint (gutterTransitionEnabled) + // so the initial SSR->CSR gutter resolution behind the anti-flicker overlay never animates. + &.ds-admin-sidebar-animate { + transition: padding-left 300ms ease-in-out; + } +} diff --git a/src/app/root/root.component.ts b/src/app/root/root.component.ts index 4c4370fc62c..f1e659b093c 100644 --- a/src/app/root/root.component.ts +++ b/src/app/root/root.component.ts @@ -1,5 +1,5 @@ import { first, map, skipWhile, startWith } from 'rxjs/operators'; -import { Component, Input, OnInit } from '@angular/core'; +import { AfterViewInit, Component, Input, OnInit } from '@angular/core'; import { Router } from '@angular/router'; import { combineLatest as combineLatestObservable, Observable, of } from 'rxjs'; @@ -8,7 +8,6 @@ import { MenuService } from '../shared/menu/menu.service'; import { HostWindowService } from '../shared/host-window.service'; import { ThemeConfig } from '../../config/theme.config'; import { environment } from '../../environments/environment'; -import { slideSidebarPadding } from '../shared/animations/slide'; import { MenuID } from '../shared/menu/menu-id.model'; import { getPageInternalServerErrorRoute } from '../app-routing-paths'; import { INotificationBoardOptions } from 'src/config/notifications-config.interfaces'; @@ -17,14 +16,31 @@ import { INotificationBoardOptions } from 'src/config/notifications-config.inter selector: 'ds-root', templateUrl: './root.component.html', styleUrls: ['./root.component.scss'], - animations: [slideSidebarPadding], }) -export class RootComponent implements OnInit { +export class RootComponent implements OnInit, AfterViewInit { theme: Observable = of({} as any); isSidebarVisible$: Observable; slideSidebarOver$: Observable; collapsedSidebarWidth$: Observable; expandedSidebarWidth$: Observable; + + /** + * The admin-sidebar padding state ('hidden' | 'unpinned' | 'pinned') used to drive the + * outer-wrapper's left gutter via CSS classes (see root.component.scss) instead of an Angular + * animation. CSS resolves the gutter width from the `--ds-admin-sidebar-*` custom properties, so it + * is rendered identically on the server (the anti-flicker SSR snapshot) and the browser (the live + * app) — no browser-only CSS-variable read, no hardcoded px, and it stays theme- and viewport-aware. + */ + sidebarPaddingState$: Observable; + + /** + * Enables the gutter's `transition: padding-left` only AFTER the first browser paint. The initial + * SSR->CSR gutter resolution happens behind the anti-flicker overlay; without this gate a plain CSS + * transition would animate that initial 0->gutter change (the overlay settle detector only watches + * DOM mutations, not style changes), which could leak a 300ms slide right as the overlay is removed. + * Off on the server and on first render, so only genuine pin/unpin toggles animate. + */ + gutterTransitionEnabled = false; notificationOptions: INotificationBoardOptions; models: any; @@ -42,7 +58,7 @@ export class RootComponent implements OnInit { private router: Router, private cssService: CSSVariableService, private menuService: MenuService, - private windowService: HostWindowService + private windowService: HostWindowService, ) { this.notificationOptions = environment.notifications; } @@ -50,6 +66,8 @@ export class RootComponent implements OnInit { ngOnInit() { this.isSidebarVisible$ = this.menuService.isMenuVisibleWithVisibleSections(MenuID.ADMIN); + // Still provided to ; the sidebar element itself sizes from CSS vars, so a + // null value on the server (the store is browser-only) is harmless there. this.expandedSidebarWidth$ = this.cssService.getVariable('--ds-admin-sidebar-total-width').pipe( skipWhile((val) => !val), first(), @@ -66,11 +84,28 @@ export class RootComponent implements OnInit { startWith(true), ); + // Drive the outer-wrapper gutter via a CSS class instead of the @slideSidebarPadding animation: the + // animation needs a concrete width from the browser-only CSS-variable store, so on the server it + // rendered padding-left:0 and the authenticated page jumped right when the SSR snapshot was removed. + // The CSS class resolves the gutter from `--ds-admin-sidebar-*` (see root.component.scss), identically + // on server and browser — fixing the jump without any hardcoded width. + this.sidebarPaddingState$ = combineLatestObservable([this.isSidebarVisible$, this.slideSidebarOver$]).pipe( + map(([visible, over]) => !visible ? 'hidden' : over ? 'unpinned' : 'pinned'), + ); + if (this.router.url === getPageInternalServerErrorRoute()) { this.shouldShowRouteLoader = false; } } + ngAfterViewInit(): void { + // Enable the gutter slide only after the first paint (browser only; requestAnimationFrame is not + // defined under SSR), so the initial padding resolution never animates — see gutterTransitionEnabled. + if (typeof requestAnimationFrame === 'function') { + requestAnimationFrame(() => { this.gutterTransitionEnabled = true; }); + } + } + skipToMainContent() { const mainContent = document.getElementById('main-content'); if (mainContent) { diff --git a/src/themes/custom/app/root/root.component.ts b/src/themes/custom/app/root/root.component.ts index 6b5b0c106fa..3f9aac59932 100644 --- a/src/themes/custom/app/root/root.component.ts +++ b/src/themes/custom/app/root/root.component.ts @@ -1,5 +1,4 @@ import { Component } from '@angular/core'; -import { slideSidebarPadding } from '../../../../app/shared/animations/slide'; import { RootComponent as BaseComponent } from '../../../../app/root/root.component'; @Component({ @@ -8,7 +7,6 @@ import { RootComponent as BaseComponent } from '../../../../app/root/root.compon styleUrls: ['../../../../app/root/root.component.scss'], // templateUrl: './root.component.html', templateUrl: '../../../../app/root/root.component.html', - animations: [slideSidebarPadding], }) export class RootComponent extends BaseComponent {