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 <audipasuatmadi@users.noreply.github.com> --- 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<RedocProps> { <SideMenu menu={menu} /> </StickyResponsiveSidebar> <ApiContentWrap className="api-content"> - <ApiInfo store={store} /> - <ContentItems items={menu.items as any} /> + <VirtualizedContent store={store} menu={menu} /> </ApiContentWrap> <BackgroundStub /> </RedocWrap> 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<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: () => 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 ( + <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; 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`] = ` </div> <div> <div - class="sc-lcIPJg sc-hknOHE gBHqkN jFBMaE" + class="sc-esYiGF sc-kAkpmW dZoiJx gPTOUI" > <p> test description @@ -122,7 +122,7 @@ exports[`FieldDetailsComponent renders correctly when default value is object in </div> <div> <div - class="sc-lcIPJg sc-hknOHE gBHqkN jFBMaE" + class="sc-esYiGF sc-kAkpmW dZoiJx gPTOUI" > <p> test description @@ -186,7 +186,7 @@ exports[`FieldDetailsComponent renders correctly when field items have string ty </div> <div> <div - class="sc-lcIPJg sc-hknOHE gBHqkN jFBMaE" + class="sc-esYiGF sc-kAkpmW dZoiJx gPTOUI" > <p> 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`] = ` -"<div id="section/Authentication/petstore_auth" data-section-id="section/Authentication/petstore_auth" class="sc-dcJsrY bBkGhy"><div class="sc-kAyceB hBQWIZ"><div class="sc-fqkvVR oJKYx"><h2 class="sc-jXbUNg fWnwAh">petstore_auth</h2><div class="sc-eeDRCY sc-eBMEME gTGgei fMmru"><p>Get access to data while protecting your account credentials. +"<div id="section/Authentication/petstore_auth" data-section-id="section/Authentication/petstore_auth" class="sc-jXbUNg fiwqHP"><div class="sc-dAlyuH jJfjeH"><div class="sc-imWYAI euMmdx"><h2 class="sc-cwHptR ijJbCP">petstore_auth</h2><div class="sc-iHGNWf sc-hRJfrW kIgucL kKrqUK"><p>Get access to data while protecting your account credentials. OAuth2 is also a safer and more secure way to give you access.</p> -</div><div class="sc-ejfMa-d a-DjBE"><div class="sc-dkmUuB hFwAIA"><b>Security Scheme Type: </b><span>OAuth2</span></div><div class="sc-eeDRCY sc-eBMEME gTGgei fMmru"><div class="sc-dkmUuB hFwAIA"><b>Flow type: </b><code>implicit </code></div><div class="sc-dkmUuB hFwAIA"><strong> Authorization URL: </strong><code><a target="_blank" rel="noopener noreferrer" href="http://petstore.swagger.io/api/oauth/dialog">http://petstore.swagger.io/api/oauth/dialog</a></code></div><div class="sc-dkmUuB hFwAIA"><b> Scopes: </b></div><div class="sc-iEXKAA blExNw container" style="height: 4em;"><ul><li><code>write:pets</code> - <div class="sc-eeDRCY sc-eBMEME gTGgei fMmru sc-fhzFiK hXtrri redoc-markdown"><p>modify pets in your account</p> -</div></li><li><code>read:pets</code> - <div class="sc-eeDRCY sc-eBMEME gTGgei fMmru sc-fhzFiK hXtrri redoc-markdown"><p>read your pets</p> -</div></li></ul></div><div class="sc-EgOXT bNSpXO"></div></div></div></div></div></div><div id="section/Authentication/GitLab_PersonalAccessToken" data-section-id="section/Authentication/GitLab_PersonalAccessToken" class="sc-dcJsrY bBkGhy"><div class="sc-kAyceB hBQWIZ"><div class="sc-fqkvVR oJKYx"><h2 class="sc-jXbUNg fWnwAh">GitLab_PersonalAccessToken</h2><div class="sc-eeDRCY sc-eBMEME gTGgei fMmru"><p>GitLab Personal Access Token description</p> -</div><div class="sc-ejfMa-d a-DjBE"><div class="sc-dkmUuB hFwAIA"><b>Security Scheme Type: </b><span>API Key</span></div><div class="sc-eeDRCY sc-eBMEME gTGgei fMmru"><div class="sc-dkmUuB hFwAIA"><b>Header parameter name: </b><code>PRIVATE-TOKEN</code></div></div></div></div></div></div><div id="section/Authentication/GitLab_OpenIdConnect" data-section-id="section/Authentication/GitLab_OpenIdConnect" class="sc-dcJsrY bBkGhy"><div class="sc-kAyceB hBQWIZ"><div class="sc-fqkvVR oJKYx"><h2 class="sc-jXbUNg fWnwAh">GitLab_OpenIdConnect</h2><div class="sc-eeDRCY sc-eBMEME gTGgei fMmru"><p>GitLab OpenIdConnect description</p> -</div><div class="sc-ejfMa-d a-DjBE"><div class="sc-dkmUuB hFwAIA"><b>Security Scheme Type: </b><span>OpenID Connect</span></div><div class="sc-eeDRCY sc-eBMEME gTGgei fMmru"><div class="sc-dkmUuB hFwAIA"><b>Connect URL: </b><code><a target="_blank" rel="noopener noreferrer" href="https://gitlab.com/.well-known/openid-configuration">https://gitlab.com/.well-known/openid-configuration</a></code></div></div></div></div></div></div><div id="section/Authentication/basicAuth" data-section-id="section/Authentication/basicAuth" class="sc-dcJsrY bBkGhy"><div class="sc-kAyceB hBQWIZ"><div class="sc-fqkvVR oJKYx"><h2 class="sc-jXbUNg fWnwAh">basicAuth</h2><div class="sc-eeDRCY sc-eBMEME gTGgei fMmru"></div><div class="sc-ejfMa-d a-DjBE"><div class="sc-dkmUuB hFwAIA"><b>Security Scheme Type: </b><span>HTTP</span></div><div class="sc-eeDRCY sc-eBMEME gTGgei fMmru"><div class="sc-dkmUuB hFwAIA"><b>HTTP Authorization Scheme: </b><code>basic</code></div><div class="sc-dkmUuB hFwAIA"></div></div></div></div></div></div>" +</div><div class="sc-bVHCgj jeamQm"><div class="sc-bDpDS hgsvLV"><b>Security Scheme Type: </b><span>OAuth2</span></div><div class="sc-iHGNWf sc-hRJfrW kIgucL kKrqUK"><div class="sc-bDpDS hgsvLV"><b>Flow type: </b><code>implicit </code></div><div class="sc-bDpDS hgsvLV"><strong> Authorization URL: </strong><code><a target="_blank" rel="noopener noreferrer" href="http://petstore.swagger.io/api/oauth/dialog">http://petstore.swagger.io/api/oauth/dialog</a></code></div><div class="sc-bDpDS hgsvLV"><b> Scopes: </b></div><div class="sc-dSIIpw iEDmXP container" style="height: 4em;"><ul><li><code>write:pets</code> - <div class="sc-iHGNWf sc-hRJfrW kIgucL kKrqUK sc-klVQfs cfshkX redoc-markdown"><p>modify pets in your account</p> +</div></li><li><code>read:pets</code> - <div class="sc-iHGNWf sc-hRJfrW kIgucL kKrqUK sc-klVQfs cfshkX redoc-markdown"><p>read your pets</p> +</div></li></ul></div><div class="sc-fMMURN eKpvEA"></div></div></div></div></div></div><div id="section/Authentication/GitLab_PersonalAccessToken" data-section-id="section/Authentication/GitLab_PersonalAccessToken" class="sc-jXbUNg fiwqHP"><div class="sc-dAlyuH jJfjeH"><div class="sc-imWYAI euMmdx"><h2 class="sc-cwHptR ijJbCP">GitLab_PersonalAccessToken</h2><div class="sc-iHGNWf sc-hRJfrW kIgucL kKrqUK"><p>GitLab Personal Access Token description</p> +</div><div class="sc-bVHCgj jeamQm"><div class="sc-bDpDS hgsvLV"><b>Security Scheme Type: </b><span>API Key</span></div><div class="sc-iHGNWf sc-hRJfrW kIgucL kKrqUK"><div class="sc-bDpDS hgsvLV"><b>Header parameter name: </b><code>PRIVATE-TOKEN</code></div></div></div></div></div></div><div id="section/Authentication/GitLab_OpenIdConnect" data-section-id="section/Authentication/GitLab_OpenIdConnect" class="sc-jXbUNg fiwqHP"><div class="sc-dAlyuH jJfjeH"><div class="sc-imWYAI euMmdx"><h2 class="sc-cwHptR ijJbCP">GitLab_OpenIdConnect</h2><div class="sc-iHGNWf sc-hRJfrW kIgucL kKrqUK"><p>GitLab OpenIdConnect description</p> +</div><div class="sc-bVHCgj jeamQm"><div class="sc-bDpDS hgsvLV"><b>Security Scheme Type: </b><span>OpenID Connect</span></div><div class="sc-iHGNWf sc-hRJfrW kIgucL kKrqUK"><div class="sc-bDpDS hgsvLV"><b>Connect URL: </b><code><a target="_blank" rel="noopener noreferrer" href="https://gitlab.com/.well-known/openid-configuration">https://gitlab.com/.well-known/openid-configuration</a></code></div></div></div></div></div></div><div id="section/Authentication/basicAuth" data-section-id="section/Authentication/basicAuth" class="sc-jXbUNg fiwqHP"><div class="sc-dAlyuH jJfjeH"><div class="sc-imWYAI euMmdx"><h2 class="sc-cwHptR ijJbCP">basicAuth</h2><div class="sc-iHGNWf sc-hRJfrW kIgucL kKrqUK"></div><div class="sc-bVHCgj jeamQm"><div class="sc-bDpDS hgsvLV"><b>Security Scheme Type: </b><span>HTTP</span></div><div class="sc-iHGNWf sc-hRJfrW kIgucL kKrqUK"><div class="sc-bDpDS hgsvLV"><b>HTTP Authorization Scheme: </b><code>basic</code></div><div class="sc-bDpDS hgsvLV"></div></div></div></div></div></div>" `; -exports[`SecurityRequirement should render authDefinition 1`] = `"<div class="sc-bDumWk iWBBny"><div class="sc-sLsrZ hgeUJn"><h5 class="sc-dAlyuH sc-fifgRP jbQuod kWJur">Authorizations:</h5><svg class="sc-cwHptR iZRiKW" version="1.1" viewBox="0 0 24 24" x="0" xmlns="http://www.w3.org/2000/svg" y="0" aria-hidden="true"><polygon points="17.3 8.3 12 13.6 6.7 8.3 5.3 9.7 12 16.4 18.7 9.7 "></polygon></svg></div><div class="sc-dBmzty eoFcYg"><span class="sc-kbousE cpXQuZ">(<span class="sc-gfoqjT kbvnry">API Key: <i>GitLab_PersonalAccessToken</i></span><span class="sc-gfoqjT kbvnry">OpenID Connect: <i>GitLab_OpenIdConnect</i></span><span class="sc-gfoqjT kbvnry">HTTP: <i>basicAuth</i></span>) </span><span class="sc-kbousE cpXQuZ"><span class="sc-gfoqjT kbvnry">OAuth2: <i>petstore_auth</i></span></span></div></div>,"`; +exports[`SecurityRequirement should render authDefinition 1`] = `"<div class="sc-kzqdkY iBGSSi"><div class="sc-kWtpeL btBUKm"><h5 class="sc-dLMFU sc-gdyeKB lcezuG gSXcBp">Authorizations:</h5><svg class="sc-gsFSXq cOXSQA" version="1.1" viewBox="0 0 24 24" x="0" xmlns="http://www.w3.org/2000/svg" y="0" aria-hidden="true"><polygon points="17.3 8.3 12 13.6 6.7 8.3 5.3 9.7 12 16.4 18.7 9.7 "></polygon></svg></div><div class="sc-ecPEgm cOTqGl"><span class="sc-hHOBiw jggLOL">(<span class="sc-dlWCHZ dCGAkl">API Key: <i>GitLab_PersonalAccessToken</i></span><span class="sc-dlWCHZ dCGAkl">OpenID Connect: <i>GitLab_OpenIdConnect</i></span><span class="sc-dlWCHZ dCGAkl">HTTP: <i>basicAuth</i></span>) </span><span class="sc-hHOBiw jggLOL"><span class="sc-dlWCHZ dCGAkl">OAuth2: <i>petstore_auth</i></span></span></div></div>,"`; exports[`SecurityRequirement should render authDefinition 2`] = ` -"<div class="sc-bDumWk gtsPcy"><div class="sc-sLsrZ hgeUJn"><h5 class="sc-dAlyuH sc-fifgRP jbQuod kWJur">Authorizations:</h5><svg class="sc-cwHptR dSJqIk" version="1.1" viewBox="0 0 24 24" x="0" xmlns="http://www.w3.org/2000/svg" y="0" aria-hidden="true"><polygon points="17.3 8.3 12 13.6 6.7 8.3 5.3 9.7 12 16.4 18.7 9.7 "></polygon></svg></div><div class="sc-dBmzty llvZdI"><span class="sc-kbousE dOwJQz">(<span class="sc-gfoqjT kbvnry">API Key: <i>GitLab_PersonalAccessToken</i></span><span class="sc-gfoqjT kbvnry">OpenID Connect: <i>GitLab_OpenIdConnect</i></span><span class="sc-gfoqjT kbvnry">HTTP: <i>basicAuth</i></span>) </span><span class="sc-kbousE dOwJQz"><span class="sc-gfoqjT kbvnry">OAuth2: <i>petstore_auth</i> (<code class="sc-eyvILC bzHwfc">write:pets</code><code class="sc-eyvILC bzHwfc">read:pets</code>) </span></span></div></div><div class="sc-ejfMa-d a-DjBE"><h5><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="11" height="11"><path fill="currentColor" d="M18 10V6A6 6 0 0 0 6 6v4H3v14h18V10h-3zM8 6c0-2.206 1.794-4 4-4s4 1.794 4 4v4H8V6zm11 16H5V12h14v10z"></path></svg> OAuth2: petstore_auth</h5><div class="sc-eeDRCY sc-eBMEME gTGgei fMmru"><p>Get access to data while protecting your account credentials. +"<div class="sc-kzqdkY ftTbsm"><div class="sc-kWtpeL btBUKm"><h5 class="sc-dLMFU sc-gdyeKB lcezuG gSXcBp">Authorizations:</h5><svg class="sc-gsFSXq eWnmLm" version="1.1" viewBox="0 0 24 24" x="0" xmlns="http://www.w3.org/2000/svg" y="0" aria-hidden="true"><polygon points="17.3 8.3 12 13.6 6.7 8.3 5.3 9.7 12 16.4 18.7 9.7 "></polygon></svg></div><div class="sc-ecPEgm fYRInh"><span class="sc-hHOBiw bWBltB">(<span class="sc-dlWCHZ dCGAkl">API Key: <i>GitLab_PersonalAccessToken</i></span><span class="sc-dlWCHZ dCGAkl">OpenID Connect: <i>GitLab_OpenIdConnect</i></span><span class="sc-dlWCHZ dCGAkl">HTTP: <i>basicAuth</i></span>) </span><span class="sc-hHOBiw bWBltB"><span class="sc-dlWCHZ dCGAkl">OAuth2: <i>petstore_auth</i> (<code class="sc-eZYNyq eZCLxU">write:pets</code><code class="sc-eZYNyq eZCLxU">read:pets</code>) </span></span></div></div><div class="sc-bVHCgj jeamQm"><h5><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="11" height="11"><path fill="currentColor" d="M18 10V6A6 6 0 0 0 6 6v4H3v14h18V10h-3zM8 6c0-2.206 1.794-4 4-4s4 1.794 4 4v4H8V6zm11 16H5V12h14v10z"></path></svg> OAuth2: petstore_auth</h5><div class="sc-iHGNWf sc-hRJfrW kIgucL kKrqUK"><p>Get access to data while protecting your account credentials. OAuth2 is also a safer and more secure way to give you access.</p> -</div><div class="sc-eeDRCY sc-eBMEME gTGgei fMmru"><div class="sc-dkmUuB hFwAIA"><b>Flow type: </b><code>implicit </code></div><div class="sc-dkmUuB hFwAIA"><strong> Authorization URL: </strong><code><a target="_blank" rel="noopener noreferrer" href="http://petstore.swagger.io/api/oauth/dialog">http://petstore.swagger.io/api/oauth/dialog</a></code></div><div><b>Required scopes: </b><code>write:pets</code> <code>read:pets</code> </div><div class="sc-dkmUuB hFwAIA"><b> Scopes: </b></div><div class="sc-iEXKAA blExNw container" style="height: 4em;"><ul><li><code>write:pets</code> - <div class="sc-eeDRCY sc-eBMEME gTGgei fMmru sc-fhzFiK hXtrri redoc-markdown"><p>modify pets in your account</p> -</div></li><li><code>read:pets</code> - <div class="sc-eeDRCY sc-eBMEME gTGgei fMmru sc-fhzFiK hXtrri redoc-markdown"><p>read your pets</p> -</div></li></ul></div><div class="sc-EgOXT bNSpXO"></div></div></div><div class="sc-ejfMa-d a-DjBE"><h5><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="11" height="11"><path fill="currentColor" d="M18 10V6A6 6 0 0 0 6 6v4H3v14h18V10h-3zM8 6c0-2.206 1.794-4 4-4s4 1.794 4 4v4H8V6zm11 16H5V12h14v10z"></path></svg> API Key: GitLab_PersonalAccessToken</h5><div class="sc-eeDRCY sc-eBMEME gTGgei fMmru"><p>GitLab Personal Access Token description</p> -</div><div class="sc-eeDRCY sc-eBMEME gTGgei fMmru"><div class="sc-dkmUuB hFwAIA"><b>Header parameter name: </b><code>PRIVATE-TOKEN</code></div></div></div><div class="sc-ejfMa-d a-DjBE"><h5><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="11" height="11"><path fill="currentColor" d="M18 10V6A6 6 0 0 0 6 6v4H3v14h18V10h-3zM8 6c0-2.206 1.794-4 4-4s4 1.794 4 4v4H8V6zm11 16H5V12h14v10z"></path></svg> OpenID Connect: GitLab_OpenIdConnect</h5><div class="sc-eeDRCY sc-eBMEME gTGgei fMmru"><p>GitLab OpenIdConnect description</p> -</div><div class="sc-eeDRCY sc-eBMEME gTGgei fMmru"><div class="sc-dkmUuB hFwAIA"><b>Connect URL: </b><code><a target="_blank" rel="noopener noreferrer" href="https://gitlab.com/.well-known/openid-configuration">https://gitlab.com/.well-known/openid-configuration</a></code></div></div></div><div class="sc-ejfMa-d a-DjBE"><h5><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="11" height="11"><path fill="currentColor" d="M18 10V6A6 6 0 0 0 6 6v4H3v14h18V10h-3zM8 6c0-2.206 1.794-4 4-4s4 1.794 4 4v4H8V6zm11 16H5V12h14v10z"></path></svg> HTTP: basicAuth</h5><div class="sc-eeDRCY sc-eBMEME gTGgei fMmru"></div><div class="sc-eeDRCY sc-eBMEME gTGgei fMmru"><div class="sc-dkmUuB hFwAIA"><b>HTTP Authorization Scheme: </b><code>basic</code></div><div class="sc-dkmUuB hFwAIA"></div></div></div>," +</div><div class="sc-iHGNWf sc-hRJfrW kIgucL kKrqUK"><div class="sc-bDpDS hgsvLV"><b>Flow type: </b><code>implicit </code></div><div class="sc-bDpDS hgsvLV"><strong> Authorization URL: </strong><code><a target="_blank" rel="noopener noreferrer" href="http://petstore.swagger.io/api/oauth/dialog">http://petstore.swagger.io/api/oauth/dialog</a></code></div><div><b>Required scopes: </b><code>write:pets</code> <code>read:pets</code> </div><div class="sc-bDpDS hgsvLV"><b> Scopes: </b></div><div class="sc-dSIIpw iEDmXP container" style="height: 4em;"><ul><li><code>write:pets</code> - <div class="sc-iHGNWf sc-hRJfrW kIgucL kKrqUK sc-klVQfs cfshkX redoc-markdown"><p>modify pets in your account</p> +</div></li><li><code>read:pets</code> - <div class="sc-iHGNWf sc-hRJfrW kIgucL kKrqUK sc-klVQfs cfshkX redoc-markdown"><p>read your pets</p> +</div></li></ul></div><div class="sc-fMMURN eKpvEA"></div></div></div><div class="sc-bVHCgj jeamQm"><h5><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="11" height="11"><path fill="currentColor" d="M18 10V6A6 6 0 0 0 6 6v4H3v14h18V10h-3zM8 6c0-2.206 1.794-4 4-4s4 1.794 4 4v4H8V6zm11 16H5V12h14v10z"></path></svg> API Key: GitLab_PersonalAccessToken</h5><div class="sc-iHGNWf sc-hRJfrW kIgucL kKrqUK"><p>GitLab Personal Access Token description</p> +</div><div class="sc-iHGNWf sc-hRJfrW kIgucL kKrqUK"><div class="sc-bDpDS hgsvLV"><b>Header parameter name: </b><code>PRIVATE-TOKEN</code></div></div></div><div class="sc-bVHCgj jeamQm"><h5><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="11" height="11"><path fill="currentColor" d="M18 10V6A6 6 0 0 0 6 6v4H3v14h18V10h-3zM8 6c0-2.206 1.794-4 4-4s4 1.794 4 4v4H8V6zm11 16H5V12h14v10z"></path></svg> OpenID Connect: GitLab_OpenIdConnect</h5><div class="sc-iHGNWf sc-hRJfrW kIgucL kKrqUK"><p>GitLab OpenIdConnect description</p> +</div><div class="sc-iHGNWf sc-hRJfrW kIgucL kKrqUK"><div class="sc-bDpDS hgsvLV"><b>Connect URL: </b><code><a target="_blank" rel="noopener noreferrer" href="https://gitlab.com/.well-known/openid-configuration">https://gitlab.com/.well-known/openid-configuration</a></code></div></div></div><div class="sc-bVHCgj jeamQm"><h5><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="11" height="11"><path fill="currentColor" d="M18 10V6A6 6 0 0 0 6 6v4H3v14h18V10h-3zM8 6c0-2.206 1.794-4 4-4s4 1.794 4 4v4H8V6zm11 16H5V12h14v10z"></path></svg> HTTP: basicAuth</h5><div class="sc-iHGNWf sc-hRJfrW kIgucL kKrqUK"></div><div class="sc-iHGNWf sc-hRJfrW kIgucL kKrqUK"><div class="sc-bDpDS hgsvLV"><b>HTTP Authorization Scheme: </b><code>basic</code></div><div class="sc-bDpDS hgsvLV"></div></div></div>," `; 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(<TestComponent items={menuItems} />); + }); + + 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(<TestComponent items={initialItems} />); + }); + + // Update render with new items + React.act(() => { + root.render(<TestComponent items={newItems} />); + }); + + 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 <p data-testid={selectedTag}>test</p>; + }; + + React.act(() => { + root.render(<TestComponent />); + }); + + 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 <p data-testid={selectedTag}>test</p>; + }; + + React.act(() => { + root.render(<TestComponent />); + }); + + 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(<TestComponent />); + }); + + 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(<TestComponent />); + }); + + // 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<RedocProps> { <SideMenu menu={menu} /> </StickyResponsiveSidebar> <ApiContentWrap className="api-content"> - <VirtualizedContent store={store} menu={menu} /> + {options.enableVirtualization ? ( + <VirtualizedContent store={store} menu={menu} /> + ) : ( + <> + <ApiInfo store={store} /> + <ContentItems items={menu.items as any} /> + </> + )} </ApiContentWrap> <BackgroundStub /> </RedocWrap> 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 <audipasuatmadi@users.noreply.github.com> 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 <p data-testid={selectedTag}>test</p>; + }; + + React.act(() => { + root.render(<TestComponent />); + }); + + 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 <audipasuatmadi@users.noreply.github.com> 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<HTMLDivElement>(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();