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