🐛 TabView 4.16.1 regression: every parent rebuild disposes and recreates all tab bodies (ValueKey<Tab> uses identity equality)
- Dominant language
- Dart
- Stars
- 3.5k
- Forks
- 506
- Avg merge
- 11d 2h
- Merged PRs (30d)
- 3
Description
**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:
```dart
// 4.16.0
key: ValueKey(index),
```
to keying it by the `Tab` instance itself:
```dart
// 4.16.1
itemBuilder: (context, index) {
final item = widget.tabs[index];
return ExcludeFocus(
key: ValueKey(item), // <-- identity-based
...
```
`Tab` is a plain `StatefulWidget` and does **not** override `==`, so `ValueKey(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` for every page, and the whole `PageView` subtree is recreated.
**To Reproduce**
```dart
import 'package:fluent_ui/fluent_ui.dart';
void main() => runApp(const TestApp());
class TestApp extends StatefulWidget {
const TestApp({super.key});
@override
State createState() => _TestAppState();
}
class _TestAppState extends State {
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 createState() => _CounterPageState();
}
class _CounterPageState extends State {
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.
Contributor guide
No contributing guide indexed for this repository
Research direction
Start in lib/src/controls/navigation/tab_view/tab.dart, focusing on _TabBody and the PageView item key change between 4.16.0 and 4.16.1. Reproduce the issue with the provided CounterPage example, then verify that an unrelated parent rebuild preserves tab body state without changing tab selection, order, or contents. A regression test should distinguish the 4.16.0 behavior from the 4.16.1 regression.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- dart, flutter
- Domain
- frontend
- Issue type
- Bug
- Difficulty
- 3/5
- Estimated time
- 1-2 days
- Activity status
- Quiet
- Clarity
- Clearly specified
- Newbie friendliness
- 72/100