From 2012a27febd88876904c725886bfe70b38d4b4bb Mon Sep 17 00:00:00 2001
From: Nick Gomez <122398915+nick-inkeep@users.noreply.github.com>
Date: Tue, 18 Aug 2026 01:18:18 +0000
Subject: [PATCH] fix(visimer): stop cold-load webfonts from clipping node
labels (#3560)
* fix(visimer): stop cold-load webfonts from clipping node labels
Mermaid measures label text against the fonts the document can use at the
moment it renders, then bakes those measurements into fixed-width
foreignObject boxes. On a first visit the webfont is still in flight behind
font-display: swap, so the boxes are sized for the fallback and the real text
overflows them once it swaps in. Every node label ends up clipped a few pixels
short: "Tests green?" loses its "?", "Ship it" reads "Ship i", "Fix bugs"
reads "Fix bug". Nothing in mermaid or in the browser re-measures, so the
diagram stays wrong for the whole session, and any reload hides it because the
font is cached by then. That is why it never shows up in development and shows
up for every first-time visitor.
MermaidCanvasView now looks at document.fonts after each render. If the
document's fonts are still loading it waits for them to settle and renders
again, which re-measures every label against the fonts the browser is actually
painting with. Checking after the render matters: mermaid's own measuring pass
is usually what first requests the webfont, so the pending load is only
visible by then. The wait arms only while fonts are pending, so a page whose
fonts are already available renders exactly once, as before, and it cannot
loop because the corrective render runs with the status already settled.
The fix is in the library rather than in apps/site because any embedder of
@visimer/dom that uses a webfont hits this, and the view is the only layer
that can act on it: it owns the render loop and the lastRenderedCode
short-circuit a correction has to bypass.
Measured with Playwright on a cold browser context against the dev site: 7 of
7 labels on the landing page and 3 of 4 on the playground were clipped before,
0 after, and the cold-load box geometry now matches the warm-cache geometry
exactly.
* fix(visimer): make the font-settled path explicit and pin the arm-once guard
Review follow-ups on the cold-load font fix.
The re-measure now routes both promise settlements through one named
handler instead of swallowing rejection in a bare catch. FontFaceSet.ready
is spec'd never to reject, but a partial polyfill could, and either way the
measurements are as final as they are going to get, so both paths should
re-render.
Adds a test for the arm-once guard. The existing tests all render once
before the fonts settle, so they pass even with the guard removed. The new
one renders twice while fonts are still loading and asserts a single
corrective render, which fails with 4 renders when the guard is dropped.
GitOrigin-RevId: 82fe42e2c9fa3470ab2c3be9afd5ca93ff6e1406
---
.changeset/pink-nights-shake.md | 9 ++
TESTING.md | 6 ++
packages/dom/src/view.ts | 42 ++++++++
packages/dom/test/fonts.test.ts | 180 ++++++++++++++++++++++++++++++++
4 files changed, 237 insertions(+)
create mode 100644 .changeset/pink-nights-shake.md
create mode 100644 packages/dom/test/fonts.test.ts
diff --git a/.changeset/pink-nights-shake.md b/.changeset/pink-nights-shake.md
new file mode 100644
index 0000000..78c5078
--- /dev/null
+++ b/.changeset/pink-nights-shake.md
@@ -0,0 +1,9 @@
+---
+'@visimer/dom': patch
+---
+
+Re-render the canvas once the document's webfonts have loaded, so labels are no longer clipped on a first visit.
+
+Mermaid measures label text against the fonts the document can use at the moment it renders, then bakes those measurements into fixed-width `foreignObject` boxes. On a cold load — a first-time visitor with an empty cache, where `font-display: swap` deliberately paints fallback text first — the webfont arrives after that measurement, and the real text is wider than the box that was sized for the fallback. Every label ends up clipped a few pixels short: "Tests green?" loses its "?", "Ship it" renders as "Ship i". Nothing in mermaid or in the browser re-measures, so the diagram stays wrong for the whole session; reloading fixes it only because the font is then cached, which is why it is invisible in normal development.
+
+`MermaidCanvasView` now watches `document.fonts` after each render. If the document's fonts are still loading it waits for them to settle and then re-renders, which re-measures every label against the fonts the browser is actually painting with. It arms only while fonts are pending, so a page whose fonts are already available renders exactly once, as before. Environments with no `FontFaceSet` are unaffected.
diff --git a/TESTING.md b/TESTING.md
index 2e7a995..037fd68 100644
--- a/TESTING.md
+++ b/TESTING.md
@@ -13,6 +13,7 @@ test exercises the seam.
| `bindTextPane` editor contract | The adapter contract any code editor integration implements | `packages/core/test/textpane.test.ts` drives the binding through an in-memory pane that implements exactly the shipped adapter interface: both sync directions, caret selection, reveal, drift resync, dispose | B |
| CodeMirror binding | `@visimer/codemirror` against a real CodeMirror 6 `EditorView` | `packages/codemirror/test/binding.test.ts` (jsdom): engine ops → view, view edits → engine, decorations in the DOM, caret → entity selection, engine-authoritative undo, teardown | A |
| Monaco binding | `@visimer/monaco` against the structural editor interface it binds | `packages/monaco/test/binding.test.ts`: fake implementing exactly the bound surface (both sync directions, decorations, caret reasons, undo keys, dispose), plus a compile-time conformance check that real `monaco-editor` types satisfy the interface | B |
+| Canvas render loop (what makes `@visimer/dom` re-render) | The triggers and the guards on them: source changes, config changes, and the webfont-settled re-measure that keeps labels from being clipped on a cold load | `packages/dom/test/fonts.test.ts` drives a real `MermaidCanvasView` over a fake mermaid and a stubbed `FontFaceSet`: re-renders once when fonts land late, never when they were already there, never after `destroy()`, never where the document exposes no font set | B |
| SVG correlation (dom package ↔ Mermaid's rendered DOM) | Third-party dependency seam: correlators key off Mermaid's internal SVG structure, which can shift between Mermaid releases | None automated. Verified manually in the playground across all 23 diagram types | uncovered |
| Canvas interaction layer (popovers, drag, in-place editing) | `@visimer/dom` gestures compiled to engine ops | None automated. Verified manually in the playground | uncovered |
| React bindings | `@visimer/react` hooks/components over core events | None automated. Thin subscription layer; exercised manually via the playground | uncovered |
@@ -29,6 +30,11 @@ What the current suite cannot catch:
provide it because Mermaid layout requires real text measurement.
- **Pointer-gesture regressions** (drag-to-connect thresholds, double-click
vs drag arbitration, popover anchoring). Same real-browser rung.
+- **Anything that depends on real text metrics.** The font-settled re-measure
+ is pinned at the contract level (does the view re-render, and only when it
+ should), not at the pixel level: jsdom cannot tell us whether a label
+ actually fits its box. Catching a *wrongly sized* label, rather than a
+ missing re-render, needs the same real-browser rung.
- **React render-loop regressions** (stale subscriptions, effect ordering).
Would need @testing-library/react coverage.
diff --git a/packages/dom/src/view.ts b/packages/dom/src/view.ts
index 3b096a4..50ec623 100644
--- a/packages/dom/src/view.ts
+++ b/packages/dom/src/view.ts
@@ -405,6 +405,9 @@ export class MermaidCanvasView {
private zoomControls: HTMLElement | null = null
/** per-instance staleness counter; a shared one would drop renders across instances */
private renderSeq = 0
+ /** a font-settled re-measure is already armed; don't stack a second one */
+ private awaitingFonts = false
+ private destroyed = false
constructor(options: ViewOptions) {
this.editor = options.editor
@@ -825,6 +828,7 @@ export class MermaidCanvasView {
}
this.editor.setDiagnostics([])
this.emit('render', { ok: true })
+ this.remeasureWhenFontsSettle()
if (this.activeInPlaceSession()) {
this.resumeInPlaceSession(liveEdit)
} else if (this.pendingEditEntity) {
@@ -847,6 +851,43 @@ export class MermaidCanvasView {
}
}
+ /**
+ * Mermaid measures label text against the fonts the document can use at the
+ * moment it renders, then bakes those measurements into fixed-width
+ * `foreignObject` boxes. A webfont that lands afterwards — the normal case
+ * on a first visit, where `font-display: swap` deliberately paints fallback
+ * text first — is wider than the box measured for the fallback, so every
+ * label ends up clipped a few pixels short for the rest of the session.
+ * Nothing in mermaid or in the browser re-measures on its own.
+ *
+ * So once the document's fonts have settled, render again. Asking *after*
+ * the render matters: mermaid's own measuring pass is usually what first
+ * requests the webfont, so the pending load is only visible by then.
+ *
+ * This cannot loop. It arms only while fonts are still loading, and by the
+ * time `ready` resolves the status is `loaded`, so the corrective render
+ * arms nothing. A font that starts loading later (a host swapping theme
+ * fonts at runtime) flips the status back and correctly arms a fresh wait.
+ */
+ private remeasureWhenFontsSettle() {
+ // no FontFaceSet in older browsers and in jsdom; nothing to wait on
+ const fonts: FontFaceSet | undefined = typeof document === 'undefined' ? undefined : document.fonts
+ if (!fonts || this.awaitingFonts || fonts.status === 'loaded') return
+ this.awaitingFonts = true
+ const remeasure = () => {
+ this.awaitingFonts = false
+ if (this.destroyed) return
+ // the code has not changed, so render() would short-circuit on
+ // lastRenderedCode — clear it to force the re-measure through
+ this.lastRenderedCode = ''
+ void this.render()
+ }
+ // `ready` is spec'd never to reject, but a partial polyfill could. Either
+ // way the measurements are as final as they are going to get, so both
+ // settlements take the same path.
+ void Promise.resolve(fonts.ready).then(remeasure, remeasure)
+ }
+
private bindSvg() {
const svg = this.svg
if (!svg) return
@@ -2642,6 +2683,7 @@ export class MermaidCanvasView {
}
destroy() {
+ this.destroyed = true
if (this.renderTimer) clearTimeout(this.renderTimer)
if (this.lifelineClearTimer) clearTimeout(this.lifelineClearTimer)
if (this.inPlaceSession?.liveTimer) clearTimeout(this.inPlaceSession.liveTimer)
diff --git a/packages/dom/test/fonts.test.ts b/packages/dom/test/fonts.test.ts
new file mode 100644
index 0000000..bf7053c
--- /dev/null
+++ b/packages/dom/test/fonts.test.ts
@@ -0,0 +1,180 @@
+// @vitest-environment jsdom
+import { afterEach, beforeEach, describe, expect, it } from 'vitest'
+import { MermaidWysiwygEditor } from '@visimer/core'
+import { MermaidCanvasView, type MermaidLike } from '../src'
+
+// jsdom has no CSS.escape (browsers do)
+if (typeof (globalThis as { CSS?: unknown }).CSS === 'undefined') {
+ ;(globalThis as { CSS?: { escape(s: string): string } }).CSS = {
+ escape: (s: string) => s.replace(/[^a-zA-Z0-9_-]/g, (c) => `\\${c}`),
+ }
+}
+
+const CODE = 'flowchart TD\n A[Tests green?] --> B[Ship it]\n'
+
+/**
+ * Mermaid measures label text against the fonts the document can use at the
+ * moment it renders, then bakes those measurements into fixed-width
+ * `foreignObject` boxes. A webfont that arrives afterwards leaves every label
+ * clipped, and nothing re-measures on its own. These tests pin the view's
+ * response to that: re-render once the document's fonts have settled, and
+ * only when there was something to wait for.
+ */
+
+function makeFakeMermaid() {
+ const renders: string[] = []
+ const fake: MermaidLike & { renders: string[] } = {
+ renders,
+ initialize() {},
+ async render(_id: string, code: string) {
+ renders.push(code)
+ const nodes = [...code.matchAll(/(\w+)\[([^\]]*)\]/g)]
+ const svg = [
+ '',
+ ].join('')
+ return { svg }
+ },
+ async parse() {
+ return {}
+ },
+ }
+ return fake
+}
+
+/**
+ * Stand-in for the slice of `FontFaceSet` the view reads. jsdom does not
+ * implement one, and the real thing cannot be driven from a test.
+ */
+function installFontFaceSet(status: 'loading' | 'loaded') {
+ let resolve!: () => void
+ const ready = new Promise((r) => {
+ resolve = r
+ })
+ const fonts = {
+ status,
+ ready,
+ /** the webfont finished loading (or failed) and metrics are now final */
+ settle() {
+ fonts.status = 'loaded'
+ resolve()
+ },
+ }
+ Object.defineProperty(document, 'fonts', { value: fonts, configurable: true, writable: true })
+ return fonts
+}
+
+function removeFontFaceSet() {
+ Object.defineProperty(document, 'fonts', { value: undefined, configurable: true, writable: true })
+}
+
+/** drain microtasks and the macrotask queue so renders in flight land */
+async function flush() {
+ for (let i = 0; i < 5; i++) await new Promise((r) => setTimeout(r, 0))
+}
+
+describe('re-measures when the document fonts land after the first render', () => {
+ let editor: MermaidWysiwygEditor
+ let container: HTMLElement
+ let view: MermaidCanvasView | null
+ let mermaid: ReturnType
+
+ beforeEach(() => {
+ editor = new MermaidWysiwygEditor({ code: CODE })
+ container = document.createElement('div')
+ document.body.appendChild(container)
+ mermaid = makeFakeMermaid()
+ view = null
+ })
+
+ afterEach(() => {
+ view?.destroy()
+ container.remove()
+ // drop the stub so the next test starts from whatever the environment has
+ delete (document as unknown as { fonts?: unknown }).fonts
+ })
+
+ it('re-renders once the fonts finish loading', async () => {
+ const fonts = installFontFaceSet('loading')
+ view = new MermaidCanvasView({ editor, container, mermaid, debounceMs: 0 })
+ await flush()
+ expect(mermaid.renders).toEqual([CODE])
+
+ fonts.settle()
+ await flush()
+
+ // same code, rendered again: the boxes from the first pass were measured
+ // with the fallback font and have to be thrown away
+ expect(mermaid.renders).toEqual([CODE, CODE])
+ })
+
+ it('does not keep re-rendering after the fonts have settled', async () => {
+ const fonts = installFontFaceSet('loading')
+ view = new MermaidCanvasView({ editor, container, mermaid, debounceMs: 0 })
+ await flush()
+ fonts.settle()
+ await flush()
+ await flush()
+
+ expect(mermaid.renders.length).toBe(2)
+ })
+
+ it('arms only one re-measure when several renders land before the fonts do', async () => {
+ const fonts = installFontFaceSet('loading')
+ view = new MermaidCanvasView({ editor, container, mermaid, debounceMs: 0 })
+ await flush()
+
+ // a second render while the fonts are still loading: re-theming, say
+ view.setMermaidConfig({})
+ await flush()
+ expect(mermaid.renders.length).toBe(2)
+
+ fonts.settle()
+ await flush()
+ await flush()
+
+ // one corrective render, not one per render that was waiting
+ expect(mermaid.renders.length).toBe(3)
+ })
+
+ it('does not re-render when the fonts were already available', async () => {
+ installFontFaceSet('loaded')
+ view = new MermaidCanvasView({ editor, container, mermaid, debounceMs: 0 })
+ await flush()
+ await flush()
+
+ expect(mermaid.renders).toEqual([CODE])
+ })
+
+ it('renders normally where the document exposes no font set', async () => {
+ removeFontFaceSet()
+ view = new MermaidCanvasView({ editor, container, mermaid, debounceMs: 0 })
+ await flush()
+ await flush()
+
+ expect(mermaid.renders).toEqual([CODE])
+ })
+
+ it('does not re-render a destroyed view when the fonts land late', async () => {
+ const fonts = installFontFaceSet('loading')
+ view = new MermaidCanvasView({ editor, container, mermaid, debounceMs: 0 })
+ await flush()
+ view.destroy()
+ view = null
+
+ fonts.settle()
+ await flush()
+
+ expect(mermaid.renders).toEqual([CODE])
+ })
+})