From a6c6b405f1e490ee660a0d2a769d010f8c967a30 Mon Sep 17 00:00:00 2001 From: Gareth Reese Date: Mon, 6 Jul 2026 10:36:53 +0100 Subject: [PATCH] Fix #132: inline builder duplicating text with multi-child elements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a custom MarkdownElementBuilder for an inline tag (e.g. `a`) returned a widget from visitElementAfterWithContext, only current.children[0] was replaced. If the element had multiple inline children — such as link text split on a `_` or `*` delimiter into separate text nodes — the remaining children were still appended to the parent, rendering the trailing text twice. Replace all children with the returned widget (..clear()..add(child)), matching the block-element path which already discards children. Behaviour is unchanged for empty/single-child elements. Adds regression tests covering `_` and `*` delimiter link text with a custom `a` builder. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_014oVQtXCPb3bSLnL2eDwGrh --- CHANGELOG.md | 8 ++++++ lib/src/builder.dart | 12 +++++---- pubspec.yaml | 2 +- test/custom_syntax_test.dart | 51 ++++++++++++++++++++++++++++++++++++ 4 files changed, 67 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ccb1127..e366034 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,11 @@ +## 1.0.9 + +* Fixed custom inline `builders` (e.g. `a`) duplicating trailing text when the element had multiple children, such as link text containing a `_` or `*` delimiter that the parser splits into separate text nodes (#132) + +**Thanks to our contributors:** +* [@Jess-Gabia](https://github.com/Jess-Gabia) for reporting the issue and identifying the fix (#132) +* [@amungi](https://github.com/amungi) for confirming the fix in production + ## 1.0.8 * Fixed custom `builders` and `paddingBuilders` for the `hr` tag being ignored diff --git a/lib/src/builder.dart b/lib/src/builder.dart index 4f75a5f..ce1bdc4 100644 --- a/lib/src/builder.dart +++ b/lib/src/builder.dart @@ -504,11 +504,13 @@ class MarkdownBuilder implements md.NodeVisitor { parent.style, ); if (child != null) { - if (current.children.isEmpty) { - current.children.add(child); - } else { - current.children[0] = child; - } + // The returned widget represents the entire element, so discard all + // existing inline children — replacing only children[0] leaves any + // siblings (e.g. link text split on a `_`/`*` delimiter) to leak + // through to the parent and render twice. See issue #132. + current.children + ..clear() + ..add(child); } } else if (tag == 'img') { // create an image widget for this image diff --git a/pubspec.yaml b/pubspec.yaml index 60ef218..26db666 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -5,7 +5,7 @@ description: A Markdown renderer for Flutter. Create rich text output, repository: https://github.com/foresightmobile/flutter_markdown_plus issue_tracker: https://github.com/foresightmobile/flutter_markdown_plus/issues documentation: https://github.com/foresightmobile/flutter_markdown_plus#readme -version: 1.0.8 +version: 1.0.9 environment: sdk: ^3.4.0 diff --git a/test/custom_syntax_test.dart b/test/custom_syntax_test.dart index 3e7974b..cc3db27 100644 --- a/test/custom_syntax_test.dart +++ b/test/custom_syntax_test.dart @@ -211,6 +211,48 @@ void defineTests() { expect(textWidget.data, 'foo'); }, ); + + testWidgets( + 'inline builder replaces all children, not just the first (issue #132)', + (WidgetTester tester) async { + // The link text starts with an unpaired `_`, which the parser splits into + // multiple text nodes (`[Text("_"), Text("hello world")]`). A custom `a` + // builder returning a widget must replace *all* of them, otherwise the + // trailing "hello world" leaks through and renders twice. + await tester.pumpWidget( + boilerplate( + Markdown( + data: '[_hello world](https://example.com)', + builders: { + 'a': LinkTagBuilder(), + }, + ), + ), + ); + + expect(find.text('LINK'), findsOneWidget); + expect(find.textContaining('hello world', findRichText: true), findsNothing); + }, + ); + + testWidgets( + 'inline builder replaces all children with a `*` delimiter (issue #132)', + (WidgetTester tester) async { + await tester.pumpWidget( + boilerplate( + Markdown( + data: '[*hello world](https://example.com)', + builders: { + 'a': LinkTagBuilder(), + }, + ), + ), + ); + + expect(find.text('LINK'), findsOneWidget); + expect(find.textContaining('hello world', findRichText: true), findsNothing); + }, + ); } class SubscriptSyntax extends md.InlineSyntax { @@ -371,6 +413,15 @@ class ImgBuilder extends MarkdownElementBuilder { } } +class LinkTagBuilder extends MarkdownElementBuilder { + @override + Widget visitElementAfter(md.Element element, TextStyle? preferredStyle) { + // Deliberately returns a marker that does not contain the link text, so a + // test can assert the original children were fully replaced. + return const Text('LINK'); + } +} + class NoteBuilder extends MarkdownElementBuilder { @override Widget? visitText(md.Text text, TextStyle? preferredStyle) {