diff --git a/packages/solid/bunfig.toml b/packages/solid/bunfig.toml index 7693482f3..229439d37 100644 --- a/packages/solid/bunfig.toml +++ b/packages/solid/bunfig.toml @@ -1 +1,5 @@ preload = ["@opentui/solid/preload"] + +[test] +preload = ["@opentui/solid/preload"] +root = "tests" diff --git a/packages/solid/package.json b/packages/solid/package.json index e193d32b2..c2f3fe387 100644 --- a/packages/solid/package.json +++ b/packages/solid/package.json @@ -15,7 +15,7 @@ "scripts": { "build": "bun scripts/build.ts", "publish": "bun scripts/publish.ts", - "test": "bun --conditions=browser --preload=./scripts/preload.ts test" + "test": "bun test" }, "exports": { ".": { diff --git a/packages/solid/src/elements/extras.ts b/packages/solid/src/elements/extras.ts new file mode 100644 index 000000000..556829d7b --- /dev/null +++ b/packages/solid/src/elements/extras.ts @@ -0,0 +1,98 @@ +import { createEffect, createMemo, getOwner, onCleanup, runWithOwner, splitProps, untrack } from "solid-js" +import { createSlotNode, createElement, insert, spread, type DomNode } from "../reconciler" +import type { JSX } from "../../jsx-runtime" +import type { ValidComponent, ComponentProps } from "solid-js" +import { useRenderer } from "./hooks" + +/** + * Renders components somewhere else in the DOM + * + * Useful for inserting modals and tooltips outside of an cropping layout. If no mount point is given, the portal is inserted on the root renderable; it is wrapped in a `` + * + * @description https://docs.solidjs.com/reference/components/portal + */ +export function Portal(props: { mount?: DomNode; ref?: (el: {}) => void; children: JSX.Element }): DomNode { + const renderer = useRenderer() + + const marker = createSlotNode(), + mount = () => props.mount || renderer.root, + owner = getOwner() + let content: undefined | (() => JSX.Element) + + createEffect( + () => { + // basically we backdoor into a sort of renderEffect here + content || (content = runWithOwner(owner, () => createMemo(() => props.children))) + const el = mount() + const container = createElement("box"), + renderRoot = container + + Object.defineProperty(container, "_$host", { + get() { + return marker.parent + }, + configurable: true, + }) + insert(renderRoot, content) + el.add(container) + props.ref && (props as any).ref(container) + onCleanup(() => el.remove(container.id)) + }, + undefined, + { render: true }, + ) + return marker +} + +export type DynamicProps> = { + [K in keyof P]: P[K] +} & { + component: T | undefined +} + +/** + * Renders an arbitrary component or element with the given props + * + * This is a lower level version of the `Dynamic` component, useful for + * performance optimizations in libraries. Do not use this unless you know + * what you are doing. + * ```typescript + * const element = () => multiline() ? 'textarea' : 'input'; + * createDynamic(element, { value: value() }); + * ``` + * @description https://docs.solidjs.com/reference/components/dynamic + */ +export function createDynamic( + component: () => T | undefined, + props: ComponentProps, +): JSX.Element { + const cached = createMemo(component) + return createMemo(() => { + const component = cached() + switch (typeof component) { + case "function": + // if (isDev) Object.assign(component, { [$DEVCOMP]: true }) + return untrack(() => component(props)) + + case "string": + const el = createElement(component) + spread(el, props) + return el + + default: + break + } + }) as unknown as JSX.Element +} + +/** + * Renders an arbitrary custom or native component and passes the other props + * ```typescript + * + * ``` + * @description https://docs.solidjs.com/reference/components/dynamic + */ +export function Dynamic(props: DynamicProps): JSX.Element { + const [, others] = splitProps(props, ["component"]) + return createDynamic(() => props.component, others as ComponentProps) +} diff --git a/packages/solid/src/elements/index.ts b/packages/solid/src/elements/index.ts index fe2e2f277..d9f3e47b8 100644 --- a/packages/solid/src/elements/index.ts +++ b/packages/solid/src/elements/index.ts @@ -13,6 +13,8 @@ import { } from "@opentui/core" import type { RenderableConstructor } from "../types/elements" export * from "./hooks" +export * from "./extras" +export * from "./slot" class SpanRenderable extends TextNodeRenderable { constructor( diff --git a/packages/solid/src/elements/slot.ts b/packages/solid/src/elements/slot.ts new file mode 100644 index 000000000..bf1253d55 --- /dev/null +++ b/packages/solid/src/elements/slot.ts @@ -0,0 +1,130 @@ +import { BaseRenderable, isTextNodeRenderable, TextNodeRenderable, TextRenderable } from "@opentui/core" +import Yoga, { Display, type Node as YogaNode } from "yoga-layout" + +class SlotBaseRenderable extends BaseRenderable { + constructor(id: string) { + super({ + id, + }) + } + + public add(obj: BaseRenderable | unknown, index?: number): number { + throw new Error("Can't add children on an Slot renderable") + } + + public getChildren(): BaseRenderable[] { + return [] + } + + public remove(id: string): void {} + + public insertBefore(obj: BaseRenderable | unknown, anchor: BaseRenderable | unknown): void { + throw new Error("Can't add children on an Slot renderable") + } + + public getRenderable(id: string): BaseRenderable | undefined { + return undefined + } + + public getChildrenCount(): number { + return 0 + } + + public requestRender(): void {} +} + +export class TextSlotRenderable extends TextNodeRenderable { + protected slotParent?: SlotRenderable + protected destroyed: boolean = false + + constructor(id: string, parent?: SlotRenderable) { + super({ id: id }) + this._visible = false + this.slotParent = parent + } + + public override destroy(): void { + if (this.destroyed) { + return + } + this.destroyed = true + + this.slotParent?.destroy() + super.destroy() + } +} + +export class LayoutSlotRenderable extends SlotBaseRenderable { + protected yogaNode: YogaNode + protected slotParent?: SlotRenderable + protected destroyed: boolean = false + + constructor(id: string, parent?: SlotRenderable) { + super(id) + + this._visible = false + this.slotParent = parent + this.yogaNode = Yoga.Node.create() + this.yogaNode.setDisplay(Display.None) + } + + public getLayoutNode(): YogaNode { + return this.yogaNode + } + + public updateFromLayout() {} + + public updateLayout() {} + + public onRemove() {} + + public override destroy(): void { + if (this.destroyed) { + return + } + this.destroyed = true + + super.destroy() + this.slotParent?.destroy() + } +} + +export class SlotRenderable extends SlotBaseRenderable { + layoutNode?: LayoutSlotRenderable + textNode?: TextSlotRenderable + protected destroyed: boolean = false + + constructor(id: string) { + super(id) + + this._visible = false + } + + getSlotChild(parent: BaseRenderable) { + if (isTextNodeRenderable(parent) || parent instanceof TextRenderable) { + if (!this.textNode) { + this.textNode = new TextSlotRenderable(`slot-text-${this.id}`, this) + } + return this.textNode + } + + if (!this.layoutNode) { + this.layoutNode = new LayoutSlotRenderable(`slot-layout-${this.id}`, this) + } + return this.layoutNode + } + + public override destroy(): void { + if (this.destroyed) { + return + } + this.destroyed = true + + if (this.layoutNode) { + this.layoutNode.destroy() + } + if (this.textNode) { + this.textNode.destroy() + } + } +} diff --git a/packages/solid/src/reconciler.ts b/packages/solid/src/reconciler.ts index 1632ca02f..18300551a 100644 --- a/packages/solid/src/reconciler.ts +++ b/packages/solid/src/reconciler.ts @@ -17,8 +17,8 @@ import { type TextNodeOptions, } from "@opentui/core" import { useContext } from "solid-js" -import { createRenderer } from "solid-js/universal" -import { getComponentCatalogue, RendererContext } from "./elements" +import { createRenderer } from "./renderer" +import { getComponentCatalogue, RendererContext, SlotRenderable } from "./elements" import { getNextId } from "./utils/id-counter" import { log } from "./utils/log" @@ -64,18 +64,30 @@ function _insertNode(parent: DomNode, node: DomNode, anchor?: DomNode): void { node instanceof TextNode, ) + if (node instanceof SlotRenderable) { + node = node.getSlotChild(parent) + } + + if (anchor && anchor instanceof SlotRenderable) { + anchor = anchor.getSlotChild(parent) + } + if (isTextNodeRenderable(node)) { if (!(parent instanceof TextRenderable) && !isTextNodeRenderable(parent)) { - // TODO this can happen naturally with match and show, probably should handle better - log(`Text must have a as a parent: ${parent.id} above ${node.id}`) - return + throw new Error( + `Orphan text error: "${node + .toChunks() + .map((c) => c.text) + .join("")}" must have a as a parent: ${parent.id} above ${node.id}`, + ) } } // Renderable nodes if (!(parent instanceof BaseRenderable)) { - log("[INSERT]", "Tried to mount a non base renderable") - return + console.error("[INSERT]", "Tried to mount a non base renderable") + // Can't be a noop, have to panic + throw new Error("Tried to mount a non base renderable") } if (!anchor) { @@ -96,10 +108,22 @@ function _insertNode(parent: DomNode, node: DomNode, anchor?: DomNode): void { function _removeNode(parent: DomNode, node: DomNode): void { log("Removing node:", logId(node), "from parent:", logId(parent)) - parent.remove(node.id) + if (node instanceof SlotRenderable) { + node = node.getSlotChild(parent) + } + + if (isTextNodeRenderable(parent)) { + if (typeof node !== "string" && !isTextNodeRenderable(node)) { + console.warn("Node not a valid child of TextNode") + } else { + parent.remove(node) + } + } else { + parent.remove(node.id) + } process.nextTick(() => { - if (node instanceof Renderable && !node.parent) { + if (node instanceof BaseRenderable && !node.parent) { node.destroyRecursively() return } @@ -118,6 +142,12 @@ function _createTextNode(value: string | number): TextNode { return TextNode.fromString(value, { id }) } +export function createSlotNode(): SlotRenderable { + const id = getNextId("slot-node") + log("Creating slot node", id) + return new SlotRenderable(id) +} + function _getParentNode(childNode: DomNode): DomNode | undefined { log("Getting parent of node:", logId(childNode)) @@ -162,6 +192,8 @@ export const { createTextNode: _createTextNode, + createSlotNode, + replaceText(textNode: TextNode, value: string): void { log("Replacing text:", value, "in node:", logId(textNode)) diff --git a/packages/solid/src/renderer/index.ts b/packages/solid/src/renderer/index.ts new file mode 100644 index 000000000..8bed33b8a --- /dev/null +++ b/packages/solid/src/renderer/index.ts @@ -0,0 +1,11 @@ +import { createRenderer as createRendererDX } from "./universal.js" +import type { RendererOptions, Renderer } from "./universal.js" +import { mergeProps } from "solid-js" + +export type { RendererOptions, Renderer } from "./universal.js" + +export function createRenderer(options: RendererOptions): Renderer { + const renderer = createRendererDX(options) + renderer.mergeProps = mergeProps + return renderer +} diff --git a/packages/solid/src/renderer/universal.d.ts b/packages/solid/src/renderer/universal.d.ts new file mode 100644 index 000000000..548ff14e1 --- /dev/null +++ b/packages/solid/src/renderer/universal.d.ts @@ -0,0 +1,30 @@ +export interface RendererOptions { + createElement(tag: string): NodeType + createTextNode(value: string): NodeType + createSlotNode(): NodeType + replaceText(textNode: NodeType, value: string): void + isTextNode(node: NodeType): boolean + setProperty(node: NodeType, name: string, value: T, prev?: T): void + insertNode(parent: NodeType, node: NodeType, anchor?: NodeType): void + removeNode(parent: NodeType, node: NodeType): void + getParentNode(node: NodeType): NodeType | undefined + getFirstChild(node: NodeType): NodeType | undefined + getNextSibling(node: NodeType): NodeType | undefined +} + +export interface Renderer { + render(code: () => NodeType, node: NodeType): () => void + effect(fn: (prev?: T) => T, init?: T): void + memo(fn: () => T, equal: boolean): () => T + createComponent(Comp: (props: T) => NodeType, props: T): NodeType + createElement(tag: string): NodeType + createTextNode(value: string): NodeType + insertNode(parent: NodeType, node: NodeType, anchor?: NodeType): void + insert(parent: any, accessor: (() => T) | T, marker?: any | null, initial?: any): NodeType + spread(node: any, accessor: (() => T) | T, skipChildren?: boolean): void + setProp(node: NodeType, name: string, value: T, prev?: T): T + mergeProps(...sources: unknown[]): unknown + use(fn: (element: NodeType, arg: A) => T, element: NodeType, arg: A): T +} + +export function createRenderer(options: RendererOptions): Renderer diff --git a/packages/solid/src/renderer/universal.js b/packages/solid/src/renderer/universal.js new file mode 100644 index 000000000..48dd83b93 --- /dev/null +++ b/packages/solid/src/renderer/universal.js @@ -0,0 +1,238 @@ +import { createRoot, createRenderEffect, createMemo, createComponent, untrack, mergeProps } from "solid-js" + +const memo = (fn) => createMemo(() => fn()) + +export function createRenderer({ + createElement, + createTextNode, + createSlotNode, + isTextNode, + replaceText, + insertNode, + removeNode, + setProperty, + getParentNode, + getFirstChild, + getNextSibling, +}) { + function insert(parent, accessor, marker, initial) { + if (marker !== undefined && !initial) initial = [] + if (typeof accessor !== "function") return insertExpression(parent, accessor, initial, marker) + createRenderEffect((current) => insertExpression(parent, accessor(), current, marker), initial) + } + function insertExpression(parent, value, current, marker, unwrapArray) { + while (typeof current === "function") current = current() + if (value === current) return current + const t = typeof value, + multi = marker !== undefined + if (t === "string" || t === "number") { + if (t === "number") value = value.toString() + if (multi) { + let node = current[0] + if (node && isTextNode(node)) { + replaceText(node, value) + } else node = createTextNode(value) + current = cleanChildren(parent, current, marker, node) + } else { + if (current !== "" && typeof current === "string") { + replaceText(getFirstChild(parent), (current = value)) + } else { + cleanChildren(parent, current, marker, createTextNode(value)) + current = value + } + } + } else if (value == null || t === "boolean") { + current = cleanChildren(parent, current, marker) + } else if (t === "function") { + createRenderEffect(() => { + let v = value() + while (typeof v === "function") v = v() + current = insertExpression(parent, v, current, marker) + }) + return () => current + } else if (Array.isArray(value)) { + const array = [] + if (normalizeIncomingArray(array, value, unwrapArray)) { + createRenderEffect(() => (current = insertExpression(parent, array, current, marker, true))) + return () => current + } + if (array.length === 0) { + const replacement = cleanChildren(parent, current, marker) + if (multi) return (current = replacement) + } else { + if (Array.isArray(current)) { + if (current.length === 0) { + appendNodes(parent, array, marker) + } else reconcileArrays(parent, current, array) + } else if (current == null || current === "") { + appendNodes(parent, array) + } else { + reconcileArrays(parent, (multi && current) || [getFirstChild(parent)], array) + } + } + current = array + } else { + if (Array.isArray(current)) { + if (multi) return (current = cleanChildren(parent, current, marker, value)) + cleanChildren(parent, current, null, value) + } else if (current == null || current === "" || !getFirstChild(parent)) { + insertNode(parent, value) + } else replaceNode(parent, value, getFirstChild(parent)) + current = value + } + return current + } + function normalizeIncomingArray(normalized, array, unwrap) { + let dynamic = false + for (let i = 0, len = array.length; i < len; i++) { + let item = array[i], + t + if (item == null || item === true || item === false); + else if (Array.isArray(item)) { + dynamic = normalizeIncomingArray(normalized, item) || dynamic + } else if ((t = typeof item) === "string" || t === "number") { + normalized.push(createTextNode(item)) + } else if (t === "function") { + if (unwrap) { + while (typeof item === "function") item = item() + dynamic = normalizeIncomingArray(normalized, Array.isArray(item) ? item : [item]) || dynamic + } else { + normalized.push(item) + dynamic = true + } + } else normalized.push(item) + } + return dynamic + } + function reconcileArrays(parentNode, a, b) { + let bLength = b.length, + aEnd = a.length, + bEnd = bLength, + aStart = 0, + bStart = 0, + after = getNextSibling(a[aEnd - 1]), + map = null + while (aStart < aEnd || bStart < bEnd) { + if (a[aStart] === b[bStart]) { + aStart++ + bStart++ + continue + } + while (a[aEnd - 1] === b[bEnd - 1]) { + aEnd-- + bEnd-- + } + if (aEnd === aStart) { + const node = bEnd < bLength ? (bStart ? getNextSibling(b[bStart - 1]) : b[bEnd - bStart]) : after + while (bStart < bEnd) insertNode(parentNode, b[bStart++], node) + } else if (bEnd === bStart) { + while (aStart < aEnd) { + if (!map || !map.has(a[aStart])) removeNode(parentNode, a[aStart]) + aStart++ + } + } else if (a[aStart] === b[bEnd - 1] && b[bStart] === a[aEnd - 1]) { + const node = getNextSibling(a[--aEnd]) + insertNode(parentNode, b[bStart++], getNextSibling(a[aStart++])) + insertNode(parentNode, b[--bEnd], node) + a[aEnd] = b[bEnd] + } else { + if (!map) { + map = new Map() + let i = bStart + while (i < bEnd) map.set(b[i], i++) + } + const index = map.get(a[aStart]) + if (index != null) { + if (bStart < index && index < bEnd) { + let i = aStart, + sequence = 1, + t + while (++i < aEnd && i < bEnd) { + if ((t = map.get(a[i])) == null || t !== index + sequence) break + sequence++ + } + if (sequence > index - bStart) { + const node = a[aStart] + while (bStart < index) insertNode(parentNode, b[bStart++], node) + } else replaceNode(parentNode, b[bStart++], a[aStart++]) + } else aStart++ + } else removeNode(parentNode, a[aStart++]) + } + } + } + function cleanChildren(parent, current, marker, replacement) { + if (marker === undefined) { + let removed + while ((removed = getFirstChild(parent))) removeNode(parent, removed) + replacement && insertNode(parent, replacement) + return "" + } + const node = replacement || createSlotNode() + if (current.length) { + let inserted = false + for (let i = current.length - 1; i >= 0; i--) { + const el = current[i] + if (node !== el) { + const isParent = getParentNode(el) === parent + if (!inserted && !i) isParent ? replaceNode(parent, node, el) : insertNode(parent, node, marker) + else isParent && removeNode(parent, el) + } else inserted = true + } + } else insertNode(parent, node, marker) + return [node] + } + function appendNodes(parent, array, marker) { + for (let i = 0, len = array.length; i < len; i++) insertNode(parent, array[i], marker) + } + function replaceNode(parent, newNode, oldNode) { + insertNode(parent, newNode, oldNode) + removeNode(parent, oldNode) + } + function spreadExpression(node, props, prevProps = {}, skipChildren) { + props || (props = {}) + if (!skipChildren) { + createRenderEffect(() => (prevProps.children = insertExpression(node, props.children, prevProps.children))) + } + createRenderEffect(() => props.ref && props.ref(node)) + createRenderEffect(() => { + for (const prop in props) { + if (prop === "children" || prop === "ref") continue + const value = props[prop] + if (value === prevProps[prop]) continue + setProperty(node, prop, value, prevProps[prop]) + prevProps[prop] = value + } + }) + return prevProps + } + return { + render(code, element) { + let disposer + createRoot((dispose) => { + disposer = dispose + insert(element, code()) + }) + return disposer + }, + insert, + spread(node, accessor, skipChildren) { + if (typeof accessor === "function") { + createRenderEffect((current) => spreadExpression(node, accessor(), current, skipChildren)) + } else spreadExpression(node, accessor, undefined, skipChildren) + }, + createElement, + createTextNode, + insertNode, + setProp(node, name, value, prev) { + setProperty(node, name, value, prev) + return value + }, + mergeProps, + effect: createRenderEffect, + memo, + createComponent, + use(fn, element, arg) { + return untrack(() => fn(element, arg)) + }, + } +} diff --git a/packages/solid/src/types/elements.ts b/packages/solid/src/types/elements.ts index 7cb39ee1d..24a985eee 100644 --- a/packages/solid/src/types/elements.ts +++ b/packages/solid/src/types/elements.ts @@ -82,7 +82,10 @@ export type GetNonStyledProperties = type ContainerProps = TOptions & { children?: JSX.Element } /** Smart component props that automatically determine excluded properties */ -type ComponentProps, TRenderable extends BaseRenderable> = TOptions & { +type ComponentProps, TRenderable extends BaseRenderable> = Omit< + TOptions, + "id" +> & { style?: Partial>>> } & ElementProps diff --git a/packages/solid/tests/__snapshots__/control-flow.test.tsx.snap b/packages/solid/tests/__snapshots__/control-flow.test.tsx.snap new file mode 100644 index 000000000..4977f6262 --- /dev/null +++ b/packages/solid/tests/__snapshots__/control-flow.test.tsx.snap @@ -0,0 +1,15 @@ +// Bun Snapshot v1, https://bun.sh/docs/test/snapshots + +exports[`SolidJS Renderer - Control Flow Components Combined Control Flow should be able to anchor to slot nodes 1`] = ` +"┌─A─────────────────────┐ +└───────────────────────┘ +┌─B─────────────────────┐ +└───────────────────────┘ + + + + + + +" +`; diff --git a/packages/solid/tests/__snapshots__/dynamic-portal.test.tsx.snap b/packages/solid/tests/__snapshots__/dynamic-portal.test.tsx.snap new file mode 100644 index 000000000..5ace90a48 --- /dev/null +++ b/packages/solid/tests/__snapshots__/dynamic-portal.test.tsx.snap @@ -0,0 +1,22 @@ +// Bun Snapshot v1, https://bun.sh/docs/test/snapshots + +exports[`SolidJS Renderer - Dynamic and Portal Components Component should pass props correctly to dynamic components 1`] = ` +"Updated text + + +" +`; + +exports[`SolidJS Renderer - Dynamic and Portal Components + Integration should handle Portal with Dynamic mount point 1`] = ` +"Custom target +Dynamic mount content +Static content + + + + + + + +" +`; diff --git a/packages/solid/tests/control-flow.test.tsx b/packages/solid/tests/control-flow.test.tsx index dae8bfd9b..03aa93b93 100644 --- a/packages/solid/tests/control-flow.test.tsx +++ b/packages/solid/tests/control-flow.test.tsx @@ -201,7 +201,7 @@ describe("SolidJS Renderer - Control Flow Components", () => { await testSetup.renderOnce() children = testSetup.renderer.root.getChildren()[0]!.getChildren() - expect(children.length).toBe(1) + expect(children.length).toBe(2) frame = testSetup.captureCharFrame() expect(frame).not.toContain("Visible content") @@ -500,6 +500,114 @@ describe("SolidJS Renderer - Control Flow Components", () => { expect(frame).not.toContain("Item: A") }) + it("should handle inside ", async () => { + const [showExtra, setShowExtra] = createSignal(true) + + testSetup = await testRender( + () => ( + + + Base text + + extra styled text + + + + ), + { width: 30, height: 5 }, + ) + + await testSetup.renderOnce() + let frame = testSetup.captureCharFrame() + expect(frame).toContain("Base text") + expect(frame).toContain("extra styled text") + + setShowExtra(false) + await testSetup.renderOnce() + frame = testSetup.captureCharFrame() + expect(frame).toContain("Base text") + expect(frame).not.toContain("extra styled text") + }) + + it("should handle inside /", async () => { + const [showExtra, setShowExtra] = createSignal(true) + + testSetup = await testRender( + () => ( + + + Base text +
+ + extra styled text + +
+ + extra bold text + +
+
+ ), + { width: 30, height: 5 }, + ) + + await testSetup.renderOnce() + let frame = testSetup.captureCharFrame() + console.log(frame) + expect(frame).toContain("Base text") + expect(frame).toContain("extra styled text") + expect(frame).toContain("extra bold text") + + setShowExtra(false) + await testSetup.renderOnce() + frame = testSetup.captureCharFrame() + expect(frame).toContain("Base text") + expect(frame).not.toContain("extra styled text") + expect(frame).not.toContain("extra bold text") + }) + + it("should handle inside ", async () => { + const items = ["A", "B", "C", "D"] + const [visibleItems, setVisibleItems] = createSignal(new Set(["A", "C"])) + + testSetup = await testRender( + () => ( + + + {(item) => ( + + Item: {item} + + )} + + + ), + { width: 20, height: 10 }, + ) + + await testSetup.renderOnce() + let children = testSetup.renderer.root.getChildren()[0]!.getChildren() + expect(children.length).toBe(2) + + let frame = testSetup.captureCharFrame() + expect(frame).toContain("Item: A") + expect(frame).toContain("Item: C") + expect(frame).not.toContain("Item: B") + expect(frame).not.toContain("Item: D") + + setVisibleItems(new Set(["B", "D"])) + await testSetup.renderOnce() + + children = testSetup.renderer.root.getChildren()[0]!.getChildren() + expect(children.length).toBe(2) + + frame = testSetup.captureCharFrame() + expect(frame).toContain("Item: B") + expect(frame).toContain("Item: D") + expect(frame).not.toContain("Item: A") + expect(frame).not.toContain("Item: C") + }) + it("should handle with inside matches", async () => { const [mode, setMode] = createSignal<"list" | "grid">("list") const items = ["One", "Two", "Three"] @@ -535,5 +643,29 @@ describe("SolidJS Renderer - Control Flow Components", () => { expect(frame).toContain("[Three]") expect(frame).not.toContain("• One") }) + + it("should be able to anchor to slot nodes", async () => { + testSetup = await testRender( + () => ( + + + + + + + + + + ), + { width: 25, height: 10 }, + ) + await testSetup.renderOnce() + let frame = testSetup.captureCharFrame() + expect(frame).toContain("A") + expect(frame).toContain("B") + expect(frame).not.toContain("C") + // Consistent ordering + expect(frame).toMatchSnapshot() + }) }) }) diff --git a/packages/solid/tests/dynamic-portal.test.tsx b/packages/solid/tests/dynamic-portal.test.tsx new file mode 100644 index 000000000..ac295867f --- /dev/null +++ b/packages/solid/tests/dynamic-portal.test.tsx @@ -0,0 +1,314 @@ +import { describe, expect, it, beforeEach, afterEach } from "bun:test" +import { testRender, Dynamic, Portal } from "../index" +import { createSignal, Show } from "solid-js" +import { createSpy } from "./utils/spy" +import type { BoxRenderable } from "@opentui/core" + +let testSetup: Awaited> + +describe("SolidJS Renderer - Dynamic and Portal Components", () => { + beforeEach(async () => { + if (testSetup) { + testSetup.renderer.destroy() + } + }) + + afterEach(() => { + if (testSetup) { + testSetup.renderer.destroy() + } + }) + + describe(" Component", () => { + it("should handle undefined component gracefully", async () => { + testSetup = await testRender( + () => ( + + This should not render + + ), + { width: 20, height: 5 }, + ) + + await testSetup.renderOnce() + const children = testSetup.renderer.root.getChildren()[0]!.getChildren() + expect(children.length).toBe(0) + }) + + it("should pass props correctly to dynamic components", async () => { + const [color, setColor] = createSignal("red") + const [text, setText] = createSignal("Initial text") + + testSetup = await testRender( + () => ( + + + {text()} + + + ), + { width: 20, height: 3 }, + ) + + await testSetup.renderOnce() + let frame = testSetup.captureCharFrame() + expect(frame).toContain("Initial text") + + setColor("blue") + setText("Updated text") + await testSetup.renderOnce() + frame = testSetup.captureCharFrame() + expect(frame).toContain("Updated text") + expect(frame).toMatchSnapshot() + }) + + it("should handle event handlers in dynamic components", async () => { + const onInputSpy = createSpy() + + testSetup = await testRender( + () => ( + + + + ), + { width: 20, height: 5 }, + ) + + await testSetup.mockInput.typeText("test") + + expect(onInputSpy.callCount()).toBe(4) + expect(onInputSpy.calls[0]?.[0]).toBe("t") + expect(onInputSpy.calls[3]?.[0]).toBe("test") + }) + + it("should handle false Show inside dynamic that switches between text and box", async () => { + /* Tests for slot renderable being able handle switching between a LayoutSlot and a TextSlot + * Expected to just run without crash + */ + const [componentType, setComponentType] = createSignal<"text" | "box">("text") + + testSetup = await testRender( + () => ( + + + This should never render + + + ), + { width: 20, height: 5 }, + ) + + await testSetup.renderOnce() + + setComponentType("box") + await testSetup.renderOnce() + }) + }) + + describe(" Component", () => { + it("should render content to default mount point", async () => { + testSetup = await testRender( + () => ( + + Before portal + + Portal content + + After portal + + ), + { width: 25, height: 8 }, + ) + + await testSetup.renderOnce() + const frame = testSetup.captureCharFrame() + expect(frame).toContain("Portal content") + }) + + it("should render content to custom mount point", async () => { + let customMount!: BoxRenderable + + testSetup = await testRender( + () => ( + + + + + Portal content + + + + ), + { width: 25, height: 8 }, + ) + + await testSetup.renderOnce() + const frame = testSetup.captureCharFrame() + expect(frame).toContain("Portal content") + expect(customMount.getChildren().length).toBe(1) + }) + + it("should handle complex nested content in portal", async () => { + testSetup = await testRender( + () => ( + + + Nested text 1 + Nested text 2 + + + ), + { width: 30, height: 10 }, + ) + + await testSetup.renderOnce() + const frame = testSetup.captureCharFrame() + expect(frame).toContain("Nested text 1") + expect(frame).toContain("Nested text 2") + }) + + it("should handle portal cleanup on unmount", async () => { + const [showPortal, setShowPortal] = createSignal(true) + + testSetup = await testRender( + () => ( + + + + Portal content + + + + ), + { width: 20, height: 5 }, + ) + + await testSetup.renderOnce() + let frame = testSetup.captureCharFrame() + expect(frame).toContain("Portal content") + + setShowPortal(false) + await testSetup.renderOnce() + frame = testSetup.captureCharFrame() + expect(frame).not.toContain("Portal content") + }) + + it("should handle multiple portals", async () => { + testSetup = await testRender( + () => ( + + + First portal + + + Second portal + + + ), + { width: 25, height: 8 }, + ) + + await testSetup.renderOnce() + const frame = testSetup.captureCharFrame() + expect(frame).toContain("First portal") + expect(frame).toContain("Second portal") + expect(testSetup.renderer.root.getChildren().length).toBe(3) + }) + }) + + describe(" + Integration", () => { + it("should handle Dynamic component inside Portal", async () => { + const [useComponentA, setUseComponentA] = createSignal(true) + + testSetup = await testRender( + () => { + const ComponentA = () => Portal Component A + const ComponentB = () => Portal Component B + + return ( + + + + + + ) + }, + { width: 25, height: 8 }, + ) + + await testSetup.renderOnce() + let frame = testSetup.captureCharFrame() + expect(frame).toContain("Portal Component A") + + setUseComponentA(false) + await testSetup.renderOnce() + frame = testSetup.captureCharFrame() + expect(frame).toContain("Portal Component B") + }) + + it("should handle Portal with Dynamic mount point", async () => { + const [useCustomMount, setUseCustomMount] = createSignal(false) + + let ref!: BoxRenderable + + testSetup = await testRender( + () => ( + + + Custom target + + + Dynamic mount content + + Static content + + ), + { width: 30, height: 10 }, + ) + + await testSetup.renderOnce() + let frame = testSetup.captureCharFrame() + expect(frame).toContain("Dynamic mount content") + expect(ref.getChildren().length).toBe(1) + + setUseCustomMount(true) + await testSetup.renderOnce() + frame = testSetup.captureCharFrame() + expect(frame).toContain("Dynamic mount content") + expect(ref.getChildren().length).toBe(2) + + setUseCustomMount(false) + frame = testSetup.captureCharFrame() + expect(frame).toContain("Dynamic mount content") + expect(ref.getChildren().length).toBe(1) + }) + + it("should handle switching between Portal and non-Portal with Dynamic", async () => { + const [usePortal, setUsePortal] = createSignal(true) + + let ref!: BoxRenderable + + testSetup = await testRender( + () => ( + + + Conditional portal content + + + ), + { width: 30, height: 8 }, + ) + + await testSetup.renderOnce() + let frame = testSetup.captureCharFrame() + expect(frame).toContain("Conditional portal content") + expect(testSetup.renderer.root.getChildren().length).toBe(2) + + setUsePortal(false) + await testSetup.renderOnce() + frame = testSetup.captureCharFrame() + expect(frame).toContain("Conditional portal content") + expect(testSetup.renderer.root.getChildren().length).toBe(1) + }) + }) +}) diff --git a/packages/solid/tests/layout.test.tsx b/packages/solid/tests/layout.test.tsx index 08cd44ecf..89ff302e1 100644 --- a/packages/solid/tests/layout.test.tsx +++ b/packages/solid/tests/layout.test.tsx @@ -51,6 +51,31 @@ describe("SolidJS Renderer Integration Tests", () => { expect(frame).toMatchSnapshot() }) + it("should throw on rendering text without parent element", async () => { + expect( + testRender(() => This text is not wrapped in a text element, { + width: 30, + height: 5, + }), + ).rejects.toThrow() + }) + + it("should throw on rendering span without parent element", async () => { + expect( + testRender( + () => ( + + This text is not wrapped in a text element + + ), + { + width: 30, + height: 5, + }, + ), + ).rejects.toThrow() + }) + it("should render text with dynamic content", async () => { const counter = () => 42