Pretext: medición de texto sin reflow, y cómo integrarlo en tus Web Components
El problema que nadie quiere admitir
Hay un bug silencioso en casi todas las UIs web que hacen algo no trivial con texto: el reflow.
Cuando necesitas saber cuánto espacio ocupa un párrafo —para virtualizar una lista, para calcular la altura de una burbuja de chat, para hacer masonry— el navegador te obliga a preguntar al DOM. Y el DOM solo sabe responder después de haber pintado. Así que JavaScript fuerza un layout reflow: el motor recalcula posiciones y dimensiones de toda la página, solo para darte un número.
getBoundingClientRect, offsetHeight, scrollHeight… cada una de estas llamadas puede ser una bomba de rendimiento en el hot path.
La solución habitual es hackear: cachear alturas, estimar con fórmulas de aproximación, usar IntersectionObserver para diferir… Parches sobre parches.
Pretext al rescate
Pretext es una
librería TypeScript/JavaScript de Cheng Lou (el mismo de react-motion) que salió esta semana y ya tiene
más de 2.1k estrellas. Su premisa es simple pero potente: medir
texto sin tocar el DOM, usando el motor de fuentes del
navegador a través de Canvas como fuente de verdad.
La arquitectura se divide en dos fases muy bien separadas:
prepare() — el trabajo pesado, una sola vez
import { prepare } from '@chenglou/pretext'
const prepared = prepare('Hola mundo 🌍', '16px Inter')Esta función hace todo el trabajo pesado de una vez: normaliza
whitespace, segmenta el texto (incluyendo emojis, texto bidi
árabe/hebreo, CJK…), aplica reglas de pegado tipográfico y mide cada
segmento con canvas.measureText. El
resultado es un handle opaco que puedes reutilizar indefinidamente.
Rendimiento en el benchmark del repo: ~19ms para un batch de 500 textos.
layout() — el hot path, aritmética pura
import { layout } from '@chenglou/pretext'
const { height, lineCount } = layout(prepared, 320, 24)
// 320px de ancho máximo, 24px de line-height
// → sin DOM, sin reflow, pura aritmética sobre anchos cacheados~0.09ms para ese mismo batch de 500. La diferencia de órdenes de magnitud es lo que lo hace usable en el scroll handler, en el resize observer, en cualquier sitio que antes era territorio prohibido.
API avanzada: control línea a línea
Para casos donde necesitas más control —Canvas, SVG, WebGL, o layout
complejo— existe prepareWithSegments +
layoutWithLines:
import { prepareWithSegments, layoutWithLines, walkLineRanges, layoutNextLine } from '@chenglou/pretext'
const prepared = prepareWithSegments('Texto largo...', '18px "Helvetica Neue"')
// Obtener todas las líneas a un ancho fijo
const { lines } = layoutWithLines(prepared, 320, 26)
lines.forEach((line, i) => ctx.fillText(line.text, 0, i * 26))
// "Shrink wrap": ancho mínimo que cabe el texto
let maxW = 0
walkLineRanges(prepared, 9999, line => { if (line.width > maxW) maxW = line.width })
// Layout con ancho variable por línea (texto que fluye alrededor de imágenes)
let cursor = { segmentIndex: 0, graphemeIndex: 0 }
let y = 0
while (true) {
const width = y < image.bottom ? columnWidth - image.width : columnWidth
const line = layoutNextLine(prepared, cursor, width)
if (line === null) break
ctx.fillText(line.text, 0, y)
cursor = line.end
y += 26
}layoutNextLine es especialmente
interesante: permite cambiar el ancho disponible línea a línea, algo
imposible con CSS puro.
Integración con Web Components
Aquí es donde se pone interesante. Los Web Components tienen un problema estructural con el texto: el Shadow DOM los aísla del layout exterior, pero internamente siguen sufriendo los mismos reflows que cualquier otro elemento. Veamos cómo Pretext resuelve patrones concretos.
Patrón 1: Auto-height text element
El caso más simple: un componente que necesita conocer su propia altura antes de que el navegador la calcule, para evitar layout shift (CLS).
class AutoHeightText extends HTMLElement {
static observedAttributes = ['text', 'font', 'width', 'line-height']
#prepared = null
#shadow = this.attachShadow({ mode: 'open' })
connectedCallback() {
this.#render()
}
attributeChangedCallback() {
this.#render()
}
#render() {
const text = this.getAttribute('text') ?? ''
const font = this.getAttribute('font') ?? '16px system-ui'
const lineHeight = parseFloat(this.getAttribute('line-height') ?? '24')
const width = parseFloat(this.getAttribute('width') ?? '300')
this.#prepared = prepare(text, font)
const { height, lineCount } = layout(this.#prepared, width, lineHeight)
this.style.height = `${height}px`
this.style.display = 'block'
this.#shadow.innerHTML = `
<style>
:host { display: block; overflow: hidden; }
p { margin: 0; font: ${font}; line-height: ${lineHeight}px; }
</style>
<p>${text}</p>
`
}
}
customElements.define('auto-height-text', AutoHeightText)Patrón 2: Virtual list Web Component
El caso estrella de Pretext. Una lista virtual sin DOM reads en el scroll:
import { prepare, layout } from '@chenglou/pretext'
class VirtualList extends HTMLElement {
#items = []
#font = '15px Inter'
#lineHeight = 22
#itemWidth = 0
#preparedCache = new Map()
#shadow = this.attachShadow({ mode: 'open' })
#scrollTop = 0
set items(data) {
this.#items = data
this.#preparedCache.clear()
for (const item of data) {
this.#preparedCache.set(item.id, prepare(item.text, this.#font))
}
this.#render()
}
#getHeight(item) {
const prepared = this.#preparedCache.get(item.id)
const { height } = layout(prepared, this.#itemWidth, this.#lineHeight)
return height
}
#getTotalHeight() {
return this.#items.reduce((acc, item) => acc + this.#getHeight(item), 0)
}
#render() {
// layout() ~0.09ms por item: viable en scroll handler
const visibleHTML = this.#items.map((item, i) => {
const h = this.#getHeight(item)
const top = this.#items.slice(0, i).reduce((a, x) => a + this.#getHeight(x), 0)
return `<div style="position:absolute;top:${top}px;height:${h}px;width:100%;padding:8px;box-sizing:border-box">
${item.text}
</div>`
}).join('')
this.#shadow.innerHTML = `
<style>
:host { display: block; overflow-y: auto; }
#viewport { position: relative; height: ${this.#getTotalHeight()}px; }
</style>
<div id="viewport">${visibleHTML}</div>
`
}
}
customElements.define('virtual-list', VirtualList)Clave: layout() es tan barato que
llamarlo en el scroll handler es viable. Sin getBoundingClientRect, sin reflow, sin frame
drops.
Patrón 3: Chat bubbles con shrink-wrap real
Burbujas de chat con el ancho mínimo que realmente necesita el texto, sin espacio vacío a la derecha:
import { prepareWithSegments, walkLineRanges, layoutWithLines } from '@chenglou/pretext'
class ChatBubble extends HTMLElement {
#shadow = this.attachShadow({ mode: 'open' })
connectedCallback() {
const text = this.textContent ?? ''
const font = '15px Inter'
const lineHeight = 22
const maxWidth = 280
const prepared = prepareWithSegments(text, font)
// Shrink-wrap: ancho mínimo real
let minWidth = 0
walkLineRanges(prepared, maxWidth, line => {
if (line.width > minWidth) minWidth = line.width
})
const { height } = layoutWithLines(prepared, minWidth, lineHeight)
this.#shadow.innerHTML = `
<style>
:host { display: inline-block; }
.bubble {
background: #0b93f6;
color: white;
border-radius: 18px;
padding: 8px 14px;
width: ${minWidth}px;
height: ${height}px;
font: ${font};
line-height: ${lineHeight}px;
box-sizing: border-box;
}
</style>
<div class="bubble">${text}</div>
`
}
}
customElements.define('chat-bubble', ChatBubble)Patrón 4: Rich text con inline-flow
Para contenido mixto —texto, badges, menciones, chips— existe el
sidecar experimental @chenglou/pretext/inline-flow:
import { prepareInlineFlow, walkInlineFlowLines } from '@chenglou/pretext/inline-flow'
class RichNote extends HTMLElement {
#shadow = this.attachShadow({ mode: 'open' })
set content(runs) {
// runs = [{ text, font, break?, extraWidth? }, ...]
const prepared = prepareInlineFlow(runs)
const containerWidth = this.offsetWidth || 400
const lineGroups = []
walkInlineFlowLines(prepared, containerWidth, line => {
lineGroups.push(line)
})
this.#shadow.innerHTML = this.#buildHTML(lineGroups, runs)
}
}ResizeObserver + Pretext: el patrón definitivo
La combinación correcta para componentes robustos y reactivos al resize:
class ResponsiveText extends HTMLElement {
#prepared = null
#ro = new ResizeObserver(entries => {
// Solo layout(), nunca prepare() aquí
for (const entry of entries) {
const width = entry.contentRect.width
const { height } = layout(this.#prepared, width, 24)
this.style.height = `${height}px`
}
})
connectedCallback() {
const text = this.textContent ?? ''
// prepare() una sola vez al montar
this.#prepared = prepare(text, '16px Inter')
this.#ro.observe(this)
}
disconnectedCallback() {
this.#ro.disconnect()
}
}La separación prepare / layout brilla aquí: el trabajo pesado ocurre una
vez, y el resize solo ejecuta aritmética pura.
Cuándo usar Pretext (y cuándo no)
Úsalo cuando:
- Virtualizas listas o grids con contenido de altura variable
- Construyes chats, feeds o timelines con burbujas dinámicas
- Necesitas masonry sin lecturas DOM
- Renderizas a Canvas, SVG o WebGL
- Quieres eliminar layout shift (CLS)
No lo necesitas si:
- Tu layout es simple y CSS lo resuelve
- No tienes problemas de rendimiento medibles
- Tu framework ya gestiona las mediciones
Caveats
system-uies inseguro en macOS: usar siempre fuentes con nombre.- Asume
white-space: normal,word-break: normal,overflow-wrap: break-word. - El sidecar
inline-flowes alpha experimental.
Conclusión
Pretext resuelve un problema que parecía inherente a la plataforma web: medir texto siempre ha significado forzar un reflow. La respuesta de Cheng Lou es desviar toda la medición a Canvas y separar el trabajo pesado del hot path.
Para Web Components encaja especialmente bien: componentes
autocontenidos que necesitan conocer sus propias dimensiones sin
depender del exterior. El patrón connectedCallback → prepare() + ResizeObserver → layout() te da componentes
performantes, reactivos y sin reflows.
- Repo: github.com/chenglou/pretext
- Demos: chenglou.me/pretext
Comentarios (0)
Sin comentarios todavia. Se el primero!
Deja un comentario