Skip to content
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
5 changes: 5 additions & 0 deletions .changeset/good-files-enjoy.md
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions packages/graphiql-plugin-doc-explorer/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
Original file line number Diff line number Diff line change
@@ -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(() => <div data-testid="mock-virtual-list" />),
}));

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<unknown, unknown> = {};
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(<SchemaDocumentation schema={schema} />);

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(<SchemaDocumentation schema={schema} />);

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);
});
});
Original file line number Diff line number Diff line change
@@ -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(
<VirtualList
items={items}
estimateSize={() => 36}
renderItem={item => <span data-testid="item">{item}</span>}
/>,
);

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(
<VirtualList
items={[]}
estimateSize={() => 36}
renderItem={item => <span data-testid="item">{String(item)}</span>}
/>,
);

expect(container.querySelectorAll('[data-testid="item"]')).toHaveLength(0);
});
});
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -31,6 +37,7 @@
position: absolute;
right: 0;
top: 0;
z-index: 2;

&:focus-within {
left: 0;
Expand Down Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -12,6 +13,8 @@ type SchemaDocumentationProps = {
schema: GraphQLSchema;
};

const UNVIRTUALIZED_MAX_LENGTH = 1000;

export const SchemaDocumentation: FC<SchemaDocumentationProps> = ({
schema,
}) => {
Expand All @@ -24,6 +27,11 @@ export const SchemaDocumentation: FC<SchemaDocumentationProps> = ({
mutationType?.name,
subscriptionType?.name,
];
const allTypes = Object.values(typeMap).filter(
type =>
!ignoreTypesInAllSchema.includes(type.name) &&
!type.name.startsWith('__'),
);

return (
<>
Expand Down Expand Up @@ -56,23 +64,22 @@ export const SchemaDocumentation: FC<SchemaDocumentationProps> = ({
</div>
)}
</ExplorerSection>
<ExplorerSection title="All Schema Types">
<div>
{Object.values(typeMap).map(type => {
if (
ignoreTypesInAllSchema.includes(type.name) ||
type.name.startsWith('__')
) {
return null;
}

return (
<ExplorerSection className="all-types" title="All Schema Types">
{allTypes.length > UNVIRTUALIZED_MAX_LENGTH ? (
<VirtualList
items={allTypes}
estimateSize={() => 23}
renderItem={type => <TypeLink type={type} />}
/>
) : (
<div>
{allTypes.map(type => (
<div key={type.name}>
<TypeLink type={type} />
</div>
);
})}
</div>
))}
</div>
)}
</ExplorerSection>
</>
);
Expand Down
15 changes: 15 additions & 0 deletions packages/graphiql-plugin-doc-explorer/src/components/section.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<ExplorerSectionProps> = ({
title,
children,
className = '',
}) => {
const Icon = TYPE_TO_ICON[title];
const additionalClassName = className
? ` ${className} graphiql-doc-explorer-section--${className}`
: '';
return (
<div>
<div className={`graphiql-doc-explorer-section${additionalClassName}`}>
<div className="graphiql-doc-explorer-section-title">
<Icon />
{title}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -219,11 +219,12 @@ const PossibleTypes: FC<{ type: GraphQLNamedType }> = ({ type }) => {
if (!schema || !isAbstractType(type)) {
return null;
}
const possibleTypes = schema.getPossibleTypes(type);
return (
<ExplorerSection
title={isInterfaceType(type) ? 'Implementations' : 'Possible Types'}
>
{schema.getPossibleTypes(type).map(possibleType => (
{possibleTypes.map(possibleType => (
<div key={possibleType.name}>
<TypeLink type={possibleType} />
</div>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { useVirtualizer } from '@tanstack/react-virtual';
import type { ReactNode } from 'react';
import { useState } from 'react';

type VirtualListProps<T> = {
items: readonly T[];
estimateSize: (index: number) => number;
renderItem: (item: T, index: number) => ReactNode;
};

export function VirtualList<T>({
items,
estimateSize,
renderItem,
}: VirtualListProps<T>) {
// 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<HTMLDivElement | null>(null);

const virtualizer = useVirtualizer({
count: items.length,
getScrollElement: () => scrollEl,
estimateSize,
overscan: 5,
initialRect: { width: 0, height: 800 },
});

const virtualItems = virtualizer.getVirtualItems();

return (
<div ref={setScrollEl} style={{ flex: 1, minHeight: 0, overflowY: 'auto' }}>
{virtualItems.length > 0 ? (
<div
style={{ height: virtualizer.getTotalSize(), position: 'relative' }}
>
{virtualItems.map(virtualRow => (
<div
key={virtualRow.key}
data-index={virtualRow.index}
ref={virtualizer.measureElement}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
transform: `translateY(${virtualRow.start}px)`,
}}
>
{renderItem(items[virtualRow.index]!, virtualRow.index)}
</div>
))}
</div>
) : (
items.map((item, index) => (
<div key={index} style={{ paddingBottom: 'var(--px-16)' }}>
{renderItem(item, index)}
</div>
))
)}
</div>
);
}
6 changes: 5 additions & 1 deletion resources/custom-words.txt
Original file line number Diff line number Diff line change
Expand Up @@ -42,9 +42,11 @@ bobbybobby
borggreve
bram
browserslistrc
cacheable
calar
chainable
changesets
classname
clsx
codebases
codegen
Expand Down Expand Up @@ -140,7 +142,6 @@ multipass
nauroze
newhope
nextjs
nodenext
nishchit
nocheck
nocursor
Expand All @@ -159,6 +160,7 @@ orche
orta
outdir
outlineable
overscan
ovsx
oxfmt
oxlint
Expand Down Expand Up @@ -214,6 +216,7 @@ svgo
svgr
tanay
tanaypratap
tanstack
testid
testonly
therox
Expand All @@ -231,6 +234,7 @@ unfocus
unnormalized
unparsable
unsubscribable
unvirtualized
urigo
urql
usememo
Expand Down
Loading