From 8d1941d610a9bb3a5fe572ffc39dfd58bdf00331 Mon Sep 17 00:00:00 2001 From: Adictya Date: Tue, 16 Sep 2025 14:13:39 +0530 Subject: [PATCH 01/16] anchor fixes --- packages/solid/src/reconciler.ts | 123 ++++++++++++++++++++++++++++++- 1 file changed, 121 insertions(+), 2 deletions(-) diff --git a/packages/solid/src/reconciler.ts b/packages/solid/src/reconciler.ts index 1632ca02f..ab5d3d9ca 100644 --- a/packages/solid/src/reconciler.ts +++ b/packages/solid/src/reconciler.ts @@ -2,6 +2,7 @@ import { BaseRenderable, createTextAttributes, + createTrackedNode, InputRenderable, InputRenderableEvents, isTextNodeRenderable, @@ -14,6 +15,9 @@ import { TabSelectRenderableEvents, TextNodeRenderable, TextRenderable, + TrackedNode, + type RenderableOptions, + type RenderContext, type TextNodeOptions, } from "@opentui/core" import { useContext } from "solid-js" @@ -21,6 +25,17 @@ import { createRenderer } from "solid-js/universal" import { getComponentCatalogue, RendererContext } from "./elements" import { getNextId } from "./utils/id-counter" import { log } from "./utils/log" +import type { JSX } from "../jsx-runtime.d.ts" +import { useRenderer } from "./elements/hooks" +import { getOwner } from "solid-js" +import { createEffect } from "solid-js" +import { runWithOwner } from "solid-js" +import { createMemo } from "solid-js" +import { onCleanup } from "solid-js" +import type { ValidComponent } from "solid-js" +import type { ComponentProps } from "solid-js" +import { splitProps } from "solid-js" +import { untrack } from "solid-js" class TextNode extends TextNodeRenderable { public static override fromString(text: string, options: Partial = {}): TextNode { @@ -30,6 +45,12 @@ class TextNode extends TextNodeRenderable { } } +class AnchorNode extends Renderable { + constructor(context: RenderContext, opts: RenderableOptions) { + super(context, opts) + } +} + export type DomNode = BaseRenderable /** @@ -68,14 +89,22 @@ function _insertNode(parent: DomNode, node: DomNode, anchor?: DomNode): void { 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}`) + // TODO: Workaround for now, final implementation to be decided + let anchorIndex = undefined + if (anchor) { + anchorIndex = getNodeChildren(parent).findIndex((el) => el.id === anchor.id) + } + const renderer = useRenderer() + parent.add(createAnchorNode(renderer), anchorIndex) return } } // 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) { @@ -118,6 +147,10 @@ function _createTextNode(value: string | number): TextNode { return TextNode.fromString(value, { id }) } +function createAnchorNode(ctx: RenderContext): AnchorNode { + return new AnchorNode(ctx, { id: getNextId("anchor-node") }) +} + function _getParentNode(childNode: DomNode): DomNode | undefined { log("Getting parent of node:", logId(childNode)) @@ -332,3 +365,89 @@ export const { return nextSibling }, }) + +export function Portal(props: { mount?: DomNode; ref?: (el: {}) => void; children: JSX.Element }) { + const renderer = useRenderer() + + const marker = createAnchorNode(renderer), + 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) +} From 3f50a93adeecec8b0b0c5daa47d5d102d21cd8e3 Mon Sep 17 00:00:00 2001 From: Adictya Date: Wed, 17 Sep 2025 18:33:15 +0530 Subject: [PATCH 02/16] add tests --- packages/solid/bunfig.toml | 4 + packages/solid/package.json | 2 +- packages/solid/src/reconciler.ts | 9 +- .../dynamic-portal.test.tsx.snap | 22 ++ packages/solid/tests/control-flow.test.tsx | 2 +- packages/solid/tests/dynamic-portal.test.tsx | 282 ++++++++++++++++++ 6 files changed, 316 insertions(+), 5 deletions(-) create mode 100644 packages/solid/tests/__snapshots__/dynamic-portal.test.tsx.snap create mode 100644 packages/solid/tests/dynamic-portal.test.tsx 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/reconciler.ts b/packages/solid/src/reconciler.ts index ab5d3d9ca..9ca4234ef 100644 --- a/packages/solid/src/reconciler.ts +++ b/packages/solid/src/reconciler.ts @@ -46,8 +46,11 @@ class TextNode extends TextNodeRenderable { } class AnchorNode extends Renderable { - constructor(context: RenderContext, opts: RenderableOptions) { - super(context, opts) + constructor(context: RenderContext, id: string) { + super(context, { + id, + visible: false, + }) } } @@ -148,7 +151,7 @@ function _createTextNode(value: string | number): TextNode { } function createAnchorNode(ctx: RenderContext): AnchorNode { - return new AnchorNode(ctx, { id: getNextId("anchor-node") }) + return new AnchorNode(ctx, getNextId("anchor-node")) } function _getParentNode(childNode: DomNode): DomNode | undefined { 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..cd1ab4b70 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") diff --git a/packages/solid/tests/dynamic-portal.test.tsx b/packages/solid/tests/dynamic-portal.test.tsx new file mode 100644 index 000000000..ae6eaa51d --- /dev/null +++ b/packages/solid/tests/dynamic-portal.test.tsx @@ -0,0 +1,282 @@ +import { describe, expect, it, beforeEach, afterEach } from "bun:test" +import { testRender, Dynamic, Portal } from "../index" +import { createSignal, For, type Ref } from "solid-js" +import { createSpy } from "./utils/spy" +import type { BoxRenderable, Renderable } 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") + }) + }) + + 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 () => { + testSetup = await testRender( + () => ( + + + + Portal content + + + + ), + { width: 25, height: 8 }, + ) + + await testSetup.renderOnce() + const frame = testSetup.captureCharFrame() + expect(frame).toContain("Portal content") + }) + + 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("Portal Box") + 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( + () => ( + + {showPortal() && ( + + 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") + }) + }) + + 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) + }) + + 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") + + setUsePortal(false) + await testSetup.renderOnce() + frame = testSetup.captureCharFrame() + expect(frame).toContain("Conditional portal content") + }) + }) +}) From 226292d649a70b2c0ee709e47963cdd5bb21f86d Mon Sep 17 00:00:00 2001 From: Adictya Date: Wed, 17 Sep 2025 20:20:51 +0530 Subject: [PATCH 03/16] bring in renderer for cleaner lifecycle --- packages/solid/src/reconciler.ts | 30 +-- packages/solid/src/renderer/index.ts | 11 + packages/solid/src/renderer/universal.d.ts | 30 +++ packages/solid/src/renderer/universal.js | 236 +++++++++++++++++++++ 4 files changed, 292 insertions(+), 15 deletions(-) create mode 100644 packages/solid/src/renderer/index.ts create mode 100644 packages/solid/src/renderer/universal.d.ts create mode 100644 packages/solid/src/renderer/universal.js diff --git a/packages/solid/src/reconciler.ts b/packages/solid/src/reconciler.ts index 9ca4234ef..32d0926c9 100644 --- a/packages/solid/src/reconciler.ts +++ b/packages/solid/src/reconciler.ts @@ -2,7 +2,6 @@ import { BaseRenderable, createTextAttributes, - createTrackedNode, InputRenderable, InputRenderableEvents, isTextNodeRenderable, @@ -15,13 +14,11 @@ import { TabSelectRenderableEvents, TextNodeRenderable, TextRenderable, - TrackedNode, - type RenderableOptions, type RenderContext, type TextNodeOptions, } from "@opentui/core" import { useContext } from "solid-js" -import { createRenderer } from "solid-js/universal" +import { createRenderer } from "./renderer" import { getComponentCatalogue, RendererContext } from "./elements" import { getNextId } from "./utils/id-counter" import { log } from "./utils/log" @@ -90,16 +87,12 @@ function _insertNode(parent: DomNode, node: DomNode, anchor?: DomNode): void { 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}`) - // TODO: Workaround for now, final implementation to be decided - let anchorIndex = undefined - if (anchor) { - anchorIndex = getNodeChildren(parent).findIndex((el) => el.id === anchor.id) - } - const renderer = useRenderer() - parent.add(createAnchorNode(renderer), anchorIndex) - 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}`, + ) } } @@ -151,7 +144,9 @@ function _createTextNode(value: string | number): TextNode { } function createAnchorNode(ctx: RenderContext): AnchorNode { - return new AnchorNode(ctx, getNextId("anchor-node")) + const id = getNextId("anchor-node") + log("Creating anchor node", id) + return new AnchorNode(ctx, id) } function _getParentNode(childNode: DomNode): DomNode | undefined { @@ -198,6 +193,11 @@ export const { createTextNode: _createTextNode, + createAnchorNode: () => { + const renderer = useRenderer() + return createAnchorNode(renderer) + }, + 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..3cc753e53 --- /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..5b2a702bd --- /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; + createAnchorNode(): 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..fdf39b978 --- /dev/null +++ b/packages/solid/src/renderer/universal.js @@ -0,0 +1,236 @@ +import { createRoot, createRenderEffect, createMemo, createComponent, untrack, mergeProps } from "solid-js"; + +const memo = fn => createMemo(() => fn()); + +export function createRenderer({ + createElement, + createTextNode, + createAnchorNode, + 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 || createAnchorNode(); + 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)); + } + }; +} From 42005ffd9b4e37711510651af88443a034d42941 Mon Sep 17 00:00:00 2001 From: Adictya Date: Wed, 17 Sep 2025 20:35:04 +0530 Subject: [PATCH 04/16] cleanup --- packages/solid/src/elements/extras.ts | 91 +++++++++++++++++++++++++ packages/solid/src/elements/index.ts | 1 + packages/solid/src/reconciler.ts | 98 +-------------------------- 3 files changed, 93 insertions(+), 97 deletions(-) create mode 100644 packages/solid/src/elements/extras.ts diff --git a/packages/solid/src/elements/extras.ts b/packages/solid/src/elements/extras.ts new file mode 100644 index 000000000..000d02068 --- /dev/null +++ b/packages/solid/src/elements/extras.ts @@ -0,0 +1,91 @@ +import { createEffect, createMemo, getOwner, onCleanup, runWithOwner, splitProps, untrack } from "solid-js" +import { createAnchorNode, createElement, insert, spread, type DomNode } from "../reconciler" +import type { JSX } from "../../jsx-runtime" +import type { ValidComponent, ComponentProps } from "solid-js" +import { useRenderer } from "./hooks" + +export function Portal(props: { mount?: DomNode; ref?: (el: {}) => void; children: JSX.Element }) { + const renderer = useRenderer() + + const marker = createAnchorNode(renderer), + 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..bef663b95 100644 --- a/packages/solid/src/elements/index.ts +++ b/packages/solid/src/elements/index.ts @@ -13,6 +13,7 @@ import { } from "@opentui/core" import type { RenderableConstructor } from "../types/elements" export * from "./hooks" +export * from "./extras" class SpanRenderable extends TextNodeRenderable { constructor( diff --git a/packages/solid/src/reconciler.ts b/packages/solid/src/reconciler.ts index 32d0926c9..70dc90d5b 100644 --- a/packages/solid/src/reconciler.ts +++ b/packages/solid/src/reconciler.ts @@ -22,17 +22,7 @@ import { createRenderer } from "./renderer" import { getComponentCatalogue, RendererContext } from "./elements" import { getNextId } from "./utils/id-counter" import { log } from "./utils/log" -import type { JSX } from "../jsx-runtime.d.ts" import { useRenderer } from "./elements/hooks" -import { getOwner } from "solid-js" -import { createEffect } from "solid-js" -import { runWithOwner } from "solid-js" -import { createMemo } from "solid-js" -import { onCleanup } from "solid-js" -import type { ValidComponent } from "solid-js" -import type { ComponentProps } from "solid-js" -import { splitProps } from "solid-js" -import { untrack } from "solid-js" class TextNode extends TextNodeRenderable { public static override fromString(text: string, options: Partial = {}): TextNode { @@ -143,7 +133,7 @@ function _createTextNode(value: string | number): TextNode { return TextNode.fromString(value, { id }) } -function createAnchorNode(ctx: RenderContext): AnchorNode { +export function createAnchorNode(ctx: RenderContext): AnchorNode { const id = getNextId("anchor-node") log("Creating anchor node", id) return new AnchorNode(ctx, id) @@ -368,89 +358,3 @@ export const { return nextSibling }, }) - -export function Portal(props: { mount?: DomNode; ref?: (el: {}) => void; children: JSX.Element }) { - const renderer = useRenderer() - - const marker = createAnchorNode(renderer), - 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) -} From ce6e01dcde5ef5ce3cfb7dc9c1044563f504be02 Mon Sep 17 00:00:00 2001 From: Adictya Date: Wed, 17 Sep 2025 20:44:12 +0530 Subject: [PATCH 05/16] chore: lint --- packages/solid/src/elements/extras.ts | 9 +- packages/solid/src/renderer/index.ts | 14 +- packages/solid/src/renderer/universal.d.ts | 48 ++--- packages/solid/src/renderer/universal.js | 230 +++++++++++---------- 4 files changed, 155 insertions(+), 146 deletions(-) diff --git a/packages/solid/src/elements/extras.ts b/packages/solid/src/elements/extras.ts index 000d02068..57ce3bbce 100644 --- a/packages/solid/src/elements/extras.ts +++ b/packages/solid/src/elements/extras.ts @@ -4,7 +4,14 @@ import type { JSX } from "../../jsx-runtime" import type { ValidComponent, ComponentProps } from "solid-js" import { useRenderer } from "./hooks" -export function Portal(props: { mount?: DomNode; ref?: (el: {}) => void; children: JSX.Element }) { +/** + * 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 = createAnchorNode(renderer), diff --git a/packages/solid/src/renderer/index.ts b/packages/solid/src/renderer/index.ts index 3cc753e53..8bed33b8a 100644 --- a/packages/solid/src/renderer/index.ts +++ b/packages/solid/src/renderer/index.ts @@ -1,11 +1,11 @@ -import { createRenderer as createRendererDX } from "./universal.js"; -import type { RendererOptions, Renderer } from "./universal.js"; -import { mergeProps } from "solid-js"; +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 type { RendererOptions, Renderer } from "./universal.js" export function createRenderer(options: RendererOptions): Renderer { - const renderer = createRendererDX(options); - renderer.mergeProps = mergeProps; - return 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 index 5b2a702bd..02c717f4f 100644 --- a/packages/solid/src/renderer/universal.d.ts +++ b/packages/solid/src/renderer/universal.d.ts @@ -1,30 +1,30 @@ export interface RendererOptions { - createElement(tag: string): NodeType; - createTextNode(value: string): NodeType; - createAnchorNode(): 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; + createElement(tag: string): NodeType + createTextNode(value: string): NodeType + createAnchorNode(): 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; + 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; +export function createRenderer(options: RendererOptions): Renderer diff --git a/packages/solid/src/renderer/universal.js b/packages/solid/src/renderer/universal.js index fdf39b978..2e414e5ac 100644 --- a/packages/solid/src/renderer/universal.js +++ b/packages/solid/src/renderer/universal.js @@ -1,6 +1,6 @@ -import { createRoot, createRenderEffect, createMemo, createComponent, untrack, mergeProps } from "solid-js"; +import { createRoot, createRenderEffect, createMemo, createComponent, untrack, mergeProps } from "solid-js" -const memo = fn => createMemo(() => fn()); +const memo = (fn) => createMemo(() => fn()) export function createRenderer({ createElement, @@ -13,95 +13,96 @@ export function createRenderer({ setProperty, getParentNode, getFirstChild, - getNextSibling + 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); + 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; + while (typeof current === "function") current = current() + if (value === current) return current const t = typeof value, - multi = marker !== undefined; + multi = marker !== undefined if (t === "string" || t === "number") { - if (t === "number") value = value.toString(); + if (t === "number") value = value.toString() if (multi) { - let node = current[0]; + let node = current[0] if (node && isTextNode(node)) { - replaceText(node, value); - } else node = createTextNode(value); - current = cleanChildren(parent, current, marker, 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); + replaceText(getFirstChild(parent), (current = value)) } else { - cleanChildren(parent, current, marker, createTextNode(value)); - current = value; + cleanChildren(parent, current, marker, createTextNode(value)) + current = value } } } else if (value == null || t === "boolean") { - current = cleanChildren(parent, current, marker); + 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; + let v = value() + while (typeof v === "function") v = v() + current = insertExpression(parent, v, current, marker) + }) + return () => current } else if (Array.isArray(value)) { - const array = []; + const array = [] if (normalizeIncomingArray(array, value, unwrapArray)) { - createRenderEffect(() => current = insertExpression(parent, array, current, marker, true)); - return () => current; + 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; + 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); + appendNodes(parent, array, marker) + } else reconcileArrays(parent, current, array) } else if (current == null || current === "") { - appendNodes(parent, array); + appendNodes(parent, array) } else { - reconcileArrays(parent, multi && current || [getFirstChild(parent)], array); + reconcileArrays(parent, (multi && current) || [getFirstChild(parent)], array) } } - current = array; + current = array } else { if (Array.isArray(current)) { - if (multi) return current = cleanChildren(parent, current, marker, value); - cleanChildren(parent, current, null, value); + 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; + insertNode(parent, value) + } else replaceNode(parent, value, getFirstChild(parent)) + current = value } - return current; + return current } function normalizeIncomingArray(normalized, array, unwrap) { - let dynamic = false; + 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; + 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)); + 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; + while (typeof item === "function") item = item() + dynamic = normalizeIncomingArray(normalized, Array.isArray(item) ? item : [item]) || dynamic } else { - normalized.push(item); - dynamic = true; + normalized.push(item) + dynamic = true } - } else normalized.push(item); + } else normalized.push(item) } - return dynamic; + return dynamic } function reconcileArrays(parentNode, a, b) { let bLength = b.length, @@ -110,127 +111,128 @@ export function createRenderer({ aStart = 0, bStart = 0, after = getNextSibling(a[aEnd - 1]), - map = null; + map = null while (aStart < aEnd || bStart < bEnd) { if (a[aStart] === b[bStart]) { - aStart++; - bStart++; - continue; + aStart++ + bStart++ + continue } while (a[aEnd - 1] === b[bEnd - 1]) { - aEnd--; - bEnd--; + 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); + 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++; + 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]; + 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++); + map = new Map() + let i = bStart + while (i < bEnd) map.set(b[i], i++) } - const index = map.get(a[aStart]); + const index = map.get(a[aStart]) if (index != null) { if (bStart < index && index < bEnd) { let i = aStart, sequence = 1, - t; + t while (++i < aEnd && i < bEnd) { - if ((t = map.get(a[i])) == null || t !== index + sequence) break; - sequence++; + 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++]); + 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 ""; + let removed + while ((removed = getFirstChild(parent))) removeNode(parent, removed) + replacement && insertNode(parent, replacement) + return "" } - const node = replacement || createAnchorNode(); + const node = replacement || createAnchorNode() if (current.length) { - let inserted = false; + let inserted = false for (let i = current.length - 1; i >= 0; i--) { - const el = current[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; + 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]; + } 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); + 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); + insertNode(parent, newNode, oldNode) + removeNode(parent, oldNode) } function spreadExpression(node, props, prevProps = {}, skipChildren) { - props || (props = {}); + props || (props = {}) if (!skipChildren) { - createRenderEffect(() => prevProps.children = insertExpression(node, props.children, prevProps.children)); + createRenderEffect(() => (prevProps.children = insertExpression(node, props.children, prevProps.children))) } - createRenderEffect(() => props.ref && props.ref(node)); + 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; + 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 prevProps } return { render(code, element) { - let disposer; - createRoot(dispose => { - disposer = dispose; - insert(element, code()); - }); - return disposer; + 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); + 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; + setProperty(node, name, value, prev) + return value }, mergeProps, effect: createRenderEffect, memo, createComponent, use(fn, element, arg) { - return untrack(() => fn(element, arg)); - } - }; + return untrack(() => fn(element, arg)) + }, + } } From a5c0809df74d1a997ef02ea20717731b37628c90 Mon Sep 17 00:00:00 2001 From: Adictya Date: Wed, 17 Sep 2025 21:17:55 +0530 Subject: [PATCH 06/16] perf improvements --- packages/core/src/Renderable.ts | 52 +++++ packages/solid/src/elements/extras.ts | 4 +- packages/solid/src/reconciler.ts | 44 ++-- packages/solid/src/renderer/index.ts | 11 - packages/solid/src/renderer/universal.d.ts | 30 --- packages/solid/src/renderer/universal.js | 238 --------------------- 6 files changed, 78 insertions(+), 301 deletions(-) delete mode 100644 packages/solid/src/renderer/index.ts delete mode 100644 packages/solid/src/renderer/universal.d.ts delete mode 100644 packages/solid/src/renderer/universal.js diff --git a/packages/core/src/Renderable.ts b/packages/core/src/Renderable.ts index 5712494a8..0ca6d116c 100644 --- a/packages/core/src/Renderable.ts +++ b/packages/core/src/Renderable.ts @@ -179,6 +179,58 @@ export abstract class BaseRenderable extends EventEmitter { } } +export class SlotRenderable extends BaseRenderable { + protected yogaNode: YogaNode + + constructor(id: string) { + super({ + id, + }) + + this.yogaNode = Yoga.Node.create() + this.yogaNode.setDisplay(Display.None) + } + + 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 {} + + public replace(obj: BaseRenderable) { + this.parent?.insertBefore(obj, this) + this.parent?.remove(this.id) + } + + public getLayoutNode(): YogaNode { + return this.yogaNode + } + + public updateFromLayout() {} + + public updateLayout() {} + + public onRemove() {} +} + export abstract class Renderable extends BaseRenderable { static renderablesByNumber: Map = new Map() diff --git a/packages/solid/src/elements/extras.ts b/packages/solid/src/elements/extras.ts index 57ce3bbce..556829d7b 100644 --- a/packages/solid/src/elements/extras.ts +++ b/packages/solid/src/elements/extras.ts @@ -1,5 +1,5 @@ import { createEffect, createMemo, getOwner, onCleanup, runWithOwner, splitProps, untrack } from "solid-js" -import { createAnchorNode, createElement, insert, spread, type DomNode } from "../reconciler" +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" @@ -14,7 +14,7 @@ import { useRenderer } from "./hooks" export function Portal(props: { mount?: DomNode; ref?: (el: {}) => void; children: JSX.Element }): DomNode { const renderer = useRenderer() - const marker = createAnchorNode(renderer), + const marker = createSlotNode(), mount = () => props.mount || renderer.root, owner = getOwner() let content: undefined | (() => JSX.Element) diff --git a/packages/solid/src/reconciler.ts b/packages/solid/src/reconciler.ts index 70dc90d5b..1b1b264ce 100644 --- a/packages/solid/src/reconciler.ts +++ b/packages/solid/src/reconciler.ts @@ -2,6 +2,7 @@ import { BaseRenderable, createTextAttributes, + SlotRenderable, InputRenderable, InputRenderableEvents, isTextNodeRenderable, @@ -18,7 +19,7 @@ import { type TextNodeOptions, } from "@opentui/core" import { useContext } from "solid-js" -import { createRenderer } from "./renderer" +import { createRenderer } from "solid-js/universal" import { getComponentCatalogue, RendererContext } from "./elements" import { getNextId } from "./utils/id-counter" import { log } from "./utils/log" @@ -32,11 +33,13 @@ class TextNode extends TextNodeRenderable { } } -class AnchorNode extends Renderable { - constructor(context: RenderContext, id: string) { - super(context, { +class AnchorNode extends BaseRenderable { + protected override _visible: boolean = false + layoutNode: T + + constructor(id: string) { + super({ id, - visible: false, }) } } @@ -77,12 +80,18 @@ function _insertNode(parent: DomNode, node: DomNode, anchor?: DomNode): void { if (isTextNodeRenderable(node)) { if (!(parent instanceof TextRenderable) && !isTextNodeRenderable(parent)) { - throw new Error( - `Orphan text error: "${node - .toChunks() - .map((c) => c.text) - .join("")}" must have a as a parent: ${parent.id} above ${node.id}`, - ) + if (node.toChunks.length >= 1 && node.toChunks()[0]?.text !== "") { + console.error( + `Orphan text error: "${node + .toChunks() + .map((c) => c.text) + .join("")}" must have a as a parent: ${parent.id} above ${node.id}`, + ) + } + node.destroyRecursively() + const anchor = createSlotNode() + parent.add(anchor) + return } } @@ -133,10 +142,10 @@ function _createTextNode(value: string | number): TextNode { return TextNode.fromString(value, { id }) } -export function createAnchorNode(ctx: RenderContext): AnchorNode { - const id = getNextId("anchor-node") - log("Creating anchor node", id) - return new AnchorNode(ctx, 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 { @@ -183,11 +192,6 @@ export const { createTextNode: _createTextNode, - createAnchorNode: () => { - const renderer = useRenderer() - return createAnchorNode(renderer) - }, - 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 deleted file mode 100644 index 8bed33b8a..000000000 --- a/packages/solid/src/renderer/index.ts +++ /dev/null @@ -1,11 +0,0 @@ -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 deleted file mode 100644 index 02c717f4f..000000000 --- a/packages/solid/src/renderer/universal.d.ts +++ /dev/null @@ -1,30 +0,0 @@ -export interface RendererOptions { - createElement(tag: string): NodeType - createTextNode(value: string): NodeType - createAnchorNode(): 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 deleted file mode 100644 index 2e414e5ac..000000000 --- a/packages/solid/src/renderer/universal.js +++ /dev/null @@ -1,238 +0,0 @@ -import { createRoot, createRenderEffect, createMemo, createComponent, untrack, mergeProps } from "solid-js" - -const memo = (fn) => createMemo(() => fn()) - -export function createRenderer({ - createElement, - createTextNode, - createAnchorNode, - 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 || createAnchorNode() - 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)) - }, - } -} From d660c754cdd81e8fc99810b34b384ac8ce095faf Mon Sep 17 00:00:00 2001 From: Adictya Date: Wed, 17 Sep 2025 21:20:13 +0530 Subject: [PATCH 07/16] cleanup --- packages/solid/src/reconciler.ts | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/packages/solid/src/reconciler.ts b/packages/solid/src/reconciler.ts index 1b1b264ce..b4e6fd363 100644 --- a/packages/solid/src/reconciler.ts +++ b/packages/solid/src/reconciler.ts @@ -33,17 +33,6 @@ class TextNode extends TextNodeRenderable { } } -class AnchorNode extends BaseRenderable { - protected override _visible: boolean = false - layoutNode: T - - constructor(id: string) { - super({ - id, - }) - } -} - export type DomNode = BaseRenderable /** From fb6d376b7c6906aac9303c329e65c7ab994b50c3 Mon Sep 17 00:00:00 2001 From: Adictya Date: Wed, 17 Sep 2025 21:24:16 +0530 Subject: [PATCH 08/16] more cleanup --- packages/core/src/Renderable.ts | 51 ------------------ packages/core/src/renderables/Slot.ts | 54 ++++++++++++++++++++ packages/core/src/renderables/index.ts | 1 + packages/solid/tests/dynamic-portal.test.tsx | 27 ++++++---- 4 files changed, 73 insertions(+), 60 deletions(-) create mode 100644 packages/core/src/renderables/Slot.ts diff --git a/packages/core/src/Renderable.ts b/packages/core/src/Renderable.ts index 0ca6d116c..a9e9b0a72 100644 --- a/packages/core/src/Renderable.ts +++ b/packages/core/src/Renderable.ts @@ -179,57 +179,6 @@ export abstract class BaseRenderable extends EventEmitter { } } -export class SlotRenderable extends BaseRenderable { - protected yogaNode: YogaNode - - constructor(id: string) { - super({ - id, - }) - - this.yogaNode = Yoga.Node.create() - this.yogaNode.setDisplay(Display.None) - } - - 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 {} - - public replace(obj: BaseRenderable) { - this.parent?.insertBefore(obj, this) - this.parent?.remove(this.id) - } - - public getLayoutNode(): YogaNode { - return this.yogaNode - } - - public updateFromLayout() {} - - public updateLayout() {} - - public onRemove() {} -} export abstract class Renderable extends BaseRenderable { static renderablesByNumber: Map = new Map() diff --git a/packages/core/src/renderables/Slot.ts b/packages/core/src/renderables/Slot.ts new file mode 100644 index 000000000..6f03c67fa --- /dev/null +++ b/packages/core/src/renderables/Slot.ts @@ -0,0 +1,54 @@ +import { BaseRenderable } from ".." +import Yoga, { Display, type Node as YogaNode } from "yoga-layout" + +export class SlotRenderable extends BaseRenderable { + protected yogaNode: YogaNode + + constructor(id: string) { + super({ + id, + }) + + this.yogaNode = Yoga.Node.create() + this.yogaNode.setDisplay(Display.None) + } + + 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 {} + + public replace(obj: BaseRenderable) { + this.parent?.insertBefore(obj, this) + this.parent?.remove(this.id) + } + + public getLayoutNode(): YogaNode { + return this.yogaNode + } + + public updateFromLayout() {} + + public updateLayout() {} + + public onRemove() {} +} diff --git a/packages/core/src/renderables/index.ts b/packages/core/src/renderables/index.ts index f0ea958cb..2a92b2018 100644 --- a/packages/core/src/renderables/index.ts +++ b/packages/core/src/renderables/index.ts @@ -11,3 +11,4 @@ export * from "./ScrollBar" export * from "./composition/constructs" export * from "./composition/vnode" export * from "./composition/VRenderable" +export * from "./Slot" diff --git a/packages/solid/tests/dynamic-portal.test.tsx b/packages/solid/tests/dynamic-portal.test.tsx index ae6eaa51d..092e431e8 100644 --- a/packages/solid/tests/dynamic-portal.test.tsx +++ b/packages/solid/tests/dynamic-portal.test.tsx @@ -1,6 +1,6 @@ import { describe, expect, it, beforeEach, afterEach } from "bun:test" import { testRender, Dynamic, Portal } from "../index" -import { createSignal, For, type Ref } from "solid-js" +import { createSignal, For, Show, type Ref } from "solid-js" import { createSpy } from "./utils/spy" import type { BoxRenderable, Renderable } from "@opentui/core" @@ -103,10 +103,13 @@ describe("SolidJS Renderer - Dynamic and Portal Components", () => { }) it("should render content to custom mount point", async () => { + let customMount!: BoxRenderable + testSetup = await testRender( () => ( - + + Portal content @@ -119,6 +122,7 @@ describe("SolidJS Renderer - Dynamic and Portal Components", () => { 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 () => { @@ -126,10 +130,8 @@ describe("SolidJS Renderer - Dynamic and Portal Components", () => { () => ( - - Nested text 1 - Nested text 2 - + Nested text 1 + Nested text 2 ), @@ -138,7 +140,6 @@ describe("SolidJS Renderer - Dynamic and Portal Components", () => { await testSetup.renderOnce() const frame = testSetup.captureCharFrame() - expect(frame).toContain("Portal Box") expect(frame).toContain("Nested text 1") expect(frame).toContain("Nested text 2") }) @@ -149,11 +150,11 @@ describe("SolidJS Renderer - Dynamic and Portal Components", () => { testSetup = await testRender( () => ( - {showPortal() && ( + Portal content - )} + ), { width: 20, height: 5 }, @@ -188,6 +189,7 @@ describe("SolidJS Renderer - Dynamic and Portal Components", () => { const frame = testSetup.captureCharFrame() expect(frame).toContain("First portal") expect(frame).toContain("Second portal") + expect(testSetup.renderer.root.getChildren().length).toBe(3) }) }) @@ -251,6 +253,11 @@ describe("SolidJS Renderer - Dynamic and Portal Components", () => { 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 () => { @@ -272,11 +279,13 @@ describe("SolidJS Renderer - Dynamic and Portal Components", () => { 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) }) }) }) From c8bfe2a4acb33dae0738bf2077019d8c1dc677e7 Mon Sep 17 00:00:00 2001 From: Adictya Date: Wed, 17 Sep 2025 21:37:30 +0530 Subject: [PATCH 09/16] import cleanup --- packages/solid/tests/dynamic-portal.test.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/solid/tests/dynamic-portal.test.tsx b/packages/solid/tests/dynamic-portal.test.tsx index 092e431e8..66d0625b9 100644 --- a/packages/solid/tests/dynamic-portal.test.tsx +++ b/packages/solid/tests/dynamic-portal.test.tsx @@ -1,8 +1,8 @@ import { describe, expect, it, beforeEach, afterEach } from "bun:test" import { testRender, Dynamic, Portal } from "../index" -import { createSignal, For, Show, type Ref } from "solid-js" +import { createSignal, Show } from "solid-js" import { createSpy } from "./utils/spy" -import type { BoxRenderable, Renderable } from "@opentui/core" +import type { BoxRenderable } from "@opentui/core" let testSetup: Awaited> From 468383665d2ce29aab670b676f7f826cd4212a25 Mon Sep 17 00:00:00 2001 From: Adictya Date: Wed, 17 Sep 2025 22:05:05 +0530 Subject: [PATCH 10/16] handle rogue text --- packages/solid/src/reconciler.ts | 23 +- packages/solid/src/renderer/index.ts | 11 + packages/solid/src/renderer/universal.d.ts | 30 +++ packages/solid/src/renderer/universal.js | 238 ++++++++++++++++++ .../__snapshots__/control-flow.test.tsx.snap | 15 ++ packages/solid/tests/control-flow.test.tsx | 24 ++ packages/solid/tests/layout.test.tsx | 18 ++ 7 files changed, 345 insertions(+), 14 deletions(-) create mode 100644 packages/solid/src/renderer/index.ts create mode 100644 packages/solid/src/renderer/universal.d.ts create mode 100644 packages/solid/src/renderer/universal.js create mode 100644 packages/solid/tests/__snapshots__/control-flow.test.tsx.snap diff --git a/packages/solid/src/reconciler.ts b/packages/solid/src/reconciler.ts index b4e6fd363..f2f23176e 100644 --- a/packages/solid/src/reconciler.ts +++ b/packages/solid/src/reconciler.ts @@ -19,11 +19,10 @@ import { type TextNodeOptions, } from "@opentui/core" import { useContext } from "solid-js" -import { createRenderer } from "solid-js/universal" +import { createRenderer } from "./renderer" import { getComponentCatalogue, RendererContext } from "./elements" import { getNextId } from "./utils/id-counter" import { log } from "./utils/log" -import { useRenderer } from "./elements/hooks" class TextNode extends TextNodeRenderable { public static override fromString(text: string, options: Partial = {}): TextNode { @@ -69,18 +68,12 @@ function _insertNode(parent: DomNode, node: DomNode, anchor?: DomNode): void { if (isTextNodeRenderable(node)) { if (!(parent instanceof TextRenderable) && !isTextNodeRenderable(parent)) { - if (node.toChunks.length >= 1 && node.toChunks()[0]?.text !== "") { - console.error( - `Orphan text error: "${node - .toChunks() - .map((c) => c.text) - .join("")}" must have a as a parent: ${parent.id} above ${node.id}`, - ) - } - node.destroyRecursively() - const anchor = createSlotNode() - parent.add(anchor) - 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}`, + ) } } @@ -181,6 +174,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/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/control-flow.test.tsx b/packages/solid/tests/control-flow.test.tsx index cd1ab4b70..53575b337 100644 --- a/packages/solid/tests/control-flow.test.tsx +++ b/packages/solid/tests/control-flow.test.tsx @@ -535,5 +535,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/layout.test.tsx b/packages/solid/tests/layout.test.tsx index 08cd44ecf..817112303 100644 --- a/packages/solid/tests/layout.test.tsx +++ b/packages/solid/tests/layout.test.tsx @@ -51,6 +51,24 @@ 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 From e158eed9b8449208dcd1860f15f9ca84ef8fb232 Mon Sep 17 00:00:00 2001 From: Adictya Date: Wed, 17 Sep 2025 22:08:44 +0530 Subject: [PATCH 11/16] chore: format --- packages/core/src/Renderable.ts | 1 - packages/solid/tests/layout.test.tsx | 15 +++++++++++---- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/packages/core/src/Renderable.ts b/packages/core/src/Renderable.ts index a9e9b0a72..5712494a8 100644 --- a/packages/core/src/Renderable.ts +++ b/packages/core/src/Renderable.ts @@ -179,7 +179,6 @@ export abstract class BaseRenderable extends EventEmitter { } } - export abstract class Renderable extends BaseRenderable { static renderablesByNumber: Map = new Map() diff --git a/packages/solid/tests/layout.test.tsx b/packages/solid/tests/layout.test.tsx index 817112303..89ff302e1 100644 --- a/packages/solid/tests/layout.test.tsx +++ b/packages/solid/tests/layout.test.tsx @@ -62,10 +62,17 @@ describe("SolidJS Renderer Integration Tests", () => { 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, - }), + testRender( + () => ( + + This text is not wrapped in a text element + + ), + { + width: 30, + height: 5, + }, + ), ).rejects.toThrow() }) From 4fd3c23229723141e5438d897fc2c25a9859b1ad Mon Sep 17 00:00:00 2001 From: Adictya Date: Wed, 17 Sep 2025 22:10:40 +0530 Subject: [PATCH 12/16] teeny tiny fix --- packages/core/src/renderables/Slot.ts | 96 ++++++++++++++++++-- packages/solid/src/reconciler.ts | 13 ++- packages/solid/src/types/elements.ts | 5 +- packages/solid/tests/control-flow.test.tsx | 42 +++++++++ packages/solid/tests/dynamic-portal.test.tsx | 20 ++++ 5 files changed, 164 insertions(+), 12 deletions(-) diff --git a/packages/core/src/renderables/Slot.ts b/packages/core/src/renderables/Slot.ts index 6f03c67fa..ccb647021 100644 --- a/packages/core/src/renderables/Slot.ts +++ b/packages/core/src/renderables/Slot.ts @@ -1,16 +1,11 @@ -import { BaseRenderable } from ".." +import { BaseRenderable, isTextNodeRenderable, TextNodeRenderable, TextRenderable } from ".." import Yoga, { Display, type Node as YogaNode } from "yoga-layout" -export class SlotRenderable extends BaseRenderable { - protected yogaNode: YogaNode - +class Slot extends BaseRenderable { constructor(id: string) { super({ id, }) - - this.yogaNode = Yoga.Node.create() - this.yogaNode.setDisplay(Display.None) } public add(obj: BaseRenderable | unknown, index?: number): number { @@ -36,10 +31,41 @@ export class SlotRenderable extends BaseRenderable { } public requestRender(): void {} +} + +export class TextSlotRenderable extends TextNodeRenderable { + protected slotParent: Slot + protected destroyed: boolean = false - public replace(obj: BaseRenderable) { - this.parent?.insertBefore(obj, this) - this.parent?.remove(this.id) + constructor(id: string, parent: Slot) { + 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 Slot { + protected yogaNode: YogaNode + protected slotParent: Slot + protected destroyed: boolean = false + + constructor(id: string, parent: Slot) { + super(id) + + this._visible = false + this.slotParent = parent + this.yogaNode = Yoga.Node.create() + this.yogaNode.setDisplay(Display.None) } public getLayoutNode(): YogaNode { @@ -51,4 +77,54 @@ export class SlotRenderable extends BaseRenderable { public updateLayout() {} public onRemove() {} + + public override destroy(): void { + if (this.destroyed) { + return + } + this.destroyed = true + + super.destroy() + this.slotParent.destroy() + } +} + +export class SlotRenderable extends Slot { + layoutNode: LayoutSlotRenderable | undefined + textNode: TextSlotRenderable | undefined + 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 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 f2f23176e..81fcaede5 100644 --- a/packages/solid/src/reconciler.ts +++ b/packages/solid/src/reconciler.ts @@ -15,7 +15,6 @@ import { TabSelectRenderableEvents, TextNodeRenderable, TextRenderable, - type RenderContext, type TextNodeOptions, } from "@opentui/core" import { useContext } from "solid-js" @@ -66,6 +65,14 @@ 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)) { throw new Error( @@ -102,6 +109,10 @@ 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)) + if (node instanceof SlotRenderable) { + node = node.getSlotChild(parent) + } + parent.remove(node.id) process.nextTick(() => { 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/control-flow.test.tsx b/packages/solid/tests/control-flow.test.tsx index 53575b337..41dff1de1 100644 --- a/packages/solid/tests/control-flow.test.tsx +++ b/packages/solid/tests/control-flow.test.tsx @@ -500,6 +500,48 @@ describe("SolidJS Renderer - Control Flow Components", () => { expect(frame).not.toContain("Item: A") }) + 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"] diff --git a/packages/solid/tests/dynamic-portal.test.tsx b/packages/solid/tests/dynamic-portal.test.tsx index 66d0625b9..c7b928613 100644 --- a/packages/solid/tests/dynamic-portal.test.tsx +++ b/packages/solid/tests/dynamic-portal.test.tsx @@ -80,6 +80,26 @@ describe("SolidJS Renderer - Dynamic and Portal Components", () => { 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 () => { + 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", () => { From b62ffd52ad30b952df1dba9fea1678e5af6a5b3b Mon Sep 17 00:00:00 2001 From: Adictya Date: Thu, 18 Sep 2025 14:09:40 +0530 Subject: [PATCH 13/16] teenier fix --- packages/solid/src/reconciler.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/packages/solid/src/reconciler.ts b/packages/solid/src/reconciler.ts index 81fcaede5..b37a7a9f7 100644 --- a/packages/solid/src/reconciler.ts +++ b/packages/solid/src/reconciler.ts @@ -113,10 +113,18 @@ function _removeNode(parent: DomNode, node: DomNode): void { node = node.getSlotChild(parent) } - parent.remove(node.id) + 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 } From 0a209c6af6c4a3959e14b9c5779d485c1c491eb0 Mon Sep 17 00:00:00 2001 From: Adictya Date: Thu, 18 Sep 2025 14:15:15 +0530 Subject: [PATCH 14/16] refine tests --- packages/solid/tests/control-flow.test.tsx | 66 ++++++++++++++++++++++ 1 file changed, 66 insertions(+) diff --git a/packages/solid/tests/control-flow.test.tsx b/packages/solid/tests/control-flow.test.tsx index 41dff1de1..03aa93b93 100644 --- a/packages/solid/tests/control-flow.test.tsx +++ b/packages/solid/tests/control-flow.test.tsx @@ -500,6 +500,72 @@ 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"])) From 8eb1408ec7a98ce8a47b2b2e35a1b18ba09402bf Mon Sep 17 00:00:00 2001 From: Adictya Date: Thu, 18 Sep 2025 14:25:10 +0530 Subject: [PATCH 15/16] kmdr plz review or ill keep making smol smol changes --- packages/core/src/renderables/Slot.ts | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/packages/core/src/renderables/Slot.ts b/packages/core/src/renderables/Slot.ts index ccb647021..5128c5cee 100644 --- a/packages/core/src/renderables/Slot.ts +++ b/packages/core/src/renderables/Slot.ts @@ -1,7 +1,7 @@ import { BaseRenderable, isTextNodeRenderable, TextNodeRenderable, TextRenderable } from ".." import Yoga, { Display, type Node as YogaNode } from "yoga-layout" -class Slot extends BaseRenderable { +class SlotBaseRenderable extends BaseRenderable { constructor(id: string) { super({ id, @@ -34,10 +34,10 @@ class Slot extends BaseRenderable { } export class TextSlotRenderable extends TextNodeRenderable { - protected slotParent: Slot + protected slotParent?: SlotRenderable protected destroyed: boolean = false - constructor(id: string, parent: Slot) { + constructor(id: string, parent?: SlotRenderable) { super({ id: id }) this._visible = false this.slotParent = parent @@ -49,17 +49,17 @@ export class TextSlotRenderable extends TextNodeRenderable { } this.destroyed = true - this.slotParent.destroy() + this.slotParent?.destroy() super.destroy() } } -export class LayoutSlotRenderable extends Slot { +export class LayoutSlotRenderable extends SlotBaseRenderable { protected yogaNode: YogaNode - protected slotParent: Slot + protected slotParent?: SlotRenderable protected destroyed: boolean = false - constructor(id: string, parent: Slot) { + constructor(id: string, parent?: SlotRenderable) { super(id) this._visible = false @@ -85,13 +85,13 @@ export class LayoutSlotRenderable extends Slot { this.destroyed = true super.destroy() - this.slotParent.destroy() + this.slotParent?.destroy() } } -export class SlotRenderable extends Slot { - layoutNode: LayoutSlotRenderable | undefined - textNode: TextSlotRenderable | undefined +export class SlotRenderable extends SlotBaseRenderable { + layoutNode?: LayoutSlotRenderable + textNode?: TextSlotRenderable protected destroyed: boolean = false constructor(id: string) { From 00e0f565848857aabf540a669d8a088f5bcf5312 Mon Sep 17 00:00:00 2001 From: Adictya Date: Thu, 18 Sep 2025 19:07:17 +0530 Subject: [PATCH 16/16] fix: review feedback --- packages/core/src/renderables/index.ts | 1 - packages/solid/src/elements/index.ts | 1 + .../src/renderables/Slot.ts => solid/src/elements/slot.ts} | 4 ++-- packages/solid/src/reconciler.ts | 3 +-- packages/solid/tests/dynamic-portal.test.tsx | 3 +++ 5 files changed, 7 insertions(+), 5 deletions(-) rename packages/{core/src/renderables/Slot.ts => solid/src/elements/slot.ts} (97%) diff --git a/packages/core/src/renderables/index.ts b/packages/core/src/renderables/index.ts index 2a92b2018..f0ea958cb 100644 --- a/packages/core/src/renderables/index.ts +++ b/packages/core/src/renderables/index.ts @@ -11,4 +11,3 @@ export * from "./ScrollBar" export * from "./composition/constructs" export * from "./composition/vnode" export * from "./composition/VRenderable" -export * from "./Slot" diff --git a/packages/solid/src/elements/index.ts b/packages/solid/src/elements/index.ts index bef663b95..d9f3e47b8 100644 --- a/packages/solid/src/elements/index.ts +++ b/packages/solid/src/elements/index.ts @@ -14,6 +14,7 @@ import { import type { RenderableConstructor } from "../types/elements" export * from "./hooks" export * from "./extras" +export * from "./slot" class SpanRenderable extends TextNodeRenderable { constructor( diff --git a/packages/core/src/renderables/Slot.ts b/packages/solid/src/elements/slot.ts similarity index 97% rename from packages/core/src/renderables/Slot.ts rename to packages/solid/src/elements/slot.ts index 5128c5cee..bf1253d55 100644 --- a/packages/core/src/renderables/Slot.ts +++ b/packages/solid/src/elements/slot.ts @@ -1,4 +1,4 @@ -import { BaseRenderable, isTextNodeRenderable, TextNodeRenderable, TextRenderable } from ".." +import { BaseRenderable, isTextNodeRenderable, TextNodeRenderable, TextRenderable } from "@opentui/core" import Yoga, { Display, type Node as YogaNode } from "yoga-layout" class SlotBaseRenderable extends BaseRenderable { @@ -114,7 +114,7 @@ export class SlotRenderable extends SlotBaseRenderable { return this.layoutNode } - public destroy(): void { + public override destroy(): void { if (this.destroyed) { return } diff --git a/packages/solid/src/reconciler.ts b/packages/solid/src/reconciler.ts index b37a7a9f7..18300551a 100644 --- a/packages/solid/src/reconciler.ts +++ b/packages/solid/src/reconciler.ts @@ -2,7 +2,6 @@ import { BaseRenderable, createTextAttributes, - SlotRenderable, InputRenderable, InputRenderableEvents, isTextNodeRenderable, @@ -19,7 +18,7 @@ import { } from "@opentui/core" import { useContext } from "solid-js" import { createRenderer } from "./renderer" -import { getComponentCatalogue, RendererContext } from "./elements" +import { getComponentCatalogue, RendererContext, SlotRenderable } from "./elements" import { getNextId } from "./utils/id-counter" import { log } from "./utils/log" diff --git a/packages/solid/tests/dynamic-portal.test.tsx b/packages/solid/tests/dynamic-portal.test.tsx index c7b928613..ac295867f 100644 --- a/packages/solid/tests/dynamic-portal.test.tsx +++ b/packages/solid/tests/dynamic-portal.test.tsx @@ -82,6 +82,9 @@ describe("SolidJS Renderer - Dynamic and Portal Components", () => { }) 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(