Skip to content

Commit

Permalink
refactor: storage
Browse files Browse the repository at this point in the history
1. Add support of serializers. It also gives an ability to sanitize unknown values against a schema.
2. Preload storage first. The previous implementation used default value as initial value that may misunderstood.
3. Use branded type for birthdate.
  • Loading branch information
khmm12 committed Jun 25, 2024
1 parent 931a7cc commit 0841909
Show file tree
Hide file tree
Showing 16 changed files with 193 additions and 88 deletions.
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,8 @@
"remeda": "^2.2.0",
"solid-js": "1.8.17",
"solid-transition-group": "^0.2.3",
"type-fest": "^4.20.1"
"type-fest": "^4.20.1",
"valibot": "^0.34.0"
},
"devDependencies": {
"@linaria/vite": "^5.0.4",
Expand Down
11 changes: 7 additions & 4 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { createForm, reset } from '@modular-forms/solid'
import { type Simplify } from 'type-fest'
import type { Settings } from '@/hooks/createSettingsStorage'
import createUniqueIds from '@/hooks/createUniqueIds'
import { type ISODate } from '@/utils/brands'
import toISODate from '@/utils/to-iso-date'
import * as css from './styles'

Expand Down Expand Up @@ -46,7 +47,7 @@ export default function SettingsForm(props: SettingsFormProps): JSX.Element {
)
}

function parseDateValue(value: string | undefined): string {
function parseDateValue(value: string | undefined): ISODate | undefined {
const parsed = value != null && value !== '' ? new Date(value) : null
return parsed != null && !Number.isNaN(parsed.valueOf()) ? toISODate(parsed) : ''
return parsed != null && !Number.isNaN(parsed.valueOf()) ? toISODate(parsed) : undefined
}
3 changes: 2 additions & 1 deletion src/components/TimeMilestones/hooks/useBirthDate.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { renderHook } from '@test/helpers/solid'
import createSettingsStorage, { type Settings } from '@/hooks/createSettingsStorage'
import { type ISODate } from '@/utils/brands'
import useBirthDate from './useBirthDate'

afterEach(() => {
Expand Down Expand Up @@ -27,7 +28,7 @@ describe('useBirthDate', () => {
})

it('returns Date when birthdate is defined', async () => {
await fillSettings({ birthDate: '1970-06-05' })
await fillSettings({ birthDate: '1970-06-05' as ISODate })
const birthDate = renderHook(() => useBirthDate()).result
await runNextTick()

Expand Down
33 changes: 28 additions & 5 deletions src/hooks/createSettingsStorage.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,38 @@
import * as v from 'valibot'
import createStorage, { type StorageReturn } from '@/hooks/createStorage'
import Storage, { getLazyStorageAdapter } from '@/utils/storage'
import { isISODate, type ISODate } from '@/utils/brands'
import Storage, { buildStorageAdapter, type ISerializer } from '@/utils/storage'

export interface Settings {
birthDate?: string
birthDate?: ISODate
}

const Key = 'settings'
const DefaultValue = { birthDate: undefined } satisfies Settings

const StorageAdapter = /* @__PURE__ */ await getLazyStorageAdapter()
const SettingsStorage = /* @__PURE__ */ new Storage<Settings>(StorageAdapter, Key, DefaultValue)
const isoDateSchema = /* @__PURE__ */ v.custom<ISODate>(isISODate, 'is not a valid ISODate')

const SettingsSchema = /* @__PURE__ */ v.fallback(
v.object({
birthDate: v.fallback(v.optional(isoDateSchema), undefined),
}),
{},
)

const Serializer: ISerializer<Settings> = {
deserialize(value) {
try {
return v.parse(SettingsSchema, value)
} catch {
return {}
}
},
serialize(value) {
return value
},
}

const StorageAdapter = /* @__PURE__ */ await buildStorageAdapter(Key)
const SettingsStorage = /* @__PURE__ */ await Storage.create(StorageAdapter, Serializer)

export default function createSettingsStorage(): StorageReturn<Settings> {
return createStorage(SettingsStorage)
Expand Down
4 changes: 3 additions & 1 deletion src/hooks/createStorage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,9 @@ export default function createStorage<T>(storage: Storage<T>): StorageReturn<T>
}

onMount(() => {
const unsubscribe = storage.subscribe((nextValue) => mutate(() => nextValue))
const unsubscribe = storage.subscribe((nextValue) => {
mutate(() => nextValue)
})
onCleanup(unsubscribe)
})

Expand Down
10 changes: 10 additions & 0 deletions src/utils/brands.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import type { Tagged } from 'type-fest'

const ISODateRegex = /^\d{4}-\d{2}-\d{2}$/

export type ISODate = Tagged<string, 'ISODate'>

export function isISODate(value: unknown): value is ISODate {
// Soft regexp, but fast.
return typeof value === 'string' && ISODateRegex.test(value)
}
34 changes: 23 additions & 11 deletions src/utils/storage/adapters/chrome-storage-adapter.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { IStorageAdapter, Subscriber } from '../types'
import StorageSubscription from '../subscription'
import type { IStorageAdapter, Subscriber, Unsubscribe } from '../types'

const read = async (key: string): Promise<Record<string, any>> => {
const items = await chrome.storage.local.get(key)
Expand All @@ -9,36 +10,47 @@ const write = async (key: string, value: any): Promise<void> => {
await chrome.storage.local.set({ [key]: value })
}

export default class ChromeStorageAdapter<T> implements IStorageAdapter<T> {
export default class ChromeStorageAdapter implements IStorageAdapter {
protected subscription = new StorageSubscription<unknown>()

readonly #listener = (changes: Partial<Record<string, chrome.storage.StorageChange>>): void => {
this.handleChanged(changes)
}

constructor(
protected readonly name: string,
protected readonly subscriber: Subscriber<T | null>,
) {
constructor(protected readonly name: string) {
chrome.storage.onChanged.addListener(this.#listener)
}

async read(): Promise<T | null> {
async read(): Promise<unknown> {
return this.parse(await read(this.name))
}

async write(value: T): Promise<void> {
async write(value: unknown): Promise<void> {
await write(this.name, value)
}

dispose(): void {
chrome.storage.onChanged.removeListener(this.#listener)
this.subscription.dispose()
}

subscribe(subscriber: Subscriber<unknown>): Unsubscribe {
this.subscription.subscribe(subscriber)
return () => {
this.unsubscribe(subscriber)
}
}

unsubscribe(subscriber: Subscriber<unknown>): void {
this.subscription.unsubscribe(subscriber)
}

protected parse(val: any): T | null {
return (val as T | null) ?? null
protected parse(val: unknown): unknown {
return val ?? null
}

protected handleChanged(changes: Partial<Record<string, chrome.storage.StorageChange>>): void {
const change = changes[this.name]
if (change != null) this.subscriber(this.parse(change.newValue))
if (change != null) this.subscription.notify(this.parse(change.newValue))
}
}
38 changes: 26 additions & 12 deletions src/utils/storage/adapters/local-starage-adapter.ts
Original file line number Diff line number Diff line change
@@ -1,47 +1,61 @@
import getPackageName from '@/utils/get-package-name'
import type { IStorageAdapter, Subscriber } from '../types'
import StorageSubscription from '../subscription'
import type { IStorageAdapter, Subscriber, Unsubscribe } from '../types'

export default class LocalStorageAdapter implements IStorageAdapter {
protected subscription = new StorageSubscription<unknown>()

export default class LocalStorageAdapter<T> implements IStorageAdapter<T> {
readonly #listener = (e: StorageEvent): void => {
this.handleChanged(e)
}

constructor(
protected readonly name: string,
protected readonly subscriber: Subscriber<T | null>,
) {
constructor(protected readonly name: string) {
window.addEventListener('storage', this.#listener)
}

read(): T | null {
read(): unknown {
return this.parse(localStorage.getItem(this.storageKey))
}

write(value: T): void {
write(value: unknown): void {
localStorage.setItem(this.storageKey, this.serialize(value))
}

dispose(): void {
window.removeEventListener('storage', this.#listener)
this.subscription.dispose()
}

subscribe(subscriber: Subscriber<unknown>): Unsubscribe {
this.subscription.subscribe(subscriber)
return () => {
this.unsubscribe(subscriber)
}
}

unsubscribe(subscriber: Subscriber<unknown>): void {
this.subscription.unsubscribe(subscriber)
}

protected get storageKey(): string {
return `${getPackageName()}:${this.name}`
}

protected parse(val: unknown): T | null {
protected parse(val: unknown): unknown {
if (typeof val !== 'string') return null

try {
return (JSON.parse(val as string) as T | null) ?? null
return JSON.parse(val) ?? null
} catch {
return null
}
}

protected serialize(value: T): string {
protected serialize(value: unknown): string {
return JSON.stringify(value)
}

protected handleChanged(e: StorageEvent): void {
if (e.key === this.storageKey) this.subscriber(this.parse(e.newValue))
if (e.key === this.storageKey) this.subscription.notify(this.parse(e.newValue))
}
}
20 changes: 15 additions & 5 deletions src/utils/storage/adapters/memory-storage-adapter.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,23 @@
import type { IStorageAdapter } from '../types'
import type { IStorageAdapter, Subscriber, Unsubscribe } from '../types'

export default class MemoryStorageAdapter<T> implements IStorageAdapter<T> {
protected value: T | null = null
export default class MemoryStorageAdapter implements IStorageAdapter {
protected value: unknown | null = null

read(): T | null {
read(): unknown {
return this.value
}

write(value: T): void {
write(value: unknown): void {
this.value = value
}

subscribe(_subscriber: Subscriber<unknown>): Unsubscribe {
return () => {
// Do nothing.
}
}

unsubscribe(_subscriber: Subscriber<unknown>): void {
// Do nothing.
}
}
10 changes: 8 additions & 2 deletions src/utils/storage/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
export { default } from './storage'
export { getStorageAdapter as getLazyStorageAdapter } from './utils'
export type { IWritableStorage, IDisposableStorage, IMemorableStorage, ISubscribableStorage } from './types'
export { buildStorageAdapter } from './utils'
export type {
IDisposableStorage,
IMemorableStorage,
ISerializer,
ISubscribableStorage,
IWritableStorage,
} from './types'
Loading

0 comments on commit 0841909

Please sign in to comment.