From 174bc22c18ed3f1e46270536b79acf6221d5e699 Mon Sep 17 00:00:00 2001 From: MapleLeaf <19603573+itsMapleLeaf@users.noreply.github.com> Date: Thu, 16 Dec 2021 20:58:48 -0600 Subject: [PATCH] back to immutable mode --- package.json | 5 +- packages/helpers/with-logged-method-calls.ts | 2 +- .../tests/rendering.test.tsx | 24 +++++- packages/reacord/src/components/text.tsx | 3 +- packages/reacord/src/instance.ts | 25 ------ packages/reacord/src/reconciler.ts | 81 ++++++++++++------- packages/reacord/src/renderer/container.ts | 37 +++------ .../src/renderer/text-element-instance.ts | 22 +++-- .../reacord/src/renderer/text-instance.ts | 8 +- packages/reacord/src/root.ts | 7 +- 10 files changed, 110 insertions(+), 104 deletions(-) delete mode 100644 packages/reacord/src/instance.ts diff --git a/package.json b/package.json index f29b4a1..1dc55ff 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,8 @@ "lint": "eslint --ext js,ts,tsx .", "lint-fix": "pnpm lint -- --fix", "format": "prettier --write .", - "test": "c8 ava", + "test": "ava", + "test-coverage": "c8 ava", "typecheck": "tsc --noEmit" }, "devDependencies": { @@ -40,7 +41,7 @@ "**/.vscode/**" ], "parserOptions": { - "project": "./tsconfig.base.json" + "project": "./tsconfig.json" } }, "prettier": "@itsmapleleaf/configs/prettier", diff --git a/packages/helpers/with-logged-method-calls.ts b/packages/helpers/with-logged-method-calls.ts index e213b6f..8c45b68 100644 --- a/packages/helpers/with-logged-method-calls.ts +++ b/packages/helpers/with-logged-method-calls.ts @@ -10,7 +10,7 @@ export function withLoggedMethodCalls(value: T) { return (...values: any[]) => { console.log( `${String(property)}(${values - .map((value: any) => inspect(value, { depth: 1 })) + .map((value) => inspect(value, { depth: 1 })) .join(", ")})`, ) return value.apply(target, values) diff --git a/packages/integration-tests/tests/rendering.test.tsx b/packages/integration-tests/tests/rendering.test.tsx index 403e7ef..1104e37 100644 --- a/packages/integration-tests/tests/rendering.test.tsx +++ b/packages/integration-tests/tests/rendering.test.tsx @@ -38,7 +38,7 @@ test.beforeEach(async () => { await Promise.all(messages.map((message) => message.delete())) }) -test("rendering", async (t) => { +test.serial("basic text & content updates", async (t) => { const root = createRoot(channel) await root.render("hi world") @@ -55,12 +55,32 @@ test("rendering", async (t) => { await assertMessages(t, [{ content: "hi world" }]) await root.render() - await assertMessages(t, []) + await assertMessages(t, [{ content: "_ _" }]) + + await root.render(hi world) + await assertMessages(t, [{ content: "hi world" }]) await root.render([]) + await assertMessages(t, [{ content: "_ _" }]) + + await root.destroy() await assertMessages(t, []) }) +test.serial("nested text", async (t) => { + const root = createRoot(channel) + + await root.render( + + hi world{" "} + + hi moon hi sun + + , + ) + await assertMessages(t, [{ content: "hi world hi moon hi sun" }]) +}) + type MessageData = ReturnType function extractMessageData(message: Message) { return pick(message, "content", "embeds", "components") diff --git a/packages/reacord/src/components/text.tsx b/packages/reacord/src/components/text.tsx index 15255a8..e17e7fa 100644 --- a/packages/reacord/src/components/text.tsx +++ b/packages/reacord/src/components/text.tsx @@ -1,7 +1,8 @@ +import type { ReactNode } from "react" import React from "react" export type TextProps = { - children?: string + children?: ReactNode } export function Text(props: TextProps) { diff --git a/packages/reacord/src/instance.ts b/packages/reacord/src/instance.ts deleted file mode 100644 index 175f97b..0000000 --- a/packages/reacord/src/instance.ts +++ /dev/null @@ -1,25 +0,0 @@ -import type { Message, TextBasedChannels } from "discord.js" - -export class ReacordInstance { - message?: Message - content: string - - constructor(content: string) { - this.content = content - } - - render(channel: TextBasedChannels) { - if (this.message) { - this.message.edit(this.content).catch(console.error) - } else { - channel.send(this.content).then((message) => { - this.message = message - }, console.error) - } - } - - destroy() { - this.message?.delete().catch(console.error) - this.message?.channel.messages.cache.delete(this.message?.id) - } -} diff --git a/packages/reacord/src/reconciler.ts b/packages/reacord/src/reconciler.ts index f27f5da..d4a84c9 100644 --- a/packages/reacord/src/reconciler.ts +++ b/packages/reacord/src/reconciler.ts @@ -1,5 +1,4 @@ /* eslint-disable unicorn/no-null */ - import { raise } from "reacord-helpers/raise.js" import ReactReconciler from "react-reconciler" import type { ReacordElementMap } from "./elements.js" @@ -7,25 +6,47 @@ import type { ReacordContainer } from "./renderer/container.js" import { TextElementInstance } from "./renderer/text-element-instance.js" import { TextInstance } from "./renderer/text-instance.js" +// instances that represent an element +type ElementInstance = TextElementInstance + +// any instance +type Instance = ElementInstance | TextInstance + +type ElementTag = + | keyof ReacordElementMap + | (string & { __autocompleteHack__?: never }) + +type Props = Record + +const createInstance = ( + type: ElementTag, + props: Props, +): TextElementInstance => { + if (type === "reacord-text") { + return new TextElementInstance() + } + raise(`Unknown element type "${type}"`) +} + export const reconciler = ReactReconciler< - keyof ReacordElementMap | (string & { __autocompleteHack__?: never }), // Type, - Record, // Props, + ElementTag, // Type, + Props, // Props, ReacordContainer, // Container, - TextElementInstance, // Instance, + ElementInstance, // Instance, TextInstance, // TextInstance, never, // SuspenseInstance, never, // HydratableInstance, never, // PublicInstance, null, // HostContext, never, // UpdatePayload, - never, // ChildSet, + Instance[], // ChildSet, unknown, // TimeoutHandle, unknown // NoTimeout >({ now: Date.now, isPrimaryRenderer: true, - supportsMutation: true, - supportsPersistence: false, + supportsMutation: false, + supportsPersistence: true, supportsHydration: false, scheduleTimeout: setTimeout, cancelTimeout: clearTimeout, @@ -35,42 +56,44 @@ export const reconciler = ReactReconciler< getChildHostContext: (parentContext) => parentContext, shouldSetTextContent: () => false, - createInstance: (type, props) => { - if (type === "reacord-text") { - return new TextElementInstance() - } - raise(`Unknown element type "${type}"`) + createInstance, + + createTextInstance: (text) => new TextInstance(text), + + createContainerChildSet: () => [], + + appendChildToContainerChildSet: (childSet: Instance[], child: Instance) => { + childSet.push(child) }, - createTextInstance: (text) => { - return new TextInstance(text) - }, + finalizeContainerChildren: ( + container: ReacordContainer, + children: Instance[], + ) => false, - clearContainer: (container) => { - container.clear() - }, - - appendChildToContainer: (container, child) => { - container.add(child) - }, - - removeChildFromContainer: (container, child) => { - container.remove(child) + replaceContainerChildren: ( + container: ReacordContainer, + children: Instance[], + ) => { + container.render(children) }, appendInitialChild: (parent, child) => { parent.add(child) }, - removeChild: (parent, child) => { - parent.remove(child) - }, + cloneInstance: ( + instance: Instance, + _: unknown, + type: ElementTag, + oldProps: Props, + newProps: Props, + ) => createInstance(type, newProps), finalizeInitialChildren: () => false, prepareForCommit: (container) => null, resetAfterCommit: () => null, prepareUpdate: () => null, - getPublicInstance: () => raise("Not implemented"), preparePortalMount: () => raise("Not implemented"), }) diff --git a/packages/reacord/src/renderer/container.ts b/packages/reacord/src/renderer/container.ts index 6f6833c..4a4b568 100644 --- a/packages/reacord/src/renderer/container.ts +++ b/packages/reacord/src/renderer/container.ts @@ -6,46 +6,35 @@ type Action = | { type: "updateMessage"; options: MessageOptions } | { type: "deleteMessage" } -type ReacordContainerChild = TextElementInstance | TextInstance +type ContainerChild = TextInstance | TextElementInstance 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 } - 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() { + render(children: ContainerChild[]) { const messageOptions: MessageOptions = {} - for (const instance of this.instances) { - instance.render(messageOptions) + for (const child of children) { + child.renderToMessage(messageOptions) } // can't render an empty message - if (!messageOptions.content) { - this.addAction({ type: "deleteMessage" }) - } else { - this.addAction({ type: "updateMessage", options: messageOptions }) + if (!messageOptions?.content) { + messageOptions.content = "_ _" } + + this.addAction({ type: "updateMessage", options: messageOptions }) + } + + destroy() { + this.actions = [] + this.addAction({ type: "deleteMessage" }) } completion() { diff --git a/packages/reacord/src/renderer/text-element-instance.ts b/packages/reacord/src/renderer/text-element-instance.ts index 30d695d..c40e3ba 100644 --- a/packages/reacord/src/renderer/text-element-instance.ts +++ b/packages/reacord/src/renderer/text-element-instance.ts @@ -1,26 +1,22 @@ import type { MessageOptions } from "discord.js" import type { TextInstance } from "./text-instance.js" -type TextElementInstanceChild = TextElementInstance | TextInstance +type TextElementChild = TextElementInstance | TextInstance export class TextElementInstance { - children = new Set() + children = new Set() - add(child: TextElementInstanceChild) { + add(child: TextElementChild) { this.children.add(child) } - remove(child: TextElementInstanceChild) { - this.children.delete(child) - } - - clear() { - this.children.clear() - } - - render(options: MessageOptions) { + renderToMessage(options: MessageOptions) { for (const child of this.children) { - child.render(options) + options.content = `${options.content ?? ""}${child.text}` } } + + get text(): string { + return [...this.children].map((child) => child.text).join("") + } } diff --git a/packages/reacord/src/renderer/text-instance.ts b/packages/reacord/src/renderer/text-instance.ts index 3ea03ff..7e0c54e 100644 --- a/packages/reacord/src/renderer/text-instance.ts +++ b/packages/reacord/src/renderer/text-instance.ts @@ -1,13 +1,9 @@ import type { MessageOptions } from "discord.js" export class TextInstance { - text: string + constructor(readonly text: string) {} - constructor(text: string) { - this.text = text - } - - render(options: MessageOptions) { + renderToMessage(options: MessageOptions) { options.content = `${options.content ?? ""}${this.text}` } } diff --git a/packages/reacord/src/root.ts b/packages/reacord/src/root.ts index 5ff6a9a..5d4be0d 100644 --- a/packages/reacord/src/root.ts +++ b/packages/reacord/src/root.ts @@ -1,3 +1,4 @@ +/* eslint-disable unicorn/no-null */ import type { TextBasedChannels } from "discord.js" import type { ReactNode } from "react" import { reconciler } from "./reconciler" @@ -7,12 +8,16 @@ 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) + container.destroy() + return container.completion() + }, } }