Skip to content

🐛 TabView 4.16.1 regression: every parent rebuild disposes and recreates all tab bodies (ValueKey<Tab> uses identity equality) #1349

Description

@smileheart0708

Describe the bug

Since 4.16.1, a plain rebuild of the parent widget (without switching tabs, reordering, or closing any tab) tears down every tab body: all State inside the tab bodies is disposed and recreated, losing scroll position, expansion state, running animations, etc. In 4.16.0 the same usage keeps the bodies (and their state) alive.

Root cause

The 4.16.1 changelog says:

fix: TabView now keeps each tab's body (and its state) attached to its tab when tabs are reordered, instead of leaving a stateful body parked at its old slot while the header moves

That fix changed _TabBody in lib/src/controls/navigation/tab_view/tab.dart from keying the page view item by index:

// 4.16.0
key: ValueKey(index),

to keying it by the Tab instance itself:

// 4.16.1
itemBuilder: (context, index) {
  final item = widget.tabs[index];
  return ExcludeFocus(
    key: ValueKey<Tab>(item),   // <-- identity-based
    ...

Tab is a plain StatefulWidget and does not override ==, so ValueKey<Tab>(item) compares by identity. The fix implicitly requires callers to keep Tab instances stable across rebuilds, but that contract is not documented anywhere and was not required by 4.16.0. The common usage pattern is to construct Tab widgets in build() (exactly what the README and examples do), which means every parent rebuild produces new Tab instances, a new ValueKey<Tab> for every page, and the whole PageView subtree is recreated.

To Reproduce

import 'package:fluent_ui/fluent_ui.dart';

void main() => runApp(const TestApp());

class TestApp extends StatefulWidget {
  const TestApp({super.key});

  @override
  State<TestApp> createState() => _TestAppState();
}

class _TestAppState extends State<TestApp> {
  int _currentIndex = 0;
  int _unrelated = 0;

  @override
  Widget build(BuildContext context) {
    return FluentApp(
      home: Column(
        children: [
          // Tapping this button only rebuilds this widget; it does not
          // touch currentIndex, the tabs, or anything inside them.
          Button(
            onPressed: () => setState(() => _unrelated++),
            child: Text('rebuild parent: $_unrelated'),
          ),
          Expanded(
            child: TabView(
              currentIndex: _currentIndex,
              onChanged: (value) => setState(() => _currentIndex = value),
              tabs: [
                Tab(text: const Text('tab 1'), body: const CounterPage()),
                Tab(text: const Text('tab 2'), body: const CounterPage()),
              ],
            ),
          ),
        ],
      ),
    );
  }
}

class CounterPage extends StatefulWidget {
  const CounterPage({super.key});

  @override
  State<CounterPage> createState() => _CounterPageState();
}

class _CounterPageState extends State<CounterPage> {
  int _counter = 0;

  @override
  Widget build(BuildContext context) {
    return Center(
      child: Button(
        onPressed: () => setState(() => _counter++),
        child: Text('counter: $_counter'),
      ),
    );
  }
}

Steps:

  1. Run on 4.16.1.
  2. Click "counter" a few times (e.g. reaches 5).
  3. Click "rebuild parent" once.
  4. The counter is back to 0 and the body was rebuilt from scratch.

On 4.16.0 the counter survives step 3. I verified the two versions side by side: with 4.16.0 the test passes, with 4.16.1 the body state is lost.

Expected behavior

Rebuilding the parent widget must not dispose tab bodies. Body state should survive as long as the tab identity (and order) is unchanged — the pre-4.16.1 behavior.

Environment

  • fluent_ui: 4.16.1 (also reproduced with 4.16.0 as the working baseline)
  • Flutter: 3.44.8 / Dart 3.12.2
  • Platform: Windows desktop (widget tests, same behavior)

Impact

Any app that rebuilds the widget containing a TabView on state changes (e.g. a parent setState/notifier) silently loses all per-tab UI state: scroll positions, expansion state, text fields, in-flight animations. In our app, an expand/collapse animation inside a tab body is instantly skipped and the state reset on every unrelated state update.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions