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
15 changes: 10 additions & 5 deletions docs/config.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@
"label": "Getting Started",
"children": [
{ "label": "Introduction", "to": "introduction" },
{ "label": "Installation", "to": "installation" }
{ "label": "Installation", "to": "installation" },
{ "label": "Text Measurement with Pretext", "to": "pretext" }
],
"frameworks": [
{
Expand Down Expand Up @@ -124,10 +125,18 @@
"to": "framework/react/examples/dynamic",
"label": "Dynamic"
},
{
"to": "framework/react/examples/pretext",
"label": "Pretext"
},
{
"to": "framework/react/examples/padding",
"label": "Padding"
},
{
"to": "framework/react/examples/scroll-padding",
"label": "Scroll Padding"
},
{
"to": "framework/react/examples/sticky",
"label": "Sticky"
Expand Down Expand Up @@ -157,10 +166,6 @@
"to": "framework/svelte/examples/fixed",
"label": "Fixed"
},
{
"to": "framework/svelte/examples/variable",
"label": "Variable"
},
{
"to": "framework/svelte/examples/dynamic",
"label": "Dynamic"
Expand Down
116 changes: 116 additions & 0 deletions docs/pretext.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
---
title: Text Measurement with Pretext
---

[Pretext](https://github.com/chenglou/pretext) is a text measurement and layout library from Cheng Lou. TanStack Virtual still owns scrolling, range calculation, item positioning, and scroll-to behavior; Pretext can own the text-height estimate for rows whose height is mostly determined by wrapped text.

This is useful for chat logs, AI streams, activity feeds, comments, changelogs, notifications, and other text-heavy timelines where DOM measurement creates visible correction work.

## When to use it

Use Pretext when each virtual row's height can be derived from:

- Text content
- The exact canvas font string used by the rendered text
- The available content width
- The rendered line-height
- Matching whitespace, word-break, and letter-spacing settings

Do not make Pretext responsible for rows whose height depends on images, embeds, block markdown, loaded components, or arbitrary CSS layout. For those rows, use `measureElement`, call `resizeItem` when the extra content resolves, or split the text-only and non-text portions into separate sizing paths.

## Install

```sh
npm install @chenglou/pretext
```

## Basic pattern

Cache `prepare()` by text and text-style inputs. Run `layout()` for the current width. When the width, font, line-height, or text options change, reset Virtual's measurements so offsets are recalculated from the new estimates.

```tsx
import { clearCache, layout, prepare } from '@chenglou/pretext'
import { useVirtualizer } from '@tanstack/react-virtual'

const font = '14px Arial'
const lineHeight = 20
const preparedCache = new Map<string, ReturnType<typeof prepare>>()

function getPrepared(row: { id: string; text: string }) {
const key = `${row.id}:${font}:${row.text}`
const cached = preparedCache.get(key)

if (cached) {
return cached
}

const prepared = prepare(row.text, font, {
whiteSpace: 'pre-wrap',
letterSpacing: 0,
})
preparedCache.set(key, prepared)
return prepared
}

function estimateRowHeight(row: { id: string; text: string }, contentWidth: number) {
const text = layout(getPrepared(row), contentWidth, lineHeight)
const textHeight = Math.max(lineHeight, text.height)

return textHeight + 24
}

function Messages({ rows }: { rows: Array<{ id: string; text: string }> }) {
const parentRef = React.useRef<HTMLDivElement>(null)
const [width, setWidth] = React.useState(640)

React.useLayoutEffect(() => {
const element = parentRef.current

if (!element) {
return
}

const update = () => setWidth(element.clientWidth)
const observer = new ResizeObserver(update)

update()
observer.observe(element)

return () => observer.disconnect()
}, [])

const virtualizer = useVirtualizer({
count: rows.length,
getItemKey: (index) => rows[index]!.id,
getScrollElement: () => parentRef.current,
estimateSize: (index) => estimateRowHeight(rows[index]!, width - 32),
})

React.useLayoutEffect(() => {
virtualizer.measure()
}, [virtualizer, width])

React.useEffect(() => {
document.fonts.ready.then(() => {
preparedCache.clear()
clearCache()
virtualizer.measure()
})
}, [virtualizer])

return <div ref={parentRef}>{/* render virtual rows */}</div>
}
```

## Robustness checklist

- Match CSS and Pretext inputs exactly. `font`, `line-height`, `letter-spacing`, `white-space`, and `word-break` must agree with the rendered row.
- Prefer named fonts. System font aliases can map differently between CSS and canvas, especially on macOS.
- Wait for fonts before trusting cached measurements. After `document.fonts.ready`, clear your prepared-text cache, call Pretext's `clearCache()`, and call `virtualizer.measure()`.
- Rerun `layout()`, not `prepare()`, on resize. `prepare()` is the expensive per-text setup; `layout()` is the cheap width-dependent path.
- Clamp empty text if your UI renders empty rows as one line. Pretext returns zero height for an empty string.
- Use one sizing owner per row. Do not call `measureElement` for the same row that you also size with `resizeItem` or Pretext estimates unless you deliberately want DOM measurement to override the text estimate.
- Keep a fallback for unsupported runtimes. Pretext currently needs `Intl.Segmenter` and Canvas 2D text measurement.
- Use `resizeItem(index, size)` when you know a row's final size outside render, such as after markdown preprocessing, image metadata loading, or a controlled expand/collapse transition.

See the React Pretext example for a complete chat-style implementation: [React Pretext](./framework/react/examples/pretext).
3 changes: 2 additions & 1 deletion examples/lit/dynamic/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@
"module": "ESNext",
"moduleResolution": "node",
"experimentalDecorators": true,
"useDefineForClassFields": false
"useDefineForClassFields": false,
"types": ["node"]
},
"files": ["src/main.ts"],
"include": ["src"]
Expand Down
3 changes: 2 additions & 1 deletion examples/lit/fixed/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@
"module": "ESNext",
"moduleResolution": "node",
"experimentalDecorators": true,
"useDefineForClassFields": false
"useDefineForClassFields": false,
"types": ["node"]
},
"files": ["src/main.ts"],
"include": ["src"]
Expand Down
6 changes: 6 additions & 0 deletions examples/react/pretext/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# Example

To run this example:

- `pnpm install`
- `pnpm --filter tanstack-react-virtual-example-pretext dev`
11 changes: 11 additions & 0 deletions examples/react/pretext/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
24 changes: 24 additions & 0 deletions examples/react/pretext/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
{
"name": "tanstack-react-virtual-example-pretext",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"serve": "vite preview"
},
"dependencies": {
"@chenglou/pretext": "^0.0.7",
"@tanstack/react-virtual": "^3.13.9",
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@types/node": "^22.13.6",
"@types/react": "^18.3.21",
"@types/react-dom": "^18.3.7",
"@vitejs/plugin-react": "^4.4.1",
"typescript": "5.2.2",
"vite": "^5.4.19"
}
}
128 changes: 128 additions & 0 deletions examples/react/pretext/src/index.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
* {
box-sizing: border-box;
}

html {
color: #1f2937;
font-family: Arial, Helvetica, sans-serif;
line-height: 1.5;
}

body {
margin: 0;
background: #f6f7fb;
}

button {
border: 1px solid #c8d1dc;
border-radius: 6px;
background: #fff;
color: #1f2937;
cursor: pointer;
font: inherit;
padding: 6px 10px;
}

button:hover {
background: #eef4ff;
}

.app {
display: grid;
gap: 16px;
margin: 0 auto;
max-width: 980px;
padding: 24px;
}

.toolbar {
align-items: center;
display: flex;
flex-wrap: wrap;
gap: 8px;
}

.stat {
color: #596579;
font-size: 13px;
margin-left: auto;
}

.list {
background: #fff;
border: 1px solid #d9e0ea;
border-radius: 8px;
height: 720px;
overflow-x: hidden;
overflow-y: auto;
}

.spacer {
position: relative;
width: 100%;
}

.message-row {
left: 0;
padding: 8px 16px;
position: absolute;
top: 0;
width: 100%;
}

.message-bubble {
background: #f8fafc;
border: 1px solid #d9e0ea;
border-radius: 8px;
max-width: 680px;
padding: 12px 14px;
width: 100%;
}

.message-row.user .message-bubble {
background: #eef6ff;
border-color: #bed8f4;
margin-left: auto;
}

.message-meta {
align-items: center;
color: #596579;
display: flex;
font-size: 12px;
font-weight: 700;
gap: 8px;
height: 18px;
line-height: 18px;
margin-bottom: 6px;
}

.message-time {
color: #8792a2;
font-weight: 400;
}

.message-body {
font:
14px/20px Arial,
Helvetica,
sans-serif;
letter-spacing: 0;
margin: 0;
white-space: pre-wrap;
}

@media (max-width: 640px) {
.app {
padding: 12px;
}

.list {
height: 640px;
}

.stat {
flex-basis: 100%;
margin-left: 0;
}
}
Loading