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,6 +1,6 @@
{ {
"printWidth": 120, "printWidth": 120,
"tabWidth": 2, "tabWidth": 4,
"trailingComma": "all", "trailingComma": "all",
"useTabs": true "useTabs": true
} }

View file

@ -24,13 +24,13 @@ See an example of Charm's features in [this example repository](https://github.c
## 🍀 Features ## 🍀 Features
- ⚛️ **Manage state with _atoms_.** Decompose state into tiny, composable containers called _atoms_, as opposed to combining them into a single store. - ⚛️ **Manage state with _atoms_.** Decompose state into tiny, composable containers called _atoms_, as opposed to combining them into a single store.
- 💪 **Minimal, yet powerful.** Less boilerplate — write simple functions to read from and write to state. - 💪 **Minimal, yet powerful.** Less boilerplate — write simple functions to read from and write to state.
- 🔬 **Immediate updates.** Listeners run asynchronously by default, avoiding the cascading effects of deferred updates and improving responsiveness. - 🔬 **Immediate updates.** Listeners run asynchronously by default, avoiding the cascading effects of deferred updates and improving responsiveness.
- 🦄 **Like magic.** Selector functions can be subscribed to as-is — with implicit dependency tracking, atoms are captured and memoized for you. - 🦄 **Like magic.** Selector functions can be subscribed to as-is — with implicit dependency tracking, atoms are captured and memoized for you.
--- ---
@ -55,16 +55,16 @@ Charm = "littensy/charm@VERSION"
## 🐛 Debugging ## 🐛 Debugging
Charm provides a debug mode to help you identify potential bugs in your project. To enable debug mode, set the global `_G.__DEV__` flag to `true` at the entry point of your project. Charm provides a debug mode to help you identify potential bugs in your project. To enable debug mode, set the global `_G.__DEV__` flag to `true` before importing Charm.
Enabling `__DEV__` adds a few helpful features: Enabling `__DEV__` adds a few helpful features:
- Better error handling for selectors, subscriptions, and batched functions: - Better error handling for selectors, subscriptions, and batched functions:
- Errors provide the function's name and line number. - Errors provide the function's name and line number.
- Yielding in certain functions will throw an error. - Yielding in certain functions will throw an error.
- Server state is validated for [remote event limitations](https://create.roblox.com/docs/scripting/events/remote#argument-limitations) before being passed to the client. - Server state is validated for [remote event limitations](https://create.roblox.com/docs/scripting/events/remote#argument-limitations) before being passed to the client.
Enabling debug mode in unit tests, storybooks, and other development environments can help you catch potential issues early. However, remember to turn off debug mode in production to avoid the performance overhead. Enabling debug mode in unit tests, storybooks, and other development environments can help you catch potential issues early. However, remember to turn off debug mode in production to avoid the performance overhead.
@ -85,11 +85,11 @@ local todosAtom: Atom<{ string }> = atom({})
#### Parameters #### Parameters
- `state`: The value to assign to the atom initially. - `state`: The value to assign to the atom initially.
- **optional** `options`: An object that configures the behavior of this atom. - **optional** `options`: An object that configures the behavior of this atom.
- **optional** `equals`: An equality function to determine whether the state has changed. By default, strict equality (`==`) is used. - **optional** `equals`: An equality function to determine whether the state has changed. By default, strict equality (`==`) is used.
#### Returns #### Returns
@ -144,9 +144,9 @@ nameAtom("Jane") --> "JANE"
#### Parameters #### Parameters
- `callback`: The function to subscribe to. This may be an atom or a selector function that depends on an atom. - `callback`: The function to subscribe to. This may be an atom or a selector function that depends on an atom.
- `listener`: The listener is called when the result of the callback changes. It receives the new state and the previous state as arguments. - `listener`: The listener is called when the result of the callback changes. It receives the new state and the previous state as arguments.
#### Returns #### Returns
@ -173,7 +173,7 @@ Because `effect` implicitly tracks all atoms read within the callback, it might
#### Parameters #### Parameters
- `callback`: The function to track for state changes. The callback will run once to retrieve its dependencies, and then again whenever they change. - `callback`: The function to track for state changes. The callback will run once to retrieve its dependencies, and then again whenever they change.
#### Returns #### Returns
@ -202,9 +202,9 @@ This function is also useful for optimizing `effect` calls that depend on multip
#### Parameters #### Parameters
- `callback`: A function that returns a new value depending on one or more atoms. - `callback`: A function that returns a new value depending on one or more atoms.
- **optional** [`options`](#parameters): An object that configures the behavior of this atom. - **optional** [`options`](#parameters): An object that configures the behavior of this atom.
#### Returns #### Returns
@ -232,9 +232,9 @@ end)
#### Parameters #### Parameters
- `callback`: An atom or selector function that returns a dictionary or an array of values. When a key is added to the state, the factory will be called with the new key and its initial value. - `callback`: An atom or selector function that returns a dictionary or an array of values. When a key is added to the state, the factory will be called with the new key and its initial value.
- `factory`: A function that will be called whenever a key is added or removed from the atom's state. The callback will receive the key and the entry's initial value as arguments, and may return a cleanup function. - `factory`: A function that will be called whenever a key is added or removed from the atom's state. The callback will receive the key and the entry's initial value as arguments, and may return a cleanup function.
#### Returns #### Returns
@ -255,13 +255,13 @@ end)
#### Parameters #### Parameters
- `callback`: The function whose result you want to map over. This can be an atom or a selector function that reads from atoms. - `callback`: The function whose result you want to map over. This can be an atom or a selector function that reads from atoms.
- `mapper`: The mapper is called for each key in your state. Given the current value and key, it should return a new corresponding value and key: - `mapper`: The mapper is called for each key in your state. Given the current value and key, it should return a new corresponding value and key:
1. Return a single value to map the table's original key to a new value. 1. Return a single value to map the table's original key to a new value.
2. Return two values, the first being the value and the second being the key, to update both keys and values. 2. Return two values, the first being the value and the second being the key, to update both keys and values.
3. Return `nil` for the value to remove the key from the resulting table. 3. Return `nil` for the value to remove the key from the resulting table.
#### Returns #### Returns
@ -285,9 +285,9 @@ end)
#### Parameters #### Parameters
- `value`: Any value. If the value is a function, `peek` will call it without tracking dependencies and return the result. - `value`: Any value. If the value is a function, `peek` will call it without tracking dependencies and return the result.
- **optional** `...args`: Additional arguments to pass to the value if it is a function. - **optional** `...args`: Additional arguments to pass to the value if it is a function.
#### Returns #### Returns
@ -311,7 +311,7 @@ end)
#### Parameters #### Parameters
- `callback`: A function that updates atoms. Listeners will only be notified once all changes have been applied. - `callback`: A function that updates atoms. Listeners will only be notified once all changes have been applied.
#### Returns #### Returns
@ -361,9 +361,9 @@ end, { props.filter })
#### Parameters #### Parameters
- `callback`: An atom or selector function that depends on an atom. - `callback`: An atom or selector function that depends on an atom.
- **optional** `dependencies`: An array of outside values that the selector depends on. If the dependencies change, the subscription is re-created and the component re-renders with the new state. - **optional** `dependencies`: An array of outside values that the selector depends on. If the dependencies change, the subscription is re-created and the component re-renders with the new state.
#### Returns #### Returns
@ -405,7 +405,7 @@ end
#### Parameters #### Parameters
- `callback`: An atom or selector function that depends on an atom. - `callback`: An atom or selector function that depends on an atom.
#### Returns #### Returns
@ -441,6 +441,7 @@ local syncer = CharmSync.server({
atoms = atomsToSync, -- A dictionary of the atoms to sync, matching the client's atoms = atomsToSync, -- A dictionary of the atoms to sync, matching the client's
interval = 0, -- The minimum interval between state updates interval = 0, -- The minimum interval between state updates
preserveHistory = false, -- Whether to send a full history of changes made to the atoms (slower) preserveHistory = false, -- Whether to send a full history of changes made to the atoms (slower)
serializeArrays = true, -- Safety measures for arrays, should be false when using ByteNet or Zap
}) })
-- Sends state updates to clients when a synced atom changes. -- Sends state updates to clients when a synced atom changes.
@ -458,27 +459,31 @@ end)
#### Parameters #### Parameters
- `options`: An object to configure sync behavior. - `options`: An object to configure sync behavior.
- `atoms`: A dictionary of the atoms to sync. The keys should match the keys on the client. - `atoms`: A dictionary of the atoms to sync. The keys should match the keys on the client.
- **optional** `interval`: The interval at which to batch state updates to clients. Defaults to `0`, meaning updates are batched every frame. - **optional** `interval`: The interval at which to batch state updates to clients. Defaults to `0`, meaning updates are batched every frame.
- **optional** `preserveHistory`: Whether to sync an exhaustive history of changes made to the atoms since the last sync event. If `true`, the server sends multiple payloads instead of one. Defaults to `false` for performance. - **optional** `preserveHistory`: Whether to sync an exhaustive history of changes made to the atoms since the last sync event. If `true`, the server sends multiple payloads instead of one. Defaults to `false` for performance.
- **optional** `serializeArrays`: Whether to convert problematic arrays to dictionaries before firing remote events. Because arrays in state patches can have holes, extra work is required to send them over a remote event intact. Defaults to `true`, but should be `false` if payloads are serialized (i.e. if you use [ByteNet](https://github.com/ffrostfall/ByteNet) or [Zap](https://github.com/red-blox/zap)).
#### Returns #### Returns
`server` returns an object with the following methods: `server` returns an object with the following methods:
- `syncer:connect(callback)`: Registers a callback to send state updates to clients. The callback will receive the player and the payload(s) to send, and should fire a remote event. The payload is read-only, so any changes should be applied to a copy of the payload. - `syncer:connect(callback)`: Registers a callback to send state updates to clients. The callback will receive the player and the payload(s) to send, and should fire a remote event. The payload is read-only, so any changes should be applied to a copy of the payload.
- `syncer:hydrate(player)`: Sends the player a full state update for all synced atoms. - `syncer:hydrate(player)`: Sends the player a full state update for all synced atoms.
#### Caveats #### Caveats
- By default, Charm omits the individual changes made to atoms between sync events (i.e. a `counterAtom` set to `1` and then `2` will only send the final state of `2`). If you need to preserve a history of changes, set `preserveHistory` to `true`. - **Do not use values that cannot be sent over remotes** in your shared atoms. This includes functions, threads, and non-string keys in dictionaries.
- Charm does not handle network communication. You must implement your own network layer to send and receive state updates. This is implemented via the `remotes` namespace in the example above. - **By default, Charm omits the individual changes made to atoms** between sync events (i.e. a `counterAtom` set to `1` and then `2` will only send the final state of `2`). If you need to preserve a history of changes, set `preserveHistory` to `true`.
- **Charm does not handle network communication.** Use remote events or a network library to send sync payloads - and remember to set `serializeArrays` accordingly!
--- ---
@ -489,6 +494,7 @@ Call `client` to create a client sync object. This synchronizes the client's ato
```luau ```luau
local syncer = CharmSync.client({ local syncer = CharmSync.client({
atoms = atomsToSync, -- A dictionary of the atoms to sync, matching the server's atoms = atomsToSync, -- A dictionary of the atoms to sync, matching the server's
ignoreUnhydrated = true, -- Whether to ignore state updates before the initial update
}) })
-- Applies state updates from the server to the client's atoms. -- Applies state updates from the server to the client's atoms.
@ -504,19 +510,21 @@ remotes.requestState:fire()
#### Parameters #### Parameters
- `options`: An object to configure sync behavior. - `options`: An object to configure sync behavior.
- `atoms`: A dictionary of the atoms to sync. The keys should match the keys on the server. - `atoms`: A dictionary of the atoms to sync. The keys should match the keys on the server.
- **optional** `ignoreUnhydrated`: Whether to ignore state updates before setting the initial state. Defaults to `true`.
#### Returns #### Returns
`client` returns an object with the following methods: `client` returns an object with the following methods:
- `syncer:sync(...payloads)` applies a state update from the server. - `syncer:sync(...payloads)` applies a state update from the server.
#### Caveats #### Caveats
- The client sync object does not handle network communication. You must implement your own network layer to send and receive state updates. This includes requesting the initial state, which is implemented via `requestState` in the example above. - **Charm does not handle network communication.** Use remote events or a network library to receive sync payloads. This includes requesting the initial state, which is implemented via `requestState` in the example above.
--- ---
@ -543,7 +551,7 @@ end)
#### Parameters #### Parameters
- `value`: Any value. If the value is `None`, `isNone` will return `true`. - `value`: Any value. If the value is `None`, `isNone` will return `true`.
#### Returns #### Returns

View file

@ -23,7 +23,7 @@
"ServerScriptService": { "ServerScriptService": {
"$className": "ServerScriptService", "$className": "ServerScriptService",
"Tests": { "Tests": {
"$path": "Tests" "$path": "tests"
} }
} }
} }

View file

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

View file

@ -8,23 +8,22 @@ type Cleanup = () => void;
declare namespace CharmSync { declare namespace CharmSync {
type AtomMap = Record<string, Atom<any>>; type AtomMap = Record<string, Atom<any>>;
type SelectorMap = Record<string, Selector<any>>;
/** /**
* @deprecated Use `SelectorMap` instead. * @deprecated Use `SelectorMap` instead.
*/ */
type MoleculeMap = Record<string, Selector<any>>; type MoleculeMap = SelectorMap;
type SelectorMap = Record<string, Selector<any>>;
/** /**
* Infers the type of the return values produced by a map of functions. * Infers the type of the return values produced by a map of functions.
*/ */
type StateOfMap<T> = { 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 * Represents the removal of a value from the state.
* removed values in patches.
*/ */
interface None { interface None {
readonly __none: "__none"; readonly __none: "__none";
@ -96,8 +95,8 @@ declare namespace CharmSync {
* state between the two. * state between the two.
*/ */
type SyncPayload<Selectors extends SelectorMap> = type SyncPayload<Selectors extends SelectorMap> =
| { type: "init"; data: StateOfMap<Selectors> } | { readonly type: "init"; readonly data: StateOfMap<Selectors> }
| { type: "patch"; data: SyncPatch<StateOfMap<Selectors>> }; | { readonly type: "patch"; readonly data: SyncPatch<StateOfMap<Selectors>> };
interface ClientOptions<Atoms extends AtomMap> { interface ClientOptions<Atoms extends AtomMap> {
/** /**
@ -132,6 +131,15 @@ declare namespace CharmSync {
* to reconstruct the state's changes over time. * to reconstruct the state's changes over time.
*/ */
preserveHistory?: boolean; 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> { interface ClientSyncer<Atoms extends AtomMap> {

View file

@ -1,5 +1,7 @@
local validate = require(script.Parent.validate) local validate = require(script.Parent.validate)
local __DEV__ = _G.__DEV__
--[=[ --[=[
A special symbol that denotes the absence of a value. Used to represent A special symbol that denotes the absence of a value. Used to represent
deleted values in patches. deleted values in patches.
@ -37,7 +39,16 @@ local function stringifySparseArray(object: { any }): { [string | number]: any }
return dictionary return dictionary
end 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) local patches = table.clone(nextState)
for key, previous in next, prevState do for key, previous in next, prevState do
@ -48,16 +59,16 @@ local function diff(prevState: { [any]: any }, nextState: { [any]: any })
elseif current == nil then elseif current == nil then
patches[key] = None patches[key] = None
elseif type(previous) == "table" and type(current) == "table" then elseif type(previous) == "table" and type(current) == "table" then
patches[key] = diff(previous, current) patches[key] = diff(previous, current, serializeArrays)
end end
end end
-- Coerce sparse array patches into dictionaries -- 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) patches = stringifySparseArray(patches)
end end
if _G.__DEV__ then if __DEV__ then
for key, value in next, prevState do for key, value in next, prevState do
validate(value, key) validate(value, key)
end end
@ -72,6 +83,13 @@ local function diff(prevState: { [any]: any }, nextState: { [any]: any })
return patches return patches
end 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 local function apply(state: any, patches: any): any
if type(patches) == "table" and patches.__none == "__none" then if type(patches) == "table" and patches.__none == "__none" then
return nil return nil
@ -97,7 +115,6 @@ local function apply(state: any, patches: any): any
end end
return { return {
NONE = None,
isNone = isNone, isNone = isNone,
diff = diff, diff = diff,
apply = apply, apply = apply,

View file

@ -10,7 +10,7 @@ type ServerOptions = {
--[=[ --[=[
The atoms to synchronize with the client. 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. 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 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`. per frame, where each individual change is important. Defaults to `false`.
]=] ]=]
preserveHistory: boolean?, 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 = { type ServerSyncer = {
@ -63,7 +72,7 @@ local function server(options: ServerOptions): ServerSyncer
local sync: (player: Player, payload: SyncPayload) -> () local sync: (player: Player, payload: SyncPayload) -> ()
local function createSnapshot() local function createSnapshot()
local snapshot: { [string]: any } = {} local snapshot = {}
for key, atom in next, options.atoms do for key, atom in next, options.atoms do
snapshot[key] = atom() snapshot[key] = atom()
@ -72,10 +81,20 @@ local function server(options: ServerOptions): ServerSyncer
return snapshot return snapshot
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()")
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 if options.preserveHistory then
-- Preserve a history of every state change to allow for more granular local snapshots = { createSnapshot() }
-- syncing with the client.
local snapshots: { { [string]: any } } = {}
local changed = false local changed = false
function self:connect(callback) function self:connect(callback)
@ -83,7 +102,7 @@ local function server(options: ServerOptions): ServerSyncer
sync = callback 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 lastSnapshot = snapshots[#snapshots]
local previousSnapshot = snapshots[#snapshots - 1] local previousSnapshot = snapshots[#snapshots - 1]
@ -108,8 +127,6 @@ local function server(options: ServerOptions): ServerSyncer
end) end)
end end
snapshots[1] = createSnapshot()
local disconnect = interval(function() local disconnect = interval(function()
if not changed then if not changed then
return return
@ -127,7 +144,7 @@ local function server(options: ServerOptions): ServerSyncer
table.insert(payloads, { table.insert(payloads, {
type = "patch", type = "patch",
data = patch.diff(snapshots[index - 1], snapshot), data = patch.diff(snapshots[index - 1], snapshot, options.serializeArrays),
}) })
end end
@ -164,7 +181,7 @@ local function server(options: ServerOptions): ServerSyncer
table.insert(payloads, { table.insert(payloads, {
type = "patch", type = "patch",
data = patch.diff(snapshots[index - 1], snapshot), data = patch.diff(snapshots[index - 1], snapshot, options.serializeArrays),
}) })
end end
@ -173,79 +190,70 @@ local function server(options: ServerOptions): ServerSyncer
sync(player, unpack(payloads)) sync(player, unpack(payloads))
end 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) return self
local subscriptions = {} 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. function self:connect(callback)
for key, atom in next, options.atoms do local subscriptions = {}
subscriptions[key] = Charm.subscribe(atom, function(state)
currentSnapshot[key] = state
changed = true
end)
end
local disconnect = interval(function() sync = callback
if not changed then
return
end
local payload: SyncPayload = { -- Subscribe to each atom and update the state when one changes.
type = "patch", for key, atom in next, options.atoms do
data = patch.diff(previousSnapshot, currentSnapshot), subscriptions[key] = Charm.subscribe(atom, function(state)
} currentSnapshot[key] = state
changed = true
previousSnapshot = table.clone(currentSnapshot) end)
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
end end
function self:_sendPatch(player: Player) local disconnect = interval(function()
assert(sync, "connect() must be called before _sendPatch()")
if not changed then if not changed then
return return
end end
local payload: SyncPayload = { local payload: SyncPayload = {
type = "patch", type = "patch",
data = patch.diff(previousSnapshot, currentSnapshot), data = patch.diff(previousSnapshot, currentSnapshot, options.serializeArrays),
} }
previousSnapshot = table.clone(currentSnapshot) previousSnapshot = table.clone(currentSnapshot)
changed = false 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
end end
-- Send the initial state to a player when they join the server. function self:_sendPatch(player)
function self:hydrate(player) assert(sync, "connect() must be called before _sendPatch()")
assert(sync, "connect() must be called before hydrate()")
sync(player, { if not changed then
type = "init", return
data = createSnapshot(), end
})
local payload: SyncPayload = {
type = "patch",
data = patch.diff(previousSnapshot, currentSnapshot, options.serializeArrays),
}
previousSnapshot = table.clone(currentSnapshot)
changed = false
sync(player, payload)
end end
return self return self

View file

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

View file

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

View file

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

View file

@ -10,24 +10,13 @@ type AnyMap<K, V> =
declare namespace Charm { declare namespace Charm {
/** /**
* A primitive state container that can be read from and written to. When the * A primitive state container that can be read from or written to. When
* state changes, all subscribers are notified. * 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. * @param state The next state or a function that produces the next state.
* @returns The current state, if no arguments are provided. * @returns The current state, if no arguments are provided.
*/ */
interface Atom<State> extends Selector<State> { type Atom<State> = (state?: State | ((prev: State) => State)) => 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;
}
/** /**
* A function that depends on one or more atoms and produces a value. Can be * 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 Set<T> = types.Set<T>
type WeakMap<K, V> = types.WeakMap<K, V> type WeakMap<K, V> = types.WeakMap<K, V>
local __DEV__ = _G.__DEV__
local listeners: WeakMap<Atom<any>, WeakMap<() -> (), unknown>> = setmetatable({}, { __mode = "k" }) local listeners: WeakMap<Atom<any>, WeakMap<() -> (), unknown>> = setmetatable({}, { __mode = "k" })
local batched: Set<() -> ()> = {} local batched: Set<() -> ()> = {}
@ -24,7 +26,7 @@ local capturing = {
@return The result of the callback. @return The result of the callback.
]=] ]=]
local function try<T, U...>(callback: (U...) -> T | any, finally: (() -> ())?, ...: U...): T 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 thread = coroutine.create(callback)
local success, result = coroutine.resume(thread, ...) 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. @param callback The atom or selector to subscribe to.
@return The reactive source. @return The reactive source.
]=] ]=]
local function useAtom<State>(callback: () -> State): State local function useAtom<State>(callback: () -> State): () -> State
local state = Vide.source(callback()) local state = Vide.source(callback())
local unsubscribe = Charm.subscribe(callback, function(value) local unsubscribe = Charm.subscribe(callback, function(value)
task.spawn(state, value) task.spawn(function()
state(value)
end)
end) end)
Vide.cleanup(unsubscribe) Vide.cleanup(unsubscribe)

View file

@ -1,19 +0,0 @@
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local store = require(ReplicatedStorage.DevPackages.Charm.store)
return function()
beforeAll(function()
_G.__DEV__ = true
end)
afterAll(function()
_G.__DEV__ = nil
end)
afterEach(function()
table.clear(store.listeners :: any)
table.clear(store.capturing.stack)
store.capturing.index = 0
end)
end

View file

@ -98,4 +98,14 @@ return function()
expect(applied["2"]).to.be.ok() expect(applied["2"]).to.be.ok()
expect(applied[2]).to.never.be.ok() expect(applied[2]).to.never.be.ok()
end) end)
it("optionally fixes sparse arrays", function()
for index in next, patch.diff({ 1, 1, 1, 1, 1 }, { 2, 1, 1, 2 }) do
expect(index).to.be.a("string")
end
for index in next, patch.diff({ 1, 1, 1, 1, 1 }, { 2, 1, 1, 2 }, false) do
expect(index).to.be.a("number")
end
end)
end end

View file

@ -49,7 +49,7 @@ return function()
server:_sendPatch(target) server:_sendPatch(target)
expect(payload.data.a.b).to.never.be.ok() expect(payload.data.a.b).to.never.be.ok()
expect(payload.data.a.c).to.equal(patch.NONE) expect(patch.isNone(payload.data.a.c)).to.equal(true)
expect(payload.data.a.d).to.equal(1) expect(payload.data.a.d).to.equal(1)
disconnect() disconnect()

View file

@ -1,5 +1,12 @@
--!nocheck
local ReplicatedStorage = game:GetService("ReplicatedStorage") local ReplicatedStorage = game:GetService("ReplicatedStorage")
local TestEZ = require(ReplicatedStorage.DevPackages.TestEZ) local TestEZ = require(ReplicatedStorage.DevPackages.TestEZ)
-- Start Charm in debug mode
_G.__DEV__ = true
require(ReplicatedStorage.DevPackages.Charm)
_G.__DEV__ = false
TestEZ.TestBootstrap:run({ script.Parent }, TestEZ.Reporters.TextReporter) TestEZ.TestBootstrap:run({ script.Parent }, TestEZ.Reporters.TextReporter)

View file

@ -3,14 +3,6 @@ local ReplicatedStorage = game:GetService("ReplicatedStorage")
local store = require(ReplicatedStorage.DevPackages.Charm.store) local store = require(ReplicatedStorage.DevPackages.Charm.store)
return function() return function()
beforeAll(function()
_G.__DEV__ = true
end)
afterAll(function()
_G.__DEV__ = nil
end)
afterEach(function() afterEach(function()
table.clear(store.listeners :: any) table.clear(store.listeners :: any)
table.clear(store.capturing.stack) table.clear(store.capturing.stack)