From 8233dc5e34f42fe4de5b959512c85f6fba49186e Mon Sep 17 00:00:00 2001 From: Maxim Tereshko Date: Fri, 5 Jul 2024 13:56:34 +0200 Subject: [PATCH 1/4] feat: added adapter factory --- .../src/storage/adapter-local-storage.ts | 35 ++++++ .../src/storage/adapter-void.ts | 23 ++++ .../feature-toggle-react/src/storage/index.ts | 17 +++ .../src/storage/storage-adapter.ts | 108 ++++++++++++++++++ .../feature-toggle-react/src/storage/types.ts | 59 ++++++++++ 5 files changed, 242 insertions(+) create mode 100644 packages/feature-toggle-react/src/storage/adapter-local-storage.ts create mode 100644 packages/feature-toggle-react/src/storage/adapter-void.ts create mode 100644 packages/feature-toggle-react/src/storage/index.ts create mode 100644 packages/feature-toggle-react/src/storage/storage-adapter.ts create mode 100644 packages/feature-toggle-react/src/storage/types.ts diff --git a/packages/feature-toggle-react/src/storage/adapter-local-storage.ts b/packages/feature-toggle-react/src/storage/adapter-local-storage.ts new file mode 100644 index 0000000..2d0c588 --- /dev/null +++ b/packages/feature-toggle-react/src/storage/adapter-local-storage.ts @@ -0,0 +1,35 @@ +import type { StorageAdapter } from './types' + +import { adapterVoid } from './adapter-void' +import { storageAdapter } from './storage-adapter' + +function supports() { + try { + return typeof localStorage !== 'undefined' + } catch (error) { + // accessing `localStorage` could throw an exception only in one case - + // when `localStorage` IS supported, but blocked by security policies + return true + } +} + +export interface LocalStorageConfig { + sync?: boolean | 'force' + serialize?: (value: any) => string + deserialize?: (value: string) => any + timeout?: number + def?: any +} + +adapterLocalStorage.factory = true as const +export function adapterLocalStorage( + config?: LocalStorageConfig, +): StorageAdapter { + return supports() + ? storageAdapter({ + storage: () => localStorage, + sync: true, + ...config, + }) + : adapterVoid({ keyArea: 'local' }) +} diff --git a/packages/feature-toggle-react/src/storage/adapter-void.ts b/packages/feature-toggle-react/src/storage/adapter-void.ts new file mode 100644 index 0000000..a231c06 --- /dev/null +++ b/packages/feature-toggle-react/src/storage/adapter-void.ts @@ -0,0 +1,23 @@ +import type { StorageAdapter } from './types' + +export interface AdapterVoidConfig { + keyArea?: any +} + +/** + * Void adapter. Does nothing. Useful for testing. + */ +export function adapterVoid({ + keyArea = '', +}: AdapterVoidConfig = {}): StorageAdapter { + const adapter: StorageAdapter = () => + { + get() {}, + set() {}, + } + + adapter.keyArea = keyArea + adapter.noop = true + return adapter +} +adapterVoid.factory = true as const diff --git a/packages/feature-toggle-react/src/storage/index.ts b/packages/feature-toggle-react/src/storage/index.ts new file mode 100644 index 0000000..15217ca --- /dev/null +++ b/packages/feature-toggle-react/src/storage/index.ts @@ -0,0 +1,17 @@ +/** + * Generic storage adapter. + */ +export { storageAdapter } from './storage-adapter' +export type { StorageAdapter, StorageConfig } from './types' + +/** + * Local storage adapter. + */ +export { adapterLocalStorage } from './adapter-local-storage' +export type { LocalStorageConfig } from './adapter-local-storage' + +/** + * Void adapter. + */ +export { adapterVoid } from './adapter-void' +export type { AdapterVoidConfig } from './adapter-void' diff --git a/packages/feature-toggle-react/src/storage/storage-adapter.ts b/packages/feature-toggle-react/src/storage/storage-adapter.ts new file mode 100644 index 0000000..5a74606 --- /dev/null +++ b/packages/feature-toggle-react/src/storage/storage-adapter.ts @@ -0,0 +1,108 @@ +import type { StorageAdapter, StorageConfig } from './types' + +/** + * Creates a generic Storage adapter. + * + * @param {StorageConfig} config - Configuration object for the storage adapter. + * @param {() => Storage} config.storage - Function that returns the Storage object (e.g., localStorage or sessionStorage). + * @param {boolean | 'force'} [config.sync=false] - Whether to synchronize the storage across different tabs. If set to 'force', always calls the update function when the storage changes. + * @param {(value: any) => string} [config.serialize=JSON.stringify] - Function to serialize the value before storing it. + * @param {(value: string) => any} [config.deserialize=JSON.parse] - Function to deserialize the value after retrieving it from storage. + * @param {number} [config.timeout] - Timeout in milliseconds before automatically flushing changes to storage. If undefined, changes are flushed immediately. + * @param {any} [config.def] - Default value to return if the key is not found in storage. + * + * @returns {StorageAdapter} The storage adapter. + * + * @example + * const adapter = storageAdapter({ + * storage: () => localStorage, + * sync: true, + * timeout: 500, + * }); + * + * const store = adapter('myKey', (newValue) => { + * console.log('Updated value:', newValue); + * }); + * + * store.set('newValue'); + * console.log(store.get()); // Outputs: 'newValue' + */ +export function storageAdapter({ + storage, + sync = false, + serialize = JSON.stringify, + deserialize = JSON.parse, + timeout, + def, +}: StorageConfig): StorageAdapter { + const adapter: StorageAdapter = ( + key: string, + update: (raw?: any) => void, + ) => { + let scheduled: ReturnType | undefined + let unsaved: State + let storageInstance: Storage = storage() + let beforeunloadListenerAdded = false + + const flush = () => storageInstance.setItem(key, serialize(unsaved)) + + const postponedFlush = (e?: BeforeUnloadEvent | 1) => { + scheduled = clearTimeout(scheduled) as undefined + if (e) flush() + if (beforeunloadListenerAdded) { + window.removeEventListener('beforeunload', postponedFlush) + beforeunloadListenerAdded = false + } + } + + const scheduleFlush = () => { + scheduled = setTimeout(postponedFlush, timeout, 1) + if (!beforeunloadListenerAdded) { + window.addEventListener('beforeunload', postponedFlush) + beforeunloadListenerAdded = true + } + } + + let syncListener: ((e: StorageEvent) => void) | undefined + if (sync) { + syncListener = e => { + if (e.storageArea === storageInstance && e.key === key) { + update(sync === 'force' ? undefined : e.newValue) + } else if (e.key === null) { + update(null) + } + } + window.addEventListener('storage', syncListener) + } + + const dispose = () => { + if (scheduled) postponedFlush(1) + if (syncListener) window.removeEventListener('storage', syncListener) + } + + return Object.assign(dispose, { + get(raw?: string | null) { + postponedFlush() + const item = raw ?? storageInstance.getItem(key) + return item === null ? def ?? raw : deserialize(item) + }, + set(value: State) { + unsaved = value + if (timeout === undefined) { + flush() + } else if (!scheduled) { + scheduleFlush() + } + }, + }) + } + + try { + adapter.keyArea = storage() + } catch (error) { + // Do nothing + } + + return adapter +} +storageAdapter.factory = true as const diff --git a/packages/feature-toggle-react/src/storage/types.ts b/packages/feature-toggle-react/src/storage/types.ts new file mode 100644 index 0000000..6d48828 --- /dev/null +++ b/packages/feature-toggle-react/src/storage/types.ts @@ -0,0 +1,59 @@ +export interface Adapter { + get(raw?: any, ctx?: any): State | Promise | undefined + set(value: State, ctx?: any): void +} + +export interface DisposableAdapter extends Adapter { + (): void +} + +export interface StorageAdapter { + ( + key: string, + update: (raw?: any) => void, + ): Adapter | DisposableAdapter + + /** The key area for the storage adapter. */ + keyArea?: any + + /** A flag to indicate if the adapter is a no-operation adapter. */ + noop?: boolean +} + +export interface StorageConfig { + /** + * Function that returns the Storage object (e.g., localStorage or sessionStorage). + * @returns {Storage} The Storage object. + */ + storage: () => Storage + + /** + * Whether to synchronize the storage across different tabs. + * If set to 'force', always calls the update function when the storage changes. + * @default false + */ + sync?: boolean | 'force' + + /** + * Function to serialize the value before storing it. + * @default JSON.stringify + */ + serialize?: (value: any) => string + + /** + * Function to deserialize the value after retrieving it from storage. + * @default JSON.parse + */ + deserialize?: (value: string) => any + + /** + * Timeout in milliseconds before automatically flushing changes to storage. + * If undefined, changes are flushed immediately. + */ + timeout?: number + + /** + * Default value to return if the key is not found in storage. + */ + def?: any +} From 93d4a568ce3b1c854f636b8fbf9144ea63889e70 Mon Sep 17 00:00:00 2001 From: Maxim Tereshko Date: Fri, 5 Jul 2024 14:00:19 +0200 Subject: [PATCH 2/4] chore: refactored storage folder --- .../src/storage/adapter-local-storage.ts | 8 +++++++- packages/feature-toggle-react/src/storage/adapter-void.ts | 2 +- packages/feature-toggle-react/src/storage/index.ts | 2 +- 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/packages/feature-toggle-react/src/storage/adapter-local-storage.ts b/packages/feature-toggle-react/src/storage/adapter-local-storage.ts index 2d0c588..2e787c5 100644 --- a/packages/feature-toggle-react/src/storage/adapter-local-storage.ts +++ b/packages/feature-toggle-react/src/storage/adapter-local-storage.ts @@ -3,6 +3,9 @@ import type { StorageAdapter } from './types' import { adapterVoid } from './adapter-void' import { storageAdapter } from './storage-adapter' +/** + * Checks if localStorage is supported + */ function supports() { try { return typeof localStorage !== 'undefined' @@ -21,7 +24,9 @@ export interface LocalStorageConfig { def?: any } -adapterLocalStorage.factory = true as const +/** + * Creates a localStorage adapter. + */ export function adapterLocalStorage( config?: LocalStorageConfig, ): StorageAdapter { @@ -33,3 +38,4 @@ export function adapterLocalStorage( }) : adapterVoid({ keyArea: 'local' }) } +adapterLocalStorage.factory = true as const diff --git a/packages/feature-toggle-react/src/storage/adapter-void.ts b/packages/feature-toggle-react/src/storage/adapter-void.ts index a231c06..50d0b58 100644 --- a/packages/feature-toggle-react/src/storage/adapter-void.ts +++ b/packages/feature-toggle-react/src/storage/adapter-void.ts @@ -5,7 +5,7 @@ export interface AdapterVoidConfig { } /** - * Void adapter. Does nothing. Useful for testing. + * Creates a void adapter. Does nothing. Useful for testing. */ export function adapterVoid({ keyArea = '', diff --git a/packages/feature-toggle-react/src/storage/index.ts b/packages/feature-toggle-react/src/storage/index.ts index 15217ca..d5a116d 100644 --- a/packages/feature-toggle-react/src/storage/index.ts +++ b/packages/feature-toggle-react/src/storage/index.ts @@ -5,7 +5,7 @@ export { storageAdapter } from './storage-adapter' export type { StorageAdapter, StorageConfig } from './types' /** - * Local storage adapter. + * LocalStorage adapter. */ export { adapterLocalStorage } from './adapter-local-storage' export type { LocalStorageConfig } from './adapter-local-storage' From 7c1a68c62dee108c65af75be15c6c8f047f14156 Mon Sep 17 00:00:00 2001 From: Maxim Tereshko Date: Fri, 5 Jul 2024 14:16:36 +0200 Subject: [PATCH 3/4] chore: updated storage structure --- packages/feature-toggle-react/src/storage/index.ts | 8 ++++---- .../{ => local-storage}/adapter-local-storage.ts | 10 +++++----- .../src/storage/local-storage/index.ts | 2 ++ .../src/storage/{ => void}/adapter-void.ts | 2 +- .../feature-toggle-react/src/storage/void/index.ts | 2 ++ 5 files changed, 14 insertions(+), 10 deletions(-) rename packages/feature-toggle-react/src/storage/{ => local-storage}/adapter-local-storage.ts (76%) create mode 100644 packages/feature-toggle-react/src/storage/local-storage/index.ts rename packages/feature-toggle-react/src/storage/{ => void}/adapter-void.ts (89%) create mode 100644 packages/feature-toggle-react/src/storage/void/index.ts diff --git a/packages/feature-toggle-react/src/storage/index.ts b/packages/feature-toggle-react/src/storage/index.ts index d5a116d..c10a0b0 100644 --- a/packages/feature-toggle-react/src/storage/index.ts +++ b/packages/feature-toggle-react/src/storage/index.ts @@ -7,11 +7,11 @@ export type { StorageAdapter, StorageConfig } from './types' /** * LocalStorage adapter. */ -export { adapterLocalStorage } from './adapter-local-storage' -export type { LocalStorageConfig } from './adapter-local-storage' +export { adapterLocalStorage } from './local-storage' +export type { AdapterLocalStorageConfig } from './local-storage' /** * Void adapter. */ -export { adapterVoid } from './adapter-void' -export type { AdapterVoidConfig } from './adapter-void' +export { adapterVoid } from './void' +export type { AdapterVoidConfig } from './void' diff --git a/packages/feature-toggle-react/src/storage/adapter-local-storage.ts b/packages/feature-toggle-react/src/storage/local-storage/adapter-local-storage.ts similarity index 76% rename from packages/feature-toggle-react/src/storage/adapter-local-storage.ts rename to packages/feature-toggle-react/src/storage/local-storage/adapter-local-storage.ts index 2e787c5..1a5d167 100644 --- a/packages/feature-toggle-react/src/storage/adapter-local-storage.ts +++ b/packages/feature-toggle-react/src/storage/local-storage/adapter-local-storage.ts @@ -1,7 +1,7 @@ -import type { StorageAdapter } from './types' +import type { StorageAdapter } from '../types' -import { adapterVoid } from './adapter-void' -import { storageAdapter } from './storage-adapter' +import { storageAdapter } from '../storage-adapter' +import { adapterVoid } from '../void/adapter-void' /** * Checks if localStorage is supported @@ -16,7 +16,7 @@ function supports() { } } -export interface LocalStorageConfig { +export interface AdapterLocalStorageConfig { sync?: boolean | 'force' serialize?: (value: any) => string deserialize?: (value: string) => any @@ -28,7 +28,7 @@ export interface LocalStorageConfig { * Creates a localStorage adapter. */ export function adapterLocalStorage( - config?: LocalStorageConfig, + config?: AdapterLocalStorageConfig, ): StorageAdapter { return supports() ? storageAdapter({ diff --git a/packages/feature-toggle-react/src/storage/local-storage/index.ts b/packages/feature-toggle-react/src/storage/local-storage/index.ts new file mode 100644 index 0000000..8363485 --- /dev/null +++ b/packages/feature-toggle-react/src/storage/local-storage/index.ts @@ -0,0 +1,2 @@ +export { adapterLocalStorage } from './adapter-local-storage' +export type { AdapterLocalStorageConfig } from './adapter-local-storage' diff --git a/packages/feature-toggle-react/src/storage/adapter-void.ts b/packages/feature-toggle-react/src/storage/void/adapter-void.ts similarity index 89% rename from packages/feature-toggle-react/src/storage/adapter-void.ts rename to packages/feature-toggle-react/src/storage/void/adapter-void.ts index 50d0b58..6a6b1b1 100644 --- a/packages/feature-toggle-react/src/storage/adapter-void.ts +++ b/packages/feature-toggle-react/src/storage/void/adapter-void.ts @@ -1,4 +1,4 @@ -import type { StorageAdapter } from './types' +import type { StorageAdapter } from '../types' export interface AdapterVoidConfig { keyArea?: any diff --git a/packages/feature-toggle-react/src/storage/void/index.ts b/packages/feature-toggle-react/src/storage/void/index.ts new file mode 100644 index 0000000..ac7aae1 --- /dev/null +++ b/packages/feature-toggle-react/src/storage/void/index.ts @@ -0,0 +1,2 @@ +export { adapterVoid } from './adapter-void' +export type { AdapterVoidConfig } from './adapter-void' From 71d5bc9ac8ea0a5c013675f8482d7760e1a99907 Mon Sep 17 00:00:00 2001 From: Maxim Tereshko Date: Sat, 6 Jul 2024 21:50:14 +0200 Subject: [PATCH 4/4] chore: refactored structure --- .../src/context/feature-toggle-context.ts | 7 ++++ .../src/context/hooks/index.ts | 1 + .../src/context/hooks/use-feature-toggle.ts | 4 ++ .../feature-toggle-react/src/context/index.ts | 3 ++ .../feature-toggle-react/src/context/types.ts | 6 +++ packages/feature-toggle-react/src/index.ts | 25 ++++++++++-- .../src/provider/feature-toggle-provider.tsx | 39 +++++++++++++++++++ .../src/provider/index.ts | 2 + .../src/provider/types.ts | 6 +++ packages/feature-toggle-react/src/types.ts | 6 ++- 10 files changed, 95 insertions(+), 4 deletions(-) create mode 100644 packages/feature-toggle-react/src/context/feature-toggle-context.ts create mode 100644 packages/feature-toggle-react/src/context/hooks/index.ts create mode 100644 packages/feature-toggle-react/src/context/hooks/use-feature-toggle.ts create mode 100644 packages/feature-toggle-react/src/context/index.ts create mode 100644 packages/feature-toggle-react/src/context/types.ts create mode 100644 packages/feature-toggle-react/src/provider/feature-toggle-provider.tsx create mode 100644 packages/feature-toggle-react/src/provider/index.ts create mode 100644 packages/feature-toggle-react/src/provider/types.ts diff --git a/packages/feature-toggle-react/src/context/feature-toggle-context.ts b/packages/feature-toggle-react/src/context/feature-toggle-context.ts new file mode 100644 index 0000000..b530e73 --- /dev/null +++ b/packages/feature-toggle-react/src/context/feature-toggle-context.ts @@ -0,0 +1,7 @@ +import { createContext } from 'react' +import type { TFeatureToggleContext } from './types' + +const initialValue = {} as TFeatureToggleContext + +export const FeatureToggleContext = + createContext(initialValue) diff --git a/packages/feature-toggle-react/src/context/hooks/index.ts b/packages/feature-toggle-react/src/context/hooks/index.ts new file mode 100644 index 0000000..b598784 --- /dev/null +++ b/packages/feature-toggle-react/src/context/hooks/index.ts @@ -0,0 +1 @@ +export { useFeatureToggle } from './use-feature-toggle' diff --git a/packages/feature-toggle-react/src/context/hooks/use-feature-toggle.ts b/packages/feature-toggle-react/src/context/hooks/use-feature-toggle.ts new file mode 100644 index 0000000..cdb34c1 --- /dev/null +++ b/packages/feature-toggle-react/src/context/hooks/use-feature-toggle.ts @@ -0,0 +1,4 @@ +import { useContext } from 'react' +import { FeatureToggleContext } from '../feature-toggle-context' + +export const useFeatureToggle = () => useContext(FeatureToggleContext) diff --git a/packages/feature-toggle-react/src/context/index.ts b/packages/feature-toggle-react/src/context/index.ts new file mode 100644 index 0000000..0038dda --- /dev/null +++ b/packages/feature-toggle-react/src/context/index.ts @@ -0,0 +1,3 @@ +export { FeatureToggleContext } from './feature-toggle-context' +export { useFeatureToggle } from './hooks' +export type { TFeatureToggleContext } from './types' diff --git a/packages/feature-toggle-react/src/context/types.ts b/packages/feature-toggle-react/src/context/types.ts new file mode 100644 index 0000000..7fc1094 --- /dev/null +++ b/packages/feature-toggle-react/src/context/types.ts @@ -0,0 +1,6 @@ +import type { FeatureFlags } from '../types' + +export type TFeatureToggleContext = { + featureFlags: FeatureFlags + isLoading: boolean +} diff --git a/packages/feature-toggle-react/src/index.ts b/packages/feature-toggle-react/src/index.ts index 7e22322..3345e89 100644 --- a/packages/feature-toggle-react/src/index.ts +++ b/packages/feature-toggle-react/src/index.ts @@ -1,3 +1,22 @@ -export { useFeatureToggle, FeatureToggleProvider, FeatureToggle } from './utils' -export { FeatureToggleConfig } from './feature-toggle-config' -export { type FeatureToggleContext, type FeatureFlags } from './types' +/** + * Feature toggle provider + */ +export { FeatureToggleProvider } from './provider' +export type { FeatureToggleProviderProps } from './provider' + +/** + * Feature toggle hooks + */ +export { useFeatureToggle } from './context' + +/** + * Storage adapters + */ +export { storageAdapter } from './storage' +export type { StorageAdapter, StorageConfig } from './storage' + +export { adapterLocalStorage } from './storage' +export type { AdapterLocalStorageConfig } from './storage' + +export { adapterVoid } from './storage' +export type { AdapterVoidConfig } from './storage' diff --git a/packages/feature-toggle-react/src/provider/feature-toggle-provider.tsx b/packages/feature-toggle-react/src/provider/feature-toggle-provider.tsx new file mode 100644 index 0000000..1341853 --- /dev/null +++ b/packages/feature-toggle-react/src/provider/feature-toggle-provider.tsx @@ -0,0 +1,39 @@ +import React, { useCallback, useLayoutEffect, useState } from 'react' +import { FeatureToggleContext } from '../context' +import type { FeatureFlags } from '../types' +import type { FeatureToggleProviderProps } from './types' + +export const FeatureToggleProvider = ({ + children, + defaultFlags, +}: FeatureToggleProviderProps) => { + const [isLoading, setIsLoading] = useState(false) + const [featureFlags, setFeatureFlags] = useState( + defaultFlags ?? {}, + ) + + const fetchStorageFlags = useCallback(async () => { + return {} + }, []) + + const fetchRemoteFlags = useCallback(async () => { + return {} + }, []) + + useLayoutEffect(() => { + ;(async () => { + const flags = await fetchRemoteFlags() + setFeatureFlags(flags) + })() + }, [fetchStorageFlags]) + + return ( + + {children} + + ) +} diff --git a/packages/feature-toggle-react/src/provider/index.ts b/packages/feature-toggle-react/src/provider/index.ts new file mode 100644 index 0000000..c82d697 --- /dev/null +++ b/packages/feature-toggle-react/src/provider/index.ts @@ -0,0 +1,2 @@ +export { FeatureToggleProvider } from './feature-toggle-provider' +export type { FeatureToggleProviderProps } from './types' diff --git a/packages/feature-toggle-react/src/provider/types.ts b/packages/feature-toggle-react/src/provider/types.ts new file mode 100644 index 0000000..98c3f5d --- /dev/null +++ b/packages/feature-toggle-react/src/provider/types.ts @@ -0,0 +1,6 @@ +import type { FeatureFlags } from '../types' + +export type FeatureToggleProviderProps = { + children: React.ReactNode + defaultFlags?: FeatureFlags +} diff --git a/packages/feature-toggle-react/src/types.ts b/packages/feature-toggle-react/src/types.ts index 97efe84..83fe1f1 100644 --- a/packages/feature-toggle-react/src/types.ts +++ b/packages/feature-toggle-react/src/types.ts @@ -1,4 +1,8 @@ -export type FeatureFlags = Record +export type FeatureFlagValue = boolean | string | number + +export type FeatureFlags = Record + +// TODO: remove below export type FeatureToggleContext = { flags: FeatureFlags