Skip to content

Commit 085ebfd

Browse files
vietnguyentuan2019Nguyễn Tuấn Việt
andauthored
feat: v1.1.2 — binary search selection, devtools v1.0.0, 3-pipeline CI
* fix(semantics): remove hand-rolled SemanticsNode creation that caused parentDataDirty assertion assembleSemanticsNode() was calling _buildSemanticNodes() which created brand-new SemanticsNode() objects on every call. Newly created nodes are not attached to the SemanticsOwner, so node.updateWith() calls _adoptChild() on them → parentDataDirty = true. PipelineOwner.flushSemantics() then walks the full tree in debug mode and fires: '!semantics.parentDataDirty': is not true Fix: simplify assembleSemanticsNode() to only forward the children already provided by Flutter's pipeline (child RenderObjects such as HyperDetailsWidget, HyperTable, CodeBlockWidget). The full text label set in describeSemanticsConfiguration() remains readable for TalkBack / VoiceOver. Also removed the now-dead _buildSemanticNodes(), _buildNodeRectCache(), _nodeRectCache, and related helpers to keep the codebase clean. * perf/fix: three code-review improvements - computeMinIntrinsicWidth: reuse two shared TextPainter instances (LTR and RTL) across all fragments instead of allocating one per fragment, then dispose both after the loop. Reduces native object churn on large tables that call this method per cell. - didHaveMemoryPressure: extend cleanup to also drain LazyImageQueue's pending (not-yet-started) load queue and clear Flutter's own decoded- image cache (PaintingBinding.imageCache). Previously only RenderHyperBox caches were cleared, leaving pending network loads and GPU textures uncollected on low-memory devices. - LazyImageQueue.clearPending(): new public method that drains the pending queue without touching in-flight loads. Called by didHaveMemoryPressure and available to callers that need finer-grained memory control. - _evaluateCalcExpr: emit a debugPrint (debug builds only, via assert) when a calc() expression contains a % unit that cannot be resolved at parse time, so developers can detect which expressions fall back silently. * feat(devtools): complete hyper_render_devtools integration Previously the devtools package existed but was entirely non-functional: renderers were never registered (registry always empty), the UI showed hardcoded placeholder data, and the devtools_extensions SDK was commented out. This commit wires all the pieces together: HyperRenderDebugHooks (new, in hyper_render_core): Static callback slots that RenderHyperBox calls at attach/detach/ performLayout. Avoids circular dependency — hyper_render_core never imports hyper_render_devtools. Zero overhead in release builds (all guards are behind kDebugMode / null checks). RenderHyperBox auto-registration: - Stable _debugId per instance (identityHashCode-based) - attach() → HyperRenderDebugHooks.onRendererAttached - detach() → HyperRenderDebugHooks.onRendererDetached (new override) - performLayout end → HyperRenderDebugHooks.onLayoutComplete (lazy getFragments/getLines getters — serialised only when DevTools reads) service_extensions.dart: - register() now injects all three hooks automatically — no per-widget setup required from the user - Added ext.hyperRender.getFragments — returns fragment list + line list from the last layout pass - Added ext.hyperRender.getPerformance — returns fragment/line counts as baseline; pluggable via HyperRenderDebugHooks.getPerformanceData for full PerformanceMonitor timing - Switched from manual JSON string building to dart:convert jsonEncode - Registry stores lastFragments/lastLines per renderer devtools_ui/main.dart (full rewrite): - Wraps app in DevToolsExtension widget (devtools_extensions SDK) - All three tabs call real service extensions via serviceManager.callServiceExtensionOnMainIsolate - Renderer dropdown — select among multiple active renderers - UDT Tree: clicking a node calls getNodeStyle and switches to Style tab - Style: shows actual computed style from the running app - Layout: shows real fragment list (capped at 200 rows) + line list + performance summary devtools_ui/pubspec.yaml: - Added devtools_extensions: ^0.2.0 * docs/feat(heuristics): document form gap and add hasForms() check HyperRender is read-only — it does not support <form>, <input>, <select>, <textarea>, or submit buttons. This is a frequent BA gap when requirements include "an article with an embedded survey at the bottom." Changes: - Library-level doc block: explains the read-only constraint and three recommended decision patterns: A. WebView fallback for the whole screen B. Native Flutter Form below HyperRender (preferred for new work) C. Strip form tags via HtmlSanitizer when form is cosmetic only - New HtmlHeuristics.hasForms(html): dedicated boolean check for form/input/select/textarea/<button type="submit">. Lets BA-driven routing logic express intent clearly rather than relying on the broader isComplex() gate. - hasUnsupportedElements() now delegates form detection to hasForms() for a single source of truth. * fix(P0): replace unbounded image cache with LRU eviction RenderHyperBox._imageCache was a plain Map<String, CachedImage> with no eviction policy. On a document with 100 high-res images, all decoded ui.Image GPU textures were held for the entire lifetime of the widget, causing unbounded GPU memory growth and OOM crashes on low-RAM Android devices. Fix: replace Map with the existing _LruCache<String, CachedImage> pattern, mirroring how _textPainters already works. - hyper_render_config.dart: New imageCacheSize field (default 30, low-end 10, tablet 60+). Added device-tier docs with recommended values. - render_hyper_box.dart: _imageCache is now _LruCache<String, CachedImage> with onEvict: (ci) => ci.image?.dispose(). LRU evicts the oldest-untouched image and frees its GPU texture. _disposeImages() simplified: _LruCache.clear() calls onEvict on every entry — the manual dispose loop is no longer needed. All _imageCache[src] = ... writes changed to _imageCache.put(). - render_hyper_box_paint.dart: _paintImage now calls _imageCache.get(src) instead of _imageCache[src]. get() promotes the entry to most-recently-used, so images that are actively being painted are never evicted mid-session. On a null return (cache miss after eviction): show shimmer and schedule _loadImage(src) via addPostFrameCallback so state is never mutated inside paint(). The re-fetch deduplicates via LazyImageQueue. * perf(P0): eliminate regex re-compilation and intrinsic-width O(N) jank ## Problem 1 — StyleResolver: 43 inline RegExp instantiations Every call to _parseColor, _parseGradient, _calculateSpecificity, _matchesSelector, _extractPseudoClasses, etc. created a new RegExp object. Dart compiles regex to a DFA on first instantiation; re-creating the same pattern in a hot path allocates a new object and re-compiles every call. With 5000+ styled nodes this produced tens of thousands of short-lived RegExp objects and measurable GC pressure. Fix: new _Re abstract final class holding 33 static final compiled patterns grouped by purpose (selectors, specificity, combinators, pseudo-classes, value functions, colors, layout, filters). All 43 inline RegExp(...) calls replaced with _Re.xxx references. Zero functional change — patterns are identical. ## Problem 2 — computeMinIntrinsicWidth: O(totalWords) → O(fragments) The previous implementation split every fragment's text on whitespace and called TextPainter.layout() for each individual word. For a 3000- word article this means ~3000 synchronous layout calls on the main thread, causing 200–400 ms jank when the widget is wrapped in IntrinsicWidth or DataTable. Fix: for each text fragment, find the single longest word by character count (an O(W) scan with no layout) and measure only that one word. The longest measured word is almost always the longest-character word; the only edge case is short wide-glyph strings vs long narrow-glyph strings, which is negligible for real prose. TextPainter calls drop from O(totalWords) to O(fragmentCount) — one call per fragment. Added _kWhitespaceSplitter library-level final to avoid re-compiling the \s+ regex on every computeMinIntrinsicWidth call. * fix(core): P1 edge-case hardening — tap slop, deep-link schemes, calc depth - Replace hardcoded tapThreshold=8.0 with computeHitSlop(event.kind, GestureBinding.instance.gestureSettings) so tap detection matches platform gesture physics (mouse: 1 px, touch: ~18 px). - Add HyperRenderConfig.extraLinkSchemes (Set<String>) so apps can permit their own deep-link schemes (e.g. 'myapp', 'shopee') without bypassing the built-in safe set (http/https/mailto/tel). - Cap _evaluateCalcInValue loop with _kMaxCalcDepth=8 to prevent an adversarially crafted calc(calc(calc(...))) with 1000 nesting levels from looping indefinitely; add a no-progress early-exit guard. * fix(a11y,perf): P2 — viewport semantics for headings/links + skip measureFragments on details toggle Accessibility (WCAG 2.1 AA): - assembleSemanticsNode now builds individual SemanticsNodes for h1–h6 heading blocks (isHeader: true) and <a href> links (isLink: true + onTap). TalkBack/VoiceOver users can now navigate headings by swipe and activate links by double-tap. - Regular paragraph text continues to be announced via the flat `label` on the container node — no change in linear reading behaviour. - Semantic nodes are pooled in _cachedSemanticAnchorNodes (same pattern as Flutter's RenderParagraph) to avoid recreating SemanticsNode objects on every assembleSemanticsNode call, which would trigger the parentDataDirty assertion in flushSemantics() in debug mode. - Caps at _kMaxSemanticAnchors = 200 to guard against adversarially large documents filling the accessibility tree. Performance (details relayout): - Split the single needsLineLayout condition into two branches: fragmentsOrWidthChanged (full rebuild including _measureFragments) and hasDetailsFragments-only (skip _measureFragments, re-run line layout). - Each frame of a <details> expand/collapse animation previously called _measureFragments() — the most expensive step (TextPainter layout). Text content and constraint width are unchanged during animation, so TextPainter output is identical frame-to-frame and measurement can be safely skipped. * fix: Fix some bugs * fix: token-based image cancellation, table OOM cap, surrogate-safe text splitting - lazy_image_queue: replace URL-based cancelAll with per-subscriber int tokens; add _inFlight set to deduplicate concurrent loads; distribute ui.Image.clone() per subscriber and dispose original to prevent GPU leak - render_hyper_box: track tokens in _imageTokens set; cancel all on dispose - render_table: clamp colspan/rowspan to _kMaxSpan=1000 to prevent OOM; remove double LayoutBuilder wrapping so nested tables answer intrinsic height queries - render_hyper_box_layout: snap breakIndex off UTF-16 low surrogates (0xDC00–0xDFFF) in _splitTextFragment and _forceSplitTextFragment to avoid invalid lone-surrogate strings crashing TextPainter; fix characterOffset double-counting trimmedLeading * fix(selection): prevent StackOverflow when releasing scroll hold after handle drag HoldScrollActivity.cancel() triggers goBallistic → beginActivity, which disposes the old HoldScrollActivity synchronously. dispose() fires onHoldCanceled = _releaseScrollHold while _scrollHold is still non-null (the null assignment hadn't run yet), causing infinite mutual recursion. Fix: capture the hold reference and null the field first, then cancel. * fix(selection): replace GestureDetector with Listener to unblock Copy button GestureDetector.onTapDown entered the gesture arena and fired _handleTap unconditionally on every pointer-down, clearing the selection and hiding the context menu before TextButton.onPressed could fire copySelection(). Replace with a Listener (no arena participation) that guards on _showContextMenu: when the menu is visible the pointer-down is ignored, letting the Copy/Share buttons receive the tap and copy the text. * test(p2): add 42-test suite covering P2 fixes; update accessibility demo with WCAG 2.1 AA features - Add packages/hyper_render_core/test/p2_fixes_test.dart with 42 tests covering: CSS inline styles, flex layout, grid layout, heading anchors, HyperDetailsWidget expand/collapse, and link tap + scheme whitelisting. All 680 tests pass. - Update example/lib/accessibility_demo.dart to demo the new P2 capabilities: heading navigation semantics (isHeader), link activation semantics (isLink + onTap), and extraLinkSchemes toggle that lets users whitelist the myapp:// deep-link scheme at runtime. * feat(selection): cross-chunk Selection Orchestrator for virtualized mode Adds VirtualizedSelectionController — a ChangeNotifier that owns a global (chunkIndex, localOffset) selection spanning multiple independent RenderHyperBox instances inside the virtualized ListView. Key changes: - RenderHyperBox: expose totalCharacterCount getter and public getCharacterPositionAtOffset() wrapper (previously private) - VirtualizedSelectionController: manages CrossChunkSelection state; translates it into per-chunk HyperTextSelection; handles cross-chunk handle dragging with closest-chunk fallback; getSelectedText() falls back to DocumentNode.textContent for off-screen chunks - VirtualizedChunk: thin StatefulWidget that registers/unregisters with the controller after first layout and wires onSelectionChanged - VirtualizedSelectionOverlay: Stack overlay with teardrop handles and Copy/Select-All popup menu in the ListView coordinate space; freezes ancestor scroll during handle drag (same as single-chunk overlay) - HyperViewer: instantiates the controller in initState (selectable only); replaces bare HyperRenderWidget in itemBuilder with VirtualizedChunk; wraps the virtualized path with VirtualizedSelectionOverlay when showSelectionMenu is true Sync mode is completely unchanged — it still uses HyperSelectionOverlay. * feat(svg): add native SVG rendering via flutter_svg - Add flutter_svg ^2.0.0 dependency - Implement buildSvgWidget() interceptor for inline <svg>, <img src="*.svg">, and data:image/svg+xml URIs - Wire SVG builder into HyperViewer._effectiveWidgetBuilder (chains before user's widgetBuilder) - Export buildSvgWidget from hyper_render.dart for composability - Update Sprint3Demo SVG tab: remove "add flutter_svg manually" note — it's now built-in - Add 10 unit tests covering all SVG rendering paths * fix(demo): use value: instead of initialValue: in DropdownButtonFormField * fix(float): add parse-time guard to avoid wasted space at chunk boundaries Issue 1 — Infinite loop risk: already fixed (lines 1512-1513 / 1589-1591 in render_hyper_box_layout.dart clamp oversized floats to container width before the search loop, ensuring O(1) termination). Issue 2 — Float wasted space in virtualized mode: - Add HtmlAdapter._containsFloatChild() — detects float:left/right img nodes - parseToSections() now skips the section split immediately after a block that contains a CSS-floated element, keeping the float and its successor in the same RenderHyperBox so text wraps around the float correctly - Add clarifying comment in performLayout() explaining the wasted-space trade-off and the float.rect.bottom height extension (lines 963-967) - Document the full FloatCarryover future work in doc/ROADMAP.md - Add 3 unit tests covering float-guard and normal-split behaviour * perf(kinsoku): replace O(N) string scan with O(1) Set<int> lookup tables Replace kinsokuStart.contains(text[i]) (O(N) linear scan + heap String allocation per character) with Set<int> lookup tables built once at class-load time from the canonical kinsoku strings. Key changes: - _startCodes / _endCodes: static final Set<int> built via _buildCodeSet() - cannotStartLine / cannotEndLine: use codeUnitAt(0) + Set.contains (O(1)) - canBreakBetween: Set + codeUnitAt — zero String allocation - _canBreakAt (hot-path): Set.contains(codeUnitAt) — O(1), zero alloc - findBreakPoint: delegates to _canBreakAt, eliminating O(N²) substring allocations from the previous canBreakBetween(substring, substring) loop All kinsoku characters are in the BMP (U+0000–U+FFFF), so codeUnitAt() returns the exact Unicode code point with no surrogate handling needed. * perf(kinsoku): upgrade Set<int> to Uint8List bitmask for O(1) direct-index lookup Replace the Set<int> hash tables with a single 64 KB Uint8List(0x10000) bitmask, encoding kinsoku-start (bit 0) and kinsoku-end (bit 1) categories for every BMP code unit. Why faster than Set<int>: - Set.contains() computes a hash + may traverse a collision chain - _table[codeUnit] is a single memory dereference — no hash, no branch - 64 KB fits entirely in L2 cache; repeated layout-scan accesses are served from cache, not RAM - _canBreakAt() reads the table once per boundary position instead of calling two separate Set.contains() — one load covers both categories The _buildTable() builder iterates kinsokuStart/kinsokuEnd once at class-load time; all public API signatures are unchanged. * fix: table ANR guard + cross-chunk float continuity ## Table nesting depth guard (ANR prevention) Add _TableNestingDepth InheritedWidget that propagates nesting depth through the widget tree. HyperTable.build() checks _TableNestingDepth.of() and returns _TableDepthExceededPlaceholder ('[table]') at depth ≥ 6. The 2-pass layout algorithm (_RenderHyperTable.performLayout) has O(2^D) complexity for nested tables: measuring column widths triggers each cell's layout, doubling per level. The depth-6 cap matches browser behaviour and prevents ANR on adversarial or poorly-authored HTML. No constructor signatures changed — InheritedWidget propagates depth automatically. ## Cross-chunk float continuity Implement float-state transfer between virtualised sections so text in Chunk N+1 wraps around a float that began in Chunk N. New types: - FloatCarryover (in render_hyper_box_types.dart): direction, width, overhangHeight — describes a float whose rect.bottom exceeds the natural text height of its section. New RenderHyperBox API: - initialFloats: List<FloatCarryover> — seeds _leftFloats/_rightFloats at the start of _performLineLayout for the inherited floats. - danglingFloats: getter — computes FloatCarryover from floats that extend past the last-line bottom after layout. - onFloatCarryover callback — fired after each performLayout with the current danglingFloats list. Threading: - HyperRenderWidget: initialFloats + onFloatCarryover parameters - VirtualizedChunk: initialFloats + onFloatCarryover pass-through - HyperViewer: _floatCarryovers state list + _onFloatCarryover handler that triggers a minimal setState when carryover changes, causing only the affected next-section item to rebuild. * feat(table): vertical-align top/middle/bottom for table cells Parse and apply CSS vertical-align / HTML valign attribute on table cells. ## Parsing - resolver.dart: add 'vertical-align' case to _applyProperty() — maps top/middle/bottom/baseline/text-top/text-bottom to HyperVerticalAlign - resolver.dart: add _parseVerticalAlign() helper (mirrors _parseTextAlign) - resolver.dart: read HTML 'valign' attribute before CSS rules (lower priority than inline styles), so valign="middle" on <tr>/<td> works Precedence: cell inline style > CSS class on cell > valign on cell > CSS class on row > valign on row > default (top) ## Layout - _TableCellParentData: add verticalAlign field (default: top) - _TableCellSlot: carry verticalAlign through applyParentData - HyperTable.build(): resolve effective vertical-align (cell > row > top); uses HyperVerticalAlign.baseline as the "not explicitly set" sentinel since ComputedStyle.verticalAlign defaults to baseline - _positionCells: compute slack = rowSlotHeight − cellHeight, then: top / baseline / text-top → dy = 0 (existing behaviour) middle → dy = slack/2 bottom / text-bottom → dy = slack Works correctly for rowspan > 1 cells: slot height is the sum of all spanned rows plus inner borders, matching _computeRowHeights Pass 3. * feat(table): add screen-reader semantics for tables and header cells - Wrap each HyperTable in Semantics(container:true, label:'Table, N rows, M columns') so TalkBack/VoiceOver announces the table structure on focus. - Wrap <th> cells in Semantics(header:true) so screen readers distinguish column/row headers from data cells. - Uses only stable Flutter Semantics API available since SDK 3.10, keeping compatibility with the declared pubspec lower-bound. * fix: temp for CSS Keyframes/Animations * feat: Update info, code, documents for release 1.1.2 * feat: v1.1.2 — binary search selection, devtools v1.0.0, 3-pipeline CI, golden tests Core engine: - O(log N) binary search hit-testing for text selection (_lineStartOffsets[]) - Ruby clipboard format: base(ふりがな) for full-fragment selection - Ruby selection pipeline: 5 bug fixes (offset sync, paint, clipboard, rects) DevTools (hyper_render_devtools v1.0.0): - First full release: UDT Tree inspector, Computed Style panel, Float region visualizer - Demo mode: explore inspector without a live app - README, CHANGELOG, LICENSE, example added CI/CD — 3-pipeline architecture: - Pipeline 1 (analyze.yml): Pre-flight — dart format + flutter analyze --fatal-infos, dorny/paths-filter for 8 path categories, skips on docs-only changes (< 2 min target) - Pipeline 2 (test.yml): Core Validation — PR uses single ubuntu-22.04 runner with per-package selective testing; push to main runs full 3-OS × 2-channel matrix - Pipeline 2 (coverage.yml): push-to-main only, pinned Flutter 3.29.2 + ubuntu-22.04 - Visual regression (golden.yml): pinned ubuntu-22.04 + Noto fonts, update-goldens job - Performance regression (benchmark.yml): layout regression guard — 6 fixtures with hard 16 ms (60 FPS) budget, fails PR on any regression Golden tests: - 9 new test cases: Float layout (left/right/clear), RTL/BiDi (Arabic/Hebrew/mixed), CJK + Ruby (kinsoku, float+CJK) — all pinned pixel-stable Documentation: - README: CI badges, devtools in extension packages table, O(log N) + layout CI guard in architecture section, roadmap updated (devtools + @Keyframes shipped) - doc/ROADMAP.md: completed items updated through v1.1.2 - .gitignore: add .metadata, .flutter-plugins, benchmark/results/, analyze_report.txt, pubspec_publish_ready.yaml; replace overbroad *.txt Packages: all sub-packages bumped to 1.1.2 with issue_tracker field --------- Co-authored-by: Nguyễn Tuấn Việt <viet.nguyen@sungrove.co.jp>
1 parent 896d060 commit 085ebfd

File tree

114 files changed

+9231
-1797
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

114 files changed

+9231
-1797
lines changed

.github/workflows/analyze.yml

Lines changed: 112 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,60 +1,140 @@
1-
name: Static Analysis
1+
name: Pre-flight Checks
2+
3+
# ─────────────────────────────────────────────────────────────────────────────
4+
# Pipeline 1 — Pre-flight (target: < 2 min)
5+
#
6+
# Runs on every push / PR as the fast gate that must pass before any heavy
7+
# job is allowed to start. Three responsibilities:
8+
#
9+
# 1. dart format — code style, fails on any formatting diff
10+
# 2. flutter analyze --fatal-infos — zero errors/warnings/infos enforced
11+
# 3. Changed-path detection — outputs which packages were touched so that
12+
# downstream jobs (test.yml) know what to test
13+
#
14+
# Optimisations vs the old setup:
15+
# • Single runner (ubuntu-22.04), single Flutter version — no duplication
16+
# • Skips ALL checks when only *.md / doc/ / .github/ files changed
17+
# • pub-cache restored from cache before `flutter pub get`
18+
# ─────────────────────────────────────────────────────────────────────────────
19+
20+
env:
21+
FLUTTER_VERSION: "3.29.2"
222

323
on:
424
push:
5-
branches: [ main, develop ]
25+
branches: [main, develop]
626
pull_request:
7-
branches: [ main, develop ]
27+
branches: [main, develop]
828

929
jobs:
10-
analyze:
11-
name: Analyze Code
12-
runs-on: ubuntu-latest
30+
preflight:
31+
name: Format · Analyze · Path-filter
32+
runs-on: ubuntu-22.04
33+
34+
outputs:
35+
# Which packages (or groups) have changed — consumed by test.yml
36+
changed_core: ${{ steps.filter.outputs.core }}
37+
changed_html: ${{ steps.filter.outputs.html }}
38+
changed_markdown: ${{ steps.filter.outputs.markdown }}
39+
changed_highlight: ${{ steps.filter.outputs.highlight }}
40+
changed_clipboard: ${{ steps.filter.outputs.clipboard }}
41+
changed_devtools: ${{ steps.filter.outputs.devtools }}
42+
changed_root: ${{ steps.filter.outputs.root }}
43+
changed_any_dart: ${{ steps.filter.outputs.any_dart }}
1344

1445
steps:
15-
- name: Checkout code
46+
- name: Checkout
1647
uses: actions/checkout@v4
1748

49+
# ── Detect changed paths ────────────────────────────────────────────────
50+
- name: Detect changed paths
51+
id: filter
52+
uses: dorny/paths-filter@v3
53+
with:
54+
filters: |
55+
core:
56+
- 'packages/hyper_render_core/**'
57+
html:
58+
- 'packages/hyper_render_html/**'
59+
markdown:
60+
- 'packages/hyper_render_markdown/**'
61+
highlight:
62+
- 'packages/hyper_render_highlight/**'
63+
clipboard:
64+
- 'packages/hyper_render_clipboard/**'
65+
devtools:
66+
- 'packages/hyper_render_devtools/**'
67+
root:
68+
- 'lib/**'
69+
- 'test/**'
70+
- 'pubspec.yaml'
71+
- 'pubspec.lock'
72+
any_dart:
73+
- '**/*.dart'
74+
- '**/pubspec.yaml'
75+
- '**/pubspec.lock'
76+
- '**/analysis_options.yaml'
77+
78+
# ── Skip everything if only docs / CI config changed ───────────────────
79+
- name: Skip if docs-only change
80+
if: steps.filter.outputs.any_dart == 'false'
81+
run: |
82+
echo "✓ Only non-Dart files changed — skipping format & analyze"
83+
echo " (golden.yml and benchmark.yml handle their own triggers)"
84+
85+
# ── Flutter setup (only when Dart files changed) ────────────────────────
1886
- name: Setup Flutter
87+
if: steps.filter.outputs.any_dart == 'true'
1988
uses: subosito/flutter-action@v2
2089
with:
90+
flutter-version: ${{ env.FLUTTER_VERSION }}
2191
channel: stable
2292
cache: true
2393

24-
- name: Get dependencies
94+
- name: Restore pub cache
95+
if: steps.filter.outputs.any_dart == 'true'
96+
uses: actions/cache@v4
97+
with:
98+
path: |
99+
~/.pub-cache
100+
${{ env.PUB_CACHE }}
101+
key: pub-ubuntu-${{ hashFiles('**/pubspec.lock') }}
102+
restore-keys: pub-ubuntu-
103+
104+
- name: flutter pub get
105+
if: steps.filter.outputs.any_dart == 'true'
25106
run: flutter pub get
26107

27-
- name: Run flutter analyze
28-
run: flutter analyze --no-pub > analyze_report.txt || true
108+
# ── dart format ─────────────────────────────────────────────────────────
109+
- name: dart format (fail on diff)
110+
if: steps.filter.outputs.any_dart == 'true'
111+
run: dart format --set-exit-if-changed .
29112

30-
- name: Check for errors
113+
# ── flutter analyze ─────────────────────────────────────────────────────
114+
- name: flutter analyze --fatal-infos
115+
if: steps.filter.outputs.any_dart == 'true'
31116
run: |
32-
errors=$(grep -c "error •" analyze_report.txt || true)
33-
warnings=$(grep -c "warning •" analyze_report.txt || true)
34-
infos=$(grep -c "info •" analyze_report.txt || true)
35-
36-
echo "Static Analysis Results:"
37-
echo " Errors: $errors"
38-
echo " Warnings: $warnings"
39-
echo " Infos: $infos"
117+
flutter analyze --no-pub --fatal-infos 2>&1 | tee analyze_report.txt
118+
EXIT=${PIPESTATUS[0]}
40119
41-
# Fail if there are errors
42-
if [ "$errors" -gt 0 ]; then
43-
echo "❌ Found $errors errors"
44-
cat analyze_report.txt
45-
exit 1
46-
fi
120+
ERRORS=$(grep -c "error •" analyze_report.txt 2>/dev/null || true)
121+
WARNS=$(grep -c "warning •" analyze_report.txt 2>/dev/null || true)
122+
INFOS=$(grep -c "info •" analyze_report.txt 2>/dev/null || true)
47123
48-
# Warn if there are many warnings
49-
if [ "$warnings" -gt 20 ]; then
50-
echo "⚠️ High number of warnings: $warnings"
51-
fi
124+
echo ""
125+
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
126+
echo " Static Analysis Results"
127+
echo " Errors: $ERRORS"
128+
echo " Warnings: $WARNS"
129+
echo " Infos: $INFOS"
130+
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
52131
53-
echo "✅ Static analysis passed"
132+
exit $EXIT
54133
55134
- name: Upload analyze report
56-
if: always()
135+
if: always() && steps.filter.outputs.any_dart == 'true'
57136
uses: actions/upload-artifact@v4
58137
with:
59-
name: analyze-report
138+
name: analyze-report-${{ github.run_number }}
60139
path: analyze_report.txt
140+
retention-days: 7

.github/workflows/benchmark.yml

Lines changed: 161 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,63 +1,190 @@
1-
name: Benchmarks
1+
name: Performance Regression
22

3-
on:
4-
# Run benchmarks weekly
5-
schedule:
6-
- cron: '0 0 * * 0' # Every Sunday at midnight UTC
3+
# ─────────────────────────────────────────────────────────────────────────────
4+
# Two jobs:
5+
#
6+
# layout-regression — runs on every PR and every push to main.
7+
# • Executes benchmark/layout_regression.dart
8+
# • Fails if any fixture's median layout time exceeds its hard threshold
9+
# • Posts a PR comment with the full results table
10+
#
11+
# full-benchmark — runs weekly + on release branches.
12+
# • Executes the original benchmark/parse_benchmark.dart (throughput info)
13+
# • Never fails CI (informational only) — results are uploaded as artifacts
14+
# ─────────────────────────────────────────────────────────────────────────────
715

8-
# Allow manual trigger
9-
workflow_dispatch:
16+
env:
17+
FLUTTER_VERSION: "3.29.2" # keep in sync with golden.yml
1018

11-
# Run on release branches
19+
on:
20+
pull_request:
21+
branches: [main, develop]
1222
push:
13-
branches:
14-
- 'release/**'
23+
branches: [main]
24+
schedule:
25+
- cron: '0 0 * * 0' # weekly full benchmark (Sunday midnight UTC)
26+
workflow_dispatch:
1527

1628
jobs:
17-
benchmark:
18-
name: Run Performance Benchmarks
19-
runs-on: ubuntu-latest
29+
# ── Layout regression guard (runs on every PR) ────────────────────────────
30+
layout-regression:
31+
name: Layout Regression (60 FPS guard)
32+
runs-on: ubuntu-22.04
33+
# Skip on the weekly schedule — that's for the full-benchmark job only
34+
if: github.event_name != 'schedule'
2035

2136
steps:
22-
- name: Checkout code
37+
- name: Checkout
2338
uses: actions/checkout@v4
2439

25-
- name: Setup Flutter
40+
- name: Setup Flutter (pinned)
2641
uses: subosito/flutter-action@v2
2742
with:
43+
flutter-version: ${{ env.FLUTTER_VERSION }}
2844
channel: stable
2945
cache: true
3046

3147
- name: Get dependencies
3248
run: flutter pub get
3349

34-
- name: Run benchmarks
50+
- name: Run layout regression benchmark
51+
id: bench
3552
run: |
36-
echo "Running benchmarks..."
37-
flutter test benchmark/ --no-test-randomize-ordering-seed
53+
mkdir -p benchmark/results
3854
39-
- name: Save benchmark results
40-
run: |
41-
mkdir -p benchmark_results
42-
echo "Date: $(date)" > benchmark_results/latest.txt
43-
echo "Commit: ${{ github.sha }}" >> benchmark_results/latest.txt
44-
echo "" >> benchmark_results/latest.txt
45-
echo "See test output above for detailed results" >> benchmark_results/latest.txt
55+
# Run with JSON reporter so we can parse pass/fail
56+
flutter test benchmark/layout_regression.dart \
57+
--reporter expanded \
58+
2>&1 | tee benchmark/results/ci_run.txt
4659
47-
- name: Upload benchmark results
60+
# Extract overall pass/fail from test exit code
61+
EXIT_CODE=${PIPESTATUS[0]}
62+
63+
# Parse the JSON result files for the summary table
64+
SUMMARY=$(python3 - <<'PYEOF'
65+
import json, os, glob
66+
67+
files = sorted(glob.glob('benchmark/results/layout_*.json'))
68+
if not files:
69+
print("No result file generated.")
70+
else:
71+
data = json.load(open(files[-1]))
72+
rows = []
73+
any_fail = False
74+
for r in data.get('results', []):
75+
icon = "✅" if r["passed"] else "❌"
76+
rows.append(
77+
f"| {icon} | `{r['fixture']}` | {r['threshold_ms']} | "
78+
f"{r['median_ms']} | {r['p95_ms']} |"
79+
)
80+
if not r["passed"]:
81+
any_fail = True
82+
83+
header = (
84+
"| | Fixture | Budget (ms) | Median (ms) | P95 (ms) |\n"
85+
"|---|---|---|---|---|"
86+
)
87+
print(header)
88+
print("\n".join(rows))
89+
if any_fail:
90+
print("\n**One or more fixtures exceeded the 16 ms budget.**")
91+
PYEOF
92+
)
93+
94+
echo "summary<<EOF" >> "$GITHUB_OUTPUT"
95+
echo "$SUMMARY" >> "$GITHUB_OUTPUT"
96+
echo "EOF" >> "$GITHUB_OUTPUT"
97+
echo "exit_code=$EXIT_CODE" >> "$GITHUB_OUTPUT"
98+
99+
exit $EXIT_CODE
100+
101+
- name: Upload result JSON
102+
if: always()
48103
uses: actions/upload-artifact@v4
49104
with:
50-
name: benchmark-results
51-
path: benchmark_results/
105+
name: layout-regression-${{ github.run_number }}
106+
path: benchmark/results/
107+
retention-days: 30
52108

53-
- name: Comment on PR (if applicable)
54-
if: github.event_name == 'pull_request'
109+
- name: Post PR comment
110+
if: always() && github.event_name == 'pull_request'
55111
uses: actions/github-script@v7
56112
with:
57113
script: |
58-
github.rest.issues.createComment({
59-
issue_number: context.issue.number,
114+
const exitCode = '${{ steps.bench.outputs.exit_code }}';
115+
const summary = `${{ steps.bench.outputs.summary }}`;
116+
const passed = exitCode === '0';
117+
const icon = passed ? '✅' : '❌';
118+
const headline = passed
119+
? '## ✅ Layout Regression — All fixtures within 60 FPS budget'
120+
: '## ❌ Layout Regression — Budget exceeded';
121+
122+
const body = [
123+
headline,
124+
'',
125+
summary,
126+
'',
127+
`> Flutter \`${{ env.FLUTTER_VERSION }}\` · ubuntu-22.04`,
128+
'',
129+
passed
130+
? '_No action required._'
131+
: [
132+
'**Action required:** a layout fixture exceeded its millisecond',
133+
'budget. Profile the regression with:',
134+
'```bash',
135+
'flutter test benchmark/layout_regression.dart --reporter expanded',
136+
'```',
137+
'and check `_performLineLayout` / `_buildCharacterMapping` for',
138+
'any new O(N²) or O(N log N) paths introduced in this PR.',
139+
].join('\n'),
140+
].join('\n');
141+
142+
await github.rest.issues.createComment({
60143
owner: context.repo.owner,
61144
repo: context.repo.repo,
62-
body: '🏎️ Benchmark tests completed. Check the Actions tab for detailed results.'
63-
})
145+
issue_number: context.issue.number,
146+
body,
147+
});
148+
149+
# ── Full throughput benchmark (weekly, informational) ────────────────────
150+
full-benchmark:
151+
name: Full Throughput Benchmark
152+
runs-on: ubuntu-22.04
153+
if: >-
154+
github.event_name == 'schedule' ||
155+
github.event_name == 'workflow_dispatch' ||
156+
startsWith(github.ref, 'refs/heads/release/')
157+
158+
steps:
159+
- name: Checkout
160+
uses: actions/checkout@v4
161+
162+
- name: Setup Flutter (pinned)
163+
uses: subosito/flutter-action@v2
164+
with:
165+
flutter-version: ${{ env.FLUTTER_VERSION }}
166+
channel: stable
167+
cache: true
168+
169+
- name: Get dependencies
170+
run: flutter pub get
171+
172+
- name: Run parse benchmarks
173+
run: |
174+
flutter test benchmark/parse_benchmark.dart \
175+
--no-test-randomize-ordering-seed \
176+
--reporter expanded \
177+
2>&1 | tee benchmark/results/parse_$(date +%Y%m%d).txt
178+
179+
- name: Run layout regression (informational — never fails here)
180+
run: |
181+
flutter test benchmark/layout_regression.dart \
182+
--reporter expanded \
183+
2>&1 | tee benchmark/results/layout_$(date +%Y%m%d).txt || true
184+
185+
- name: Upload benchmark results
186+
uses: actions/upload-artifact@v4
187+
with:
188+
name: benchmark-full-${{ github.run_number }}
189+
path: benchmark/results/
190+
retention-days: 90

0 commit comments

Comments
 (0)