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/storage/index.ts b/packages/feature-toggle-react/src/storage/index.ts new file mode 100644 index 0000000..c10a0b0 --- /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' + +/** + * LocalStorage adapter. + */ +export { adapterLocalStorage } from './local-storage' +export type { AdapterLocalStorageConfig } from './local-storage' + +/** + * Void adapter. + */ +export { adapterVoid } from './void' +export type { AdapterVoidConfig } from './void' diff --git a/packages/feature-toggle-react/src/storage/local-storage/adapter-local-storage.ts b/packages/feature-toggle-react/src/storage/local-storage/adapter-local-storage.ts new file mode 100644 index 0000000..1a5d167 --- /dev/null +++ b/packages/feature-toggle-react/src/storage/local-storage/adapter-local-storage.ts @@ -0,0 +1,41 @@ +import type { StorageAdapter } from '../types' + +import { storageAdapter } from '../storage-adapter' +import { adapterVoid } from '../void/adapter-void' + +/** + * Checks if localStorage is supported + */ +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 AdapterLocalStorageConfig { + sync?: boolean | 'force' + serialize?: (value: any) => string + deserialize?: (value: string) => any + timeout?: number + def?: any +} + +/** + * Creates a localStorage adapter. + */ +export function adapterLocalStorage( + config?: AdapterLocalStorageConfig, +): StorageAdapter { + return supports() + ? storageAdapter({ + storage: () => localStorage, + sync: true, + ...config, + }) + : adapterVoid({ keyArea: 'local' }) +} +adapterLocalStorage.factory = true as const 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/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 +} diff --git a/packages/feature-toggle-react/src/storage/void/adapter-void.ts b/packages/feature-toggle-react/src/storage/void/adapter-void.ts new file mode 100644 index 0000000..6a6b1b1 --- /dev/null +++ b/packages/feature-toggle-react/src/storage/void/adapter-void.ts @@ -0,0 +1,23 @@ +import type { StorageAdapter } from '../types' + +export interface AdapterVoidConfig { + keyArea?: any +} + +/** + * Creates a 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/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' 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