rearchitecting wip

This commit is contained in:
MapleLeaf
2021-12-16 19:01:37 -06:00
parent ffc91900d2
commit 460e7cde1a
16 changed files with 224 additions and 289 deletions

View File

@@ -51,7 +51,8 @@
"nodeArguments": [ "nodeArguments": [
"--loader=esbuild-node-loader", "--loader=esbuild-node-loader",
"--experimental-specifier-resolution=node", "--experimental-specifier-resolution=node",
"--no-warnings" "--no-warnings",
"--enable-source-maps"
], ],
"extensions": { "extensions": {
"ts": "module", "ts": "module",

10
packages/helpers/pick.ts Normal file
View File

@@ -0,0 +1,10 @@
export function pick<T, K extends keyof T>(
object: T,
...keys: K[]
): Pick<T, K> {
const result: any = {}
for (const key of keys) {
result[key] = object[key]
}
return result
}

View File

@@ -1 +1,5 @@
export type MaybePromise<T> = T | Promise<T> export type MaybePromise<T> = T | Promise<T>
export type ValueOf<Type> = Type extends ReadonlyArray<infer Value>
? Value
: Type[keyof Type]

View File

@@ -0,0 +1,20 @@
import { inspect } from "node:util"
export function withLoggedMethodCalls<T extends object>(value: T) {
return new Proxy(value as Record<string | symbol, unknown>, {
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
}

View File

@@ -1,10 +1,11 @@
import type { ExecutionContext } from "ava" import type { ExecutionContext } from "ava"
import test from "ava" import test from "ava"
import type { Message } from "discord.js"
import { Client, TextChannel } from "discord.js" import { Client, TextChannel } from "discord.js"
import { nanoid } from "nanoid" import { createRoot, Text } from "reacord"
import { createRoot, Embed } from "reacord" import { pick } from "reacord-helpers/pick.js"
import { raise } from "reacord-helpers/raise.js" import { raise } from "reacord-helpers/raise.js"
import React, { useState } from "react" import React from "react"
import { testBotToken, testChannelId } from "./test-environment.js" import { testBotToken, testChannelId } from "./test-environment.js"
const client = new Client({ const client = new Client({
@@ -32,88 +33,53 @@ test.after(() => {
client.destroy() 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) const root = createRoot(channel)
await root.render("hi world")
await assertMessages(t, [{ content: "hi world" }])
await root.render( await root.render(
<> <>
<Embed color="BLUE"> {"hi world"} {"hi moon"}
<Embed color="DARKER_GREY" />
</Embed>
<Embed color="DARKER_GREY" />
</>, </>,
) )
t.pass() await assertMessages(t, [{ content: "hi world hi moon" }])
await root.render(<Text>hi world</Text>)
await assertMessages(t, [{ content: "hi world" }])
await root.render(<Text></Text>)
await assertMessages(t, [])
await root.render([])
await assertMessages(t, [])
}) })
test("kitchen sink", async (t) => { type MessageData = ReturnType<typeof extractMessageData>
const root = createRoot(channel) function extractMessageData(message: Message) {
return pick(message, "content", "embeds", "components")
}
const content = nanoid() async function assertMessages(
await root.render(content) t: ExecutionContext<unknown>,
expected: Array<Partial<MessageData>>,
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(<Counter />)
await assertSomeMessageHasContent(t, initialMessage)
setMessage!(newMessage)
await root.completion()
await assertSomeMessageHasContent(t, newMessage)
await root.destroy()
})
async function assertSomeMessageHasContent(
t: ExecutionContext,
content: string,
) { ) {
const messages = await channel.messages.fetch() const messages = await channel.messages.fetch()
t.true(messages.some((m) => m.content.includes(content)))
}
async function assertNoMessageHasContent(t: ExecutionContext, content: string) { const messageDataFallback: MessageData = {
const messages = await channel.messages.fetch() content: "",
t.true(messages.every((m) => !m.content.includes(content))) embeds: [],
components: [],
}
t.deepEqual(
messages.map((message) => extractMessageData(message)),
expected.map((data) => ({ ...messageDataFallback, ...data })),
)
} }

View File

@@ -12,7 +12,7 @@
"require": "./dist/main.cjs" "require": "./dist/main.cjs"
}, },
"scripts": { "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": [], "keywords": [],
"author": "itsMapleLeaf", "author": "itsMapleLeaf",

View File

@@ -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 (
<reacord-element
modifyOptions={(options) => ({
...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<EmbedInstance | undefined>(
undefined,
)
function EmbedInstanceProvider({
instance,
children,
}: {
instance: EmbedInstance
children: ReactNode
}) {
return (
<EmbedInstanceContext.Provider value={instance}>
{children}
</EmbedInstanceContext.Provider>
)
}
class EmbedInstance {
private children = new Set<EmbedChild>()
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

View File

@@ -0,0 +1,9 @@
import React from "react"
export type TextProps = {
children?: string
}
export function Text(props: TextProps) {
return <reacord-text {...props} />
}

View File

@@ -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<ReacordElementJsxTag, ReacordElement> {}
}
}

View File

@@ -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 {}
}
}

View File

@@ -1,2 +1,2 @@
export * from "./components/embed.js" export * from "./components/text.js"
export * from "./root.js" export * from "./root.js"

View File

@@ -1,94 +1,76 @@
/* eslint-disable unicorn/no-null */ /* eslint-disable unicorn/no-null */
import { raise } from "reacord-helpers/raise.js" import { raise } from "reacord-helpers/raise.js"
import ReactReconciler from "react-reconciler" import ReactReconciler from "react-reconciler"
import type { ReacordContainer } from "./container.js" import type { ReacordElementMap } from "./elements.js"
import type { ReacordElement, ReacordElementJsxTag } from "./element.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< export const reconciler = ReactReconciler<
ReacordElementJsxTag, keyof ReacordElementMap | (string & { __autocompleteHack__?: never }), // Type,
ReacordElement, Record<string, unknown>, // Props,
ReacordContainer, ReacordContainer, // Container,
ReacordElement, TextElementInstance, // Instance,
string, TextInstance, // TextInstance,
unknown, never, // SuspenseInstance,
unknown, never, // HydratableInstance,
unknown, never, // PublicInstance,
unknown, null, // HostContext,
unknown, never, // UpdatePayload,
unknown, never, // ChildSet,
unknown, unknown, // TimeoutHandle,
unknown unknown // NoTimeout
>({ >({
now: Date.now, now: Date.now,
isPrimaryRenderer: true, isPrimaryRenderer: true,
supportsMutation: false, supportsMutation: true,
supportsPersistence: true, supportsPersistence: false,
supportsHydration: false, supportsHydration: false,
scheduleTimeout: setTimeout, scheduleTimeout: setTimeout,
cancelTimeout: clearTimeout, cancelTimeout: clearTimeout,
noTimeout: -1, noTimeout: -1,
getRootHostContext: () => ({}), getRootHostContext: () => null,
getChildHostContext: () => ({}), getChildHostContext: (parentContext) => parentContext,
shouldSetTextContent: () => false, shouldSetTextContent: () => false,
createInstance: ( createInstance: (type, props) => {
type, if (type === "reacord-text") {
props, return new TextElementInstance()
rootContainerInstance, }
hostContext, raise(`Unknown element type "${type}"`)
internalInstanceHandle,
) => {
return props
}, },
createTextInstance: ( createTextInstance: (text) => {
text, return new TextInstance(text)
rootContainerInstance,
hostContext,
internalInstanceHandle,
) => {
return 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, resetAfterCommit: () => null,
prepareUpdate: () => null,
appendInitialChild: (parent, child) => raise("Not implemented"),
finalizeInitialChildren: (...args) => {
console.log("finalizeInitialChildren", args)
return false
},
getPublicInstance: () => raise("Not implemented"), getPublicInstance: () => raise("Not implemented"),
prepareUpdate: () => raise("Not implemented"),
preparePortalMount: () => 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)
},
}) })

View File

@@ -1,32 +1,51 @@
import type { Message, MessageOptions, TextBasedChannels } from "discord.js" 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 Action =
| { type: "updateMessage"; options: MessageOptions } | { type: "updateMessage"; options: MessageOptions }
| { type: "deleteMessage" } | { type: "deleteMessage" }
type ReacordContainerChild = TextElementInstance | TextInstance
export class ReacordContainer { export class ReacordContainer {
private channel: TextBasedChannels private channel: TextBasedChannels
private message?: Message private message?: Message
private actions: Action[] = [] private actions: Action[] = []
private runningPromise?: Promise<void> private runningPromise?: Promise<void>
private instances = new Set<ReacordContainerChild>()
constructor(channel: TextBasedChannels) { constructor(channel: TextBasedChannels) {
this.channel = channel this.channel = channel
} }
render(instances: ReacordElement[]) { add(instance: ReacordContainerChild) {
const messageOptions: MessageOptions = { this.instances.add(instance)
content: instances.join("") || undefined, // empty strings are not allowed this.render()
} }
const hasContent = messageOptions.content !== undefined remove(instance: ReacordContainerChild) {
this.instances.delete(instance)
this.render()
}
this.addAction( clear() {
hasContent this.instances.clear()
? { type: "updateMessage", options: messageOptions } this.render()
: { type: "deleteMessage" }, }
)
render() {
const messageOptions: MessageOptions = {}
for (const instance of this.instances) {
instance.render(messageOptions)
}
// can't render an empty message
if (!messageOptions.content) {
this.addAction({ type: "deleteMessage" })
} else {
this.addAction({ type: "updateMessage", options: messageOptions })
}
} }
completion() { completion() {

View File

@@ -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<TextElementInstanceChild>()
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)
}
}
}

View File

@@ -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}`
}
}

View File

@@ -1,23 +1,18 @@
/* eslint-disable unicorn/no-null */
import type { TextBasedChannels } from "discord.js" import type { TextBasedChannels } from "discord.js"
import type { ReactNode } from "react" import type { ReactNode } from "react"
import { ReacordContainer } from "./container"
import { reconciler } from "./reconciler" import { reconciler } from "./reconciler"
import { ReacordContainer } from "./renderer/container"
export type ReacordRenderTarget = TextBasedChannels export type ReacordRenderTarget = TextBasedChannels
export function createRoot(target: ReacordRenderTarget) { export function createRoot(target: ReacordRenderTarget) {
const container = new ReacordContainer(target) const container = new ReacordContainer(target)
// eslint-disable-next-line unicorn/no-null
const containerId = reconciler.createContainer(container, 0, false, null) const containerId = reconciler.createContainer(container, 0, false, null)
return { return {
render: (content: ReactNode) => { render: (content: ReactNode) => {
reconciler.updateContainer(content, containerId) reconciler.updateContainer(content, containerId)
return container.completion() return container.completion()
}, },
destroy: () => {
reconciler.updateContainer(null, containerId)
return container.completion()
},
completion: () => container.completion(),
} }
} }