Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add virtualization (configurable) #2623

Open
wants to merge 4 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions docs/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,12 @@ _Default: false_

If set to `true`, the API definition is considered untrusted and all HTML/Markdown is sanitized to prevent XSS.

### enableVirtualization

If set to `true`, the API documentation content will use virtualization. Virtualization only renders the API content when it is currently visible in user's viewport.

_Default: false_

## Theme settings

* `spacing`
Expand Down
39 changes: 39 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,7 @@
"dependencies": {
"@cfaester/enzyme-adapter-react-18": "^0.8.0",
"@redocly/openapi-core": "^1.4.0",
"@tanstack/react-virtual": "^3.10.8",
"classnames": "^2.3.2",
"decko": "^1.2.0",
"dompurify": "^3.0.6",
Expand Down
1 change: 1 addition & 0 deletions src/common-elements/panels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ export const RightPanel = styled.div`

export const DarkRightPanel = styled(RightPanel)`
background-color: ${props => props.theme.rightPanel.backgroundColor};
border-radius: 0.2rem;
`;

export const Row = styled.div`
Expand Down
16 changes: 12 additions & 4 deletions src/components/Redoc/Redoc.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,17 @@ import { ThemeProvider } from '../../styled-components';
import { OptionsProvider } from '../OptionsProvider';

import { AppStore } from '../../services';
import { ApiInfo } from '../ApiInfo/';

import { ApiLogo } from '../ApiLogo/ApiLogo';
import { ContentItems } from '../ContentItems/ContentItems';
import { SideMenu } from '../SideMenu/SideMenu';
import { StickyResponsiveSidebar } from '../StickySidebar/StickyResponsiveSidebar';
import { ApiContentWrap, BackgroundStub, RedocWrap } from './styled.elements';

import { SearchBox } from '../SearchBox/SearchBox';
import { StoreProvider } from '../StoreBuilder';
import VirtualizedContent from '../Virtualization/VirtualizedContent';
import { ApiInfo } from '../ApiInfo/ApiInfo';
import { ContentItems } from '../ContentItems/ContentItems';

export interface RedocProps {
store: AppStore;
Expand Down Expand Up @@ -56,8 +58,14 @@ export class Redoc extends React.Component<RedocProps> {
<SideMenu menu={menu} />
</StickyResponsiveSidebar>
<ApiContentWrap className="api-content">
<ApiInfo store={store} />
<ContentItems items={menu.items as any} />
{options.enableVirtualization ? (
<VirtualizedContent store={store} menu={menu} />
) : (
<>
<ApiInfo store={store} />
<ContentItems items={menu.items as any} />
</>
)}
</ApiContentWrap>
<BackgroundStub />
</RedocWrap>
Expand Down
92 changes: 92 additions & 0 deletions src/components/Virtualization/VirtualizedContent.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import * as React from 'react';
import { ApiInfo, AppStore, ContentItem, ContentItemModel, MenuStore } from '../..';
import { useVirtualizer } from '@tanstack/react-virtual';
import useSelectedTag from './useSelectedTag';
import useItemReverseIndex from './useItemReverseIndex';

type VirtualizedContentProps = {
store: AppStore;
menu: MenuStore;
};

const ESTIMATED_EACH_API_HEIGHT_PX = 1000;

/**
* VirtualizedContent optimizes the rendering of API documentation in Redoc by virtualizing the content.
*
* It ensures that only the API sections currently visible within the user's viewport are rendered,
* while off-screen sections remain unloaded until they come into view.
* The data is still in the memory, at least the HTML doesn't have to render it which does frees
* quite a huge amount of memory.
*
* This approach prevents memory issues that can arise when rendering large API documentation
* by reducing the amount of content loaded into memory at any one time, thereby enhancing
* performance and preventing potential crashes due to excessive memory usage.
*
*/
const VirtualizedContent = ({ store, menu }: VirtualizedContentProps) => {
const scrollableRef = React.useRef<HTMLDivElement>(null);

const renderables = React.useMemo(() => {
return menu.flatItems;
}, [menu.flatItems.length]);
const { reverseIndexToVirtualIndex: reverseIndex } = useItemReverseIndex(renderables);

const virtualizer = useVirtualizer({
count: renderables.length,
getScrollElement: () => scrollableRef.current!,
estimateSize: () => ESTIMATED_EACH_API_HEIGHT_PX,
});

const selectedTag = useSelectedTag();

/**
* The side effect is responsible for moving user based on the
* selected tag into the API of choice in the virtualized view.
*/
React.useEffect(() => {
const idx: number | undefined = reverseIndex[selectedTag];
if (!idx) {
return;
}

virtualizer.scrollToIndex(idx, {
align: 'start',
});
}, [selectedTag]);

return (
<div ref={scrollableRef} style={{ height: '100dvh', width: '100%', overflowY: 'auto' }}>
<ApiInfo store={store} />
<div
style={{
height: virtualizer.getTotalSize(),
width: '100%',
position: 'relative',
}}
>
{virtualizer.getVirtualItems().map(virtualItem => (
<div
key={virtualItem.key}
data-index={virtualItem.index}
ref={virtualizer.measureElement}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
transform: `translateY(${virtualItem.start}px)`,
}}
>
<ContentItem
key={renderables[virtualItem.index].id}
item={renderables[virtualItem.index] as ContentItemModel}
/>
</div>
))}
</div>
</div>
);
};

export default VirtualizedContent;
32 changes: 32 additions & 0 deletions src/components/Virtualization/useItemReverseIndex.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import * as React from 'react';
import { IMenuItem } from '../..';

export interface MenuItemReverseIndexToVirtualIndex {
[key: string]: number | undefined;
}

/**
* Helps in calculating the Reverse Index of menu items. This will pre-compute
* the location in the virtualized index of each menu item IDs.
*
* The purpose is to help for faster lookup (O(1)) when user clicks the sidebar to
* "jump" to a certain API endpoint.
*
* @param menuItems array of IMenuItem to create the reverse index
* @returns key/value of id/virtualized index
*/
const useItemReverseIndex = (menuItems: IMenuItem[]) => {
const reverseIndexToVirtualIndex = React.useMemo(() => {
return menuItems.reduce(
(prev, curr, idx) => ({ ...prev, [curr.id]: idx }),
{} as MenuItemReverseIndexToVirtualIndex,
);

// It is highly unlikely an API doc to change in runtime, so we would only
// like to re-render if the API doc API quantity changes.
}, [menuItems.length]);

return { reverseIndexToVirtualIndex: reverseIndexToVirtualIndex };
};

export default useItemReverseIndex;
42 changes: 42 additions & 0 deletions src/components/Virtualization/useSelectedTag.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import * as React from 'react';

/**
* Redoc has a unique "tag" that serves as an anchor when user clicks
* their sidebar.
*
* For example, the tag looks like "tag/myproduct-mynamespace-myendpoint".
* It transforms a hash from the url into those of Redoc tag. For example,
* transforms "#tag/myendpoint" into "tag/myendpoint".
*/
export const toRedocTag = (hash: string) => {
const decodedHash = decodeURIComponent(hash);
return decodedHash.substring(1, decodedHash.length);
};

/**
* Helps in retrieving the redoc tag user currently activates.
* This is to help to redirect user into the associated API endpoint in the
* Virtualization Content as the traditional HTML mechanism to redirect into anchor
* cannot happen as not everything is rendered initially in the Virtualization Content.
*/
const useSelectedTag = () => {
const [selectedTag, setSelectedTag] = React.useState('');

React.useEffect(() => {
const hashCheckInterval = setInterval(() => {
const redocTag = toRedocTag(window.location.hash);
console.log(redocTag);
if (redocTag !== selectedTag) {
setSelectedTag(redocTag);
}
}, 100);

return () => {
clearInterval(hashCheckInterval);
};
}, [selectedTag]);

return selectedTag;
};

export default useSelectedTag;
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ exports[`Components SchemaView discriminator should correctly render SchemaView
"disableSearch": false,
"downloadDefinitionUrl": undefined,
"downloadFileName": undefined,
"enableVirtualization": false,
"enumSkipQuotes": false,
"expandDefaultServerVariables": false,
"expandResponses": {},
Expand Down Expand Up @@ -351,6 +352,7 @@ exports[`Components SchemaView discriminator should correctly render SchemaView
"disableSearch": false,
"downloadDefinitionUrl": undefined,
"downloadFileName": undefined,
"enableVirtualization": false,
"enumSkipQuotes": false,
"expandDefaultServerVariables": false,
"expandResponses": {},
Expand Down Expand Up @@ -610,6 +612,7 @@ exports[`Components SchemaView discriminator should correctly render SchemaView
"disableSearch": false,
"downloadDefinitionUrl": undefined,
"downloadFileName": undefined,
"enableVirtualization": false,
"enumSkipQuotes": false,
"expandDefaultServerVariables": false,
"expandResponses": {},
Expand Down Expand Up @@ -931,6 +934,7 @@ exports[`Components SchemaView discriminator should correctly render SchemaView
"disableSearch": false,
"downloadDefinitionUrl": undefined,
"downloadFileName": undefined,
"enableVirtualization": false,
"enumSkipQuotes": false,
"expandDefaultServerVariables": false,
"expandResponses": {},
Expand Down Expand Up @@ -1215,6 +1219,7 @@ exports[`Components SchemaView discriminator should correctly render SchemaView
"disableSearch": false,
"downloadDefinitionUrl": undefined,
"downloadFileName": undefined,
"enableVirtualization": false,
"enumSkipQuotes": false,
"expandDefaultServerVariables": false,
"expandResponses": {},
Expand Down Expand Up @@ -1470,6 +1475,7 @@ exports[`Components SchemaView discriminator should correctly render SchemaView
"disableSearch": false,
"downloadDefinitionUrl": undefined,
"downloadFileName": undefined,
"enableVirtualization": false,
"enumSkipQuotes": false,
"expandDefaultServerVariables": false,
"expandResponses": {},
Expand Down Expand Up @@ -1750,6 +1756,7 @@ exports[`Components SchemaView discriminator should correctly render SchemaView
"disableSearch": false,
"downloadDefinitionUrl": undefined,
"downloadFileName": undefined,
"enableVirtualization": false,
"enumSkipQuotes": false,
"expandDefaultServerVariables": false,
"expandResponses": {},
Expand Down Expand Up @@ -2060,6 +2067,7 @@ exports[`Components SchemaView discriminator should correctly render SchemaView
"disableSearch": false,
"downloadDefinitionUrl": undefined,
"downloadFileName": undefined,
"enableVirtualization": false,
"enumSkipQuotes": false,
"expandDefaultServerVariables": false,
"expandResponses": {},
Expand Down Expand Up @@ -2332,6 +2340,7 @@ exports[`Components SchemaView discriminator should correctly render SchemaView
"disableSearch": false,
"downloadDefinitionUrl": undefined,
"downloadFileName": undefined,
"enableVirtualization": false,
"enumSkipQuotes": false,
"expandDefaultServerVariables": false,
"expandResponses": {},
Expand Down Expand Up @@ -2591,6 +2600,7 @@ exports[`Components SchemaView discriminator should correctly render SchemaView
"disableSearch": false,
"downloadDefinitionUrl": undefined,
"downloadFileName": undefined,
"enableVirtualization": false,
"enumSkipQuotes": false,
"expandDefaultServerVariables": false,
"expandResponses": {},
Expand Down
Loading