Skip to content

Commit c2e1036

Browse files
jr-rkclaude
andcommitted
fix(ssr): drive admin-sidebar gutter via CSS var (no logged-in reload shift)
For an authenticated user a hard reload shifted the page right by the admin-sidebar width when the SSR freeze-frame was removed: the .outer-wrapper gutter came from the @slideSidebarPadding animation whose width is read from a browser-only CSS-variable store, so the server rendered padding-left:0 and the browser then resolved the real width. Drive the gutter from a CSS class (ds-admin-sidebar-{hidden,unpinned, pinned}) resolving padding-left from the --ds-admin-sidebar-* custom properties, identically on server and browser. Pin/unpin slide preserved via ds-admin-sidebar-animate, gated behind gutterTransitionEnabled (enabled after first paint). Applied to the base root component and the active datashare themed root. Mirrors mendelu DSpace#1355 (backport of DSpace#1333). Refs dataquest-dev/dspace-customers#717. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 7219381 commit c2e1036

4 files changed

Lines changed: 71 additions & 9 deletions

File tree

src/app/root/root.component.html

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,12 @@
22
{{ 'root.skip-to-content' | translate }}
33
</button>
44

5-
<div class="outer-wrapper" [class.d-none]="shouldShowFullscreenLoader" [ngClass]="browserOsClasses.asObservable() | async" [@slideSidebarPadding]="{
6-
value: ((isSidebarVisible$ | async) !== true ? 'hidden' : (slideSidebarOver$ | async) ? 'unpinned' : 'pinned'),
7-
params: { collapsedWidth: (collapsedSidebarWidth$ | async), expandedWidth: (expandedSidebarWidth$ | async) }
8-
}">
5+
<div class="outer-wrapper" [class.d-none]="shouldShowFullscreenLoader"
6+
[ngClass]="browserOsClasses.asObservable() | async"
7+
[class.ds-admin-sidebar-animate]="gutterTransitionEnabled"
8+
[class.ds-admin-sidebar-hidden]="(sidebarPaddingState$ | async) === 'hidden'"
9+
[class.ds-admin-sidebar-unpinned]="(sidebarPaddingState$ | async) === 'unpinned'"
10+
[class.ds-admin-sidebar-pinned]="(sidebarPaddingState$ | async) === 'pinned'">
911
<ds-admin-sidebar [expandedSidebarWidth$]="expandedSidebarWidth$" [collapsedSidebarWidth$]="collapsedSidebarWidth$"></ds-admin-sidebar>
1012
<div class="inner-wrapper">
1113
<ds-system-wide-alert-banner></ds-system-wide-alert-banner>

src/app/root/root.component.scss

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,3 +14,31 @@
1414
top: 0;
1515
}
1616
}
17+
18+
// Admin-sidebar left gutter. Driven by the `ds-admin-sidebar-*` class set in root.component.html (from
19+
// sidebarPaddingState$) rather than the @slideSidebarPadding Angular animation. The animation needed a
20+
// concrete width from the browser-only CSS-variable store, so on the server it rendered padding-left:0
21+
// and the authenticated page jumped right when the anti-flicker SSR snapshot was removed. Resolving the
22+
// gutter from the `--ds-admin-sidebar-*` custom properties in CSS instead renders identically on the
23+
// server (snapshot) and the browser (live app) — no hardcoded px, theme- and viewport-aware — and the
24+
// transition keeps the pin/unpin slide. 'hidden' (no admin sidebar) keeps the default padding-left: 0.
25+
.outer-wrapper {
26+
// padding-left:0 (no admin sidebar); explicit for self-documentation.
27+
&.ds-admin-sidebar-hidden {
28+
padding-left: 0;
29+
}
30+
31+
&.ds-admin-sidebar-unpinned {
32+
padding-left: var(--ds-admin-sidebar-fixed-element-width);
33+
}
34+
35+
&.ds-admin-sidebar-pinned {
36+
padding-left: var(--ds-admin-sidebar-total-width);
37+
}
38+
39+
// Slide only genuine pin/unpin toggles. The class is added after first paint (gutterTransitionEnabled)
40+
// so the initial SSR->CSR gutter resolution behind the anti-flicker overlay never animates.
41+
&.ds-admin-sidebar-animate {
42+
transition: padding-left 300ms ease-in-out;
43+
}
44+
}

src/app/root/root.component.ts

Lines changed: 37 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
NgIf,
55
} from '@angular/common';
66
import {
7+
AfterViewInit,
78
Component,
89
Inject,
910
Input,
@@ -39,7 +40,6 @@ import {
3940
} from '../core/services/window.service';
4041
import { ThemedFooterComponent } from '../footer/themed-footer.component';
4142
import { ThemedHeaderNavbarWrapperComponent } from '../header-nav-wrapper/themed-header-navbar-wrapper.component';
42-
import { slideSidebarPadding } from '../shared/animations/slide';
4343
import { HostWindowService } from '../shared/host-window.service';
4444
import { LiveRegionComponent } from '../shared/live-region/live-region.component';
4545
import { ThemedLoadingComponent } from '../shared/loading/themed-loading.component';
@@ -53,7 +53,6 @@ import { SystemWideAlertBannerComponent } from '../system-wide-alert/alert-banne
5353
selector: 'ds-base-root',
5454
templateUrl: './root.component.html',
5555
styleUrls: ['./root.component.scss'],
56-
animations: [slideSidebarPadding],
5756
standalone: true,
5857
imports: [
5958
TranslateModule,
@@ -71,12 +70,30 @@ import { SystemWideAlertBannerComponent } from '../system-wide-alert/alert-banne
7170
LiveRegionComponent,
7271
],
7372
})
74-
export class RootComponent implements OnInit {
73+
export class RootComponent implements OnInit, AfterViewInit {
7574
theme: Observable<ThemeConfig> = of({} as any);
7675
isSidebarVisible$: Observable<boolean>;
7776
slideSidebarOver$: Observable<boolean>;
7877
collapsedSidebarWidth$: Observable<string>;
7978
expandedSidebarWidth$: Observable<string>;
79+
80+
/**
81+
* The admin-sidebar padding state ('hidden' | 'unpinned' | 'pinned') used to drive the
82+
* outer-wrapper's left gutter via CSS classes (see root.component.scss) instead of an Angular
83+
* animation. CSS resolves the gutter width from the `--ds-admin-sidebar-*` custom properties, so it
84+
* is rendered identically on the server (the anti-flicker SSR snapshot) and the browser (the live
85+
* app) — no browser-only CSS-variable read, no hardcoded px, and it stays theme- and viewport-aware.
86+
*/
87+
sidebarPaddingState$: Observable<string>;
88+
89+
/**
90+
* Enables the gutter's `transition: padding-left` only AFTER the first browser paint. The initial
91+
* SSR->CSR gutter resolution happens behind the anti-flicker overlay; without this gate a plain CSS
92+
* transition would animate that initial 0->gutter change (the overlay settle detector only watches
93+
* DOM mutations, not style changes), which could leak a 300ms slide right as the overlay is removed.
94+
* Off on the server and on first render, so only genuine pin/unpin toggles animate.
95+
*/
96+
gutterTransitionEnabled = false;
8097
notificationOptions: INotificationBoardOptions;
8198
models: any;
8299

@@ -132,11 +149,28 @@ export class RootComponent implements OnInit {
132149
startWith(true),
133150
);
134151

152+
// Drive the outer-wrapper gutter via a CSS class instead of the @slideSidebarPadding animation: the
153+
// animation needs a concrete width from the browser-only CSS-variable store, so on the server it
154+
// rendered padding-left:0 and the authenticated page jumped right when the SSR snapshot was removed.
155+
// The CSS class resolves the gutter from `--ds-admin-sidebar-*` (see root.component.scss), identically
156+
// on server and browser — fixing the jump without any hardcoded width.
157+
this.sidebarPaddingState$ = combineLatestObservable([this.isSidebarVisible$, this.slideSidebarOver$]).pipe(
158+
map(([visible, over]) => !visible ? 'hidden' : over ? 'unpinned' : 'pinned'),
159+
);
160+
135161
if (this.router.url === getPageInternalServerErrorRoute()) {
136162
this.shouldShowRouteLoader = false;
137163
}
138164
}
139165

166+
ngAfterViewInit(): void {
167+
// Enable the gutter slide only after the first paint (browser only; requestAnimationFrame is not
168+
// defined under SSR), so the initial padding resolution never animates — see gutterTransitionEnabled.
169+
if (typeof requestAnimationFrame === 'function') {
170+
requestAnimationFrame(() => { this.gutterTransitionEnabled = true; });
171+
}
172+
}
173+
140174
skipToMainContent() {
141175
const mainContent = document.getElementById('main-content');
142176
if (mainContent) {

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

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@ import { ThemedBreadcrumbsComponent } from '../../../../app/breadcrumbs/themed-b
1212
import { ThemedFooterComponent } from '../../../../app/footer/themed-footer.component';
1313
import { ThemedHeaderNavbarWrapperComponent } from '../../../../app/header-nav-wrapper/themed-header-navbar-wrapper.component';
1414
import { RootComponent as BaseComponent } from '../../../../app/root/root.component';
15-
import { slideSidebarPadding } from '../../../../app/shared/animations/slide';
1615
import { LiveRegionComponent } from '../../../../app/shared/live-region/live-region.component';
1716
import { ThemedLoadingComponent } from '../../../../app/shared/loading/themed-loading.component';
1817
import { NotificationsBoardComponent } from '../../../../app/shared/notifications/notifications-board/notifications-board.component';
@@ -24,7 +23,6 @@ import { SystemWideAlertBannerComponent } from '../../../../app/system-wide-aler
2423
styleUrls: ['../../../../app/root/root.component.scss'],
2524
// templateUrl: './root.component.html',
2625
templateUrl: '../../../../app/root/root.component.html',
27-
animations: [slideSidebarPadding],
2826
standalone: true,
2927
imports: [
3028
TranslateModule,

0 commit comments

Comments
 (0)