mirror of
https://github.com/littensy/charm.git
synced 2026-10-21 19:54:09 +00:00
feat: prepare for alpha release
This commit is contained in:
parent
5a05f01223
commit
f5709c3112
10 changed files with 251 additions and 54 deletions
|
|
@ -8,13 +8,13 @@ local test = suite.test
|
|||
local afterEach = suite.afterEach
|
||||
|
||||
afterEach(function()
|
||||
client.clearAtoms()
|
||||
client.reset()
|
||||
end)
|
||||
|
||||
test("should sync state payload", function()
|
||||
local a = atom(1)
|
||||
|
||||
client.registerAtoms({ a = a })
|
||||
client.syncAtoms({ a = a })
|
||||
client.applyPayloads({
|
||||
{ type = "init", data = { a = 2 } },
|
||||
})
|
||||
|
|
@ -26,7 +26,7 @@ test("should sync state patch", function()
|
|||
local a = atom({ b = 1, c = 2 })
|
||||
local previous = a()
|
||||
|
||||
client.registerAtoms({ a = a })
|
||||
client.syncAtoms({ a = a })
|
||||
client.applyPayloads({
|
||||
{ type = "init", data = { a = a() } },
|
||||
})
|
||||
|
|
@ -45,7 +45,7 @@ end)
|
|||
test("should fix sparse array patches", function()
|
||||
local a = atom({ 1 })
|
||||
|
||||
client.registerAtoms({ a = a })
|
||||
client.syncAtoms({ a = a })
|
||||
client.applyPayloads({
|
||||
{ type = "init", data = { a = a() } },
|
||||
})
|
||||
|
|
@ -65,7 +65,7 @@ test("should apply multiple patches", function()
|
|||
local a = atom(1)
|
||||
local calls = {}
|
||||
|
||||
client.registerAtoms({ a = a })
|
||||
client.syncAtoms({ a = a })
|
||||
|
||||
subscribe(a, function(value)
|
||||
table.insert(calls, value)
|
||||
|
|
@ -89,7 +89,7 @@ test("should batch patches", function()
|
|||
local b = atom(0)
|
||||
local states = {}
|
||||
|
||||
client.registerAtoms({ a = a, b = b })
|
||||
client.syncAtoms({ a = a, b = b })
|
||||
|
||||
subscribe(a, function()
|
||||
states.b = b()
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ local test = suite.test
|
|||
|
||||
afterEach(function()
|
||||
server.reset()
|
||||
client.clearAtoms()
|
||||
client.reset()
|
||||
config.preserveHistory = false
|
||||
end)
|
||||
|
||||
|
|
@ -20,7 +20,7 @@ test("should replicate server state to client", function()
|
|||
local clientA = Charm.atom({ count = -1 })
|
||||
local clientB = Charm.atom({ count = -1 })
|
||||
|
||||
client.registerAtoms({ a = clientA, b = clientB })
|
||||
client.syncAtoms({ a = clientA, b = clientB })
|
||||
server.syncAtomsToClient(target, { a = serverA, b = serverB })
|
||||
server.startSync(function(syncTarget, payloads)
|
||||
if syncTarget == target then
|
||||
|
|
@ -54,7 +54,7 @@ test("should replicate historical server state to client", function()
|
|||
|
||||
config.preserveHistory = true
|
||||
|
||||
client.registerAtoms({ a = clientA, b = clientB })
|
||||
client.syncAtoms({ a = clientA, b = clientB })
|
||||
server.syncAtomsToClient(target, { a = serverA, b = serverB })
|
||||
server.startSync(function(syncTarget, payloads)
|
||||
if syncTarget == target then
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ type SyncPayload = types.SyncPayload
|
|||
|
||||
local atomRegister: { [string]: Charm.Atom } = {}
|
||||
|
||||
local function registerAtoms(atoms: { [string]: Charm.Atom })
|
||||
local function syncAtoms(atoms: { [string]: Charm.Atom })
|
||||
for key, atom in atoms do
|
||||
if atomRegister[key] and atomRegister[key] ~= atom then
|
||||
warn("[Charm] Attempted to sync different atoms with the same key:", key)
|
||||
|
|
@ -17,17 +17,17 @@ local function registerAtoms(atoms: { [string]: Charm.Atom })
|
|||
end
|
||||
end
|
||||
|
||||
local function clearAtoms()
|
||||
table.clear(atomRegister)
|
||||
end
|
||||
|
||||
local function unregisterAtoms(...: string)
|
||||
local function unsyncAtoms(...: string)
|
||||
for index = 1, select("#", ...) do
|
||||
local key = select(index, ...)
|
||||
atomRegister[key] = nil
|
||||
end
|
||||
end
|
||||
|
||||
local function reset()
|
||||
table.clear(atomRegister)
|
||||
end
|
||||
|
||||
local function applyInitPayload(data: { [string]: any })
|
||||
for key, value in data do
|
||||
local atom = atomRegister[key]
|
||||
|
|
@ -62,8 +62,8 @@ local function applyPayloads(payloads: { SyncPayload })
|
|||
end
|
||||
|
||||
return {
|
||||
registerAtoms = registerAtoms,
|
||||
clearAtoms = clearAtoms,
|
||||
unregisterAtoms = unregisterAtoms,
|
||||
syncAtoms = syncAtoms,
|
||||
unsyncAtoms = unsyncAtoms,
|
||||
reset = reset,
|
||||
applyPayloads = applyPayloads,
|
||||
}
|
||||
|
|
|
|||
189
packages/charm-sync/src/index.d.ts
vendored
189
packages/charm-sync/src/index.d.ts
vendored
|
|
@ -1 +1,188 @@
|
|||
export {};
|
||||
import { Atom } from "@rbxts/charm";
|
||||
|
||||
type Cleanup = () => void;
|
||||
|
||||
type Key = string | number | symbol;
|
||||
|
||||
/**
|
||||
* Represents the removal of a value from the state.
|
||||
*/
|
||||
export interface None {
|
||||
readonly __none: "__none";
|
||||
}
|
||||
|
||||
export type MaybeNone<T> = undefined extends T ? None : never;
|
||||
|
||||
type SelectorMap = Record<string, () => any>;
|
||||
|
||||
/**
|
||||
* Infers the type of the return values produced by a map of functions.
|
||||
*/
|
||||
type StateOfMap<T> = {
|
||||
readonly [P in keyof T]: T[P] extends () => infer State ? State : never;
|
||||
};
|
||||
|
||||
type DataTypes = {
|
||||
[P in keyof CheckableTypes as P extends keyof CheckablePrimitives ? never : P]: CheckableTypes[P];
|
||||
};
|
||||
|
||||
/**
|
||||
* A type that should not be made partial in patches.
|
||||
*/
|
||||
type DataType = DataTypes[keyof DataTypes];
|
||||
|
||||
/**
|
||||
* A partial patch that can be applied to the state to update it. Represents
|
||||
* the difference between the current state and the next state.
|
||||
*
|
||||
* If a value was removed, it is replaced with `None`. This can be checked
|
||||
* using the `isNone` function.
|
||||
*/
|
||||
export type SyncPatch<State, Serialize extends boolean = true> = MaybeNone<State> | State extends
|
||||
| ReadonlyMap<infer K, infer V>
|
||||
| Map<infer K, infer V>
|
||||
? ReadonlyMap<K, SyncPatch<V, Serialize> | None>
|
||||
: State extends Set<infer T> | ReadonlySet<infer T>
|
||||
? ReadonlyMap<T, true | None>
|
||||
: State extends readonly (infer T)[]
|
||||
? Serialize extends true
|
||||
? ReadonlyMap<string | number, SyncPatch<T, Serialize> | None>
|
||||
: readonly (SyncPatch<T, Serialize> | None | undefined)[]
|
||||
: State extends DataType
|
||||
? State
|
||||
: State extends object
|
||||
? { readonly [P in keyof State]?: SyncPatch<State[P], Serialize> }
|
||||
: State;
|
||||
|
||||
/**
|
||||
* A payload that can be sent from the server to the client to synchronize
|
||||
* state between the two.
|
||||
*/
|
||||
export type SyncPayload<Selectors extends SelectorMap = SelectorMap, Serialize extends boolean = true> =
|
||||
| { type: "init"; data: StateOfMap<Selectors> }
|
||||
| { type: "patch"; data: SyncPatch<StateOfMap<Selectors>, Serialize> };
|
||||
|
||||
/**
|
||||
* Checks whether a value is `None`. If `true`, the value is scheduled to be
|
||||
* removed from the state when the patch is applied.
|
||||
*
|
||||
* @param value The value to check.
|
||||
* @returns `true` if the value is `None`, otherwise `false`.
|
||||
*/
|
||||
export function isNone(value: unknown): value is None;
|
||||
|
||||
export const config: {
|
||||
/**
|
||||
* The interval at which to send patches to the client, in seconds.
|
||||
* Defaults to `0` (patches are sent up to once per frame). Set to a
|
||||
* negative value to disable automatic syncing.
|
||||
*
|
||||
* @server
|
||||
*/
|
||||
interval: number;
|
||||
/**
|
||||
* Whether the history of state changes since the client's last update
|
||||
* should be preserved. This is useful for values that change multiple
|
||||
* times per frame, where each individual change is important. Defaults
|
||||
* to `false`.
|
||||
*
|
||||
* @server
|
||||
*/
|
||||
preserveHistory: boolean;
|
||||
/**
|
||||
* When `true`, Charm will apply validation and serialize unsafe arrays
|
||||
* to address remote event argument limitations. Defaults to `true`.
|
||||
* This option should be disabled if your network library uses a custom
|
||||
* serialization method (i.e. Zap, ByteNet) to prevent interference.
|
||||
*
|
||||
* @server
|
||||
*/
|
||||
autoSerialize: boolean;
|
||||
/**
|
||||
* When `true`, patches will be validated before being sent to clients to
|
||||
* enforce remote event limitations. Only checked in strict mode and if
|
||||
* `autoSerialize` is enabled. Defaults to `true`.
|
||||
*
|
||||
* @server
|
||||
*/
|
||||
validatePatches: boolean;
|
||||
};
|
||||
|
||||
export namespace client {
|
||||
export function syncAtoms(atoms: { [key: string]: Atom<any> }): void;
|
||||
|
||||
export function unsyncAtoms(...keys: string[]): void;
|
||||
|
||||
export function reset(): void;
|
||||
|
||||
export function applyPayloads<Atoms extends { [key: string]: Atom<any> }, Serialize extends boolean>(
|
||||
payloads: SyncPayload<Atoms, Serialize>[],
|
||||
): void;
|
||||
}
|
||||
|
||||
export namespace server {
|
||||
export function syncAtomsToClient(client: Player, atoms: { [key: string]: Atom<any> }): void;
|
||||
|
||||
export function unsyncAtomsFromClient(client: Player, ...keys: string[]): void;
|
||||
|
||||
export function unsyncClient(client: Player): void;
|
||||
|
||||
export function startSync<Atoms extends { [key: string]: Atom<any> }, Serialize extends boolean>(
|
||||
onSync: (client: Player, payload: SyncPayload<Atoms, Serialize>) => void,
|
||||
): Cleanup;
|
||||
|
||||
export function stopSync(): void;
|
||||
|
||||
export function flush(): void;
|
||||
|
||||
export function reset(): void;
|
||||
}
|
||||
|
||||
export namespace patch {
|
||||
export function isNone(value: unknown): value is None;
|
||||
|
||||
export function nilToNone<T>(value: T | undefined): T | None;
|
||||
|
||||
export function diff<State, Serialize extends boolean = true>(
|
||||
currentState: State,
|
||||
nextState: State,
|
||||
): SyncPatch<State, Serialize>;
|
||||
|
||||
export function apply<State, Serialize extends boolean = true>(
|
||||
state: State,
|
||||
patch: SyncPatch<State, Serialize>,
|
||||
): State;
|
||||
}
|
||||
|
||||
interface NestedAtomMap {
|
||||
readonly [key: string]: NestedAtomMap | Atom<any>;
|
||||
}
|
||||
|
||||
type AppendPath<Path extends Key | undefined, Name extends Key> = undefined extends Path
|
||||
? Name
|
||||
: Name extends string
|
||||
? Path extends string
|
||||
? `${Path}/${Name}`
|
||||
: never
|
||||
: never;
|
||||
|
||||
type UnionToIntersection<U> = (U extends any ? (k: U) => void : never) extends (k: infer I) => void ? I : never;
|
||||
|
||||
type IntersectValues<T> = UnionToIntersection<{ readonly [K in keyof T]: T[K] }[keyof T]>;
|
||||
|
||||
type FlattenNestedAtoms<Atoms extends NestedAtomMap, Path extends Key | undefined = undefined> = IntersectValues<{
|
||||
readonly [Name in keyof Atoms as AppendPath<Path, Name>]: Atoms[Name] extends Atom<any>
|
||||
? { readonly [K in AppendPath<Path, Name>]: Atoms[Name] }
|
||||
: Atoms[Name] extends NestedAtomMap
|
||||
? FlattenNestedAtoms<Atoms[Name], AppendPath<Path, Name>>
|
||||
: never;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* Flattens a nested atom map into a single object with slash-separated keys.
|
||||
* Useful for recursively collecting atoms returned by modules.
|
||||
*
|
||||
* @param atoms The nested atom map to flatten.
|
||||
* @return A flattened atom map.
|
||||
*/
|
||||
export function flatten<Atoms extends NestedAtomMap>(atoms: Atoms): FlattenNestedAtoms<Atoms>;
|
||||
|
|
|
|||
|
|
@ -287,20 +287,27 @@ local function startSync(onSync: (client: any, payloads: { SyncPayload }) -> ())
|
|||
|
||||
onSyncCallback = onSync
|
||||
|
||||
if game then
|
||||
local elapsedTime = 0
|
||||
|
||||
connection = game:GetService("RunService").Heartbeat:Connect(function(deltaTime)
|
||||
elapsedTime += deltaTime
|
||||
if elapsedTime >= config.interval then
|
||||
elapsedTime %= config.interval
|
||||
debug.profilebegin("CharmSync")
|
||||
flush()
|
||||
debug.profileend()
|
||||
end
|
||||
end)
|
||||
if not game then
|
||||
return stopSync
|
||||
end
|
||||
|
||||
local timer = 0
|
||||
|
||||
connection = game:GetService("RunService").Heartbeat:Connect(function(deltaTime)
|
||||
if config.interval < 0 then
|
||||
return
|
||||
end
|
||||
|
||||
timer += deltaTime
|
||||
|
||||
if timer >= config.interval then
|
||||
timer %= config.interval
|
||||
debug.profilebegin("CharmSync")
|
||||
flush()
|
||||
debug.profileend()
|
||||
end
|
||||
end)
|
||||
|
||||
return stopSync
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -41,9 +41,7 @@ local function validate(value: any, key: any)
|
|||
error(`Invalid key type '{typeKey}' at key '{key}'`)
|
||||
elseif UNSAFE_VALUES[typeValue] then
|
||||
error(`Invalid value type '{typeValue}' at key '{key}'`)
|
||||
end
|
||||
|
||||
if typeValue == "table" then
|
||||
elseif typeValue == "table" then
|
||||
if getmetatable(value) ~= nil then
|
||||
error(`Cannot sync tables with metatables! Got {value} at key '{key}'`)
|
||||
elseif isTableUnsafe(value) then
|
||||
|
|
|
|||
|
|
@ -127,4 +127,21 @@ test("should track outside untracked", function()
|
|||
assert(spyCalls == 3, "should not run on c change")
|
||||
end)
|
||||
|
||||
test("should stack untracked", function()
|
||||
local a = atom(1)
|
||||
local spyCalls = 0
|
||||
|
||||
effect(function()
|
||||
untracked(function()
|
||||
untracked(a)
|
||||
return a()
|
||||
end)
|
||||
spyCalls += 1
|
||||
end)
|
||||
|
||||
assert(spyCalls == 1, "should be called once")
|
||||
a(2)
|
||||
assert(spyCalls == 1, "should not be called twice")
|
||||
end)
|
||||
|
||||
return {}
|
||||
|
|
|
|||
12
packages/charm/src/index.d.ts
vendored
12
packages/charm/src/index.d.ts
vendored
|
|
@ -21,7 +21,7 @@ export function atom<T>(): Atom<T | undefined>;
|
|||
|
||||
export function computed<T>(getter: (previousValue?: T) => T, equals?: Equals<T>): () => T;
|
||||
|
||||
export function effect(callback: () => Cleanup | void): Cleanup;
|
||||
export function effect(callback: (dispose: Cleanup) => Cleanup | void): Cleanup;
|
||||
|
||||
export function effectScope(callback: () => Cleanup | void, detached?: boolean): Cleanup;
|
||||
|
||||
|
|
@ -65,13 +65,3 @@ export function batched<Args extends any[], Result>(
|
|||
export function untracked<Args extends any[], Result>(callback: (...args: Args) => Result, ...args: Args): Result;
|
||||
|
||||
export function onCleanup(callback: Cleanup, failSilently?: boolean): void;
|
||||
|
||||
/**
|
||||
* @deprecated Use `batched()` instead.
|
||||
*/
|
||||
export const batch: typeof batched;
|
||||
|
||||
/**
|
||||
* @deprecated Use `untracked()` instead.
|
||||
*/
|
||||
export const peek: typeof untracked;
|
||||
|
|
|
|||
|
|
@ -15,7 +15,6 @@ local function useAtom<T>(getter: () -> T, dependencies: { any }?): T
|
|||
local state, setState = React.useState(getter)
|
||||
|
||||
React.useLayoutEffect(function()
|
||||
setState(getter())
|
||||
return Charm.effect(function()
|
||||
setState(getter())
|
||||
end)
|
||||
|
|
@ -38,8 +37,7 @@ local function useAtomBinding<T>(getter: () -> T, dependencies: { any }?)
|
|||
local initialState = React.useMemo(getter, {})
|
||||
local binding, setBinding = React.useBinding(initialState)
|
||||
|
||||
React.useEffect(function()
|
||||
setBinding(getter())
|
||||
React.useLayoutEffect(function()
|
||||
return Charm.effect(function()
|
||||
setBinding(getter())
|
||||
end)
|
||||
|
|
|
|||
|
|
@ -1,18 +1,18 @@
|
|||
local Charm = require("./Charm")
|
||||
local Vide = require("./Vide")
|
||||
|
||||
local subscribe = Charm.subscribe
|
||||
local source = Vide.source
|
||||
local cleanup = Vide.cleanup
|
||||
|
||||
--- Returns a reactive source that updates when the given getter function's
|
||||
--- value changes.
|
||||
---
|
||||
--- @param getter The atom or selector to subscribe to.
|
||||
--- @return The reactive source.
|
||||
local function useAtom<State>(getter: () -> State): () -> State
|
||||
local state = source(getter())
|
||||
cleanup(subscribe(getter, state))
|
||||
local state = Vide.source(getter())
|
||||
|
||||
Vide.cleanup(Charm.effect(function()
|
||||
state(getter())
|
||||
end))
|
||||
|
||||
return state
|
||||
end
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue