fix(frontend): preserve app-shell scroll on inline drawer focus

This commit is contained in:
rcourtman
2026-04-02 22:19:46 +01:00
parent 6a38b9ed36
commit ed54b70ef8
19 changed files with 419 additions and 123 deletions
+1 -15
View File
@@ -3712,21 +3712,7 @@
]
}
],
"work_claims": [
{
"id": "codex-lane-l8",
"agent_id": "codex",
"summary": "Fix canonical row-focus close behavior so collapsing an inline drawer never leaves behind accidental filter state.",
"target_id": "v6-ga-promotion",
"claimed_at": "2026-04-01T22:34:28Z",
"heartbeat_at": "2026-04-01T22:51:28Z",
"expires_at": "2026-04-02T00:51:28Z",
"work_item": {
"kind": "lane",
"id": "L8"
}
}
],
"work_claims": [],
"open_decisions": [],
"source_of_truth_file": "docs/release-control/v6/internal/SOURCE_OF_TRUTH.md",
"resolved_decisions": [
@@ -142,6 +142,12 @@ auth bootstrap, theme sync, and post-reconnect hosted refresh work through
Solid `onMount(...)` inside the runtime owner instead of letting `App.tsx`,
`AppLayout.tsx`, or module-evaluation side effects reach directly into browser
APIs before the hosted shell has mounted.
That same hosted browser shell boundary also owns same-path query-state
transitions that refresh hosted/org context without changing the active page.
`frontend-modern/src/App.tsx` must preserve the mounted `.app-scroll-shell`
position across those remount-like transitions so inline row focus, hosted
drawer state, and org-context route updates do not present as a full-page
refresh.
Persisted billing state is now also part of that canonical boundary: when a
recognized Cloud/MSP plan version is loaded or saved, the stored `plan_version`
must canonicalize and `limits.max_monitored_systems` must reconcile to the authoritative
@@ -9,7 +9,7 @@
"contract_file": "docs/release-control/v6/internal/subsystems/frontend-primitives.md",
"status_file": "docs/release-control/v6/internal/status.json",
"registry_file": "docs/release-control/v6/internal/subsystems/registry.json",
"dependency_subsystem_ids": ["agent-lifecycle", "storage-recovery"]
"dependency_subsystem_ids": ["agent-lifecycle", "cloud-paid", "storage-recovery"]
}
```
@@ -153,7 +153,7 @@ work extends shared components instead of creating new local variants.
8. Keep summary chart interaction identity on one shared helper. Summary surfaces that expose row-hover, group-hover, chart-hover, or route-focus-driven chart emphasis must derive page/group/entity scope through `frontend-modern/src/components/shared/summaryCardInteraction.ts` and pass that same resolved scope into card-state, sparkline, and density-map primitives, rather than letting cards read `hovered || focused` while charts listen to a different page-local ID source. Hovering one summary chart must promote that series into the shared active entity so sibling cards highlight the same object instead of keeping chart-local hover islands, and hovering or pinning a workload group header, infrastructure cluster header, or storage pool-group header must scope the matching summary cards through that same shared contract instead of forking a page-local summary filter path. Sibling cards should surface that synchronized hover as one compact header readout through the shared summary-card contract, while the chart under the pointer keeps the only floating tooltip. `frontend-modern/src/components/Recovery/RecoverySummary.tsx` is explicitly outside this interaction dialect: recovery posture cards may share summary framing, but they must not silently grow row/group/chart hover behavior without a separate governed product decision.
9. Keep page summaries page-scoped when table rows enter contextual focus. Route-backed row selection may add a focused label and shared series emphasis, but infrastructure, workloads, and storage summary cards must continue to render the page-level series set instead of collapsing the summary down to the selected row or replacing the global trend view with row-local empty states.
10. Keep contextual row focus on the shared summary primitive. Summary surfaces and same-route table drill-ins must reuse `frontend-modern/src/components/shared/contextualFocus.ts` for interactive-series filtering, focused-name lookup, active-series derivation, local scroll preservation, and deliberate inline-detail reveal instead of rebuilding page-local `Set` filters, focused-label scans, drawer-aware scroll math, or ad hoc scroll restoration in each surface.
11. Keep summary-to-table coordination deliberate, explicit, and reversible. Shared summary hover may highlight the matching table row when it is already visible, but transient chart hover must not auto-filter tables, auto-scroll the page, or reshuffle table ordering. When a page/group/entity scope is active on workloads, infrastructure, or storage, the page shell must surface that state through the shared `frontend-modern/src/components/shared/SummaryScopeBar.tsx` plus `frontend-modern/src/components/shared/summaryScopePresentation.ts` contract instead of inventing page-local chips, breadcrumbs, or hidden route-only focus. That shared scope presenter must read like native page context, not a pill/badge widget: it should distinguish transient preview from pinned focus with quiet inline language, keep the current scope visible even when the sticky summary is collapsed or off-screen, and expose a clear reset path for pinned scope so touch-only operators do not depend on hover or “click the same row again” discovery. When the active row is off-screen, page owners must still route through `frontend-modern/src/components/shared/summaryTableFocus.ts` and surface a lightweight `Jump to row` affordance that reveals and scrolls only on explicit user action. Deliberate row focus may reveal inline detail automatically, but that reveal must be drawer-aware: preserve already-good positions, avoid hard centering, and scroll only enough to keep the row header plus the top of the inline detail visible.
11. Keep summary-to-table coordination deliberate, explicit, and reversible. Shared summary hover may highlight the matching table row when it is already visible, but transient chart hover must not auto-filter tables, auto-scroll the page, or reshuffle table ordering. When a page/group/entity scope is active on workloads, infrastructure, or storage, the page shell must surface that state through the shared `frontend-modern/src/components/shared/SummaryScopeBar.tsx` plus `frontend-modern/src/components/shared/summaryScopePresentation.ts` contract instead of inventing page-local chips, breadcrumbs, or hidden route-only focus. That shared scope presenter must read like native page context, not a pill/badge widget: it should distinguish transient preview from pinned focus with quiet inline language, keep the current scope visible even when the sticky summary is collapsed or off-screen, and expose a clear reset path for pinned scope so touch-only operators do not depend on hover or “click the same row again” discovery. When the active row is off-screen, page owners must still route through `frontend-modern/src/components/shared/summaryTableFocus.ts` and surface a lightweight `Jump to row` affordance that reveals and scrolls only on explicit user action. Deliberate row focus may reveal inline detail automatically, but that reveal must be drawer-aware: infrastructure and workload row toggles that already have the row in view must hand the current `.app-scroll-shell` position through `frontend-modern/src/utils/appShellScrollRestoration.ts` so the remounted shell in `frontend-modern/src/App.tsx` can reopen the inline detail in place instead of looking like a page refresh, while storage and other non-local reveal paths may still scroll only enough to keep the row header plus the top of the inline detail visible.
Shared summary-linked rows and group headers must also route their preview
semantics through
`frontend-modern/src/components/shared/summaryInteractionA11y.ts`.
@@ -251,8 +251,10 @@ primitive render the emphasis, instead of layering lane-local row-fill classes
that diverge across pages or wash out inline metric bars.
`frontend-modern/src/components/Dashboard/useDashboardSelectionState.ts` must
write workload selection back into the workloads route through the shared
same-path route-state scheduler so opening a focused workload preserves scroll
and does not look like a full page reload, and the governed infrastructure and
same-path route-state scheduler, but the actual shell-position handoff for
query-only row focus must go through `frontend-modern/src/utils/appShellScrollRestoration.ts`
plus the root `frontend-modern/src/App.tsx` shell so opening a focused
workload does not look like a full page reload, and the governed infrastructure and
workloads summary surfaces must keep the summary page-scoped while that focus
reuses the shared highlight contract; density maps may retain page-level
context, but they must now also surface focused-entity detail inside the same
@@ -269,9 +271,13 @@ infrastructure, or workloads hot paths.
That same hot-path ownership now also covers deliberate inline-detail reveal.
When a focused workload or infrastructure row opens its inline detail, the hot
path may preserve scroll across same-route state writes, but the actual reveal
must still flow through the shared contextual-focus and summary-table helpers,
mark the movement as deliberate so route-state restore does not replay over it,
and scroll only enough to keep the row header plus the top of the detail
must still flow through the shared contextual-focus and summary-table helpers.
Direct row toggles that already have the row in view must capture the current
app-shell scroll position before the focus write and let the remounted root
shell restore that position, so the interaction stays anchored instead of
looking like a full refresh; non-local reveal paths may still mark the
movement as deliberate so route-state restore does not replay over it, and
then scroll only enough to keep the row header plus the top of the detail
visible instead of hard-centering every expansion.
That same hot-path ownership now includes summary cache invalidation.
Infrastructure and workload summary caches may hydrate charts for fast remounts,
@@ -333,9 +333,12 @@ expanding the owning group only for that deliberate reveal path.
That same reveal contract now also owns inline-detail expansion. When a pool or
disk row is deliberately focused and its inline detail opens on the storage
page, the detail row must publish the same canonical summary series ID through
`data-inline-detail-for` and let the shared contextual-focus helper reveal only
enough of that drawer to show the row header plus the top of the detail,
instead of reverting to storage-local centering or a second row/detail ID map.
`data-inline-detail-for`, and the shared contextual-focus helper may still
reveal only enough of that drawer to show the row header plus the top of the
detail instead of reverting to storage-local centering or a second row/detail
ID map. Storage should not fork that behavior into a fully in-place
infrastructure-style shell handoff unless a separately governed product model
changes the storage interaction contract.
The recovery backend is a real product boundary, not just a helper package:
`internal/recovery/` owns per-tenant SQLite persistence, rollup derivation,
@@ -180,8 +180,11 @@ assembly branch.
provider-local hover aliases when the selected series is highlighted.
10. Keep infrastructure contextual focus route-backed and page-scoped. When an
infrastructure row opens its detail drawer, the selection must stay on the
same route through canonical resource query state, preserve scroll via the
shared route-state scheduler, and keep `frontend-modern/src/components/Infrastructure/InfrastructureSummary.tsx`
same route through canonical resource query state, and the shared
summary-table focus/runtime contract plus the root app-shell restore path
must let direct row toggles open that drawer in place instead of looking
like a page refresh or remount. The
summary must keep `frontend-modern/src/components/Infrastructure/InfrastructureSummary.tsx`
rendering the full page-level series set while only the focused label and
highlight state change.
11. Keep infrastructure summary hover scope on canonical unified-resource ids
@@ -309,11 +312,15 @@ highlighting the same canonical resource IDs used by the unified-resource
table and route state.
That same infrastructure focus boundary now also owns deliberate drawer reveal.
When an infrastructure row opens inline detail on the same route, the page must
tag that detail with the canonical active resource ID and let the shared
tag that detail with the canonical active resource ID. Direct row toggles that
already have the row in view must capture the current `.app-scroll-shell`
position through `frontend-modern/src/utils/appShellScrollRestoration.ts`, so
the remounted root shell in `frontend-modern/src/App.tsx` can stay anchored,
while non-local reveal paths may still let the shared
summary-table/contextual-focus helpers reveal only enough of the drawer to show
the row header plus the start of the detail. `useUnifiedResourceTableViewportSync.ts`
must stay viewport-only; it may not grow a second selected-row reveal path or a
resource-local centering rule.
the row header plus the start of the detail.
`useUnifiedResourceTableViewportSync.ts` must stay viewport-only; it may not
grow a second selected-row reveal path or a resource-local centering rule.
That same unified-resource boundary now also owns stored metrics-target
continuity for provider-backed resources. When registry rebuild cannot derive a
fresh metrics target from raw source facets, `internal/unifiedresources/registry.go`
+80
View File
@@ -42,6 +42,10 @@ import {
} from './routing/resourceLinks';
import { AppLayout } from '@/AppLayout';
import { useAppRuntimeState } from '@/useAppRuntimeState';
import {
clearPendingAppShellRestoreTop,
readPendingAppShellRestoreTop,
} from '@/utils/appShellScrollRestoration';
function isPublicRoutePath(pathname: string): boolean {
// Public routes must be viewable without authentication.
@@ -223,9 +227,84 @@ function App() {
const RootLayout = (props: { children?: JSX.Element }) => {
const [shortcutsOpen, setShortcutsOpen] = createSignal(false);
const [commandPaletteOpen, setCommandPaletteOpen] = createSignal(false);
const [appScrollShellRef, setAppScrollShellRef] = createSignal<HTMLDivElement | undefined>(
undefined,
);
const [pendingAppShellRestoreTop, setPendingAppShellRestoreTop] = createSignal<number | null>(
null,
);
const location = useLocation();
const isPublicRoute = createMemo(() => isPublicRoutePath(location.pathname));
createEffect(() => {
location.pathname;
location.search;
const pendingRestoreTop = readPendingAppShellRestoreTop();
if (pendingRestoreTop !== null) {
setPendingAppShellRestoreTop(pendingRestoreTop);
}
});
createEffect(() => {
const shell = appScrollShellRef();
const restoreTop = pendingAppShellRestoreTop();
if (!shell || restoreTop === null) {
return;
}
if (Math.abs(shell.scrollTop - restoreTop) <= 2) {
clearPendingAppShellRestoreTop();
setPendingAppShellRestoreTop(null);
return;
}
let settled = false;
let rafId: number | undefined;
let timeoutId: number | undefined;
const finish = () => {
if (settled) return;
settled = true;
if (rafId !== undefined) {
window.cancelAnimationFrame(rafId);
}
if (timeoutId !== undefined) {
window.clearTimeout(timeoutId);
}
};
const attemptRestore = (remainingFrames: number) => {
if (settled) return;
const maxScrollTop = Math.max(0, shell.scrollHeight - shell.clientHeight);
if (restoreTop <= maxScrollTop) {
shell.scrollTop = restoreTop;
if (Math.abs(shell.scrollTop - restoreTop) <= 2) {
clearPendingAppShellRestoreTop();
setPendingAppShellRestoreTop(null);
finish();
return;
}
}
if (remainingFrames <= 0) {
clearPendingAppShellRestoreTop();
setPendingAppShellRestoreTop(null);
finish();
return;
}
rafId = window.requestAnimationFrame(() => attemptRestore(remainingFrames - 1));
};
rafId = window.requestAnimationFrame(() => attemptRestore(90));
timeoutId = window.setTimeout(() => {
const maxScrollTop = Math.max(0, shell.scrollHeight - shell.clientHeight);
shell.scrollTop = Math.min(restoreTop, maxScrollTop);
clearPendingAppShellRestoreTop();
setPendingAppShellRestoreTop(null);
finish();
}, 1500);
onCleanup(finish);
});
useKeyboardShortcuts({
enabled: () => !runtime.needsAuth(),
isShortcutsOpen: shortcutsOpen,
@@ -322,6 +401,7 @@ function App() {
<div class="flex h-screen overflow-hidden">
{/* Main content area - shrinks when AI panel is open, scrolls independently */}
<div
ref={setAppScrollShellRef}
class={`app-scroll-shell flex-1 min-w-0 overflow-y-scroll bg-base text-base-content font-sans py-4 sm:py-6 transition-all duration-300`}
>
<AppLayout
@@ -8,7 +8,13 @@ describe('App architecture', () => {
expect(appSource).toContain('DASHBOARD_PATH,');
expect(appSource).toContain("import { AppLayout } from '@/AppLayout';");
expect(appSource).toContain("import { useAppRuntimeState } from '@/useAppRuntimeState';");
expect(appSource).toContain("import {");
expect(appSource).toContain("} from '@/utils/appShellScrollRestoration';");
expect(appSource).toContain('const runtime = useAppRuntimeState();');
expect(appSource).toContain('pendingAppShellRestoreTop');
expect(appSource).toContain('setAppScrollShellRef');
expect(appSource).toContain('readPendingAppShellRestoreTop');
expect(appSource).toContain('clearPendingAppShellRestoreTop');
expect(appSource).toContain('const ROOT_DASHBOARD_PATH = DASHBOARD_PATH;');
expect(appSource).toContain('<Route path={ROOT_DASHBOARD_PATH} component={DashboardPage} />');
expect(appSource).toContain('<Route path="/" component={() => <Navigate href={ROOT_DASHBOARD_PATH} />} />');
@@ -242,7 +242,7 @@ describe('useDashboardSelectionState', () => {
expect(row.scrollIntoView).toHaveBeenCalledWith({ behavior: 'smooth', block: 'center' });
});
it('reveals mounted workload detail after selecting a row', () => {
it('opens mounted workload detail in place when the row itself triggers focus', () => {
locationSearch = '?type=app-container&platform=truenas&agent=truenas-main';
const workloadId = 'app-container:truenas-main:nextcloud';
const [filteredGuests] = createSignal<WorkloadGuest[]>([
@@ -306,11 +306,15 @@ describe('useDashboardSelectionState', () => {
result.setSelectedGuestId(workloadId);
vi.runAllTimers();
expect(scrollToSpy).toHaveBeenCalledWith(
expect.objectContaining({
behavior: 'smooth',
}),
);
expect(
scrollToSpy.mock.calls.some(
([firstArg]) =>
Boolean(firstArg) &&
typeof firstArg === 'object' &&
'behavior' in firstArg &&
firstArg.behavior === 'smooth',
),
).toBe(false);
});
it('tracks hovered workload groups without letting them override entity selection outside scope', () => {
@@ -1,17 +1,14 @@
import { useLocation, useNavigate } from '@solidjs/router';
import { createEffect, createMemo, createSignal, onCleanup, untrack, type Accessor } from 'solid-js';
import {
findInlineDetailElement,
preserveScrollableAncestorVerticalOffset,
revealInlineDetailInViewport,
} from '@/components/shared/contextualFocus';
import { preserveScrollableAncestorVerticalOffset } from '@/components/shared/contextualFocus';
import { useSummaryPageInteractionState } from '@/components/shared/summaryTableFocus';
import {
isSummarySeriesInGroupScope,
type SummarySeriesGroupScope,
} from '@/components/shared/summaryCardInteraction';
import type { WorkloadGuest } from '@/types/workloads';
import { capturePendingAppShellRestoreTop } from '@/utils/appShellScrollRestoration';
import { createRouteStateNavigateScheduler } from '@/utils/routeStateNavigation';
import {
dashboardHasHoveredWorkload,
@@ -41,6 +38,7 @@ export function useDashboardSelectionState(options: UseDashboardSelectionStateOp
const [handledResourceId, setHandledResourceId] = createSignal<string | null>(null);
const [handledWorkloadGroupId, setHandledWorkloadGroupId] = createSignal<string | null>(null);
const [revealedGuestId, setRevealedGuestId] = createSignal<string | null>(null);
const [skipNextFocusedReveal, setSkipNextFocusedReveal] = createSignal(false);
const [tableWrapperRef, setTableWrapperRefSignal] = createSignal<HTMLDivElement | undefined>(
undefined,
@@ -60,6 +58,13 @@ export function useDashboardSelectionState(options: UseDashboardSelectionStateOp
focusedSeriesId: selectedGuestId,
focusedGroupScope: focusedWorkloadGroupScope,
revealActiveSeries: setRevealedGuestId,
consumeNextFocusedRevealSkip: () => {
const shouldSkip = skipNextFocusedReveal();
if (shouldSkip) {
setSkipNextFocusedReveal(false);
}
return shouldSkip;
},
});
const setTableWrapperRef = (element: HTMLDivElement | undefined) => {
@@ -74,6 +79,10 @@ export function useDashboardSelectionState(options: UseDashboardSelectionStateOp
};
const setSelectedGuestId = (id: string | null) => {
capturePendingAppShellRestoreTop();
if (id) {
setSkipNextFocusedReveal(true);
}
const activeFocusedGroupScope = focusedWorkloadGroupScope();
const nextGroupScope =
activeFocusedGroupScope && !isSummarySeriesInGroupScope(activeFocusedGroupScope, id)
@@ -207,52 +216,6 @@ export function useDashboardSelectionState(options: UseDashboardSelectionStateOp
}
});
createEffect(() => {
const selectedId = selectedGuestId();
const root = tableWrapperRef();
if (!selectedId || !root || typeof window === 'undefined') {
return;
}
let rafId: number | undefined;
let timeoutId: number | undefined;
let settled = false;
const cleanup = () => {
settled = true;
if (rafId !== undefined) {
window.cancelAnimationFrame(rafId);
}
if (timeoutId !== undefined) {
window.clearTimeout(timeoutId);
}
};
const attemptReveal = (remainingFrames: number) => {
if (settled) {
return;
}
const row = root.querySelector<HTMLElement>(`[data-summary-series-id="${selectedId}"]`);
const detail = findInlineDetailElement(root, selectedId);
if (row && detail) {
const didScroll = revealInlineDetailInViewport({ row, detail });
if (!didScroll || remainingFrames <= 0) {
cleanup();
return;
}
} else if (remainingFrames <= 0) {
cleanup();
return;
}
rafId = window.requestAnimationFrame(() => attemptReveal(remainingFrames - 1));
};
rafId = window.requestAnimationFrame(() => attemptReveal(24));
timeoutId = window.setTimeout(cleanup, 1500);
});
onCleanup(() => {
routeStateNavigate.cleanup();
});
@@ -312,6 +312,7 @@ describe('shared primitive guardrails', () => {
expect(summaryTableFocusSource).toContain('focusedSeriesId');
expect(summaryTableFocusSource).toContain('findInlineDetailElement');
expect(summaryTableFocusSource).toContain('revealInlineDetailInViewport');
expect(summaryTableFocusSource).toContain('consumeNextFocusedRevealSkip');
expect(summaryTableFocusSource).toContain('MutationObserver');
expect(summaryTableFocusSource).toContain('querySelector<HTMLElement>(');
expect(summaryTableFocusSource).toContain(
@@ -152,4 +152,46 @@ describe('useSummaryPageInteractionState', () => {
expect(revealActiveSeries).toHaveBeenCalledWith('workload-a');
expect(scrollTo).toHaveBeenCalledWith({ top: 456, behavior: 'smooth' });
});
it('skips the next focused reveal when the interaction owner suppresses inline auto-scroll', () => {
const [focusedSeriesId] = createSignal<string | null>('resource-a');
const [skipNextFocusedReveal, setSkipNextFocusedReveal] = createSignal(true);
const revealActiveSeries = vi.fn();
const scrollTo = vi.fn();
const root = document.createElement('div');
const row = document.createElement('div');
const detail = document.createElement('div');
Object.defineProperty(window, 'scrollTo', {
configurable: true,
value: scrollTo,
});
row.setAttribute('data-summary-series-id', 'resource-a');
row.getBoundingClientRect = vi.fn(() => buildRect(680, 40));
detail.setAttribute('data-inline-detail-for', 'resource-a');
detail.getBoundingClientRect = vi.fn(() => buildRect(724, 220));
root.append(row, detail);
document.body.appendChild(root);
const { result } = renderHook(() =>
useSummaryPageInteractionState({
focusedSeriesId,
revealActiveSeries,
consumeNextFocusedRevealSkip: () => {
const shouldSkip = skipNextFocusedReveal();
if (shouldSkip) {
setSkipNextFocusedReveal(false);
}
return shouldSkip;
},
}),
);
result.setTableRootRef(root);
expect(revealActiveSeries).not.toHaveBeenCalled();
expect(scrollTo).not.toHaveBeenCalled();
expect(skipNextFocusedReveal()).toBe(false);
});
});
@@ -1,4 +1,4 @@
import { createEffect, createMemo, createSignal, onCleanup, type Accessor } from 'solid-js';
import { createEffect, createMemo, createSignal, onCleanup, untrack, type Accessor } from 'solid-js';
import {
findInlineDetailElement,
revealInlineDetailInViewport,
@@ -52,6 +52,7 @@ export interface UseSummaryTableFocusBridgeOptions {
activeSeriesId: Accessor<string | null | undefined>;
focusedSeriesId?: Accessor<string | null | undefined>;
revealActiveSeries?: (seriesId: string) => void;
consumeNextFocusedRevealSkip?: () => boolean;
}
export function useSummaryTableFocusBridge(options: UseSummaryTableFocusBridgeOptions) {
@@ -127,6 +128,10 @@ export function useSummaryTableFocusBridge(options: UseSummaryTableFocusBridgeOp
return;
}
if (untrack(() => options.consumeNextFocusedRevealSkip?.() ?? false)) {
return;
}
options.revealActiveSeries?.(focusedId);
let settled = false;
@@ -230,6 +235,7 @@ export interface UseSummaryPageInteractionStateOptions {
hoveredGroupScope?: Accessor<SummarySeriesGroupScope | null | undefined>;
focusedGroupScope?: Accessor<SummarySeriesGroupScope | null | undefined>;
revealActiveSeries?: (seriesId: string) => void;
consumeNextFocusedRevealSkip?: () => boolean;
}
export function useSummaryPageInteractionState(options: UseSummaryPageInteractionStateOptions) {
@@ -270,6 +276,7 @@ export function useSummaryPageInteractionState(options: UseSummaryPageInteractio
activeSeriesId,
focusedSeriesId,
revealActiveSeries: options.revealActiveSeries,
consumeNextFocusedRevealSkip: options.consumeNextFocusedRevealSkip,
});
return {
@@ -64,11 +64,17 @@ describe('InfrastructurePageSurface guardrails', () => {
expect(infrastructurePageStateSource).toContain('jumpToActiveResourceRow');
expect(infrastructurePageStateSource).toContain('setSummaryTableRootRef');
expect(infrastructurePageStateSource).toContain('shouldShowJumpToActiveResourceRow');
expect(infrastructurePageStateSource).toContain('preserveScrollableAncestorVerticalOffset');
expect(infrastructurePageStateSource).toContain('consumeNextFocusedRevealSkip');
expect(infrastructurePageStateSource).toContain('setSkipNextFocusedReveal(true)');
expect(infrastructurePageStateSource).not.toContain('querySelector<HTMLElement>(');
expect(infrastructurePageStateSource).not.toContain('scrollIntoView({ behavior: \'smooth\', block: \'center\' })');
expect(infrastructurePageRouteStateSource).not.toContain('useSummaryPageInteractionState');
expect(infrastructurePageRouteStateSource).not.toContain('setSummaryTableRootRef');
expect(infrastructurePageRouteStateSource).toContain('createRouteStateNavigateScheduler');
expect(infrastructurePageRouteStateSource).not.toContain('replaceSamePathRouteState');
expect(infrastructurePageRouteStateSource).not.toContain('createRouteStateSearchSignal');
});
it('keeps inline-detail reveal out of infrastructure viewport sync helpers', () => {
@@ -1,5 +1,6 @@
import { createEffect, createMemo, createSignal } from 'solid-js';
import type { TimeRange } from '@/api/charts';
import { preserveScrollableAncestorVerticalOffset } from '@/components/shared/contextualFocus';
import { useUnifiedResources } from '@/hooks/useUnifiedResources';
import { usePersistentSignal } from '@/hooks/usePersistentSignal';
import { useBreakpoint } from '@/hooks/useBreakpoint';
@@ -8,6 +9,7 @@ import { useKioskMode } from '@/hooks/useKioskMode';
import { useSummaryPageInteractionState } from '@/components/shared/summaryTableFocus';
import { isSummaryTimeRange } from '@/components/shared/summaryTimeRange';
import { buildSummaryScopePresentation } from '@/components/shared/summaryScopePresentation';
import { capturePendingAppShellRestoreTop } from '@/utils/appShellScrollRestoration';
import {
buildInfrastructureSummaryGroupScope,
groupResources,
@@ -56,6 +58,8 @@ export function useInfrastructurePageState() {
createSignal<SummarySeriesGroupScope | null>(null);
const [deployCluster, setDeployCluster] = createSignal<DeployCluster | null>(null);
const [filtersOpen, setFiltersOpen] = createSignal(false);
const [tableRootRef, setTableRootRef] = createSignal<HTMLDivElement | undefined>(undefined);
const [skipNextFocusedReveal, setSkipNextFocusedReveal] = createSignal(false);
const hasResources = createMemo(() => resources().length > 0);
const showNoResources = createMemo(() => initialLoadComplete() && !hasResources() && !error());
@@ -106,18 +110,52 @@ export function useInfrastructurePageState() {
focusedSeriesId: routeState.expandedResourceId,
focusedGroupScope: focusedResourceGroupScope,
revealActiveSeries: routeState.setRevealedResourceId,
consumeNextFocusedRevealSkip: () => {
const shouldSkip = skipNextFocusedReveal();
if (shouldSkip) {
setSkipNextFocusedReveal(false);
}
return shouldSkip;
},
});
const setSummaryTableRootRef = (element: HTMLDivElement | undefined) => {
setTableRootRef(element);
summaryInteraction.setTableRootRef(element);
};
const preserveTableScrollAnchor = (apply: () => void) => {
preserveScrollableAncestorVerticalOffset(tableRootRef(), apply);
};
const setExpandedResourceId = (resourceId: string | null) => {
capturePendingAppShellRestoreTop();
if (resourceId) {
setSkipNextFocusedReveal(true);
}
const groupScope = focusedResourceGroupScope();
if (groupScope && resourceId && !isSummarySeriesInGroupScope(groupScope, resourceId)) {
routeState.setFocusedResourceGroupId(null);
preserveTableScrollAnchor(() => {
routeState.setFocusedResourceGroupId(null);
});
}
routeState.setExpandedResourceId(resourceId);
preserveTableScrollAnchor(() => {
routeState.setExpandedResourceId(resourceId);
});
};
const setFocusedResourceGroupId = (groupId: string | null) => {
capturePendingAppShellRestoreTop();
preserveTableScrollAnchor(() => {
routeState.setFocusedResourceGroupId(groupId);
});
};
const clearPinnedSummaryScope = () => {
routeState.setExpandedResourceId(null);
routeState.setFocusedResourceGroupId(null);
capturePendingAppShellRestoreTop();
preserveTableScrollAnchor(() => {
routeState.setExpandedResourceId(null);
routeState.setFocusedResourceGroupId(null);
});
};
const clearFilters = () => {
@@ -256,8 +294,9 @@ export function useInfrastructurePageState() {
setExpandedResourceId,
setChartHoverSync: summaryInteraction.setChartHoverSync,
setHoveredResourceGroupScope,
setSummaryTableRootRef: summaryInteraction.setTableRootRef,
setSummaryTableRootRef,
summaryScopePresentation,
shouldShowJumpToActiveResourceRow: summaryInteraction.shouldShowJumpToActiveRow,
setFocusedResourceGroupId,
};
}
@@ -5,6 +5,10 @@ import {
createRouteStateNavigateScheduler,
markRouteStateDeliberateScroll,
} from '@/utils/routeStateNavigation';
import {
clearPendingAppShellRestoreTop,
readPendingAppShellRestoreTop,
} from '@/utils/appShellScrollRestoration';
describe('routeStateNavigation', () => {
const scrollToSpy = vi.fn();
@@ -13,6 +17,7 @@ describe('routeStateNavigation', () => {
beforeEach(() => {
vi.useFakeTimers();
clearPendingAppShellRestoreTop();
scrollToSpy.mockReset();
currentScrollX = 24;
currentScrollY = 320;
@@ -59,6 +64,25 @@ describe('routeStateNavigation', () => {
expect(scrollToSpy).toHaveBeenCalledWith(24, 320);
});
it('captures the app scroll shell before same-path route state navigation', () => {
const navigate = vi.fn();
let currentPath = '/infrastructure?source=proxmox-pve';
const scheduler = createRouteStateNavigateScheduler(navigate, () => currentPath);
const shell = document.createElement('div');
shell.className = 'app-scroll-shell';
shell.scrollTop = 55;
document.body.appendChild(shell);
scheduler.schedule('/infrastructure?source=proxmox-pve&resource=agent-123');
vi.runAllTimers();
expect(navigate).toHaveBeenCalledWith(
'/infrastructure?source=proxmox-pve&resource=agent-123',
ROUTE_STATE_REPLACE_OPTIONS,
);
expect(readPendingAppShellRestoreTop()).toBe(55);
});
it('skips redundant navigations to the current path', () => {
const navigate = vi.fn();
const scheduler = createRouteStateNavigateScheduler(navigate, () => '/recovery?rollupId=abc');
@@ -0,0 +1,23 @@
let pendingAppShellRestoreTop: number | null = null;
const APP_SCROLL_SHELL_SELECTOR = '.app-scroll-shell';
export const schedulePendingAppShellRestoreTop = (scrollTop: number): void => {
pendingAppShellRestoreTop = Math.max(0, scrollTop);
};
export const capturePendingAppShellRestoreTop = (): void => {
if (typeof document === 'undefined') {
return;
}
const shell = document.querySelector<HTMLElement>(APP_SCROLL_SHELL_SELECTOR);
if (!shell || shell.scrollTop <= 0) {
return;
}
schedulePendingAppShellRestoreTop(shell.scrollTop);
};
export const readPendingAppShellRestoreTop = (): number | null => pendingAppShellRestoreTop;
export const clearPendingAppShellRestoreTop = (): void => {
pendingAppShellRestoreTop = null;
};
@@ -1,3 +1,5 @@
import { schedulePendingAppShellRestoreTop } from '@/utils/appShellScrollRestoration';
export const ROUTE_STATE_REPLACE_OPTIONS = {
replace: true,
scroll: false,
@@ -19,6 +21,13 @@ const isSamePathnameNavigation = (currentPath: string, targetPath: string): bool
return new URL(currentPath, base).pathname === new URL(targetPath, base).pathname;
};
const shouldRestorePreservedScrollValue = (current: number, restore: number): boolean => {
if (Math.abs(current - restore) <= ROUTE_STATE_SCROLL_RESTORE_DIVERGENCE_PX) {
return true;
}
return restore > ROUTE_STATE_SCROLL_RESTORE_DIVERGENCE_PX && current === 0;
};
export const createRouteStateNavigateScheduler = (
navigate: (path: string, options: typeof ROUTE_STATE_REPLACE_OPTIONS) => void,
readCurrentPath: () => string,
@@ -45,14 +54,18 @@ export const createRouteStateNavigateScheduler = (
if (restoreScroll) {
const previousScrollRestoration = window.history.scrollRestoration;
window.history.scrollRestoration = 'manual';
const shell = document.querySelector<HTMLElement>('.app-scroll-shell');
if (shell && shell.scrollTop > 0) {
schedulePendingAppShellRestoreTop(shell.scrollTop);
}
const applyScrollRestore = () => {
if (/jsdom/i.test(window.navigator.userAgent)) return;
if (routeStateDeliberateScrollSuppressedUntil > Date.now()) {
return;
}
if (
Math.abs(window.scrollX - restoreScroll.x) > ROUTE_STATE_SCROLL_RESTORE_DIVERGENCE_PX ||
Math.abs(window.scrollY - restoreScroll.y) > ROUTE_STATE_SCROLL_RESTORE_DIVERGENCE_PX
!shouldRestorePreservedScrollValue(window.scrollX, restoreScroll.x) ||
!shouldRestorePreservedScrollValue(window.scrollY, restoreScroll.y)
) {
return;
}
@@ -89,15 +89,45 @@ async function scrollSectionIntoView(
): Promise<number> {
const top = await locator.evaluate(
(element, sectionOffset) =>
Math.max(
0,
window.scrollY + element.getBoundingClientRect().top - sectionOffset,
),
(() => {
const shell = document.querySelector<HTMLElement>(".app-scroll-shell");
if (shell && shell.contains(element)) {
const shellRect = shell.getBoundingClientRect();
return Math.max(
0,
shell.scrollTop +
element.getBoundingClientRect().top -
shellRect.top -
sectionOffset,
);
}
return Math.max(
0,
window.scrollY + element.getBoundingClientRect().top - sectionOffset,
);
})(),
offset,
);
await page.evaluate((nextTop) => window.scrollTo(0, nextTop), top);
await page.evaluate((nextTop) => {
const shell = document.querySelector<HTMLElement>(".app-scroll-shell");
if (shell) {
shell.scrollTop = nextTop;
return;
}
window.scrollTo(0, nextTop);
}, top);
await page.waitForTimeout(150);
return page.evaluate(() => window.scrollY);
return page.evaluate(() => {
const shell = document.querySelector<HTMLElement>(".app-scroll-shell");
return shell ? shell.scrollTop : window.scrollY;
});
}
async function readPrimaryViewportScrollTop(page: Page): Promise<number> {
return page.evaluate(() => {
const shell = document.querySelector<HTMLElement>(".app-scroll-shell");
return shell ? shell.scrollTop : window.scrollY;
});
}
async function positionElementNearViewportBottom(
@@ -107,17 +137,60 @@ async function positionElementNearViewportBottom(
): Promise<number> {
const targetTop = await locator.evaluate(
(element, inset) =>
Math.max(
0,
window.scrollY + element.getBoundingClientRect().top - (window.innerHeight - inset),
),
(() => {
const shell = document.querySelector<HTMLElement>(".app-scroll-shell");
if (shell && shell.contains(element)) {
const shellRect = shell.getBoundingClientRect();
return Math.max(
0,
shell.scrollTop +
element.getBoundingClientRect().top -
shellRect.top -
(shell.clientHeight - inset),
);
}
return Math.max(
0,
window.scrollY +
element.getBoundingClientRect().top -
(window.innerHeight - inset),
);
})(),
bottomInset,
);
await page.evaluate((nextTop) => window.scrollTo(0, nextTop), targetTop);
await page.evaluate((nextTop) => {
const shell = document.querySelector<HTMLElement>(".app-scroll-shell");
if (shell) {
shell.scrollTop = nextTop;
return;
}
window.scrollTo(0, nextTop);
}, targetTop);
await page.waitForTimeout(150);
return locator.evaluate((element) => element.getBoundingClientRect().top);
}
async function readPrimaryViewportBounds(
page: Page,
): Promise<{ top: number; bottom: number; height: number }> {
return page.evaluate(() => {
const shell = document.querySelector<HTMLElement>(".app-scroll-shell");
if (shell) {
const rect = shell.getBoundingClientRect();
return {
top: rect.top,
bottom: rect.bottom,
height: shell.clientHeight,
};
}
return {
top: 0,
bottom: window.innerHeight,
height: window.innerHeight,
};
});
}
async function findLegacyScopedWorkloadRow(page: Page): Promise<Locator> {
const rows = page.locator("tr[data-guest-id]");
const rowCount = await rows.count();
@@ -184,6 +257,7 @@ test.describe.serial("Inline selection scroll stability", () => {
.first();
await expect(row).toBeVisible();
const beforeRowTop = await positionElementNearViewportBottom(page, row);
const beforeClickScroll = await readPrimaryViewportScrollTop(page);
expect(beforeRowTop).toBeGreaterThan(500);
await row.click();
@@ -197,16 +271,18 @@ test.describe.serial("Inline selection scroll stability", () => {
await expect(detailRow).toBeVisible();
await page.waitForTimeout(350);
const viewportHeight = await page.evaluate(() => window.innerHeight);
const viewportBounds = await readPrimaryViewportBounds(page);
const afterRowTop = await row.evaluate((element) => element.getBoundingClientRect().top);
const detailTop = await detailRow.evaluate((element) => element.getBoundingClientRect().top);
expect(afterRowTop).toBeLessThan(beforeRowTop - 150);
expect(afterRowTop).toBeLessThan(viewportHeight * 0.5);
expect(detailTop).toBeLessThan(viewportHeight - 48);
expect(Math.abs(afterRowTop - beforeRowTop)).toBeLessThanOrEqual(12);
expect(detailTop).toBeLessThan(viewportBounds.bottom - 48);
const afterScroll = await page.evaluate(() => window.scrollY);
expect(afterScroll).toBeGreaterThanOrEqual(Math.max(10, beforeScroll - 60));
const afterScroll = await page.evaluate(() => {
const shell = document.querySelector<HTMLElement>(".app-scroll-shell");
return shell ? shell.scrollTop : window.scrollY;
});
expect(Math.abs(afterScroll - beforeClickScroll)).toBeLessThanOrEqual(12);
});
test("keeps the recovery viewport stable when selecting a protected item", async ({
@@ -240,7 +316,7 @@ test.describe.serial("Inline selection scroll stability", () => {
page.getByTestId("recovery-history-item-filter-trigger"),
).not.toContainText("Any Item");
const afterScroll = await page.evaluate(() => window.scrollY);
const afterScroll = await readPrimaryViewportScrollTop(page);
expect(afterScroll).toBeGreaterThanOrEqual(Math.max(10, beforeScroll - 60));
});
@@ -261,6 +337,7 @@ test.describe.serial("Inline selection scroll stability", () => {
const beforeScroll = await scrollSectionIntoView(page, row);
expect(beforeScroll).toBeGreaterThan(10);
const beforeRowTop = await positionElementNearViewportBottom(page, row);
const beforeClickScroll = await readPrimaryViewportScrollTop(page);
expect(beforeRowTop).toBeGreaterThan(500);
const workloadId = (await row.getAttribute("data-guest-id")) ?? "";
@@ -280,16 +357,18 @@ test.describe.serial("Inline selection scroll stability", () => {
);
await page.waitForTimeout(350);
const viewportHeight = await page.evaluate(() => window.innerHeight);
const viewportBounds = await readPrimaryViewportBounds(page);
const afterRowTop = await row.evaluate((element) => element.getBoundingClientRect().top);
const detailTop = await detailRow.evaluate((element) => element.getBoundingClientRect().top);
expect(afterRowTop).toBeLessThan(beforeRowTop - 150);
expect(afterRowTop).toBeLessThan(viewportHeight * 0.5);
expect(detailTop).toBeLessThan(viewportHeight - 48);
expect(Math.abs(afterRowTop - beforeRowTop)).toBeLessThanOrEqual(12);
expect(detailTop).toBeLessThan(viewportBounds.bottom - 48);
const afterScroll = await page.evaluate(() => window.scrollY);
expect(afterScroll).toBeGreaterThanOrEqual(Math.max(10, beforeScroll - 60));
const afterScroll = await page.evaluate(() => {
const shell = document.querySelector<HTMLElement>(".app-scroll-shell");
return shell ? shell.scrollTop : window.scrollY;
});
expect(Math.abs(afterScroll - beforeClickScroll)).toBeLessThanOrEqual(12);
await row.click();
await expect.poll(() => page.url()).not.toContain("resource=");
@@ -359,6 +438,7 @@ test.describe.serial("Inline selection scroll stability", () => {
const beforeScroll = await scrollSectionIntoView(page, row);
expect(beforeScroll).toBeGreaterThan(10);
const beforeRowTop = await positionElementNearViewportBottom(page, row);
const beforeClickScroll = await readPrimaryViewportScrollTop(page);
expect(beforeRowTop).toBeGreaterThan(500);
await row.click();
@@ -367,12 +447,12 @@ test.describe.serial("Inline selection scroll stability", () => {
await expect(detailRow).toBeVisible();
await page.waitForTimeout(350);
const viewportHeight = await page.evaluate(() => window.innerHeight);
const viewportBounds = await readPrimaryViewportBounds(page);
const afterRowTop = await row.evaluate((element) => element.getBoundingClientRect().top);
const detailTop = await detailRow.evaluate((element) => element.getBoundingClientRect().top);
expect(afterRowTop).toBeLessThan(beforeRowTop - 150);
expect(afterRowTop).toBeLessThan(viewportHeight * 0.42);
expect(detailTop).toBeLessThan(viewportHeight - 48);
expect(afterRowTop).toBeLessThan(viewportBounds.top + viewportBounds.height * 0.42);
expect(detailTop).toBeLessThan(viewportBounds.bottom - 48);
});
});