Skip to content

Commit c474d52

Browse files
jr-rkclaude
andauthored
TUL/Backport #1333: admin-sidebar gutter via CSS var (no logged-in reload shift) (#1354)
Backport of #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 (older DSpace 7.5 generation, empty root scss recreated, gutter var --ds-collapsed/total-sidebar-width), not a cherry-pick. Refs: dataquest-dev/dspace-customers#717, #1333 Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 5e1de44 commit c474d52

4 files changed

Lines changed: 67 additions & 10 deletions

File tree

src/app/root/root.component.html

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
1-
<div class="outer-wrapper" [class.d-none]="shouldShowFullscreenLoader" [@slideSidebarPadding]="{
2-
value: (!(sidebarVisible | async) ? 'hidden' : (slideSidebarOver | async) ? 'shown' : 'expanded'),
3-
params: {collapsedSidebarWidth: (collapsedSidebarWidth | async), totalSidebarWidth: (totalSidebarWidth | async)}
4-
}">
1+
<div class="outer-wrapper" [class.d-none]="shouldShowFullscreenLoader"
2+
[class.ds-admin-sidebar-animate]="gutterTransitionEnabled"
3+
[ngClass]="'ds-admin-sidebar-' + (sidebarPaddingState$ | async)">
54
<ds-themed-admin-sidebar></ds-themed-admin-sidebar>
65
<div class="inner-wrapper">
76
<ds-system-wide-alert-banner></ds-system-wide-alert-banner>

src/app/root/root.component.scss

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
// Admin-sidebar left gutter. Driven by the `ds-admin-sidebar-*` class set in root.component.html (from
2+
// sidebarPaddingState$) rather than the @slideSidebarPadding Angular animation. The animation needed a
3+
// concrete width from the browser-only CSS-variable store, so on the server it rendered padding-left:0
4+
// and the authenticated page jumped right when the anti-flicker SSR snapshot was removed. Resolving the
5+
// gutter from the sidebar-width custom properties in CSS instead renders identically on the server
6+
// (snapshot) and the browser (live app) — no hardcoded px, theme- and viewport-aware — and the
7+
// transition keeps the pin/unpin slide. 'hidden' (no admin sidebar) keeps the default padding-left: 0.
8+
.outer-wrapper {
9+
// padding-left:0 (no admin sidebar); explicit for self-documentation.
10+
&.ds-admin-sidebar-hidden {
11+
padding-left: 0;
12+
}
13+
14+
&.ds-admin-sidebar-unpinned {
15+
padding-left: var(--ds-collapsed-sidebar-width);
16+
}
17+
18+
&.ds-admin-sidebar-pinned {
19+
padding-left: var(--ds-total-sidebar-width);
20+
}
21+
22+
// Slide only genuine pin/unpin toggles. The class is added after first paint (gutterTransitionEnabled)
23+
// so the initial SSR->CSR gutter resolution behind the anti-flicker overlay never animates.
24+
&.ds-admin-sidebar-animate {
25+
transition: padding-left 300ms ease-in-out;
26+
}
27+
}

src/app/root/root.component.ts

Lines changed: 37 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { map, startWith } from 'rxjs/operators';
2-
import { Component, Inject, Input, OnInit } from '@angular/core';
2+
import { AfterViewInit, Component, Inject, Input, OnInit } from '@angular/core';
33
import { Router } from '@angular/router';
44

55
import { combineLatest as combineLatestObservable, Observable, of } from 'rxjs';
@@ -16,21 +16,37 @@ import { HostWindowService } from '../shared/host-window.service';
1616
import { ThemeConfig } from '../../config/theme.model';
1717
import { Angulartics2DSpace } from '../statistics/angulartics/dspace-provider';
1818
import { environment } from '../../environments/environment';
19-
import { slideSidebarPadding } from '../shared/animations/slide';
2019
import { MenuID } from '../shared/menu/menu-id.model';
2120
import { getPageInternalServerErrorRoute } from '../app-routing-paths';
2221

2322
@Component({
2423
selector: 'ds-root',
2524
templateUrl: './root.component.html',
2625
styleUrls: ['./root.component.scss'],
27-
animations: [slideSidebarPadding],
2826
})
29-
export class RootComponent implements OnInit {
27+
export class RootComponent implements OnInit, AfterViewInit {
3028
sidebarVisible: Observable<boolean>;
3129
slideSidebarOver: Observable<boolean>;
3230
collapsedSidebarWidth: Observable<string>;
3331
totalSidebarWidth: Observable<string>;
32+
33+
/**
34+
* The admin-sidebar padding state ('hidden' | 'unpinned' | 'pinned') used to drive the
35+
* outer-wrapper's left gutter via CSS classes (see root.component.scss) instead of an Angular
36+
* animation. CSS resolves the gutter width from the sidebar-width custom properties, so it is
37+
* rendered identically on the server (the anti-flicker SSR snapshot) and the browser (the live
38+
* app) — no browser-only CSS-variable read, no hardcoded px, and it stays theme- and viewport-aware.
39+
*/
40+
sidebarPaddingState$: Observable<string>;
41+
42+
/**
43+
* Enables the gutter's `transition: padding-left` only AFTER the first browser paint. The initial
44+
* SSR->CSR gutter resolution happens behind the anti-flicker overlay; without this gate a plain CSS
45+
* transition would animate that initial 0->gutter change (the overlay settle detector only watches
46+
* DOM mutations, not style changes), which could leak a 300ms slide right as the overlay is removed.
47+
* Off on the server and on first render, so only genuine pin/unpin toggles animate.
48+
*/
49+
gutterTransitionEnabled = false;
3450
theme: Observable<ThemeConfig> = of({} as any);
3551
notificationOptions;
3652
models;
@@ -73,8 +89,25 @@ export class RootComponent implements OnInit {
7389
startWith(true),
7490
);
7591

92+
// Drive the outer-wrapper gutter via a CSS class instead of the @slideSidebarPadding animation: the
93+
// animation needs a concrete width from the browser-only CSS-variable store, so on the server it
94+
// rendered padding-left:0 and the authenticated page jumped right when the SSR snapshot was removed.
95+
// The CSS class resolves the gutter from the sidebar-width custom properties (see root.component.scss),
96+
// identically on server and browser — fixing the jump without any hardcoded width.
97+
this.sidebarPaddingState$ = combineLatestObservable([this.sidebarVisible, this.slideSidebarOver]).pipe(
98+
map(([visible, over]) => !visible ? 'hidden' : over ? 'unpinned' : 'pinned'),
99+
);
100+
76101
if (this.router.url === getPageInternalServerErrorRoute()) {
77102
this.shouldShowRouteLoader = false;
78103
}
79104
}
105+
106+
ngAfterViewInit(): void {
107+
// Enable the gutter slide only after the first paint (browser only; requestAnimationFrame is not
108+
// defined under SSR), so the initial padding resolution never animates — see gutterTransitionEnabled.
109+
if (typeof requestAnimationFrame === 'function') {
110+
requestAnimationFrame(() => { this.gutterTransitionEnabled = true; });
111+
}
112+
}
80113
}

src/themes/custom/app/root/root.component.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import { Component } from '@angular/core';
2-
import { slideSidebarPadding } from '../../../../app/shared/animations/slide';
32
import { RootComponent as BaseComponent } from '../../../../app/root/root.component';
43

54
@Component({
@@ -8,7 +7,6 @@ import { RootComponent as BaseComponent } from '../../../../app/root/root.compon
87
styleUrls: ['../../../../app/root/root.component.scss'],
98
// templateUrl: './root.component.html',
109
templateUrl: '../../../../app/root/root.component.html',
11-
animations: [slideSidebarPadding],
1210
})
1311
export class RootComponent extends BaseComponent {
1412

0 commit comments

Comments
 (0)