From d2cb6658a8e23535d337dc4043571121fbe85b67 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nguye=CC=82=CC=83n=20Tua=CC=82=CC=81n=20Vie=CC=A3=CC=82t?= Date: Mon, 23 Mar 2026 15:12:57 +0700 Subject: [PATCH 01/25] fix(semantics): remove hand-rolled SemanticsNode creation that caused parentDataDirty assertion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../lib/src/core/render_hyper_box.dart | 56 +-- .../core/render_hyper_box_accessibility.dart | 416 +----------------- .../lib/src/core/render_hyper_box_layout.dart | 26 -- 3 files changed, 21 insertions(+), 477 deletions(-) diff --git a/packages/hyper_render_core/lib/src/core/render_hyper_box.dart b/packages/hyper_render_core/lib/src/core/render_hyper_box.dart index d33f215..e3f6331 100644 --- a/packages/hyper_render_core/lib/src/core/render_hyper_box.dart +++ b/packages/hyper_render_core/lib/src/core/render_hyper_box.dart @@ -237,12 +237,6 @@ class RenderHyperBox extends RenderBox /// Rebuilt at the end of [_layoutChildren]; cleared by [_invalidateLayout]. final Map _fragmentChildMap = {}; - /// Maps UDTNode → its bounding Rect for O(1) accessibility lookup. - /// Built at the end of [performLayout] after all fragment offsets are set; - /// cleared by [_invalidateLayout]. Used by [_getNodeRect] so that - /// VoiceOver/TalkBack semantics queries are instant rather than O(fragments). - final Map _nodeRectCache = {}; - /// Maps CSS `id` attribute values → their y-offset within this RenderObject. /// Populated during [_performLineLayout]; cleared by [_invalidateLayout]. /// Consumed by [HyperViewerController.scrollToId]. @@ -503,7 +497,7 @@ class RenderHyperBox extends RenderBox _characterToFragment.clear(); _fragmentRanges.clear(); _fragmentChildMap.clear(); - _nodeRectCache.clear(); + anchorOffsets.clear(); headingAnchors.clear(); _totalCharacterCount = 0; @@ -846,10 +840,6 @@ class RenderHyperBox extends RenderBox // Step 7: Layout child RenderBoxes (always — child constraints may change) _layoutChildren(); - // Step 8: Build node→Rect cache for O(1) accessibility queries. - // Done here (after _layoutChildren) so child-widget offsets are final. - _buildNodeRectCache(); - // Calculate final size double height = 0; if (_lines.isNotEmpty) { @@ -1112,42 +1102,26 @@ class RenderHyperBox extends RenderBox ..textDirection = _textDirection; } - /// Fragment count above which we skip per-node semantic tree building. - /// - /// Building thousands of [SemanticsNode]s for large documents causes - /// measurable TalkBack/VoiceOver jank because every `updateWith` call - /// triggers a synchronous tree diff on the accessibility thread. - /// - /// Above this threshold we fall back to Flutter's default flat semantics - /// (the coarse document-level label set in [describeSemanticsConfiguration]). - /// Interactive elements like links already receive individual nodes from - /// Flutter's own semantics pipeline, so screen-reader users keep tap targets. - static const int _kSemanticNodeBuildThreshold = 500; - @override void assembleSemanticsNode( SemanticsNode node, SemanticsConfiguration config, Iterable children, ) { - final doc = _document; - if (doc != null && _fragments.length <= _kSemanticNodeBuildThreshold) { - final semanticNodes = []; - _buildSemanticNodes(doc, semanticNodes, node); - node.updateWith( - config: config, - childrenInInversePaintOrder: - semanticNodes.isNotEmpty ? semanticNodes : children.toList(), - ); - } else { - // Large document or no document: use flat children provided by Flutter's - // semantics pipeline. The document-level label (set in - // describeSemanticsConfiguration) remains readable for screen readers. - node.updateWith( - config: config, - childrenInInversePaintOrder: children.toList(), - ); - } + // Use only the children that Flutter's own pipeline provides (child + // RenderObjects: HyperDetailsWidget, HyperTable, CodeBlockWidget, etc.). + // The full text content is already announced via the `label` set in + // describeSemanticsConfiguration. + // + // Previously _buildSemanticNodes() created new SemanticsNode() objects on + // every assembleSemanticsNode call. Newly created nodes are not attached + // to the SemanticsOwner, so node.updateWith() calls _adoptChild() on them + // which sets parentDataDirty = true. flushSemantics() then walks the tree + // in debug mode and fires '!semantics.parentDataDirty'. + node.updateWith( + config: config, + childrenInInversePaintOrder: children.toList(), + ); } @override diff --git a/packages/hyper_render_core/lib/src/core/render_hyper_box_accessibility.dart b/packages/hyper_render_core/lib/src/core/render_hyper_box_accessibility.dart index 68cf0c2..22296fb 100644 --- a/packages/hyper_render_core/lib/src/core/render_hyper_box_accessibility.dart +++ b/packages/hyper_render_core/lib/src/core/render_hyper_box_accessibility.dart @@ -1,7 +1,12 @@ part of 'render_hyper_box.dart'; extension _RenderHyperBoxAccessibility on RenderHyperBox { - /// Builds a plain text representation of the content for screen readers + /// Builds a plain text representation of the content for screen readers. + /// + /// Used by [describeSemanticsConfiguration] to populate the top-level + /// `label` of the semantics node so that TalkBack / VoiceOver can announce + /// the full document text even when no finer-grained semantic children are + /// generated. String _buildTextContentForSemantics() { if (_document == null) return ''; @@ -12,7 +17,6 @@ extension _RenderHyperBoxAccessibility on RenderHyperBox { buffer.write((node as TextNode).text); break; case NodeType.ruby: - // For ruby text, read both base and annotation final ruby = node as RubyNode; buffer.write('${ruby.baseText} (${ruby.rubyText})'); break; @@ -20,7 +24,6 @@ extension _RenderHyperBoxAccessibility on RenderHyperBox { buffer.write(' '); break; case NodeType.atomic: - // For images, read alt text final atomic = node as AtomicNode; if (atomic.alt != null && atomic.alt!.isNotEmpty) { buffer.write('[Image: ${atomic.alt}] '); @@ -35,411 +38,4 @@ extension _RenderHyperBoxAccessibility on RenderHyperBox { return buffer.toString().trim(); } - - /// Recursively builds semantic nodes for the document tree - void _buildSemanticNodes( - UDTNode node, - List semanticNodes, - SemanticsNode parentNode, - ) { - // Handle links - they need special semantic treatment - if (node is InlineNode && node.tagName == 'a') { - final href = node.attributes['href']; - if (href != null) { - final rect = _getNodeRect(node); - // Only add semantic node if it has a valid, visible rect - if (rect != null && rect.width > 0 && rect.height > 0) { - final linkNode = SemanticsNode(); - - linkNode.updateWith( - config: SemanticsConfiguration() - ..isLink = true - ..textDirection = textDirection - ..label = node.textContent - ..hint = 'Link to $href' - ..onTap = () { - onLinkTap?.call(href); - }, - ); - - linkNode.rect = rect; - semanticNodes.add(linkNode); - } - return; // Don't process children of links separately - } - } - - // Handle headings - announce heading level - if (node is BlockNode && node.tagName != null) { - final headingLevel = _getHeadingLevel(node.tagName!); - if (headingLevel > 0) { - final rect = _getNodeRect(node); - // Only add semantic node if it has a valid, visible rect - if (rect != null && rect.width > 0 && rect.height > 0) { - final headingNode = SemanticsNode(); - - headingNode.updateWith( - config: SemanticsConfiguration() - ..isHeader = true - ..textDirection = textDirection - ..label = node.textContent - ..hint = 'Heading level $headingLevel', - ); - - headingNode.rect = rect; - semanticNodes.add(headingNode); - } - return; - } - } - - // Handle images - if (node is AtomicNode && node.tagName == 'img') { - final rect = _getNodeRect(node); - // Only add semantic node if it has a valid, visible rect - if (rect != null && rect.width > 0 && rect.height > 0) { - final imgNode = SemanticsNode(); - - imgNode.updateWith( - config: SemanticsConfiguration() - ..isImage = true - ..textDirection = textDirection - ..label = node.alt ?? 'Image', - ); - - imgNode.rect = rect; - semanticNodes.add(imgNode); - } - return; - } - - // Handle buttons (if any interactive elements) - if (node.tagName == 'button') { - final rect = _getNodeRect(node); - // Only add semantic node if it has a valid, visible rect - if (rect != null && rect.width > 0 && rect.height > 0) { - final buttonNode = SemanticsNode(); - final label = _ariaLabel(node) ?? node.textContent; - buttonNode.updateWith( - config: SemanticsConfiguration() - ..isButton = true - ..textDirection = textDirection - ..label = label, - ); - buttonNode.rect = rect; - semanticNodes.add(buttonNode); - } - return; - } - - // Handle / / - if (node.tagName == 'input') { - final inputType = node.attributes['type']?.toLowerCase() ?? ''; - if (inputType == 'button' || - inputType == 'submit' || - inputType == 'reset') { - final rect = _getNodeRect(node); - if (rect != null && rect.width > 0 && rect.height > 0) { - final btnNode = SemanticsNode(); - final label = - _ariaLabel(node) ?? node.attributes['value'] ?? inputType; - btnNode.updateWith( - config: SemanticsConfiguration() - ..isButton = true - ..textDirection = textDirection - ..label = label, - ); - btnNode.rect = rect; - semanticNodes.add(btnNode); - } - return; - } - } - - // Handle