Skip to content

Commit

Permalink
feat: Allow tab content to be retained in memory (#3233)
Browse files Browse the repository at this point in the history
  • Loading branch information
gethinwebster authored and georgylobko committed Feb 18, 2025
1 parent 1fe7f2c commit b84fa40
Show file tree
Hide file tree
Showing 6 changed files with 220 additions and 3 deletions.
76 changes: 76 additions & 0 deletions pages/tabs/content-render-strategy-integ.page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
import React, { useEffect, useState } from 'react';

import { FormField, Input } from '~components';
import SpaceBetween from '~components/space-between';
import Tabs, { TabsProps } from '~components/tabs';

import { IframeWrapper } from '../utils/iframe-wrapper';

const TabWithState = () => {
const [value, setValue] = useState('');
return (
<FormField label="Input">
<Input value={value} onChange={e => setValue(e.detail.value)} />
</FormField>
);
};

const TabWithLoading = ({ id }: { id: string }) => {
const [loaded, setLoaded] = useState(false);
useEffect(() => {
setTimeout(() => setLoaded(true), 1000);
}, []);
return <div id={id}>{loaded ? 'Loaded' : 'Loading...'}</div>;
};

export default function TabsDemoPage() {
const tabs: Array<TabsProps.Tab> = [
{
label: 'Tab with state',
id: 'state',
content: <TabWithState />,
contentRenderStrategy: 'lazy',
},
{
label: 'Tab with state (not retained)',
id: 'state2',
content: <TabWithState />,
contentRenderStrategy: 'active',
},
{
label: 'Lazy loading',
id: 'lazy',
content: <TabWithLoading id="loading-lazy" />,
contentRenderStrategy: 'lazy',
},
{
label: 'Eager loading',
id: 'eager',
content: <TabWithLoading id="loading-eager" />,
contentRenderStrategy: 'eager',
},
{
label: 'Eager loading iframe',
id: 'eager-iframe',
content: <IframeWrapper id="iframe" AppComponent={() => <TabWithState />} />,
contentRenderStrategy: 'eager',
},
];
return (
<>
<h1>Tabs</h1>

<SpaceBetween size="xs">
<div>
<h2>Content render strategy</h2>
<Tabs
tabs={tabs}
i18nStrings={{ scrollLeftAriaLabel: 'Scroll left', scrollRightAriaLabel: 'Scroll right' }}
/>
</div>
</SpaceBetween>
</>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -16638,6 +16638,10 @@ use the \`id\` attribute, consider setting it on a parent element instead.",
ALT, SHIFT, META). This enables the user to open new browser tabs with an initially selected component tab,
if your application routing can handle such deep links. You can manually update routing on the current page
using the \`activeTabHref\` property of the \`change\` event's detail.
- \`contentRenderStrategy\` (string) - (Optional) Determines when tab content is rendered:
- \`'active'\`: (Default) Only render content when the tab is active.
- \`'eager'\`: Always render tab content (hidden when the tab is not active).
- \`'lazy'\`: Like 'eager', but content is only rendered after the tab is first activated.
",
"name": "tabs",
"optional": false,
Expand Down
73 changes: 73 additions & 0 deletions src/tabs/__integ__/content-render-strategy.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
import { BasePageObject } from '@cloudscape-design/browser-test-tools/page-objects';
import useBrowser from '@cloudscape-design/browser-test-tools/use-browser';

import createWrapper from '../../../lib/components/test-utils/selectors';

const wrapper = createWrapper();

const setupTest = (testFn: (page: BasePageObject) => Promise<void>) => {
return useBrowser(async browser => {
const page = new BasePageObject(browser);
await browser.url('#/light/tabs/content-render-strategy-integ');
await page.waitForVisible(wrapper.findTabs().findTabContent().toSelector());
await testFn(page);
});
};

test(
'strategy:eager tab content should already be available',
setupTest(async page => {
await expect(page.isExisting('#loading-eager')).resolves.toBeTruthy();
})
);
test(
'strategy:lazy tab content should load only when activated (but then remain)',
setupTest(async page => {
await expect(page.isExisting('#loading-lazy')).resolves.toBeFalsy();
await page.click(wrapper.findTabs().findTabLinkByIndex(3).toSelector());
await expect(page.isExisting('#loading-lazy')).resolves.toBeTruthy();
await page.click(wrapper.findTabs().findTabLinkByIndex(2).toSelector());
await expect(page.isExisting('#loading-lazy')).resolves.toBeTruthy();
})
);
test(
'strategy:eager tab state is retained when switching away and back',
setupTest(async page => {
const input = wrapper.findTabs().findTabContent().findInput().findNativeInput().toSelector();
await page.setValue(input, 'new value');
await expect(page.getValue(input)).resolves.toBe('new value');
await page.click(wrapper.findTabs().findTabLinkByIndex(3).toSelector());
await page.click(wrapper.findTabs().findTabLinkByIndex(1).toSelector());
await expect(page.getValue(input)).resolves.toBe('new value');
})
);
test(
'strategy:eager (iframe) tab state is retained when switching away and back',
setupTest(async page => {
const iframeInput = wrapper.findInput().findNativeInput().toSelector();
await page.click(wrapper.findTabs().findTabLinkByIndex(5).toSelector());
await page.runInsideIframe('iframe', true, async () => {
await page.setValue(iframeInput, 'new value');
await expect(page.getValue(iframeInput)).resolves.toBe('new value');
});
await page.click(wrapper.findTabs().findTabLinkByIndex(3).toSelector());
await page.click(wrapper.findTabs().findTabLinkByIndex(5).toSelector());
await page.runInsideIframe('iframe', true, async () => {
await expect(page.getValue(iframeInput)).resolves.toBe('new value');
});
})
);
test(
'strategy:active tab state is not retained when switching away and back',
setupTest(async page => {
const input = wrapper.findTabs().findTabContent().findInput().findNativeInput().toSelector();
await page.click(wrapper.findTabs().findTabLinkByIndex(2).toSelector());
await page.setValue(input, 'new value');
await expect(page.getValue(input)).resolves.toBe('new value');
await page.click(wrapper.findTabs().findTabLinkByIndex(3).toSelector());
await page.click(wrapper.findTabs().findTabLinkByIndex(2).toSelector());
await expect(page.getValue(input)).resolves.toBe('');
})
);
33 changes: 33 additions & 0 deletions src/tabs/__tests__/tabs.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,39 @@ describe('Tabs', () => {
});
});

test('renders tabs with contentRenderStrategy: eager', () => {
const tabs = renderTabs(
<Tabs tabs={defaultTabs.map(tab => ({ ...tab, contentRenderStrategy: 'eager' }))} />
).wrapper;
const tabContents = tabs.findAllByClassName(styles['tabs-content']);
expect(tabContents[0].getElement()).not.toBeEmptyDOMElement();
expect(tabContents[1].getElement()).not.toBeEmptyDOMElement();
});

test('renders tabs with contentRenderStrategy: lazy', () => {
const tabs = renderTabs(
<Tabs tabs={defaultTabs.map(tab => ({ ...tab, contentRenderStrategy: 'lazy' }))} />
).wrapper;
const tabContents = tabs.findAllByClassName(styles['tabs-content']);
expect(tabContents[0].getElement()).not.toBeEmptyDOMElement();
expect(tabContents[1].getElement()).toBeEmptyDOMElement();
tabs.findTabLinkByIndex(2)!.click();
expect(tabContents[0].getElement()).not.toBeEmptyDOMElement();
expect(tabContents[1].getElement()).not.toBeEmptyDOMElement();
});

test('renders tabs with contentRenderStrategy: active', () => {
const tabs = renderTabs(
<Tabs tabs={defaultTabs.map(tab => ({ ...tab, contentRenderStrategy: 'active' }))} />
).wrapper;
const tabContents = tabs.findAllByClassName(styles['tabs-content']);
expect(tabContents[0].getElement()).not.toBeEmptyDOMElement();
expect(tabContents[1].getElement()).toBeEmptyDOMElement();
tabs.findTabLinkByIndex(2)!.click();
expect(tabContents[0].getElement()).toBeEmptyDOMElement();
expect(tabContents[1].getElement()).not.toBeEmptyDOMElement();
});

describe('Active tab', () => {
test('does not render any tab as active if active tab id is set on a disabled tab', () => {
const tabs = renderTabs(<Tabs tabs={defaultTabs} activeTabId="third" onChange={() => void 0} />).wrapper;
Expand Down
26 changes: 23 additions & 3 deletions src/tabs/index.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
import React from 'react';
import React, { useRef } from 'react';
import clsx from 'clsx';

import { getAnalyticsMetadataAttribute } from '@cloudscape-design/component-toolkit/internal/analytics-metadata';
Expand Down Expand Up @@ -30,6 +30,18 @@ function firstEnabledTab(tabs: ReadonlyArray<TabsProps.Tab>) {
return null;
}

function shouldRenderTabContent(tab: TabsProps.Tab, viewedTabs: Set<string>) {
switch (tab.contentRenderStrategy) {
case 'active':
return false; // rendering active tab is handled directly in component
case 'eager':
return true;
case 'lazy':
return viewedTabs.has(tab.id);
}
return false;
}

export default function Tabs({
tabs,
variant = 'default',
Expand All @@ -52,6 +64,8 @@ export default function Tabs({
hasActions: tabs.some(tab => !!tab.action),
hasHeaderActions: !!actions,
hasDisabledReasons: tabs.some(tab => !!tab.disabledReason),
hasEagerLoadedTabs: tabs.some(tab => tab.contentRenderStrategy === 'eager'),
hasLazyLoadedTabs: tabs.some(tab => tab.contentRenderStrategy === 'lazy'),
},
});
const idNamespace = useUniqueId('awsui-tabs-');
Expand All @@ -62,6 +76,11 @@ export default function Tabs({
changeHandler: 'onChange',
});

const viewedTabs = useRef(new Set<string>());
if (activeTabId !== undefined) {
viewedTabs.current.add(activeTabId);
}

const baseProps = getBaseProps(rest);

const analyticsComponentMetadata: GeneratedAnalyticsMetadataTabsComponent = {
Expand Down Expand Up @@ -97,8 +116,9 @@ export default function Tabs({
'aria-labelledby': getTabElementId({ namespace: idNamespace, tabId: tab.id }),
};

const isContentShown = isTabSelected && !selectedTab.disabled;
return <div {...contentAttributes}>{isContentShown && selectedTab.content}</div>;
const isContentShown = !tab.disabled && (isTabSelected || shouldRenderTabContent(tab, viewedTabs.current));

return <div {...contentAttributes}>{isContentShown && tab.content}</div>;
};

return (
Expand Down
11 changes: 11 additions & 0 deletions src/tabs/interfaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,10 @@ export interface TabsProps extends BaseComponentProps {
* ALT, SHIFT, META). This enables the user to open new browser tabs with an initially selected component tab,
* if your application routing can handle such deep links. You can manually update routing on the current page
* using the `activeTabHref` property of the `change` event's detail.
* - `contentRenderStrategy` (string) - (Optional) Determines when tab content is rendered:
- `'active'`: (Default) Only render content when the tab is active.
* - `'eager'`: Always render tab content (hidden when the tab is not active).
* - `'lazy'`: Like 'eager', but content is only rendered after the tab is first activated.
*/
tabs: ReadonlyArray<TabsProps.Tab>;

Expand Down Expand Up @@ -142,6 +146,13 @@ export namespace TabsProps {
* using the `activeTabHref` property of the `change` event's detail.
*/
href?: string;
/**
* Determines when tab content is rendered:
* - 'active' (default): Only render content when the tab is active.
* - 'eager': Always render tab content (hidden when the tab is not active).
* - 'lazy': Like 'eager', but content is only rendered after the tab is first activated.
*/
contentRenderStrategy?: 'active' | 'eager' | 'lazy';
}

export interface ChangeDetail {
Expand Down

0 comments on commit b84fa40

Please sign in to comment.