Repository navigation
Conversation
Contributor
There was a problem hiding this comment.
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.
10 of 11 tasks
Rusino
force-pushed
the
webparagraph_layout_perf
branch
from
November 4, 2026 23:25
429dce5 to
b933608
Compare
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
force-pushed
the
webparagraph_layout_perf
branch
from
November 5, 2026 16:30
b933608 to
9d49f7d
Compare
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.
Summary
Eliminates redundant browser
Canvas2Dtext metrics queries and eagerIntl.Segmenterpasses duringWebParagraphfirst layout and re-layout:paragraph.layoutat a new width):TextSpan, yetTextLayout.addLineandTextLayout.performLayoutpreviously issued browsergetSelectionRectsandgetActualBoundingBoxqueries on every visual block on everylayout(width)call.TextBlockadvances andTextLine.trailingSpacesWidthare now derived directly in Dart from the cached cluster advances (TextLayout._clusterSelection), andTextLayout.paintBounds/TextLineink bounds are computed lazily on first read (such aspaint) instead of on everylayout(width).TextLayout.wrapTextresetsellipsisClustersat the start of each layout so a wider re-layout after truncation does not retain stale ellipsis clusters.DomTextCluster.xand lazy word and grapheme segmentation on first layout:layout(),TextWrapper.breakLinesreadscluster.advance.widthfor every cluster in the paragraph, which previously triggeredDomTextMetrics.getSelectionRects(start, end)calls into the browser (~45% of cold layout time on large paragraphs).layoutContextuses defaulttextAlign = 'start', eachDomTextCluster.xis the cluster's left edge in LTR and right edge in RTL, while the vertical selection bounds are(-span.fontBoundingBoxAscent, span.fontBoundingBoxDescent).TextSpan.extractClustersnow derives each cluster'sadvancerectangle directly from adjacentDomTextCluster.xcoordinates (andmetrics.widthat the span boundary), falling back togetSelectionRectsonly when adjacentxcoordinates are tied or non-monotonic with negativeletterSpacing/wordSpacing.AllCodeUnitFlagspreviously ranIntl.Segmenterfor bothgraphemeandwordboundaries eagerly in its constructor, even though line wrapping only querieswhitespace,softLineBreak, andhardLineBreak.AllCodeUnitFlagsnow computeswordBreakandgraphemeflags lazily on the firsthasFlagquery for those flags.TextLayout.extractTextClusterstracks whether clusterstartoffsets are already monotonic across spans and skipsallClusters.sortin the common single-run case, while caching paragraph-widestartandendUTF-16 offsets onTextCluster.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 1measureTextperTextSpan(+ 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-uionengine/src/flutter/lib/web_ui):1. Cold First Layout ($n = 60$ runs)
first_layout, median ofus)us)short('Hello world')latin_long(~880 chars)large(~4,100 chars)multistyle(40 styled spans)rtl_mixed(24 BiDi spans)many_lines(30 hard-break lines)2. Re-layout at Changing Widths ($n = 90$ runs)
relayout, 6 width changes per sample, median ofus)us)latin_longgetSelectionRects+ 17,100getActualBoundingBoxmultistylegetSelectionRects+ 26,700getActualBoundingBoxrtl_mixedgetSelectionRects+ 16,200getActualBoundingBoxmany_linesgetSelectionRects+ 36,000getActualBoundingBoxKey Changes by File
engine/src/flutter/lib/web_ui/lib/src/engine/web_paragraph/paragraph.dart:TextSpan.extractClusters: computes clusteradvancerects from neighboringDomTextCluster.xpositions and span font box metrics in LTR and RTL, falling back togetSelectionRectsonly on tiedxcoordinates or non-monotonic negative spacing.engine/src/flutter/lib/web_ui/lib/src/engine/web_paragraph/code_unit_flags.dart:AllCodeUnitFlags: segmentswhitespaceand V8lineBreakflags eagerly, and defersIntl.SegmenterwordBreakandgraphemesegmentation untilhasFlagrequestsCodeUnitFlag.wordBreakorCodeUnitFlag.grapheme.engine/src/flutter/lib/web_ui/lib/src/engine/web_paragraph/layout.dart:TextLayout._clusterSelection: derivesTextBlock.advanceandTextLine.trailingSpacesWidthfrom cached cluster advances without callinggetSelectionRects.TextLayout.paintBoundsandTextLine._measurePaintBounds: defersgetActualBoundingBoxqueries untilpaintBoundsis read.TextLayout.wrapText: clearsellipsisClusterson every layout.TextLayout.extractTextClusters&TextCluster: caches paragraph-widestartandendoffsets onTextClusterand skipsallClusters.sortwhen clusters are already in ascendingstartorder.engine/src/flutter/lib/web_ui/test/webparagraph/paragraph_relayout_test.dart:measureTextquery 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, andellipsisClustersresets when re-laid out at a wider width.Verification
Tested (rebased on
origin/masterc3f332d1739; Linux x86_64, headless Chrome 157, localwasm_releaseengine build, CanvasKit renderer withpreferWebParagraph: true, test fonts on;baseline = same
origin/masterwithout this PR):dart analyze --fatal-infosonengine/src/flutter/lib/web_ui: PASS.test/webparagraph/*(18 files,feltsuitechrome-dart2js-webparagraph-ui):+276 ~5 -1vs+229 ~5 -1on baseline (+47 new tests, same 1 failure as baseline).test/ui/{paragraph_builder,paragraph_style,text,text_style}_test.dart(dart2js):+26 ~0 -6, same as baseline.packages/fluttertest/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.b4262ef9d46, Linux x86_64 headless Chrome): full webcorpus of 896 framework and 78 engine
web_uitest files, 0 regressions.Not tested: full web corpus on the new base, Skwasm renderer, macOS/Windows, Firefox/Safari.
Pre-launch Checklist
///).Assisted-by: Gemini 4 Argon (Medium)