From 2e5c98e9fc62ab36ffd24a3226a06e0904db4c5e Mon Sep 17 00:00:00 2001 From: Putu Audi Pasuatmadi <63685606+audipasuatmadi@users.noreply.github.com> Date: Fri, 8 Nov 2024 12:42:20 +0800 Subject: [PATCH 1/4] Add Virtualization to Redoc (#1) * add virtualization to redoc * address code review, simply stuffs --------- Co-authored-by: Putu Audi Pasuatmadi --- package-lock.json | 39 +++++ package.json | 1 + src/common-elements/panels.ts | 1 + src/components/Redoc/Redoc.tsx | 7 +- .../Virtualization/VirtualizedContent.tsx | 91 ++++++++++++ .../Virtualization/useItemReverseIndex.tsx | 32 +++++ .../Virtualization/useSelectedTag.tsx | 40 ++++++ .../__snapshots__/FieldDetails.test.tsx.snap | 6 +- .../SecurityRequirement.test.tsx.snap | 26 ++-- .../__tests__/useItemReverseIndex.test.tsx | 100 +++++++++++++ .../__tests__/useSelectedTag.test.tsx | 134 ++++++++++++++++++ 11 files changed, 457 insertions(+), 20 deletions(-) create mode 100644 src/components/Virtualization/VirtualizedContent.tsx create mode 100644 src/components/Virtualization/useItemReverseIndex.tsx create mode 100644 src/components/Virtualization/useSelectedTag.tsx create mode 100644 src/components/__tests__/useItemReverseIndex.test.tsx create mode 100644 src/components/__tests__/useSelectedTag.test.tsx diff --git a/package-lock.json b/package-lock.json index 69e5754b34..5e7a92c861 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,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", @@ -3623,6 +3624,31 @@ "size-limit": "11.1.4" } }, + "node_modules/@tanstack/react-virtual": { + "version": "3.10.8", + "resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.10.8.tgz", + "integrity": "sha512-VbzbVGSsZlQktyLrP5nxE+vE1ZR+U0NFAWPbJLoG2+DKPwd2D7dVICTVIIaYlJqX1ZCEnYDbaOpmMwbsyhBoIA==", + "dependencies": { + "@tanstack/virtual-core": "3.10.8" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0" + } + }, + "node_modules/@tanstack/virtual-core": { + "version": "3.10.8", + "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.10.8.tgz", + "integrity": "sha512-PBu00mtt95jbKFi6Llk9aik8bnR3tR/oQP1o3TSi+iG//+Q2RTIzCEgKkHG8BB86kxMNW6O8wku+Lmi+QFR6jA==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, "node_modules/@tootallnate/once": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.0.tgz", @@ -21749,6 +21775,19 @@ "dev": true, "requires": {} }, + "@tanstack/react-virtual": { + "version": "3.10.8", + "resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.10.8.tgz", + "integrity": "sha512-VbzbVGSsZlQktyLrP5nxE+vE1ZR+U0NFAWPbJLoG2+DKPwd2D7dVICTVIIaYlJqX1ZCEnYDbaOpmMwbsyhBoIA==", + "requires": { + "@tanstack/virtual-core": "3.10.8" + } + }, + "@tanstack/virtual-core": { + "version": "3.10.8", + "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.10.8.tgz", + "integrity": "sha512-PBu00mtt95jbKFi6Llk9aik8bnR3tR/oQP1o3TSi+iG//+Q2RTIzCEgKkHG8BB86kxMNW6O8wku+Lmi+QFR6jA==" + }, "@tootallnate/once": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.0.tgz", diff --git a/package.json b/package.json index 4c50b8e9ca..7d9687cc57 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/common-elements/panels.ts b/src/common-elements/panels.ts index cacdb685c5..7ce949a6a9 100644 --- a/src/common-elements/panels.ts +++ b/src/common-elements/panels.ts @@ -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` diff --git a/src/components/Redoc/Redoc.tsx b/src/components/Redoc/Redoc.tsx index a3d0eef7cd..bc12a84d18 100644 --- a/src/components/Redoc/Redoc.tsx +++ b/src/components/Redoc/Redoc.tsx @@ -5,15 +5,15 @@ 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'; export interface RedocProps { store: AppStore; @@ -56,8 +56,7 @@ export class Redoc extends React.Component { - - + diff --git a/src/components/Virtualization/VirtualizedContent.tsx b/src/components/Virtualization/VirtualizedContent.tsx new file mode 100644 index 0000000000..bdf8f86f05 --- /dev/null +++ b/src/components/Virtualization/VirtualizedContent.tsx @@ -0,0 +1,91 @@ +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; +}; + +/** + * 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. + * + * @author Audi + */ +const VirtualizedContent = ({ store, menu }: VirtualizedContentProps) => { + const scrollableRef = React.useRef(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: () => 1000, + }); + + 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 ( +
+ +
+ {virtualizer.getVirtualItems().map(virtualItem => ( +
+ +
+ ))} +
+
+ ); +}; + +export default VirtualizedContent; diff --git a/src/components/Virtualization/useItemReverseIndex.tsx b/src/components/Virtualization/useItemReverseIndex.tsx new file mode 100644 index 0000000000..74af438081 --- /dev/null +++ b/src/components/Virtualization/useItemReverseIndex.tsx @@ -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; diff --git a/src/components/Virtualization/useSelectedTag.tsx b/src/components/Virtualization/useSelectedTag.tsx new file mode 100644 index 0000000000..25eafcc9e3 --- /dev/null +++ b/src/components/Virtualization/useSelectedTag.tsx @@ -0,0 +1,40 @@ +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) => { + return hash.substring(1, hash.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); + if (redocTag !== selectedTag) { + setSelectedTag(redocTag); + } + }, 100); + + return () => { + clearInterval(hashCheckInterval); + }; + }, [selectedTag]); + + return selectedTag; +}; + +export default useSelectedTag; diff --git a/src/components/__tests__/__snapshots__/FieldDetails.test.tsx.snap b/src/components/__tests__/__snapshots__/FieldDetails.test.tsx.snap index a8b1a44fac..1e85bbb3f4 100644 --- a/src/components/__tests__/__snapshots__/FieldDetails.test.tsx.snap +++ b/src/components/__tests__/__snapshots__/FieldDetails.test.tsx.snap @@ -56,7 +56,7 @@ exports[`FieldDetailsComponent renders correctly 1`] = `

test description @@ -122,7 +122,7 @@ exports[`FieldDetailsComponent renders correctly when default value is object in

test description @@ -186,7 +186,7 @@ exports[`FieldDetailsComponent renders correctly when field items have string ty

test description diff --git a/src/components/__tests__/__snapshots__/SecurityRequirement.test.tsx.snap b/src/components/__tests__/__snapshots__/SecurityRequirement.test.tsx.snap index c76566e5c3..daa2fedd47 100644 --- a/src/components/__tests__/__snapshots__/SecurityRequirement.test.tsx.snap +++ b/src/components/__tests__/__snapshots__/SecurityRequirement.test.tsx.snap @@ -1,23 +1,23 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`SecurityRequirement should render SecurityDefs 1`] = ` -"

petstore_auth

Get access to data while protecting your account credentials. +"

petstore_auth

Get access to data while protecting your account credentials. OAuth2 is also a safer and more secure way to give you access.

-
Security Scheme Type: OAuth2
Flow type: implicit
Scopes:
  • write:pets -

    modify pets in your account

    -
  • read:pets -

    read your pets

    -

GitLab_PersonalAccessToken

GitLab Personal Access Token description

-
Security Scheme Type: API Key
Header parameter name: PRIVATE-TOKEN

GitLab_OpenIdConnect

GitLab OpenIdConnect description

-
Security Scheme Type: OpenID Connect

basicAuth

Security Scheme Type: HTTP
HTTP Authorization Scheme: basic
" +
Security Scheme Type: OAuth2
Flow type: implicit
Scopes:
  • write:pets -

    modify pets in your account

    +
  • read:pets -

    read your pets

    +

GitLab_PersonalAccessToken

GitLab Personal Access Token description

+
Security Scheme Type: API Key
Header parameter name: PRIVATE-TOKEN

GitLab_OpenIdConnect

GitLab OpenIdConnect description

+
Security Scheme Type: OpenID Connect

basicAuth

Security Scheme Type: HTTP
HTTP Authorization Scheme: basic
" `; -exports[`SecurityRequirement should render authDefinition 1`] = `"
Authorizations:
(API Key: GitLab_PersonalAccessTokenOpenID Connect: GitLab_OpenIdConnectHTTP: basicAuth) OAuth2: petstore_auth
,"`; +exports[`SecurityRequirement should render authDefinition 1`] = `"
Authorizations:
(API Key: GitLab_PersonalAccessTokenOpenID Connect: GitLab_OpenIdConnectHTTP: basicAuth) OAuth2: petstore_auth
,"`; exports[`SecurityRequirement should render authDefinition 2`] = ` -"
Authorizations:
(API Key: GitLab_PersonalAccessTokenOpenID Connect: GitLab_OpenIdConnectHTTP: basicAuth) OAuth2: petstore_auth (write:petsread:pets)
OAuth2: petstore_auth

Get access to data while protecting your account credentials. +"

Authorizations:
(API Key: GitLab_PersonalAccessTokenOpenID Connect: GitLab_OpenIdConnectHTTP: basicAuth) OAuth2: petstore_auth (write:petsread:pets)
OAuth2: petstore_auth

Get access to data while protecting your account credentials. OAuth2 is also a safer and more secure way to give you access.

-
Flow type: implicit
Required scopes: write:pets read:pets
Scopes:
  • write:pets -

    modify pets in your account

    -
  • read:pets -

    read your pets

    -
API Key: GitLab_PersonalAccessToken

GitLab Personal Access Token description

-
Header parameter name: PRIVATE-TOKEN
OpenID Connect: GitLab_OpenIdConnect

GitLab OpenIdConnect description

-
HTTP: basicAuth
HTTP Authorization Scheme: basic
," +
Flow type: implicit
Required scopes: write:pets read:pets
Scopes:
  • write:pets -

    modify pets in your account

    +
  • read:pets -

    read your pets

    +
API Key: GitLab_PersonalAccessToken

GitLab Personal Access Token description

+
Header parameter name: PRIVATE-TOKEN
OpenID Connect: GitLab_OpenIdConnect

GitLab OpenIdConnect description

+
HTTP: basicAuth
HTTP Authorization Scheme: basic
," `; diff --git a/src/components/__tests__/useItemReverseIndex.test.tsx b/src/components/__tests__/useItemReverseIndex.test.tsx new file mode 100644 index 0000000000..b15ad2b4d1 --- /dev/null +++ b/src/components/__tests__/useItemReverseIndex.test.tsx @@ -0,0 +1,100 @@ +import * as React from 'react'; +import * as ReactDOM from 'react-dom/client'; +import { IMenuItem } from '../..'; +import useItemReverseIndex, { + MenuItemReverseIndexToVirtualIndex, +} from '../Virtualization/useItemReverseIndex'; + +(global as any).IS_REACT_ACT_ENVIRONMENT = true; + +describe('useItemReverseIndex', () => { + let container: HTMLDivElement; + let root: ReactDOM.Root; + + beforeEach(() => { + container = document.createElement('div'); + document.body.appendChild(container); + root = ReactDOM.createRoot(container); + }); + + afterEach(() => { + React.act(() => root.unmount()); + document.body.removeChild(container); + container = null as any; + }); + + it('it should maps item based on the id for quick lookup', () => { + const menuItems: any[] = [ + { id: 'item1', name: 'Item 1' }, + { id: 'item2', name: 'Item 2' }, + { id: 'item3', name: 'Item 3' }, + ]; + + let result: MenuItemReverseIndexToVirtualIndex | undefined; + + // Hooks can only be used inside a React component. + // This component is just to host the hook. + const TestComponent = ({ items }: { items: IMenuItem[] }) => { + const { reverseIndexToVirtualIndex } = useItemReverseIndex(items); + result = reverseIndexToVirtualIndex; // this is to capture the hook's output for later's assertions + return null; + }; + + // Render component within act to handle React state updates + React.act(() => { + root.render(); + }); + + const expectedMapping: MenuItemReverseIndexToVirtualIndex = { + item1: 0, + item2: 1, + item3: 2, + }; + + expect(result).toEqual(expectedMapping); + }); + + // Note: the test below only tests when the items change in quantity. + // This is because it is very unlikely for an API docs to change in the first-place, + // so just-in-case, I only allow it to re-render when the items change in quantity. + it('should update the mapping when menu items change in quantity', () => { + let result: MenuItemReverseIndexToVirtualIndex | undefined; + + const initialItems: any[] = [ + { id: 'item1', description: 'Item 1' }, + { id: 'item2', description: 'Item 2' }, + ]; + + const newItems: any[] = [ + { id: 'newItem1', description: 'New Item 1' }, + { id: 'newItem2', description: 'New Item 2' }, + { id: 'newItem3', description: 'New Item 3' }, + ]; + + // Hooks can only be used inside a React component. + // This component is just to host the hook. + const TestComponent = ({ items }: { items: IMenuItem[] }) => { + const { reverseIndexToVirtualIndex } = useItemReverseIndex(items); + result = reverseIndexToVirtualIndex; + return null; + }; + + // Initial render + React.act(() => { + root.render(); + }); + + // Update render with new items + React.act(() => { + root.render(); + }); + + const expectedNewMapping: MenuItemReverseIndexToVirtualIndex = { + newItem1: 0, + newItem2: 1, + newItem3: 2, + }; + + expect(result).toEqual(expectedNewMapping); + }); +}); diff --git a/src/components/__tests__/useSelectedTag.test.tsx b/src/components/__tests__/useSelectedTag.test.tsx new file mode 100644 index 0000000000..e4c47f965f --- /dev/null +++ b/src/components/__tests__/useSelectedTag.test.tsx @@ -0,0 +1,134 @@ +import * as React from 'react'; +import * as ReactDOM from 'react-dom/client'; +import useSelectedTag from '../Virtualization/useSelectedTag'; + +(global as any).IS_REACT_ACT_ENVIRONMENT = true; + +jest.useFakeTimers(); + +describe('useSelectedTag', () => { + let container: HTMLDivElement; + let root: ReactDOM.Root; + + beforeEach(() => { + container = document.createElement('div'); + document.body.appendChild(container); + root = ReactDOM.createRoot(container); + }); + + afterEach(() => { + React.act(() => root.unmount()); + document.body.removeChild(container); + container = null as any; + jest.clearAllTimers(); + }); + + it('should return the correct tag based on the hash in the URL', async () => { + window.location.hash = '#tag/product-namespace-verb'; + + let selectedTag: string | undefined; + + // Hooks can only be used inside a React component. + // This component is just to host the hook. + const TestComponent = () => { + selectedTag = useSelectedTag(); + return

test

; + }; + + React.act(() => { + root.render(); + }); + + await React.act(async () => { + jest.advanceTimersByTime(100); + }); + + expect(selectedTag).toBe('tag/product-namespace-verb'); + }); + + it('resetting a tag will also reset the selected tag from the hook', async () => { + let selectedTag: string | undefined; + + // Hooks can only be used inside a React component. + // This component is just to host the hook. + const TestComponent = () => { + selectedTag = useSelectedTag(); + return

test

; + }; + + React.act(() => { + root.render(); + }); + + expect(selectedTag).toBe(''); + + window.location.hash = '#tag/product-namespace-verb'; + await React.act(async () => { + jest.advanceTimersByTime(100); + }); + expect(selectedTag).toBe('tag/product-namespace-verb'); + + window.location.hash = ''; + await React.act(async () => { + jest.advanceTimersByTime(100); + }); + expect(selectedTag).toBe(''); + }); + + it('should update the selected tag when hash changes in the URL', async () => { + window.location.hash = '#tag/product-namespace-verb'; + + let selectedTag: string | undefined; + + // Hooks can only be used inside a React component. + // This component is just to host the hook. + const TestComponent = () => { + selectedTag = useSelectedTag(); + return null; + }; + + React.act(() => { + root.render(); + }); + + await React.act(async () => { + jest.advanceTimersByTime(100); + }); + expect(selectedTag).toBe('tag/product-namespace-verb'); + + window.location.hash = + '#tag/product-namespace-verb/operation/product-namespace-verb_OperationID'; + await React.act(async () => { + jest.advanceTimersByTime(100); + }); + expect(selectedTag).toBe( + 'tag/product-namespace-verb/operation/product-namespace-verb_OperationID', + ); + }); + + it('should clear the interval on component unmount', () => { + const clearIntervalSpy = jest.spyOn(window, 'clearInterval'); + + // Hooks can only be used inside a React component. + // This component is just to host the hook. + const TestComponent = () => { + useSelectedTag(); + return null; + }; + + React.act(() => { + root.render(); + }); + + // Ensure the component is mounted + expect(clearIntervalSpy).not.toHaveBeenCalled(); + + // Unmount the component + React.act(() => { + root.unmount(); + }); + + expect(clearIntervalSpy).toHaveBeenCalledTimes(1); + clearIntervalSpy.mockRestore(); + }); +}); From 80509a8cebdac9f266d76a250db0d4af423f808c Mon Sep 17 00:00:00 2001 From: Putu Audi Pasuatmadi <63685606+audipasuatmadi@users.noreply.github.com> Date: Tue, 26 Nov 2024 13:59:10 +0800 Subject: [PATCH 2/4] feat: make virtualization configurable where the default is not using virtualization --- docs/config.md | 6 ++++++ src/components/Redoc/Redoc.tsx | 11 ++++++++++- .../__snapshots__/DiscriminatorDropdown.test.tsx.snap | 10 ++++++++++ src/services/RedocNormalizedOptions.ts | 5 +++++ 4 files changed, 31 insertions(+), 1 deletion(-) diff --git a/docs/config.md b/docs/config.md index 28e24f7eda..1648e95824 100644 --- a/docs/config.md +++ b/docs/config.md @@ -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` diff --git a/src/components/Redoc/Redoc.tsx b/src/components/Redoc/Redoc.tsx index bc12a84d18..483860fa77 100644 --- a/src/components/Redoc/Redoc.tsx +++ b/src/components/Redoc/Redoc.tsx @@ -14,6 +14,8 @@ 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; @@ -56,7 +58,14 @@ export class Redoc extends React.Component { - + {options.enableVirtualization ? ( + + ) : ( + <> + + + + )} diff --git a/src/components/__tests__/__snapshots__/DiscriminatorDropdown.test.tsx.snap b/src/components/__tests__/__snapshots__/DiscriminatorDropdown.test.tsx.snap index 488199829b..49d0986ef2 100644 --- a/src/components/__tests__/__snapshots__/DiscriminatorDropdown.test.tsx.snap +++ b/src/components/__tests__/__snapshots__/DiscriminatorDropdown.test.tsx.snap @@ -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": {}, @@ -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": {}, @@ -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": {}, @@ -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": {}, @@ -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": {}, @@ -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": {}, @@ -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": {}, @@ -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": {}, @@ -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": {}, @@ -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": {}, diff --git a/src/services/RedocNormalizedOptions.ts b/src/services/RedocNormalizedOptions.ts index 0cdd7f9e2a..bdacc0878b 100644 --- a/src/services/RedocNormalizedOptions.ts +++ b/src/services/RedocNormalizedOptions.ts @@ -57,6 +57,8 @@ export interface RedocRawOptions { hideFab?: boolean; minCharacterLengthToInitSearch?: number; showWebhookVerb?: boolean; + + enableVirtualization?: boolean | string; } export function argValueToBoolean(val?: string | boolean, defaultValue?: boolean): boolean { @@ -259,6 +261,8 @@ export class RedocNormalizedOptions { minCharacterLengthToInitSearch: number; showWebhookVerb: boolean; + enableVirtualization: boolean; + nonce?: string; constructor(raw: RedocRawOptions, defaults: RedocRawOptions = {}) { @@ -338,5 +342,6 @@ export class RedocNormalizedOptions { this.hideFab = argValueToBoolean(raw.hideFab); this.minCharacterLengthToInitSearch = argValueToNumber(raw.minCharacterLengthToInitSearch) || 3; this.showWebhookVerb = argValueToBoolean(raw.showWebhookVerb); + this.enableVirtualization = argValueToBoolean(raw.enableVirtualization); } } From 894224544ce2fdad9324c7ddcf4c0cdc53b55e10 Mon Sep 17 00:00:00 2001 From: Putu Audi Pasuatmadi Date: Tue, 26 Nov 2024 14:19:18 +0800 Subject: [PATCH 3/4] fix: decode uri component from hash to match the id and data-section-id of the api endpoint --- .../Virtualization/useSelectedTag.tsx | 4 +++- .../__tests__/useSelectedTag.test.tsx | 23 +++++++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/src/components/Virtualization/useSelectedTag.tsx b/src/components/Virtualization/useSelectedTag.tsx index 25eafcc9e3..140252fba8 100644 --- a/src/components/Virtualization/useSelectedTag.tsx +++ b/src/components/Virtualization/useSelectedTag.tsx @@ -9,7 +9,8 @@ import * as React from 'react'; * transforms "#tag/myendpoint" into "tag/myendpoint". */ export const toRedocTag = (hash: string) => { - return hash.substring(1, hash.length); + const decodedHash = decodeURIComponent(hash); + return decodedHash.substring(1, decodedHash.length); }; /** @@ -24,6 +25,7 @@ const useSelectedTag = () => { React.useEffect(() => { const hashCheckInterval = setInterval(() => { const redocTag = toRedocTag(window.location.hash); + console.log(redocTag); if (redocTag !== selectedTag) { setSelectedTag(redocTag); } diff --git a/src/components/__tests__/useSelectedTag.test.tsx b/src/components/__tests__/useSelectedTag.test.tsx index e4c47f965f..6548aabc4f 100644 --- a/src/components/__tests__/useSelectedTag.test.tsx +++ b/src/components/__tests__/useSelectedTag.test.tsx @@ -46,6 +46,29 @@ describe('useSelectedTag', () => { expect(selectedTag).toBe('tag/product-namespace-verb'); }); + it('uri encoded tags are decoded to match the tags in the actual html id and data-section-id', async () => { + window.location.hash = '#tag/Notes/paths/~1notes~1%7Bid%7D/get'; + + let selectedTag: string | undefined; + + // Hooks can only be used inside a React component. + // This component is just to host the hook. + const TestComponent = () => { + selectedTag = useSelectedTag(); + return

test

; + }; + + React.act(() => { + root.render(); + }); + + await React.act(async () => { + jest.advanceTimersByTime(100); + }); + + expect(selectedTag).toBe('tag/Notes/paths/~1notes~1{id}/get'); + }); + it('resetting a tag will also reset the selected tag from the hook', async () => { let selectedTag: string | undefined; From 43e1b3df0c38143057a7680170874a5d94dd7fd8 Mon Sep 17 00:00:00 2001 From: Putu Audi Pasuatmadi Date: Tue, 26 Nov 2024 14:41:56 +0800 Subject: [PATCH 4/4] fix: remove magic numbers --- src/components/Virtualization/VirtualizedContent.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/components/Virtualization/VirtualizedContent.tsx b/src/components/Virtualization/VirtualizedContent.tsx index bdf8f86f05..ed9a2a965e 100644 --- a/src/components/Virtualization/VirtualizedContent.tsx +++ b/src/components/Virtualization/VirtualizedContent.tsx @@ -9,6 +9,8 @@ type VirtualizedContentProps = { menu: MenuStore; }; +const ESTIMATED_EACH_API_HEIGHT_PX = 1000; + /** * VirtualizedContent optimizes the rendering of API documentation in Redoc by virtualizing the content. * @@ -21,7 +23,6 @@ type VirtualizedContentProps = { * 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. * - * @author Audi */ const VirtualizedContent = ({ store, menu }: VirtualizedContentProps) => { const scrollableRef = React.useRef(null); @@ -34,7 +35,7 @@ const VirtualizedContent = ({ store, menu }: VirtualizedContentProps) => { const virtualizer = useVirtualizer({ count: renderables.length, getScrollElement: () => scrollableRef.current!, - estimateSize: () => 1000, + estimateSize: () => ESTIMATED_EACH_API_HEIGHT_PX, }); const selectedTag = useSelectedTag();