Bluesky app fork with some witchin' additions 💫

[Persisted] Fix the race condition causing clobbered writes between tabs (#4873)

* Broadcast the update in the same tick

The motivation for the original code is unclear. I was not able to reproduce the described behavior and have not seen it mentioned on the web. I'll assume that this was a misunderstanding.

* Remove defensive programming

The only places in this code that we can expect to throw are schema.parse(), JSON.parse(), JSON.stringify(), and localStorage.getItem/setItem/removeItem. Let's push try/catch'es where we expect them to be necessary.

* Don't write or clobber defaults

Writing defaults to local storage is unnecessary. We would write them as a part of next update anyway. So I'm removing that to reduce the number of moving pieces.

However, we do need to be wary of _state being set to defaults. Because _state gets mutated on write. We don't want to mutate the defaults object. To avoid having to think about this, let's copy on write. We don't write to this object very often.

* Refactor: extract tryParse

* Refactor: move string parsing into tryParse

* Extract tryStringify, split logging by platform

Shared data parsing/stringification errors are always logged. Storage errors are only logged on native because we trust the web APIs to work.

* Add a layer of caching to readFromStorage to web

We're going to be doing a read on every write so let's add a fast path that avoids parsing and validating.

* Fix the race condition causing clobbered writes between tabs

authored by danabra.mov and committed by

GitHub 966f6c51 5bf7f376

+130 -104
+33 -41
src/state/persisted/index.ts
··· 1 1 import AsyncStorage from '@react-native-async-storage/async-storage' 2 2 3 3 import {logger} from '#/logger' 4 - import {defaults, Schema, schema} from '#/state/persisted/schema' 4 + import { 5 + defaults, 6 + Schema, 7 + tryParse, 8 + tryStringify, 9 + } from '#/state/persisted/schema' 5 10 import {PersistedApi} from './types' 6 11 7 12 export type {PersistedAccount, Schema} from '#/state/persisted/schema' ··· 12 17 let _state: Schema = defaults 13 18 14 19 export async function init() { 15 - try { 16 - const stored = await readFromStorage() 17 - if (!stored) { 18 - await writeToStorage(defaults) 19 - } 20 - _state = stored || defaults 21 - } catch (e) { 22 - logger.error('persisted state: failed to load root state from storage', { 23 - message: e, 24 - }) 20 + const stored = await readFromStorage() 21 + if (stored) { 22 + _state = stored 25 23 } 26 24 } 27 25 init satisfies PersistedApi['init'] ··· 35 33 key: K, 36 34 value: Schema[K], 37 35 ): Promise<void> { 38 - try { 39 - _state[key] = value 40 - await writeToStorage(_state) 41 - } catch (e) { 42 - logger.error(`persisted state: failed writing root state to storage`, { 43 - message: e, 44 - }) 36 + _state = { 37 + ..._state, 38 + [key]: value, 45 39 } 40 + await writeToStorage(_state) 46 41 } 47 42 write satisfies PersistedApi['write'] 48 43 ··· 61 56 clearStorage satisfies PersistedApi['clearStorage'] 62 57 63 58 async function writeToStorage(value: Schema) { 64 - schema.parse(value) 65 - await AsyncStorage.setItem(BSKY_STORAGE, JSON.stringify(value)) 59 + const rawData = tryStringify(value) 60 + if (rawData) { 61 + try { 62 + await AsyncStorage.setItem(BSKY_STORAGE, rawData) 63 + } catch (e) { 64 + logger.error(`persisted state: failed writing root state to storage`, { 65 + message: e, 66 + }) 67 + } 68 + } 66 69 } 67 70 68 71 async function readFromStorage(): Promise<Schema | undefined> { 69 - const rawData = await AsyncStorage.getItem(BSKY_STORAGE) 70 - const objData = rawData ? JSON.parse(rawData) : undefined 71 - 72 - // new user 73 - if (!objData) return undefined 74 - 75 - // existing user, validate 76 - const parsed = schema.safeParse(objData) 77 - 78 - if (parsed.success) { 79 - return objData 80 - } else { 81 - const errors = 82 - parsed.error?.errors?.map(e => ({ 83 - code: e.code, 84 - // @ts-ignore exists on some types 85 - expected: e?.expected, 86 - path: e.path?.join('.'), 87 - })) || [] 88 - logger.error(`persisted store: data failed validation on read`, {errors}) 89 - return undefined 72 + let rawData: string | null = null 73 + try { 74 + rawData = await AsyncStorage.getItem(BSKY_STORAGE) 75 + } catch (e) { 76 + logger.error(`persisted state: failed reading root state from storage`, { 77 + message: e, 78 + }) 79 + } 80 + if (rawData) { 81 + return tryParse(rawData) 90 82 } 91 83 }
+55 -62
src/state/persisted/index.web.ts
··· 2 2 3 3 import BroadcastChannel from '#/lib/broadcast' 4 4 import {logger} from '#/logger' 5 - import {defaults, Schema, schema} from '#/state/persisted/schema' 5 + import { 6 + defaults, 7 + Schema, 8 + tryParse, 9 + tryStringify, 10 + } from '#/state/persisted/schema' 6 11 import {PersistedApi} from './types' 7 12 8 13 export type {PersistedAccount, Schema} from '#/state/persisted/schema' ··· 18 23 19 24 export async function init() { 20 25 broadcast.onmessage = onBroadcastMessage 21 - 22 - try { 23 - const stored = readFromStorage() 24 - if (!stored) { 25 - writeToStorage(defaults) 26 - } 27 - _state = stored || defaults 28 - } catch (e) { 29 - logger.error('persisted state: failed to load root state from storage', { 30 - message: e, 31 - }) 26 + const stored = readFromStorage() 27 + if (stored) { 28 + _state = stored 32 29 } 33 30 } 34 31 init satisfies PersistedApi['init'] ··· 42 39 key: K, 43 40 value: Schema[K], 44 41 ): Promise<void> { 45 - try { 46 - _state[key] = value 47 - writeToStorage(_state) 48 - // must happen on next tick, otherwise the tab will read stale storage data 49 - setTimeout(() => broadcast.postMessage({event: UPDATE_EVENT}), 0) 50 - } catch (e) { 51 - logger.error(`persisted state: failed writing root state to storage`, { 52 - message: e, 53 - }) 42 + const next = readFromStorage() 43 + if (next) { 44 + // The storage could have been updated by a different tab before this tab is notified. 45 + // Make sure this write is applied on top of the latest data in the storage as long as it's valid. 46 + _state = next 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, 54 53 } 54 + writeToStorage(_state) 55 + broadcast.postMessage({event: UPDATE_EVENT}) 55 56 } 56 57 write satisfies PersistedApi['write'] 57 58 ··· 65 66 try { 66 67 localStorage.removeItem(BSKY_STORAGE) 67 68 } catch (e: any) { 68 - logger.error(`persisted store: failed to clear`, {message: e.toString()}) 69 + // Expected on the web in private mode. 69 70 } 70 71 } 71 72 clearStorage satisfies PersistedApi['clearStorage'] 72 73 73 74 async function onBroadcastMessage({data}: MessageEvent) { 74 75 if (typeof data === 'object' && data.event === UPDATE_EVENT) { 75 - try { 76 - // read next state, possibly updated by another tab 77 - const next = readFromStorage() 78 - 79 - if (next) { 80 - _state = next 81 - _emitter.emit('update') 82 - } else { 83 - logger.error( 84 - `persisted state: handled update update from broadcast channel, but found no data`, 85 - ) 86 - } 87 - } catch (e) { 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 { 88 82 logger.error( 89 - `persisted state: failed handling update from broadcast channel`, 90 - { 91 - message: e, 92 - }, 83 + `persisted state: handled update update from broadcast channel, but found no data`, 93 84 ) 94 85 } 95 86 } 96 87 } 97 88 98 89 function writeToStorage(value: Schema) { 99 - schema.parse(value) 100 - localStorage.setItem(BSKY_STORAGE, JSON.stringify(value)) 90 + const rawData = tryStringify(value) 91 + if (rawData) { 92 + try { 93 + localStorage.setItem(BSKY_STORAGE, rawData) 94 + } catch (e) { 95 + // Expected on the web in private mode. 96 + } 97 + } 101 98 } 102 99 100 + let lastRawData: string | undefined 101 + let lastResult: Schema | undefined 103 102 function readFromStorage(): Schema | undefined { 104 - const rawData = localStorage.getItem(BSKY_STORAGE) 105 - const objData = rawData ? JSON.parse(rawData) : undefined 106 - 107 - // new user 108 - if (!objData) return undefined 109 - 110 - // existing user, validate 111 - const parsed = schema.safeParse(objData) 112 - 113 - if (parsed.success) { 114 - return objData 115 - } else { 116 - const errors = 117 - parsed.error?.errors?.map(e => ({ 118 - code: e.code, 119 - // @ts-ignore exists on some types 120 - expected: e?.expected, 121 - path: e.path?.join('.'), 122 - })) || [] 123 - logger.error(`persisted store: data failed validation on read`, {errors}) 124 - return undefined 103 + let rawData: string | null = null 104 + try { 105 + rawData = localStorage.getItem(BSKY_STORAGE) 106 + } catch (e) { 107 + // Expected on the web in private mode. 108 + } 109 + if (rawData) { 110 + if (rawData === lastRawData) { 111 + return lastResult 112 + } else { 113 + const result = tryParse(rawData) 114 + lastRawData = rawData 115 + lastResult = result 116 + return result 117 + } 125 118 } 126 119 }
+42 -1
src/state/persisted/schema.ts
··· 1 1 import {z} from 'zod' 2 2 3 + import {logger} from '#/logger' 3 4 import {deviceLocales} from '#/platform/detection' 4 5 import {PlatformInfo} from '../../../modules/expo-bluesky-swiss-army' 5 6 ··· 43 44 }) 44 45 export type PersistedCurrentAccount = z.infer<typeof currentAccountSchema> 45 46 46 - export const schema = z.object({ 47 + const schema = z.object({ 47 48 colorMode: z.enum(['system', 'light', 'dark']), 48 49 darkTheme: z.enum(['dim', 'dark']).optional(), 49 50 session: z.object({ ··· 133 134 kawaii: false, 134 135 hasCheckedForStarterPack: false, 135 136 } 137 + 138 + export function tryParse(rawData: string): Schema | undefined { 139 + let objData 140 + try { 141 + objData = JSON.parse(rawData) 142 + } catch (e) { 143 + logger.error('persisted state: failed to parse root state from storage', { 144 + message: e, 145 + }) 146 + } 147 + if (!objData) { 148 + return undefined 149 + } 150 + const parsed = schema.safeParse(objData) 151 + if (parsed.success) { 152 + return objData 153 + } else { 154 + const errors = 155 + parsed.error?.errors?.map(e => ({ 156 + code: e.code, 157 + // @ts-ignore exists on some types 158 + expected: e?.expected, 159 + path: e.path?.join('.'), 160 + })) || [] 161 + logger.error(`persisted store: data failed validation on read`, {errors}) 162 + return undefined 163 + } 164 + } 165 + 166 + export function tryStringify(value: Schema): string | undefined { 167 + try { 168 + schema.parse(value) 169 + return JSON.stringify(value) 170 + } catch (e) { 171 + logger.error(`persisted state: failed stringifying root state`, { 172 + message: e, 173 + }) 174 + return undefined 175 + } 176 + }