fix(lib/storage): allow imports during preinit

This commit is contained in:
PalmDevs 2025-07-11 16:58:47 +07:00
parent f05885e559
commit 8bd9f4f8b2
No known key found for this signature in database
GPG key ID: 6FAE8F8677ACA8BA
4 changed files with 159 additions and 110 deletions

View file

@ -1,3 +1,4 @@
import { proxify } from '@revenge-mod/utils/proxy'
import type { DiscordNativeModules } from './types/native'
/**
@ -9,22 +10,52 @@ import type { DiscordNativeModules } from './types/native'
* - If the module name ends with "Manager", include it
*/
export let CacheModule: DiscordNativeModules.CacheModule = proxify(() => {
const module = __turboModuleProxy('NativeCacheModule') as typeof CacheModule
if (module) return (CacheModule = module)
})!
export let FileModule: DiscordNativeModules.FileModule = proxify(() => {
const module = __turboModuleProxy('NativeFileModule') as typeof FileModule
if (module) return (FileModule = module)
})!
export let ClientInfoModule: DiscordNativeModules.ClientInfoModule = proxify(
() => {
const module = __turboModuleProxy(
'NativeClientInfoModule',
) as typeof ClientInfoModule
if (module) return (ClientInfoModule = module)
},
)!
export let DeviceModule: DiscordNativeModules.DeviceModule = proxify(() => {
const module = __turboModuleProxy(
'NativeDeviceModule',
) as typeof DeviceModule
if (module) return (DeviceModule = module)
})!
export let ThemeModule: DiscordNativeModules.ThemeModule = proxify(() => {
const module = __turboModuleProxy('NativeThemeModule') as typeof ThemeModule
if (ThemeModule) return (ThemeModule = module)
})!
const nmp = nativeModuleProxy
export const CacheModule = __turboModuleProxy(
'NativeCacheModule',
) as DiscordNativeModules.CacheModule
export const FileModule = __turboModuleProxy(
'NativeFileModule',
) as DiscordNativeModules.FileModule
export const ClientInfoModule = __turboModuleProxy(
'NativeClientInfoModule',
) as DiscordNativeModules.ClientInfoModule
export const DeviceModule = __turboModuleProxy(
'NativeDeviceModule',
) as DiscordNativeModules.DeviceModule
export const BundleUpdaterManager =
nmp.BundleUpdaterManager as DiscordNativeModules.BundleUpdaterManager
export const ThemeModule = __turboModuleProxy(
'NativeThemeModule',
) as DiscordNativeModules.ThemeModule
export let BundleUpdaterManager: DiscordNativeModules.BundleUpdaterManager =
proxify(() => {
// Need try-catch here because if you access this before RN sets it up, it will throw an error
try {
const module =
nmp.BundleUpdaterManager as typeof BundleUpdaterManager
return (BundleUpdaterManager = module)
} catch {
return null
}
})!

View file

@ -1,12 +1,81 @@
// TODO(storage): Implement using NativeModules interop instead
import { FileModule } from '@revenge-mod/discord/native'
// This file is usually imported way too early, so we can't import our shim
import { React } from '@revenge-mod/react'
import { getErrorStack } from '@revenge-mod/utils/errors'
import { mergeDeep } from '@revenge-mod/utils/objects'
import { useReRender } from '@revenge-mod/utils/react'
import type { AnyObject, DeepPartial } from '@revenge-mod/utils/types'
export type StorageSubscription<T extends AnyObject = AnyObject> = (
v: DeepPartial<T>,
) => void
export function Storage<T extends AnyObject>(
this: Storage<T>,
path: string,
options?: StorageOptions<T>,
) {
const { CacheDirPath, DocumentsDirPath } = FileModule.getConstants()
const directory = options?.directory ?? 'documents'
const dirPath = directory === 'cache' ? CacheDirPath : DocumentsDirPath
const fullPath = `${dirPath}/${path}`
this._s = new Set<StorageSubscription<T>>()
this.loaded = false
this.cache = options?.default
this.exists = () => FileModule.fileExists(fullPath)
this.delete = () => FileModule.removeFile(directory, path)
this.get = async function () {
if (!(await this.exists())) {
this.cache = options?.default ?? {}
await this.set({})
this.loaded = true
return this.cache
}
const contents = await FileModule.readFile(fullPath, 'utf8')
if (contents) {
this.loaded = true
try {
const cache = (this.cache = JSON.parse(contents))
for (const sub of this._s) sub(cache)
return cache
} catch (e) {
nativeLoggingHook(
`Failed to parse storage (<${directory}>/${path}): ${getErrorStack(e)}`,
2,
)
}
}
}
this.set = async function (value) {
if (!this.cache) await this.get()
mergeDeep(this.cache!, value)
try {
const contents = JSON.stringify(this.cache)
await FileModule.writeFile(directory, path, contents, 'utf8')
for (const sub of this._s) sub(value)
} catch (e) {
nativeLoggingHook(
`Failed to write storage (<${directory}>/${path}): ${getErrorStack(e)}`,
2,
)
}
}
}
// React is only initialized right before the init stage, so this is a dummy method
// See init.ts for the actual implementation
Storage.prototype.use = () => {
throw new Error('Storage#use can only be called after the init stage!')
}
/**
* Get a storage object for a given path and directory.
*
@ -17,91 +86,8 @@ export function getStorage<T extends AnyObject = AnyObject>(
path: string,
options?: StorageOptions<T>,
): Storage<T> {
const { CacheDirPath, DocumentsDirPath } = FileModule.getConstants()
const directory = options?.directory ?? 'documents'
const dirPath = directory === 'cache' ? CacheDirPath : DocumentsDirPath
const fullPath = `${dirPath}/${path}`
type Subscription = (v: DeepPartial<T>) => void
const subs = new Set<Subscription>()
const storage: Storage<T> = {
loaded: false,
cache: options?.default,
use(filter) {
if (!this.cache) this.get()
const reRender = useReRender()
React.useEffect(() => {
const sub = (
filter
? v => {
if (filter(v)) reRender()
}
: reRender
) as Subscription
subs.add(sub)
return () => {
subs.delete(sub)
}
}, [])
return this.cache
},
exists() {
return FileModule.fileExists(fullPath)
},
delete() {
return FileModule.removeFile(directory, path)
},
async get() {
if (!(await this.exists())) {
this.cache = options?.default ?? {}
await this.set({})
this.loaded = true
return this.cache
}
const contents = await FileModule.readFile(fullPath, 'utf8')
if (contents) {
this.loaded = true
try {
const cache = (this.cache = JSON.parse(contents))
for (const sub of subs) sub(cache)
return cache
} catch (e) {
console.error(
'Failed to parse storage file (most likely corrupted)',
directory,
path,
e,
)
}
}
},
async set(value) {
if (!this.cache) await this.get()
mergeDeep(this.cache!, value)
try {
const contents = JSON.stringify(this.cache)
await FileModule.writeFile(directory, path, contents, 'utf8')
for (const sub of subs) sub(value)
} catch (e) {
console.error(
'Failed to write storage file',
directory,
path,
e,
)
}
},
}
const storage: Storage<T> = Object.create(Storage.prototype)
Storage.call(storage, path, options)
if (options?.load) storage.get()
@ -128,6 +114,10 @@ export type UseStorageFilter<T extends AnyObject> = (
) => any
export interface Storage<T extends AnyObject> {
/**
* @internal
*/
_s: Set<StorageSubscription<T>>
/**
* Whether the storage has been loaded. If the storage is not loaded, `storage.cache` may be `undefined`.
* If you have `options.default` set, you can use this property to check if `storage.cache` is the default value or not.
@ -141,6 +131,8 @@ export interface Storage<T extends AnyObject> {
/**
* Use the storage in a React component. The component will re-render when the storage is updated.
*
* This can only be used in the `init` stage or later, as it requires React to be initialized.
*
* @example
* ```tsx
* type Settings = { key: boolean, nested: { key: boolean } }

View file

@ -1,7 +1,34 @@
import { pEmitter } from '@revenge-mod/plugins/_'
import { PluginsStorageDirectory } from '@revenge-mod/plugins/constants'
import { defineLazyProperty } from '@revenge-mod/utils/objects'
import { getStorage } from '.'
import { useReRender } from '@revenge-mod/utils/react'
import { useEffect } from 'react'
import { getStorage, Storage } from '.'
import type { StorageSubscription } from '.'
// Actual implementation of Storage#use
const SP: Storage<any> = Storage.prototype
SP.use = function (filter) {
if (!this.cache) this.get()
const reRender = useReRender()
useEffect(() => {
const sub: StorageSubscription = filter
? v => {
if (filter(v)) reRender()
}
: reRender
this._s.add(sub)
return () => {
this._s.delete(sub)
}
}, [])
return this.cache
}
pEmitter.on('init', ({ _s: storageOptions, manifest: { id } }, api) =>
defineLazyProperty(api, 'storage', () =>

View file

@ -1,5 +1,4 @@
// @revenge-mod/storage depends on this file, and it is imported way too early, so we can't import our shim
import { React } from '@revenge-mod/react'
import { useEffect, useReducer } from 'react'
import { findInTree } from './trees'
import type { ReactNode } from 'react'
import type { SearchFilter } from './trees'
@ -7,7 +6,7 @@ import type { SearchFilter } from './trees'
export function useIsFirstRender() {
let firstRender = false
React.useEffect(() => {
useEffect(() => {
firstRender = true
}, [])
@ -15,7 +14,7 @@ export function useIsFirstRender() {
}
export function useReRender() {
const [, forceUpdate] = React.useReducer(x => ~x, 0)
const [, forceUpdate] = useReducer(x => ~x, 0)
return forceUpdate
}