RS-22108: Wait for fonts before laying out the heatmap - #60
Open
JustinCCYap wants to merge 10 commits into
Open
Conversation
Labels are sized by measuring them in the DOM, so a font that arrives after layout leaves them positioned and truncated for the fallback font's metrics. On screen any resize re-renders and hides this, but an image export renders once, so the exported chart does not match what Displayr shows. Load every configured font family and wait for the document's fonts, bounded by a timeout, before rendering. The widget status is now set to loading before that wait, so an export cannot screenshot the chart while it is still waiting. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
jsdom does not allow document to be reassigned, so the stubbed font set was being ignored and waitForFonts took its no font set path. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Quote the family in the font shorthand so it is always parseable, which removes the need to guard against a synchronous throw, flatten the two nested loops into one, and drop the timeout constant from the exports since nothing imports it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
heatmapcore reached outside itself to set rhtmlwidget-status on its container, which left the loading claim in heatmapOuter and its release in heatmapcore. Move the ready write to heatmapOuter, so the whole lifecycle sits with the code that owns the render. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
waitForFonts is evaluated as an argument to Promise.all, so a synchronous throw escapes before the render chain has a catch, and the status stays at loading until the export times out. Blink throws rather than rejects when it cannot parse the font shorthand, and quoting the family does not rule that out for a family name containing a quote or a trailing backslash, so restore the guard removed in 9efdba3. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A resize renders from scratch without cancelling the render it interrupts, so an older chain can settle after its svg has been discarded. Both status writes now check that this render's svg is still in the container, so a stale chain cannot mark a newer, unfinished chart as ready. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
JustinCCYap
marked this pull request as ready for review
August 21, 2026 07:23
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes RS-22108, and its duplicate RS-23543: heatmap axis labels are shifted and truncated in a PPT or PDF export when the document uses a custom font, intermittently and not reproducibly.
Diagnosis
The chart sizes its axis bands by measuring throwaway
<text>elements in the live DOM (rhtmlLabelUtilsgetSingleLineLabelDimensions, called fromtheSrc/scripts/lib/components/parts/labelUtilsWrapper.js:52, consumed atyAxis.js:34-38andxAxis.js:59-75), and it never re-measures.In the Displayr export page the custom fonts arrive through an asynchronous CSS
@import, so the chart can lay out with fallback font metrics. The widget then reported itself ready synchronously, andrhtmlwidget-statusis the only readiness signal the export screenshot waits on. The custom font swaps in after layout, leaving the labels in bands sized for the wrong font, so they shift, truncate with an ellipsis, or are clipped at the SVG edge.On screen the problem is usually invisible because any resize triggers a full re-render and re-measure (
rhtmlHeatmap.factory.js:29-32). An export renders once, which is why only the export is affected, and why identical steps give different results: it is a race against the font load.Solution
The chart now waits for the fonts it draws with before it measures anything.
theSrc/scripts/lib/fonts.js.fontFamiliesInUse(options)returns the distinct values of every*_font_familyoption, so no hardcoded list of components is needed.waitForFonts(options)callsdocument.fonts.load()for each family in normal and bold, then awaitsdocument.fonts.ready, bounded by a 3s timeout so an unloadable font can only delay the chart, never prevent it. The explicitload()matters:fonts.readyalone can resolve before a face that nothing has rendered yet is fetched.heatmapOuter.jsgates rendering onPromise.all([loadImage(image), waitForFonts(options)]), so fonts are awaited alongside the image data and cost no extra time when already available.heatmapOuter.jsclaimsrhtmlwidget-statusas loading before the asynchronous work starts. It was previously only claimed inside theHeatmapconstructor, which runs after the image load, so during the wait an export would see a widget that is not loading and could screenshot an empty SVG.heatmapOuter.jsreports ready on the error path too, so a failed render cannot leave an export waiting out its screenshot timeout.Two hardening changes came out of reviewing the above, both of which matter only because step 3 widened the window between claiming loading and reporting ready:
fonts.load()call is guarded against a synchronous throw.waitForFontsis evaluated as an argument toPromise.all, so a throw would escape before the chain has acatchand leave the status at loading; Blink throws rather than rejects when it cannot parse a font shorthand.svgis still in the container. A resize re-renders without cancelling the render it interrupts, so an older chain could otherwise settle and mark a newer, unfinished chart ready.Included refactor
heatmapcoreno longer writesrhtmlwidget-statuson its container — it was reaching outside itself to set an attribute on its parent, which left the claim in one file and the release in another. All three transitions now sit inheatmapOuter.js, the code that owns the render. Behaviour and timing are unchanged, andheatmapOuter.jsis the only caller ofheatmapcoreanywhere in the Displayr org.Tests
theSrc/scripts/lib/fonts.jest.test.jscovers the option collection and the failure paths that must not stop a chart rendering: an unloadable font, a synchronous throw fromload, a font set with noloadmethod, nodocument.fontsat all, and a font set that never becomes ready.gulp testSpecsis 11 green.Not unit covered, and worth a reviewer's eye: the status lifecycle and the stale-render guard in
heatmapOuter.js, which needs a DOM,Imageand canvas to exercise and has no harness in this repo. The visual regression suite waits ondiv[rhtmlwidget-status=ready], so a broken ready transition would show up there as timeouts.Warning
Author: before requesting review, attach evidence the bug is fixed.
A screenshot, screen recording, test log, or step-by-step repro showing the
original failure no longer happens. A reviewer cannot tell whether this PR
actually fixes the ticket from the diff alone - make it easy for them.
🤖 Generated with Claude Code