Skip to content

fix: Recalculate bounding boxes after fonts load - #8094

Merged
camdecoster merged 6 commits into
mainfrom
cam/8089/replot-after-font-load
Oct 2, 2026
Merged

camdecoster merged 6 commits into
mainfrom
cam/8089/replot-after-font-load

Conversation

@camdecoster

@camdecoster camdecoster commented Sep 30, 2026 •

Copy link
Copy Markdown
Contributor

Description

Recalculate bounding boxes after fonts load to ensure they are sized properly.

Closes #8089.

Changes

  • Track which fonts aren't loaded yet and redraw when they settle
  • Clean up tracking token during purge
  • Add tests
  • Update internal type
  • Add utility for parsing font families

Screenshots

Before After
image image

Testing

  • Be on main
  • Open plotly devtools and paste the following into the browser devtools console:
    // A new family and URL on every run, so the plot always draws before the font arrives
    const family = `JetBrainsMono${Date.now()}`;
    const face = new FontFace(
        family,
        `url(https://cdn.jsdelivr.net/npm/@fontsource/jetbrains-mono@5.0.20/files/jetbrains-mono-latin-400-normal.woff2?v=${Math.random()})`
    );
    document.fonts.add(face);
    
    const gd = Tabs.fresh();
    Plotly.newPlot(
        gd,
        [
            { type: 'bar', x: ['NO', 'DE', 'CA'], y: [30, 12, 10], name: 'Gold: a test of sizes' },
            { type: 'bar', x: ['NO', 'DE', 'CA'], y: [8, 10, 5], name: 'Silver: a second test of sizes' }
        ],
        {
            font: { family, size: 13 },
            legend: { borderwidth: 1, x: 0.02, y: 0.98 },
            width: 500,
            height: 300,
            annotations: [
                {
                    x: 1.02, y: 0, xref: 'paper', yref: 'paper', xanchor: 'left', yanchor: 'bottom',
                    text: 'Source: official list', textangle: -90, showarrow: false,
                    bgcolor: 'rgba(0,0,0,0.15)', borderwidth: 1
                }
            ]
        }
    );
  • Note that the text boxes have the incorrect sizes
  • Switch to this branch
  • Run the snippet again
  • Note that the text boxes have the correct sizes

Notes

  • The first draw is synchronous, so plots with no specified fonts shouldn't see any change with this update
  • Plots with fonts specified that are drawn before the fonts load will be redrawn after those fonts load
    • The redraw costs about as much as the first draw
    • It happens once per font load
    • Here are some performance numbers from Chrome running locally:
      Plots First draw Redraws after the font loads
      1 small bar chart 18 ms 21 ms
      20 small bar charts (3 runs) 112-139 ms 94-185 ms
      1 plot with a 50,000-point SVG scatter 596 ms 593 ms
      20 plots with a 5,000-point SVG scatter each 1.6 s 1.3 s
    • The redraws run synchronously in one task.
  • Plots with fonts specified that are drawn after the fonts load will be drawn once with the correct sizes
  • Purged plots don't get redrawn
  • The redraw fires plotly_beforeplot and plotly_afterplot again
  • To skip the extra draw, wait for the font before the first draw:
    await document.fonts.load('12px "Open Sans"');
    await Plotly.newPlot(gd, data, layout);
  • document.fonts.ready does not work for this, because ready does not start the load of a font that no text uses yet
  • A WebKit bug prevents use of the loadingdone event on the document fonts
  • This fix does not cover text drawn on a canvas, such as scattergl text and gl3d labels. A plot redraws only when an SVG element in the plot uses the loaded family.
  • This fix is related to Axis labels and tick labels in 3D plots do not use fonts loaded from remote server #7413, but it doesn't actually fix that issue. gl-axes3d caches each label mesh by font family for the life of the page, so gl3d labels keep the fallback font after the redraw.

@camdecoster
camdecoster marked this pull request as ready for review September 30, 2026 23:17
Comment thread src/types/core/graph-div.internal.d.ts
Comment thread src/plot_api/plot_api.js
*/
async function redrawAfterFontLoads(gd, unloadedFaces) {
// The draw started these loads, or they finished during the draw
const faces = unloadedFaces.filter((face) => ['loaded', 'loading'].includes(face.status));

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So the other options apparently are unloaded and error (or possibly failed, MDN is inconsistent about this). Makes sense to ignore errors at this point, as there's nothing more to wait for. Can we be confident that every relevant font will have been transitioned from unloaded to loading synchronously? I guess we have to assume this, as looking at an existing site I see various fonts staying indefinitely in the unloaded state, presumably because it was declared but never used on the page.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we can be confident in this. That's why my testing showed. If real world use tells a different story, we can tweak the check (or force the fonts to load ourselves).

@camdecoster
camdecoster merged commit 67cc7fa into main Oct 2, 2026
88 checks passed
@camdecoster
camdecoster deleted the cam/8089/replot-after-font-load branch October 2, 2026 12:00
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[BUG]: Text extents are not recomputed after web font loading

2 participants