Skip to content

[web_paragraph] Avoid browser text metrics queries on re-layout and speed up first layout - #195122

Open
Rusino wants to merge 11 commits into
flutter:masterfrom
Rusino:webparagraph_layout_perf
Open

Rusino wants to merge 11 commits into
flutter:masterfrom
Rusino:webparagraph_layout_perf

Conversation

@Rusino

@Rusino Rusino commented Oct 22, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Eliminates redundant browser Canvas2D text metrics queries and eager Intl.Segmenter passes during WebParagraph first layout and re-layout:

  1. Zero browser calls on re-layout (paragraph.layout at a new width):
    • Re-layout only re-wraps clusters that were already measured once per TextSpan, yet TextLayout.addLine and TextLayout.performLayout previously issued browser getSelectionRects and getActualBoundingBox queries on every visual block on every layout(width) call.
    • TextBlock advances and TextLine.trailingSpacesWidth are now derived directly in Dart from the cached cluster advances (TextLayout._clusterSelection), and TextLayout.paintBounds / TextLine ink bounds are computed lazily on first read (such as paint) instead of on every layout(width).
    • TextLayout.wrapText resets ellipsisClusters at the start of each layout so a wider re-layout after truncation does not retain stale ellipsis clusters.
  2. O(1) cluster advances from DomTextCluster.x and lazy word and grapheme segmentation on first layout:
    • During the first layout(), TextWrapper.breakLines reads cluster.advance.width for every cluster in the paragraph, which previously triggered $N$ per-cluster DomTextMetrics.getSelectionRects(start, end) calls into the browser (~45% of cold layout time on large paragraphs).
    • Because layoutContext uses default textAlign = 'start', each DomTextCluster.x is the cluster's left edge in LTR and right edge in RTL, while the vertical selection bounds are (-span.fontBoundingBoxAscent, span.fontBoundingBoxDescent). TextSpan.extractClusters now derives each cluster's advance rectangle directly from adjacent DomTextCluster.x coordinates (and metrics.width at the span boundary), falling back to getSelectionRects only when adjacent x coordinates are tied or non-monotonic with negative letterSpacing / wordSpacing.
    • AllCodeUnitFlags previously ran Intl.Segmenter for both grapheme and word boundaries eagerly in its constructor, even though line wrapping only queries whitespace, softLineBreak, and hardLineBreak. AllCodeUnitFlags now computes wordBreak and grapheme flags lazily on the first hasFlag query for those flags.
    • TextLayout.extractTextClusters tracks whether cluster start offsets are already monotonic across spans and skips allClusters.sort in the common single-run case, while caching paragraph-wide start and end UTF-16 offsets on TextCluster.

Together, for non-ellipsized paragraphs (when the tied-x / negative-spacing fallback and strut measurement are not triggered), these changes reduce browser text metrics queries (measureText, getSelectionRects, getActualBoundingBox) to 1 measureText per TextSpan (+ 1 for the trailing empty cluster span) on first layout and 0 browser queries on subsequent re-layouts.


Performance Impact

Measured in Chrome (felt test --suite chrome-dart2js-webparagraph-ui on engine/src/flutter/lib/web_ui):

1. Cold First Layout (first_layout, median of $n = 60$ runs)

Workload Baseline (us) Optimized (us) Speedup
short ('Hello world') 500 201 -59.8% (2.49x faster)
latin_long (~880 chars) 6,050 2,100 -65.3% (2.88x faster)
large (~4,100 chars) 14,500 4,200 -71.0% (3.45x faster)
multistyle (40 styled spans) 3,600 1,400 -61.1% (2.57x faster)
rtl_mixed (24 BiDi spans) 2,401 1,400 -41.7% (1.72x faster)
many_lines (30 hard-break lines) 6,900 2,500 -63.8% (2.76x faster)

2. Re-layout at Changing Widths (relayout, 6 width changes per sample, median of $n = 90$ runs)

Workload Browser Queries Before (per 600 re-layouts) Browser Queries After Baseline (us) Optimized (us) Speedup
latin_long 34,200 getSelectionRects + 17,100 getActualBoundingBox 0 990.0 555.0 -43.9% (1.78x faster)
multistyle 36,900 getSelectionRects + 26,700 getActualBoundingBox 0 815.0 440.0 -46.0% (1.85x faster)
rtl_mixed 21,300 getSelectionRects + 16,200 getActualBoundingBox 0 470.0 255.1 -45.7% (1.84x faster)
many_lines 36,000 getSelectionRects + 36,000 getActualBoundingBox 0 1,450.0 877.5 -39.5% (1.65x faster)

Key Changes by File

  • engine/src/flutter/lib/web_ui/lib/src/engine/web_paragraph/paragraph.dart:
    • TextSpan.extractClusters: computes cluster advance rects from neighboring DomTextCluster.x positions and span font box metrics in LTR and RTL, falling back to getSelectionRects only on tied x coordinates or non-monotonic negative spacing.
  • engine/src/flutter/lib/web_ui/lib/src/engine/web_paragraph/code_unit_flags.dart:
    • AllCodeUnitFlags: segments whitespace and V8 lineBreak flags eagerly, and defers Intl.Segmenter wordBreak and grapheme segmentation until hasFlag requests CodeUnitFlag.wordBreak or CodeUnitFlag.grapheme.
  • engine/src/flutter/lib/web_ui/lib/src/engine/web_paragraph/layout.dart:
    • TextLayout._clusterSelection: derives TextBlock.advance and TextLine.trailingSpacesWidth from cached cluster advances without calling getSelectionRects.
    • TextLayout.paintBounds and TextLine._measurePaintBounds: defers getActualBoundingBox queries until paintBounds is read.
    • TextLayout.wrapText: clears ellipsisClusters on every layout.
    • TextLayout.extractTextClusters & TextCluster: caches paragraph-wide start and end offsets on TextCluster and skips allClusters.sort when clusters are already in ascending start order.
  • engine/src/flutter/lib/web_ui/test/webparagraph/paragraph_relayout_test.dart:
    • Verifies that first layout issues only 1 measureText query per text span (+ 1 trailing empty cluster span), re-layout across multiple widths issues 0 browser queries, cached cluster, block, and trailing-space geometry matches direct browser queries across scripts, ligatures, BiDi, emoji, placeholders, and negative spacing, and ellipsisClusters resets when re-laid out at a wider width.

Verification

Tested (rebased on origin/master c3f332d1739; Linux x86_64, headless Chrome 157, local
wasm_release engine build, CanvasKit renderer with preferWebParagraph: true, test fonts on;
baseline = same origin/master without this PR):

  • dart analyze --fatal-infos on engine/src/flutter/lib/web_ui: PASS.
  • Engine test/webparagraph/* (18 files, felt suite chrome-dart2js-webparagraph-ui):
    +276 ~5 -1 vs +229 ~5 -1 on baseline (+47 new tests, same 1 failure as baseline).
  • Engine test/ui/{paragraph_builder,paragraph_style,text,text_style}_test.dart (dart2js):
    +26 ~0 -6, same as baseline.
  • Framework packages/flutter test/painting/text_painter_test.dart,
    test/rendering/paragraph_test.dart, test/rendering/paragraph_intrinsics_test.dart
    (flutter test --platform=chrome): +76 ~5 -37, same as baseline.
  • 0 regressions, 0 fixed; all 44 failures also fail on baseline.
  • Earlier patchset (same approach, base b4262ef9d46, Linux x86_64 headless Chrome): full web
    corpus of 896 framework and 78 engine web_ui test files, 0 regressions.

Not tested: full web corpus on the new base, Skwasm renderer, macOS/Windows, Firefox/Safari.

Pre-launch Checklist

  • I read the [Contributor Guide] and followed the process outlined there for submitting PRs.
  • I read the [Tree Hygiene] wiki page, which explains my responsibilities.
  • I read and followed the [Flutter Style Guide], including [Features we expect every widget to implement].
  • I signed the [CLA].
  • I listed at least one issue that this PR fixes in the description above.
  • I updated or added relevant documentation (doc comments with ///).
  • I added new tests to check the change I am making, or this PR is [test-exempt].
  • I followed the [breaking change policy] and added [Data Driven Fixes] where supported.
  • All existing and new tests are passing.

Assisted-by: Gemini 4 Argon (Medium)

@flutter-dashboard flutter-dashboard Bot added the CICD Run CI/CD label Oct 22, 2026
@github-actions github-actions Bot added engine flutter/engine related. See also e: labels. platform-web Web applications specifically team-web Owned by Web platform team labels Oct 23, 2026

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Code Review

This pull request optimizes web paragraph layout performance by lazily extracting word and grapheme boundaries, lazily measuring paint bounds, and caching cluster advances to avoid redundant browser text metrics queries during re-layout. The review feedback suggests adding a defensive guard in _clusterSelection to handle empty ranges gracefully and prevent potential indexing errors.

@dbebawy dbebawy mentioned this pull request Oct 26, 2026
10 of 11 tasks
@flutter-zl
flutter-zl requested a review from mdebbar November 4, 2026 19:17
@Rusino
Rusino force-pushed the webparagraph_layout_perf branch from 429dce5 to b933608 Compare November 4, 2026 23:25
Rusino added 11 commits November 5, 2026 16:17
Every layout(width) created new TextBlocks and asked the span metrics for each block's selection
rect through getSelectionRects, although a re-layout only regroups clusters whose selection rects
are measured once per span and cached. The block advance is now the union of its clusters' cached
rects, which matches the span metrics exactly (paragraph_relayout_test compares both on several
widths); ellipsis blocks still use the span metrics.

Perf: relayout_latin_long 985 -> 880 us (-10.7%, 95% CI [-12.4, -9.1], n=90/90) WIN
Perf: relayout_paint_latin_long 5248 -> 4990 us (-4.9%, 95% CI [-7.0, -3.1], n=90/90) WIN
Perf: relayout_multistyle 825 -> 705 us (-14.5%, 95% CI [-15.9, -13.4], n=90/90) WIN
Perf: relayout_paint_multistyle 3655 -> 3542 us (-3.1%, 95% CI [-4.8, -1.2], n=90/90) WIN
Perf: relayout_rtl_mixed 470 -> 395 us (-16.0%, 95% CI [-17.9, -15.1], n=90/90) WIN
Perf: relayout_paint_rtl_mixed 2750 -> 2690 us (-2.2%, 95% CI [-3.1, -1.1], n=90/90) WIN
Perf: relayout_many_lines 1430 -> 1195 us (-16.4%, 95% CI [-17.6, -15.8], n=90/90) WIN
Perf: relayout_paint_many_lines 5360 -> 5010 us (-6.5%, 95% CI [-8.7, -5.0], n=90/90) WIN

Assisted-by: Claude Opus 5.5 (Medium)
…r rects

Each layout(width) also asked the span metrics for the selection of every block's trailing
whitespace. Those clusters are cached like the rest of the span, so the width now comes from
their rects as well; paragraph_relayout_test checks block and line widths against the span
metrics.

Perf: relayout_latin_long 880 -> 780 us (-11.4%, 95% CI [-13.4, -9.8], n=90/90) WIN
Perf: relayout_paint_latin_long 4773 -> 4718 us (-1.2%, 95% CI [-2.6, -0.0], n=90/90) NOISE
Perf: relayout_multistyle 705 -> 635 us (-9.9%, 95% CI [-10.6, -7.3], n=90/90) WIN
Perf: relayout_paint_multistyle 3468 -> 3473 us (+0.1%, 95% CI [-1.4, +1.0], n=90/90) NOISE
Perf: relayout_rtl_mixed 390 -> 360 us (-7.7%, 95% CI [-8.8, -6.3], n=90/90) WIN
Perf: relayout_paint_rtl_mixed 2645 -> 2645 us (+0.0%, 95% CI [-0.9, +0.7], n=90/90) NOISE
Perf: relayout_many_lines 1198 -> 1185 us (-1.0%, 95% CI [-1.9, +0.0], n=90/90) NOISE
Perf: relayout_paint_many_lines 4958 -> 4932 us (-0.5%, 95% CI [-2.3, +1.3], n=90/90) NOISE

Assisted-by: Claude Opus 5.5 (Medium)
…layout

Every layout(width) queried getActualBoundingBox for each new text block to fold its ink bounds
into the line and paragraph paint bounds, which only the painter reads. Line ink bounds are now
measured on first use and the paragraph paint bounds are computed on first read after a layout,
so a re-layout makes no ink queries and a paint pays for them once.

Perf: relayout_latin_long 797.5 -> 555 us (-30.4%, 95% CI [-31.9, -29.3], n=90/90) WIN
Perf: relayout_paint_latin_long 5098 -> 4748 us (-6.9%, 95% CI [-8.9, -3.7], n=90/90) WIN
Perf: relayout_multistyle 640 -> 440 us (-31.2%, 95% CI [-32.0, -30.6], n=90/90) WIN
Perf: relayout_paint_multistyle 3565 -> 3438 us (-3.6%, 95% CI [-4.7, -2.0], n=90/90) WIN
Perf: relayout_rtl_mixed 360 -> 255.1 us (-29.2%, 95% CI [-30.1, -27.8], n=90/90) WIN
Perf: relayout_paint_rtl_mixed 2627 -> 2590 us (-1.4%, 95% CI [-1.8, -0.4], n=90/90) NOISE
Perf: relayout_many_lines 1185 -> 877.5 us (-26.0%, 95% CI [-26.5, -25.4], n=90/90) WIN
Perf: relayout_paint_many_lines 4972 -> 5032 us (+1.2%, 95% CI [-0.9, +2.9], n=90/90) NOISE

Assisted-by: Claude Opus 5.5 (Medium)
TextWrapper.ellipsize stored the shaped ellipsis in TextLayout.ellipsisClusters, which nothing
cleared, so after a layout that truncated the text every later layout attached an ellipsis to
its lines, even at a width where the whole text fits, and re-measured it on every line.
wrapText now starts each layout without an ellipsis.

Assisted-by: Claude Opus 5.5 (Medium)
debugTextMetricsQueryCount counts measureText, getSelectionRects and getActualBoundingBox calls,
and paragraph_relayout_test asserts that re-layout at several widths makes none, that the first
paint bounds read after it does, and that a second read does not. The geometry tests gain
zero-width, combining, CJK, CR LF and negative letter spacing inputs and aligned paint bounds
with a trailing newline. Reading paint bounds before the first layout now asserts instead of
returning an inverted infinite rect.

Assisted-by: Claude Opus 5.5 (Medium)
First layout queried getSelectionRects once per cluster during line wrapping (~45% of cold layout time on large paragraphs). In default start alignment, each cluster's x is its left edge in LTR and its right edge in RTL while the vertical selection bounds are the span's font box, so TextSpan.extractClusters derives cluster advances directly from neighboring x coordinates and falls back to getSelectionRects only on tied x coordinates or non-monotonic negative spacing.

Perf: first_layout_short 500 -> 301 us (-39.8%, 95% CI [-40.0, +0.0], n=60/60) NOISE
Perf: first_layout_latin_long 6050 -> 3000 us (-50.4%, 95% CI [-54.1, -43.8], n=60/60) WIN
Perf: first_layout_large 1.45e+04 -> 6000 us (-58.6%, 95% CI [-59.7, -56.7], n=60/60) WIN
Perf: first_layout_multistyle 3600 -> 2000 us (-44.4%, 95% CI [-47.4, -44.4], n=60/60) WIN
Perf: first_layout_rtl_mixed 2401 -> 1699 us (-29.2%, 95% CI [-36.0, -29.2], n=60/60) WIN
Perf: first_layout_many_lines 6900 -> 3300 us (-52.2%, 95% CI [-52.9, -51.5], n=60/60) WIN
Assisted-by: Gemini 4 Argon (Medium)
AllCodeUnitFlags previously ran Intl.Segmenter for both grapheme and word boundaries eagerly during construction, even though layout only queries whitespace and line-break flags. Populate whitespace and V8 line-break flags eagerly and compute wordBreak and grapheme flags on the first matching hasFlag query.

Perf: first_layout_short 300 -> 201 us (-33.0%, 95% CI [-42.9, +0.0], n=60/60) NOISE
Perf: first_layout_latin_long 2850 -> 2250 us (-21.0%, 95% CI [-31.2, -12.7], n=60/60) WIN
Perf: first_layout_large 6000 -> 4500 us (-25.0%, 95% CI [-27.9, -23.7], n=60/60) WIN
Perf: first_layout_multistyle 1950 -> 1500 us (-23.1%, 95% CI [-25.0, -21.1], n=60/60) WIN
Perf: first_layout_rtl_mixed 1700 -> 1399 us (-17.7%, 95% CI [-23.5, -15.2], n=60/60) WIN
Perf: first_layout_many_lines 3300 -> 2600 us (-21.2%, 95% CI [-24.3, -18.8], n=60/60) WIN

Assisted-by: Gemini 4 Argon (Medium)
…ster bounds

Spans are in logical order and single-run spans emit clusters in ascending start order, so TextLayout.extractTextClusters only needs to sort allClusters when a multi-run BiDi span emits clusters out of logical order. Cache paragraph-wide start and end UTF-16 offsets on TextCluster so cluster insertion, mapping, and line wrapping avoid repeated span.start additions.

Perf: first_layout_short 201 -> 201 us (+0.0%, 95% CI [-33.1, +49.3], n=60/60) NOISE
Perf: first_layout_latin_long 2200 -> 2100 us (-4.5%, 95% CI [-8.7, +4.6], n=60/60) NOISE
Perf: first_layout_large 4400 -> 4200 us (-4.6%, 95% CI [-8.9, -2.3], n=60/60) WIN
Perf: first_layout_multistyle 1500 -> 1400 us (-6.7%, 95% CI [-6.7, +0.0], n=60/60) NOISE
Perf: first_layout_rtl_mixed 1400 -> 1400 us (+0.0%, 95% CI [+0.0, +7.1], n=60/60) NOISE
Perf: first_layout_many_lines 2450 -> 2500 us (+2.0%, 95% CI [-4.0, +4.2], n=60/60) NOISE

Assisted-by: Gemini 4 Argon (Medium)
Both callers of _clusterSelection guard empty text ranges before mapping, so assert at the helper boundary that the mapped cluster range is non-empty and within allClusters bounds.

Assisted-by: Gemini 4 Argon (Medium)
Canvas2D start alignment anchors the first visual cluster at 0.0 in both LTR and RTL, so only enter the single-run fast path when lastX == 0.0. Wrap three TextCluster comments to stay strictly within 100 columns.

Assisted-by: Gemini 3.8 Flash (High)
…, and didExceedMaxLines on re-layout

Normalize disjoint bidiLinePhysicalTextRange.intersect(span) slices to
TextRange.collapsed(bidiLineSpanTextRange.start) so a clusterless character
before a styled hard line break neither passes an inverted range to
_mapping.toClusterRange nor leaves (-1, -1) in TextBlock.physicalTextRange. Fall
back to span endpoint selection queries for block and trailing-space advances
when negative spacing inverts visual cluster order inside a TextSpan. Reset
paragraph.didExceedMaxLines alongside ellipsisClusters at the start of wrapText
so a wider re-layout after truncation clears maxLines overflow state.

Without these fixes, the three new tests in paragraph_relayout_test.dart fail
with ArgumentError 'TextRange [6:5) is out of paragraph text range',
Rect(0.0, -19.0, 28.3, 5.0) instead of Rect(0.0, -19.0, 21.1, 5.0), and
didExceedMaxLines == true instead of false.

Test: felt test --suite chrome-dart2js-webparagraph-ui test/webparagraph/paragraph_relayout_test.dart (47 tests)

Assisted-by: Gemini 4 Carbon (High)
@Rusino
Rusino force-pushed the webparagraph_layout_perf branch from b933608 to 9d49f7d Compare November 5, 2026 16:30
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CICD Run CI/CD engine flutter/engine related. See also e: labels. platform-web Web applications specifically team-web Owned by Web platform team

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant