ConfirmDialog.js
1 month ago
LabelWithTooltip.js
1 month ago
ProGate.js
1 month ago
SectionCard.js
1 month ago
SettingsDataProvider.js
1 month ago
SettingsPageShell.js
1 month ago
UnsavedChangesDialog.js
1 month ago
navigationGuard.js
1 month ago
SettingsDataProvider.js
194 lines
| 1 | import { |
| 2 | createContext, |
| 3 | useContext, |
| 4 | useEffect, |
| 5 | useRef, |
| 6 | useState, |
| 7 | useCallback, |
| 8 | useMemo, |
| 9 | } from 'react'; |
| 10 | import wpApiFetch from '@wordpress/api-fetch'; |
| 11 | import isEqual from 'lodash/isEqual'; |
| 12 | |
| 13 | const SettingsDataContext = createContext( null ); |
| 14 | |
| 15 | export const useSettingsData = () => { |
| 16 | const ctx = useContext( SettingsDataContext ); |
| 17 | if ( ! ctx ) { |
| 18 | throw new Error( |
| 19 | 'useSettingsData must be used inside <SettingsDataProvider>' |
| 20 | ); |
| 21 | } |
| 22 | return ctx; |
| 23 | }; |
| 24 | |
| 25 | const SettingsDataProvider = ( { children } ) => { |
| 26 | const [ settings, setSettings ] = useState( {} ); |
| 27 | const [ lastSaved, setLastSaved ] = useState( {} ); |
| 28 | const [ isLoading, setIsLoading ] = useState( true ); |
| 29 | const [ loadError, setLoadError ] = useState( null ); |
| 30 | const [ savingKeys, setSavingKeys ] = useState( () => new Set() ); |
| 31 | |
| 32 | // Mirror state into refs so stable callbacks (saveSlice, resetKey) can read |
| 33 | // the freshest values without listing them as deps and losing their identity. |
| 34 | const settingsRef = useRef( settings ); |
| 35 | const lastSavedRef = useRef( lastSaved ); |
| 36 | useEffect( () => { |
| 37 | settingsRef.current = settings; |
| 38 | }, [ settings ] ); |
| 39 | useEffect( () => { |
| 40 | lastSavedRef.current = lastSaved; |
| 41 | }, [ lastSaved ] ); |
| 42 | |
| 43 | // Guards against React 18 StrictMode's double-invoke of mount effects in dev. |
| 44 | const hasFetched = useRef( false ); |
| 45 | |
| 46 | const fetchSettings = useCallback( async () => { |
| 47 | setIsLoading( true ); |
| 48 | setLoadError( null ); |
| 49 | try { |
| 50 | const response = await wpApiFetch( { path: '/wp/v2/settings' } ); |
| 51 | setSettings( response ); |
| 52 | setLastSaved( response ); |
| 53 | } catch ( err ) { |
| 54 | // eslint-disable-next-line no-console |
| 55 | console.error( 'SettingsDataProvider: initial fetch failed', err ); |
| 56 | setLoadError( err ); |
| 57 | } finally { |
| 58 | setIsLoading( false ); |
| 59 | } |
| 60 | }, [] ); |
| 61 | |
| 62 | useEffect( () => { |
| 63 | if ( hasFetched.current ) { |
| 64 | return; |
| 65 | } |
| 66 | hasFetched.current = true; |
| 67 | fetchSettings(); |
| 68 | }, [ fetchSettings ] ); |
| 69 | |
| 70 | const updateLocal = useCallback( ( key, value ) => { |
| 71 | setSettings( ( prev ) => { |
| 72 | const next = typeof value === 'function' ? value( prev[ key ] ) : value; |
| 73 | // Returning the same reference when unchanged avoids a spurious render, |
| 74 | // and lets callers' early-return no-ops propagate correctly. |
| 75 | if ( isEqual( prev[ key ], next ) ) { |
| 76 | return prev; |
| 77 | } |
| 78 | return { ...prev, [ key ]: next }; |
| 79 | } ); |
| 80 | }, [] ); |
| 81 | |
| 82 | const resetKey = useCallback( ( key ) => { |
| 83 | setSettings( ( prev ) => { |
| 84 | const target = lastSavedRef.current[ key ]; |
| 85 | if ( isEqual( prev[ key ], target ) ) { |
| 86 | return prev; |
| 87 | } |
| 88 | return { ...prev, [ key ]: target }; |
| 89 | } ); |
| 90 | }, [] ); |
| 91 | |
| 92 | const mutateSaving = useCallback( ( keys, op ) => { |
| 93 | setSavingKeys( ( prev ) => { |
| 94 | const next = new Set( prev ); |
| 95 | keys.forEach( ( k ) => next[ op ]( k ) ); |
| 96 | return next; |
| 97 | } ); |
| 98 | }, [] ); |
| 99 | |
| 100 | const saveSlice = useCallback( |
| 101 | async ( keys ) => { |
| 102 | if ( ! Array.isArray( keys ) || keys.length === 0 ) { |
| 103 | return; |
| 104 | } |
| 105 | |
| 106 | mutateSaving( keys, 'add' ); |
| 107 | |
| 108 | try { |
| 109 | const current = settingsRef.current; |
| 110 | // Skip null as well as undefined. WP interprets a null in the |
| 111 | // request body as "delete/reset this option", and refuses the |
| 112 | // call with `rest_invalid_stored_value` whenever the current |
| 113 | // stored value doesn't pass the registered schema. A settings |
| 114 | // save is never meant to delete, so we never send null. |
| 115 | const payload = keys.reduce( ( acc, k ) => { |
| 116 | if ( current[ k ] !== undefined && current[ k ] !== null ) { |
| 117 | acc[ k ] = current[ k ]; |
| 118 | } |
| 119 | return acc; |
| 120 | }, {} ); |
| 121 | |
| 122 | if ( Object.keys( payload ).length === 0 ) { |
| 123 | return; |
| 124 | } |
| 125 | |
| 126 | const response = await wpApiFetch( { |
| 127 | path: '/wp/v2/settings', |
| 128 | method: 'POST', |
| 129 | data: payload, |
| 130 | } ); |
| 131 | |
| 132 | // WP's POST /wp/v2/settings may legitimately omit keys it considers |
| 133 | // unchanged. Fall back to the current draft in that case so we don't |
| 134 | // clobber a saved-but-unreturned field with `undefined`. |
| 135 | const mergeFromResponse = ( prev ) => { |
| 136 | const next = { ...prev }; |
| 137 | keys.forEach( ( k ) => { |
| 138 | if ( |
| 139 | response && |
| 140 | Object.prototype.hasOwnProperty.call( response, k ) |
| 141 | ) { |
| 142 | next[ k ] = response[ k ]; |
| 143 | } else if ( current[ k ] !== undefined ) { |
| 144 | next[ k ] = current[ k ]; |
| 145 | } |
| 146 | } ); |
| 147 | return next; |
| 148 | }; |
| 149 | |
| 150 | setLastSaved( mergeFromResponse ); |
| 151 | setSettings( mergeFromResponse ); |
| 152 | |
| 153 | return response; |
| 154 | } finally { |
| 155 | mutateSaving( keys, 'delete' ); |
| 156 | } |
| 157 | }, |
| 158 | [ mutateSaving ] |
| 159 | ); |
| 160 | |
| 161 | const value = useMemo( |
| 162 | () => ( { |
| 163 | settings, |
| 164 | lastSaved, |
| 165 | isLoading, |
| 166 | loadError, |
| 167 | retryLoad: fetchSettings, |
| 168 | savingKeys, |
| 169 | updateLocal, |
| 170 | resetKey, |
| 171 | saveSlice, |
| 172 | } ), |
| 173 | [ |
| 174 | settings, |
| 175 | lastSaved, |
| 176 | isLoading, |
| 177 | loadError, |
| 178 | fetchSettings, |
| 179 | savingKeys, |
| 180 | updateLocal, |
| 181 | resetKey, |
| 182 | saveSlice, |
| 183 | ] |
| 184 | ); |
| 185 | |
| 186 | return ( |
| 187 | <SettingsDataContext.Provider value={ value }> |
| 188 | { children } |
| 189 | </SettingsDataContext.Provider> |
| 190 | ); |
| 191 | }; |
| 192 | |
| 193 | export default SettingsDataProvider; |
| 194 |