Status: Accepted
Date: 2024-2025 (Design Phase)
After deciding on the UDT model (ADR 0001), we needed to decide how to render the tree in Flutter. We had several architectural choices:
// HTML: <div><p>Hello</p><p>World</p></div>
Column(
children: [
Paragraph(child: Text('Hello')),
Paragraph(child: Text('World')),
],
)Pros:
- Familiar Flutter pattern
- Easy to understand
- Leverages Flutter's widget system
Cons:
- Creates thousands of widgets for large HTML
- Widget rebuild cost for 10,000+ widgets is prohibitive
- No CSS inheritance (widgets don't cascade styles)
- Selection across multiple Text widgets is complex
- Float layout impossible (widgets can't flow around each other)
- Memory overhead: ~100 bytes per widget × 10,000 widgets = 1MB+
// Blocks = widgets, inline content = RichText
Column(
children: [
RichText(text: TextSpan(text: 'Hello')),
RichText(text: TextSpan(text: 'World')),
],
)Pros:
- Slightly better than pure widgets
- RichText handles inline content efficiently
Cons:
- Still creates widget per block (~100-1000 widgets)
- Selection breaks at widget boundaries
- Float layout still impossible
- Complex coordinate mapping for selection
// Entire document = 1 CustomMultiChildLayout widget
// → 1 RenderHyperBox (custom RenderObject)
HyperRenderWidget(
document: udtTree, // Entire document
)Pros:
- Single layout pass for entire document
- Custom paint method → full control
- Continuous selection (entire document is single selectable)
- Float layout possible (custom line-breaking algorithm)
- CSS cascade natural (single style resolution)
- Minimal memory (1 RenderObject vs 1000+ widgets)
Cons:
- Must implement custom layout algorithm
- Must implement custom paint logic
- More complex than widget approach
- Harder for contributors to understand
We chose Single RenderObject Architecture.
// Widget layer (lightweight)
class HyperRenderWidget extends MultiChildRenderObjectWidget {
final DocumentNode document;
@override
RenderObject createRenderObject(BuildContext context) {
return RenderHyperBox(document: document);
}
}
// RenderObject layer (where the magic happens)
class RenderHyperBox extends RenderBox
with ContainerRenderObjectMixin<RenderBox, HyperBoxParentData> {
DocumentNode? _document;
// Layout algorithm (custom)
@override
void performLayout() {
// 1. Tokenize UDT → Fragments
// 2. Measure fragments
// 3. Line-breaking with float support
// 4. Position fragments
// 5. Set size
}
// Paint algorithm (custom)
@override
void paint(PaintingContext context, Offset offset) {
// 1. Paint backgrounds
// 2. Paint borders
// 3. Paint floats
// 4. Paint text
// 5. Paint images
}
// Hit testing (for selection)
@override
bool hitTestSelf(Offset position) => true;
}1. Performance
- Single layout pass (vs 1000+ widget builds)
- Single paint call (vs 1000+ widget paints)
- No widget tree overhead
- Example: 100KB HTML
- Widget approach: 50-100ms layout + 30-50ms paint
- RenderObject approach: 20-30ms layout + 10-15ms paint
- ~3x faster
2. Memory Efficiency
- 1 RenderObject ≈ 200 bytes
- vs 1000 widgets × 100 bytes = 100KB
- 500x less memory
3. Float Layout
// Custom line-breaking allows text to flow around floats
// Impossible with Column/Row widgets!
void _performLineLayout() {
for (final fragment in fragments) {
// Check if float blocks this position
final leftFloatEdge = _getFloatEdgeAtY(y, isLeft: true);
final rightFloatEdge = _getFloatEdgeAtY(y, isLeft: false);
final availableWidth = rightFloatEdge - leftFloatEdge;
// Place fragment in available space
if (fragment.width <= availableWidth) {
// Fits on this line
} else {
// Move to next line, below float if needed
}
}
}4. Continuous Selection
// Selection spans entire document seamlessly
// No widget boundaries to cross!
@override
TextSelection? selectWordAtPosition(Offset position) {
final charIndex = _getCharacterIndexAtPosition(position);
// Find word boundaries in entire document
return _getWordSelectionAt(charIndex);
}Performance
- 3-4x faster than widget approach
- Scales to 1MB+ HTML documents
- 60fps scrolling even with complex content
Memory
- 500x less memory than widget tree
- Enables mobile devices to handle large documents
- Example: 800KB HTML → 8MB (RenderObject) vs 400MB (widgets)
Features
- Float layout (unique to HyperRender!)
- Continuous selection across entire document
- CSS cascade works naturally
- Custom line-breaking (Kinsoku for CJK)
Simplicity
- Single class handles layout + paint
- No complex widget composition
- Easier to debug (one place to look)
Complexity
- Must implement layout from scratch
- Must implement paint from scratch
- ~2000 lines of RenderObject code
- Harder for contributors to understand
Debugging
- Flutter DevTools doesn't show internal structure
- Can't inspect "widgets" (there are none)
- Need custom debugging tools
Learning Curve
- Contributors must learn RenderObject API
- Not as approachable as widget code
- Requires understanding of Flutter's rendering pipeline
We mitigate the negatives by:
- Documentation: Extensive inline comments in
render_hyper_box.dart - Debugging: Custom selection overlay shows internal state
- Testing: Comprehensive unit tests for layout algorithm
- Error Boundaries: Try-catch in layout/paint to prevent crashes
| Metric | Widget Tree | Single RenderObject | Improvement |
|---|---|---|---|
| Parse time | 25ms | 25ms | Same |
| Layout time | 80ms | 25ms | 3.2x faster |
| Paint time | 45ms | 12ms | 3.8x faster |
| Total render | 150ms | 62ms | 2.4x faster |
| Memory usage | 120MB | 8MB | 15x less |
| Widget count | 1,247 | 1 | 1247x less |
| Metric | Widget Tree | Single RenderObject | Improvement |
|---|---|---|---|
| Layout time | 420ms | 95ms | 4.4x faster |
| Memory usage | 580MB | 38MB | 15x less |
| Scroll FPS | 30fps | 60fps | 2x smoother |
Widget tree becomes unusable at 500KB!
Problem: Standard Flutter line-breaking doesn't support floats.
Solution: Custom line-breaking algorithm that:
- Tracks float rectangles
- Calculates available width per line
- Wraps text around floats
- Clears floats when needed
See ADR 0003.
Problem: Flutter's SelectableText only works with widgets.
Solution: Custom selection implementation:
- Character index mapping (position → char index)
- Custom hit testing
- Selection handles rendering
- Copy/paste integration
Code: lib/src/widgets/hyper_selection_overlay.dart
Problem: Images need to be embedded in text flow.
Solution: Hybrid approach:
- Text fragments rendered directly (custom paint)
- Images as child RenderBoxes (positioned during layout)
ContainerRenderObjectMixinmanages children
class RenderHyperBox extends RenderBox
with ContainerRenderObjectMixin<RenderBox, HyperBoxParentData> {
void _layoutChildren() {
visitChildren((child) {
// Layout image RenderBox
child.layout(constraints);
// Position based on fragment data
final parentData = child.parentData as HyperBoxParentData;
parentData.offset = fragment.offset;
});
}
}- Uses widget tree (Column/Row/Text)
- Slow for large content (widget overhead)
- No float support
- Selection breaks at widget boundaries
- Uses widget tree + RichText
- Better than flutter_html, still slower than RenderObject
- No float support
- Complex widget composition
- Single RenderObject ✅
- 4x faster ✅
- Float support ✅
- Continuous selection ✅
- Handles 1MB+ documents ✅
- ADR 0001: Unified Document Tree - Why UDT
- ADR 0003: CSS Float Support - Float layout
- ADR 0005: InlineSpan Over Widget Tree - Text rendering
- Implementation:
packages/hyper_render_core/lib/src/core/render_hyper_box.dart(2000+ lines) - Flutter RenderObject docs: https://api.flutter.dev/flutter/rendering/RenderObject-class.html
- Performance benchmarks:
docs/PERFORMANCE_TUNING.md
Decision makers: vietnguyen (Lead Developer)
Last updated: March 2026
render_hyper_box.dart is split into part files (layout, paint, accessibility, selection, types) but all share the same _RenderHyperBoxLayout mixin. As CSS feature count grows, consider extracting layout algorithms into dedicated LayoutStrategy classes:
LayoutStrategy (abstract)
├── BlockFormattingContext — block stacking, margin collapse
├── InlineFormattingContext — fragment placement, line breaking
├── FloatLayoutStrategy — float box placement, clearance
├── FlexLayoutStrategy — flex container + item sizing
└── GridLayoutStrategy — grid track + area placement
Trigger: Refactor when any single strategy exceeds ~600 lines or when adding a new layout mode (e.g. multi-column) requires touching all existing strategies.
Risk: All strategies share the same coordinate system and _lineRows state. Splitting prematurely forces complex cross-strategy callbacks for float/inline interaction. Only split when the boundary is genuinely clean.