Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Integrate Navigator 2.0 and state restoration for the web #65777

Open
chunhtai opened this issue Sep 14, 2020 · 27 comments
Open

Integrate Navigator 2.0 and state restoration for the web #65777

chunhtai opened this issue Sep 14, 2020 · 27 comments
Labels
c: new feature Nothing broken; request for a new capability engine flutter/engine repository. See also e: labels. f: routes Navigator, Router, and related APIs. framework flutter/packages/flutter repository. See also f: labels. P3 Issues that are less important to the Flutter project platform-web Web applications specifically team-web Owned by Web platform team triaged-web Triaged by Web platform team

Comments

@chunhtai
Copy link
Contributor

chunhtai commented Sep 14, 2020

We would like to integrate state restoration to web application.

This includes:

  1. Web engine listens to restorable state reported from the framework and save the restorable state into the browser history entry. (blocked on [web] implement PluginUtilities for flutter web #33615)
  2. Web engine starts sending the restorable state to the framework as part of onPopstate event(when user click forward or backward buttons)
  3. Provides an opinionated route information parser and router delegate that will restore the app automatically when it receives the restorable state
@chunhtai chunhtai added engine flutter/engine repository. See also e: labels. f: routes Navigator, Router, and related APIs. framework flutter/packages/flutter repository. See also f: labels. labels Sep 14, 2020
@ghost
Copy link

ghost commented Sep 15, 2020

Does this implementation fix the following bugs?
#32248
I'm in trouble because "Back" and "Forward" of the browser are not working.

@chunhtai
Copy link
Contributor Author

the back/forward button is working now if you use router widget at the latest master. See this example #63424

@yjbanov yjbanov added P3 Issues that are less important to the Flutter project platform-web Web applications specifically c: new feature Nothing broken; request for a new capability labels Sep 15, 2020
@ghost
Copy link

ghost commented Sep 16, 2020

@chunhtai
I switched the SDK to chunhtai:nav-refactor-stock and then ran dev/benchmarks/test_apps/stock.
However, even if I moved the page, I couldn't press "Back" and "Forward" in the browser.
When I press F5 to reload, I can press "Back", but it's not working properly.

@chunhtai
Copy link
Contributor Author

The stock app rewrite pr has not been merged yet #63424

@ghost
Copy link

ghost commented Sep 16, 2020

@chunhtai
So I switched Flutter from flutter:master to chunhtai:nav-refactor-stock.
However, the "forward" and "back" of the browser do not work.
Is it not enough to just switch flutter?

@chunhtai
Copy link
Contributor Author

oh yeah, that branch does not have the the right engine, let me update the branch

@ghost
Copy link

ghost commented Sep 17, 2020

@chunhtai
I have confirmed that the "Back" and "Forward" buttons work.
Thank you!

@lulupointu
Copy link

@chunhtai will you at some point enable state reservation even when we leave the web app but use our web history to go back to the given location?

@chunhtai
Copy link
Contributor Author

chunhtai commented Feb 4, 2021

@lulupointu you can do that even without the router, right? if you click back button the flutter app will run with the initial route equal to the last url before you navigate outside of flutter

@lulupointu
Copy link

The route location is right but the route state is null even if it wasn't I think

@chunhtai
Copy link
Contributor Author

chunhtai commented Feb 4, 2021

we currently do not have plan to restore the state automatically. If you use router, you can store the state in the url(as query parameter) or the state object. you will have to do it manually.

@lulupointu
Copy link

I'm not talking about restoring it manually, I'm saying that the state attribute of RouteInformation is always null when we leave the web app and come back trough the browser history. However I confirm that the state is still here since I can access it trough html.window.history.state['state'].

@chunhtai
Copy link
Contributor Author

chunhtai commented Feb 4, 2021

the state in the RouteInformation is not for the restoration state, it is any data you want to put in in restoreRouteInformation. but you need Router.navigate to make sure to create a browser history entry when it changes. Otherwise the it will only create new browser history entry every time the url changes

@lulupointu
Copy link

I don't think we understand each other so I will just give you an example.

Here is a simple app:
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'dart:html' as html;

void main() {
  runApp(UrlHandler());
}

class UrlHandler extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp.router(
      routerDelegate: UrlHandlerRouterDelegate(),
      routeInformationParser: UrlHandlerInformationParser(),
    );
  }
}

class NavigationState {
  final int value;
  NavigationState(this.value);
}

final GlobalKey<NavigatorState> _urlHandlerRouterDelegateNavigatorKey =
GlobalKey<NavigatorState>();
class UrlHandlerRouterDelegate extends RouterDelegate<NavigationState>
    with ChangeNotifier, PopNavigatorRouterDelegateMixin {
  int count = 0;

  @override
  Widget build(BuildContext context) {
    return Navigator(
      pages: [
        MaterialPage(child: MyHomePage(count: count, increase: increase)),
      ],
      onPopPage: (_, __) {
        // We don't handle routing logic here, so we just return false
        return false;
      },
    );
  }

  @override
  GlobalKey<NavigatorState> get navigatorKey => _urlHandlerRouterDelegateNavigatorKey;

  // Navigation state to app state
  @override
  Future<void> setNewRoutePath(NavigationState navigationState) async {
    // If a value which is not a number has been entered,
    // navigationState.value is null so we just notifyListeners
    // without changing the app state to change the value of the url
    // to its previous value
    if (navigationState.value == null) {
      notifyListeners();
      return null;
    }

    // Get the new count, which is navigationState.value//10
    count = (navigationState.value / 10).floor();

    // If the navigationState.value was not a multiple of 10
    // the url is not equal to count*10, therefore the url isn't right
    // In that case, we notifyListener in order to get the valid NavigationState
    // from the new app state
    if (count * 10 != navigationState.value) notifyListeners();
    return null;
  }

  // App state to Navigation state, triggered by notifyListeners()
  @override
  NavigationState get currentConfiguration => NavigationState(count*10);

  void increase() {
    count++;
    notifyListeners();
  }
}

class UrlHandlerInformationParser extends RouteInformationParser<NavigationState> {
  // Url to navigation state
  @override
  Future<NavigationState> parseRouteInformation(RouteInformation routeInformation) async {
    print('Got new route information with state: ${routeInformation.state}');
    print('State from browser (html.window.history.state["state"]): ${html.window.history.state["state"]}');
    return NavigationState(int.tryParse(routeInformation.state as String ?? ''));
  }

  // Navigation state to url
  @override
  RouteInformation restoreRouteInformation(NavigationState navigationState) {
    print('restoreRouteInformation with location "${'/${navigationState.value}'}" and value "${navigationState.value}"');
    return RouteInformation(location: '/${navigationState.value}', state: navigationState.value);
  }
}

class MyHomePage extends StatefulWidget {
  MyHomePage({Key key, @required this.count, @required this.increase}) : super(key: key);
  final int count;
  final VoidCallback increase;

  @override
  _MyHomePageState createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('Counter App'),
      ),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: <Widget>[
            Text(
              'You have pushed the button this many times:',
            ),
            Text(
              '${widget.count}',
              style: Theme.of(context).textTheme.headline4,
            ),
          ],
        ),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: () {
          widget.increase();
        },
        tooltip: 'Counter',
        child: Icon(Icons.add),
      ),
    );
  }
}

Steps to reproduce the bug

  1. Click the FAB, this will update the route state
  2. Go to a random address
  3. Click the back button

What we get

The RouteInformation state is null

What we expect

The RouteInformation State should be the same value as the one it was when we left the page

Proof that the state was in the browser but not well reported by flutter

If we get the state from the browser using html.window.history.state["state"], we found our saved RouteInformation state from before we left

Video footage of the bug

leaving_breaks_navigation_2_state.mp4
Flutter doctor: Doctor summary (to see all details, run flutter doctor -v): [✓] Flutter (Channel beta, 1.25.0-8.3.pre, on Linux, locale en_US.UTF-8) [✓] Android toolchain - develop for Android devices (Android SDK version 30.0.2) [✓] Chrome - develop for the web [✓] Android Studio [✓] IntelliJ IDEA Ultimate Edition (version 2020.2) [✓] VS Code (version 1.52.1) [✓] Connected device (1 available)

• No issues found!

I hope to see what I mean ! It's not a big issue for me because I end up always checking html.window.history.state["state"] but this is definitely not intended.

@chunhtai
Copy link
Contributor Author

chunhtai commented Feb 5, 2021

yes, this is a use case we have not thought about, currently the browser history only create an entry if url change, that meant the subsequence update to the state without updating url will not be registered. Essentially what we want to do is to keep replacing the state in the current history entry.

you can use Router.navigate() to force it create an entry, but that may not help your use case.

I will see what i can do about this

@steeling
Copy link

Is this what's causing the issue in https://gallery.flutter.dev/#/ ? Even when the URL changes the nav barely works. Try clicking around the various categories and you'll see what i mean. Both back and forward are broken

@esDotDev
Copy link

esDotDev commented Oct 31, 2021

Is it possible to use this for other platforms as well? If we could extend this to desktop, we essentially can get Stateful PageRoutes, which is a killer feature that developers have wanted for a very long time.

It would be nice if Flutter team would just put this functionality in the deveopers hands in a more straightforward way. All the plumbing is there, but no general purpose API is provided for us to use it in flexible ways (unless I'm missing it)

Picturing something like

bool _isRestoring = true;
initState(){
   RstorationManager.tryRestoreState(this).then((){
     setState(() => _isRestoring = false);
   });
}

Similarly we should have a straighfoward API call to make when we want to save current route state, rather than relying on some obfuscated Navigator call.

imo we should start thinking about it more wholistically, this is not a mobile feature, or a web feature, it's a general purpose feature that is useful in a variety of contexts, including:

  • on mobile where OS's try and kill your app.
  • on web when using back/fowards nav
  • on any platform, anytime you want your routes to appear to persistState

For example a basic "stateful tabs" iOS style implementation, would benefit from this ability to easily have routes restore their previous state when switching tabs, so when I change tabs, each route actually remembers it's scroll position, texfields, etc etc

@chunhtai
Copy link
Contributor Author

chunhtai commented Nov 7, 2022

This has come up during my investigation of browser refresh button. Right now browser refresh will kill everything and restart the app from scratch. This seems like a perfect case for state restoration. This can work as the following:

Web engine stores state restoration data into browser history. When the app first launch it will restore state if current browser history has restoration data. It is important we only do this for first build, otherwise backward and forward button would also restore state which may be weird if it was handled by router (or maybe we should do that anyway?)

This can also help with the case where the mobile browser kills the tab when it is not active

@kengu
Copy link

kengu commented Mar 6, 2023

Hi,

I have found a neat workaround that adds full support for State Restoration with RestorationManager to the web platform. By using a web plugin, it is possible to implement the platform side of state restoration for web. I've reimplemented the same channel contract implemented on the android platform and used shared_preferences for persistence. Inspired by How to Write a Flutter Web Plugin, I ended up with the following code:

pubspec.yaml (snippet)
Minimal configuration needed for this workaround to work on web platform.

dependencies:
  flutter:
    sdk: flutter
  flutter_web_plugins:
    sdk: flutter
  shared_preferences: ^2.0.18

flutter:
  plugin:
    platforms:
      web:
        pluginClass: RestorationWebPlugin
        fileName: restoration_web_plugin.dart

lib/restoration_web_plugin.dart
I've used shared_preferences as an example, it is just an implementation detail.

import 'dart:async';
import 'dart:convert';

import 'package:flutter/services.dart';
import 'package:flutter_web_plugins/flutter_web_plugins.dart';
import 'package:shared_preferences/shared_preferences.dart';

class RestorationWebPlugin {
  static const String childrenMapKey = 'c';
  static const String valuesMapKey = 'v';

  static void registerWith(Registrar registrar) {
    final MethodChannel channel = MethodChannel(
        'flutter/restoration', const StandardMethodCodec(), registrar);
    final RestorationWebPlugin instance = RestorationWebPlugin();
    channel.setMethodCallHandler(instance.handleMethodCall);
  }

  Future<dynamic> handleMethodCall(MethodCall call) async {
    switch (call.method) {
      case 'get':
        final prefs = await SharedPreferences.getInstance();
        final json = prefs.getString('restoration:data');
        final ByteData? encoded = const StandardMessageCodec()
            .encodeMessage(json != null ? jsonDecode(json) : null);
        return <dynamic, dynamic>{
          'enabled': true,
          'data': encoded?.buffer.asUint8List(
            encoded.offsetInBytes,
            encoded.lengthInBytes,
          ),
        };
      case 'put':
        final prefs = await SharedPreferences.getInstance();
        final data = call.arguments as Uint8List;
        final Object? decoded =
            const StandardMessageCodec().decodeMessage(data.buffer.asByteData(
          data.offsetInBytes,
          data.lengthInBytes,
        ));
        final json = jsonEncode(decoded);
        try {
          await prefs.setString('restoration:data', json);
          return null;
        } catch (e) {
          throw PlatformException(
            message: e.toString(),
            code: 'Unknown',
            details: "Failed to write 'restoration:data' to shared_preferences",
          );
        }
      default:
        throw PlatformException(
          code: 'Unimplemented',
          details: "The restoration_web_plugin plugin for "
              "web doesn't implement "
              "the method '${call.method}'",
        );
    }
  }
}

I have tested it with go_router, and it persists browser history and navigation rail state as expected. NavigationRail extended state is stored with RestorationMixin and RestorableBool.

class _SmartDashScaffoldState extends State<SmartDashScaffold>
    with RestorationMixin {
  int _selectedIndex = 0;
  final _extended = RestorableBool(false);

  @override
  String? get restorationId => 'SmartDashScaffold';

  @override
  void restoreState(RestorationBucket? oldBucket, bool initialRestore) {
    registerForRestoration(_extended, 'extended');
  }

  @override
  Widget build(BuildContext context) {
    final nextIndex = Pages.indexOf(widget.location);
    return _buildScaffold(
      nextIndex > -1 ? nextIndex : _selectedIndex,
      widget.child,
    );
  }

  Scaffold _buildScaffold(int index, Widget content) {
    return Scaffold(
      body: SafeArea(
        child: Row(children: [
          NavigationRail(
            extended: _extended.value,
            selectedIndex: (_selectedIndex = index),
            groupAlignment: -0.9,
            leading: SmartDashMenu(
              onPressed: () {
                setState(() {
                  _extended.value = !_extended.value;
                });
              },
            ),
            onDestinationSelected: (int index) {
              context.go(Pages.locations[index]);
            },
            destinations: const [
              NavigationRailDestination(
                icon: Icon(Icons.home_outlined),
                selectedIcon: Icon(Icons.home),
                label: Text('Home'),
              ),
              NavigationRailDestination(
                icon: Icon(
                  Icons.history_outlined,
                ),
                selectedIcon: Icon(Icons.history),
                label: Text('History'),
              ),
              NavigationRailDestination(
                icon: Badge(
                  child: Icon(
                    Icons.notifications_none_outlined,
                  ),
                ),
                selectedIcon: Icon(Icons.notifications),
                label: Text('Notifications'),
              ),
              NavigationRailDestination(
                icon: Icon(
                  Icons.more_horiz_outlined,
                ),
                selectedIcon: Icon(Icons.more_horiz),
                label: Text('Settings'),
              ),
            ],
          ),
          const VerticalDivider(thickness: 1, width: 1),
          Expanded(child: content),
        ]),
      ),
    );
  }
}
go_router_with_state_restoration_on_web.webm

Note

This should work with any flutter project on web that uses State Restoration, you don't need to depend on it from another package. Maybe I will publish this workaround as a web platform plugin depending on the response here.

Gotcha

When building Flutter for Android, I had to move RestorationWebPlugin into a local flutter plugin project and depend on it as any other dart package. It was not nessessary for MacOS which compiled and ran as expected.

@dengzq
Copy link

dengzq commented Mar 8, 2023

Here are some top restoration issues in our product.
1.How to restore state when we click refresh button on chrome ?
2.How to restore state when users open flutter web by a url on a new window ?

Hope to have solutions!

@esDotDev
Copy link

esDotDev commented Mar 8, 2023

This is why I've been saying that state_restoration, and stateful_routing need to be thought of under the same umbrella. Whether its the OS reloading the app at some specific view, or a user hitting refresh in the browser, we need some unified simple way to be able to restore the state of the app and current view stack.

Currently restoration API is limited in it's viewpoint, and only considered OS mobile use cases, but it seems all of the plumbing is there to work as a general purpose restoration mechanism for any view on any platform, restarting for any reason.

@kengu
Copy link

kengu commented Mar 8, 2023

Here are some top restoration issues in our product. 1.How to restore state when we click refresh button on chrome ? 2.How to restore state when users open flutter web by a url on a new window ?

Hope to have solutions!

@dengzq The workaround proposed above fixes item 1.

@polarby
Copy link

polarby commented May 7, 2023

I am surprised that this issue is not further in focus. This makes flutter websites quite unuseful and results in a very poor user experience.

The current way is to completely rebuild the app on refresh and specified url entry (returning to the first route) to ensure a secure route stack (Not resulting in empty screens on pop). Routes that are not required to be in the stack may be opened in a new tab to prevent a broken structure. Passing arguments via an URL query (by routeSettings.name) would work, but makes it obsolete if the route has to be within a working navigation stack.

The way to check if the route was reloaded or called by a specified URL also seems inconvenient. Each navigation route in the app has to pass arguments in order to determine its origin:

MaterialApp(
 onGenerateRoute: (settings) {
        if (settings.arguments == null) {
          // route has no arguments, therefore has to be a reload or specified
          // url call -> redirect to initial route, to ensure secure stack 
        } else {
          // open the route by name and pass the arguments
        }
      },
)

@kengu Are there any limitations to your approach? Is there a pub.dev plugin for easy usage? How would you determine if as site was reloaded or called by an URL (aka within a working navigation stack or not)?

@IncognitoGK9
Copy link

I am following on this, I have similar issue. On refresh, the whole web app goes back to the main page and does not stay in the same page. I am using GetMaterialApp.

@naveenbharadwaj19
Copy link

Can i use go_router for state restoration?

@TheOlajos
Copy link

Looking forward to state restoration being implemented in the stable channel. This is vital to our webapp

@troya2
Copy link

troya2 commented May 7, 2024

Before I found this thread, I spent some time getting my flutter web app all set up with state restoration, but it didn't seem to work. This thread clarified why.

Then I tried kengu's work around, above. It was raising an exception on the json encoding because the NavigatorState is also using state restoration and inserting some data with null keys, which json encoding doesn't easily handle. I changed from json en/decoding to base64 and it's working well, and it avoids any extra parsing of the data.

Here are the relevant changes to the above restoration_web_plugin (drop-in replacement for the "get" and "put" cases):

switch (call.method) {
  case 'get':
    final prefs = await SharedPreferences.getInstance();
    final base64encoded = prefs.getString('restoration:data');
    final data = base64encoded == null ? null : base64Decode(base64encoded);
    return {
      'enabled': true,
      'data': data,
    };
  case 'put':
    final prefs = await SharedPreferences.getInstance();
    final data = call.arguments as Uint8List;
    final base64encoded = base64Encode(data);
    try {
      await prefs.setString('restoration:data', base64encoded);
      return null;
    } catch (e) {
      throw PlatformException(
        message: e.toString(),
        code: 'Unknown',
        details: "Failed to write 'restoration:data' to shared_preferences",
      );
    }

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
c: new feature Nothing broken; request for a new capability engine flutter/engine repository. See also e: labels. f: routes Navigator, Router, and related APIs. framework flutter/packages/flutter repository. See also f: labels. P3 Issues that are less important to the Flutter project platform-web Web applications specifically team-web Owned by Web platform team triaged-web Triaged by Web platform team
Projects
None yet
Development

No branches or pull requests