diff --git a/docs/release-control/v6/internal/status.json b/docs/release-control/v6/internal/status.json index 2695917d7..fd3787d9c 100644 --- a/docs/release-control/v6/internal/status.json +++ b/docs/release-control/v6/internal/status.json @@ -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": [ diff --git a/docs/release-control/v6/internal/subsystems/cloud-paid.md b/docs/release-control/v6/internal/subsystems/cloud-paid.md index 01d320999..bf0ded1c3 100644 --- a/docs/release-control/v6/internal/subsystems/cloud-paid.md +++ b/docs/release-control/v6/internal/subsystems/cloud-paid.md @@ -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 diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index 4207e1f0b..450a89778 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -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`. diff --git a/docs/release-control/v6/internal/subsystems/performance-and-scalability.md b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md index 15ed75bee..9a3d9b8f9 100644 --- a/docs/release-control/v6/internal/subsystems/performance-and-scalability.md +++ b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md @@ -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, diff --git a/docs/release-control/v6/internal/subsystems/storage-recovery.md b/docs/release-control/v6/internal/subsystems/storage-recovery.md index 412317616..a0a27a441 100644 --- a/docs/release-control/v6/internal/subsystems/storage-recovery.md +++ b/docs/release-control/v6/internal/subsystems/storage-recovery.md @@ -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, diff --git a/docs/release-control/v6/internal/subsystems/unified-resources.md b/docs/release-control/v6/internal/subsystems/unified-resources.md index 5aa41eddd..c22355964 100644 --- a/docs/release-control/v6/internal/subsystems/unified-resources.md +++ b/docs/release-control/v6/internal/subsystems/unified-resources.md @@ -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` diff --git a/frontend-modern/src/App.tsx b/frontend-modern/src/App.tsx index 9f4bad9d5..067462d4e 100644 --- a/frontend-modern/src/App.tsx +++ b/frontend-modern/src/App.tsx @@ -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( + undefined, + ); + const [pendingAppShellRestoreTop, setPendingAppShellRestoreTop] = createSignal( + 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() {
{/* Main content area - shrinks when AI panel is open, scrolls independently */}
{ 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(''); expect(appSource).toContain(' } />'); diff --git a/frontend-modern/src/components/Dashboard/__tests__/useDashboardSelectionState.test.ts b/frontend-modern/src/components/Dashboard/__tests__/useDashboardSelectionState.test.ts index 07b009ccc..10e008ab3 100644 --- a/frontend-modern/src/components/Dashboard/__tests__/useDashboardSelectionState.test.ts +++ b/frontend-modern/src/components/Dashboard/__tests__/useDashboardSelectionState.test.ts @@ -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([ @@ -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', () => { diff --git a/frontend-modern/src/components/Dashboard/useDashboardSelectionState.ts b/frontend-modern/src/components/Dashboard/useDashboardSelectionState.ts index 2d218d022..d24bf6fa6 100644 --- a/frontend-modern/src/components/Dashboard/useDashboardSelectionState.ts +++ b/frontend-modern/src/components/Dashboard/useDashboardSelectionState.ts @@ -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(null); const [handledWorkloadGroupId, setHandledWorkloadGroupId] = createSignal(null); const [revealedGuestId, setRevealedGuestId] = createSignal(null); + const [skipNextFocusedReveal, setSkipNextFocusedReveal] = createSignal(false); const [tableWrapperRef, setTableWrapperRefSignal] = createSignal( 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(`[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(); }); diff --git a/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts b/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts index ec6a468f4..6902974da 100644 --- a/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts +++ b/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts @@ -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('); expect(summaryTableFocusSource).toContain( diff --git a/frontend-modern/src/components/shared/__tests__/summaryTableFocus.test.tsx b/frontend-modern/src/components/shared/__tests__/summaryTableFocus.test.tsx index 65e7497a3..f6cdcc0c2 100644 --- a/frontend-modern/src/components/shared/__tests__/summaryTableFocus.test.tsx +++ b/frontend-modern/src/components/shared/__tests__/summaryTableFocus.test.tsx @@ -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('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); + }); }); diff --git a/frontend-modern/src/components/shared/summaryTableFocus.ts b/frontend-modern/src/components/shared/summaryTableFocus.ts index ae439d63e..a6caccc6a 100644 --- a/frontend-modern/src/components/shared/summaryTableFocus.ts +++ b/frontend-modern/src/components/shared/summaryTableFocus.ts @@ -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; focusedSeriesId?: Accessor; 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; focusedGroupScope?: Accessor; 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 { diff --git a/frontend-modern/src/features/infrastructure/__tests__/InfrastructurePageSurface.guardrails.test.ts b/frontend-modern/src/features/infrastructure/__tests__/InfrastructurePageSurface.guardrails.test.ts index 448f0d16d..a164751b2 100644 --- a/frontend-modern/src/features/infrastructure/__tests__/InfrastructurePageSurface.guardrails.test.ts +++ b/frontend-modern/src/features/infrastructure/__tests__/InfrastructurePageSurface.guardrails.test.ts @@ -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('); 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', () => { diff --git a/frontend-modern/src/features/infrastructure/useInfrastructurePageState.ts b/frontend-modern/src/features/infrastructure/useInfrastructurePageState.ts index 6bed50326..1c76c9acb 100644 --- a/frontend-modern/src/features/infrastructure/useInfrastructurePageState.ts +++ b/frontend-modern/src/features/infrastructure/useInfrastructurePageState.ts @@ -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(null); const [deployCluster, setDeployCluster] = createSignal(null); const [filtersOpen, setFiltersOpen] = createSignal(false); + const [tableRootRef, setTableRootRef] = createSignal(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, }; } diff --git a/frontend-modern/src/utils/__tests__/routeStateNavigation.test.ts b/frontend-modern/src/utils/__tests__/routeStateNavigation.test.ts index a2886d7f6..edbe523b6 100644 --- a/frontend-modern/src/utils/__tests__/routeStateNavigation.test.ts +++ b/frontend-modern/src/utils/__tests__/routeStateNavigation.test.ts @@ -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'); diff --git a/frontend-modern/src/utils/appShellScrollRestoration.ts b/frontend-modern/src/utils/appShellScrollRestoration.ts new file mode 100644 index 000000000..294fc38f2 --- /dev/null +++ b/frontend-modern/src/utils/appShellScrollRestoration.ts @@ -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(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; +}; diff --git a/frontend-modern/src/utils/routeStateNavigation.ts b/frontend-modern/src/utils/routeStateNavigation.ts index ce8013e6a..4e3abea5c 100644 --- a/frontend-modern/src/utils/routeStateNavigation.ts +++ b/frontend-modern/src/utils/routeStateNavigation.ts @@ -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('.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; } diff --git a/tests/integration/tests/47-inline-selection-scroll-stability.spec.ts b/tests/integration/tests/47-inline-selection-scroll-stability.spec.ts index fea179bc9..553cb4607 100644 --- a/tests/integration/tests/47-inline-selection-scroll-stability.spec.ts +++ b/tests/integration/tests/47-inline-selection-scroll-stability.spec.ts @@ -89,15 +89,45 @@ async function scrollSectionIntoView( ): Promise { const top = await locator.evaluate( (element, sectionOffset) => - Math.max( - 0, - window.scrollY + element.getBoundingClientRect().top - sectionOffset, - ), + (() => { + const shell = document.querySelector(".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(".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(".app-scroll-shell"); + return shell ? shell.scrollTop : window.scrollY; + }); +} + +async function readPrimaryViewportScrollTop(page: Page): Promise { + return page.evaluate(() => { + const shell = document.querySelector(".app-scroll-shell"); + return shell ? shell.scrollTop : window.scrollY; + }); } async function positionElementNearViewportBottom( @@ -107,17 +137,60 @@ async function positionElementNearViewportBottom( ): Promise { const targetTop = await locator.evaluate( (element, inset) => - Math.max( - 0, - window.scrollY + element.getBoundingClientRect().top - (window.innerHeight - inset), - ), + (() => { + const shell = document.querySelector(".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(".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(".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 { 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(".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(".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); }); });