From e4b84b72abac255f5604776632af1662a453f7f4 Mon Sep 17 00:00:00 2001 From: Mario Mohar <76663003+Mario-Mohar@users.noreply.github.com> Date: Thu, 24 Sep 2026 17:32:21 +0200 Subject: [PATCH] fix(site): measure the longest string width instead of estimating it (#2411) Co-authored-by: henrygd --- .../network-monitors-columns.tsx | 2 +- .../network-monitors-table.tsx | 6 ++- internal/site/src/lib/stores.ts | 5 ++ internal/site/src/lib/systemsManager.ts | 25 +++++++--- internal/site/src/lib/utils.ts | 46 ++++++++++++++++++- 5 files changed, 73 insertions(+), 11 deletions(-) diff --git a/internal/site/src/components/network-monitors-table/network-monitors-columns.tsx b/internal/site/src/components/network-monitors-table/network-monitors-columns.tsx index eebbb3a1..b6cccb24 100644 --- a/internal/site/src/components/network-monitors-table/network-monitors-columns.tsx +++ b/internal/site/src/components/network-monitors-table/network-monitors-columns.tsx @@ -138,7 +138,7 @@ export function getMonitorColumns( ), - [status, name] + [status, name, longestSystemName] ) }, }, diff --git a/internal/site/src/components/network-monitors-table/network-monitors-table.tsx b/internal/site/src/components/network-monitors-table/network-monitors-table.tsx index 793862e3..6618d234 100644 --- a/internal/site/src/components/network-monitors-table/network-monitors-table.tsx +++ b/internal/site/src/components/network-monitors-table/network-monitors-table.tsx @@ -36,7 +36,7 @@ import { useToast } from "@/components/ui/use-toast" import { isReadOnlyUser, queueUserSettings } from "@/lib/api" import { pb } from "@/lib/api" import { SystemStatus } from "@/lib/enums" -import { $allSystemsById, $direction, $userSettings, getUserChartTime } from "@/lib/stores" +import { $allSystemsById, $direction, $textMeasureVersion, $userSettings, getUserChartTime } from "@/lib/stores" import { cn, formatShortDate, isVisuallyLonger, matchesFilterGroups, parseFilterGroups, parseSemVer } from "@/lib/utils" import type { ChartData, MonitorCertInfo, NetworkMonitorRecord } from "@/types" import { AddMonitorDialog, EditMonitorDialog } from "./monitor-dialog" @@ -145,6 +145,8 @@ export default function NetworkMonitorsTableNew({ [sortSettingsKey, sortStorageKey] ) + // recompute when measured widths are invalidated (e.g. web font finished loading) + const textMeasureVersion = useStore($textMeasureVersion) const longestTarget = useMemo(() => { let longestTarget = "" for (const p of monitors) { @@ -153,7 +155,7 @@ export default function NetworkMonitorsTableNew({ } } return longestTarget - }, [monitors]) + }, [monitors, textMeasureVersion]) const runMonitorBatch = useCallback( async (ids: string[], enqueue: (batch: ReturnType, id: string) => void) => { diff --git a/internal/site/src/lib/stores.ts b/internal/site/src/lib/stores.ts index fe0199b7..eb0e304c 100644 --- a/internal/site/src/lib/stores.ts +++ b/internal/site/src/lib/stores.ts @@ -93,3 +93,8 @@ export const $direction = atom<"ltr" | "rtl">("ltr") /** Longest system name string. Used to reserve width in virtualized tables. */ export const $longestSystemName = atom("") + +/** Incremented when measured text widths are invalidated (e.g. web font finished loading). + * Anything that caches a comparison from isVisuallyLonger should recompute when this changes. + */ +export const $textMeasureVersion = atom(0) diff --git a/internal/site/src/lib/systemsManager.ts b/internal/site/src/lib/systemsManager.ts index 287d58ee..1a222a85 100644 --- a/internal/site/src/lib/systemsManager.ts +++ b/internal/site/src/lib/systemsManager.ts @@ -7,6 +7,7 @@ import { $downSystems, $longestSystemName, $pausedSystems, + $textMeasureVersion, $upSystems, } from "@/lib/stores" import { isVisuallyLonger, updateFavicon } from "@/lib/utils" @@ -67,6 +68,11 @@ export function init() { // run things that need to be done when systems change onSystemsChanged(newSystems, newSystem, oldSystem) }) + + // widths measured with the fallback font may rank names differently, so recompute once they're invalidated + $textMeasureVersion.listen(() => { + $longestSystemName.set(findLongestName($allSystemsById.get())) + }) } /** Update the longest system name string and favicon based on system status */ @@ -78,13 +84,7 @@ function onSystemsChanged(systems: Record, newSystem?: Sys // otherwise, if the changed system's new name is longer than the current longest, update it const longestName = $longestSystemName.get() if (oldSystem?.name === longestName && oldSystem.name !== newSystem?.name) { - let newLongest = "" - for (const id in systems) { - if (isVisuallyLonger(systems[id].name, newLongest)) { - newLongest = systems[id].name - } - } - $longestSystemName.set(newLongest) + $longestSystemName.set(findLongestName(systems)) } else if (newSystem && newSystem.name !== longestName && isVisuallyLonger(newSystem.name, longestName)) { $longestSystemName.set(newSystem.name) } @@ -92,6 +92,17 @@ function onSystemsChanged(systems: Record, newSystem?: Sys updateFavicon(downSystems.length) } +/** Find the visually longest system name */ +function findLongestName(systems: Record): string { + let longest = "" + for (const id in systems) { + if (isVisuallyLonger(systems[id].name, longest)) { + longest = systems[id].name + } + } + return longest +} + /** Fetch systems from collection */ async function fetchSystems(): Promise { try { diff --git a/internal/site/src/lib/utils.ts b/internal/site/src/lib/utils.ts index 0b06f63b..69c6a1eb 100644 --- a/internal/site/src/lib/utils.ts +++ b/internal/site/src/lib/utils.ts @@ -7,7 +7,7 @@ import { twMerge } from "tailwind-merge" import { toast } from "@/components/ui/use-toast" import type { ChartTimeData, FingerprintRecord, SemVer, SystemRecord } from "@/types" import { HourFormat, Unit } from "./enums" -import { $copyContent, $userSettings } from "./stores" +import { $copyContent, $textMeasureVersion, $userSettings } from "./stores" export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)) @@ -451,6 +451,45 @@ export function runOnce any>(fn: T): T { const visualWidthCache = new Map() +let measureContext: CanvasRenderingContext2D | null | undefined +let measureFont = "" + +/** Canvas context for measuring text in the font the app renders with, or null where canvas is unavailable. + * Only relative widths matter here, so the font size is arbitrary. + */ +function getMeasureContext(): CanvasRenderingContext2D | null { + if (measureContext === undefined) { + measureContext = document.createElement("canvas").getContext("2d") + // the fallback font has different metrics, so re-measure whenever a font finishes loading. + // loadingdone also covers fonts that start loading after the first measurement, + // which fonts.ready does not if it has already resolved. + if (measureContext && "fonts" in document) { + document.fonts.addEventListener("loadingdone", invalidateVisualWidths) + } + } + if (measureContext) { + const { fontFamily, fontWeight } = getComputedStyle(document.body) + const font = `${fontWeight} 16px ${fontFamily}` + if (font !== measureFont) { + const isFirstFont = !measureFont + measureFont = font + measureContext.font = font + visualWidthCache.clear() + // defer so stores aren't updated in the middle of a comparison or a render + if (!isFirstFont) { + queueMicrotask(invalidateVisualWidths) + } + } + } + return measureContext +} + +/** Drop cached widths and notify anything holding a result from isVisuallyLonger */ +function invalidateVisualWidths() { + visualWidthCache.clear() + $textMeasureVersion.set($textMeasureVersion.get() + 1) +} + /** Get the visual width of a string, accounting for full-width and narrow punctuation characters. * Don't use for monospaced fonts, use .length instead */ @@ -459,6 +498,11 @@ function getVisualStringWidth(str: string): number { if (cached !== undefined) { return cached } + const measured = getMeasureContext()?.measureText(str).width + if (measured !== undefined) { + visualWidthCache.set(str, measured) + return measured + } let width = 0 for (const char of str) { if (char === ".") {