From 8c17adf15b784514a28efbe3404fa13283b09968 Mon Sep 17 00:00:00 2001 From: Hornwitser Date: Tue, 12 Dec 2023 23:05:40 +0100 Subject: [PATCH] Refactor EventSubscriber to track its resource Process updates of the resource EventSubscriber are subscribed to and have it keep a mapping of id to value syncronised with event provider as long as there are subscription callbacks registered with it. The mapping is kept around after the last callback is unsubscribed so that it can be efficiently resynced with the resource based on the updatedAt timestaps of individual elements. This allows the EventSubscriber to provide the right building blocks to provide its values to React components via the the useSyncExternalStore hook. This in turn greatly simplifies the web_ui models for the subscribed resources. --- .eslintrc.js | 5 +- packages/lib/src/data/ModPack.ts | 2 +- packages/lib/src/data/User.ts | 5 + packages/lib/src/subscriptions.ts | 113 +++++++++---- .../src/components/AssignInstanceModal.tsx | 6 +- .../web_ui/src/components/HostViewPage.tsx | 24 ++- packages/web_ui/src/components/HostsPage.tsx | 6 +- .../web_ui/src/components/InstanceList.tsx | 35 ++-- .../src/components/InstanceViewPage.tsx | 28 ++-- .../web_ui/src/components/InstancesPage.tsx | 10 +- .../src/components/LoadScenarioModal.tsx | 5 +- .../web_ui/src/components/ModPackViewPage.tsx | 49 +++--- packages/web_ui/src/components/ModsPage.tsx | 12 +- .../web_ui/src/components/PluginExtra.tsx | 12 +- .../web_ui/src/components/RoleViewPage.tsx | 44 +++-- packages/web_ui/src/components/SavesList.tsx | 28 ++-- .../components/StartStopInstanceButton.tsx | 11 +- .../web_ui/src/components/UserViewPage.tsx | 28 ++-- packages/web_ui/src/components/UsersPage.tsx | 6 +- packages/web_ui/src/model/host.ts | 95 +---------- packages/web_ui/src/model/instance.ts | 94 +---------- packages/web_ui/src/model/mod_pack.ts | 96 +---------- packages/web_ui/src/model/mods.ts | 56 ++----- packages/web_ui/src/model/saves.ts | 65 ++------ packages/web_ui/src/model/user.tsx | 108 ++----------- packages/web_ui/src/util/websocket.ts | 29 +--- test/lib/subscriptions.js | 151 +++++++++++++----- 27 files changed, 410 insertions(+), 713 deletions(-) diff --git a/.eslintrc.js b/.eslintrc.js index b316b1ed..0764886b 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -350,8 +350,9 @@ module.exports = { "node/exports-style": "off", "node/file-extension-in-import": "off", "node/global-require": "off", - "node/handle-callback-err": "error", - "node/no-callback-literal": "error", + // Node.js callback passing style of (err, value) is not used in this project + "node/handle-callback-err": "off", + "node/no-callback-literal": "off", "node/no-deprecated-api": "error", "node/no-exports-assign": "error", "node/no-extraneous-import": "off", diff --git a/packages/lib/src/data/ModPack.ts b/packages/lib/src/data/ModPack.ts index e413cf32..f5761137 100644 --- a/packages/lib/src/data/ModPack.ts +++ b/packages/lib/src/data/ModPack.ts @@ -58,7 +58,7 @@ export default class ModPack { /** * Id of this mod pack */ - id?: number; + id: number = Math.random() * 2**31 | 0; /** * Name of this mod pack. diff --git a/packages/lib/src/data/User.ts b/packages/lib/src/data/User.ts index 604194e8..42a99a34 100644 --- a/packages/lib/src/data/User.ts +++ b/packages/lib/src/data/User.ts @@ -143,6 +143,11 @@ export default class User { } return playerStats; } + + /** Name of the user, used by event subscriptions */ + get id() { + return this.name; + } } export interface IControllerUser extends User { diff --git a/packages/lib/src/subscriptions.ts b/packages/lib/src/subscriptions.ts index 0cd427cc..abf237cd 100644 --- a/packages/lib/src/subscriptions.ts +++ b/packages/lib/src/subscriptions.ts @@ -4,7 +4,7 @@ import { logger } from "./logging"; import { Address, MessageRequest, IControllerUser } from "./data"; export type SubscriptionRequestHandler = RequestHandler | null>; -export type EventSubscriberCallback = (value: T) => void; +export type EventSubscriberCallback = (updates: T[], synced: boolean) => void /** * A subscription request sent by a subscriber, this updates what events the subscriber will be sent @@ -150,68 +150,118 @@ export class SubscriptionController { } } +export interface SubscribableValue { + id: number | string, + updatedAt: number, + isDeleted: boolean, +} + +export interface EventSubscribable extends Event { + updates: V[], +} + /** - * A class component to allow subscribing and unsubscribing to/from an event + * Component for subscribing to and tracking updates of a remote resource * Multiple handlers can be subscribed at the same time */ -export class EventSubscriber, V=T> { +export class EventSubscriber< + T extends EventSubscribable, + K extends string | number = T["updates"][number]["id"], + V extends SubscribableValue = T["updates"][number], +> { _callbacks = new Array>(); - lastResponse: Event | null = null; + /** Values of the subscribed resource */ + values = new Map(); + /** True if this subscriber is currently synced with the source */ + synced = false; + _snapshot?: readonly [ReadonlyMap>, boolean]; + _snapshotLastUpdated = 0; lastResponseTime = -1; constructor( private Event: EventClass, public control: Link, - private prehandler?: (e: T) => V, ) { control.handle(Event, this._handle.bind(this)); control.connector.on("connect", () => { this._updateSubscription(); }); + control.connector.on("close", () => { + if (this.synced) { + this.synced = false; + for (let callback of this._callbacks) { + callback([], this.synced); + } + } + }); } /** * Handle incoming events and distribute it to the correct callbacks - * @param response - event from subscribed resource + * @param event - event from subscribed resource * @internal */ - async _handle(response: Event) { - this.lastResponse = response; - this.lastResponseTime = Date.now(); - const value = this.prehandler ? this.prehandler(response as T) : response as any; + async _handle(event: EventSubscribable) { + for (const value of event.updates) { + this.lastResponseTime = Math.max(this.lastResponseTime, value.updatedAt); + if (value.isDeleted) { + this.values.delete(value.id as K); + } else { + this.values.set(value.id as K, value); + } + } for (let callback of this._callbacks) { - callback(value); + callback(event.updates, this.synced); } } /** * Subscribe to receive all event notifications * @param handler - - * callback invoked whenever the subscribed resource changes. + * callback invoked whenever the subscribed resource changes or the + * synced property changes, in which case the updates will be empty. + * @returns function that will unsubscribe from notifications */ - async subscribe(handler: EventSubscriberCallback) { + subscribe(handler: EventSubscriberCallback) { this._callbacks.push(handler); - await this._updateSubscription(); + if (this._callbacks.length === 1) { + this._updateSubscription(); + } + return () => { + // During a page transition the components currently rendered + // are unmounted and then the components for the new page is + // mounted. This means that if a resource is used by both pages + // it is first unsubscribed by the unmounted component causing + // the callbacks count to go to zero and a subscription update + // to be sent, and then subscribed by the mounted component + // causing another subscription update to be sent. + + // By delaying the unsubscription here the subscription happens + // before the unsubscription, thus preventing the redundant + // updates from being sent out. + setImmediate(() => { + let index = this._callbacks.lastIndexOf(handler); + if (index === -1) { + return; + } + this._callbacks.splice(index, 1); + if (this._callbacks.length === 0) { + this._updateSubscription(); + } + }); + }; } /** - * Unsubscribe from receiving all event notifications, does not effect - * the subscriptions of other handlers. - * - * @param handler - A callback previously passed to {@link subscribe} - * - * @throws {Error} - * If your handler is not subscribed, does not accept anonymous - * functions + * Obtain a snapshot of the current state of the tracked resource + * @returns tuple of values map snapshot and synced property. */ - async unsubscribe(handler: EventSubscriberCallback) { - let index = this._callbacks.lastIndexOf(handler); - if (index === -1) { - throw new Error("handler is not registered"); + getSnapshot() { + if (this._snapshotLastUpdated !== this.lastResponseTime) { + this._snapshotLastUpdated = this.lastResponseTime; + this._snapshot = [new Map(this.values), this.synced]; } - - this._callbacks.splice(index, 1); - await this._updateSubscription(); + return this._snapshot!; } /** @@ -229,6 +279,11 @@ export class EventSubscriber, V=T> { this._callbacks.length > 0, this.lastResponseTime )); + this.synced = this._callbacks.length > 0; + this._snapshotLastUpdated = 0; + for (let callback of this._callbacks) { + callback([], this.synced); + } } catch (err: any) { logger.error( `Unexpected error updating ${entry.name} subscription:\n${err.stack}` diff --git a/packages/web_ui/src/components/AssignInstanceModal.tsx b/packages/web_ui/src/components/AssignInstanceModal.tsx index b033469f..f6525522 100644 --- a/packages/web_ui/src/components/AssignInstanceModal.tsx +++ b/packages/web_ui/src/components/AssignInstanceModal.tsx @@ -6,7 +6,7 @@ import * as lib from "@clusterio/lib"; import ControlContext from "./ControlContext"; import { notifyErrorHandler } from "../util/notify"; -import { useHostList } from "../model/host"; +import { useHosts } from "../model/host"; const { Paragraph } = Typography; @@ -20,7 +20,7 @@ type AssignInstanceModalProps = { }; export default function AssignInstanceModal(props: AssignInstanceModalProps) { let [open, setOpen] = useState(false); - let [hostList] = useHostList(); + let [hosts] = useHosts(); let [applying, setApplying] = useState(false); let [form] = Form.useForm(); let control = useContext(ControlContext); @@ -80,7 +80,7 @@ export default function AssignInstanceModal(props: AssignInstanceModalProps) { Unassigned - {hostList.map((host) => instance.assignedHost === hostId)); - instanceList = instanceList.filter(instance => instance.assignedHost === hostId); + let nav = [{ name: "Hosts", path: "/hosts" }, { name: host?.name ?? String(hostId) }]; + if (!host) { + if (!synced) { + return ; + } - let nav = [{ name: "Hosts", path: "/hosts" }, { name: host.name || String(hostId) }]; - if (host.loading) { - return ; - } - - if (host.missing) { return

Host with id {hostId} was not found on the controller.

@@ -83,7 +81,7 @@ export default function HostViewPage() { {account.hasPermission("core.instance.list") && <> Instances - + } {account.hasPermission("core.log.follow") && <> Console diff --git a/packages/web_ui/src/components/HostsPage.tsx b/packages/web_ui/src/components/HostsPage.tsx index 5d59c4af..cbec5dc2 100644 --- a/packages/web_ui/src/components/HostsPage.tsx +++ b/packages/web_ui/src/components/HostsPage.tsx @@ -10,7 +10,7 @@ import ControlContext from "./ControlContext"; import PageHeader from "./PageHeader"; import PageLayout from "./PageLayout"; import PluginExtra from "./PluginExtra"; -import { useHostList } from "../model/host"; +import { useHosts } from "../model/host"; import notify, { notifyErrorHandler } from "../util/notify"; const strcmp = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" }).compare; @@ -184,7 +184,7 @@ function CopyButton({ text, message }: { text:string, message:string }) { export default function HostsPage() { let account = useAccount(); let navigate = useNavigate(); - let [hostList] = useHostList(); + let [hosts] = useHosts(); return Number(a.connected) - Number(b.connected), }, ]} - dataSource={hostList} + dataSource={[...hosts.values()]} rowKey={host => host.id} pagination={false} onRow={(record, rowIndex) => ({ diff --git a/packages/web_ui/src/components/InstanceList.tsx b/packages/web_ui/src/components/InstanceList.tsx index feec3335..09868175 100644 --- a/packages/web_ui/src/components/InstanceList.tsx +++ b/packages/web_ui/src/components/InstanceList.tsx @@ -6,7 +6,7 @@ import type { SizeType } from "antd/es/config-provider/SizeContext"; import type { ColumnsType } from "antd/es/table"; import { useAccount } from "../model/account"; -import { useHostList } from "../model/host"; +import { useHosts } from "../model/host"; import InstanceStatusTag from "./InstanceStatusTag"; import StartStopInstanceButton from "./StartStopInstanceButton"; import { InstanceDetails } from "@clusterio/lib"; @@ -14,7 +14,7 @@ import { InstanceDetails } from "@clusterio/lib"; const strcmp = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" }).compare; type InstanceListProps = { - instances: InstanceDetails[]; + instances: ReadonlyMap>; size?: SizeType; hideAssignedHost?: boolean; }; @@ -22,28 +22,27 @@ type InstanceListProps = { export default function InstanceList(props: InstanceListProps) { let account = useAccount(); let navigate = useNavigate(); - let [hostList] = useHostList(); + let [hosts] = useHosts(); - function hostName(hostId?: number|null) { - if (hostId === null ||hostId === undefined) { + function hostName(hostId?: number) { + if (hostId === undefined) { return ""; } - let host = hostList.find(s => s.id === hostId); - if (host) { - return host.name; - } - return String(hostId); + return hosts.get(hostId)?.name ?? String(hostId); } function instancePublicAddress(instance: InstanceDetails) { - let host = hostList.find(s => s.id === instance.assignedHost); - if (host && host.publicAddress) { - if (instance.gamePort) { - return `${host.publicAddress}:${instance.gamePort}`; - } + if (instance.assignedHost === undefined) { + return ""; + } + let host = hosts.get(instance.assignedHost); + if (!host || !host.publicAddress) { + return ""; + } + if (instance.gamePort === undefined) { return host.publicAddress; } - return null; + return `${host.publicAddress}:${instance.gamePort}`; } let columns: ColumnsType = [ @@ -78,7 +77,7 @@ export default function InstanceList(props: InstanceListProps) { /> : ""; }, - sorter: (a, b) => strcmp(instancePublicAddress(a)||"", instancePublicAddress(b)||""), + sorter: (a, b) => strcmp(instancePublicAddress(a), instancePublicAddress(b)), responsive: ["lg"], }, { @@ -109,7 +108,7 @@ export default function InstanceList(props: InstanceListProps) { return instance["id"]} pagination={false} onRow={(record, rowIndex) => ({ diff --git a/packages/web_ui/src/components/InstanceViewPage.tsx b/packages/web_ui/src/components/InstanceViewPage.tsx index e8c72614..13258761 100644 --- a/packages/web_ui/src/components/InstanceViewPage.tsx +++ b/packages/web_ui/src/components/InstanceViewPage.tsx @@ -20,15 +20,15 @@ import StartStopInstanceButton from "./StartStopInstanceButton"; import LoadScenarioModal from "./LoadScenarioModal"; import SavesList from "./SavesList"; import { notifyErrorHandler } from "../util/notify"; -import { InstanceState, useInstance } from "../model/instance"; -import { HostState, useHost } from "../model/host"; +import { useInstance } from "../model/instance"; +import { useHost } from "../model/host"; import InstanceStatusTag from "./InstanceStatusTag"; const { Title } = Typography; type InstanceDescriptionProps = { - host: HostState; - instance: InstanceState; + host?: Readonly; + instance: Readonly; }; function InstanceDescription(props: InstanceDescriptionProps) { let account = useAccount(); @@ -42,7 +42,7 @@ function InstanceDescription(props: InstanceDescriptionProps) { {!assigned ? Unassigned - : host.name || instance.assignedHost + : host?.name ?? instance.assignedHost } {account.hasPermission("core.instance.assign") && ; } -function InstanceButtons(props: { instance: InstanceState }) { +function InstanceButtons(props: { instance: lib.InstanceDetails }) { let account = useAccount(); let control = useContext(ControlContext); let navigate = useNavigate(); @@ -169,18 +169,18 @@ export default function InstanceViewPage() { let navigate = useNavigate(); let account = useAccount(); - let [instance] = useInstance(instanceId); - let [host] = useHost(Number(instance.assignedHost)); + let [instance, synced] = useInstance(instanceId); + let [host] = useHost(instance?.assignedHost); - let nav = [{ name: "Instances", path: "/instances" }, { name: instance.name || "Unknown" }]; - if (instance.loading) { - return ; - } + let nav = [{ name: "Instances", path: "/instances" }, { name: instance?.name ?? String(instanceId) }]; + if (!instance) { + if (!synced) { + return ; + } - if (instance.missing || instance.status === "deleted") { return Instance with id {instanceId} was not found on the controller.} type="warning" diff --git a/packages/web_ui/src/components/InstancesPage.tsx b/packages/web_ui/src/components/InstancesPage.tsx index eba88f67..351d6fad 100644 --- a/packages/web_ui/src/components/InstancesPage.tsx +++ b/packages/web_ui/src/components/InstancesPage.tsx @@ -9,7 +9,7 @@ import ControlContext from "./ControlContext"; import PageHeader from "./PageHeader"; import PageLayout from "./PageLayout"; import PluginExtra from "./PluginExtra"; -import { useInstanceList } from "../model/instance"; +import { useInstances } from "../model/instance"; import InstanceList from "./InstanceList"; import { notifyErrorHandler } from "../util/notify"; @@ -62,7 +62,7 @@ function CreateInstanceButton() { export default function InstancesPage() { let control = useContext(ControlContext); let account = useAccount(); - let [instanceList] = useInstanceList(); + let [instances] = useInstances(); return {account.hasPermission("core.instance.create") && } {account.hasPermission("core.instance.start") - && } {account.hasPermission("core.instance.stop") - && } {account.hasPermission("core.role.delete") && @@ -153,17 +153,17 @@ function TransferModal(props: ModalProps) { (option?.title.toLowerCase().indexOf(input.toLowerCase())??-1) >= 0 )} > - {instanceList.filter( + {[...instances.values()].filter( instance => instance["id"] !== props.instanceId ).map((instance) => - {instance["name"]} - {instance["status"] === "unassigned" && " (unassigned)"} - {instance["status"] === "unknown" && " (offline)"} + {instance.name} + {instance.status === "unassigned" && " (unassigned)"} + {instance.status === "unknown" && " (offline)"} )} @@ -198,10 +198,10 @@ type File = { status: string; } -export default function SavesList(props: { instance: InstanceState }) { +export default function SavesList(props: { instance: lib.InstanceDetails }) { let account = useAccount(); let control = useContext(ControlContext); - let saves = useSaves(props.instance.id); + let [saves] = useSavesOfInstance(props.instance.id); let [starting, setStarting] = useState(false); let [uploadingFiles, setUploadingFiles] = useState([]); @@ -234,7 +234,7 @@ export default function SavesList(props: { instance: InstanceState }) { defaultSortOrder: "descend", }, ]} - dataSource={saves} + dataSource={[...saves.values()]} rowKey={save => save.name} pagination={false} expandable={{ diff --git a/packages/web_ui/src/components/StartStopInstanceButton.tsx b/packages/web_ui/src/components/StartStopInstanceButton.tsx index 2920721a..2948675a 100644 --- a/packages/web_ui/src/components/StartStopInstanceButton.tsx +++ b/packages/web_ui/src/components/StartStopInstanceButton.tsx @@ -5,11 +5,10 @@ import * as lib from "@clusterio/lib"; import ControlContext from "./ControlContext"; import { notifyErrorHandler } from "../util/notify"; -import type { InstanceState } from "../model/instance"; import { ButtonProps } from "antd/es/button"; type StartStopInstanceButtonProps = { - instance: InstanceState; + instance: lib.InstanceDetails; onFinish?: () => void; buttonProps?: ButtonProps; }; @@ -23,15 +22,15 @@ export default function StartStopInstanceButton(props: StartStopInstanceButtonPr let action; if (props.instance.status === "stopped") { action = control.sendTo( - { instanceId: props.instance.id! }, + { instanceId: props.instance.id }, new lib.InstanceStartRequest(undefined), ).catch( notifyErrorHandler("Error starting instance") ); - } else if (["starting", "running"].includes(props.instance.status!)) { + } else if (["starting", "running"].includes(props.instance.status)) { action = control.sendTo( - { instanceId: props.instance.id! }, + { instanceId: props.instance.id }, new lib.InstanceStopRequest(), ).catch( notifyErrorHandler("Error stopping instance") @@ -54,7 +53,7 @@ export default function StartStopInstanceButton(props: StartStopInstanceButtonPr {...(props.buttonProps || {})} loading={switching} type="primary" - disabled={!["starting", "running", "stopped"].includes(props.instance.status!)} + disabled={!["starting", "running", "stopped"].includes(props.instance.status)} onClick={onClick} > {props.instance.status === "stopped" ? "Start" : "Stop"} diff --git a/packages/web_ui/src/components/UserViewPage.tsx b/packages/web_ui/src/components/UserViewPage.tsx index 5d41a4ac..07620da2 100644 --- a/packages/web_ui/src/components/UserViewPage.tsx +++ b/packages/web_ui/src/components/UserViewPage.tsx @@ -9,7 +9,7 @@ import DeleteOutlined from "@ant-design/icons/DeleteOutlined"; import * as lib from "@clusterio/lib"; import { useAccount } from "../model/account"; -import { useInstanceList } from "../model/instance"; +import { useInstances } from "../model/instance"; import ControlContext from "./ControlContext"; import PageHeader from "./PageHeader"; import PageLayout from "./PageLayout"; @@ -33,8 +33,8 @@ export default function UserViewPage() { let account = useAccount(); let control = useContext(ControlContext); - let [instanceList] = useInstanceList(); - let [user] = useUser(userName); + let [instances] = useInstances(); + const [user, synced] = useUser(userName); let [roles, setRoles] = useState>(new Map()); let [form] = Form.useForm(); let [rolesDirty, setRolesDirty] = useState(false); @@ -54,6 +54,7 @@ export default function UserViewPage() { if ( account.hasPermission("core.user.set_banned") && !banReasonDirty + && user && user.banReason !== form.getFieldValue("banReason") ) { form.setFieldsValue({ "banReason": user.banReason }); @@ -61,14 +62,14 @@ export default function UserViewPage() { }, [account, form, user, banReasonDirty]); let nav = [{ name: "Users", path: "/users" }, { name: userName }]; - if (user.loading) { - return - - - ; - } + if (!user) { + if (!synced) { + return + + + ; + } - if (user.missing) { return

User with name {userName} was not found on the controller.

@@ -78,7 +79,7 @@ export default function UserViewPage() { let roleSelector = (rolesError ? Promise.reject(rolesError) : Promise.resolve()) }, ]} @@ -100,9 +101,8 @@ export default function UserViewPage() { ; - function instanceName(id: number): string { - let instance = instanceList.find(i => i.id === id); - return instance ? instance.name : String(id); + function instanceName(id: number) { + return instances.get(id)?.name ?? String(id); } return diff --git a/packages/web_ui/src/components/UsersPage.tsx b/packages/web_ui/src/components/UsersPage.tsx index e0966187..b7469d40 100644 --- a/packages/web_ui/src/components/UsersPage.tsx +++ b/packages/web_ui/src/components/UsersPage.tsx @@ -11,7 +11,7 @@ import { formatDuration } from "../util/time_format"; import PageHeader from "./PageHeader"; import PageLayout from "./PageLayout"; import PluginExtra from "./PluginExtra"; -import { formatLastSeen, sortLastSeen, useUserList } from "../model/user"; +import { formatLastSeen, sortLastSeen, useUsers } from "../model/user"; const strcmp = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" }).compare; @@ -60,7 +60,7 @@ export default function UsersPage() { let account = useAccount(); let control = useContext(ControlContext); let navigate = useNavigate(); - let [userList] = useUserList(); + let [users] = useUsers(); let [roles, setRoles] = useState(new Map()); @@ -117,7 +117,7 @@ export default function UsersPage() { responsive: ["lg"], }, ]} - dataSource={userList} + dataSource={[...users.values()]} pagination={false} rowKey={user => user.name} onRow={(user, rowIndex) => ({ diff --git a/packages/web_ui/src/model/host.ts b/packages/web_ui/src/model/host.ts index ec067f14..c6e28ff3 100644 --- a/packages/web_ui/src/model/host.ts +++ b/packages/web_ui/src/model/host.ts @@ -1,92 +1,13 @@ -import { useEffect, useContext, useState } from "react"; +import { useCallback, useContext, useSyncExternalStore } from "react"; import ControlContext from "../components/ControlContext"; -import * as lib from "@clusterio/lib"; - - -export type HostState = Partial & { - loading?: boolean, - present?: boolean, - missing?: boolean, +export function useHost(id?: number) { + const [hosts, synced] = useHosts(); + return [id !== undefined ? hosts.get(id) : undefined, synced] as const; } -export function useHost(id: number): [ HostState, () => void ] { - let control = useContext(ControlContext); - let [host, setHost] = useState({ loading: true }); - - function updateHost() { - // XXX optimize by requesting only the host in question - control.send( - new lib.HostListRequest() - ).then((hosts: lib.HostDetails[]) => { - let match = hosts.find(i => i.id === id); - if (!match) { - setHost({ missing: true }); - } else { - setHost({ ...match, present: true }); - } - }); - } - - useEffect(() => { - if (!Number.isInteger(id)) { - setHost({ missing: true }); - return undefined; - } - updateHost(); - - function updateHandler(newHosts: lib.HostDetails[]) { - const newHost = newHosts.find(h => h.id === id); - if (newHost) { - setHost({ ...newHost, loading: false, missing: false, present: true }); - } - } - - control.hostUpdate.subscribe(updateHandler); - return () => { - control.hostUpdate.unsubscribe(updateHandler); - }; - }, [id]); - - return [host, updateHost]; -} - -export function useHostList() { - let control = useContext(ControlContext); - let [hostList, setHostList] = useState([]); - - function updateHostList() { - control.send( - new lib.HostListRequest() - ).then((hosts: lib.HostDetails[]) => { - setHostList(hosts); - }); - } - - useEffect(() => { - updateHostList(); - - function updateHandler(newHosts: lib.HostDetails[]) { - setHostList(oldList => { - let newList = oldList.concat(); - for (const newHost of newHosts) { - let index = newList.findIndex(s => s.id === newHost.id); - if (index !== -1) { - newList[index] = newHost; - } else { - newList.push(newHost); - } - } - return newList; - }); - } - - control.hostUpdate.subscribe(updateHandler); - return () => { - control.hostUpdate.unsubscribe(updateHandler); - }; - }, []); - - - return [hostList]; +export function useHosts() { + const control = useContext(ControlContext); + const subscribe = useCallback((callback: () => void) => control.hosts.subscribe(callback), [control]); + return useSyncExternalStore(subscribe, () => control.hosts.getSnapshot()); } diff --git a/packages/web_ui/src/model/instance.ts b/packages/web_ui/src/model/instance.ts index bd50b3a4..41bb8b99 100644 --- a/packages/web_ui/src/model/instance.ts +++ b/packages/web_ui/src/model/instance.ts @@ -1,91 +1,13 @@ -import { useEffect, useContext, useState } from "react"; +import { useCallback, useContext, useSyncExternalStore } from "react"; import ControlContext from "../components/ControlContext"; -import * as lib from "@clusterio/lib"; - -const { logger } = lib; - -export type InstanceState = Partial & { - loading?: boolean, - present?: boolean, - missing?: boolean, +export function useInstance(id?: number) { + const [instances, synced] = useInstances(); + return [id !== undefined ? instances.get(id) : undefined, synced] as const; } -export function useInstance(id: number): [InstanceState, ()=>void] { - let control = useContext(ControlContext); - let [instance, setInstance] = useState({ loading: true }); - - function updateInstance() { - control.send(new lib.InstanceDetailsGetRequest(id)).then(updatedInstance => { - setInstance({ ...updatedInstance, present: true }); - }).catch((err: any) => { - logger.error(`Failed to get instance: ${err}`); - setInstance({ missing: true }); - }); - } - - useEffect(() => { - if (!Number.isInteger(id)) { - setInstance({ missing: true }); - return undefined; - } - updateInstance(); - - function updateHandler(newInstances: lib.InstanceDetails[]) { - const newInstance = newInstances.find(i => i.id === id); - if (newInstance) { - setInstance({ ...newInstance, present: true }); - } - } - - control.instanceUpdate.subscribe(updateHandler); - return () => { - control.instanceUpdate.unsubscribe(updateHandler); - }; - }, [id]); - - return [instance, updateInstance]; -} - -export function useInstanceList() { - let control = useContext(ControlContext); - let [instanceList, setInstanceList] = useState([]); - - function updateInstanceList() { - control.send(new lib.InstanceDetailsListRequest()).then(instances => { - setInstanceList(instances); - }).catch(err => { - logger.error(`Failed to list instances:\n${err}`); - }); - } - - useEffect(() => { - updateInstanceList(); - - function updateHandler(newInstances: lib.InstanceDetails[]) { - setInstanceList(oldList => { - let newList = oldList.concat(); - for (const newInstance of newInstances) { - let index = newList.findIndex(s => s.id === newInstance.id); - if (newInstance.status !== "deleted") { - if (index !== -1) { - newList[index] = newInstance; - } else { - newList.push(newInstance); - } - } else if (index !== -1) { - newList.splice(index, 1); - } - } - return newList; - }); - } - - control.instanceUpdate.subscribe(updateHandler); - return () => { - control.instanceUpdate.unsubscribe(updateHandler); - }; - }, []); - - return [instanceList]; +export function useInstances() { + const control = useContext(ControlContext); + const subscribe = useCallback((callback: () => void) => control.instances.subscribe(callback), [control]); + return useSyncExternalStore(subscribe, () => control.instances.getSnapshot()); } diff --git a/packages/web_ui/src/model/mod_pack.ts b/packages/web_ui/src/model/mod_pack.ts index 6b65b36b..927a9a75 100644 --- a/packages/web_ui/src/model/mod_pack.ts +++ b/packages/web_ui/src/model/mod_pack.ts @@ -1,93 +1,13 @@ -import { useContext, useEffect, useState } from "react"; - -import * as lib from "@clusterio/lib"; - +import { useCallback, useContext, useSyncExternalStore } from "react"; import ControlContext from "../components/ControlContext"; -const { logger } = lib; - - -export type StateLoadStatus = { - loading?: boolean; - present?: boolean; - missing?: boolean; -}; - -export function useModPack(id: number) { - let control = useContext(ControlContext); - let [modPack, setModPack] = useState({ loading: true }); - - function updateModPack() { - control.send(new lib.ModPackGetRequest(id)).then(updatedModPack => { - setModPack(updatedModPack); - }).catch(err => { - logger.error(`Failed to get mod pack: ${err}`); - setModPack({ missing: true }); - }); - } - - useEffect(() => { - if (typeof id !== "number") { - setModPack({ missing: true }); - return undefined; - } - updateModPack(); - - function updateHandler(modPacks: lib.ModPack[]) { - const newModPack = modPacks.find(m => m.id === id); - if (newModPack) { - setModPack(newModPack); - } - } - control.modPackUpdate.subscribe(updateHandler); - return () => { - control.modPackUpdate.unsubscribe(updateHandler); - }; - }, [id]); - - return [modPack]; +export function useModPack(id?: number) { + const [modPacks, synced] = useModPacks(); + return [id !== undefined ? modPacks.get(id) : undefined, synced] as const; } -export function useModPackList() { - let control = useContext(ControlContext); - let [modPackList, setModPackList] = useState([]); - - function updateModPackList() { - control.send(new lib.ModPackListRequest()).then(modPacks => { - setModPackList(modPacks); - }).catch(err => { - logger.error(`Failed to list mod packs:\n${err}`); - }); - } - - useEffect(() => { - updateModPackList(); - - function updateHandler(newModPacks: lib.ModPack[]) { - setModPackList(oldList => { - let newList = oldList.concat(); - for (const newModPack of newModPacks) { - let index = newList.findIndex(u => u.id === newModPack.id); - if (!newModPack.isDeleted) { - if (index !== -1) { - newList[index] = newModPack; - } else { - newList.push(newModPack); - } - } else if (index !== -1) { - newList.splice(index, 1); - } - } - return newList; - }); - } - - control.modPackUpdate.subscribe(updateHandler); - return () => { - control.modPackUpdate.unsubscribe(updateHandler); - }; - }, []); - - return [modPackList]; - +export function useModPacks() { + const control = useContext(ControlContext); + const subscribe = useCallback((callback: () => void) => control.modPacks.subscribe(callback), [control]); + return useSyncExternalStore(subscribe, () => control.modPacks.getSnapshot()); } diff --git a/packages/web_ui/src/model/mods.ts b/packages/web_ui/src/model/mods.ts index 6bbdfc16..4c618d38 100644 --- a/packages/web_ui/src/model/mods.ts +++ b/packages/web_ui/src/model/mods.ts @@ -1,51 +1,13 @@ -import { useContext, useEffect, useState } from "react"; - -import * as lib from "@clusterio/lib"; - +import { useCallback, useContext, useSyncExternalStore } from "react"; import ControlContext from "../components/ControlContext"; -const { logger } = lib; - -export function useModList() { - let control = useContext(ControlContext); - let [modList, setModList] = useState([]); - - function updateModList() { - control.send(new lib.ModListRequest()).then(mods => { - setModList(mods); - }).catch(err => { - logger.error(`Failed to list mods:\n${err}`); - }); - } - - useEffect(() => { - updateModList(); - - function updateHandler(newMods: lib.ModInfo[]) { - setModList(oldList => { - let newList = oldList.concat(); - for (const newMod of newMods) { - let index = newList.findIndex(mod => mod.name === newMod.name && mod.version === newMod.version); - if (!newMod.isDeleted) { - if (index !== -1) { - newList[index] = newMod; - } else { - newList.push(newMod); - } - } else if (index !== -1) { - newList.splice(index, 1); - } - } - return newList; - }); - } - - control.modUpdate.subscribe(updateHandler); - return () => { - control.modUpdate.unsubscribe(updateHandler); - }; - }, []); - - return [modList]; +export function useMod(id?: string) { + const [mods, synced] = useMods(); + return [id !== undefined ? mods.get(id) : undefined, synced] as const; } +export function useMods() { + const control = useContext(ControlContext); + const subscribe = useCallback((callback: () => void) => control.mods.subscribe(callback), [control]); + return useSyncExternalStore(subscribe, () => control.mods.getSnapshot()); +} diff --git a/packages/web_ui/src/model/saves.ts b/packages/web_ui/src/model/saves.ts index 6e7296e8..f92b5ce5 100644 --- a/packages/web_ui/src/model/saves.ts +++ b/packages/web_ui/src/model/saves.ts @@ -1,57 +1,14 @@ -import { useEffect, useContext, useState } from "react"; +import { useCallback, useContext, useSyncExternalStore } from "react"; import ControlContext from "../components/ControlContext"; -import * as lib from "@clusterio/lib"; - -const { logger } = lib; - -export function useSaves(instanceId?: number): lib.SaveDetails[] { - let control = useContext(ControlContext); - let [saves, setSaves] = useState([]); - - function updateSaves() { - control.sendTo("controller", new lib.InstanceSaveDetailsListRequest()).then(updatedSaves => { - setSaves(updatedSaves.filter(save => save.instanceId === instanceId)); - }).catch(err => { - logger.error(`Failed to list instance saves: ${err}`); - setSaves([]); - }); - } - - useEffect(() => { - if (!Number.isInteger(instanceId)) { - setSaves([]); - return undefined; - } - updateSaves(); - - function updateHandler(updates: lib.SaveDetails[]) { - setSaves(oldList => { - let newList = oldList.concat(); - for (const newSave of updates) { - if (newSave.instanceId !== instanceId) { - continue; - } - let index = newList.findIndex(s => s.id === newSave.id); - if (!newSave.isDeleted) { - if (index !== -1) { - newList[index] = newSave; - } else { - newList.push(newSave); - } - } else if (index !== -1) { - newList.splice(index, 1); - } - } - return newList; - }); - } - - control.saveListUpdate.subscribe(updateHandler); - return () => { - control.saveListUpdate.unsubscribe(updateHandler); - }; - }, [instanceId]); - - return saves; +export function useSavesOfInstance(instanceId?: number) { + const [saves, synced] = useSaves(); + const savesOfInstance = new Map([...saves].filter(([_id, save]) => save.instanceId === instanceId)); + return [savesOfInstance, synced] as const; +} + +export function useSaves() { + const control = useContext(ControlContext); + const subscribe = useCallback((callback: () => void) => control.saves.subscribe(callback), [control]); + return useSyncExternalStore(subscribe, () => control.saves.getSnapshot()); } diff --git a/packages/web_ui/src/model/user.tsx b/packages/web_ui/src/model/user.tsx index 9ad9e745..f0ff2b7e 100644 --- a/packages/web_ui/src/model/user.tsx +++ b/packages/web_ui/src/model/user.tsx @@ -1,31 +1,29 @@ -import React, { useEffect, useContext, useState } from "react"; +import React, { useCallback, useContext, useSyncExternalStore } from "react"; import { Tag } from "antd"; import ControlContext from "../components/ControlContext"; import * as lib from "@clusterio/lib"; -const { logger } = lib; - -function calculateLastSeen(user: RawUserState, instanceId?: number) { +function calculateLastSeen(user: lib.User, instanceId?: number) { let stats; if (instanceId === undefined) { stats = user.playerStats; } else { - stats = (user.instanceStats || new Map()).get(instanceId); + stats = user.instanceStats.get(instanceId); if (!stats) { return undefined; } } - if (stats.lastLeaveAt > stats.lastJoinAt) { - return stats.lastLeaveAt; + if ((stats.lastLeaveAt?.getTime() ?? 0) > (stats.lastJoinAt?.getTime() ?? 0)) { + return stats.lastLeaveAt?.getTime(); } if (stats.lastJoinAt) { - return stats.lastLeaveAt; + return stats.lastLeaveAt?.getTime(); } return undefined; } -export function formatLastSeen(user: RawUserState, instanceId?: number) { +export function formatLastSeen(user: lib.User, instanceId?: number) { if (user.instances && [...user.instances].some(id => instanceId === undefined || id === instanceId)) { return Online; } @@ -36,8 +34,8 @@ export function formatLastSeen(user: RawUserState, instanceId?: number) { return new Date(lastSeen).toLocaleString(); } -export function sortLastSeen(userA:RawUserState, userB:RawUserState, instanceIdA?: number, instanceIdB?: number) { - function epoch(user:RawUserState, instanceId?: number) { +export function sortLastSeen(userA: lib.User, userB: lib.User, instanceIdA?: number, instanceIdB?: number) { + function epoch(user: lib.User, instanceId?: number) { return user.instances && [...user.instances].some(id => instanceId === undefined || id === instanceId); } @@ -52,87 +50,13 @@ export function sortLastSeen(userA:RawUserState, userB:RawUserState, instanceIdA return lastSeenA - lastSeenB; } -export type RawUserState = Partial & { - loading?: boolean; - present?: boolean; - missing?: boolean; -}; - -export function useUser(name: string): [RawUserState, () => void] { - let control = useContext(ControlContext); - let [user, setUser] = useState({ loading: true }); - - function updateUser() { - control.send(new lib.UserGetRequest(name)).then(updatedUser => { - setUser({ ...updatedUser, present: true }); - }).catch(err => { - logger.error(`Failed to get user: ${err}`); - setUser({ missing: true }); - }); - } - - useEffect(() => { - if (typeof name !== "string") { - setUser({ missing: true }); - return undefined; - } - updateUser(); - - function updateHandler(newUsers: lib.User[]) { - const newUser = newUsers.find(u => u.name === name); - if (newUser) { - setUser({ ...newUser, present: true }); - } - } - - control.userUpdate.subscribe(updateHandler); - return () => { - control.userUpdate.unsubscribe(updateHandler); - }; - }, [name]); - - return [user, updateUser]; +export function useUser(name?: string) { + const [users, synced] = useUsers(); + return [name !== undefined ? users.get(name) : undefined, synced] as const; } -export function useUserList() { - let control = useContext(ControlContext); - let [userList, setUserList] = useState([]); - - function updateUserList() { - control.send(new lib.UserListRequest()).then(users => { - setUserList(users); - }).catch(err => { - logger.error(`Failed to list users:\n${err}`); - }); - } - - useEffect(() => { - updateUserList(); - - function updateHandler(newUsers: lib.User[]) { - setUserList(oldList => { - let newList = oldList.concat(); - for (const newUser of newUsers) { - let index = newList.findIndex(u => u.name === newUser.name); - if (!newUser.isDeleted) { - if (index !== -1) { - newList[index] = newUser; - } else { - newList.push(newUser); - } - } else if (index !== -1) { - newList.splice(index, 1); - } - } - return newList; - }); - } - - control.userUpdate.subscribe(updateHandler); - return () => { - control.userUpdate.unsubscribe(updateHandler); - }; - }, []); - - return [userList]; +export function useUsers() { + const control = useContext(ControlContext); + const subscribe = useCallback((callback: () => void) => control.users.subscribe(callback), [control]); + return useSyncExternalStore(subscribe, () => control.users.getSnapshot()); } diff --git a/packages/web_ui/src/util/websocket.ts b/packages/web_ui/src/util/websocket.ts index cb7f4320..06f796e9 100644 --- a/packages/web_ui/src/util/websocket.ts +++ b/packages/web_ui/src/util/websocket.ts @@ -60,29 +60,12 @@ export class Control extends lib.Link { logHandlers: Map = new Map(); /** Updates handled by the subscription service */ - hostUpdate = new lib.EventSubscriber( - lib.HostUpdatesEvent, this, event => event.updates, - ); - - instanceUpdate = new lib.EventSubscriber( - lib.InstanceDetailsUpdatesEvent, this, event => event.updates, - ); - - saveListUpdate = new lib.EventSubscriber( - lib.InstanceSaveDetailsUpdatesEvent, this, event => event.updates, - ); - - modPackUpdate = new lib.EventSubscriber( - lib.ModPackUpdatesEvent, this, event => event.updates, - ); - - modUpdate = new lib.EventSubscriber( - lib.ModUpdatesEvent, this, event => event.updates, - ); - - userUpdate = new lib.EventSubscriber( - lib.UserUpdatesEvent, this, event => event.updates, - ); + hosts = new lib.EventSubscriber(lib.HostUpdatesEvent, this); + instances = new lib.EventSubscriber(lib.InstanceDetailsUpdatesEvent, this); + saves = new lib.EventSubscriber(lib.InstanceSaveDetailsUpdatesEvent, this); + modPacks = new lib.EventSubscriber(lib.ModPackUpdatesEvent, this); + mods = new lib.EventSubscriber(lib.ModUpdatesEvent, this); + users = new lib.EventSubscriber(lib.UserUpdatesEvent, this); declare connector: ControlConnector; diff --git a/test/lib/subscriptions.js b/test/lib/subscriptions.js index ec46a846..5196b9b0 100644 --- a/test/lib/subscriptions.js +++ b/test/lib/subscriptions.js @@ -19,6 +19,9 @@ describe("lib/subscriptions", function() { static src = ["controller", "control"]; static dst = ["controller", "control"]; static permission = null; + constructor(updates = []) { + this.updates = updates; + } } lib.Link.register(RegisteredEvent); @@ -275,7 +278,7 @@ describe("lib/subscriptions", function() { assertNoEvent(0); }); it("should accept a respond with an event replay when returned by the handler", async function() { - subscriptions.handle(StringPermissionEvent, undefined, async function() { + subscriptions.handle(StringPermissionEvent, async function() { return new StringPermissionEvent(); }); @@ -283,7 +286,6 @@ describe("lib/subscriptions", function() { await subscriptions.handleRequest( getLink(0), request, connectorSetupDate[0].src, connectorSetupDate[0].dst ); - subscriptions.broadcast(new StringPermissionEvent()); await onceConnectorSend(0); assertLastEvent(0, StringPermissionEvent); @@ -293,6 +295,18 @@ describe("lib/subscriptions", function() { const response = eventEntry.eventFromJSON(lastMessage.data); assert.deepEqual(response, new StringPermissionEvent()); }); + it("should accept a respond with null when returned by the handler", async function() { + subscriptions.handle(StringPermissionEvent, async function() { + return null; + }); + + const request = new lib.SubscriptionRequest(StringPermissionEvent.name, true); + await subscriptions.handleRequest( + getLink(0), request, connectorSetupDate[0].src, connectorSetupDate[0].dst + ); + await new Promise(resolve => setImmediate(resolve)); + assertNoEvent(0); + }); }); }); @@ -314,6 +328,10 @@ describe("lib/subscriptions", function() { assert.deepEqual(lastMessage.data, request); } + function assertNoMessageSent() { + assert.equal(mockControl.connector.sentMessages.length, 0, "messages were sent"); + } + describe("constructor()", function() { it("should not accept unregistered events", function() { assert.throws( @@ -324,54 +342,75 @@ describe("lib/subscriptions", function() { it("should handle the provided event", function() { assert.equal(mockControl._eventHandlers.has(RegisteredEvent), true); }); - it("should call and use the return of a pre-handler, if provided", function() { - let calledWith = null; - function prehandler() { return "PreHandlerReturn"; } - const eventSubscriber = new lib.EventSubscriber(StringPermissionEvent, mockControl, prehandler); - eventSubscriber.subscribe(function(value) { calledWith = value; }); - eventSubscriber._handle(new StringPermissionEvent()); - assert.equal(calledWith, "PreHandlerReturn"); - }); }); describe("subscribe()", function() { - it("should allow subscriptions to an event", function() { + it("should allow subscriptions to an event", async function() { let calledWith = null; let calledWithTwo = null; - const event = new RegisteredEvent(); - registeredEvent.subscribe(function(value) { calledWith = value; }); - registeredEvent.subscribe(function(value) { calledWithTwo = value; }); - registeredEvent._handle(event); - assert.deepEqual(calledWith, event); - assert.deepEqual(calledWithTwo, event); + const item = { id: 1, updatedAt: 1, isDeleted: false }; + const event = new RegisteredEvent([item]); + registeredEvent.subscribe(function(updates, synced) { calledWith = [updates, synced]; }); + registeredEvent.subscribe(function(updates, synced) { calledWithTwo = [updates, synced]; }); + await registeredEvent._handle(event); + assert.deepEqual(calledWith, [[item], false]); + assert.deepEqual(calledWithTwo, [[item], false]); + }); + it("should allow unsubscribing from an event", async function() { + let called = false; + let event = new RegisteredEvent(); + function callback() { called = true; } + const unsubscribe = registeredEvent.subscribe(callback); + await registeredEvent._handle(event); + assert.deepEqual(called, true); + + called = false; + event = new RegisteredEvent(); + unsubscribe(); + // Wait for unsubscribe to happen. + await new Promise(resolve => setImmediate(resolve)); + await registeredEvent._handle(event); + assert.equal(called, false); + }); + it("should not throw error on incorrectly unsubscribing multiple times", async function() { + const unsubscribe = registeredEvent.subscribe(() => {}); + unsubscribe(); + unsubscribe(); + // Wait for unsubscribe to happen. + await new Promise(resolve => setImmediate(resolve)); }); }); - describe("unsubscribe()", function() { - it("should allow unsubscribing from an event", function() { - let calledWith = null; - let event = new RegisteredEvent(); - function callback(value) { calledWith = value; } - registeredEvent.subscribe(callback); - registeredEvent._handle(event); - assert.deepEqual(calledWith, event); - - calledWith = null; - event = new RegisteredEvent(); - registeredEvent.unsubscribe(callback); - registeredEvent._handle(event); - assert.equal(calledWith, null); - }); - it("should throw an error if the handler was not subscribed", function() { - function callback(value) { calledWith = value; } - assert.rejects( - () => registeredEvent.unsubscribe(callback), - new Error("handler is not registered") - ); + describe("getSnapshot()", function() { + it("should give an immutable copy of the tracked values", async function() { + const update1 = [{ id: 1, updatedAt: 1, isDeleted: false }]; + const update2 = [{ id: 1, updatedAt: 2, isDeleted: false }, { id: 2, updatedAt: 3, isDeleted: false }]; + const update3 = [{ id: 1, updatedAt: 4, isDeleted: true }]; + const snap0 = registeredEvent.getSnapshot(); + assert.deepEqual(snap0, [new Map(), false]); + await registeredEvent._handle(new RegisteredEvent(update1)); + const snap1 = registeredEvent.getSnapshot(); + assert.deepEqual(snap1, [new Map([[1, update1[0]]]), false]); + await registeredEvent._handle(new RegisteredEvent(update2)); + const snap2 = registeredEvent.getSnapshot(); + assert.deepEqual(snap2, [new Map([[1, update2[0]], [2, update2[1]]]), false]); + await registeredEvent._handle(new RegisteredEvent(update3)); + const snap3 = registeredEvent.getSnapshot(); + assert.deepEqual(snap3, [new Map([[2, update2[1]]]), false]); + // validate previous snapshots were not modified + assert.deepEqual(snap2, [new Map([[1, update2[0]], [2, update2[1]]]), false]); + assert.deepEqual(snap1, [new Map([[1, update1[0]]]), false]); + assert.deepEqual(snap0, [new Map(), false]); }); }); describe("_updateSubscription()", function() { + it("should do nothing if not connected", async function() { + registeredEvent.control.connector.connected = false; + registeredEvent.subscribe(() => true); + await new Promise(resolve => setImmediate(resolve)); + assertNoMessageSent(); + }); it("should correctly request a subscription", async function() { const expected = new lib.SubscriptionRequest(RegisteredEvent.name, true, -1); registeredEvent.subscribe(() => true); @@ -381,12 +420,44 @@ describe("lib/subscriptions", function() { it("should correctly request no subscriptions", async function() { const expected = new lib.SubscriptionRequest(RegisteredEvent.name, false, -1); function callback() {} - registeredEvent.subscribe(callback); + const unsubscribe = registeredEvent.subscribe(callback); await events.once(mockControl.connector, "send"); - registeredEvent.unsubscribe(callback); + unsubscribe(); await events.once(mockControl.connector, "send"); assertLastRequest(expected); }); + it("should set synced and invoke callbacks when request is responded to", async function() { + let calledWith; + registeredEvent.subscribe((values, synced) => { calledWith = [values, synced]; }); + const [msg] = await events.once(mockControl.connector, "send"); + assert(!registeredEvent.synced, "synced was set before response"); + mockControl.connector.emit("message", new lib.MessageResponse(0, msg.dst, msg.src)); + await new Promise(resolve => setImmediate(resolve)); + assert(calledWith !== undefined, "callback not called"); + assert.deepEqual(calledWith, [[], true]); + assert(registeredEvent.synced, "synced was not set"); + }); + it("should be called con connector connect event", async function() { + const expected = new lib.SubscriptionRequest(RegisteredEvent.name, true, -1); + registeredEvent._callbacks.push(() => {}); + registeredEvent.control.connector.emit("connect"); + await events.once(mockControl.connector, "send"); + assertLastRequest(expected); + }); + it("should revert synced and invoke callbacks on connector close event", async function() { + let calledWith; + registeredEvent.subscribe((values, synced) => { calledWith = [values, synced]; }); + const [msg] = await events.once(mockControl.connector, "send"); + mockControl.connector.emit("message", new lib.MessageResponse(0, msg.dst, msg.src)); + await new Promise(resolve => setImmediate(resolve)); + + assert(registeredEvent.synced, "synced was not set before close"); + calledWith = undefined; + registeredEvent.control.connector.emit("close"); + assert(calledWith !== undefined, "callback not called"); + assert.deepEqual(calledWith, [[], false]); + assert(!registeredEvent.synced, "synced was not reset"); + }); }); }); });