Skip to content

Commit

Permalink
fix(jsx): render a factory returning an atom
Browse files Browse the repository at this point in the history
  • Loading branch information
artalar authored and kasperskei committed Jan 5, 2025
1 parent 0439318 commit 03e8a25
Show file tree
Hide file tree
Showing 3 changed files with 156 additions and 72 deletions.
129 changes: 104 additions & 25 deletions packages/jsx/src/index.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -77,16 +77,16 @@ it('children updates', setup((ctx, h, hf, mount, parent) => {

mount(parent, element)

assert.is(element.childNodes.length, 3)
assert.is(element.childNodes[1]?.textContent, 'foo')
assert.is(element.childNodes[2], a)
assert.is(element.childNodes.length, 5)
assert.is(element.childNodes[2]?.textContent, 'foo')
assert.is(element.childNodes[4], a)

val(ctx, 'bar')
assert.is(element.childNodes[1]?.textContent, 'bar')
assert.is(element.childNodes[2]?.textContent, 'bar')

assert.is(element.childNodes[2], a)
assert.is(element.childNodes[4], a)
route(ctx, 'b')
assert.is(element.childNodes[2], b)
assert.is(element.childNodes[4], b)
}))

it('dynamic children', setup((ctx, h, hf, mount, parent) => {
Expand All @@ -96,14 +96,14 @@ it('dynamic children', setup((ctx, h, hf, mount, parent) => {

mount(parent, element)

assert.is(element.childNodes.length, 1)
assert.is(element.childNodes.length, 2)

children(ctx, <div>Hello, world!</div>)
assert.is(element.childNodes[0]?.textContent, 'Hello, world!')
assert.is(element.childNodes[1]?.textContent, 'Hello, world!')

const inner = <span>inner</span>
children(ctx, <div>{inner}</div>)
assert.is(element.childNodes[0]?.childNodes[0], inner)
assert.is(element.childNodes[1]?.childNodes[0], inner)

const before = atom('before', 'before')
const after = atom('after', 'after')
Expand Down Expand Up @@ -159,25 +159,24 @@ it('fragment as child', setup((ctx, h, hf, mount, parent) => {

it('array children', setup((ctx, h, hf, mount, parent) => {
const n = atom(1)
const list = atom((ctx) => Array.from({ length: ctx.spy(n) }, (_, i) => <li>{i + 1}</li>))

assert.throws(() => {
mount(
parent,
<ul>
{list /* expected TS error */ as any}
<br />
</ul>,
)
})
const list = atom((ctx) => (<>
{...Array.from({ length: ctx.spy(n) }, (_, i) => <li>{i + 1}</li>)}
</>))

const element = <ul>{list}</ul>
const element = (
<ul>
{list}
<br />
</ul>
)

assert.is(element.childNodes.length, 1)
mount(parent, element)

assert.is(element.childNodes.length, 3)
assert.is(element.textContent, '1')

n(ctx, 2)
assert.is(element.childNodes.length, 2)
assert.is(element.childNodes.length, 4)
assert.is(element.textContent, '12')
}))

Expand Down Expand Up @@ -288,15 +287,15 @@ it('render HTMLElement atom', setup((ctx, h, hf, mount, parent) => {

const element = <div>{htmlAtom}</div>

assert.is(element.innerHTML, '<div>div</div>')
assert.is(element.innerHTML, '<!--html--><div>div</div>')
}))

it('render SVGElement atom', setup((ctx, h, hf, mount, parent) => {
const svgAtom = atom(<svg:svg>svg</svg:svg>, 'svg')

const element = <div>{svgAtom}</div>

assert.is(element.innerHTML, '<svg>svg</svg>')
assert.is(element.innerHTML, '<!--svg--><svg>svg</svg>')
}))

it('custom component', setup((ctx, h, hf, mount, parent) => {
Expand Down Expand Up @@ -529,3 +528,83 @@ it('style object update', setup((ctx, h, hf, mount, parent) => {

assert.is(component.getAttribute('style'), 'left: 0px; bottom: 0px;')
}))

it('render different atom children', setup((ctx, h, hf, mount, parent) => {
const name = 'child'
const target = `<!--${name}-->`
const childAtom = atom<Node | string>(<>
<div>div</div>
<p>p</p>
</>, name)

const element = <div>{childAtom}</div>
assert.is(element.innerHTML, `<div>${target}div>div</div><p>p</p></div>`)

childAtom(ctx, <span>span</span>)
assert.is(element.innerHTML, `<div>${target}span>span</span></div>`)

childAtom(ctx, 'text')
assert.is(element.innerHTML, `<div>${target}text</div>`)
}))

it('render atom fragments', setup((ctx, h, hf, mount, parent) => {
const bool1Atom = atom(false)
const bool2Atom = atom(false)

const element = (
<div>
<p>0</p>
{atom(
(ctx) => ctx.spy(bool1Atom)
? <>
<p>1</p>
{atom(
(ctx) => ctx.spy(bool2Atom)
? <>
<p>2</p>
<p>3</p>
</>
: undefined,
'2'
)}
<p>4</p>
</>
: undefined,
'1',
)}
<p>5</p>
</div>
)

const expect1 = '<p>0</p><!--1--><p>5</p>'
const expect2 = '<p>0</p><!--1--><p>1</p><!--2--><p>4</p><p>5</p>'
const expect3 = '<p>0</p><!--1--><p>1</p><!--2--><p>2</p><p>3</p><p>4</p><p>5</p>'

bool1Atom(ctx, false)
bool2Atom(ctx, false)
assert.is(element.innerHTML, expect1)

bool1Atom(ctx, false)
bool2Atom(ctx, true)
assert.is(element.innerHTML, expect1)

bool1Atom(ctx, true)
bool2Atom(ctx, false)
assert.is(element.innerHTML, expect2)

bool1Atom(ctx, true)
bool2Atom(ctx, true)
assert.is(element.innerHTML, expect3)

bool1Atom(ctx, true)
bool2Atom(ctx, false)
assert.is(element.innerHTML, expect2)

bool1Atom(ctx, false)
bool2Atom(ctx, true)
assert.is(element.innerHTML, expect1)

bool1Atom(ctx, false)
bool2Atom(ctx, false)
assert.is(element.innerHTML, expect1)
}))
98 changes: 51 additions & 47 deletions packages/jsx/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,8 @@ type DomApis = Pick<
const isSkipped = (value: unknown): value is boolean | '' | null | undefined =>
typeof value === 'boolean' || value === '' || value == null

let unsubscribesMap = new WeakMap<Element, Array<Fn>>()
let unlink = (parent: JSX.Element, un: Unsubscribe) => {
let unsubscribesMap = new WeakMap<Node, Array<Fn>>()
let unlink = (parent: Node, un: Unsubscribe) => {
// check the connection in the next tick
// to give the user (programmer) an ability
// to put the created element in the dom
Expand Down Expand Up @@ -140,8 +140,44 @@ export const reatomJsx = (ctx: Ctx, DOM: DomApis = globalThis.window) => {
}
}

const walkAtom = (ctx: Ctx, anAtom: Atom<JSX.ElementPrimitiveChildren>): DocumentFragment => {
const fragment = DOM.document.createDocumentFragment()
const target = DOM.document.createComment(anAtom.__reatom.name ?? '')
fragment.append(target)

let childNodes: ChildNode[] = []
const un = ctx.subscribe(anAtom, (v): void => {
childNodes.forEach((node) => node.remove())

if (v instanceof DOM.Node) {
childNodes = v instanceof DOM.DocumentFragment ? [...v.childNodes] : [v as ChildNode]
target.after(v)
} else if (isSkipped(v)) {
childNodes = []
} else {
const node = DOM.document.createTextNode(String(v))
childNodes = [node]
target.after(node)
}
})

if (!unsubscribesMap.get(target)) unsubscribesMap.set(target, [])
unlink(target, un)

return fragment
}

let h = (tag: any, props: Rec, ...children: any[]) => {
if (tag === hf) return children
if (isAtom(tag)) {
return walkAtom(ctx, tag)
}

if (tag === hf) {
const fragment = DOM.document.createDocumentFragment()
children = children.map((child) => isAtom(child) ? walkAtom(ctx, child) : child)
fragment.append(...children)
return fragment
}

props ??= {}

Expand Down Expand Up @@ -209,56 +245,21 @@ export const reatomJsx = (ctx: Ctx, DOM: DomApis = globalThis.window) => {
}
}

/**
* @todo Explore adding elements to a DocumentFragment before adding them to a Document.
* @see https://www.measurethat.net/Benchmarks/Show/13274
*/
let walk = (child: JSX.DOMAttributes<JSX.Element>['children']) => {
if (Array.isArray(child)) {
for (let i = 0; i < child.length; i++) walk(child[i])
} else {
if (isLinkedListAtom(child)) {
walkLinkedList(ctx, element, child)
} else if (isAtom(child)) {
let innerChild = DOM.document.createTextNode('') as ChildNode | DocumentFragment
let error: any
var un: undefined | Unsubscribe = ctx.subscribe(child, (v): void => {
try {
if (un && !innerChild.isConnected && innerChild instanceof DOM.DocumentFragment === false) {
un()
} else {
throwReatomError(
Array.isArray(v) && children.length > 1,
'array children with other children are not supported',
)

if (v instanceof DOM.Element) {
let list = unsubscribesMap.get(v)
if (!list) unsubscribesMap.set(v, (list = []))

if (un) element.replaceChild(v, innerChild)
innerChild = v
} else if (Array.isArray(v)) {
if (un) element.replaceChildren(...v)
else {
const fragment = new DOM.DocumentFragment()
v.forEach((el) => fragment.append(el))
innerChild = fragment
}
} else {
// TODO more tests
innerChild.textContent = isSkipped(v) ? '' : String(v)
}
}
} catch (e) {
error = e
}
})
if (error) throw error
unlink(element, un)
element.appendChild(innerChild)
const fragment = walkAtom(ctx, child)
element.append(fragment)
} else if (!isSkipped(child)) {
element.appendChild(
isObject(child) && 'nodeType' in child
? (child as JSX.Element)
: DOM.document.createTextNode(String(child)),
)
element.append(child as Node | string)
}
}
}
Expand All @@ -270,7 +271,10 @@ export const reatomJsx = (ctx: Ctx, DOM: DomApis = globalThis.window) => {
return element
}

/** Fragment */
/**
* Fragment.
* @todo Describe a function as a component.
*/
let hf = () => {}

let mount = (target: Element, child: Element) => {
Expand All @@ -283,7 +287,7 @@ export const reatomJsx = (ctx: Ctx, DOM: DomApis = globalThis.window) => {
* @see https://stackoverflow.com/a/64551276
* @note A custom NodeFilter function slows down performance by 1.5 times.
*/
const walker = DOM.document.createTreeWalker(removedNode, 1)
const walker = DOM.document.createTreeWalker(removedNode, 1 | 128)

do {
const node = walker.currentNode as Element
Expand Down
1 change: 1 addition & 0 deletions packages/jsx/src/jsx.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ type ElementsAttributesAtomMaybe<T extends Record<keyof any, any>> = {
export namespace JSX {
type Element = HTMLElement | SVGElement

/** @todo Try replacing `Node | Element` with `ChildNode`. */
type ElementPrimitiveChildren = Node | Element | (string & {}) | number | boolean | null | undefined

type ElementChildren =
Expand Down

0 comments on commit 03e8a25

Please sign in to comment.