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])
+ })
+})