Skip to content

[rive_native]: ArtboardWidgetPainter leaked on controller swap (didUpdateWidget skips dispose) #636

@eli1stark

Description

@eli1stark

Description

_ArtboardWidgetRiveRendererState.didUpdateWidget replaces its internal ArtboardWidgetPainter without disposing the outgoing one. Each time the painter prop of ArtboardWidgetRiveRenderer changes, the previous ArtboardWidgetPainter (a ChangeNotifier) is dropped on the floor — dispose() is never called, and leak_tracker reports it as notDisposed.

Package: rive_native 0.1.6 (also reproduces against rive 0.14.6, which is what triggers the swap by passing the controller through as the painter).

Source

rive_native/lib/src/rive_widget.dart, lines 583–589:

@override
void didUpdateWidget(covariant ArtboardWidgetRiveRenderer oldWidget) {
  super.didUpdateWidget(oldWidget);
  if (oldWidget.painter != widget.painter) {
    setState(() {
      _painter = ArtboardWidgetPainter(widget.painter); // ← old _painter dropped, never disposed
    });
  }
}

State.initState / State.dispose are paired correctly — only didUpdateWidget is missing the disposal of the outgoing painter.

Reproduction

Any flow that swaps the RiveWidgetController passed to RiveWidget will trigger it, since _RiveWidgetState.build passes widget.controller straight through as the painter of RiveArtboardWidgetArtboardWidgetRiveRenderer. Each controller swap leaks one ArtboardWidgetPainter.

Minimal repro:

class Demo extends StatefulWidget {
  const Demo({super.key});
  @override
  State<Demo> createState() => _DemoState();
}

class _DemoState extends State<Demo> {
  RiveWidgetController? _controller;
  late RiveFile _file;

  @override
  void initState() {
    super.initState();
    File.asset('assets/anim.riv').then((f) {
      setState(() {
        _file = f!;
        _controller = RiveWidgetController(_file);
      });
    });
  }

  void _swap() {
    final old = _controller;
    setState(() => _controller = RiveWidgetController(_file));
    WidgetsBinding.instance.addPostFrameCallback((_) => old?.dispose());
  }

  @override
  Widget build(BuildContext context) {
    if (_controller == null) return const SizedBox.shrink();
    return Column(children: [
      Expanded(child: RiveWidget(controller: _controller!)),
      ElevatedButton(onPressed: _swap, child: const Text('swap controller')),
    ]);
  }
}

Enable leak_tracker in main, tap the swap button N times, force a GC — leak_tracker reports N ArtboardWidgetPainter<ProceduralPainter> instances as notDisposed, with creation stacks pointing at either _RiveNativeView.createRenderObject (first painter, then replaced by a later swap) or _RiveNativeView.updateRenderObject (subsequent painters created in didUpdateWidget).

Expected

didUpdateWidget should dispose the outgoing _painter before replacing it, matching the initState / dispose lifecycle.

Suggested fix

@override
void didUpdateWidget(covariant ArtboardWidgetRiveRenderer oldWidget) {
  super.didUpdateWidget(oldWidget);
  if (oldWidget.painter != widget.painter) {
    final old = _painter;
    setState(() {
      _painter = ArtboardWidgetPainter(widget.painter);
    });
    old?.dispose();
  }
}

(Could also be deferred via addPostFrameCallback if there's any concern about disposing mid-build, but in practice the painter isn't referenced by the render object yet at this point.)

Environment

  • rive_native: 0.1.6
  • rive: 0.14.6
  • Flutter: 3.41.9
  • Platform: iOS / macOS / Android (any — the bug is in pure Dart)

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions