feat: v1.1.2 — binary search selection, devtools v1.0.0, 3-pipeline CI#2
Merged
vietnguyentuan2019 merged 26 commits intomainfrom Mar 25, 2026
Merged
feat: v1.1.2 — binary search selection, devtools v1.0.0, 3-pipeline CI#2vietnguyentuan2019 merged 26 commits intomainfrom
vietnguyentuan2019 merged 26 commits intomainfrom
Conversation
… 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.
- 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.
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
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.
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.
## 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.
… 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.
…sureFragments 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.
…xt 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
…r 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.
… 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.
…emo 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.
…n tests Merge resolution (render_hyper_box.dart conflict): - Keep token-based image cancellation from refactor/improve - Use LRU onEvict for GPU disposal (main), removing redundant manual dispose loop to avoid double-free Fix 3 pre-existing pumpAndSettle timeout failures in integration tests: - error_recovery: measure layout time separately from network image loading - resource_management: use pump(2s) for large-doc semantics test - resource_management: split zoom-virtualized into two tests — one using sync mode to verify InteractiveViewer, one using runAsync to let the real isolate complete before asserting no crash
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.
- 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
…aries 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
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.
…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.
## 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.
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.
- 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.
…I, 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
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
_lineStartOffsets[]precomputed at layoutbase(ふりがな)for fully-selected ruby fragments; 5 ruby selection pipeline bugs fixedhyper_render_devtoolsv1.0.0 first full release — UDT Tree inspector, Computed Style panel, Float region visualizer, demo mode (no live app required).gitignorefixedChanges by area
Core engine (
hyper_render_core)render_hyper_box.dart—_lineStartOffsets[]field, cleared on layout invalidationrender_hyper_box_layout.dart—_buildCharacterMapping()populates per-line start offsetsrender_hyper_box_selection.dart—_lineIndexAt()binary search, O(log N)_findFragmentAtPosition/_getCharacterPositionAtOffset, ruby clipboard formatDevTools (
hyper_render_devtools)devtools_ui/lib/main.dart— demo mode with sample UDT tree, fragments, style dataCI/CD (
.github/workflows/)analyze.yml— rewritten as Pre-flight:dorny/paths-filter, pub cache, format + analyze, skip on docs-onlytest.yml— rewritten:test-pr(ubuntu-22.04, selective) +test-matrix(3-OS × 2-channel on push)coverage.yml— push-to-main only, pinned Flutter 3.29.2golden.yml— pinned ubuntu-22.04 + Noto fonts, update-goldens dispatch jobbenchmark.yml— layout regression guard + weekly full-benchmarkTests & Docs
test/golden/critical_layouts_test.dart— 9 new cases: float left/right/clear, RTL Arabic/Hebrew/mixed, CJK ruby/kinsoku/float+CJKbenchmark/layout_regression.dart— new file: 6 fixtures, 3 warmup + 10 measured runs, hardexpect()thresholdsREADME.md— CI badges, devtools row, O(log N) + layout CI guard in architecture, roadmap updated.gitignore—.metadata,.flutter-plugins,benchmark/results/,analyze_report.txt,pubspec_publish_ready.yamlTest plan
test-prpasses on ubuntu-22.04scripts/publish.sh dry-runpasses for all 6 packages