feat: integrate more lint rules and missing dispose calls (#1174)

* chore: enable additional lint rules and update dependencies

Enable new lint rules including no_equal_then_else, no_equal_conditions,
avoid_contradictory_expressions, and resource cleanup rules. Update
awesome_lints dependency to use local path reference.

* fix: add missing dispose calls for proper resource cleanup

Add dispose() calls for controllers, notifiers, and other resources
across multiple components to prevent memory leaks. Also fix parameter
type in table_node.dart and collection equality check in scroll service.

* refactor: remove deprecated document and node classes

Delete deprecated DocumentV0 and NodeV0 classes and remove the
deprecated Document.empty() factory method in favor of Document.blank().

* fix: remove deprecated exports and duplicate lint ignores

Remove exports for deleted deprecated document and node files from
core.dart and remove duplicate dispose_fields ignore comments.

* chore: point awesome_lints to git main branch

Change awesome_lints dependency from local path to git repository
reference pointing to main branch.

* fix: remove duplicate dispose calls in find replace widget

Remove duplicate findTextEditingController.dispose() call and remove
searchService.dispose() from FindMenu since it doesn't own the service.
The searchService is owned and disposed by the parent widget.

* fix: correct overlay entry disposal in toolbar widgets

Remove overlay entries from overlay before disposing. OverlayEntry must
be removed with remove() before dispose() is called. Move cleanup logic
to proper order in dispose methods.

* refactor: inline overlay cleanup logic in toolbar dispose methods

Replace helper method calls with inline cleanup logic to make the
overlay entry disposal order explicit (remove, dispose, set to null).
This commit is contained in:
Lucas 2025-12-26 17:05:02 +08:00 • committed by GitHub
commit b1e7853608
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 98 additions and 507 deletions

View file

@ -18,7 +18,21 @@ analyzer:
custom_lint: custom_lint:
enable_all_lint_rules: false enable_all_lint_rules: false
rules: rules:
# Formatting rules
- newline_before_case - newline_before_case
- newline_before_constructor - newline_before_constructor
- newline_before_method - newline_before_method
- newline_before_return - newline_before_return
- no_equal_then_else
- no_equal_conditions
- avoid_contradictory_expressions
- avoid_constant_conditions
- avoid_collection_equality_checks
- avoid_collection_methods_with_unrelated_types
# - avoid_late_context
- avoid_mounted_in_setstate
- proper_super_calls
- dispose_fields
# - avoid_undisposed_instances
# - dispose_class_fields

View file

@ -1,6 +1,4 @@
export 'document/attributes.dart'; export 'document/attributes.dart';
export 'document/deprecated/document.dart';
export 'document/deprecated/node.dart';
export 'document/diff.dart'; export 'document/diff.dart';
export 'document/document.dart'; export 'document/document.dart';
export 'document/node.dart'; export 'document/node.dart';

View file

@ -1,145 +0,0 @@
import 'dart:collection';
import 'package:appflowy_editor/src/core/document/attributes.dart';
import 'package:appflowy_editor/src/core/document/deprecated/node.dart';
import 'package:appflowy_editor/src/core/document/path.dart';
import 'package:appflowy_editor/src/core/document/text_delta.dart';
///
/// ⚠️ THIS FILE HAS BEEN DEPRECATED.
/// ⚠️ THIS FILE HAS BEEN DEPRECATED.
/// ⚠️ THIS FILE HAS BEEN DEPRECATED.
///
/// ONLY USE FOR MIGRATION.
///
class DocumentV0 {
DocumentV0({
required this.root,
});
factory DocumentV0.fromJson(Map<String, dynamic> json) {
assert(json['document'] is Map);
final document = Map<String, Object>.from(json['document'] as Map);
final root = NodeV0.fromJson(document);
return DocumentV0(root: root);
}
/// Creates a empty document with a single text node.
factory DocumentV0.empty() {
final root = NodeV0(
type: 'editor',
children: LinkedList<NodeV0>()..add(TextNodeV0.empty()),
);
return DocumentV0(
root: root,
);
}
final NodeV0 root;
/// Returns the node at the given [path].
NodeV0? nodeAtPath(Path path) {
return root.childAtPath(path);
}
/// Inserts a [NodeV0]s at the given [Path].
bool insert(Path path, Iterable<NodeV0> nodes) {
if (path.isEmpty || nodes.isEmpty) {
return false;
}
final target = nodeAtPath(path);
if (target != null) {
for (final node in nodes) {
target.insertBefore(node);
}
return true;
}
final parent = nodeAtPath(path.parent);
if (parent != null) {
for (var i = 0; i < nodes.length; i++) {
parent.insert(nodes.elementAt(i), index: path.last + i);
}
return true;
}
return false;
}
/// Deletes the [NodeV0]s at the given [Path].
bool delete(Path path, [int length = 1]) {
if (path.isEmpty || length <= 0) {
return false;
}
var target = nodeAtPath(path);
if (target == null) {
return false;
}
while (target != null && length > 0) {
final next = target.next;
target.unlink();
target = next;
length--;
}
return true;
}
/// Updates the [NodeV0] at the given [Path]
bool update(Path path, Attributes attributes) {
if (path.isEmpty) {
return false;
}
final target = nodeAtPath(path);
if (target == null) {
return false;
}
target.updateAttributes(attributes);
return true;
}
/// Updates the [TextNodeV0] at the given [Path]
bool updateText(Path path, Delta delta) {
if (path.isEmpty) {
return false;
}
final target = nodeAtPath(path);
if (target == null || target is! TextNodeV0) {
return false;
}
target.delta = target.delta.compose(delta);
return true;
}
bool get isEmpty {
if (root.children.isEmpty) {
return true;
}
if (root.children.length > 1) {
return false;
}
final node = root.children.first;
if (node is TextNodeV0 &&
(node.delta.isEmpty || node.delta.toPlainText().isEmpty)) {
return true;
}
return false;
}
Map<String, Object> toJson() {
return {
'document': root.toJson(),
};
}
}

View file

@ -1,318 +0,0 @@
import 'dart:collection';
import 'package:flutter/material.dart';
import 'package:appflowy_editor/src/core/document/attributes.dart';
import 'package:appflowy_editor/src/core/document/path.dart';
import 'package:appflowy_editor/src/core/document/text_delta.dart';
import 'package:appflowy_editor/src/core/legacy/built_in_attribute_keys.dart';
///
/// ⚠️ THIS FILE HAS BEEN DEPRECATED.
/// ⚠️ THIS FILE HAS BEEN DEPRECATED.
/// ⚠️ THIS FILE HAS BEEN DEPRECATED.
///
/// ONLY USE FOR MIGRATION.
///
final class NodeV0 extends ChangeNotifier with LinkedListEntry<NodeV0> {
NodeV0({
required this.type,
Attributes? attributes,
this.parent,
LinkedList<NodeV0>? children,
}) : children = children ?? LinkedList<NodeV0>(),
_attributes = attributes ?? {} {
for (final child in this.children) {
child.parent = this;
}
}
factory NodeV0.fromJson(Map<String, Object> json) {
assert(json['type'] is String);
final jType = json['type'] as String;
final jChildren = json['children'] as List?;
final jAttributes = json['attributes'] != null
? Attributes.from(json['attributes'] as Map)
: Attributes.from({});
final children = LinkedList<NodeV0>();
if (jChildren != null) {
children.addAll(
jChildren.map(
(jChild) => NodeV0.fromJson(
Map<String, Object>.from(jChild),
),
),
);
}
NodeV0 node;
if (jType == 'text') {
final jDelta = json['delta'] as List<dynamic>?;
final delta = jDelta == null ? Delta() : Delta.fromJson(jDelta);
node = TextNodeV0(
children: children,
attributes: jAttributes,
delta: delta,
);
} else {
node = NodeV0(
type: jType,
children: children,
attributes: jAttributes,
);
}
for (final child in children) {
child.parent = node;
}
return node;
}
final String type;
final LinkedList<NodeV0> children;
NodeV0? parent;
Attributes _attributes;
// Renderable
final key = GlobalKey();
final layerLink = LayerLink();
Attributes get attributes => {..._attributes};
String get id {
if (subtype != null) {
return '$type/$subtype';
}
return type;
}
String? get subtype {
if (attributes[BuiltInAttributeKey.subtype] is String) {
return attributes[BuiltInAttributeKey.subtype] as String;
}
return null;
}
Path get path => _computePath();
void updateAttributes(Attributes attributes) {
final oldAttributes = this.attributes;
_attributes = composeAttributes(this.attributes, attributes) ?? {};
// Notifies the new attributes
// if attributes contains 'subtype', should notify parent to rebuild node
// else, just notify current node.
bool shouldNotifyParent =
this.attributes['subtype'] != oldAttributes['subtype'];
shouldNotifyParent ? parent?.notifyListeners() : notifyListeners();
}
NodeV0? childAtIndex(int index) {
if (children.length <= index || index < 0) {
return null;
}
return children.elementAt(index);
}
NodeV0? childAtPath(Path path) {
if (path.isEmpty) {
return this;
}
return childAtIndex(path.first)?.childAtPath(path.sublist(1));
}
void insert(NodeV0 entry, {int? index}) {
final length = children.length;
index ??= length;
if (children.isEmpty) {
entry.parent = this;
children.add(entry);
notifyListeners();
return;
}
// If index is out of range, insert at the end.
// If index is negative, insert at the beginning.
// If index is positive, insert at the index.
if (index >= length) {
children.last.insertAfter(entry);
} else if (index <= 0) {
children.first.insertBefore(entry);
} else {
childAtIndex(index)?.insertBefore(entry);
}
}
@override
void insertAfter(NodeV0 entry) {
entry.parent = parent;
super.insertAfter(entry);
// Notifies the new node.
parent?.notifyListeners();
}
@override
void insertBefore(NodeV0 entry) {
entry.parent = parent;
super.insertBefore(entry);
// Notifies the new node.
parent?.notifyListeners();
}
@override
void unlink() {
super.unlink();
parent?.notifyListeners();
parent = null;
}
Map<String, Object> toJson() {
var map = <String, Object>{
'type': type,
};
if (children.isNotEmpty) {
map['children'] =
children.map((node) => node.toJson()).toList(growable: false);
}
if (attributes.isNotEmpty) {
map['attributes'] = attributes;
}
return map;
}
NodeV0 copyWith({
String? type,
LinkedList<NodeV0>? children,
Attributes? attributes,
}) {
final node = NodeV0(
type: type ?? this.type,
attributes: attributes ?? {...this.attributes},
children: children,
);
if (children == null && this.children.isNotEmpty) {
for (final child in this.children) {
node.children.add(
child.copyWith()..parent = node,
);
}
}
return node;
}
Path _computePath([Path previous = const []]) {
if (parent == null) {
return previous;
}
var index = 0;
for (final child in parent!.children) {
if (child == this) {
break;
}
index += 1;
}
return parent!._computePath([index, ...previous]);
}
}
final class TextNodeV0 extends NodeV0 {
TextNodeV0({
required Delta delta,
super.children,
Attributes? attributes,
}) : _delta = delta,
super(
type: 'text',
attributes: attributes ?? {},
);
TextNodeV0.empty({Attributes? attributes})
: _delta = Delta(operations: [TextInsert('')]),
super(
type: 'text',
attributes: attributes ?? {},
);
Delta _delta;
Delta get delta => _delta;
set delta(Delta v) {
_delta = v;
notifyListeners();
}
@override
Map<String, Object> toJson() {
final map = super.toJson();
map['delta'] = delta.toJson();
return map;
}
@override
TextNodeV0 copyWith({
String? type = 'text',
LinkedList<NodeV0>? children,
Attributes? attributes,
Delta? delta,
}) {
final textNode = TextNodeV0(
children: children,
attributes: attributes ?? this.attributes,
delta: delta ?? this.delta,
);
if (children == null && this.children.isNotEmpty) {
for (final child in this.children) {
textNode.children.add(
child.copyWith()..parent = textNode,
);
}
}
return textNode;
}
String toPlainText() => _delta.toPlainText();
}
extension NodeV0Equality on Iterable<NodeV0> {
bool equals(Iterable<NodeV0> other) {
if (length != other.length) {
return false;
}
for (var i = 0; i < length; i++) {
if (!_nodeEquals(elementAt(i), other.elementAt(i))) {
return false;
}
}
return true;
}
bool _nodeEquals<T, U>(T base, U other) {
if (identical(this, other)) return true;
return base is NodeV0 &&
other is NodeV0 &&
other.type == base.type &&
other.children.equals(base.children);
}
}

View file

@ -45,19 +45,6 @@ class Document {
return Document(root: root); return Document(root: root);
} }
/// Creates a empty document with a single text node.
@Deprecated('use Document.blank() instead')
factory Document.empty() {
final root = Node(
type: 'document',
children: LinkedList<Node>()..add(TextNode.empty()),
);
return Document(
root: root,
);
}
/// Creates a blank [Document] containing an empty root [Node]. /// Creates a blank [Document] containing an empty root [Node].
/// ///
/// If [withInitialText] is true, the document will contain an empty /// If [withInitialText] is true, the document will contain an empty

View file

@ -23,26 +23,36 @@ class _AnimatedSelectionAreaPaintState extends State<AnimatedSelectionAreaPaint>
with SingleTickerProviderStateMixin { with SingleTickerProviderStateMixin {
late Animation<double> animation; late Animation<double> animation;
late AnimationController controller; late AnimationController controller;
late CurvedAnimation curvedAnimation;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
if (widget.withAnimation) { if (widget.withAnimation) {
curvedAnimation = CurvedAnimation(
parent: controller,
curve: Curves.bounceInOut,
);
controller = AnimationController( controller = AnimationController(
duration: const Duration(seconds: 4), duration: const Duration(seconds: 4),
vsync: this, vsync: this,
); );
animation = Tween<double>(begin: 0, end: 1.0).animate( animation = Tween<double>(begin: 0, end: 1.0).animate(curvedAnimation);
CurvedAnimation(
parent: controller,
curve: Curves.bounceInOut,
),
);
controller.repeat(); controller.repeat();
} }
} }
@override
void dispose() {
if (widget.withAnimation) {
curvedAnimation.dispose();
controller.dispose();
}
super.dispose();
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
Widget builder(double value) { Widget builder(double value) {
@ -70,15 +80,6 @@ class _AnimatedSelectionAreaPaintState extends State<AnimatedSelectionAreaPaint>
}, },
); );
} }
@override
void dispose() {
if (widget.withAnimation) {
controller.dispose();
}
super.dispose();
}
} }
class SelectionAreaPaint extends StatelessWidget { class SelectionAreaPaint extends StatelessWidget {

View file

@ -132,6 +132,13 @@ class ImageBlockComponentWidgetState extends State<ImageBlockComponentWidget>
bool alwaysShowMenu = false; bool alwaysShowMenu = false;
@override
void dispose() {
showActionsNotifier.dispose();
super.dispose();
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final node = widget.node; final node = widget.node;

View file

@ -129,6 +129,7 @@ class _UploadImageMenuState extends State<UploadImageMenu> {
@override @override
void dispose() { void dispose() {
_focusNode.dispose(); _focusNode.dispose();
_textEditingController.dispose();
super.dispose(); super.dispose();
} }

View file

@ -219,6 +219,12 @@ class _TableBlockComponentWidgetState extends State<TableBlockComponentWidget>
late final editorState = Provider.of<EditorState>(context, listen: false); late final editorState = Provider.of<EditorState>(context, listen: false);
final _scrollController = ScrollController(); final _scrollController = ScrollController();
@override
void dispose() {
_scrollController.dispose();
super.dispose();
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
Widget child = Scrollbar( Widget child = Scrollbar(

View file

@ -129,7 +129,7 @@ class TableNode {
return TableNode(node: node); return TableNode(node: node);
} }
Node getCell(int col, row) => _cells[col][row]; Node getCell(int col, int row) => _cells[col][row];
TableConfig get config => _config; TableConfig get config => _config;

View file

@ -33,7 +33,7 @@ class KeyboardServiceWidget extends StatefulWidget {
class KeyboardServiceWidgetState extends State<KeyboardServiceWidget> class KeyboardServiceWidgetState extends State<KeyboardServiceWidget>
implements AppFlowyKeyboardService { implements AppFlowyKeyboardService {
late final SelectionGestureInterceptor interceptor; late final SelectionGestureInterceptor interceptor;
late final EditorState editorState; late final EditorState editorState = context.read<EditorState>();
late final TextInputService textInputService; late final TextInputService textInputService;
late final FocusNode focusNode; late final FocusNode focusNode;
@ -52,7 +52,6 @@ class KeyboardServiceWidgetState extends State<KeyboardServiceWidget>
void initState() { void initState() {
super.initState(); super.initState();
editorState = Provider.of<EditorState>(context, listen: false);
editorState.selectionNotifier.addListener(_onSelectionChanged); editorState.selectionNotifier.addListener(_onSelectionChanged);
interceptor = SelectionGestureInterceptor( interceptor = SelectionGestureInterceptor(
@ -78,6 +77,7 @@ class KeyboardServiceWidgetState extends State<KeyboardServiceWidget>
@override @override
void dispose() { void dispose() {
textInputService.close();
editorState.selectionNotifier.removeListener(_onSelectionChanged); editorState.selectionNotifier.removeListener(_onSelectionChanged);
editorState.service.selectionService.unregisterGestureInterceptor( editorState.service.selectionService.unregisterGestureInterceptor(
'keyboard', 'keyboard',

View file

@ -43,6 +43,7 @@ class _ScrollServiceWidgetState extends State<ScrollServiceWidget>
@override @override
void dispose() { void dispose() {
scrollController.dispose();
editorState.selectionNotifier.removeListener(_onSelectionChanged); editorState.selectionNotifier.removeListener(_onSelectionChanged);
super.dispose(); super.dispose();
} }
@ -123,10 +124,13 @@ class _ScrollServiceWidgetState extends State<ScrollServiceWidget>
default: default:
targetRect = selectionRects.last; targetRect = selectionRects.last;
/// sometimes moving up in a long single node may be not working // sometimes moving up in a long single node may be not working
/// so we need to special handle this case. // so we need to special handle this case.
final isInSingleNode = (lastSelection?.isSingle ?? false) && final isLastSelectionSingle = lastSelection?.isSingle ?? false;
lastSelection?.start.path == selection.start.path; final isLastSelectionPathEqual =
lastSelection?.start.path.equals(selection.start.path) ?? false;
final isInSingleNode =
isLastSelectionSingle && isLastSelectionPathEqual;
if (selection.isForward && isInSingleNode) { if (selection.isForward && isInSingleNode) {
targetRect = selectionRects.first; targetRect = selectionRects.first;
} }

View file

@ -89,6 +89,8 @@ class _DesktopSelectionServiceWidgetState
@override @override
void dispose() { void dispose() {
clearSelection(); clearSelection();
_dropTargetEntry?.dispose();
_dropTargetEntry = null;
WidgetsBinding.instance.removeObserver(this); WidgetsBinding.instance.removeObserver(this);
editorState.selectionNotifier.removeListener(_updateSelection); editorState.selectionNotifier.removeListener(_updateSelection);
editorState.removeScrollViewScrolledListener( editorState.removeScrollViewScrolledListener(

View file

@ -115,6 +115,8 @@ class _MobileSelectionServiceWidgetState
@override @override
void dispose() { void dispose() {
clearSelection(); clearSelection();
_lastPanOffset.dispose();
currentSelection.dispose();
WidgetsBinding.instance.removeObserver(this); WidgetsBinding.instance.removeObserver(this);
selectionNotifierAfterLayout.dispose(); selectionNotifierAfterLayout.dispose();
editorState.selectionNotifier.removeListener(_updateSelection); editorState.selectionNotifier.removeListener(_updateSelection);

View file

@ -66,6 +66,12 @@ class _FindAndReplaceMenuWidgetState extends State<FindAndReplaceMenuWidget> {
showReplaceMenu = widget.showReplaceMenu; showReplaceMenu = widget.showReplaceMenu;
} }
@override
void dispose() {
searchService.dispose();
super.dispose();
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Column( return Column(
@ -187,12 +193,13 @@ class _FindMenuState extends State<FindMenu> {
@override @override
void dispose() { void dispose() {
widget.searchService.matchWrappers.removeListener(_setState); findTextFieldFocusNode.dispose();
widget.searchService.currentSelectedIndex.removeListener(_setState);
widget.searchService.dispose();
findTextEditingController.removeListener(_searchPattern); findTextEditingController.removeListener(_searchPattern);
findTextEditingController.dispose(); findTextEditingController.dispose();
widget.searchService.matchWrappers.removeListener(_setState);
widget.searchService.currentSelectedIndex.removeListener(_setState);
super.dispose(); super.dispose();
} }
@ -389,6 +396,13 @@ class _ReplaceMenuState extends State<ReplaceMenu> {
final replaceTextFieldFocusNode = FocusNode(); final replaceTextFieldFocusNode = FocusNode();
final replaceTextEditingController = TextEditingController(); final replaceTextEditingController = TextEditingController();
@override
void dispose() {
replaceTextFieldFocusNode.dispose();
replaceTextEditingController.dispose();
super.dispose();
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Row( return Row(

View file

@ -100,13 +100,17 @@ class _FloatingToolbarState extends State<FloatingToolbar>
@override @override
void dispose() { void dispose() {
Debounce.cancel(_debounceKey);
_toolbarContainer?.remove();
_toolbarContainer?.dispose();
_toolbarContainer = null;
editorState.selectionNotifier.removeListener(_onSelectionChanged); editorState.selectionNotifier.removeListener(_onSelectionChanged);
widget.editorScrollController.offsetNotifier.removeListener( widget.editorScrollController.offsetNotifier.removeListener(
_onScrollPositionChanged, _onScrollPositionChanged,
); );
WidgetsBinding.instance.removeObserver(this); WidgetsBinding.instance.removeObserver(this);
_clear();
_toolbarWidget = null; _toolbarWidget = null;
super.dispose(); super.dispose();

View file

@ -44,6 +44,13 @@ class _ColorPickerState extends State<ColorPicker> {
_convertHexToOpacity(customColorHex ?? selectedColorHex) ?? '100'; _convertHexToOpacity(customColorHex ?? selectedColorHex) ?? '100';
} }
@override
void dispose() {
_colorHexController.dispose();
_colorOpacityController.dispose();
super.dispose();
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return basicOverlay( return basicOverlay(

View file

@ -41,6 +41,7 @@ class _LinkMenuState extends State<LinkMenu> {
@override @override
void dispose() { void dispose() {
_focusNode.dispose();
_textEditingController.dispose(); _textEditingController.dispose();
super.dispose(); super.dispose();
} }

View file

@ -83,6 +83,10 @@ class _MobileFloatingToolbarState extends State<MobileFloatingToolbar>
@override @override
void dispose() { void dispose() {
_toolbarContainer?.remove();
_toolbarContainer?.dispose();
_toolbarContainer = null;
editorState.selectionNotifier.removeListener(_onSelectionChanged); editorState.selectionNotifier.removeListener(_onSelectionChanged);
widget.editorScrollController.offsetNotifier.removeListener( widget.editorScrollController.offsetNotifier.removeListener(
_onScrollPositionChanged, _onScrollPositionChanged,
@ -90,8 +94,6 @@ class _MobileFloatingToolbarState extends State<MobileFloatingToolbar>
_onTapSelectionAreaSubscription.cancel(); _onTapSelectionAreaSubscription.cancel();
WidgetsBinding.instance.removeObserver(this); WidgetsBinding.instance.removeObserver(this);
_clear();
super.dispose(); super.dispose();
} }

View file

@ -74,7 +74,10 @@ class _MobileToolbarV2State extends State<MobileToolbarV2> {
@override @override
void dispose() { void dispose() {
_removeKeyboardToolbar(); isKeyboardShow.dispose();
toolbarOverlay?.remove();
toolbarOverlay?.dispose();
toolbarOverlay = null;
KeyboardHeightObserver.instance.removeListener(_onKeyboardHeightChanged); KeyboardHeightObserver.instance.removeListener(_onKeyboardHeightChanged);
super.dispose(); super.dispose();

Some files were not shown because too many files have changed in this diff Show more