From f5709c3112bc377ddddc8260e86b86f74a196696 Mon Sep 17 00:00:00 2001 From: littensy <56808540+littensy@users.noreply.github.com> Date: Thu, 2 Oct 2025 01:43:28 -0700 Subject: [PATCH] feat: prepare for alpha release --- .../charm-sync/src/__tests__/client.spec.luau | 12 +- .../charm-sync/src/__tests__/e2e.spec.luau | 6 +- packages/charm-sync/src/client.luau | 18 +- packages/charm-sync/src/index.d.ts | 189 +++++++++++++++++- packages/charm-sync/src/server.luau | 31 +-- packages/charm-sync/src/validate.luau | 4 +- .../charm/src/__tests__/untracked.spec.luau | 17 ++ packages/charm/src/index.d.ts | 12 +- packages/react-charm/src/init.luau | 4 +- packages/vide-charm/src/init.luau | 12 +- 10 files changed, 251 insertions(+), 54 deletions(-) diff --git a/packages/charm-sync/src/__tests__/client.spec.luau b/packages/charm-sync/src/__tests__/client.spec.luau index 33f3013..d761ca0 100644 --- a/packages/charm-sync/src/__tests__/client.spec.luau +++ b/packages/charm-sync/src/__tests__/client.spec.luau @@ -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() diff --git a/packages/charm-sync/src/__tests__/e2e.spec.luau b/packages/charm-sync/src/__tests__/e2e.spec.luau index e386e85..acc75e8 100644 --- a/packages/charm-sync/src/__tests__/e2e.spec.luau +++ b/packages/charm-sync/src/__tests__/e2e.spec.luau @@ -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 diff --git a/packages/charm-sync/src/client.luau b/packages/charm-sync/src/client.luau index 3beb209..3610056 100644 --- a/packages/charm-sync/src/client.luau +++ b/packages/charm-sync/src/client.luau @@ -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, } diff --git a/packages/charm-sync/src/index.d.ts b/packages/charm-sync/src/index.d.ts index cb0ff5c..ca736db 100644 --- a/packages/charm-sync/src/index.d.ts +++ b/packages/charm-sync/src/index.d.ts @@ -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 = undefined extends T ? None : never; + +type SelectorMap = Record any>; + +/** + * Infers the type of the return values produced by a map of functions. + */ +type StateOfMap = { + 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 = MaybeNone | State extends + | ReadonlyMap + | Map + ? ReadonlyMap | None> + : State extends Set | ReadonlySet + ? ReadonlyMap + : State extends readonly (infer T)[] + ? Serialize extends true + ? ReadonlyMap | None> + : readonly (SyncPatch | None | undefined)[] + : State extends DataType + ? State + : State extends object + ? { readonly [P in keyof State]?: SyncPatch } + : State; + +/** + * A payload that can be sent from the server to the client to synchronize + * state between the two. + */ +export type SyncPayload = + | { type: "init"; data: StateOfMap } + | { type: "patch"; data: SyncPatch, 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 }): void; + + export function unsyncAtoms(...keys: string[]): void; + + export function reset(): void; + + export function applyPayloads }, Serialize extends boolean>( + payloads: SyncPayload[], + ): void; +} + +export namespace server { + export function syncAtomsToClient(client: Player, atoms: { [key: string]: Atom }): void; + + export function unsyncAtomsFromClient(client: Player, ...keys: string[]): void; + + export function unsyncClient(client: Player): void; + + export function startSync }, Serialize extends boolean>( + onSync: (client: Player, payload: SyncPayload) => 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(value: T | undefined): T | None; + + export function diff( + currentState: State, + nextState: State, + ): SyncPatch; + + export function apply( + state: State, + patch: SyncPatch, + ): State; +} + +interface NestedAtomMap { + readonly [key: string]: NestedAtomMap | Atom; +} + +type AppendPath = undefined extends Path + ? Name + : Name extends string + ? Path extends string + ? `${Path}/${Name}` + : never + : never; + +type UnionToIntersection = (U extends any ? (k: U) => void : never) extends (k: infer I) => void ? I : never; + +type IntersectValues = UnionToIntersection<{ readonly [K in keyof T]: T[K] }[keyof T]>; + +type FlattenNestedAtoms = IntersectValues<{ + readonly [Name in keyof Atoms as AppendPath]: Atoms[Name] extends Atom + ? { readonly [K in AppendPath]: Atoms[Name] } + : Atoms[Name] extends NestedAtomMap + ? FlattenNestedAtoms> + : 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: Atoms): FlattenNestedAtoms; diff --git a/packages/charm-sync/src/server.luau b/packages/charm-sync/src/server.luau index 55f94b6..2d3789d 100644 --- a/packages/charm-sync/src/server.luau +++ b/packages/charm-sync/src/server.luau @@ -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 diff --git a/packages/charm-sync/src/validate.luau b/packages/charm-sync/src/validate.luau index 6d747ce..64d7caf 100644 --- a/packages/charm-sync/src/validate.luau +++ b/packages/charm-sync/src/validate.luau @@ -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 diff --git a/packages/charm/src/__tests__/untracked.spec.luau b/packages/charm/src/__tests__/untracked.spec.luau index 6d7fb55..b32fd8c 100644 --- a/packages/charm/src/__tests__/untracked.spec.luau +++ b/packages/charm/src/__tests__/untracked.spec.luau @@ -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 {} diff --git a/packages/charm/src/index.d.ts b/packages/charm/src/index.d.ts index 8be022f..533b052 100644 --- a/packages/charm/src/index.d.ts +++ b/packages/charm/src/index.d.ts @@ -21,7 +21,7 @@ export function atom(): Atom; export function computed(getter: (previousValue?: T) => T, equals?: Equals): () => 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( export function untracked(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; diff --git a/packages/react-charm/src/init.luau b/packages/react-charm/src/init.luau index b7598f0..c81d7dd 100644 --- a/packages/react-charm/src/init.luau +++ b/packages/react-charm/src/init.luau @@ -15,7 +15,6 @@ local function useAtom(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(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) diff --git a/packages/vide-charm/src/init.luau b/packages/vide-charm/src/init.luau index c6326dd..b2d27d8 100644 --- a/packages/vide-charm/src/init.luau +++ b/packages/vide-charm/src/init.luau @@ -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(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