From 151213e971440a81b4612277c74a1544c3131d1c Mon Sep 17 00:00:00 2001 From: Juraj Roka <95219754+jr-rk@users.noreply.github.com> Date: Fri, 3 Jul 2026 14:35:04 +0200 Subject: [PATCH] Backport #1333: admin-sidebar gutter via CSS var (no logged-in reload shift) Backport of dataquest-dev/dspace-angular#1333 (originally landed on customer/vsb-tuo). Problem: for an authenticated user, a hard reload shifted the whole page right by the admin-sidebar width when the SSR snapshot was removed. The .outer-wrapper left gutter was produced by the @slideSidebarPadding animation, whose width is read from a browser-only CSS-variable store; on the server that store is empty so it renders padding-left:0, then the browser resolves the real width and the page jumps. Fix: drive the gutter from a CSS class (ds-admin-sidebar-{hidden,unpinned,pinned}, set from a small sidebarPaddingState$) whose padding-left resolves from the admin-sidebar width custom properties in CSS. CSS resolves those identically on the server (the SSR snapshot) and the browser (the live app), so there is no shift and no hardcoded width. The pin/unpin slide is preserved via transition:padding-left, gated behind ds-admin-sidebar-animate (enabled only after the first browser paint) so the initial SSR->CSR gutter resolution does not animate. Translated to this branch's root.component (identical to the vsb-tuo pre-#1333 generation; applied verbatim), not a cherry-pick. Refs: dataquest-dev/dspace-customers#717, dataquest-dev/dspace-angular#1333 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 {