Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/core/lib/flutter_widget_from_html_core.dart
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
export 'package:html/dom.dart' show NodeList;

export 'src/core_data.dart';
export 'src/core_helpers.dart';
export 'src/core_html_widget.dart';
Expand Down
39 changes: 32 additions & 7 deletions packages/core/lib/src/core_html_widget.dart
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,17 @@ class HtmlWidget extends StatefulWidget {
/// The input string.
final String html;

/// Pre-parsed DOM nodes, produced by [parseHtmlToNodes].
///
/// When provided, [html] is ignored entirely — the widget uses these nodes
/// directly and skips HTML-string-to-DOM parsing. You may pass an empty
/// string for [html]:
///
/// ```dart
/// HtmlWidget('', parsedNodes: nodes)
/// ```
final dom.NodeList? parsedNodes;

/// The custom [WidgetFactory] builder.
final WidgetFactory Function()? factoryBuilder;

Expand All @@ -66,13 +77,15 @@ class HtmlWidget extends StatefulWidget {
/// - [buildAsync]
/// - [enableCaching]
/// - [html]
/// - [parsedNodes]
/// - [renderMode]
/// - [textStyle]
List<dynamic> get rebuildTriggers => [
baseUrl,
buildAsync,
enableCaching,
html,
parsedNodes,
renderMode,
textStyle,
..._rebuildTriggers ?? const [],
Expand Down Expand Up @@ -106,6 +119,7 @@ class HtmlWidget extends StatefulWidget {
this.onLoadingBuilder,
this.onTapImage,
this.onTapUrl,
this.parsedNodes,
List<dynamic>? rebuildTriggers,
this.renderMode = RenderMode.column,
this.textStyle,
Expand All @@ -124,8 +138,9 @@ class HtmlWidgetState extends State<HtmlWidget> {
Future<Widget>? _future;
InheritedProperties? _rootProperties;

bool get buildAsync =>
widget.buildAsync ?? widget.html.length > kShouldBuildAsync;
bool get buildAsync => widget.parsedNodes != null
? widget.buildAsync ?? false
: widget.buildAsync ?? widget.html.length > kShouldBuildAsync;

bool get enableCaching => widget.enableCaching ?? !buildAsync;

Expand Down Expand Up @@ -224,11 +239,12 @@ class HtmlWidgetState extends State<HtmlWidget> {
Future<bool> scrollToAnchor(String id) => _wf.onTapUrl('#$id');

Future<Widget> _buildAsync() async {
if (widget.html.isEmpty) {
if (widget.html.isEmpty && widget.parsedNodes == null) {
return Future.sync(() => _sliverOrWidget0);
}

final domNodes = await compute(_parseHtml, widget.html);
final dom.NodeList domNodes =
widget.parsedNodes ?? await compute(parseHtmlToNodes, widget.html);
if (!mounted) {
return _sliverOrWidget0;
}
Expand All @@ -241,15 +257,15 @@ class HtmlWidgetState extends State<HtmlWidget> {
}

Widget _buildSync() {
if (widget.html.isEmpty) {
if (widget.html.isEmpty && widget.parsedNodes == null) {
return _sliverOrWidget0;
}

Timeline.startSync('Build $widget (sync)');

Widget built;
try {
final domNodes = _parseHtml(widget.html);
final domNodes = widget.parsedNodes ?? parseHtmlToNodes(widget.html);
built = _buildBody(this, domNodes);
} catch (error, stackTrace) {
built =
Expand Down Expand Up @@ -325,5 +341,14 @@ Widget _buildBody(HtmlWidgetState state, dom.NodeList domNodes) {
return built;
}

dom.NodeList _parseHtml(String html) =>
/// Parses an HTML string into a DOM node list.
///
/// This is a pure-Dart function with no Flutter dependencies, so it can be
/// called inside a [compute] callback or a custom isolate:
///
/// ```dart
/// final nodes = await Isolate.run(() => parseHtmlToNodes(myHtml));
/// HtmlWidget(myHtml, parsedNodes: nodes);
/// ```
dom.NodeList parseHtmlToNodes(String html) =>
parser.HtmlParser(html, parseMeta: false).parseFragment().nodes;
94 changes: 94 additions & 0 deletions packages/core/test/parsed_nodes_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_widget_from_html_core/flutter_widget_from_html_core.dart';
import 'package:html/dom.dart' as dom;

import '_.dart';

void main() {
testWidgets('renders from parsedNodes', (tester) async {
const html = 'Hello <b>World</b>';
final nodes = parseHtmlToNodes(html);

final explained = await explain(
tester,
null,
hw: HtmlWidget(
'',
key: hwKey,
parsedNodes: nodes,
),
);

expect(explained, equals('[RichText:(:Hello (+b:World))]'));
});

testWidgets('reacts to parsedNodes changes (new object)', (tester) async {
final nodes1 = parseHtmlToNodes('Foo');
final nodes2 = parseHtmlToNodes('Bar');

final e1 = await explain(
tester,
null,
hw: HtmlWidget(
'',
key: hwKey,
parsedNodes: nodes1,
),
);
expect(e1, equals('[RichText:(:Foo)]'));

final e2 = await explain(
tester,
null,
hw: HtmlWidget(
'',
key: hwKey,
parsedNodes: nodes2,
),
);
expect(e2, equals('[RichText:(:Bar)]'));
});

testWidgets('does NOT react to in-place parsedNodes changes', (tester) async {
final nodes = parseHtmlToNodes('Foo');

final e1 = await explain(
tester,
null,
hw: HtmlWidget(
'',
key: hwKey,
parsedNodes: nodes,
),
);
expect(e1, equals('[RichText:(:Foo)]'));

// Modify nodes in place
nodes.first.text = 'Bar';

// We need to trigger a rebuild of the TestApp/HtmlWidget with the SAME nodes object
await tester.pumpWidget(
StatefulBuilder(
builder: (context, setState) => GestureDetector(
onTap: () => setState(() {}),
child: HtmlWidget(
'',
key: hwKey,
parsedNodes: nodes,
),
),
),
);

// Initial state
expect(await explainWithoutPumping(), equals('[RichText:(:Foo)]'));

// Trigger rebuild
await tester.tap(find.byType(GestureDetector));
await tester.pump();

// It should STILL be Foo because rebuildTriggers didn't change (same NodeList object)
expect(await explainWithoutPumping(), equals('[RichText:(:Foo)]'));
});
}
Loading