Skip to content

Instantly share code, notes, and snippets.

@aronduby
Last active March 11, 2025 05:48
Show Gist options
  • Save aronduby/ec61cb19b409a90b8dfa722d56f983a5 to your computer and use it in GitHub Desktop.
Save aronduby/ec61cb19b409a90b8dfa722d56f983a5 to your computer and use it in GitHub Desktop.
Pinia Store that Persists to Browser Storage for Extension Development
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
}
}
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,
});
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);
@aronduby
Copy link
Author

aronduby commented Mar 9, 2025

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 🤷‍♂️

@aronduby
Copy link
Author

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