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:
- Run on 4.16.1.
- Click "counter" a few times (e.g. reaches 5).
- Click "rebuild parent" once.
- 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.
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
Stateinside 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:
That fix changed
_TabBodyinlib/src/controls/navigation/tab_view/tab.dartfrom keying the page view item by index:to keying it by the
Tabinstance itself:Tabis a plainStatefulWidgetand does not override==, soValueKey<Tab>(item)compares by identity. The fix implicitly requires callers to keepTabinstances stable across rebuilds, but that contract is not documented anywhere and was not required by 4.16.0. The common usage pattern is to constructTabwidgets inbuild()(exactly what the README and examples do), which means every parent rebuild produces newTabinstances, a newValueKey<Tab>for every page, and the wholePageViewsubtree is recreated.To Reproduce
Steps:
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
Impact
Any app that rebuilds the widget containing a
TabViewon state changes (e.g. a parentsetState/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.