diff --git a/src/render.ts b/src/render.ts index 718c8d4..8b4c934 100644 --- a/src/render.ts +++ b/src/render.ts @@ -1,44 +1,26 @@ -import { createRenderer as solidCreateRenderer } from 'solid-js/universal'; -import { - Config, - type NodeProps, - type TextProps, - startLightningRenderer, - type RendererMainSettings, -} from '@lightningtv/core'; -import nodeOpts from './solidOpts.js'; -import { - splitProps, - createMemo, - createRenderEffect, - untrack, - type JSXElement, - createRoot, - type Component, -} from 'solid-js'; -import type { SolidNode } from './types.js'; +import * as s from 'solid-js'; +import * as lng from '@lightningtv/core'; +import * as universal from './universal.js'; import { activeElement, setActiveElement } from './activeElement.js'; -const solidRenderer = solidCreateRenderer(nodeOpts); +let renderer: lng.IRendererMain; +export const rootNode = universal.createElement('App'); -let renderer; -export const rootNode = nodeOpts.createElement('App'); - -const render = function (code: () => JSXElement) { - // @ts-expect-error - code is jsx element and not SolidElement yet - return solidRenderer.render(code, rootNode); +const render = (code: () => s.JSX.Element): (() => void) => { + return universal.render(code as any, rootNode); }; export function createRenderer( - rendererOptions?: RendererMainSettings, + rendererOptions?: lng.RendererMainSettings, node?: HTMLElement | string, ) { - const options = - rendererOptions || (Config.rendererOptions as RendererMainSettings); + rendererOptions ??= lng.Config.rendererOptions as lng.RendererMainSettings; + lng.assertTruthy(rendererOptions, 'Renderer options must be provided'); + + renderer = lng.startLightningRenderer(rendererOptions, node); - renderer = startLightningRenderer(options, node || 'app'); //Prevent this from happening automatically - Config.setActiveElement = setActiveElement; + lng.Config.setActiveElement = setActiveElement; rootNode.lng = renderer.root!; rootNode.rendered = true; renderer.on('idle', () => { @@ -65,14 +47,14 @@ export const { setProp, mergeProps, use, -} = solidRenderer; +} = universal; type Task = () => void; const taskQueue: Task[] = []; let tasksEnabled = false; -createRoot(() => { - createRenderEffect(() => { +s.createRoot(() => { + s.createRenderEffect(() => { // should change whenever a keypress occurs, so we disable the task queue // until the renderer is idle again. activeElement(); @@ -108,7 +90,7 @@ function processTasks(): void { task(); processTasks(); } - }, Config.taskDelay || 50); + }, lng.Config.taskDelay || 50); } } @@ -120,17 +102,17 @@ function processTasks(): void { * @description https://www.solidjs.com/docs/latest/api#dynamic */ export function Dynamic>( - props: T & { component?: Component | undefined | null }, -): JSXElement { - const [p, others] = splitProps(props, ['component']); + props: T & { component?: s.Component | undefined | null }, +): s.JSXElement { + const [p, others] = s.splitProps(props, ['component']); - const cached = createMemo(() => p.component); + const cached = s.createMemo(() => p.component); - return createMemo(() => { + return s.createMemo(() => { const component = cached(); switch (typeof component) { case 'function': - return untrack(() => component(others)); + return s.untrack(() => component(others)); case 'string': { const el = createElement(component); @@ -141,18 +123,18 @@ export function Dynamic>( default: break; } - }) as unknown as JSXElement; + }) as unknown as s.JSXElement; } // Dont use JSX as it creates circular dependencies and causes trouble with the playground. -export const View = (props: NodeProps) => { +export const View = (props: lng.NodeProps) => { const el = createElement('node'); spread(el, props, false); - return el as unknown as JSXElement; + return el as unknown as s.JSXElement; }; -export const Text = (props: TextProps) => { +export const Text = (props: lng.TextProps) => { const el = createElement('text'); spread(el, props, false); - return el as unknown as JSXElement; + return el as unknown as s.JSXElement; }; diff --git a/src/solidOpts.ts b/src/solidOpts.ts deleted file mode 100644 index 3d7cc7c..0000000 --- a/src/solidOpts.ts +++ /dev/null @@ -1,115 +0,0 @@ -import { - assertTruthy, - isElementText, - ElementNode, - NodeType, - log, - type ElementText, - type TextNode, -} from '@lightningtv/core'; -import type { SolidNode, SolidRendererOptions } from './types.js'; - -declare module '@lightningtv/core' { - interface ElementNode { - /** @internal for managing series of insertions and deletions */ - _queueDelete?: number; - preserve?: boolean; - } -} - -Object.defineProperty(ElementNode.prototype, 'preserve', { - get(): boolean | undefined { - return this._queueDelete === 0; - }, - set(v: boolean) { - this._queueDelete = v ? 0 : undefined; - }, -}); - -let elementDeleteQueue: ElementNode[] = []; - -function flushDeleteQueue(): void { - for (let el of elementDeleteQueue) { - if (Number(el._queueDelete) < 0) { - el.destroy(); - } - el._queueDelete = undefined; - } - elementDeleteQueue.length = 0; -} - -function pushDeleteQueue(node: ElementNode, n: number): void { - if (node._queueDelete === undefined) { - node._queueDelete = n; - if (elementDeleteQueue.push(node) === 1) { - queueMicrotask(flushDeleteQueue); - } - } else { - node._queueDelete += n; - } -} - -export default { - createElement(name: string): ElementNode { - return new ElementNode(name); - }, - createTextNode(text: string): TextNode { - // A text node is just a string - not the node - return { _type: NodeType.Text, text }; - }, - replaceText(node: TextNode, value: string): void { - log('Replace Text: ', node, value); - node.text = value; - const parent = node.parent; - assertTruthy(parent); - parent.text = parent.getText(); - }, - setProperty(node: ElementNode, name: string, value: any = true): void { - node[name] = value; - }, - insertNode(parent: ElementNode, node: SolidNode, anchor: SolidNode): void { - log('INSERT: ', parent, node, anchor); - - let prevParent = node.parent; - parent.insertChild(node, anchor); - - if (node instanceof ElementNode) { - node.parent!.rendered && node.render(true); - if (prevParent !== undefined) { - pushDeleteQueue(node, 1); - } - } else if (isElementText(parent)) { - // TextNodes can be placed outside of nodes when is used as placeholder - parent.text = parent.getText(); - } - }, - isTextNode(node: SolidNode): boolean { - return isElementText(node); - }, - removeNode(parent: ElementNode, node: SolidNode): void { - log('REMOVE: ', parent, node); - - parent.removeChild(node); - - if (node instanceof ElementNode) { - pushDeleteQueue(node, -1); - } else if (isElementText(parent)) { - // TextNodes can be placed outside of nodes when is used as placeholder - parent.text = parent.getText(); - } - }, - getParentNode(node: SolidNode): ElementNode | ElementText | undefined { - return node.parent; - }, - getFirstChild(node: ElementNode): SolidNode | undefined { - return node.children[0] as SolidNode; - }, - getNextSibling(node: SolidNode): SolidNode | undefined { - const children = node.parent!.children || []; - const index = children.indexOf(node as any) + 1; - if (index < children.length) { - return children[index] as SolidNode; - } - return undefined; - }, -} satisfies SolidRendererOptions; diff --git a/src/universal.ts b/src/universal.ts new file mode 100644 index 0000000..db978e1 --- /dev/null +++ b/src/universal.ts @@ -0,0 +1,292 @@ +import * as s from 'solid-js'; +import type * as su from 'solid-js/universal/types/universal.js'; +import * as lng from '@lightningtv/core'; +import { SolidNode } from './types.js'; + +declare module '@lightningtv/core' { + interface ElementNode { + /** @internal for managing series of insertions and deletions */ + _queueDelete?: number; + preserve?: boolean; + _childrenChunks?: NodeChildren[] | string[][]; + } +} + +type JSXChildren = s.JSX.Element | (() => JSXChildren); +type NodeChildren = (lng.ElementNode | lng.ElementText)[]; + +Object.defineProperty(lng.ElementNode.prototype, 'preserve', { + get(): boolean | undefined { + return this._queueDelete === 0; + }, + set(v: boolean) { + this._queueDelete = v ? 0 : undefined; + }, +}); + +let elementDeleteQueue: lng.ElementNode[] = []; + +function flushDeleteQueue(): void { + for (let el of elementDeleteQueue) { + if (Number(el._queueDelete) < 0) { + el.destroy(); + } + el._queueDelete = undefined; + } + elementDeleteQueue.length = 0; +} + +function pushDeleteQueue(node: lng.ElementNode, n: number): void { + if (node._queueDelete === undefined) { + node._queueDelete = n; + if (elementDeleteQueue.push(node) === 1) { + queueMicrotask(flushDeleteQueue); + } + } else { + node._queueDelete += n; + } +} + +function resolveNodeJSXChildren( + jsx: JSXChildren, + out: NodeChildren = [], +): NodeChildren { + while (typeof jsx === 'function') jsx = jsx(); + if (Array.isArray(jsx)) { + for (let i = 0; i < jsx.length; i++) { + resolveNodeJSXChildren(jsx[i], out); + } + } else if (jsx instanceof lng.ElementNode) { + out.push(jsx); + } + return out; +} +function resolveTextJSXChildren( + jsx: JSXChildren, + out: string[] = [], +): string[] { + while (typeof jsx === 'function') jsx = jsx(); + if (Array.isArray(jsx)) { + for (let i = 0; i < jsx.length; i++) { + resolveTextJSXChildren(jsx[i], out); + } + } else if (typeof jsx === 'string' || typeof jsx === 'number') { + out.push(jsx.toString()); + } + return out; +} + +function commitChunks(el: lng.ElementNode | lng.ElementText): void { + if (lng.isElementText(el)) { + let text = ''; + for (let chunk of el._childrenChunks as string[][]) { + text += chunk.join(''); + } + el.text = text; + } else { + el.children = (el._childrenChunks as NodeChildren[]).flat(); + } +} +function commitChild( + parent: lng.ElementNode, + child: lng.ElementNode | lng.ElementText, +): void { + let prevParent = child.parent; + child.parent = parent; + if (parent.rendered) child.render(true); + if (prevParent) pushDeleteQueue(child as lng.ElementNode, 1); +} + +function insertChunk(el: lng.ElementNode, before: any): NodeChildren; +function insertChunk(el: lng.ElementText, before: any): string[]; +function insertChunk( + el: lng.ElementNode | lng.ElementText, + before: any, +): string[] | NodeChildren { + let chunks = (el._childrenChunks ??= []); + let chunk: any[] = []; + if (before != null) { + for (let i = 0; i < chunks.length; i++) { + if (before === chunks[i]![0]) { + chunks.splice(i, 0, chunk); + return chunk; + } + } + } + chunks.push(chunk); + return chunk; +} + +// Core renderer functions - adapted from solid-js/universal +export const insert: su.Renderer['insert'] = ( + parent, + accessor: any, + before, +) => { + /* */ + if (lng.isElementText(parent)) { + let chunk = insertChunk(parent, before); + + /* dynamic chunk */ + if (typeof accessor === 'function') { + s.createRenderEffect(() => { + chunk.length = 0; + resolveTextJSXChildren(accessor(), chunk); + commitChunks(parent); + }); + } else { + /* constant chunk */ + resolveTextJSXChildren(accessor, chunk); + commitChunks(parent); + } + + return chunk[0] || ''; + } + // + else { + let el = parent as lng.ElementNode; + let chunk = insertChunk(el, before); + + /* dynamic chunk */ + if (typeof accessor === 'function') { + s.createRenderEffect(() => { + let prev = chunk.slice(); + for (let i = 0; i < prev.length; i++) { + prev[i]!.parent = undefined; // commitChild will set the parent again + } + chunk.length = 0; + resolveNodeJSXChildren(accessor(), chunk); + commitChunks(el); + for (let i = 0; i < chunk.length; i++) { + commitChild(el, chunk[i]!); + } + /* Handle removed children */ + for (let i = 0; i < prev.length; i++) { + let c = prev[i]!; + if (c.parent !== el) { + (c as lng.ElementNode).onRemove?.(c as lng.ElementNode); // ? Should this be called in effect? + if (el.requiresLayout()) { + lng.addToLayoutQueue(el); + } + pushDeleteQueue(c as lng.ElementNode, -1); + } + } + }); + } else { + /* constant chunk */ + resolveNodeJSXChildren(accessor, chunk); + commitChunks(el); + for (let i = 0; i < chunk.length; i++) { + commitChild(el, chunk[i]!); + } + } + + return chunk[0] as any; + } +}; + +export const insertNode: su.Renderer['insertNode'] = ( + parent, + node, + before, +) => { + // + if (lng.isElementText(parent)) { + let chunk = insertChunk(parent, before); + resolveTextJSXChildren(node as any, chunk); + commitChunks(parent); + } + // + else { + let el = parent as lng.ElementNode; + let chunk = insertChunk(el, before); + chunk.push(node as lng.ElementNode); + commitChunks(el); + commitChild(el, node as lng.ElementNode); + } +}; + +export const spread: su.Renderer['spread'] = ( + node, + accessor: any, + skipChildren, +) => { + /* Ref */ + if (typeof accessor === 'function') { + s.createRenderEffect(() => { + let { ref } = accessor(); + if (ref) ref(node); + }); + } else if (accessor.ref) { + accessor.ref(node); + } + + /* Children */ + if (!skipChildren) { + if (typeof accessor === 'function') { + insert(node, () => accessor().children); + } else if ('children' in accessor) { + insert(node, () => accessor.children); + } + } + + /* Rest */ + let prevProps: any = {}; + s.createRenderEffect(() => { + let props = typeof accessor === 'function' ? accessor() : accessor; + for (let prop in props) { + switch (prop) { + case 'children': + case 'ref': + continue; + default: + let value = props[prop]; + if (value !== prevProps[prop]) { + node[prop] = prevProps[prop] = value; + } + } + } + }); +}; + +export const setProp: su.Renderer['setProp'] = ( + node, + name, + value, +) => { + return (node[name] = value); +}; + +// Lightning TV specific renderer functions +export const createElement: su.Renderer['createElement'] = ( + name, +) => { + return new lng.ElementNode(name); +}; + +export const createTextNode: su.Renderer['createTextNode'] = ( + text, +) => { + // A text node is just a string - not the node + // return { _type: lng.NodeType.Text, text }; + return text; +}; + +export const render: su.Renderer['render'] = (code, element) => { + let disposer: (() => void) | undefined; + s.createRoot((dispose) => { + disposer = dispose; + insert(element, code()); + }); + return disposer!; +}; + +export const mergeProps: su.Renderer['mergeProps'] = s.mergeProps; +export const effect: su.Renderer['effect'] = s.createRenderEffect; +export const memo: su.Renderer['memo'] = s.createMemo; +export const createComponent: su.Renderer['createComponent'] = + s.createComponent as any; + +export const use: su.Renderer['use'] = (fn, element, arg) => { + return s.untrack(() => fn(element, arg)); +}; diff --git a/tests/basic.test.tsx b/tests/basic.test.tsx index a696734..fb1dd3a 100644 --- a/tests/basic.test.tsx +++ b/tests/basic.test.tsx @@ -30,3 +30,34 @@ v.test('Update text', () => { dispose() }) + +v.test('reconcile children', () => { + + const [children, setChildren] = s.createSignal('') + + let view!: lng.ElementNode + const dispose = renderer.render(() => <> + + {children()} + + ) + + v.assert.equal(view.children.length, 0) + + setChildren([Child 1, undefined]) + v.assert.equal(view.children.length, 1) + v.assert.equal(view.children[0]!.text, 'Child 1') + + setChildren('') + v.assert.equal(view.children.length, 0) + + setChildren(Child 2) + v.assert.equal(view.children.length, 1) + v.assert.equal(view.children[0]!.text, 'Child 2') + + setChildren([Child 3, undefined]) + v.assert.equal(view.children.length, 1) + v.assert.equal(view.children[0]!.text, 'Child 3') + + dispose() +})