Last active
March 11, 2025 05:48
-
-
Save aronduby/ec61cb19b409a90b8dfa722d56f983a5 to your computer and use it in GitHub Desktop.
Pinia Store that Persists to Browser Storage for Extension Development
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { PiniaPluginContext } from "pinia"; | |
import { computed, reactive, readonly, ref } from "vue"; | |
import browser from "webextension-polyfill"; | |
export type StorageType = 'sync'|'local'|'managed'|'session'; | |
export interface StorageState { | |
loading: Promise<void>, | |
loaded: boolean, | |
reading: boolean, | |
writing: boolean, | |
working: boolean, | |
} | |
export default function useBrowserStorage(defaultStorageType: StorageType) { | |
return ({ options, store }: PiniaPluginContext) => { | |
if (options.hasOwnProperty('useBrowserStorage') && options.useBrowserStorage !== false) { | |
const storage = browser.storage[options.useBrowserStorage === true ? defaultStorageType : options.useBrowserStorage!]; | |
// used with our initial load of the storage data so we don't needlessly write the data back due to the subscription | |
const internalKey = '__'+Math.random().toString(20).substring(2); | |
/** | |
* State tracking references | |
*/ | |
// our internal values | |
const loaded = ref(false); | |
const reading = ref(false); | |
const writing = ref(false); | |
const working = computed(() => reading.value || writing.value); | |
/** | |
* Reads the initial data from storage | |
* This is using Object.keys() to only get the values this store uses | |
*/ | |
const stateKeys = Object.keys(store.$state); | |
reading.value = true; | |
const storagePromise = storage.get(stateKeys) | |
.then((data) => { | |
store.$patch({ | |
...data, | |
[internalKey]: true | |
}); | |
loaded.value = true; | |
}) | |
.finally(() => { | |
reading.value = false; | |
}); | |
/** | |
* Our internal state gets added to the store as a custom property | |
* This is after our call to `storage.get()` so that we can use that promise | |
*/ | |
store.storageState = readonly(reactive({ | |
loading: storagePromise, | |
loaded: loaded, | |
reading: reading, | |
writing: writing, | |
working: working, | |
})); | |
// add it to devtools | |
if (import.meta.env.DEV) { | |
store._customProperties.add('storageState'); | |
} | |
/** | |
* Writing data to browser storage when the store updates | |
*/ | |
let timer: number; | |
store.$subscribe((mutation, state) => { | |
// skip our initial update to state | |
// @ts-ignore should have disabled with ?. | |
if (mutation?.payload?.hasOwnProperty(internalKey) ?? false) { | |
return; | |
} | |
if (timer) { | |
clearTimeout(timer); | |
} | |
timer = window.setTimeout(async () => { | |
try { | |
const writable = { ...state }; | |
delete writable[internalKey]; | |
writing.value = true; | |
await storage.set(writable) | |
} finally { | |
writing.value = false | |
} | |
}, 500); | |
}, {flush: 'post'}); | |
} | |
} | |
} | |
declare module 'pinia' { | |
// noinspection JSUnusedGlobalSymbols | |
export interface PiniaCustomProperties { | |
storageState: Readonly<StorageState> | |
} | |
// noinspection JSUnusedGlobalSymbols | |
export interface DefineStoreOptionsBase<S, Store> { | |
useBrowserStorage?: boolean|StorageType | |
} | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { defineStore } from "pinia"; | |
export interface OptionsData { | |
// Matchplay Related | |
matchplayApiKey?: string, | |
matchplayUserId?: number, | |
// Wheel Related | |
wheelApiKey?: string, | |
wheelUseExisting?: false|string, | |
wheelIncludeSelf?: boolean, | |
} | |
export const useOptionsStore = defineStore('browserStorage', { | |
state: (): OptionsData => ({ | |
// Matchplay Related | |
matchplayApiKey: undefined, | |
matchplayUserId: undefined, | |
// Wheel Related | |
wheelApiKey: undefined, | |
wheelUseExisting: false, | |
wheelIncludeSelf: false, | |
}), | |
getters: { | |
hasMatchplay: (state) => !!state.matchplayApiKey, | |
hasWheel: (state) => !!state.wheelApiKey, | |
}, | |
useBrowserStorage: true, | |
}); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { Component, createApp } from "vue"; | |
import { createPinia } from "pinia"; | |
import useBrowserStorage, { StorageType } from "@/plugins/pinia.browserStorage"; | |
// firefox needs extra work to use sync during dev, so let's just switch to local for now | |
export const storageType: StorageType = import.meta.env.DEV && __BROWSER__ === 'firefox' ? 'local' : 'sync'; | |
const pinia = createPinia() | |
.use(useBrowserStorage(storageType)); | |
createApp(component) | |
.use(pinia) | |
.use(vuetify); |
Added storageState.loading
that's a promise that resolves when the data has been read from storage. This makes it super easy to use Suspense
(or anything else)
// in setup function
const options = useOptionsStore();
await options.storageState.loading;
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
I couldn't find anything super obvious for what I wanted, so I tried my hand at making it. Seems to work ok so far. Don't have time to make it something official and submitted so I figured a public gist is the next best thing 🤷♂️