feat: add serializeArrays option (#29)

* feat: add optional `convertArrays`

* perf: sync atomic changes to keys

* feat: add `serializeArrays` option

* docs: rephrase debug mode flag
This commit is contained in:
richard 2024-11-17 01:22:07 -08:00 • committed by GitHub
parent e4019c049c
commit aae769c981
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 230 additions and 205 deletions

View file

@ -1,3 +0,0 @@
{
"languageMode": "strict"
}

View file

@ -7,7 +7,7 @@ type ClientOptions = {
--[=[
The atoms to synchronize with the server.
]=]
atoms: { [string]: Charm.Atom<any> },
atoms: { [string | number]: Charm.Atom<any> },
--[=[
Whether to ignore patches sent before the client has been hydrated.
Default is `true`.
@ -33,33 +33,44 @@ type ClientSyncer = {
@return A `ClientSyncer` object.
]=]
local function client(options: ClientOptions): ClientSyncer
local self = {} :: ClientSyncer
local atoms = options.atoms
local ignoreUnhydrated = options.ignoreUnhydrated ~= false
local self = {} :: ClientSyncer
local hydrated = false
-- Apply the state changes sent by the server.
local function hydrate(state: { [string | number]: any })
hydrated = true
for key, value in next, state do
local atom = atoms[key]
atom(value)
end
end
local function apply(data: { [string | number]: any })
local target = {}
for key, atom in next, atoms do
target[key] = atom()
end
target = patch.apply(target, data)
for key, atom in next, atoms do
atom(target[key])
end
end
function self:sync(...)
for index = 1, select("#", ...) do
local payload: SyncPayload = select(index, ...)
if ignoreUnhydrated and payload.type == "patch" and not hydrated then
-- If the client is not initialized, ignore patches. This is
-- to prevent patching incomplete state.
continue
end
hydrated = true
Charm.batch(function()
for key, state in next, payload.data do
local atom = atoms[key]
if payload.type == "patch" then
atom(patch.apply(atom(), state))
else
atom(state)
end
if payload.type == "init" then
hydrate(payload.data)
elseif not ignoreUnhydrated or hydrated then
apply(payload.data)
end
end)
end

View file

@ -8,23 +8,22 @@ type Cleanup = () => void;
declare namespace CharmSync {
type AtomMap = Record<string, Atom<any>>;
type SelectorMap = Record<string, Selector<any>>;
/**
* @deprecated Use `SelectorMap` instead.
*/
type MoleculeMap = Record<string, Selector<any>>;
type SelectorMap = Record<string, Selector<any>>;
type MoleculeMap = SelectorMap;
/**
* Infers the type of the return values produced by a map of functions.
*/
type StateOfMap<T> = {
[P in keyof T]: T[P] extends Selector<infer State> ? State : never;
readonly [P in keyof T]: T[P] extends Selector<infer State> ? State : never;
};
/**
* A special value that denotes the absence of a value. Used to represent
* removed values in patches.
* Represents the removal of a value from the state.
*/
interface None {
readonly __none: "__none";
@ -96,8 +95,8 @@ declare namespace CharmSync {
* state between the two.
*/
type SyncPayload<Selectors extends SelectorMap> =
| { type: "init"; data: StateOfMap<Selectors> }
| { type: "patch"; data: SyncPatch<StateOfMap<Selectors>> };
| { readonly type: "init"; readonly data: StateOfMap<Selectors> }
| { readonly type: "patch"; readonly data: SyncPatch<StateOfMap<Selectors>> };
interface ClientOptions<Atoms extends AtomMap> {
/**
@ -132,6 +131,15 @@ declare namespace CharmSync {
* to reconstruct the state's changes over time.
*/
preserveHistory?: boolean;
/**
* When `true`, convert problematic sparse arrays into dictionaries
* with string keys. Defaults to `true`.
*
* While this is mandatory for safely syncing arrays over vanilla Roblox
* remotes, it can be disabled if your network library uses a custom
* serialization method (i.e. Zap, ByteNet).
*/
serializeArrays?: boolean;
}
interface ClientSyncer<Atoms extends AtomMap> {

View file

@ -1,5 +1,7 @@
local validate = require(script.Parent.validate)
local __DEV__ = _G.__DEV__
--[=[
A special symbol that denotes the absence of a value. Used to represent
deleted values in patches.
@ -37,7 +39,16 @@ local function stringifySparseArray(object: { any }): { [string | number]: any }
return dictionary
end
local function diff(prevState: { [any]: any }, nextState: { [any]: any })
--[=[
Compares two states and returns a patch that can be applied to the previous
state to produce the next state.
@param prevState The previous state.
@param nextState The next state.
@param serializeArrays Whether to convert sparse arrays into dictionaries. Defaults to `true`.
@returns A patch that can be applied to the previous state.
]=]
local function diff(prevState: { [any]: any }, nextState: { [any]: any }, serializeArrays: boolean?)
local patches = table.clone(nextState)
for key, previous in next, prevState do
@ -48,16 +59,16 @@ local function diff(prevState: { [any]: any }, nextState: { [any]: any })
elseif current == nil then
patches[key] = None
elseif type(previous) == "table" and type(current) == "table" then
patches[key] = diff(previous, current)
patches[key] = diff(previous, current, serializeArrays)
end
end
-- Coerce sparse array patches into dictionaries
if prevState[1] ~= nil or nextState[1] ~= nil then
if serializeArrays ~= false and (prevState[1] ~= nil or nextState[1] ~= nil) then
patches = stringifySparseArray(patches)
end
if _G.__DEV__ then
if __DEV__ then
for key, value in next, prevState do
validate(value, key)
end
@ -72,6 +83,13 @@ local function diff(prevState: { [any]: any }, nextState: { [any]: any })
return patches
end
--[=[
Applies a patch to a state and returns the next state.
@param state The current state.
@param patches The patches to apply.
@returns The next state.
]=]
local function apply(state: any, patches: any): any
if type(patches) == "table" and patches.__none == "__none" then
return nil
@ -97,7 +115,6 @@ local function apply(state: any, patches: any): any
end
return {
NONE = None,
isNone = isNone,
diff = diff,
apply = apply,

View file

@ -10,7 +10,7 @@ type ServerOptions = {
--[=[
The atoms to synchronize with the client.
]=]
atoms: { [string]: Charm.Atom<any> },
atoms: { [string | number]: Charm.Atom<any> },
--[=[
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
@ -23,6 +23,15 @@ type ServerOptions = {
per frame, where each individual change is important. Defaults to `false`.
]=]
preserveHistory: boolean?,
--[=[
Whether to convert problematic sparse arrays into dictionaries with
string keys. Defaults to `true`.
While this is mandatory for safely syncing arrays over vanilla Roblox
remotes, it can be disabled if your network library uses a custom
serialization method (i.e. Zap, ByteNet).
]=]
serializeArrays: boolean?,
}
type ServerSyncer = {
@ -63,7 +72,7 @@ local function server(options: ServerOptions): ServerSyncer
local sync: (player: Player, payload: SyncPayload) -> ()
local function createSnapshot()
local snapshot: { [string]: any } = {}
local snapshot = {}
for key, atom in next, options.atoms do
snapshot[key] = atom()
@ -72,10 +81,20 @@ local function server(options: ServerOptions): ServerSyncer
return snapshot
end
-- Send the initial state to a player when they join the server.
function self:hydrate(player)
assert(sync, "connect() must be called before hydrate()")
sync(player, {
type = "init",
data = createSnapshot(),
})
end
-- If the history of state changes should be preserved, keep a list of
-- previous snapshots and diff-check each one from the last.
if options.preserveHistory then
-- Preserve a history of every state change to allow for more granular
-- syncing with the client.
local snapshots: { { [string]: any } } = {}
local snapshots = { createSnapshot() }
local changed = false
function self:connect(callback)
@ -83,7 +102,7 @@ local function server(options: ServerOptions): ServerSyncer
sync = callback
local function pushSnapshot(key: string, current: any, previous: any)
local function pushSnapshot(key: string | number, current: any, previous: any)
local lastSnapshot = snapshots[#snapshots]
local previousSnapshot = snapshots[#snapshots - 1]
@ -108,8 +127,6 @@ local function server(options: ServerOptions): ServerSyncer
end)
end
snapshots[1] = createSnapshot()
local disconnect = interval(function()
if not changed then
return
@ -127,7 +144,7 @@ local function server(options: ServerOptions): ServerSyncer
table.insert(payloads, {
type = "patch",
data = patch.diff(snapshots[index - 1], snapshot),
data = patch.diff(snapshots[index - 1], snapshot, options.serializeArrays),
})
end
@ -164,7 +181,7 @@ local function server(options: ServerOptions): ServerSyncer
table.insert(payloads, {
type = "patch",
data = patch.diff(snapshots[index - 1], snapshot),
data = patch.diff(snapshots[index - 1], snapshot, options.serializeArrays),
})
end
@ -173,79 +190,70 @@ local function server(options: ServerOptions): ServerSyncer
sync(player, unpack(payloads))
end
else
-- Only send the most recent state to the client.
local previousSnapshot = createSnapshot()
local currentSnapshot = table.clone(previousSnapshot)
local changed = false
function self:connect(callback)
local subscriptions = {}
return self
end
sync = callback
local previousSnapshot = createSnapshot()
local currentSnapshot = table.clone(previousSnapshot)
local changed = false
-- Subscribe to each atom and update the state when one changes.
for key, atom in next, options.atoms do
subscriptions[key] = Charm.subscribe(atom, function(state)
currentSnapshot[key] = state
changed = true
end)
end
function self:connect(callback)
local subscriptions = {}
local disconnect = interval(function()
if not changed then
return
end
sync = callback
local payload: SyncPayload = {
type = "patch",
data = patch.diff(previousSnapshot, currentSnapshot),
}
previousSnapshot = table.clone(currentSnapshot)
changed = false
for _, player in next, Players:GetPlayers() do
callback(player, payload)
end
end, options.interval or 0)
return function()
disconnect()
for _, unsubscribe in next, subscriptions do
unsubscribe()
end
end
-- Subscribe to each atom and update the state when one changes.
for key, atom in next, options.atoms do
subscriptions[key] = Charm.subscribe(atom, function(state)
currentSnapshot[key] = state
changed = true
end)
end
function self:_sendPatch(player: Player)
assert(sync, "connect() must be called before _sendPatch()")
local disconnect = interval(function()
if not changed then
return
end
local payload: SyncPayload = {
type = "patch",
data = patch.diff(previousSnapshot, currentSnapshot),
data = patch.diff(previousSnapshot, currentSnapshot, options.serializeArrays),
}
previousSnapshot = table.clone(currentSnapshot)
changed = false
sync(player, payload)
for _, player in next, Players:GetPlayers() do
callback(player, payload)
end
end, options.interval or 0)
return function()
disconnect()
for _, unsubscribe in next, subscriptions do
unsubscribe()
end
end
end
-- Send the initial state to a player when they join the server.
function self:hydrate(player)
assert(sync, "connect() must be called before hydrate()")
function self:_sendPatch(player)
assert(sync, "connect() must be called before _sendPatch()")
sync(player, {
type = "init",
data = createSnapshot(),
})
if not changed then
return
end
local payload: SyncPayload = {
type = "patch",
data = patch.diff(previousSnapshot, currentSnapshot, options.serializeArrays),
}
previousSnapshot = table.clone(currentSnapshot)
changed = false
sync(player, payload)
end
return self

View file

@ -3,8 +3,11 @@
state between the two.
]=]
export type SyncPayload = {
type: "patch" | "init",
data: { [any]: any },
type: "init",
data: { [string | number]: any },
} | {
type: "patch",
data: { [string | number]: any },
}
return nil

View file

@ -1,9 +1,7 @@
local Charm = require(script.Parent.Parent.Charm)
local SAFE_KEYS = { string = true, number = true }
local SAFE_VALUES = { string = true, number = true, boolean = true, table = true, userdata = true }
local SAFE_VALUES = { string = true, number = true, boolean = true, table = true, userdata = true, vector = true }
local function isUnsafeTable(object: { [any]: any })
local function isTableUnsafe(object: { [any]: any })
local keyType = nil
local objectSize = 0
@ -47,9 +45,7 @@ local function validate(value: any, key: any)
elseif typeOfValue == "table" then
if getmetatable(value) ~= nil then
error(`Cannot sync tables with metatables! Got {value} at key '{key}'`)
elseif Charm.isAtom(value) then
error(`Cannot sync nested atoms! Got atom at key '{key}'`)
elseif isUnsafeTable(value) then
elseif isTableUnsafe(value) then
error(
`Cannot sync tables unsupported by remote events! The value has the key '{key}'.\n\n`
.. "This can be for the following reasons:\n"
@ -69,7 +65,7 @@ local function validate(value: any, key: any)
elseif typeOfKey == "number" then
if key == math.huge or key == -math.huge then
error("Cannot sync infinity as key")
elseif math.floor(key) ~= key then
elseif key ~= math.floor(key) then
error("Cannot sync non-integer number as key")
end
end

View file

@ -1,3 +0,0 @@
{
"languageMode": "strict"
}

View file

@ -10,24 +10,13 @@ type AnyMap<K, V> =
declare namespace Charm {
/**
* A primitive state container that can be read from and written to. When the
* state changes, all subscribers are notified.
* A primitive state container that can be read from or written to. When
* the state changes, all subscribers are notified.
*
* @template State The type of the state.
* @param state The next state or a function that produces the next state.
* @returns The current state, if no arguments are provided.
*/
interface Atom<State> extends Selector<State> {
/**
* @deprecated This property is not meant to be accessed directly.
*/
readonly __nominal: unique symbol;
/**
* @param state The next state or a function that produces the next state.
* @returns The current state, if no arguments are provided.
*/
(state?: State | ((prev: State) => State)): State;
}
type Atom<State> = (state?: State | ((prev: State) => State)) => State;
/**
* A function that depends on one or more atoms and produces a value. Can be

View file

@ -3,6 +3,8 @@ type Atom<T> = types.Atom<T>
type Set<T> = types.Set<T>
type WeakMap<K, V> = types.WeakMap<K, V>
local __DEV__ = _G.__DEV__
local listeners: WeakMap<Atom<any>, WeakMap<() -> (), unknown>> = setmetatable({}, { __mode = "k" })
local batched: Set<() -> ()> = {}
@ -24,7 +26,7 @@ local capturing = {
@return The result of the callback.
]=]
local function try<T, U...>(callback: (U...) -> T | any, finally: (() -> ())?, ...: U...): T
if _G.__DEV__ then
if __DEV__ then
local thread = coroutine.create(callback)
local success, result = coroutine.resume(thread, ...)

View file

@ -1,3 +0,0 @@
{
"languageMode": "strict"
}

View file

@ -1,3 +0,0 @@
{
"languageMode": "strict"
}

View file

@ -9,10 +9,12 @@ local Vide = if script.Parent:FindFirstChild("vide")
@param callback The atom or selector to subscribe to.
@return The reactive source.
]=]
local function useAtom<State>(callback: () -> State): State
local function useAtom<State>(callback: () -> State): () -> State
local state = Vide.source(callback())
local unsubscribe = Charm.subscribe(callback, function(value)
task.spawn(state, value)
task.spawn(function()
state(value)
end)
end)
Vide.cleanup(unsubscribe)