my fork of the bluesky client

[Persisted] Make broadcast subscriptions granular by key (#4874)

* Add fast path for guaranteed noop updates

* Change persisted.onUpdate() API to take a key

* Implement granular broadcast listeners

authored by danabra.mov and committed by

GitHub 686d5ebb 966f6c51

+95 -42
+3 -2
src/state/invites.tsx
··· 1 import React from 'react' 2 import * as persisted from '#/state/persisted' 3 4 type StateContext = persisted.Schema['invites'] ··· 35 ) 36 37 React.useEffect(() => { 38 - return persisted.onUpdate(() => { 39 - setState(persisted.get('invites')) 40 }) 41 }, [setState]) 42
··· 1 import React from 'react' 2 + 3 import * as persisted from '#/state/persisted' 4 5 type StateContext = persisted.Schema['invites'] ··· 36 ) 37 38 React.useEffect(() => { 39 + return persisted.onUpdate('invites', nextInvites => { 40 + setState(nextInvites) 41 }) 42 }, [setState]) 43
+4 -1
src/state/persisted/index.ts
··· 41 } 42 write satisfies PersistedApi['write'] 43 44 - export function onUpdate(_cb: () => void): () => void { 45 return () => {} 46 } 47 onUpdate satisfies PersistedApi['onUpdate']
··· 41 } 42 write satisfies PersistedApi['write'] 43 44 + export function onUpdate<K extends keyof Schema>( 45 + _key: K, 46 + _cb: (v: Schema[K]) => void, 47 + ): () => void { 48 return () => {} 49 } 50 onUpdate satisfies PersistedApi['onUpdate']
+35 -6
src/state/persisted/index.web.ts
··· 47 // Don't fire the update listeners yet to avoid a loop. 48 // If there was a change, we'll receive the broadcast event soon enough which will do that. 49 } 50 _state = { 51 ..._state, 52 [key]: value, 53 } 54 writeToStorage(_state) 55 - broadcast.postMessage({event: UPDATE_EVENT}) 56 } 57 write satisfies PersistedApi['write'] 58 59 - export function onUpdate(cb: () => void): () => void { 60 - _emitter.addListener('update', cb) 61 - return () => _emitter.removeListener('update', cb) 62 } 63 onUpdate satisfies PersistedApi['onUpdate'] 64 ··· 72 clearStorage satisfies PersistedApi['clearStorage'] 73 74 async function onBroadcastMessage({data}: MessageEvent) { 75 - if (typeof data === 'object' && data.event === UPDATE_EVENT) { 76 // read next state, possibly updated by another tab 77 const next = readFromStorage() 78 if (next) { 79 _state = next 80 - _emitter.emit('update') 81 } else { 82 logger.error( 83 `persisted state: handled update update from broadcast channel, but found no data`,
··· 47 // Don't fire the update listeners yet to avoid a loop. 48 // If there was a change, we'll receive the broadcast event soon enough which will do that. 49 } 50 + try { 51 + if (JSON.stringify({v: _state[key]}) === JSON.stringify({v: value})) { 52 + // Fast path for updates that are guaranteed to be noops. 53 + // This is good mostly because it avoids useless broadcasts to other tabs. 54 + return 55 + } 56 + } catch (e) { 57 + // Ignore and go through the normal path. 58 + } 59 _state = { 60 ..._state, 61 [key]: value, 62 } 63 writeToStorage(_state) 64 + broadcast.postMessage({event: {type: UPDATE_EVENT, key}}) 65 + broadcast.postMessage({event: UPDATE_EVENT}) // Backcompat while upgrading 66 } 67 write satisfies PersistedApi['write'] 68 69 + export function onUpdate<K extends keyof Schema>( 70 + key: K, 71 + cb: (v: Schema[K]) => void, 72 + ): () => void { 73 + const listener = () => cb(get(key)) 74 + _emitter.addListener('update', listener) // Backcompat while upgrading 75 + _emitter.addListener('update:' + key, listener) 76 + return () => { 77 + _emitter.removeListener('update', listener) // Backcompat while upgrading 78 + _emitter.removeListener('update:' + key, listener) 79 + } 80 } 81 onUpdate satisfies PersistedApi['onUpdate'] 82 ··· 90 clearStorage satisfies PersistedApi['clearStorage'] 91 92 async function onBroadcastMessage({data}: MessageEvent) { 93 + if ( 94 + typeof data === 'object' && 95 + (data.event === UPDATE_EVENT || // Backcompat while upgrading 96 + data.event?.type === UPDATE_EVENT) 97 + ) { 98 // read next state, possibly updated by another tab 99 const next = readFromStorage() 100 + if (next === _state) { 101 + return 102 + } 103 if (next) { 104 _state = next 105 + if (typeof data.event.key === 'string') { 106 + _emitter.emit('update:' + data.event.key) 107 + } else { 108 + _emitter.emit('update') // Backcompat while upgrading 109 + } 110 } else { 111 logger.error( 112 `persisted state: handled update update from broadcast channel, but found no data`,
+4 -1
src/state/persisted/types.ts
··· 4 init(): Promise<void> 5 get<K extends keyof Schema>(key: K): Schema[K] 6 write<K extends keyof Schema>(key: K, value: Schema[K]): Promise<void> 7 - onUpdate(_cb: () => void): () => void 8 clearStorage: () => Promise<void> 9 }
··· 4 init(): Promise<void> 5 get<K extends keyof Schema>(key: K): Schema[K] 6 write<K extends keyof Schema>(key: K, value: Schema[K]): Promise<void> 7 + onUpdate<K extends keyof Schema>( 8 + key: K, 9 + cb: (v: Schema[K]) => void, 10 + ): () => void 11 clearStorage: () => Promise<void> 12 }
+6 -3
src/state/preferences/alt-text-required.tsx
··· 26 ) 27 28 React.useEffect(() => { 29 - return persisted.onUpdate(() => { 30 - setState(persisted.get('requireAltTextEnabled')) 31 - }) 32 }, [setStateWrapped]) 33 34 return (
··· 26 ) 27 28 React.useEffect(() => { 29 + return persisted.onUpdate( 30 + 'requireAltTextEnabled', 31 + nextRequireAltTextEnabled => { 32 + setState(nextRequireAltTextEnabled) 33 + }, 34 + ) 35 }, [setStateWrapped]) 36 37 return (
+2 -2
src/state/preferences/autoplay.tsx
··· 24 ) 25 26 React.useEffect(() => { 27 - return persisted.onUpdate(() => { 28 - setState(Boolean(persisted.get('disableAutoplay'))) 29 }) 30 }, [setStateWrapped]) 31
··· 24 ) 25 26 React.useEffect(() => { 27 + return persisted.onUpdate('disableAutoplay', nextDisableAutoplay => { 28 + setState(Boolean(nextDisableAutoplay)) 29 }) 30 }, [setStateWrapped]) 31
+2 -2
src/state/preferences/disable-haptics.tsx
··· 24 ) 25 26 React.useEffect(() => { 27 - return persisted.onUpdate(() => { 28 - setState(Boolean(persisted.get('disableHaptics'))) 29 }) 30 }, [setStateWrapped]) 31
··· 24 ) 25 26 React.useEffect(() => { 27 + return persisted.onUpdate('disableHaptics', nextDisableHaptics => { 28 + setState(Boolean(nextDisableHaptics)) 29 }) 30 }, [setStateWrapped]) 31
+2 -2
src/state/preferences/external-embeds-prefs.tsx
··· 35 ) 36 37 React.useEffect(() => { 38 - return persisted.onUpdate(() => { 39 - setState(persisted.get('externalEmbeds')) 40 }) 41 }, [setStateWrapped]) 42
··· 35 ) 36 37 React.useEffect(() => { 38 + return persisted.onUpdate('externalEmbeds', nextExternalEmbeds => { 39 + setState(nextExternalEmbeds) 40 }) 41 }, [setStateWrapped]) 42
+2 -2
src/state/preferences/hidden-posts.tsx
··· 44 ) 45 46 React.useEffect(() => { 47 - return persisted.onUpdate(() => { 48 - setState(persisted.get('hiddenPosts')) 49 }) 50 }, [setStateWrapped]) 51
··· 44 ) 45 46 React.useEffect(() => { 47 + return persisted.onUpdate('hiddenPosts', nextHiddenPosts => { 48 + setState(nextHiddenPosts) 49 }) 50 }, [setStateWrapped]) 51
+2 -2
src/state/preferences/in-app-browser.tsx
··· 34 ) 35 36 React.useEffect(() => { 37 - return persisted.onUpdate(() => { 38 - setState(persisted.get('useInAppBrowser')) 39 }) 40 }, [setStateWrapped]) 41
··· 34 ) 35 36 React.useEffect(() => { 37 + return persisted.onUpdate('useInAppBrowser', nextUseInAppBrowser => { 38 + setState(nextUseInAppBrowser) 39 }) 40 }, [setStateWrapped]) 41
+2 -2
src/state/preferences/kawaii.tsx
··· 21 ) 22 23 React.useEffect(() => { 24 - return persisted.onUpdate(() => { 25 - setState(persisted.get('kawaii')) 26 }) 27 }, [setStateWrapped]) 28
··· 21 ) 22 23 React.useEffect(() => { 24 + return persisted.onUpdate('kawaii', nextKawaii => { 25 + setState(nextKawaii) 26 }) 27 }, [setStateWrapped]) 28
+2 -2
src/state/preferences/languages.tsx
··· 43 ) 44 45 React.useEffect(() => { 46 - return persisted.onUpdate(() => { 47 - setState(persisted.get('languagePrefs')) 48 }) 49 }, [setStateWrapped]) 50
··· 43 ) 44 45 React.useEffect(() => { 46 + return persisted.onUpdate('languagePrefs', nextLanguagePrefs => { 47 + setState(nextLanguagePrefs) 48 }) 49 }, [setStateWrapped]) 50
+6 -3
src/state/preferences/large-alt-badge.tsx
··· 26 ) 27 28 React.useEffect(() => { 29 - return persisted.onUpdate(() => { 30 - setState(persisted.get('largeAltBadgeEnabled')) 31 - }) 32 }, [setStateWrapped]) 33 34 return (
··· 26 ) 27 28 React.useEffect(() => { 29 + return persisted.onUpdate( 30 + 'largeAltBadgeEnabled', 31 + nextLargeAltBadgeEnabled => { 32 + setState(nextLargeAltBadgeEnabled) 33 + }, 34 + ) 35 }, [setStateWrapped]) 36 37 return (
+6 -3
src/state/preferences/used-starter-packs.tsx
··· 19 } 20 21 React.useEffect(() => { 22 - return persisted.onUpdate(() => { 23 - setState(persisted.get('hasCheckedForStarterPack')) 24 - }) 25 }, []) 26 27 return (
··· 19 } 20 21 React.useEffect(() => { 22 + return persisted.onUpdate( 23 + 'hasCheckedForStarterPack', 24 + nextHasCheckedForStarterPack => { 25 + setState(nextHasCheckedForStarterPack) 26 + }, 27 + ) 28 }, []) 29 30 return (
+2 -2
src/state/session/index.tsx
··· 185 }, [state]) 186 187 React.useEffect(() => { 188 - return persisted.onUpdate(() => { 189 - const synced = persisted.get('session') 190 addSessionDebugLog({type: 'persisted:receive', data: synced}) 191 dispatch({ 192 type: 'synced-accounts',
··· 185 }, [state]) 186 187 React.useEffect(() => { 188 + return persisted.onUpdate('session', nextSession => { 189 + const synced = nextSession 190 addSessionDebugLog({type: 'persisted:receive', data: synced}) 191 dispatch({ 192 type: 'synced-accounts',
+10 -3
src/state/shell/color-mode.tsx
··· 1 import React from 'react' 2 import * as persisted from '#/state/persisted' 3 4 type StateContext = { ··· 43 ) 44 45 React.useEffect(() => { 46 - return persisted.onUpdate(() => { 47 - setColorMode(persisted.get('colorMode')) 48 - setDarkTheme(persisted.get('darkTheme')) 49 }) 50 }, []) 51 52 return (
··· 1 import React from 'react' 2 + 3 import * as persisted from '#/state/persisted' 4 5 type StateContext = { ··· 44 ) 45 46 React.useEffect(() => { 47 + const unsub1 = persisted.onUpdate('darkTheme', nextDarkTheme => { 48 + setDarkTheme(nextDarkTheme) 49 }) 50 + const unsub2 = persisted.onUpdate('colorMode', nextColorMode => { 51 + setColorMode(nextColorMode) 52 + }) 53 + return () => { 54 + unsub1() 55 + unsub2() 56 + } 57 }, []) 58 59 return (
+5 -4
src/state/shell/onboarding.tsx
··· 1 import React from 'react' 2 import * as persisted from '#/state/persisted' 3 - import {track} from '#/lib/analytics/analytics' 4 5 export const OnboardingScreenSteps = { 6 Welcome: 'Welcome', ··· 81 ) 82 83 React.useEffect(() => { 84 - return persisted.onUpdate(() => { 85 - const next = persisted.get('onboarding').step 86 // TODO we've introduced a footgun 87 if (state.step !== next) { 88 dispatch({ 89 type: 'set', 90 - step: persisted.get('onboarding').step as OnboardingStep, 91 }) 92 } 93 })
··· 1 import React from 'react' 2 + 3 + import {track} from '#/lib/analytics/analytics' 4 import * as persisted from '#/state/persisted' 5 6 export const OnboardingScreenSteps = { 7 Welcome: 'Welcome', ··· 82 ) 83 84 React.useEffect(() => { 85 + return persisted.onUpdate('onboarding', nextOnboarding => { 86 + const next = nextOnboarding.step 87 // TODO we've introduced a footgun 88 if (state.step !== next) { 89 dispatch({ 90 type: 'set', 91 + step: nextOnboarding.step as OnboardingStep, 92 }) 93 } 94 })