From 7fef81d18768f01d45eabf4f23121cded5da3dcc Mon Sep 17 00:00:00 2001 From: MapleLeaf <19603573+itsMapleLeaf@users.noreply.github.com> Date: Wed, 22 Dec 2021 22:51:07 -0600 Subject: [PATCH] some refactors, mainly splitting out action queue --- integration/rendering.test.tsx | 2 +- src/action-queue.ts | 52 +++++++++++++ src/channel-renderer.ts | 95 +++++++++++++++++++++++ src/reconciler.ts | 8 +- src/renderer.ts | 137 --------------------------------- src/root.ts | 20 +++-- 6 files changed, 161 insertions(+), 153 deletions(-) create mode 100644 src/action-queue.ts create mode 100644 src/channel-renderer.ts delete mode 100644 src/renderer.ts diff --git a/integration/rendering.test.tsx b/integration/rendering.test.tsx index 8b757a9..0d805a9 100644 --- a/integration/rendering.test.tsx +++ b/integration/rendering.test.tsx @@ -323,7 +323,7 @@ async function clickButton(index = 0) { filter: (interaction) => interaction.customId === customId, time: 1000, }) - await root.complete() + await root.done() } function createButtonInteraction(customId: string) { diff --git a/src/action-queue.ts b/src/action-queue.ts new file mode 100644 index 0000000..61ab32d --- /dev/null +++ b/src/action-queue.ts @@ -0,0 +1,52 @@ +export type Action = { + id: string + priority: number + run: () => unknown +} + +export class ActionQueue { + private actions: Action[] = [] + private runningPromise?: Promise + + add(action: Action) { + const lastAction = this.actions[this.actions.length - 1] + if (lastAction?.id === action.id) { + this.actions[this.actions.length - 1] = action + } else { + this.actions.push(action) + } + + this.actions.sort((a, b) => a.priority - b.priority) + + this.runActions() + } + + clear() { + this.actions = [] + } + + done() { + return this.runningPromise ?? Promise.resolve() + } + + private runActions() { + if (this.runningPromise) return + + this.runningPromise = new Promise((resolve) => { + // using a microtask to allow multiple actions to be added synchronously + queueMicrotask(async () => { + let action: Action | undefined + while ((action = this.actions.shift())) { + try { + await action.run() + } catch (error) { + console.error(`Failed to run action:`, action) + console.error(error) + } + } + resolve() + this.runningPromise = undefined + }) + }) + } +} diff --git a/src/channel-renderer.ts b/src/channel-renderer.ts new file mode 100644 index 0000000..e1b6f66 --- /dev/null +++ b/src/channel-renderer.ts @@ -0,0 +1,95 @@ +import type { + InteractionCollector, + Message, + MessageComponentInteraction, + MessageComponentType, + TextBasedChannels, +} from "discord.js" +import type { Action } from "./action-queue.js" +import { ActionQueue } from "./action-queue.js" +import type { MessageNode } from "./node-tree.js" +import { collectInteractionHandlers, getMessageOptions } from "./node-tree.js" + +export class ChannelRenderer { + private channel: TextBasedChannels + private interactionCollector: InteractionCollector + private message?: Message + private tree?: MessageNode + private actions = new ActionQueue() + + constructor(channel: TextBasedChannels) { + this.channel = channel + this.interactionCollector = this.createInteractionCollector() + } + + private getInteractionHandler(customId: string) { + if (!this.tree) return undefined + const handlers = collectInteractionHandlers(this.tree) + return handlers.find((handler) => handler.customId === customId) + } + + private createInteractionCollector() { + const collector = + this.channel.createMessageComponentCollector({ + filter: (interaction) => + !!this.getInteractionHandler(interaction.customId), + }) + + collector.on("collect", (interaction) => { + const handler = this.getInteractionHandler(interaction.customId) + if (handler?.type === "button" && interaction.isButton()) { + interaction.deferUpdate().catch(console.error) + handler.onClick(interaction) + } + }) + + return collector as InteractionCollector + } + + render(node: MessageNode) { + this.actions.add(this.createUpdateMessageAction(node)) + } + + destroy() { + this.actions.clear() + this.actions.add(this.createDeleteMessageAction()) + this.interactionCollector.stop() + } + + done() { + return this.actions.done() + } + + private createUpdateMessageAction(tree: MessageNode): Action { + return { + id: "updateMessage", + priority: 0, + run: async () => { + const options = getMessageOptions(tree) + + // eslint-disable-next-line unicorn/prefer-ternary + if (this.message) { + this.message = await this.message.edit({ + ...options, + + // need to ensure that the proper fields are erased if there's no content + // eslint-disable-next-line unicorn/no-null + content: options.content ?? null, + // eslint-disable-next-line unicorn/no-null + embeds: options.embeds ?? [], + }) + } else { + this.message = await this.channel.send(options) + } + }, + } + } + + private createDeleteMessageAction(): Action { + return { + id: "deleteMessage", + priority: 0, + run: () => this.message?.delete(), + } + } +} diff --git a/src/reconciler.ts b/src/reconciler.ts index 13eb895..6f896e2 100644 --- a/src/reconciler.ts +++ b/src/reconciler.ts @@ -1,9 +1,9 @@ /* eslint-disable unicorn/no-null */ import { inspect } from "node:util" import ReactReconciler from "react-reconciler" +import type { ChannelRenderer } from "./channel-renderer.js" import { raise } from "./helpers/raise.js" import type { MessageNode, Node, TextNode } from "./node-tree.js" -import type { MessageRenderer } from "./renderer.js" type ElementTag = string @@ -27,7 +27,7 @@ type ChildSet = MessageNode export const reconciler = ReactReconciler< string, // Type (jsx tag), Props, // Props, - MessageRenderer, // Container, + ChannelRenderer, // Container, Node, // Instance, TextNode, // TextInstance, never, // SuspenseInstance, @@ -65,11 +65,11 @@ export const reconciler = ReactReconciler< childSet.children.push(child) }, - finalizeContainerChildren: (container: MessageRenderer, children: ChildSet) => + finalizeContainerChildren: (container: ChannelRenderer, children: ChildSet) => false, replaceContainerChildren: ( - container: MessageRenderer, + container: ChannelRenderer, children: ChildSet, ) => { container.render(children) diff --git a/src/renderer.ts b/src/renderer.ts deleted file mode 100644 index 81b4b19..0000000 --- a/src/renderer.ts +++ /dev/null @@ -1,137 +0,0 @@ -import type { - InteractionCollector, - Message, - MessageComponentInteraction, - MessageComponentType, - TextBasedChannels, -} from "discord.js" -import type { MessageNode } from "./node-tree.js" -import { collectInteractionHandlers, getMessageOptions } from "./node-tree.js" - -type Action = - | { type: "updateMessage"; tree: MessageNode } - | { type: "deleteMessage" } - | { - type: "interaction.deferUpdate" - interaction: MessageComponentInteraction - } - -export class MessageRenderer { - private channel: TextBasedChannels - private interactionCollector: InteractionCollector - private message?: Message - private tree?: MessageNode - private actions: Action[] = [] - private runningPromise?: Promise - - constructor(channel: TextBasedChannels) { - this.channel = channel - this.interactionCollector = - this.createInteractionCollector() as InteractionCollector - } - - private getInteractionHandler(customId: string) { - if (!this.tree) return undefined - const handlers = collectInteractionHandlers(this.tree) - return handlers.find((handler) => handler.customId === customId) - } - - private createInteractionCollector() { - const collector = - this.channel.createMessageComponentCollector({ - filter: (interaction) => - !!this.getInteractionHandler(interaction.customId), - }) - - collector.on("collect", (interaction) => { - const handler = this.getInteractionHandler(interaction.customId) - if (handler?.type === "button" && interaction.isButton()) { - this.actions.unshift({ type: "interaction.deferUpdate", interaction }) - handler.onClick(interaction) - } - }) - - return collector - } - - render(node: MessageNode) { - this.addAction({ - type: "updateMessage", - tree: node, - }) - } - - destroy() { - this.actions = [] - this.addAction({ type: "deleteMessage" }) - this.interactionCollector.stop() - } - - completion() { - return this.runningPromise ?? Promise.resolve() - } - - private addAction(action: Action) { - const lastAction = this.actions[this.actions.length - 1] - if (lastAction?.type === action.type) { - this.actions[this.actions.length - 1] = action - } else { - this.actions.push(action) - } - this.runActions() - } - - private runActions() { - if (this.runningPromise) return - - this.runningPromise = new Promise((resolve) => { - // using a microtask to allow multiple actions to be added synchronously - queueMicrotask(async () => { - let action: Action | undefined - while ((action = this.actions.shift())) { - try { - await this.runAction(action) - } catch (error) { - console.error(`Failed to run action:`, action) - console.error(error) - } - } - resolve() - this.runningPromise = undefined - }) - }) - } - - private async runAction(action: Action) { - if (action.type === "updateMessage") { - const options = getMessageOptions(action.tree) - // eslint-disable-next-line unicorn/prefer-ternary - if (this.message) { - this.message = await this.message.edit({ - ...options, - - // need to ensure that the proper fields are erased if there's no content - // eslint-disable-next-line unicorn/no-null - content: options.content ?? null, - // components: options.components?.length - // ? options.components - // : null, - // eslint-disable-next-line unicorn/no-null - embeds: options.embeds ?? [], - }) - } else { - this.message = await this.channel.send(options) - } - this.tree = action.tree - } - - if (action.type === "deleteMessage") { - await this.message?.delete() - this.message = undefined - } - - if (action.type === "interaction.deferUpdate") { - await action.interaction.deferUpdate() - } - } -} diff --git a/src/root.ts b/src/root.ts index 0ad9e17..a38b973 100644 --- a/src/root.ts +++ b/src/root.ts @@ -1,26 +1,24 @@ /* eslint-disable unicorn/no-null */ import type { TextBasedChannels } from "discord.js" import type { ReactNode } from "react" -import { reconciler } from "./reconciler" -import { MessageRenderer } from "./renderer" - -export type ReacordRenderTarget = TextBasedChannels +import { ChannelRenderer } from "./channel-renderer.js" +import { reconciler } from "./reconciler.js" export type ReacordRoot = ReturnType -export function createRoot(target: ReacordRenderTarget) { - const container = new MessageRenderer(target) - const containerId = reconciler.createContainer(container, 0, false, null) +export function createRoot(target: TextBasedChannels) { + const renderer = new ChannelRenderer(target) + const containerId = reconciler.createContainer(renderer, 0, false, null) return { render: (content: ReactNode) => { reconciler.updateContainer(content, containerId) - return container.completion() + return renderer.done() }, destroy: () => { reconciler.updateContainer(null, containerId) - container.destroy() - return container.completion() + renderer.destroy() + return renderer.done() }, - complete: () => container.completion(), + done: () => renderer.done(), } }