From 586f25c9d1c368736fa76d66eff15f7d2e7ed85e Mon Sep 17 00:00:00 2001 From: simbiozizv Date: Tue, 29 Sep 2026 17:35:18 +0300 Subject: [PATCH] feat!: unmount inactive queries sidebar tabs by default Add keepMounted to preserve visited tab contents when enabled. Cover content lifecycle transitions and document adapter cleanup and migration. --- src/types/queriesSidebar.ts | 2 + .../QueriesSidebar/QueriesSidebar.stories.tsx | 13 +- src/widgets/QueriesSidebar/QueriesSidebar.tsx | 2 + src/widgets/QueriesSidebar/README.md | 94 +++++++++++- .../QueriesSidebar/internal/SidebarPanel.tsx | 8 +- tests/components/QueriesSidebar.test.tsx | 143 ++++++++++++++++-- 6 files changed, 240 insertions(+), 22 deletions(-) diff --git a/src/types/queriesSidebar.ts b/src/types/queriesSidebar.ts index d907020..02de70f 100644 --- a/src/types/queriesSidebar.ts +++ b/src/types/queriesSidebar.ts @@ -54,5 +54,7 @@ export type QueriesSidebarProps< onActiveTabChange?: (id: string) => void; /** Hide the tab strip, for example when activeTab follows external navigation. */ hideTabs?: boolean; + /** Preserve visited tab contents while inactive. Default: false. */ + keepMounted?: boolean; className?: string; }; diff --git a/src/widgets/QueriesSidebar/QueriesSidebar.stories.tsx b/src/widgets/QueriesSidebar/QueriesSidebar.stories.tsx index 24bdb02..fe01566 100644 --- a/src/widgets/QueriesSidebar/QueriesSidebar.stories.tsx +++ b/src/widgets/QueriesSidebar/QueriesSidebar.stories.tsx @@ -1,4 +1,4 @@ -import React, {useState} from 'react'; +import React, {useEffect, useState} from 'react'; import type {Meta, StoryObj} from '@storybook/react'; import {Button, Flex, Icon, SegmentedRadioGroup, Text} from '@gravity-ui/uikit'; import {Star} from '@gravity-ui/icons'; @@ -193,6 +193,7 @@ function Example({external = false, narrow = false, custom = false}) { tabs={tabs} hideTabs={external} activeTab={external ? activeTab : undefined} + keepMounted={false} defaultActiveTab={custom ? 'favorites' : undefined} /> @@ -202,9 +203,19 @@ function Example({external = false, narrow = false, custom = false}) { function Favorites({active}: {active: boolean}) { const [count, setCount] = useState(0); + const [ticks, setTicks] = useState(0); + useEffect(() => { + action('favorites.mount')(); + const timer = setInterval(() => setTicks((value) => value + 1), 1000); + return () => { + clearInterval(timer); + action('favorites.cleanup')(); + }; + }, []); return ( {active ? 'Favorites' : 'Inactive'} + Polling ticks: {ticks} ); diff --git a/src/widgets/QueriesSidebar/QueriesSidebar.tsx b/src/widgets/QueriesSidebar/QueriesSidebar.tsx index 7ff454f..bee3b77 100644 --- a/src/widgets/QueriesSidebar/QueriesSidebar.tsx +++ b/src/widgets/QueriesSidebar/QueriesSidebar.tsx @@ -40,6 +40,7 @@ export function QueriesSidebar< defaultActiveTab, onActiveTabChange, hideTabs = false, + keepMounted = false, className, }: QueriesSidebarProps) { const id = useId(); @@ -117,6 +118,7 @@ export function QueriesSidebar< aria-label={hideTabs ? title(tab) : undefined} aria-labelledby={hideTabs ? undefined : `${id}-tab-${tab.id}`} active={tab.id === selected} + keepMounted={keepMounted} className={block('panel')} > diff --git a/src/widgets/QueriesSidebar/README.md b/src/widgets/QueriesSidebar/README.md index 2185802..cf2c0b9 100644 --- a/src/widgets/QueriesSidebar/README.md +++ b/src/widgets/QueriesSidebar/README.md @@ -32,6 +32,7 @@ const tabs: QueriesSidebarTab[] = [ | `defaultActiveTab` | Initial uncontrolled ID; defaults to the first enabled tab | | `onActiveTabChange` | User selection requests, or uncontrolled fallback changes; never echoes prop updates or initial selection | | `hideTabs` | Defaults to `false`; hides only the tab strip, without resetting content | +| `keepMounted` | Defaults to `false`; set to `true` to preserve visited contents while inactive | | `className` | External layout class on the root | IDs must be non-empty, unique and stable. Keep a tab's type stable as well. @@ -49,14 +50,88 @@ and `renderContent({active})`. All tab types accept `disabled`. ## State and accessibility -Sections mount on their first visit, then remain mounted while hidden. Local -state and scroll are retained when switching sections or toggling `hideTabs`. -Removing an ID from `tabs` discards its mounted content. Application-controlled -state continues to follow the supplied props. +By default, only the active section's contents are mounted. Switching sections +unmounts the previous contents, runs effect cleanup and resets their local state. +Panel containers may remain in the DOM. This also applies to custom sections and +external `activeTab` updates. `hideTabs` only controls the tab strip and does not +change the content lifecycle. -Hidden built-in sections pause automatic list pagination. Custom sections can -use `active` to pause their own fetching, subscriptions or timers. The sidebar -does not cancel requests already started by the application. +With `keepMounted={true}`, sections mount on their first visit and remain mounted +while hidden, preserving local state and scroll. Hidden built-in sections pause +automatic list pagination; custom sections can use `active` to pause their own +fetching, subscriptions or timers. + +Changing `keepMounted` to `false` immediately unmounts inactive contents. Changing +it back to `true` preserves the active contents and retains subsequent visits; +it does not remount previously discarded inactive contents. Removing a tab +always discards its contents. Application-controlled state follows supplied props. + +## Loading and polling inside custom sections + +The option controls only panel contents. Hooks called above `QueriesSidebar` to +prepare `tab.props` continue running even when a panel unmounts. Put loading, +delayed search and polling hooks inside a component adapter returned by +`custom.renderContent`. Do not call hooks directly inside `renderContent`. + +```tsx +import {useEffect, useState} from 'react'; +import {QueriesHistory} from '@gravity-ui/querieskit/modules/QueriesHistory'; +import {QueriesSidebar} from '@gravity-ui/querieskit/widgets/QueriesSidebar'; +import type {QueriesHistoryProps} from '@gravity-ui/querieskit'; + +type HistoryItems = QueriesHistoryProps['items']; + +function HistoryAdapter({ + loadHistory, +}: { + loadHistory: (signal: AbortSignal, query: string) => Promise; +}) { + const [items, setItems] = useState([]); + const [search, setSearch] = useState({value: '', fullSearch: false}); + + useEffect(() => { + const controller = new AbortController(); + let timer: ReturnType | undefined; + + async function refresh() { + try { + const nextItems = await loadHistory(controller.signal, search.value); + if (!controller.signal.aborted) setItems(nextItems); + } catch (error) { + if (!controller.signal.aborted) console.error(error); + } finally { + if (!controller.signal.aborted) timer = setTimeout(refresh, 5000); + } + } + + void refresh(); + return () => { + controller.abort(); + clearTimeout(timer); + }; + }, [loadHistory, search.value]); + + return ; +} + +// loadHistory is a stable application-provided loader accepting an AbortSignal. + , + }, + // Other sections use their own adapters in the same way. + ]} +/>; +``` + +Cleanup must cancel pending work or ignore stale results. Unmounting alone does +not cancel a request already started by the application. Visible navigation uses icon tabs with names and tooltips. With `hideTabs`, panels become named regions without references to missing tabs; hidden content cannot @@ -66,6 +141,11 @@ usage and should not duplicate that shared header. ## Migration +**Changed default:** inactive section contents now unmount. Add +`keepMounted={true}` to preserve the previous lazy-mount-and-retain behavior. +Without it, returning to a section resets local state and restarts its effects. + + `QueriesHistory`, `SavedQueries`, `QueriesNavigation`, `TutorialsHistory` now live in `src/modules` and are published at `@gravity-ui/querieskit/modules/`. Their names, props, helpers and root exports are preserved. The previous explicit diff --git a/src/widgets/QueriesSidebar/internal/SidebarPanel.tsx b/src/widgets/QueriesSidebar/internal/SidebarPanel.tsx index 2c464e2..7b08176 100644 --- a/src/widgets/QueriesSidebar/internal/SidebarPanel.tsx +++ b/src/widgets/QueriesSidebar/internal/SidebarPanel.tsx @@ -3,17 +3,19 @@ import {ListActivityContext} from '../../../helpers/ListActivityContext'; export function SidebarPanel({ active, + keepMounted, children, ...props -}: React.HTMLAttributes & {active: boolean}) { +}: React.HTMLAttributes & {active: boolean; keepMounted: boolean}) { const parentActive = useContext(ListActivityContext); const [visited, setVisited] = useState(active); - if (active && !visited) setVisited(true); + const mounted = active || (keepMounted && visited); + if (visited !== mounted) setVisited(mounted); return ( ); diff --git a/tests/components/QueriesSidebar.test.tsx b/tests/components/QueriesSidebar.test.tsx index b9853af..d9d694c 100644 --- a/tests/components/QueriesSidebar.test.tsx +++ b/tests/components/QueriesSidebar.test.tsx @@ -1,6 +1,6 @@ // @vitest-environment jsdom -import React, {act, useState} from 'react'; -import {createRoot, type Root} from 'react-dom/client'; +import React, {act, useEffect, useState} from 'react'; +import {type Root, createRoot} from 'react-dom/client'; import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; import {Tab, TabList, ThemeProvider, configure} from '@gravity-ui/uikit'; import {QueriesSidebar} from '../../src/widgets/QueriesSidebar'; @@ -24,7 +24,14 @@ function ListContent({onLoadMore}: {onLoadMore?: () => void}) { ); } +const lifecycle = {mount: vi.fn(), cleanup: vi.fn()}; function Counter() { + useEffect(() => { + lifecycle.mount(); + return () => { + lifecycle.cleanup(); + }; + }, []); const [count, setCount] = useState(0); return ; } @@ -73,6 +80,8 @@ describe('QueriesSidebar', () => { globalThis.IS_REACT_ACT_ENVIRONMENT = true; configure({lang: 'en'}); changes.mockClear(); + lifecycle.mount.mockClear(); + lifecycle.cleanup.mockClear(); observers.length = 0; vi.stubGlobal( 'IntersectionObserver', @@ -167,34 +176,146 @@ describe('QueriesSidebar', () => { }); it('retains state, scroll and DOM when hiding tabs or switching externally, with valid ARIA associations', () => { - render({activeTab: 'a', header:
Product
}); + render({keepMounted: true, activeTab: 'a', header:
Product
}); const first = panel(); expect(tab('a').getAttribute('aria-controls')).toBe(first.id); expect(first.getAttribute('aria-labelledby')).toBe(tab('a').id); expect(container.querySelectorAll('button:not([role="tab"])')).toHaveLength(1); first.scrollTop = 120; act(() => first.querySelector('button')!.click()); - render({activeTab: 'a', hideTabs: true, header:
Product
}); + render({keepMounted: true, activeTab: 'a', hideTabs: true, header:
Product
}); expect(panel()).toBe(first); expect(panel().getAttribute('role')).toBe('region'); expect(panel().getAttribute('aria-label')).toBe('a'); expect(panel().hasAttribute('aria-labelledby')).toBe(false); expect(container.querySelector('[role="tablist"]')).toBeNull(); expect(container.textContent).toContain('Product'); - render({activeTab: 'b', hideTabs: true}); + render({keepMounted: true, activeTab: 'b', hideTabs: true}); expect(first.hidden).toBe(true); expect(first.querySelector('[data-active]')!.getAttribute('data-active')).toBe('false'); - render({activeTab: 'a'}); + render({keepMounted: true, activeTab: 'a'}); expect(panel()).toBe(first); expect(panel().textContent).toContain('Count 1'); expect(panel().scrollTop).toBe(120); expect(changes).not.toHaveBeenCalled(); - render({activeTab: 'b', tabs: [custom('b')]}); - render({activeTab: 'a'}); + render({keepMounted: true, activeTab: 'b', tabs: [custom('b')]}); + render({keepMounted: true, activeTab: 'a'}); expect(panel()).not.toBe(first); expect(panel().textContent).toContain('Count 0'); }); + it.each([undefined, false])( + 'unmounts inactive custom content with keepMounted=%s', + (keepMounted) => { + const renderA = vi.fn(() => ); + const renderB = vi.fn(() => ); + const tabs: QueriesSidebarTab[] = [ + {...custom('a'), type: 'custom', renderContent: renderA}, + {...custom('b'), type: 'custom', renderContent: renderB}, + ]; + render({tabs, keepMounted}); + expect(renderB).not.toHaveBeenCalled(); + expect(lifecycle.mount.mock.calls.length - lifecycle.cleanup.mock.calls.length).toBe(1); + act(() => panel().querySelector('button')!.click()); + const cleanups = lifecycle.cleanup.mock.calls.length; + act(() => tab('b').click()); + expect(lifecycle.cleanup.mock.calls.length).toBeGreaterThan(cleanups); + expect(container.querySelector('[role="tabpanel"][hidden]')!.textContent).toBe(''); + expect(lifecycle.mount.mock.calls.length - lifecycle.cleanup.mock.calls.length).toBe(1); + act(() => tab('a').click()); + expect(panel().textContent).toBe('Count 0'); + }, + ); + + it.each(['history', 'saved', 'tutorials', 'navigation'] as const)( + 'unmounts %s on external selection and remounts with fresh state', + (type) => { + const section: QueriesSidebarTab = + type === 'navigation' + ? { + id: 'list', + type, + props: { + location: {cluster: undefined, path: undefined}, + onUpdate: vi.fn(), + listState: {}, + }, + } + : {id: 'list', type, props: {items: [], search: {onUpdate: vi.fn()}}}; + const tabs = [section, custom('other')]; + render({tabs, activeTab: 'other', keepMounted: false}); + expect(observers).toHaveLength(0); + render({tabs, activeTab: 'list', keepMounted: false}); + const first = panel(); + expect( + container + .querySelector('[role="tab"][aria-selected="true"]')! + .getAttribute('aria-controls'), + ).toBe(first.id); + act(() => first.querySelector('button')!.click()); + const cleanupCount = lifecycle.cleanup.mock.calls.length; + render({tabs, activeTab: 'other', keepMounted: false}); + expect(first.textContent).toBe(''); + expect(lifecycle.cleanup.mock.calls.length).toBeGreaterThan(cleanupCount); + expect(lifecycle.mount.mock.calls.length - lifecycle.cleanup.mock.calls.length).toBe(1); + render({tabs, activeTab: 'list', keepMounted: false}); + expect(panel().textContent).toBe('Count 0'); + expect(changes).not.toHaveBeenCalled(); + }, + ); + + it('cleans up removed active content and uses the selection fallback', () => { + render(); + const first = panel(); + const cleanups = lifecycle.cleanup.mock.calls.length; + render({tabs: [custom('b')]}); + expect(first.isConnected).toBe(false); + expect(lifecycle.cleanup.mock.calls.length).toBeGreaterThan(cleanups); + expect(lifecycle.mount.mock.calls.length - lifecycle.cleanup.mock.calls.length).toBe(1); + expect(changes).toHaveBeenLastCalledWith('b'); + render({tabs: [{...custom('b'), disabled: true}]}); + expect(panel()).toBeNull(); + expect(lifecycle.mount.mock.calls.length).toBe(lifecycle.cleanup.mock.calls.length); + }); + + it('updates retention without remounting active content or resurrecting discarded tabs', () => { + render({keepMounted: true, activeTab: 'a'}); + act(() => panel().querySelector('button')!.click()); + render({keepMounted: true, activeTab: 'b'}); + expect(lifecycle.mount.mock.calls.length - lifecycle.cleanup.mock.calls.length).toBe(2); + const activeContent = panel().querySelector('button'); + const mounts = lifecycle.mount.mock.calls.length; + const cleanups = lifecycle.cleanup.mock.calls.length; + render({keepMounted: false, activeTab: 'b'}); + expect(lifecycle.cleanup).toHaveBeenCalledTimes(cleanups + 1); + render({keepMounted: true, activeTab: 'b'}); + expect(lifecycle.mount).toHaveBeenCalledTimes(mounts); + expect(panel().querySelector('button')).toBe(activeContent); + expect(container.querySelector('[role="tabpanel"][hidden]')!.textContent).toBe(''); + render({keepMounted: true, activeTab: 'a'}); + expect(panel().textContent).toBe('Count 0'); + expect(lifecycle.mount.mock.calls.length - lifecycle.cleanup.mock.calls.length).toBe(2); + }); + + it('only changes navigation and ARIA when toggling hideTabs', () => { + render(); + const first = panel(); + const button = first.querySelector('button')!; + act(() => button.click()); + const mounts = lifecycle.mount.mock.calls.length; + const cleanups = lifecycle.cleanup.mock.calls.length; + render({hideTabs: true}); + expect(panel()).toBe(first); + expect(panel().getAttribute('role')).toBe('region'); + expect(panel().hasAttribute('aria-labelledby')).toBe(false); + render({hideTabs: false}); + expect(panel().querySelector('button')).toBe(button); + expect(panel().textContent).toBe('Count 1'); + expect(panel().getAttribute('aria-labelledby')).toBe(tab('a').id); + expect(lifecycle.mount).toHaveBeenCalledTimes(mounts); + expect(lifecycle.cleanup).toHaveBeenCalledTimes(cleanups); + }); + it('supports hidden tabs in uncontrolled mode and filters invalid IDs', () => { const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); render({ @@ -240,17 +361,17 @@ describe('QueriesSidebar', () => { {} as IntersectionObserver, ), ); - render({tabs, activeTab: 'list'}); + render({keepMounted: true, tabs, activeTab: 'list'}); const beforeHide = observers.length - 1; intersect(beforeHide); expect(load).toHaveBeenCalledTimes(1); const first = panel(); act(() => first.querySelector('button')!.click()); - render({tabs, activeTab: 'other'}); + render({keepMounted: true, tabs, activeTab: 'other'}); expect(observers[beforeHide].disconnect).toHaveBeenCalled(); intersect(beforeHide); expect(load).toHaveBeenCalledTimes(1); - render({tabs, activeTab: 'list'}); + render({keepMounted: true, tabs, activeTab: 'list'}); expect(panel()).toBe(first); expect(panel().textContent).toContain('Count 1'); intersect(observers.length - 1);