split stuff up + handle immediate renders

This commit is contained in:
itsMapleLeaf
2022-07-23 18:29:16 -05:00
parent 1197d12a19
commit 02808b7550
6 changed files with 304 additions and 191 deletions

View File

@@ -4,29 +4,79 @@ import type {
Message, Message,
MessageEditOptions, MessageEditOptions,
MessageOptions, MessageOptions,
TextBasedChannel,
} from "discord.js" } from "discord.js"
import type { ReactNode } from "react" import type { ReactNode } from "react"
import ReactReconciler from "react-reconciler" import type { ReacordOptions } from "./reacord"
import { DefaultEventPriority } from "react-reconciler/constants" import { createReacordInstanceManager } from "./reacord"
export function createReacordDiscordJs(client: Client) { export function createReacordDiscordJs(
client: Client,
options: ReacordOptions = {},
) {
const manager = createReacordInstanceManager(options)
return { return {
send(channelId: string, initialContent?: ReactNode) { send(channelId: string, initialContent?: ReactNode) {
let message: Message | undefined const messageUpdater = createMessageUpdater()
return manager.createInstance(initialContent, async (tree) => {
const tree: MessageTree = {
children: [],
render: async () => {
const messageOptions: MessageOptions & MessageEditOptions = { const messageOptions: MessageOptions & MessageEditOptions = {
content: tree.children.map((child) => child.text).join(""), content: tree.children.map((child) => child.text).join(""),
} }
const channel = await getTextChannel(client, channelId)
await messageUpdater.update(messageOptions, channel)
})
},
try { reply(interaction: Interaction, initialContent?: ReactNode) {},
if (message) {
await message.edit(messageOptions) ephemeralReply(interaction: Interaction, initialContent?: ReactNode) {},
return }
} }
function createMessageUpdater() {
type UpdatePayload = {
options: MessageOptions & MessageEditOptions
channel: TextBasedChannel
}
let message: Message | undefined
const queue: UpdatePayload[] = []
let queuePromise: Promise<void> | undefined
async function update(
options: MessageOptions & MessageEditOptions,
channel: TextBasedChannel,
) {
queue.push({ options, channel })
if (queuePromise) {
return queuePromise
}
queuePromise = runQueue()
try {
await queuePromise
} finally {
queuePromise = undefined
}
}
async function runQueue() {
let payload: UpdatePayload | undefined
while ((payload = queue.shift())) {
if (message) {
await message.edit(payload.options)
} else {
message = await payload.channel.send(payload.options)
}
}
}
return { update }
}
async function getTextChannel(client: Client<boolean>, channelId: string) {
let channel = client.channels.cache.get(channelId) let channel = client.channels.cache.get(channelId)
if (!channel) { if (!channel) {
channel = (await client.channels.fetch(channelId)) ?? undefined channel = (await client.channels.fetch(channelId)) ?? undefined
@@ -37,175 +87,5 @@ export function createReacordDiscordJs(client: Client) {
if (!channel.isTextBased()) { if (!channel.isTextBased()) {
throw new Error(`Channel ${channelId} is not a text channel`) throw new Error(`Channel ${channelId} is not a text channel`)
} }
message = await channel.send(messageOptions) return channel
} catch (error) {
console.error(
"Reacord encountered an error while rendering.",
error,
)
} }
},
}
const container = reconciler.createContainer(
tree,
0,
// eslint-disable-next-line unicorn/no-null
null,
false,
// eslint-disable-next-line unicorn/no-null
null,
"reacord",
() => {},
// eslint-disable-next-line unicorn/no-null
null,
)
const instance = {
render(content: ReactNode) {
reconciler.updateContainer(content, container)
},
}
if (initialContent !== undefined) {
instance.render(initialContent)
}
return instance
},
reply(interaction: Interaction, initialContent?: ReactNode) {},
ephemeralReply(interaction: Interaction, initialContent?: ReactNode) {},
}
}
type MessageTree = {
children: TextNode[]
render: () => void
}
type TextNode = {
type: "text"
text: string
}
const reconciler = ReactReconciler<
string, // Type
Record<string, unknown>, // Props
MessageTree, // Container
never, // Instance
TextNode, // TextInstance
never, // SuspenseInstance
never, // HydratableInstance
never, // PublicInstance
{}, // HostContext
true, // UpdatePayload
never, // ChildSet
NodeJS.Timeout, // TimeoutHandle
-1 // NoTimeout
>({
isPrimaryRenderer: true,
supportsMutation: true,
supportsHydration: false,
supportsPersistence: false,
scheduleTimeout: setTimeout,
cancelTimeout: clearTimeout,
noTimeout: -1,
createInstance() {
throw new Error("Not implemented")
},
createTextInstance(text) {
return { type: "text", text }
},
appendInitialChild(parent, child) {},
appendChild(parentInstance, child) {},
appendChildToContainer(container, child) {
container.children.push(child)
},
insertBefore(parentInstance, child, beforeChild) {},
insertInContainerBefore(container, child, beforeChild) {
const index = container.children.indexOf(beforeChild)
if (index !== -1) container.children.splice(index, 0, child)
},
removeChild(parentInstance, child) {},
removeChildFromContainer(container, child) {
container.children = container.children.filter((c) => c !== child)
},
clearContainer(container) {
container.children = []
},
commitTextUpdate(textInstance, oldText, newText) {
textInstance.text = newText
},
commitUpdate(
instance,
updatePayload,
type,
prevProps,
nextProps,
internalHandle,
) {},
prepareForCommit() {
// eslint-disable-next-line unicorn/no-null
return null
},
resetAfterCommit(container) {
container.render()
},
finalizeInitialChildren() {
return false
},
prepareUpdate() {
return true
},
shouldSetTextContent() {
return false
},
getRootHostContext() {
return {}
},
getChildHostContext() {
return {}
},
getPublicInstance() {
throw new Error("Refs are not supported")
},
preparePortalMount() {},
getCurrentEventPriority() {
return DefaultEventPriority
},
getInstanceFromNode() {
return undefined
},
beforeActiveInstanceBlur() {},
afterActiveInstanceBlur() {},
prepareScopeUpdate() {},
getInstanceFromScope() {
// eslint-disable-next-line unicorn/no-null
return null
},
detachDeletedInstance() {},
})

View File

@@ -0,0 +1,2 @@
export { createReacordDiscordJs } from "./discord-js"
export { type ReacordInstance, type ReacordOptions } from "./reacord"

View File

@@ -0,0 +1,9 @@
export type MessageTree = {
children: TextNode[]
render: () => void
}
export type TextNode = {
type: "text"
text: string
}

View File

@@ -0,0 +1,91 @@
import type { ReactNode } from "react"
import type { MessageTree } from "./message-tree"
import { reconciler } from "./reconciler"
export type ReacordOptions = {
/**
* The max number of active instances.
* When this limit is exceeded, the oldest instances will be disabled.
*/
maxInstances?: number
}
export type ReacordInstance = {
/** Render some JSX to this instance (edits the message) */
render: (content: ReactNode) => void
/** Remove this message */
destroy: () => void
/**
* Same as destroy, but keeps the message and disables the components on it.
* This prevents it from listening to user interactions.
*/
deactivate: () => void
}
export function createReacordInstanceManager({
maxInstances = 50,
}: ReacordOptions) {
const instances: ReacordInstance[] = []
function createInstance(...args: Parameters<typeof createReacordInstance>) {
const instance = createReacordInstance(...args)
instances.push(instance)
if (instances.length > maxInstances) {
instances.shift()?.deactivate()
}
return instance
}
return { createInstance }
}
function createReacordInstance(
initialContent: ReactNode,
render: (tree: MessageTree) => unknown,
): ReacordInstance {
const tree: MessageTree = {
children: [],
render: async () => {
try {
await render(tree)
} catch (error) {
console.error(
"Reacord encountered an error while updating the message.",
error,
)
}
},
}
const container = reconciler.createContainer(
tree,
0,
// eslint-disable-next-line unicorn/no-null
null,
false,
// eslint-disable-next-line unicorn/no-null
null,
"reacord",
() => {},
// eslint-disable-next-line unicorn/no-null
null,
)
const instance: ReacordInstance = {
render(content: ReactNode) {
reconciler.updateContainer(content, container)
},
destroy() {},
deactivate() {},
}
if (initialContent !== undefined) {
instance.render(initialContent)
}
return instance
}

View File

@@ -0,0 +1,125 @@
import ReactReconciler from "react-reconciler"
import { DefaultEventPriority } from "react-reconciler/constants"
import type { MessageTree, TextNode } from "./message-tree"
export const reconciler = ReactReconciler<
string,
Record<string, unknown>,
MessageTree,
never,
TextNode,
never,
never,
never,
{},
true,
never,
NodeJS.Timeout,
-1 // NoTimeout
>({
isPrimaryRenderer: true,
supportsMutation: true,
supportsHydration: false,
supportsPersistence: false,
scheduleTimeout: setTimeout,
cancelTimeout: clearTimeout,
noTimeout: -1,
createInstance() {
throw new Error("Not implemented")
},
createTextInstance(text) {
return { type: "text", text }
},
appendInitialChild(parent, child) {},
appendChild(parentInstance, child) {},
appendChildToContainer(container, child) {
container.children.push(child)
},
insertBefore(parentInstance, child, beforeChild) {},
insertInContainerBefore(container, child, beforeChild) {
const index = container.children.indexOf(beforeChild)
if (index !== -1) container.children.splice(index, 0, child)
},
removeChild(parentInstance, child) {},
removeChildFromContainer(container, child) {
container.children = container.children.filter((c) => c !== child)
},
clearContainer(container) {
container.children = []
},
commitTextUpdate(textInstance, oldText, newText) {
textInstance.text = newText
},
commitUpdate(
instance,
updatePayload,
type,
prevProps,
nextProps,
internalHandle,
) {},
prepareForCommit() {
// eslint-disable-next-line unicorn/no-null
return null
},
resetAfterCommit(container) {
container.render()
},
finalizeInitialChildren() {
return false
},
prepareUpdate() {
return true
},
shouldSetTextContent() {
return false
},
getRootHostContext() {
return {}
},
getChildHostContext() {
return {}
},
getPublicInstance() {
throw new Error("Refs are not supported")
},
preparePortalMount() {},
getCurrentEventPriority() {
return DefaultEventPriority
},
getInstanceFromNode() {
return undefined
},
beforeActiveInstanceBlur() {},
afterActiveInstanceBlur() {},
prepareScopeUpdate() {},
getInstanceFromScope() {
// eslint-disable-next-line unicorn/no-null
return null
},
detachDeletedInstance() {},
})

View File

@@ -52,3 +52,9 @@ await createTest("basic", (channel) => {
reacord.send(channel.id, <Timer />) reacord.send(channel.id, <Timer />)
}) })
await createTest("immediate renders", async (channel) => {
const instance = reacord.send(channel.id)
instance.render("hi world")
instance.render("hi moon")
})