diff --git a/.changeset/good-files-enjoy.md b/.changeset/good-files-enjoy.md new file mode 100644 index 00000000000..f8adb1b7923 --- /dev/null +++ b/.changeset/good-files-enjoy.md @@ -0,0 +1,5 @@ +--- +'@graphiql/plugin-doc-explorer': minor +--- + +Virtualize the All Types list in the Docs pane when the schema contains more than 1000 elements diff --git a/packages/graphiql-plugin-doc-explorer/package.json b/packages/graphiql-plugin-doc-explorer/package.json index 3cb1ccb77f2..718645fc578 100644 --- a/packages/graphiql-plugin-doc-explorer/package.json +++ b/packages/graphiql-plugin-doc-explorer/package.json @@ -45,6 +45,7 @@ }, "dependencies": { "@headlessui/react": "^2.2", + "@tanstack/react-virtual": "^3.13.24", "react-compiler-runtime": "19.1.0-rc.1", "zustand": "^5" }, diff --git a/packages/graphiql-plugin-doc-explorer/src/components/__tests__/schema-documentation.spec.tsx b/packages/graphiql-plugin-doc-explorer/src/components/__tests__/schema-documentation.spec.tsx new file mode 100644 index 00000000000..b3dcb358dfb --- /dev/null +++ b/packages/graphiql-plugin-doc-explorer/src/components/__tests__/schema-documentation.spec.tsx @@ -0,0 +1,62 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { render } from '@testing-library/react'; +import { + GraphQLObjectType, + GraphQLSchema, + GraphQLString, + type GraphQLFieldConfigMap, +} from 'graphql'; +import { SchemaDocumentation } from '../schema-documentation'; +import { VirtualList } from '../virtual-list'; + +vi.mock('../virtual-list', () => ({ + VirtualList: vi.fn(() =>
), +})); + +const VirtualListMock = vi.mocked(VirtualList); + +function makeSchemaWithNTypes(typeCount: number): GraphQLSchema { + const userTypes = Array.from( + { length: typeCount }, + (_, i) => + new GraphQLObjectType({ + name: `UserType${i}`, + fields: { name: { type: GraphQLString } }, + }), + ); + const queryFields: GraphQLFieldConfigMap = {}; + for (const [i, t] of userTypes.entries()) { + queryFields[`field${i}`] = { type: t }; + } + return new GraphQLSchema({ + query: new GraphQLObjectType({ name: 'Query', fields: queryFields }), + types: userTypes, + }); +} + +describe('SchemaDocumentation', () => { + beforeEach(() => { + VirtualListMock.mockClear(); + }); + + it('renders VirtualList when there are more than 1000 types in allTypes', () => { + const schema = makeSchemaWithNTypes(1500); + render(); + + expect(VirtualListMock).toHaveBeenCalledTimes(1); + const items = VirtualListMock.mock.calls[0]![0].items as unknown[]; + expect(items.length).toBeGreaterThan(1000); + }); + + it('renders a plain list (not VirtualList) for small schemas', () => { + const schema = makeSchemaWithNTypes(5); + const { container } = render(); + + expect(VirtualListMock).not.toHaveBeenCalled(); + // Every UserType plus the built-in String scalar should render a TypeLink. + const typeLinks = container.querySelectorAll( + '.graphiql-doc-explorer-section--all-types .graphiql-doc-explorer-type-name', + ); + expect(typeLinks.length).toBeGreaterThanOrEqual(5); + }); +}); diff --git a/packages/graphiql-plugin-doc-explorer/src/components/__tests__/virtual-list.spec.tsx b/packages/graphiql-plugin-doc-explorer/src/components/__tests__/virtual-list.spec.tsx new file mode 100644 index 00000000000..ca53f08d7fe --- /dev/null +++ b/packages/graphiql-plugin-doc-explorer/src/components/__tests__/virtual-list.spec.tsx @@ -0,0 +1,38 @@ +import { describe, expect, it } from 'vitest'; +import { render } from '@testing-library/react'; +import { VirtualList } from '../virtual-list'; + +describe('VirtualList', () => { + it('renders every item via the fallback path when the scroll element has no layout (JSDOM)', () => { + const items = Array.from({ length: 5 }, (_, i) => `item-${i}`); + const { container } = render( + 36} + renderItem={item => {item}} + />, + ); + + const rendered = container.querySelectorAll('[data-testid="item"]'); + expect(rendered).toHaveLength(5); + expect(Array.from(rendered, el => el.textContent)).toEqual([ + 'item-0', + 'item-1', + 'item-2', + 'item-3', + 'item-4', + ]); + }); + + it('renders nothing and does not crash with an empty items array', () => { + const { container } = render( + 36} + renderItem={item => {String(item)}} + />, + ); + + expect(container.querySelectorAll('[data-testid="item"]')).toHaveLength(0); + }); +}); diff --git a/packages/graphiql-plugin-doc-explorer/src/components/doc-explorer.css b/packages/graphiql-plugin-doc-explorer/src/components/doc-explorer.css index 88178fa122d..31e69362228 100644 --- a/packages/graphiql-plugin-doc-explorer/src/components/doc-explorer.css +++ b/packages/graphiql-plugin-doc-explorer/src/components/doc-explorer.css @@ -1,3 +1,9 @@ +.graphiql-doc-explorer { + display: flex; + flex-direction: column; + height: 100%; +} + /* The header of the doc explorer */ .graphiql-doc-explorer-header { display: flex; @@ -31,6 +37,7 @@ position: absolute; right: 0; top: 0; + z-index: 2; &:focus-within { left: 0; @@ -87,6 +94,14 @@ a.graphiql-doc-explorer-back { } /* The contents of the currently active page in the doc explorer */ +.graphiql-doc-explorer-content { + display: flex; + flex: 1; + flex-direction: column; + min-height: 0; + overflow: auto; +} + .graphiql-doc-explorer-content > * { color: hsla(var(--color-neutral), var(--alpha-secondary)); margin-top: var(--px-20); diff --git a/packages/graphiql-plugin-doc-explorer/src/components/schema-documentation.tsx b/packages/graphiql-plugin-doc-explorer/src/components/schema-documentation.tsx index 280533644c7..343d087c489 100644 --- a/packages/graphiql-plugin-doc-explorer/src/components/schema-documentation.tsx +++ b/packages/graphiql-plugin-doc-explorer/src/components/schema-documentation.tsx @@ -3,6 +3,7 @@ import type { GraphQLSchema } from 'graphql'; import { MarkdownContent } from '@graphiql/react'; import { ExplorerSection } from './section'; import { TypeLink } from './type-link'; +import { VirtualList } from './virtual-list'; import './schema-documentation.css'; type SchemaDocumentationProps = { @@ -12,6 +13,8 @@ type SchemaDocumentationProps = { schema: GraphQLSchema; }; +const UNVIRTUALIZED_MAX_LENGTH = 1000; + export const SchemaDocumentation: FC = ({ schema, }) => { @@ -24,6 +27,11 @@ export const SchemaDocumentation: FC = ({ mutationType?.name, subscriptionType?.name, ]; + const allTypes = Object.values(typeMap).filter( + type => + !ignoreTypesInAllSchema.includes(type.name) && + !type.name.startsWith('__'), + ); return ( <> @@ -56,23 +64,22 @@ export const SchemaDocumentation: FC = ({
)} - -
- {Object.values(typeMap).map(type => { - if ( - ignoreTypesInAllSchema.includes(type.name) || - type.name.startsWith('__') - ) { - return null; - } - - return ( + + {allTypes.length > UNVIRTUALIZED_MAX_LENGTH ? ( + 23} + renderItem={type => } + /> + ) : ( +
+ {allTypes.map(type => (
- ); - })} -
+ ))} +
+ )}
); diff --git a/packages/graphiql-plugin-doc-explorer/src/components/section.css b/packages/graphiql-plugin-doc-explorer/src/components/section.css index 3cc72bb874a..d7022c59153 100644 --- a/packages/graphiql-plugin-doc-explorer/src/components/section.css +++ b/packages/graphiql-plugin-doc-explorer/src/components/section.css @@ -20,3 +20,18 @@ margin-top: var(--px-16); } } + +.graphiql-doc-explorer-section:last-child { + display: flex; + flex: 1; + flex-direction: column; + min-height: 0; +} + +.graphiql-doc-explorer-section:last-child + > .graphiql-doc-explorer-section-content { + display: flex; + flex: 1; + flex-direction: column; + min-height: 0; +} diff --git a/packages/graphiql-plugin-doc-explorer/src/components/section.tsx b/packages/graphiql-plugin-doc-explorer/src/components/section.tsx index c800c86428f..b3dea38d9fa 100644 --- a/packages/graphiql-plugin-doc-explorer/src/components/section.tsx +++ b/packages/graphiql-plugin-doc-explorer/src/components/section.tsx @@ -33,15 +33,23 @@ type ExplorerSectionProps = { | 'Deprecated Enum Values' | 'Directives' | 'All Schema Types'; + /** + * Optionally pass a classname for an ExplorerSection instance + */ + className?: string; }; export const ExplorerSection: FC = ({ title, children, + className = '', }) => { const Icon = TYPE_TO_ICON[title]; + const additionalClassName = className + ? ` ${className} graphiql-doc-explorer-section--${className}` + : ''; return ( -
+
{title} diff --git a/packages/graphiql-plugin-doc-explorer/src/components/type-documentation.tsx b/packages/graphiql-plugin-doc-explorer/src/components/type-documentation.tsx index 219ecf3488b..ec305994fec 100644 --- a/packages/graphiql-plugin-doc-explorer/src/components/type-documentation.tsx +++ b/packages/graphiql-plugin-doc-explorer/src/components/type-documentation.tsx @@ -219,11 +219,12 @@ const PossibleTypes: FC<{ type: GraphQLNamedType }> = ({ type }) => { if (!schema || !isAbstractType(type)) { return null; } + const possibleTypes = schema.getPossibleTypes(type); return ( - {schema.getPossibleTypes(type).map(possibleType => ( + {possibleTypes.map(possibleType => (
diff --git a/packages/graphiql-plugin-doc-explorer/src/components/virtual-list.tsx b/packages/graphiql-plugin-doc-explorer/src/components/virtual-list.tsx new file mode 100644 index 00000000000..e77b775fafe --- /dev/null +++ b/packages/graphiql-plugin-doc-explorer/src/components/virtual-list.tsx @@ -0,0 +1,66 @@ +import { useVirtualizer } from '@tanstack/react-virtual'; +import type { ReactNode } from 'react'; +import { useState } from 'react'; + +type VirtualListProps = { + items: readonly T[]; + estimateSize: (index: number) => number; + renderItem: (item: T, index: number) => ReactNode; +}; + +export function VirtualList({ + items, + estimateSize, + renderItem, +}: VirtualListProps) { + // React Compiler memoizes this component's output. Because `virtualizer` is a + // stable instance, the compiler treats the render as cacheable and skips the + // re-renders that TanStack Virtual's internal useReducer dispatch triggers + // on scroll/resize. Disabling memoization here keeps scroll-driven updates working. + 'use no memo'; + + const [scrollEl, setScrollEl] = useState(null); + + const virtualizer = useVirtualizer({ + count: items.length, + getScrollElement: () => scrollEl, + estimateSize, + overscan: 5, + initialRect: { width: 0, height: 800 }, + }); + + const virtualItems = virtualizer.getVirtualItems(); + + return ( +
+ {virtualItems.length > 0 ? ( +
+ {virtualItems.map(virtualRow => ( +
+ {renderItem(items[virtualRow.index]!, virtualRow.index)} +
+ ))} +
+ ) : ( + items.map((item, index) => ( +
+ {renderItem(item, index)} +
+ )) + )} +
+ ); +} diff --git a/resources/custom-words.txt b/resources/custom-words.txt index ab691a503e4..66480c5fd63 100644 --- a/resources/custom-words.txt +++ b/resources/custom-words.txt @@ -42,9 +42,11 @@ bobbybobby borggreve bram browserslistrc +cacheable calar chainable changesets +classname clsx codebases codegen @@ -140,7 +142,6 @@ multipass nauroze newhope nextjs -nodenext nishchit nocheck nocursor @@ -159,6 +160,7 @@ orche orta outdir outlineable +overscan ovsx oxfmt oxlint @@ -214,6 +216,7 @@ svgo svgr tanay tanaypratap +tanstack testid testonly therox @@ -231,6 +234,7 @@ unfocus unnormalized unparsable unsubscribable +unvirtualized urigo urql usememo diff --git a/yarn.lock b/yarn.lock index b5d2ba1697c..e1698aef15b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3035,6 +3035,7 @@ __metadata: dependencies: "@graphiql/react": "npm:^0.37.4" "@headlessui/react": "npm:^2.2" + "@tanstack/react-virtual": "npm:^3.13.24" "@testing-library/dom": "npm:^10.4.0" "@testing-library/jest-dom": "npm:^6.6.3" "@testing-library/react": "npm:^16.3.0" @@ -6025,22 +6026,22 @@ __metadata: languageName: node linkType: hard -"@tanstack/react-virtual@npm:^3.13.6": - version: 3.13.6 - resolution: "@tanstack/react-virtual@npm:3.13.6" +"@tanstack/react-virtual@npm:^3.13.24, @tanstack/react-virtual@npm:^3.13.6": + version: 3.13.24 + resolution: "@tanstack/react-virtual@npm:3.13.24" dependencies: - "@tanstack/virtual-core": "npm:3.13.6" + "@tanstack/virtual-core": "npm:3.14.0" peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - checksum: 10c0/3d2fdf024cb9189981cc015518a34a771a57cd698ca775022ad481431e6f26e596309f57d2238ef15f3b53b805879edaed6394e4cad1bb550ee92259e4d52633 + checksum: 10c0/f409b2bb67965a513b75a1403e622c0b86c88c67419f757c79f670615979e38dc7ad5569a02c924741697df3d4301a0f45208fd4b9f935a5d58dd83e1db5622a languageName: node linkType: hard -"@tanstack/virtual-core@npm:3.13.6": - version: 3.13.6 - resolution: "@tanstack/virtual-core@npm:3.13.6" - checksum: 10c0/9c40af4deccf0fadc5c371f4e659f1aff221db0ede9664ee7cf3642a2d05f6f1c9494605f3606f70d7c8948dc22e50c48519a8435d0e845f1b1f34c9353722c8 +"@tanstack/virtual-core@npm:3.14.0": + version: 3.14.0 + resolution: "@tanstack/virtual-core@npm:3.14.0" + checksum: 10c0/9e07e7f74f5e02dfc47b358f7b5089e680d8b14b9c5b90e9497be6f57c76ca98d185fbe5008795b89919346bfc3676ebe1c61b34980eefe6674c88ec6ff4b136 languageName: node linkType: hard