react-wire-persisted 2.0.1 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,102 +1,105 @@
1
-
2
1
  /**
3
2
  * Base class to allow storage access
4
- * @see `LocalStorageProvider.js` for an example implementation
3
+ * @see `LocalStorageProvider.ts` for an example implementation
5
4
  */
6
- class StorageProvider {
7
-
5
+ /** biome-ignore-all lint/correctness/noUnusedFunctionParameters: WIP next PR will switch to TypeScript */
6
+ export abstract class RWPStorageProvider {
7
+ namespace: string | null
8
+ registry: Record<string, unknown>
9
+
8
10
  /**
9
11
  * Initializes the class
10
- * @param {String} namespace Namespace to prefix all keys with. Mostly used for the logging & reset functions
12
+ * @param {String} namespace Namespace to prefix all keys with. Mostly used for the logging and reset functions
11
13
  * @param {Object} registry (Optional) Initialize the storage provider with an existing registry
12
14
  */
13
- constructor(namespace, registry) {
14
-
15
+ protected constructor(namespace: string, registry: Record<string, unknown>) {
15
16
  // Simulate being an abstract class
16
- if (new.target === StorageProvider)
17
+ if (new.target === RWPStorageProvider)
17
18
  throw TypeError(`StorageProvider is abstract. Extend this class to implement it`)
18
-
19
+
19
20
  this.namespace = namespace || null
20
21
  this.registry = registry || /* istanbul ignore next */ {}
21
-
22
22
  }
23
-
23
+
24
24
  /**
25
25
  * Sets the namespace for this storage provider, and migrates
26
26
  * all stored values to the new namespace
27
27
  * @param {String} namespace New namespace for this storage provider
28
28
  */
29
29
  /* istanbul ignore next */
30
- setNamespace(namespace) {}
31
-
30
+ abstract setNamespace(namespace: string | null): void
31
+
32
32
  /**
33
- * Registers an item with it's initial value. This is used for logging, resetting, etc.
34
- * @param {String} key Storage item's key
35
- * @param {*} initialValue Storage item's initial value
33
+ * Registers an item with its initial value. This is used for logging, resetting, etc.
34
+ * @param {String} key InternalStorage item's key
35
+ * @param {*} initialValue InternalStorage item's initial value
36
36
  */
37
- register(key, initialValue) {
37
+ register<T>(key: string, initialValue: T | null) {
38
38
  this.registry[key] = initialValue
39
39
  }
40
-
40
+
41
41
  /**
42
42
  * Reads an item from storage
43
43
  * @param {String} key Key for the item to retrieve
44
44
  */
45
45
  /* istanbul ignore next */
46
- getItem(key) {}
47
-
46
+ abstract getItem<T>(key: string): T | null
47
+
48
48
  /**
49
49
  * Stores a value
50
50
  * @param {String} key Item's storage key
51
51
  * @param {String} value Item's value to store
52
52
  */
53
53
  /* istanbul ignore next */
54
- setItem(key, value) {}
55
-
54
+ abstract setItem<T>(key: string, value: T | null): void
55
+
56
56
  /**
57
57
  * Removes an item from storage
58
58
  * @param {String} key Item's storage key
59
59
  * @param {Boolean} fromRegistry (Optional) If the item should also be removed from the registry
60
60
  */
61
61
  /* istanbul ignore next */
62
- removeItem(key, fromRegistry = false) {}
63
-
62
+ abstract removeItem(key: string, fromRegistry: boolean): void
63
+
64
64
  /**
65
- * Gets all stored keys & values
65
+ * Gets all stored keys and values
66
66
  * If a `namespace` was set, only keys prefixed with the namespace will be returned
67
67
  */
68
68
  /* istanbul ignore next */
69
- getAll() {}
70
-
69
+ abstract getAll(): Record<string, unknown>
70
+
71
71
  /**
72
- *
72
+ *
73
73
  * @param {Boolean} useInitialValues If values should be replaced with their initial values. If false, keys are removed
74
74
  * @param {String[]} excludedKeys (Optional) List of keys to exclude
75
75
  * @param {Boolean} clearRegistry (Optional) If the registry should also be cleared
76
76
  */
77
77
  /* istanbul ignore next */
78
- _resetAll(
79
- useInitialValues = true,
80
- excludedKeys = [],
81
- clearRegistry = false
82
- ) {}
83
-
78
+ abstract _resetAll(useInitialValues: boolean, excludedKeys: string[], clearRegistry: boolean): void
79
+
84
80
  /**
85
81
  * Resets all values to their initial values
86
82
  * If a `namespace` is set, only keys prefixed with the namespace will be reset
87
83
  * @param {String[]} excludedKeys (Optional) List of keys to exclude
88
84
  */
89
85
  /* istanbul ignore next */
90
- resetAll(excludedKeys = []) {}
91
-
86
+ abstract resetAll(excludedKeys: string[]): void
87
+
92
88
  /**
93
89
  * Removes all items from local storage.
94
90
  * If a `namespace` is set, only keys prefixed with the namespace will be removed
95
91
  * @param {String[]} excludedKeys (Optional) List of keys to exclude
96
92
  */
97
93
  /* istanbul ignore next */
98
- removeAll(excludedKeys = []) {}
99
-
94
+ abstract removeAll(excludedKeys: string[]): void
95
+
96
+ upgradeToRealStorage(): boolean {
97
+ return false
98
+ }
99
+
100
+ isUsingFakeStorage(): boolean {
101
+ return false
102
+ }
100
103
  }
101
104
 
102
- export default StorageProvider
105
+ export default RWPStorageProvider
@@ -0,0 +1,257 @@
1
+ import { createWire, type Defined, type Wire } from '@forminator/react-wire'
2
+ import LocalStorageProvider from '@/providers/LocalStorageProvider'
3
+ import type RWPStorageProvider from '@/providers/RWPStorageProvider'
4
+ import type { PersistedWire, RWPOptions, WireLikeObject } from '@/types'
5
+ import { getHasHydratedStorage, getIsClient, markStorageAsHydrated } from '@/utils'
6
+
7
+ // Generate unique instance ID
8
+ const instanceId = Math.random().toString(36).substring(7)
9
+ const rwpLog = (...args: unknown[]) => {
10
+ if (typeof globalThis !== 'undefined' && globalThis.__RWP_LOGGING_ENABLED__ !== false) {
11
+ console.log(...args)
12
+ }
13
+ }
14
+
15
+ export const defaultOptions: RWPOptions = {
16
+ logging: {
17
+ enabled: false,
18
+ },
19
+ storageProvider: LocalStorageProvider,
20
+ }
21
+
22
+ let options: RWPOptions = { ...defaultOptions }
23
+ let storage: RWPStorageProvider
24
+ const pendingLogs: unknown[][] = []
25
+
26
+ // Set global logging flag on startup
27
+ if (typeof globalThis !== 'undefined' && globalThis.__RWP_LOGGING_ENABLED__ === undefined) {
28
+ globalThis.__RWP_LOGGING_ENABLED__ = defaultOptions.logging.enabled
29
+ }
30
+
31
+ rwpLog('[RWP] Module initialized, instance ID:', instanceId)
32
+
33
+ // Make storage global so all instances share the same storage after upgrade
34
+ rwpLog('[RWP] About to check global storage, instanceId:', instanceId)
35
+ try {
36
+ if (!globalThis.__RWP_STORAGE__) {
37
+ rwpLog('[RWP] Creating global storage in instance:', instanceId)
38
+ globalThis.__RWP_STORAGE__ = new LocalStorageProvider('__internal_rwp_storage__')
39
+ } else {
40
+ rwpLog('[RWP] Using existing global storage in instance:', instanceId)
41
+ }
42
+ storage = globalThis.__RWP_STORAGE__
43
+ rwpLog('[RWP] InternalStorage assigned successfully')
44
+ } catch (error) {
45
+ if (globalThis.__RWP_LOGGING_ENABLED__) console.error('[RWP] Error setting up global storage:', error)
46
+ storage = new LocalStorageProvider('__internal_rwp_storage__')
47
+ }
48
+
49
+ // Use a global registry to handle multiple module instances
50
+ // This ensures all instances share the same wire registry
51
+ if (typeof globalThis !== 'undefined') {
52
+ if (!globalThis.__RWP_REGISTERED_WIRES__) {
53
+ rwpLog('[RWP] Creating global registeredWires in instance:', instanceId)
54
+ globalThis.__RWP_REGISTERED_WIRES__ = new Map()
55
+ } else {
56
+ rwpLog('[RWP] Using existing global registeredWires in instance:', instanceId)
57
+ }
58
+ }
59
+
60
+ // Registry to track wire instances for hydration refresh
61
+ const registeredWires = globalThis.__RWP_REGISTERED_WIRES__ || new Map<string, WireLikeObject>()
62
+ rwpLog('[RWP] registeredWires Map reference in instance:', instanceId, 'size:', registeredWires.size)
63
+
64
+ export const getNamespace = (): string | null => storage.namespace
65
+
66
+ export const getStorage = (): RWPStorageProvider => storage
67
+
68
+ /**
69
+ * Sets the namespace for the storage provider
70
+ *
71
+ * @param {String} namespace The namespace for the storage provider
72
+ */
73
+ export const setNamespace = (namespace: string) => {
74
+ rwpLog('[RWP] setNamespace() called with:', namespace, 'registered wires before:', registeredWires.size)
75
+ const currentNamespace = namespace || getNamespace()
76
+
77
+ if (!currentNamespace) throw new Error('react-wire-persisted: Cannot set namespace to null or undefined')
78
+
79
+ storage.setNamespace(namespace)
80
+ storage = new LocalStorageProvider(currentNamespace)
81
+ rwpLog(`[RWP] setNamespace() done, registered wires after:`, registeredWires.size)
82
+ }
83
+
84
+ export const getOptions = (): RWPOptions => options
85
+
86
+ export const setOptions = (value: Partial<RWPOptions>) => {
87
+ options = {
88
+ ...options,
89
+ ...value,
90
+ }
91
+ // Update global logging flag
92
+ if (typeof globalThis !== 'undefined') {
93
+ globalThis.__RWP_LOGGING_ENABLED__ = options.logging.enabled
94
+ }
95
+ /* istanbul ignore next */
96
+ if (options.logging.enabled) {
97
+ console.info('Flushing', pendingLogs.length, 'pending logs')
98
+ while (pendingLogs.length)
99
+ /* istanbul ignore next */
100
+ console.log(...(pendingLogs.shift() || []))
101
+ }
102
+ }
103
+
104
+ /**
105
+ * Refresh all registered wires by reading from storage
106
+ * Called after storage upgrade to sync wires with persisted values
107
+ */
108
+ const refreshAllWires = () => {
109
+ rwpLog('[RWP] refreshAllWires() called in instance:', instanceId, 'registered wires:', registeredWires.size)
110
+ log('react-wire-persisted: refreshAllWires() called, registered wires:', registeredWires.size)
111
+
112
+ registeredWires.forEach((wire: Wire<unknown> | WireLikeObject, key: string) => {
113
+ const storedValue = storage.getItem(key)
114
+ const currentValue = wire.getValue()
115
+
116
+ rwpLog('[RWP] Checking wire', key, {
117
+ storedValue,
118
+ currentValue,
119
+ willUpdate: storedValue !== null && storedValue !== currentValue,
120
+ })
121
+
122
+ log('react-wire-persisted: Checking wire', key, {
123
+ storedValue,
124
+ currentValue,
125
+ willUpdate: storedValue !== null && storedValue !== currentValue,
126
+ })
127
+
128
+ if (storedValue !== null && storedValue !== currentValue) {
129
+ rwpLog('[RWP] Refreshing wire', key, 'with stored value', storedValue)
130
+ log('react-wire-persisted: Refreshing wire', key, 'with stored value', storedValue)
131
+ wire.setValue(storedValue)
132
+ }
133
+ })
134
+ }
135
+
136
+ /**
137
+ * Attempts to upgrade the storage provider from fake storage to real localStorage
138
+ * This should be called on the client side after hydration
139
+ *
140
+ * @returns true if upgrade was successful
141
+ */
142
+ export const upgradeStorage = (): boolean => {
143
+ rwpLog('[RWP] upgradeStorage() called in instance:', instanceId, {
144
+ isClient: getIsClient(),
145
+ isUsingFakeStorage: storage.isUsingFakeStorage(),
146
+ })
147
+
148
+ log('react-wire-persisted: upgradeStorage() called', {
149
+ isClient: getIsClient(),
150
+ isUsingFakeStorage: storage.isUsingFakeStorage(),
151
+ })
152
+
153
+ if (!getIsClient()) return false
154
+
155
+ const upgraded = storage.upgradeToRealStorage()
156
+
157
+ rwpLog('[RWP] upgradeToRealStorage() returned', upgraded)
158
+ log('react-wire-persisted: upgradeToRealStorage() returned', upgraded)
159
+
160
+ if (upgraded) {
161
+ markStorageAsHydrated()
162
+ rwpLog('[RWP] Upgraded to real localStorage, calling refreshAllWires()')
163
+ log('react-wire-persisted: Upgraded to real localStorage after hydration')
164
+
165
+ // Refresh all wires with stored values
166
+ refreshAllWires()
167
+ }
168
+
169
+ return upgraded
170
+ }
171
+
172
+ const log = (...args: unknown[]) => {
173
+ /* istanbul ignore next */
174
+ if (options.logging.enabled)
175
+ /* istanbul ignore next */
176
+ console.log(...args)
177
+ else pendingLogs.push(args)
178
+ }
179
+
180
+ /**
181
+ * Creates a persisted Wire using the `RWPStorageProvider` that is currently set
182
+ * Defaults to `localStorage` via `LocalStorageProvider`
183
+ *
184
+ * @param {String} key Unique key for storing this value
185
+ * @param {*} value Initial value of this Wire
186
+ * @returns A new Wire decorated with localStorage functionality
187
+ */
188
+ export const createPersistedWire = <T = null>(key: string, value: T = null as T): PersistedWire<T> => {
189
+ rwpLog('[RWP] createPersistedWire() called in instance:', instanceId, 'key:', key, 'value:', value)
190
+
191
+ // This check helps ensure no accidental key typos occur
192
+ if (!key) throw new Error(`createPersistedWire: Key cannot be a falsey value (${key}}`)
193
+
194
+ // Track this writable entry so we can easily clear all
195
+ storage.register(key, value)
196
+
197
+ // The actual Wire backing object
198
+ const wire = createWire<T>(value)
199
+
200
+ const getValue = () => wire.getValue()
201
+
202
+ const setValue = (newValue: Defined<T>) => {
203
+ rwpLog(
204
+ '[RWP] setValue called in instance:',
205
+ instanceId,
206
+ 'key:',
207
+ key,
208
+ 'isUsingFakeStorage:',
209
+ storage.isUsingFakeStorage(),
210
+ )
211
+ storage.setItem(key, newValue)
212
+ return wire.setValue(newValue)
213
+ }
214
+
215
+ const subscribe = (callback: (value: Defined<T>) => void) => {
216
+ return wire.subscribe(callback)
217
+ }
218
+
219
+ // Always start with default value to ensure SSR consistency
220
+ let initialValue: T = value
221
+
222
+ // Only read from storage if we've hydrated OR if storage is already using real localStorage
223
+ // (prevents hydration mismatch in SSR, but allows normal behavior in client-only apps)
224
+ const canReadStorage = getHasHydratedStorage() || !storage.isUsingFakeStorage()
225
+
226
+ if (canReadStorage && getIsClient()) {
227
+ const storedValue = storage.getItem<T>(key)
228
+
229
+ if (storedValue !== null) initialValue = storedValue
230
+ }
231
+
232
+ log('react-wire-persisted: create', key, {
233
+ value,
234
+ initialValue,
235
+ hasHydratedStorage: getHasHydratedStorage(),
236
+ isUsingFakeStorage: storage.isUsingFakeStorage(),
237
+ canReadStorage,
238
+ })
239
+
240
+ if (initialValue !== value && initialValue !== undefined) setValue(initialValue as Defined<T>)
241
+
242
+ // Register wire for post-hydration refresh
243
+ registeredWires.set(key, {
244
+ getValue,
245
+ setValue,
246
+ subscribe,
247
+ })
248
+
249
+ rwpLog('[RWP] Wire registered, total wires:', registeredWires.size, 'keys:', Array.from(registeredWires.keys()))
250
+
251
+ return {
252
+ ...wire,
253
+ getValue,
254
+ setValue,
255
+ subscribe,
256
+ }
257
+ }
package/src/types.ts ADDED
@@ -0,0 +1,19 @@
1
+ import type { Wire } from '@forminator/react-wire'
2
+ import type RWPStorageProvider from '@/providers/RWPStorageProvider'
3
+
4
+ export type RWPOptions = {
5
+ logging: { enabled: boolean }
6
+ storageProvider?: typeof RWPStorageProvider
7
+ }
8
+
9
+ export type PersistedWire<T> = Wire<T>
10
+
11
+ export interface InternalStorage {
12
+ getItem: (key: string) => string | null
13
+ setItem: (key: string, value: string) => void
14
+ removeItem: (key: string) => void
15
+ }
16
+
17
+ export type AnyStorage = InternalStorage | Storage
18
+
19
+ export type WireLikeObject = Pick<Wire<unknown>, 'getValue' | 'setValue' | 'subscribe'>
@@ -0,0 +1,17 @@
1
+ import type { InternalStorage } from '@/types'
2
+
3
+ const storage: Record<string, string> = {
4
+ __IS_FAKE_LOCAL_STORAGE__: 'true',
5
+ }
6
+
7
+ export const fakeLocalStorage: InternalStorage = {
8
+ getItem: (key: string): string | null => storage[key],
9
+ setItem: (key: string, value: string): void => {
10
+ storage[key] = value
11
+ },
12
+ removeItem: (key: string): void => {
13
+ delete storage[key]
14
+ },
15
+ // Make Object.keys() work properly for _resetAll method
16
+ ...storage,
17
+ }
@@ -1,21 +1,19 @@
1
- export * from './keys'
2
1
  export * from './fakeLocalStorage'
3
2
  export * from './isomorphic'
3
+ export * from './keys'
4
4
 
5
5
  /**
6
6
  * Checks if a value is a primitive type
7
- *
7
+ *
8
8
  * @param {*} val Value to check
9
9
  * @returns {Boolean} True if value is a primitive type
10
10
  */
11
- export const isPrimitive = val => {
12
-
11
+ export const isPrimitive = (val: unknown): boolean => {
13
12
  const type = typeof val
14
-
13
+
15
14
  if (val === null) return true
16
15
  if (Array.isArray(val)) return false
17
16
  if (type === 'object') return false
18
-
17
+
19
18
  return type !== 'function'
20
-
21
19
  }
@@ -2,45 +2,58 @@
2
2
  * Utilities for handling server-side rendering and client-side hydration
3
3
  */
4
4
 
5
- let isClient = false
6
- let hasHydrated = false
5
+ let isClient: boolean = false
6
+ let hasHydrated: boolean = false
7
+ let hasHydratedStorage: boolean = false
7
8
 
8
9
  // Detect if we're running in a browser environment
9
10
  if (typeof window !== 'undefined') {
10
11
  isClient = true
11
12
 
12
13
  // Mark as hydrated when the DOM is ready
13
- if (document.readyState === 'loading') {
14
+ if (document.readyState === 'loading')
14
15
  document.addEventListener('DOMContentLoaded', () => {
15
16
  hasHydrated = true
16
17
  })
17
- } else {
18
- hasHydrated = true
19
- }
18
+ else hasHydrated = true
20
19
  }
21
20
 
22
21
  /**
23
22
  * Check if we're running in a browser environment
24
23
  */
25
- export const getIsClient = () => isClient
24
+ export const getIsClient = (): boolean => isClient
26
25
 
27
26
  /**
28
27
  * Check if the client has finished hydrating
29
28
  */
30
- export const getHasHydrated = () => hasHydrated
29
+ export const getHasHydrated = (): boolean => hasHydrated
30
+
31
+ /**
32
+ * Check if storage has been hydrated (safe to read from real localStorage)
33
+ */
34
+ export const getHasHydratedStorage = (): boolean => hasHydratedStorage
35
+
36
+ /**
37
+ * Mark storage as hydrated (called after upgradeStorage)
38
+ */
39
+ export const markStorageAsHydrated = (): void => {
40
+ hasHydratedStorage = true
41
+ }
31
42
 
32
43
  /**
33
44
  * Check if localStorage is available and safe to use
34
45
  */
35
- export const isLocalStorageAvailable = () => {
46
+ export const isLocalStorageAvailable = (): boolean => {
36
47
  if (!isClient) return false
37
48
 
38
49
  try {
39
50
  const testKey = '__rwp_test__'
51
+
40
52
  window.localStorage.setItem(testKey, 'test')
41
53
  window.localStorage.removeItem(testKey)
54
+
42
55
  return true
43
- } catch (e) {
56
+ } catch (_) {
44
57
  return false
45
58
  }
46
- }
59
+ }
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Convenience map of keys
3
+ */
4
+ const storageKeys: Record<string, string> = {}
5
+
6
+ /**
7
+ * Adds a key to the keys map
8
+ *
9
+ * @param {String} value Key name
10
+ */
11
+ export const addKey = (value: string): void => {
12
+ storageKeys[value] = value
13
+ }
14
+
15
+ /**
16
+ * Adds a key to the keys map
17
+ * (Alias for `addKey`)
18
+ *
19
+ * @param {String} value Key name
20
+ */
21
+ export const key = (value: string) => addKey(value)
22
+
23
+ /**
24
+ * Convenience method to get internally managed storage keys
25
+ *
26
+ * @returns {Object} InternalStorage keys map
27
+ */
28
+ export const getKeys = (): Record<string, string> => storageKeys
29
+
30
+ /**
31
+ * Helper utility to prefix all keys in a map to use a namespace
32
+ *
33
+ * @param {String} namespace InternalStorage namespace prefix
34
+ * @param {Object} keys (Optional) InternalStorage key/values. Defaults to the internally managed keys map
35
+ */
36
+ export const getPrefixedKeys = (namespace: string, keys: Record<string, string> | null = null) => {
37
+ const items = keys || storageKeys
38
+
39
+ if (!namespace) return items
40
+
41
+ return Object.keys(items).reduce(
42
+ (acc, it) => {
43
+ acc[it] = `${namespace}.${items[it]}`
44
+
45
+ return acc
46
+ },
47
+ {} as Record<string, string>,
48
+ )
49
+ }
@@ -1,45 +0,0 @@
1
- 'use client'
2
-
3
- import { useEffect, useRef } from 'react'
4
- import { upgradeStorage } from '../react-wire-persisted'
5
- import { getIsClient, getHasHydrated } from '../utils'
6
-
7
- /**
8
- * A Next.js App Router compatible component that handles automatic storage upgrade
9
- * after hydration. Use this in your root layout.
10
- *
11
- * @param {Object} props Component props
12
- * @param {React.ReactNode} props.children Child components to render
13
- * @param {Function} props.onUpgrade Callback called when storage is upgraded
14
- * @param {Boolean} props.autoUpgrade Whether to automatically upgrade storage (default: true)
15
- */
16
- export function HydrationProvider({ children, onUpgrade, autoUpgrade = true }) {
17
- const hasUpgraded = useRef(false)
18
-
19
- useEffect(() => {
20
- if (!autoUpgrade || hasUpgraded.current || !getIsClient()) {
21
- return
22
- }
23
-
24
- const attemptUpgrade = () => {
25
- if (getHasHydrated() && !hasUpgraded.current) {
26
- const upgraded = upgradeStorage()
27
-
28
- if (upgraded) {
29
- hasUpgraded.current = true
30
- onUpgrade?.()
31
- }
32
- }
33
- }
34
-
35
- // Try to upgrade immediately if already hydrated
36
- attemptUpgrade()
37
-
38
- // Also try after a short delay to ensure DOM is ready
39
- const timeoutId = setTimeout(attemptUpgrade, 0)
40
-
41
- return () => clearTimeout(timeoutId)
42
- }, [autoUpgrade, onUpgrade])
43
-
44
- return children
45
- }
@@ -1 +0,0 @@
1
- export { HydrationProvider } from './HydrationProvider.js'
package/src/index.js DELETED
@@ -1,6 +0,0 @@
1
- import * as utils from './utils'
2
-
3
- export { utils }
4
- export * from './react-wire-persisted'
5
- export * from './hooks/useHydration'
6
- export * from './components'