From 7a20b134bbee193d49be9b01919339a0cef17e6a 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: Sat, 3 Oct 2026 22:58:30 +0700 Subject: [PATCH] fix(core): block plugins on custom tags were laid out at 0x0 and never painted A custom tag (, ) has no UA display style, so the HTML adapters build it as an InlineNode. _handleInlineNode only consulted the inline plugin tags, so a registered BLOCK plugin on such a tag never reached _tokenizeBlockPlugin: its widget was built, linked to no fragment, sized 0x0 and not painted ('More child widgets than fragments' in debug). A registered block tag is now handled as a block whatever its node type. The existing plugin tests hand-built BlockNodes or asserted findsOneWidget, which a 0x0 widget satisfies. The new tests assert size, position between the neighbouring paragraphs, painted pixels, stacking, virtualized mode, that the plugin owns its children, and that unregistered and inline plugins are unaffected. --- packages/hyper_render_core/CHANGELOG.md | 4 + .../lib/src/core/render_hyper_box_layout.dart | 10 + test/block_plugin_custom_tag_test.dart | 207 ++++++++++++++++++ 3 files changed, 221 insertions(+) create mode 100644 test/block_plugin_custom_tag_test.dart diff --git a/packages/hyper_render_core/CHANGELOG.md b/packages/hyper_render_core/CHANGELOG.md index 55f9345..65b6f44 100644 --- a/packages/hyper_render_core/CHANGELOG.md +++ b/packages/hyper_render_core/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog — hyper_render_core +## Unreleased + +- **Block-tier plugins on custom tags now render.** ``, `` and any other tag without a UA display style are built by the HTML adapters as inline nodes, so a registered **block** plugin on them never took the block path: its widget was built, linked to no fragment, laid out at 0×0 and never painted, with "Layout Warning: More child widgets than fragments" in debug. Only tags that are already block (`figure`, `div`) worked. A registered block tag is now treated as a block whatever its node type. Existing tests passed because they hand-built `BlockNode`s or only asserted `findsOneWidget`, which a 0×0 widget satisfies. + ## 1.10.0 ### 🆕 New diff --git a/packages/hyper_render_core/lib/src/core/render_hyper_box_layout.dart b/packages/hyper_render_core/lib/src/core/render_hyper_box_layout.dart index d01f115..6f2e25f 100644 --- a/packages/hyper_render_core/lib/src/core/render_hyper_box_layout.dart +++ b/packages/hyper_render_core/lib/src/core/render_hyper_box_layout.dart @@ -106,6 +106,16 @@ extension _RenderHyperBoxLayout on RenderHyperBox { } void _handleInlineNode(UDTNode node) { + // A custom tag (``, ``) has no UA display style, so the + // adapters build it as an InlineNode even when its plugin is block-tier. + // Routing it through the inline path never emits a fragment for the plugin + // widget: the widget was built but never linked, laid out at 0x0 and never + // painted. A registered block tag is a block whatever its node type. + if (_blockPluginTags.isNotEmpty && + _blockPluginTags.contains(node.tagName?.toLowerCase())) { + _tokenizeBlockPlugin(node); + return; + } if (_inlinePluginTags.isNotEmpty && _inlinePluginTags.contains(node.tagName?.toLowerCase())) { _fragments.add(Fragment.atomic( diff --git a/test/block_plugin_custom_tag_test.dart b/test/block_plugin_custom_tag_test.dart new file mode 100644 index 0000000..0db3173 --- /dev/null +++ b/test/block_plugin_custom_tag_test.dart @@ -0,0 +1,207 @@ +// A block-tier plugin on a CUSTOM tag (``, ``), reached the +// documented way — HyperViewer(html:, pluginRegistry:). The adapters build a +// custom tag as an InlineNode (no UA display style), so the block-plugin path +// was never taken: the widget was built, linked to no fragment, laid out at +// 0x0 and never painted ("Layout Warning: More child widgets than fragments"). +// +// The older plugin tests constructed `BlockNode(tagName: 'figure')` by hand or +// used `findsOneWidget`, which a 0x0 widget satisfies — so none could see it. +// These assert size, position and pixels. +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hyper_render/hyper_render.dart'; + +const _amber = Color(0xFFFFC107); + +class _Block implements HyperNodePlugin { + const _Block(this.tag, {this.height = 40}); + final String tag; + final double height; + @override + List get tagNames => [tag]; + @override + bool get isInline => false; + @override + Widget? buildWidget(UDTNode node, HyperPluginBuildContext ctx) => SizedBox( + key: ValueKey(tag), + height: height, + child: const ColoredBox(color: _amber), + ); +} + +Future<({List warnings, GlobalKey capture})> _pump( + WidgetTester t, + String html, + List plugins, { + HyperRenderMode mode = HyperRenderMode.sync, +}) async { + final warnings = []; + final reg = HyperPluginRegistry(); + for (final p in plugins) { + reg.register(p); + } + final key = GlobalKey(); + // flutter_test fails a test that leaves `debugPrint` replaced, and tearDown + // callbacks run after that check — so restore it here, not in addTearDown. + final old = debugPrint; + debugPrint = (m, {wrapWidth}) { + if (m != null && m.contains('More child widgets than fragments')) { + warnings.add(m); + } + }; + try { + await t.pumpWidget(MaterialApp( + home: Scaffold( + backgroundColor: Colors.white, + body: RepaintBoundary( + key: key, + child: SizedBox( + width: 400, + height: 400, + child: HyperViewer( + html: html, + pluginRegistry: reg, + mode: mode, + renderConfig: const HyperRenderConfig(useMicrotaskParsing: true), + ), + ), + ), + ), + )); + await t.pumpAndSettle(); + } finally { + debugPrint = old; + } + return (warnings: warnings, capture: key); +} + +/// Top offset of each text fragment, keyed by its text. +Map _textTops(WidgetTester t) { + RenderHyperBox? box; + void walk(RenderObject o) { + if (o is RenderHyperBox) box ??= o; + o.visitChildren(walk); + } + + walk(t.renderObject(find.byType(HyperRenderWidget).first)); + return { + for (final f in box!.debugFragments()) + if (f['text'] != null && f['offsetY'] != null) + (f['text'] as String).trim(): (f['offsetY'] as num).toDouble(), + }; +} + +Future _amberPixels(WidgetTester t, GlobalKey key) async => + (await t.runAsync(() async { + final b = key.currentContext!.findRenderObject() as RenderRepaintBoundary; + final d = (await (await b.toImage()).toByteData())!; + var n = 0; + for (var i = 0; i < d.lengthInBytes; i += 4) { + if (d.getUint8(i) == 255 && + d.getUint8(i + 1) == 193 && + d.getUint8(i + 2) == 7) { + n++; + } + } + return n; + }))!; + +void main() { + for (final html in [ + '

Before

After

', + '

Before

content the plugin owns

After

', + ]) { + testWidgets( + 'block plugin on a custom tag is sized, placed and painted: ' + '$html', (t) async { + final r = await _pump(t, html, [const _Block('x-a')]); + final f = find.byKey(const ValueKey('x-a')); + expect(f, findsOneWidget); + + // Full available width, its own height. + expect(t.getSize(f), const Size(400, 40)); + + // Between the two paragraphs, not stacked on top of either. Text is + // painted on the canvas, so read its position from the render object. + final y = _textTops(t); + final top = t.getTopLeft(f).dy; + expect(top, greaterThan(y['Before']!)); + expect(y['After']!, greaterThanOrEqualTo(top + 40 - 0.5)); + + expect(await _amberPixels(t, r.capture), 400 * 40, + reason: 'the plugin widget must actually paint'); + expect(r.warnings, isEmpty); + }); + } + + testWidgets('the plugin owns its children: their text is not also rendered', + (t) async { + await _pump( + t, '

Before

SECRET-CHILD', [const _Block('x-a')]); + expect( + find.textContaining('SECRET-CHILD', findRichText: true), findsNothing); + }); + + testWidgets('three adjacent block plugins stack, none overlapping', + (t) async { + await _pump(t, '', [ + const _Block('x-a'), + const _Block('x-b', height: 30), + const _Block('x-c', height: 20) + ]); + final ys = [ + for (final k in ['x-a', 'x-b', 'x-c']) + t.getTopLeft(find.byKey(ValueKey(k))).dy, + ]; + expect(ys[1], greaterThanOrEqualTo(ys[0] + 40 - 0.5)); + expect(ys[2], greaterThanOrEqualTo(ys[1] + 30 - 0.5)); + }); + + testWidgets('works in virtualized mode too', (t) async { + final r = await _pump( + t, '

Before

After

', [const _Block('x-a')], + mode: HyperRenderMode.virtualized); + expect(t.getSize(find.byKey(const ValueKey('x-a'))), const Size(400, 40)); + expect(r.warnings, isEmpty); + }); + + testWidgets( + 'an unregistered custom tag is unaffected (no widget, no warning)', + (t) async { + final r = await _pump(t, '

Before

inline text', []); + expect(find.byKey(const ValueKey('x-zzz')), findsNothing); + expect(r.warnings, isEmpty); + }); + + testWidgets('an INLINE plugin on a custom tag still flows with text', + (t) async { + final reg = HyperPluginRegistry()..register(const _InlineChip()); + await t.pumpWidget(MaterialApp( + home: Scaffold( + body: SizedBox( + width: 400, + child: HyperViewer( + html: '

Status: done

', + pluginRegistry: reg, + mode: HyperRenderMode.sync, + ), + ), + ), + )); + await t.pumpAndSettle(); + expect(find.byKey(const ValueKey('chip')), findsOneWidget); + expect(t.getSize(find.byKey(const ValueKey('chip'))).width, greaterThan(0)); + }); +} + +class _InlineChip implements HyperNodePlugin { + const _InlineChip(); + @override + List get tagNames => const ['x-chip']; + @override + bool get isInline => true; + @override + Widget? buildWidget(UDTNode node, HyperPluginBuildContext ctx) => + const SizedBox(key: ValueKey('chip'), width: 40, height: 16); +}