Files
supabase/apps/docs/features/ui/CodeBlock/CodeBlock.test.tsx
Anthony Lio 6d08a747f1 fix(docs): guide reference perf enhancements (#50239)
## What kind of change does this PR introduce?

follow-up to #50235 to reduce reference page payloads and cold rendering
overhead

## What is the current behavior?

reference pages ship a large rsc payload inside the html _ most of it is
duplication rather than content along with shiki that writes ~30
character css variable name for every syntax token making the page heavy
in some cases

## What is the new behavior?

- moves repeated styles into shared css and uses compact, namespaced
token classes
- renders details icons inside the client trigger
- follows shiki’s guidance to [reuse one
highlighter](https://shiki.style/guide/best-performance#cache-the-highlighter-instance)
and [load languages on
demand](https://shiki.style/guide/best-performance#use-shorthands)

`page size`
page | before | after | change
-- | -- | -- | --
javascript | 10.61 mb | 8.28 mb | -21.9%
dart | 4.59 mb | 4.13 mb | -10.1%
python | 4.38 mb | 3.73 mb | -14.9%
swift | 2.87 mb | 2.56 mb | -10.9%
server | 1.59 mb | 1.35 mb | -15.2%
kotlin | 3.42 mb | 3.17 mb | -7.2%

`cold initialization`
language | before | after | reduction
-- | -- | -- | --
bash | 2,180 ms | 23 ms | 98.95%
javascript | 2,245 ms | 38 ms | 98.29%

## Additional context

measured on a local production build which uses the checked in generated
content _ production has larger sdk data, so absolute sizes there will
be higher

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Added reusable expand/collapse controls for API reference details,
with updated icons, labels, and styling.
* Improved code block rendering with class-based syntax highlighting,
wrapped-code support, responsive layouts, and lazy language loading.

* **Style**
* Added theme-aware syntax-token colors, line-number styling, and
configurable code-block shadows.
  * Consolidated expandable reference panel and item styling.

* **Tests**
* Added coverage for syntax highlighting, code block rendering, language
support, token stability, and reference details.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-09-15 23:55:49 +03:00

188 lines
7.2 KiB
TypeScript

import { readFile } from 'node:fs/promises'
import { load } from 'cheerio'
import { type ComponentProps, type PropsWithChildren } from 'react'
import { renderToStaticMarkup } from 'react-dom/server'
import { createHighlighter, type BundledLanguage, type ThemeRegistration } from 'shiki'
import { createTwoslasher } from 'twoslash'
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
import { CodeBlock } from './CodeBlock'
import { type CodeToken } from './CodeBlock.client'
import { getTokenClassName } from './CodeBlock.utils'
vi.mock('./types/lib.deno.d.ts.include', async () => ({
default: await readFile(new URL('./types/lib.deno.d.ts.include', import.meta.url), 'utf8'),
}))
// Keep the real token renderer while isolating unrelated UI imports and tooltip portals.
vi.mock('ui', async () => {
const { createElement } = await import('react')
return {
cn: (...classes: Array<unknown>) => classes.filter(Boolean).join(' '),
Tooltip: ({ children }: PropsWithChildren) => children,
TooltipTrigger: ({ children }: PropsWithChildren) => children,
TooltipContent: () => null,
Button: ({ variant, ...props }: ComponentProps<'button'> & { variant?: string }) =>
createElement('button', props),
copyToClipboard: vi.fn(),
}
})
function getLines(block: Awaited<ReturnType<typeof CodeBlock>>): Array<Array<CodeToken>> {
return block.props.children[0].props.children.props.lines
}
const fixtures: Array<{ name: string; lang?: string; code: string }> = [
{
name: 'JavaScript',
lang: 'javascript',
code: '// A greeting\nconst greeting = "hello"\ngreeting',
},
{
name: 'TypeScript',
lang: 'typescript',
code: 'const count: number = 42\nconst values = [count]',
},
{ name: 'SQL', lang: 'sql', code: "select 'hello' as greeting, 42 as count;\n-- A comment" },
{ name: 'shell', lang: 'shell', code: 'echo "hello ${USER}"\n# A comment' },
{ name: 'JSON', lang: 'json', code: '{\n "greeting": "hello",\n "count": 42\n}' },
{ name: 'empty code', lang: 'typescript', code: '' },
{ name: 'plain text', code: 'plain <text> & punctuation\n second line' },
{ name: 'unsupported language', lang: 'not-a-language', code: 'plain <text> & punctuation' },
]
describe('code block serialization and rendering', () => {
let highlighter: Awaited<ReturnType<typeof createHighlighter>>
beforeAll(async () => {
// Shiki mutates theme.colors, so use a fresh raw theme for this independent tokenization.
const theme: ThemeRegistration = JSON.parse(
await readFile(new URL('./supabase-2.json', import.meta.url), 'utf8')
)
highlighter = await createHighlighter({
themes: [theme],
langs: ['javascript', 'typescript', 'sql', 'shell', 'json'],
})
})
afterEach(() => vi.restoreAllMocks())
afterAll(() => highlighter.dispose())
it.each(fixtures)(
'preserves $name token boundaries using compact namespaced classes',
async ({ lang, code }) => {
const block = await CodeBlock({
contents: code,
lang,
skipTypeGeneration: true,
hideControls: true,
})
const lines = getLines(block)
const { tokens } = highlighter.codeToTokens(code, {
lang: lang === 'not-a-language' ? undefined : (lang as BundledLanguage | undefined),
theme: 'Supabase Theme',
tokenizeTimeLimit: 0,
tokenizeMaxLineLength: 100_000,
})
expect(lines.map((line) => line.map(([content]) => content))).toEqual(
tokens.map((line) => line.map(({ content }) => content))
)
for (const [lineIndex, line] of tokens.entries()) {
for (const [tokenIndex, token] of line.entries()) {
expect(lines[lineIndex][tokenIndex]).toEqual([
token.content,
getTokenClassName(token.color, token.fontStyle),
])
}
}
const $ = load(renderToStaticMarkup(block))
expect(
$('.code-line-number')
.toArray()
.map((element) => $(element).text())
).toEqual(lines.map((_, index) => String(index + 1)))
expect(
$('.code-line-content')
.toArray()
.map((element) => $(element).text())
).toEqual(lines.map((line) => line.map(([content]) => content).join('')))
expect($('.code-content [style]')).toHaveLength(0)
}
)
it('preserves actual Twoslash annotations and offsets after its source edits', async () => {
const source = [
"const prefix = 'Hello'",
'// ---cut---',
'/** The name shown in the greeting. */',
"const username = 'reader'",
'const message = `${prefix}, ${username}`',
'message',
].join('\n')
const twoslashed = createTwoslasher({ compilerOptions: { ignoreDeprecations: '6.0' } })(source)
const hovers = twoslashed.nodes.filter((node) => node.type === 'hover')
expect(hovers.length).toBeGreaterThan(0)
expect(twoslashed.code).not.toContain('// ---cut---')
const block = await CodeBlock({ contents: source, lang: 'typescript', hideControls: true })
const lines = getLines(block)
expect(lines.map((line) => line.map(([content]) => content).join('')).join('\n')).toBe(
twoslashed.code
)
for (const [lineIndex, line] of lines.entries()) {
let offset = 0
for (const token of line) {
const annotations = hovers
.filter((hover) => hover.line === lineIndex && hover.character === offset)
.map(({ text, docs, tags }) => ({ text, docs, tags }))
expect(token[2]).toEqual(annotations.length ? annotations : undefined)
expect(token).toHaveLength(annotations.length ? 3 : 2)
offset += token[0].length
}
}
const annotated = lines.flat().filter((token) => token[2])
expect(annotated.length).toBeGreaterThan(0)
const $ = load(renderToStaticMarkup(block))
expect(
$('.code-content button')
.toArray()
.map((element) => $(element).text())
).toEqual(annotated.map(([content]) => content))
expect($('.code-content button[tabindex="0"]')).toHaveLength(annotated.length)
})
it('keeps classes stable across repeated renders in a different order', async () => {
const render = async ({ lang, code }: (typeof fixtures)[number]) =>
getLines(await CodeBlock({ contents: code, lang, skipTypeGeneration: true }))
const first = await Promise.all(fixtures.map(render))
const reversed = await Promise.all([...fixtures].reverse().map(render))
expect(reversed.reverse()).toEqual(first)
})
it('retains unnumbered layout, source text, hidden controls, and the accessible label', async () => {
const code = 'echo "hello"\necho "reader"'
const block = await CodeBlock({
contents: code,
lang: 'shell',
lineNumbers: false,
hideControls: true,
})
const $ = load(renderToStaticMarkup(block))
expect($('.code-line-number')).toHaveLength(0)
expect($('.code-content')).toHaveLength(1)
expect(
$('.code-content > span')
.toArray()
.map((element) => $(element).text())
.join('\n')
).toBe(code)
expect($('button')).toHaveLength(0)
expect($('.code-scroll').attr('aria-label')).toBe('Shell, 2 lines')
expect($('.code-scroll').attr('tabindex')).toBe('0')
expect($('pre > code').hasClass('grid')).toBe(false)
})
})