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 (
- {visited || active ? children : null}
+ {mounted ? children : null}