Skip to content

Commit 59b1f84

Browse files
Add Pretext virtualized text example (TanStack#1178)
1 parent 693d915 commit 59b1f84

15 files changed

Lines changed: 880 additions & 65 deletions

File tree

docs/config.json

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,8 @@
1010
"label": "Getting Started",
1111
"children": [
1212
{ "label": "Introduction", "to": "introduction" },
13-
{ "label": "Installation", "to": "installation" }
13+
{ "label": "Installation", "to": "installation" },
14+
{ "label": "Text Measurement with Pretext", "to": "pretext" }
1415
],
1516
"frameworks": [
1617
{
@@ -128,10 +129,18 @@
128129
"to": "framework/react/examples/dynamic",
129130
"label": "Dynamic"
130131
},
132+
{
133+
"to": "framework/react/examples/pretext",
134+
"label": "Pretext"
135+
},
131136
{
132137
"to": "framework/react/examples/padding",
133138
"label": "Padding"
134139
},
140+
{
141+
"to": "framework/react/examples/scroll-padding",
142+
"label": "Scroll Padding"
143+
},
135144
{
136145
"to": "framework/react/examples/sticky",
137146
"label": "Sticky"
@@ -165,10 +174,6 @@
165174
"to": "framework/svelte/examples/fixed",
166175
"label": "Fixed"
167176
},
168-
{
169-
"to": "framework/svelte/examples/variable",
170-
"label": "Variable"
171-
},
172177
{
173178
"to": "framework/svelte/examples/dynamic",
174179
"label": "Dynamic"

docs/pretext.md

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
---
2+
title: Text Measurement with Pretext
3+
---
4+
5+
[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.
6+
7+
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.
8+
9+
## When to use it
10+
11+
Use Pretext when each virtual row's height can be derived from:
12+
13+
- Text content
14+
- The exact canvas font string used by the rendered text
15+
- The available content width
16+
- The rendered line-height
17+
- Matching whitespace, word-break, and letter-spacing settings
18+
19+
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.
20+
21+
## Install
22+
23+
```sh
24+
npm install @chenglou/pretext
25+
```
26+
27+
## Basic pattern
28+
29+
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.
30+
31+
```tsx
32+
import { clearCache, layout, prepare } from '@chenglou/pretext'
33+
import { useVirtualizer } from '@tanstack/react-virtual'
34+
35+
const font = '14px Arial'
36+
const lineHeight = 20
37+
const preparedCache = new Map<string, ReturnType<typeof prepare>>()
38+
39+
function getPrepared(row: { id: string; text: string }) {
40+
const key = `${row.id}:${font}:${row.text}`
41+
const cached = preparedCache.get(key)
42+
43+
if (cached) {
44+
return cached
45+
}
46+
47+
const prepared = prepare(row.text, font, {
48+
whiteSpace: 'pre-wrap',
49+
letterSpacing: 0,
50+
})
51+
preparedCache.set(key, prepared)
52+
return prepared
53+
}
54+
55+
function estimateRowHeight(row: { id: string; text: string }, contentWidth: number) {
56+
const text = layout(getPrepared(row), contentWidth, lineHeight)
57+
const textHeight = Math.max(lineHeight, text.height)
58+
59+
return textHeight + 24
60+
}
61+
62+
function Messages({ rows }: { rows: Array<{ id: string; text: string }> }) {
63+
const parentRef = React.useRef<HTMLDivElement>(null)
64+
const [width, setWidth] = React.useState(640)
65+
66+
React.useLayoutEffect(() => {
67+
const element = parentRef.current
68+
69+
if (!element) {
70+
return
71+
}
72+
73+
const update = () => setWidth(element.clientWidth)
74+
const observer = new ResizeObserver(update)
75+
76+
update()
77+
observer.observe(element)
78+
79+
return () => observer.disconnect()
80+
}, [])
81+
82+
const virtualizer = useVirtualizer({
83+
count: rows.length,
84+
getItemKey: (index) => rows[index]!.id,
85+
getScrollElement: () => parentRef.current,
86+
estimateSize: (index) => estimateRowHeight(rows[index]!, width - 32),
87+
})
88+
89+
React.useLayoutEffect(() => {
90+
virtualizer.measure()
91+
}, [virtualizer, width])
92+
93+
React.useEffect(() => {
94+
document.fonts.ready.then(() => {
95+
preparedCache.clear()
96+
clearCache()
97+
virtualizer.measure()
98+
})
99+
}, [virtualizer])
100+
101+
return <div ref={parentRef}>{/* render virtual rows */}</div>
102+
}
103+
```
104+
105+
## Robustness checklist
106+
107+
- Match CSS and Pretext inputs exactly. `font`, `line-height`, `letter-spacing`, `white-space`, and `word-break` must agree with the rendered row.
108+
- Prefer named fonts. System font aliases can map differently between CSS and canvas, especially on macOS.
109+
- Wait for fonts before trusting cached measurements. After `document.fonts.ready`, clear your prepared-text cache, call Pretext's `clearCache()`, and call `virtualizer.measure()`.
110+
- Rerun `layout()`, not `prepare()`, on resize. `prepare()` is the expensive per-text setup; `layout()` is the cheap width-dependent path.
111+
- Clamp empty text if your UI renders empty rows as one line. Pretext returns zero height for an empty string.
112+
- 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.
113+
- Keep a fallback for unsupported runtimes. Pretext currently needs `Intl.Segmenter` and Canvas 2D text measurement.
114+
- 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.
115+
116+
See the React Pretext example for a complete chat-style implementation: [React Pretext](./framework/react/examples/pretext).

examples/lit/dynamic/tsconfig.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,8 @@
66
"module": "ESNext",
77
"moduleResolution": "node",
88
"experimentalDecorators": true,
9-
"useDefineForClassFields": false
9+
"useDefineForClassFields": false,
10+
"types": ["node"]
1011
},
1112
"files": ["src/main.ts"],
1213
"include": ["src"]

examples/lit/fixed/tsconfig.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,8 @@
66
"module": "ESNext",
77
"moduleResolution": "node",
88
"experimentalDecorators": true,
9-
"useDefineForClassFields": false
9+
"useDefineForClassFields": false,
10+
"types": ["node"]
1011
},
1112
"files": ["src/main.ts"],
1213
"include": ["src"]

examples/react/pretext/README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
# Example
2+
3+
To run this example:
4+
5+
- `pnpm install`
6+
- `pnpm --filter tanstack-react-virtual-example-pretext dev`

examples/react/pretext/index.html

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
<!doctype html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="UTF-8" />
5+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
6+
</head>
7+
<body>
8+
<div id="root"></div>
9+
<script type="module" src="/src/main.tsx"></script>
10+
</body>
11+
</html>
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
{
2+
"name": "tanstack-react-virtual-example-pretext",
3+
"private": true,
4+
"type": "module",
5+
"scripts": {
6+
"dev": "vite",
7+
"build": "tsc && vite build",
8+
"serve": "vite preview"
9+
},
10+
"dependencies": {
11+
"@chenglou/pretext": "^0.0.7",
12+
"@tanstack/react-virtual": "^3.13.26",
13+
"react": "^18.3.1",
14+
"react-dom": "^18.3.1"
15+
},
16+
"devDependencies": {
17+
"@types/node": "^24.5.2",
18+
"@types/react": "^18.3.23",
19+
"@types/react-dom": "^18.3.7",
20+
"@vitejs/plugin-react": "^4.5.2",
21+
"typescript": "5.6.3",
22+
"vite": "^6.4.2"
23+
}
24+
}
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
* {
2+
box-sizing: border-box;
3+
}
4+
5+
html {
6+
color: #1f2937;
7+
font-family: Arial, Helvetica, sans-serif;
8+
line-height: 1.5;
9+
}
10+
11+
body {
12+
margin: 0;
13+
background: #f6f7fb;
14+
}
15+
16+
button {
17+
border: 1px solid #c8d1dc;
18+
border-radius: 6px;
19+
background: #fff;
20+
color: #1f2937;
21+
cursor: pointer;
22+
font: inherit;
23+
padding: 6px 10px;
24+
}
25+
26+
button:hover {
27+
background: #eef4ff;
28+
}
29+
30+
.app {
31+
display: grid;
32+
gap: 16px;
33+
margin: 0 auto;
34+
max-width: 980px;
35+
padding: 24px;
36+
}
37+
38+
.toolbar {
39+
align-items: center;
40+
display: flex;
41+
flex-wrap: wrap;
42+
gap: 8px;
43+
}
44+
45+
.stat {
46+
color: #596579;
47+
font-size: 13px;
48+
margin-left: auto;
49+
}
50+
51+
.list {
52+
background: #fff;
53+
border: 1px solid #d9e0ea;
54+
border-radius: 8px;
55+
height: 720px;
56+
overflow-x: hidden;
57+
overflow-y: auto;
58+
}
59+
60+
.spacer {
61+
position: relative;
62+
width: 100%;
63+
}
64+
65+
.message-row {
66+
left: 0;
67+
padding: 8px 16px;
68+
position: absolute;
69+
top: 0;
70+
width: 100%;
71+
}
72+
73+
.message-bubble {
74+
background: #f8fafc;
75+
border: 1px solid #d9e0ea;
76+
border-radius: 8px;
77+
max-width: 680px;
78+
padding: 12px 14px;
79+
width: 100%;
80+
}
81+
82+
.message-row.user .message-bubble {
83+
background: #eef6ff;
84+
border-color: #bed8f4;
85+
margin-left: auto;
86+
}
87+
88+
.message-meta {
89+
align-items: center;
90+
color: #596579;
91+
display: flex;
92+
font-size: 12px;
93+
font-weight: 700;
94+
gap: 8px;
95+
height: 18px;
96+
line-height: 18px;
97+
margin-bottom: 6px;
98+
}
99+
100+
.message-time {
101+
color: #8792a2;
102+
font-weight: 400;
103+
}
104+
105+
.message-body {
106+
font:
107+
14px/20px Arial,
108+
Helvetica,
109+
sans-serif;
110+
letter-spacing: 0;
111+
margin: 0;
112+
white-space: pre-wrap;
113+
}
114+
115+
@media (max-width: 640px) {
116+
.app {
117+
padding: 12px;
118+
}
119+
120+
.list {
121+
height: 640px;
122+
}
123+
124+
.stat {
125+
flex-basis: 100%;
126+
margin-left: 0;
127+
}
128+
}

0 commit comments

Comments
 (0)