bloc icon indicating copy to clipboard operation
bloc copied to clipboard

Hydrated bloc state lost after deploying updates to web app

Open JakesMD opened this issue 10 months ago • 22 comments
trafficstars

Description Whenever I deploy an update to my web app, the bloc state is lost.

Steps To Reproduce

HydratedBloc.storage = await HydratedStorage.build(
    storageDirectory: HydratedStorage.webStorageDirectory,
  );
  1. Deploy the web app. (e.g. the counter example)
  2. Open the app and make changes to the state in the app (e.g. increment a counter)
  3. Deploy a new version of the app.
  4. Make sure the your using the updated version of the app.
  5. The state from step 2 is lost.

Expected Behavior The state should persist no matter if push an update.

I'm guessing this is because the HydratedStorage.webStorageDirectory is just cache which gets deleted on an update. But I would expect the default webStorageDirectory to persist.

Additional Context I'm using hydrated bloc to persist the theme mode, language settings as well as recently viewed items.

JakesMD avatar Jan 14 '25 08:01 JakesMD

Hi @JakesMD šŸ‘‹ Thanks for opening an issue!

Does the issue persist when you upgrade to the latest stable version of hydrated_bloc (v10.0.0)?

felangel avatar Jan 14 '25 16:01 felangel

Hi! Good point! I missed the update you released a couple hours ago. Will get back asap...

JakesMD avatar Jan 14 '25 17:01 JakesMD

I works! 🄳 Thanks!

JakesMD avatar Jan 14 '25 18:01 JakesMD

Sorry šŸ™ˆ I just released another update to my app and the state was lost again.

JakesMD avatar Jan 14 '25 20:01 JakesMD

Tomorrow I'll have a go at hosting the counter example app and seeing if I can reproduce the issue that way. That would prove whether this really is a hydrated_bloc issue.

JakesMD avatar Jan 14 '25 20:01 JakesMD

How are you deploying updates? Sorry for the inconvenience!

felangel avatar Jan 14 '25 21:01 felangel

Hi! Can confirm: this is reproducible with the counter example. You tend to lose state when you include a new package.

Steps to reproduce

  1. Create/clone the hydrated_bloc example.
  2. Create a Firebase hosting project and link the app to it.
  3. Build the release app with flutter clean && flutter build web --release.
  4. Deploy the app with firebase deploy.
  5. Open the deployed app in your browser and press some buttons.
  6. Repeat x times:
    1. Bump the app version in pubspec.yaml.
    2. Rename the app bar title to Counter [VERSION].
    3. Build the release app with flutter clean && flutter build web --release.
    4. Deploy the app with firebase deploy.
    5. Reload the deployed app until your browser finally runs the new version with the changed app bar title.

That works perfectly. The state is always persisted.

Now do this:

  1. Bump the app version in pubspec.yaml.
  2. Rename the app bar title to Counter [VERSION].
  3. Add a new package to pubspec.yaml. I used flutter_confetti.
  4. Add the new package somewhere in the app. I did this:
        FloatingActionButton(
            child: const Icon(Icons.add),
            onPressed: () {
              Confetti.launch(context,
                  options: const ConfettiOptions(
                      particleCount: 100, spread: 70, y: 0.6));
              context.read<CounterBloc>().add(CounterIncrementPressed());
            },
          ),
    
  5. Build the release app with flutter clean && flutter build web --release
  6. Deploy the app with firebase deploy
  7. Reload the deployed app until your browser finally runs the new version with the changed app bar title.

Voila, your state was lost. Or at least parts of it. When I added flutter_confetti I lost the brightness, when I add signed_spacing_flex I lost all the state.

I tested this on MacBook Safari and Android Chrome.

JakesMD avatar Jan 15 '25 09:01 JakesMD

@JakesMD thanks for the update and detailed reproduction steps! Are you able to share the Flutter version you're using? I'll try to reproduce shortly šŸ‘

felangel avatar Jan 15 '25 17:01 felangel

Stable 3.27.1

JakesMD avatar Jan 15 '25 18:01 JakesMD

Hi! I've been playing around and have discovered some important points:

  • The issue occurs after a significant change to the widget tree (more than just changing the app bar title)
  • You can make the app fetch OLD state:
    • Increment the counter to 6 and set brightness to dark
    • Duplicate one of the floating action buttons, update, build and deploy
    • The counter is now 0 and the brightness is light
    • Increment the counter a few times to change the state
    • Remove the duplicate floating action button, update, build and deploy
    • The counter is now 6 again and brightness is dark

JakesMD avatar Jan 24 '25 15:01 JakesMD

Hi! I've been playing around and have discovered some important points:

  • The issue occurs after a significant change to the widget tree (more than just changing the app bar title)

  • You can make the app fetch OLD state:

    • Increment the counter to 6 and set brightness to dark
    • Duplicate one of the floating action buttons, update, build and deploy
    • The counter is now 0 and the brightness is light
    • Increment the counter a few times to change the state
    • Remove the duplicate floating action button, update, build and deploy
    • The counter is now 6 again and brightness is dark

Thanks for all the context! I'm digging into this now šŸ‘

felangel avatar Jan 24 '25 18:01 felangel

I was able to reproduce and I was able to resolve this by changing my firebase hosting configuration to use:

"public": "build/web",

instead of

"source": "."

This way the firebase-deploy command doesn't actually run the flutter build step. Then I ran flutter build web --pwa-strategy=none to disable the service worker and I was able to add new packages without losing my cached state.

Let me know if that helps and apologies for the delayed response!

felangel avatar Jan 24 '25 19:01 felangel

Seems to be fixed šŸ‘ It works when you make different changes to the app on every update (which will be 99% of the time). But when you revert changes it loads old state again.

My firebase config was already set to "public": "build/web".

After building with --pwa-strategy=none I was unable to make the app load the new version. So I set the max cache age to 60s in firebase.json:

  "headers": [
      {
        "source": "**",
        "headers": [
          {
            "key": "Cache-Control",
            "value": "max-age=60"
          }
        ]
      }
    ]

I've only tested this with the counter example and have yet to try it with the production app.

JakesMD avatar Jan 25 '25 09:01 JakesMD

Seems to be fixed šŸ‘ It works when you make different changes to the app on every update (which will be 99% of the time). But when you revert changes it loads old state again.

My firebase config was already set to "public": "build/web".

After building with --pwa-strategy=none I was unable to make the app load the new version. So I set the max cache age to 60s in firebase.json:

"headers": [ { "source": "**", "headers": [ { "key": "Cache-Control", "value": "max-age=60" } ] } ] I've only tested this with the counter example and have yet to try it with the production app.

Can you try unregistering the service worker manually? I need to dig into what the generated service worker is doing but I’d check if a brand new app/deployment has the same issues.

felangel avatar Jan 25 '25 16:01 felangel

Can you try the code here and see if anything changes?

https://github.com/IO-Design-Team/hive_ce/pull/74

Rexios80 avatar Jan 27 '25 15:01 Rexios80

Hi! Sorry guys, I missed your last few comments. And I'm afraid --pwa-strategy=none didn't work the last time we released an update...

JakesMD avatar Feb 26 '25 08:02 JakesMD

Glad this issue is getting attention; I'm experiencing the same problem.

I thought the issue was related to IndexedDB/Hive and was planning on making my own implementation of your Storage interface using something like SharedPreferences. Sounds like you've recently ruled that out, however.

I don't know enough about the service worker to know if this issue would still persist if I were to swap HydatedStorage.build() for a custom SharedPreferencesStorage.

I'll keep an eye on this thread. If there's anything you'd like me to try on my site, let me know.

andrewsheridan avatar Mar 07 '25 19:03 andrewsheridan

Image

edit: Updating to below works: [Breaking change] HydratedBloc.storage = await HydratedStorage.build( storageDirectory: kIsWeb ? HydratedStorageDirectory.web : HydratedStorageDirectory((await getTemporaryDirectory()).path), );

bharathwajv avatar Apr 20 '25 05:04 bharathwajv

Any updates on this? This issue is preventing me from deploying updates to the two websites that are my livelihood, and each workaround I've tried has not worked.

The last workaround I tried was creating my own Storage implementation using SharedPreferences if kIsWeb is true, and using the state constructor parameter of my Cubits/Blocs to manually bring in the value from SharedPreferences. Alas, whatever's going on with generated service worker still wiped the data.

Also I'm not sure if the previous comment from bharathwajv has anything to do with this particular issue.

andrewsheridan avatar May 19 '25 19:05 andrewsheridan

Any updates on this? This issue is preventing me from deploying updates to the two websites that are my livelihood, and each workaround I've tried has not worked.

The last workaround I tried was creating my own Storage implementation using SharedPreferences if kIsWeb is true, and using the state constructor parameter of my Cubits/Blocs to manually bring in the value from SharedPreferences. Alas, whatever's going on with generated service worker still wiped the data.

Also I'm not sure if the previous comment from bharathwajv has anything to do with this particular issue.

@andrewsheridan HydratedStorageDirectory.web this should work!

bharathwajv avatar May 21 '25 15:05 bharathwajv

This may have been fixed in the latest version of hive_ce... maybe? Probably not but worth a try. I haven't got around to trying it out yet.

https://github.com/IO-Design-Team/hive_ce/pull/139 https://github.com/IO-Design-Team/hive_ce/pull/138 https://github.com/IO-Design-Team/hive_ce/pull/132

JakesMD avatar Jun 14 '25 21:06 JakesMD

Those PRs should not have changed any functionality

Rexios80 avatar Jun 14 '25 23:06 Rexios80

@andrewsheridan do you find any workaround for this issue?

ChoyCheeWei avatar Jun 26 '25 14:06 ChoyCheeWei

In the example app overriding String get storagePrefix fixes it.

If you override storagePrefix in the CounterBloc but not the BrightnessBloc, the CounterBloc will keep its state, but the BrightnessBloc won't. (note that the first time you override storagePrefix the bloc will be reset.)

This makes sense when you read the documentation comment:

Storage prefix which can be overridden to provide a custom storage namespace. Defaults to [runtimeType] but should be overridden in cases where stored data should be resilient to obfuscation or persist between debug/release builds.

JakesMD avatar Jun 26 '25 18:06 JakesMD

In the example app overriding String get storagePrefix fixes it.

If you override storagePrefix in the CounterBloc but not the BrightnessBloc, the CounterBloc will keep its state, but the BrightnessBloc won't. (note that the first time you override storagePrefix the bloc will be reset.)

This makes sense when you read the documentation comment:

Storage prefix which can be overridden to provide a custom storage namespace. Defaults to [runtimeType] but should be overridden in cases where stored data should be resilient to obfuscation or persist between debug/release builds.

but this will affect the existing users right? their stored data will be removed

ChoyCheeWei avatar Jun 26 '25 19:06 ChoyCheeWei

In the example app overriding String get storagePrefix fixes it.

If you override storagePrefix in the CounterBloc but not the BrightnessBloc, the CounterBloc will keep its state, but the BrightnessBloc won't. (note that the first time you override storagePrefix the bloc will be reset.)

Good to know, this at least gives some direction going forward and reveals why my SharedPreferencesStorage workaround didn't end up working.

@JakesMD likely already knows this following bit, but I'm going to write it out loud to make sure my understanding is correct.

Sounds like the core issue is that web builds are getting minified or obfuscated in a way that the runtimeType of Blocs & Cubits is getting changed. When an update gets pushed out, maybe your CounterBloc was previously minified to a and in the new build it's been minified to b, and now the default storagePrefix is different.

Unfortunately this likely means that in order to recover the old data, you'd need a way to figure out what the runtimeType of CounterBloc was minified to in the old version, then go update your storagePrefix for CounterBloc to be that minified type.

If I've made any mistakes here, let me know. If anyone has any ideas on how to track down those obfuscated runtimeTypes, if it's even possible, I'd love to hear it.

andrewsheridan avatar Jun 26 '25 20:06 andrewsheridan

Trying to think through another potential avenue for recovering data that involves accessing the Box inside of HydratedStorage.

  • Get access to that Box
  • Use _box.keys to get the list of all the keys that have been used.
  • Iterate through those keys, retrieving the values associated to those keys.
  • If the value is a data structure match for your Bloc/Cubit, rehydrate that Bloc/Cubit with that value.

For example, if you have a UserCubit which has a User with a username and id, and you find a value which successfully creates a User via User.fromJson(value), then you can recover that data.

Big shortcoming is that this wouldn't really help if you have a bunch of different HydratedBlocs which are storing something basic like a String.

Just throwing out ideas because I'm desperate.

andrewsheridan avatar Jun 26 '25 20:06 andrewsheridan

@andrewsheridan How important is recovering the old data to you? If you were to override storagePrefix to say 'user_cubit' and deploy it, your users would lose their state one last time and (hopefully) never again in future releases of your app.

And yes, if you really needed to recover the old data from before you updated storagePrefix, your best option would be to find out what the minified runtimetype is and set storagePrefix to that. But I wouldn't know how to do that without releasing a new version of the app anyhow.

JakesMD avatar Jun 26 '25 21:06 JakesMD

@JakesMD it's critical. I used HydratedBloc to store pretty much everything. So if I were to push out an update, users would lose all their data and be outraged.

Didn't know about this web issue at the time, so when I discovered it I just stopped updating the site until it could be resolved. So I haven't updated one of my sites in 2-3 years.

Using HydratedBloc on web was meant to be temporary. The plan was always to shift that data over to something like Firebase, but I can't do that without pushing out a new update, which would cause the data to be lost. And now that the web version has this many users, they'd be outraged if I wiped their data, and would ruin my reputation.

I was planning on just putting up a new site that uses a different storage mechanism, provide a way to port data from the old site, and give users a year or so to migrate. But even then, I had no way to inform users of the old site that they needed to migrate.

At least now I know the root cause and have some options. I do think it's worth really calling attention to this fallback of HydratedBloc in the documentation, or finding a good long-term solution that doesn't require developers to lose data first to find out about this issue.

andrewsheridan avatar Jun 26 '25 21:06 andrewsheridan

You can access the box and therefore the runtime-types using the browser itself:

Image

Now you just have to work out which is which using the values.

As far I can tell, some of the data is stored as ascii so you just have to convert it. For example:

12: 98 (b)
13: 114 (r)
14: 105 (i)
15: 103 (g)
16: 104 (h)
17: 116 (t)
18: 110 (n)
19: 101 (e)
20: 115 (s)
21: 115 (s)

JakesMD avatar Jun 27 '25 05:06 JakesMD