From 460e7cde1acc552e9e30371bd0ae575cf18bbc97 Mon Sep 17 00:00:00 2001 From: MapleLeaf <19603573+itsMapleLeaf@users.noreply.github.com> Date: Thu, 16 Dec 2021 19:01:37 -0600 Subject: [PATCH] rearchitecting wip --- package.json | 3 +- packages/helpers/pick.ts | 10 ++ packages/helpers/types.ts | 4 + packages/helpers/with-logged-method-calls.ts | 20 +++ .../tests/rendering.test.tsx | 118 ++++++----------- packages/reacord/package.json | 2 +- packages/reacord/src/components/embed.tsx | 107 --------------- packages/reacord/src/components/text.tsx | 9 ++ packages/reacord/src/element.ts | 15 --- packages/reacord/src/elements.ts | 12 ++ packages/reacord/src/main.ts | 2 +- packages/reacord/src/reconciler.ts | 122 ++++++++---------- .../reacord/src/{ => renderer}/container.ts | 41 ++++-- .../src/renderer/text-element-instance.ts | 26 ++++ .../reacord/src/renderer/text-instance.ts | 13 ++ packages/reacord/src/root.ts | 9 +- 16 files changed, 224 insertions(+), 289 deletions(-) create mode 100644 packages/helpers/pick.ts create mode 100644 packages/helpers/with-logged-method-calls.ts delete mode 100644 packages/reacord/src/components/embed.tsx create mode 100644 packages/reacord/src/components/text.tsx delete mode 100644 packages/reacord/src/element.ts create mode 100644 packages/reacord/src/elements.ts rename packages/reacord/src/{ => renderer}/container.ts (66%) create mode 100644 packages/reacord/src/renderer/text-element-instance.ts create mode 100644 packages/reacord/src/renderer/text-instance.ts diff --git a/package.json b/package.json index 75908d1..7d80e0e 100644 --- a/package.json +++ b/package.json @@ -51,7 +51,8 @@ "nodeArguments": [ "--loader=esbuild-node-loader", "--experimental-specifier-resolution=node", - "--no-warnings" + "--no-warnings", + "--enable-source-maps" ], "extensions": { "ts": "module", diff --git a/packages/helpers/pick.ts b/packages/helpers/pick.ts new file mode 100644 index 0000000..f5312fc --- /dev/null +++ b/packages/helpers/pick.ts @@ -0,0 +1,10 @@ +export function pick( + object: T, + ...keys: K[] +): Pick { + const result: any = {} + for (const key of keys) { + result[key] = object[key] + } + return result +} diff --git a/packages/helpers/types.ts b/packages/helpers/types.ts index 2f782c1..5060b24 100644 --- a/packages/helpers/types.ts +++ b/packages/helpers/types.ts @@ -1 +1,5 @@ export type MaybePromise = T | Promise + +export type ValueOf = Type extends ReadonlyArray + ? Value + : Type[keyof Type] diff --git a/packages/helpers/with-logged-method-calls.ts b/packages/helpers/with-logged-method-calls.ts new file mode 100644 index 0000000..e53fbfc --- /dev/null +++ b/packages/helpers/with-logged-method-calls.ts @@ -0,0 +1,20 @@ +import { inspect } from "node:util" + +export function withLoggedMethodCalls(value: T) { + return new Proxy(value as Record, { + get(target, property) { + const value = target[property] + if (typeof value !== "function") { + return value + } + return (...values: any[]) => { + console.log( + `${String(property)}(${values + .map((value: any) => inspect(value)) + .join(", ")})`, + ) + return value.apply(target, values) + } + }, + }) as T +} diff --git a/packages/integration-tests/tests/rendering.test.tsx b/packages/integration-tests/tests/rendering.test.tsx index 6c0bd75..403e7ef 100644 --- a/packages/integration-tests/tests/rendering.test.tsx +++ b/packages/integration-tests/tests/rendering.test.tsx @@ -1,10 +1,11 @@ import type { ExecutionContext } from "ava" import test from "ava" +import type { Message } from "discord.js" import { Client, TextChannel } from "discord.js" -import { nanoid } from "nanoid" -import { createRoot, Embed } from "reacord" +import { createRoot, Text } from "reacord" +import { pick } from "reacord-helpers/pick.js" import { raise } from "reacord-helpers/raise.js" -import React, { useState } from "react" +import React from "react" import { testBotToken, testChannelId } from "./test-environment.js" const client = new Client({ @@ -32,88 +33,53 @@ test.after(() => { client.destroy() }) -test.only("test", async (t) => { +test.beforeEach(async () => { + const messages = await channel.messages.fetch() + await Promise.all(messages.map((message) => message.delete())) +}) + +test("rendering", async (t) => { const root = createRoot(channel) + + await root.render("hi world") + await assertMessages(t, [{ content: "hi world" }]) + await root.render( <> - - - - + {"hi world"} {"hi moon"} , ) - t.pass() + await assertMessages(t, [{ content: "hi world hi moon" }]) + + await root.render(hi world) + await assertMessages(t, [{ content: "hi world" }]) + + await root.render() + await assertMessages(t, []) + + await root.render([]) + await assertMessages(t, []) }) -test("kitchen sink", async (t) => { - const root = createRoot(channel) +type MessageData = ReturnType +function extractMessageData(message: Message) { + return pick(message, "content", "embeds", "components") +} - const content = nanoid() - await root.render(content) - - await assertSomeMessageHasContent(t, content) - - const newContent = nanoid() - await root.render(newContent) - - await assertSomeMessageHasContent(t, newContent) - - await root.render(false) - - await assertNoMessageHasContent(t, newContent) -}) - -test("kitchen sink, rapid updates", async (t) => { - const root = createRoot(channel) - - const content = nanoid() - const newContent = nanoid() - - void root.render(content) - await root.render(newContent) - - await assertSomeMessageHasContent(t, newContent) - - void root.render(content) - await root.render(false) - - await assertNoMessageHasContent(t, newContent) -}) - -test("state", async (t) => { - let setMessage: (message: string) => void - - const initialMessage = nanoid() - const newMessage = nanoid() - - function Counter() { - const [message, setMessage_] = useState(initialMessage) - setMessage = setMessage_ - return `state: ${message}` as any - } - - const root = createRoot(channel) - await root.render() - - await assertSomeMessageHasContent(t, initialMessage) - - setMessage!(newMessage) - await root.completion() - - await assertSomeMessageHasContent(t, newMessage) - - await root.destroy() -}) - -async function assertSomeMessageHasContent( - t: ExecutionContext, - content: string, +async function assertMessages( + t: ExecutionContext, + expected: Array>, ) { const messages = await channel.messages.fetch() - t.true(messages.some((m) => m.content.includes(content))) -} -async function assertNoMessageHasContent(t: ExecutionContext, content: string) { - const messages = await channel.messages.fetch() - t.true(messages.every((m) => !m.content.includes(content))) + const messageDataFallback: MessageData = { + content: "", + embeds: [], + components: [], + } + + t.deepEqual( + messages.map((message) => extractMessageData(message)), + expected.map((data) => ({ ...messageDataFallback, ...data })), + ) } diff --git a/packages/reacord/package.json b/packages/reacord/package.json index b3f45c2..ba70cfd 100644 --- a/packages/reacord/package.json +++ b/packages/reacord/package.json @@ -12,7 +12,7 @@ "require": "./dist/main.cjs" }, "scripts": { - "build": "tsup src/main.ts --clean --target node16 --format cjs,esm --dts" + "build": "tsup src/main.ts --clean --target node16 --format cjs,esm --dts --sourcemap" }, "keywords": [], "author": "itsMapleLeaf", diff --git a/packages/reacord/src/components/embed.tsx b/packages/reacord/src/components/embed.tsx deleted file mode 100644 index c411b59..0000000 --- a/packages/reacord/src/components/embed.tsx +++ /dev/null @@ -1,107 +0,0 @@ -import type { ColorResolvable, MessageEmbedOptions } from "discord.js" -import { raise } from "reacord-helpers/raise" -import type { ReactNode } from "react" -import * as React from "react" - -export type EmbedProps = { - color?: ColorResolvable - children?: ReactNode -} - -export function Embed(props: EmbedProps) { - const [instance] = React.useState(() => new EmbedInstance()) - return ( - ({ - ...options, - embeds: [ - ...(options.embeds || []), - { - color: props.color, - description: props.children, - }, - ], - })} - /> - ) -} - -export type EmbedTitleProps = { - children?: string - url?: string -} - -export function EmbedTitle(props: EmbedTitleProps) { - const { useEmbedChild } = useEmbedContext() - - useEmbedChild( - React.useCallback( - (options) => { - options.title = props.children - options.url = props.url - }, - [props.children, props.url], - ), - ) - - return <> -} - -function useEmbedContext() { - const instance = - React.useContext(EmbedInstanceContext) ?? - raise("Embed instance provider not found") - - return React.useMemo(() => { - function useEmbedChild( - modifyEmbedOptions: (options: MessageEmbedOptions) => void, - ) { - React.useEffect(() => { - instance.add(modifyEmbedOptions) - return () => instance.remove(modifyEmbedOptions) - }, [modifyEmbedOptions]) - } - - return { useEmbedChild } - }, [instance]) -} - -const EmbedInstanceContext = React.createContext( - undefined, -) - -function EmbedInstanceProvider({ - instance, - children, -}: { - instance: EmbedInstance - children: ReactNode -}) { - return ( - - {children} - - ) -} - -class EmbedInstance { - private children = new Set() - - add(child: EmbedChild) { - this.children.add(child) - } - - remove(child: EmbedChild) { - this.children.delete(child) - } - - getEmbedOptions(): MessageEmbedOptions { - const options: MessageEmbedOptions = {} - for (const child of this.children) { - child(options) - } - return options - } -} - -type EmbedChild = (options: MessageEmbedOptions) => void diff --git a/packages/reacord/src/components/text.tsx b/packages/reacord/src/components/text.tsx new file mode 100644 index 0000000..15255a8 --- /dev/null +++ b/packages/reacord/src/components/text.tsx @@ -0,0 +1,9 @@ +import React from "react" + +export type TextProps = { + children?: string +} + +export function Text(props: TextProps) { + return +} diff --git a/packages/reacord/src/element.ts b/packages/reacord/src/element.ts deleted file mode 100644 index b7a53d6..0000000 --- a/packages/reacord/src/element.ts +++ /dev/null @@ -1,15 +0,0 @@ -import type { MessageOptions } from "discord.js" - -export type ReacordElementJsxTag = "reacord-element" - -export type ReacordElement = { - modifyOptions: (options: MessageOptions) => void -} - -declare global { - namespace JSX { - // eslint-disable-next-line @typescript-eslint/consistent-type-definitions - interface IntrinsicElements - extends Record {} - } -} diff --git a/packages/reacord/src/elements.ts b/packages/reacord/src/elements.ts new file mode 100644 index 0000000..55dec10 --- /dev/null +++ b/packages/reacord/src/elements.ts @@ -0,0 +1,12 @@ +import type { TextProps } from "./components/text.jsx" + +export type ReacordElementMap = { + "reacord-text": TextProps +} + +declare global { + namespace JSX { + // eslint-disable-next-line @typescript-eslint/consistent-type-definitions + interface IntrinsicElements extends ReacordElementMap {} + } +} diff --git a/packages/reacord/src/main.ts b/packages/reacord/src/main.ts index 01bf818..c34b051 100644 --- a/packages/reacord/src/main.ts +++ b/packages/reacord/src/main.ts @@ -1,2 +1,2 @@ -export * from "./components/embed.js" +export * from "./components/text.js" export * from "./root.js" diff --git a/packages/reacord/src/reconciler.ts b/packages/reacord/src/reconciler.ts index bb7f9dd..f27f5da 100644 --- a/packages/reacord/src/reconciler.ts +++ b/packages/reacord/src/reconciler.ts @@ -1,94 +1,76 @@ /* eslint-disable unicorn/no-null */ + import { raise } from "reacord-helpers/raise.js" import ReactReconciler from "react-reconciler" -import type { ReacordContainer } from "./container.js" -import type { ReacordElement, ReacordElementJsxTag } from "./element.js" +import type { ReacordElementMap } from "./elements.js" +import type { ReacordContainer } from "./renderer/container.js" +import { TextElementInstance } from "./renderer/text-element-instance.js" +import { TextInstance } from "./renderer/text-instance.js" export const reconciler = ReactReconciler< - ReacordElementJsxTag, - ReacordElement, - ReacordContainer, - ReacordElement, - string, - unknown, - unknown, - unknown, - unknown, - unknown, - unknown, - unknown, - unknown + keyof ReacordElementMap | (string & { __autocompleteHack__?: never }), // Type, + Record, // Props, + ReacordContainer, // Container, + TextElementInstance, // Instance, + TextInstance, // TextInstance, + never, // SuspenseInstance, + never, // HydratableInstance, + never, // PublicInstance, + null, // HostContext, + never, // UpdatePayload, + never, // ChildSet, + unknown, // TimeoutHandle, + unknown // NoTimeout >({ now: Date.now, isPrimaryRenderer: true, - supportsMutation: false, - supportsPersistence: true, + supportsMutation: true, + supportsPersistence: false, supportsHydration: false, scheduleTimeout: setTimeout, cancelTimeout: clearTimeout, noTimeout: -1, - getRootHostContext: () => ({}), - getChildHostContext: () => ({}), + getRootHostContext: () => null, + getChildHostContext: (parentContext) => parentContext, shouldSetTextContent: () => false, - createInstance: ( - type, - props, - rootContainerInstance, - hostContext, - internalInstanceHandle, - ) => { - return props + createInstance: (type, props) => { + if (type === "reacord-text") { + return new TextElementInstance() + } + raise(`Unknown element type "${type}"`) }, - createTextInstance: ( - text, - rootContainerInstance, - hostContext, - internalInstanceHandle, - ) => { - return text + createTextInstance: (text) => { + return new TextInstance(text) }, - prepareForCommit: () => null, + clearContainer: (container) => { + container.clear() + }, + + appendChildToContainer: (container, child) => { + container.add(child) + }, + + removeChildFromContainer: (container, child) => { + container.remove(child) + }, + + appendInitialChild: (parent, child) => { + parent.add(child) + }, + + removeChild: (parent, child) => { + parent.remove(child) + }, + + finalizeInitialChildren: () => false, + prepareForCommit: (container) => null, resetAfterCommit: () => null, + prepareUpdate: () => null, - appendInitialChild: (parent, child) => raise("Not implemented"), - finalizeInitialChildren: (...args) => { - console.log("finalizeInitialChildren", args) - return false - }, getPublicInstance: () => raise("Not implemented"), - prepareUpdate: () => raise("Not implemented"), preparePortalMount: () => raise("Not implemented"), - - createContainerChildSet: (): ReacordElement[] => { - // console.log("createContainerChildSet", [container]) - return [] - }, - - appendChildToContainerChildSet: ( - children: ReacordElement[], - child: ReacordElement, - ) => { - // console.log("appendChildToContainerChildSet", [children, child]) - children.push(child) - }, - - finalizeContainerChildren: ( - container: ReacordContainer, - children: ReacordElement[], - ) => { - // console.log("finalizeContainerChildren", [container, children]) - return false - }, - - replaceContainerChildren: ( - container: ReacordContainer, - children: ReacordElement[], - ) => { - console.log("replaceContainerChildren", [container, children]) - container.render(children) - }, }) diff --git a/packages/reacord/src/container.ts b/packages/reacord/src/renderer/container.ts similarity index 66% rename from packages/reacord/src/container.ts rename to packages/reacord/src/renderer/container.ts index 8900a29..6f6833c 100644 --- a/packages/reacord/src/container.ts +++ b/packages/reacord/src/renderer/container.ts @@ -1,32 +1,51 @@ import type { Message, MessageOptions, TextBasedChannels } from "discord.js" -import type { ReacordElement } from "./element.js" +import type { TextElementInstance } from "./text-element-instance.js" +import type { TextInstance } from "./text-instance.js" type Action = | { type: "updateMessage"; options: MessageOptions } | { type: "deleteMessage" } +type ReacordContainerChild = TextElementInstance | TextInstance + export class ReacordContainer { private channel: TextBasedChannels private message?: Message private actions: Action[] = [] private runningPromise?: Promise + private instances = new Set() constructor(channel: TextBasedChannels) { this.channel = channel } - render(instances: ReacordElement[]) { - const messageOptions: MessageOptions = { - content: instances.join("") || undefined, // empty strings are not allowed + add(instance: ReacordContainerChild) { + this.instances.add(instance) + this.render() + } + + remove(instance: ReacordContainerChild) { + this.instances.delete(instance) + this.render() + } + + clear() { + this.instances.clear() + this.render() + } + + render() { + const messageOptions: MessageOptions = {} + for (const instance of this.instances) { + instance.render(messageOptions) } - const hasContent = messageOptions.content !== undefined - - this.addAction( - hasContent - ? { type: "updateMessage", options: messageOptions } - : { type: "deleteMessage" }, - ) + // can't render an empty message + if (!messageOptions.content) { + this.addAction({ type: "deleteMessage" }) + } else { + this.addAction({ type: "updateMessage", options: messageOptions }) + } } completion() { diff --git a/packages/reacord/src/renderer/text-element-instance.ts b/packages/reacord/src/renderer/text-element-instance.ts new file mode 100644 index 0000000..30d695d --- /dev/null +++ b/packages/reacord/src/renderer/text-element-instance.ts @@ -0,0 +1,26 @@ +import type { MessageOptions } from "discord.js" +import type { TextInstance } from "./text-instance.js" + +type TextElementInstanceChild = TextElementInstance | TextInstance + +export class TextElementInstance { + children = new Set() + + add(child: TextElementInstanceChild) { + this.children.add(child) + } + + remove(child: TextElementInstanceChild) { + this.children.delete(child) + } + + clear() { + this.children.clear() + } + + render(options: MessageOptions) { + for (const child of this.children) { + child.render(options) + } + } +} diff --git a/packages/reacord/src/renderer/text-instance.ts b/packages/reacord/src/renderer/text-instance.ts new file mode 100644 index 0000000..3ea03ff --- /dev/null +++ b/packages/reacord/src/renderer/text-instance.ts @@ -0,0 +1,13 @@ +import type { MessageOptions } from "discord.js" + +export class TextInstance { + text: string + + constructor(text: string) { + this.text = text + } + + render(options: MessageOptions) { + options.content = `${options.content ?? ""}${this.text}` + } +} diff --git a/packages/reacord/src/root.ts b/packages/reacord/src/root.ts index 4658f9a..5ff6a9a 100644 --- a/packages/reacord/src/root.ts +++ b/packages/reacord/src/root.ts @@ -1,23 +1,18 @@ -/* eslint-disable unicorn/no-null */ import type { TextBasedChannels } from "discord.js" import type { ReactNode } from "react" -import { ReacordContainer } from "./container" import { reconciler } from "./reconciler" +import { ReacordContainer } from "./renderer/container" export type ReacordRenderTarget = TextBasedChannels export function createRoot(target: ReacordRenderTarget) { const container = new ReacordContainer(target) + // eslint-disable-next-line unicorn/no-null const containerId = reconciler.createContainer(container, 0, false, null) return { render: (content: ReactNode) => { reconciler.updateContainer(content, containerId) return container.completion() }, - destroy: () => { - reconciler.updateContainer(null, containerId) - return container.completion() - }, - completion: () => container.completion(), } }