From 4b4063b6df733cb7ea1ebf8c266c9679c43e08a6 Mon Sep 17 00:00:00 2001 From: Henry Allen Date: Sat, 20 Jul 2024 23:24:56 -0400 Subject: [PATCH 01/12] mergeChildNodes attempt 1 --- packages/pipe/src/createElement.ts | 42 +++++++++++++++++-- packages/pipe/src/index.spec.ts | 66 ++++++++++++++++++++++++++++++ 2 files changed, 105 insertions(+), 3 deletions(-) diff --git a/packages/pipe/src/createElement.ts b/packages/pipe/src/createElement.ts index f5981e8..b78b86f 100644 --- a/packages/pipe/src/createElement.ts +++ b/packages/pipe/src/createElement.ts @@ -15,7 +15,7 @@ export function createElement< >( component: Component | string, props: Props, - children?: PipeNode[] + children?: PipeNode[] | Observable<[string, PipeNode | null]> ): PipeNode { const cleanup$ = new Subject(); const element = @@ -29,16 +29,52 @@ export function createElement< }, }); - if (children) { + const node = { element, cleanup$ }; + + if (Array.isArray(children)) { for (const { element: child, cleanup$: childCleanup$ } of children) { element.appendChild(child); cleanup$.subscribe(childCleanup$); } + } else if (children) { + mergeChildNodes(children, node); } - return { element, cleanup$ }; + return node; } +const mergeChildNodes = ( + source: Observable<[string, PipeNode | null]>, + parentNode: PipeNode +) => { + const nodes = new Map(); + + source.pipe(takeUntil(parentNode.cleanup$)).subscribe({ + next: ([key, nextNode]) => { + const existingNode = nodes.get(key); + + if (existingNode) { + existingNode.cleanup$.next(); + existingNode.cleanup$.complete(); + } + + if (nextNode) { + parentNode.cleanup$.subscribe(nextNode.cleanup$); + parentNode.element.appendChild(nextNode.element); + nodes.set(key, nextNode); + } else { + nodes.delete(key); + } + }, + complete: () => { + for (const node of nodes.values()) { + node.cleanup$.next(); + node.cleanup$.complete(); + } + }, + }); +}; + function initializeDomElement< Props extends Record>, >(component: string, props: Props, cleanup$: Observable): HTMLElement { diff --git a/packages/pipe/src/index.spec.ts b/packages/pipe/src/index.spec.ts index af4dbe1..aa08893 100644 --- a/packages/pipe/src/index.spec.ts +++ b/packages/pipe/src/index.spec.ts @@ -151,6 +151,72 @@ describe('Pipe', () => { expect(spy).toHaveBeenCalledTimes(4); }); + it('should render a list of children from an observable', () => { + const TextWrapper: Component<{ + addChild$: Observable<[string, string]>; + }> = ({ addChild$ }) => { + return createElement( + 'div', + {}, + addChild$.pipe( + map(([key, value]) => [ + key, + createElement('p', { + textContent: new BehaviorSubject(value), + }), + ]) + ) + ); + }; + const container = document.createElement('div'); + + const { render, unmount } = createRoot(container); + + const addChild$ = new Subject<[string, string]>(); + + render( + createElement(TextWrapper, { + addChild$, + }) + ); + + const div = container.firstChild as HTMLDivElement; + expect(div).toBeInstanceOf(HTMLDivElement); + + expect(div.childNodes.length).toEqual(0); + + addChild$.next(['a', 'foo']); + + expect(div.childNodes.length).toEqual(1); + const childA = div.firstChild as HTMLParagraphElement; + expect(childA.textContent).toEqual('foo'); + + addChild$.next(['b', 'bar']); + + expect(div.childNodes.length).toEqual(2); + const childB = childA.nextSibling as HTMLParagraphElement; + expect(childB.textContent).toEqual('bar'); + + addChild$.next(['a', 'baz']); + + expect(div.childNodes.length).toEqual(2); + const childAVersion2 = div.firstChild as HTMLParagraphElement; + expect(childAVersion2.textContent).toEqual('baz'); + + expect(childAVersion2.nextSibling).toEqual(childB); + + addChild$.next(['b', null]); + expect(div.childNodes.length).toEqual(1); + expect(childAVersion2.nextSibling).toBeFalsy(); + + addChild$.next(['a', null]); + expect(div.childNodes.length).toEqual(0); + + unmount(); + + expect(container.firstChild).toBeFalsy(); + }); + it('should clean up observables on unmount', () => { const spy = jest.fn(); const Text: Component<{ From 2c2f1a5f95d72c935f3e32cbf1dcfaa6d4c64ec7 Mon Sep 17 00:00:00 2001 From: Henry Allen Date: Sun, 21 Jul 2024 00:02:26 -0400 Subject: [PATCH 02/12] Get test passing --- packages/pipe/src/createElement.ts | 24 +++++++++++++++--------- packages/pipe/src/index.spec.ts | 13 ++++++------- 2 files changed, 21 insertions(+), 16 deletions(-) diff --git a/packages/pipe/src/createElement.ts b/packages/pipe/src/createElement.ts index b78b86f..86c08c8 100644 --- a/packages/pipe/src/createElement.ts +++ b/packages/pipe/src/createElement.ts @@ -25,6 +25,7 @@ export function createElement< cleanup$.subscribe({ complete: () => { + console.log(element.textContent); element.remove(); }, }); @@ -52,19 +53,24 @@ const mergeChildNodes = ( source.pipe(takeUntil(parentNode.cleanup$)).subscribe({ next: ([key, nextNode]) => { const existingNode = nodes.get(key); + const nextSibling = existingNode?.element.nextSibling; + existingNode?.cleanup$.next(); + existingNode?.cleanup$.complete(); - if (existingNode) { - existingNode.cleanup$.next(); - existingNode.cleanup$.complete(); + if (!nextNode) { + nodes.delete(key); + return; } + nodes.set(key, nextNode); + parentNode.cleanup$.subscribe(nextNode.cleanup$); - if (nextNode) { - parentNode.cleanup$.subscribe(nextNode.cleanup$); - parentNode.element.appendChild(nextNode.element); - nodes.set(key, nextNode); - } else { - nodes.delete(key); + if (nextSibling) { + parentNode.element.insertBefore(nextNode.element, nextSibling); + return; } + + parentNode.element.appendChild(nextNode.element); + return; }, complete: () => { for (const node of nodes.values()) { diff --git a/packages/pipe/src/index.spec.ts b/packages/pipe/src/index.spec.ts index aa08893..bd7db8a 100644 --- a/packages/pipe/src/index.spec.ts +++ b/packages/pipe/src/index.spec.ts @@ -183,34 +183,33 @@ describe('Pipe', () => { const div = container.firstChild as HTMLDivElement; expect(div).toBeInstanceOf(HTMLDivElement); - expect(div.childNodes.length).toEqual(0); + expect(div.textContent).toEqual(''); addChild$.next(['a', 'foo']); - expect(div.childNodes.length).toEqual(1); + expect(div.textContent).toEqual('foo'); const childA = div.firstChild as HTMLParagraphElement; expect(childA.textContent).toEqual('foo'); addChild$.next(['b', 'bar']); - expect(div.childNodes.length).toEqual(2); + expect(div.textContent).toEqual('foobar'); const childB = childA.nextSibling as HTMLParagraphElement; expect(childB.textContent).toEqual('bar'); addChild$.next(['a', 'baz']); - expect(div.childNodes.length).toEqual(2); + expect(div.textContent).toEqual('bazbar'); const childAVersion2 = div.firstChild as HTMLParagraphElement; expect(childAVersion2.textContent).toEqual('baz'); expect(childAVersion2.nextSibling).toEqual(childB); addChild$.next(['b', null]); - expect(div.childNodes.length).toEqual(1); - expect(childAVersion2.nextSibling).toBeFalsy(); + expect(div.textContent).toEqual('baz'); addChild$.next(['a', null]); - expect(div.childNodes.length).toEqual(0); + expect(div.textContent).toEqual(''); unmount(); From 8f5799a5be1443efb2ab0a944d2536f3236cd7c8 Mon Sep 17 00:00:00 2001 From: Henry Allen Date: Sun, 21 Jul 2024 00:06:27 -0400 Subject: [PATCH 03/12] Remove ternary --- packages/pipe/src/components/index.ts | 1 - packages/pipe/src/components/ternary.spec.ts | 231 ------------------- packages/pipe/src/components/ternary.ts | 49 ---- packages/pipe/src/index.ts | 1 - 4 files changed, 282 deletions(-) delete mode 100644 packages/pipe/src/components/index.ts delete mode 100644 packages/pipe/src/components/ternary.spec.ts delete mode 100644 packages/pipe/src/components/ternary.ts diff --git a/packages/pipe/src/components/index.ts b/packages/pipe/src/components/index.ts deleted file mode 100644 index bbdc4be..0000000 --- a/packages/pipe/src/components/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './ternary'; diff --git a/packages/pipe/src/components/ternary.spec.ts b/packages/pipe/src/components/ternary.spec.ts deleted file mode 100644 index a792abb..0000000 --- a/packages/pipe/src/components/ternary.spec.ts +++ /dev/null @@ -1,231 +0,0 @@ -import { BehaviorSubject, Observable, Subject, map } from 'rxjs'; -import { Component, createElement } from '../createElement'; -import { Ternary } from './ternary'; -import { createRoot } from '../createRoot'; - -const SwitchComponent: Component<{ - bool$: Observable; -}> = ({ bool$ }) => { - return Ternary( - bool$, - () => - createElement('p', { - textContent: new BehaviorSubject('true'), - }), - () => - createElement('p', { - textContent: new BehaviorSubject('false'), - }) - ); -}; - -const CountListenerComponent: Component<{ count$: Observable }> = ({ - count$, -}) => { - return createElement('p', { - textContent: count$, - }); -}; - -const ComplexSwitchComponent: Component<{ - bool$: Observable; - count$: Observable; -}> = ({ bool$, count$ }) => { - return Ternary( - bool$, - () => - createElement('div', {}, [ - createElement(CountListenerComponent, { count$ }), - ]), - () => - createElement('div', {}, [ - createElement(CountListenerComponent, { - count$: count$.pipe(map((count) => count * 2)), - }), - ]) - ); -}; - -const TernaryChildComponent: Component<{ - bool$: Observable; -}> = ({ bool$ }) => { - return createElement('div', {}, [ - createElement(SwitchComponent, { bool$ }), - ]); -}; - -describe('Ternary', () => { - it('should mount the truthy node on initialization', () => { - const container = document.createElement('div'); - - const { render, unmount } = createRoot(container); - - const bool$ = new Subject(); - - render(createElement(SwitchComponent, { bool$ })); - - const truthyText = container.firstChild as HTMLParagraphElement; - expect(truthyText).toBeInstanceOf(HTMLParagraphElement); - expect(truthyText.textContent).toEqual('true'); - - unmount(); - - expect(container.firstChild).toBeFalsy(); - }); - - it('should switch to the falsy value when the bool is updated', () => { - const container = document.createElement('div'); - - const { render, unmount } = createRoot(container); - - const bool$ = new Subject(); - - render(createElement(SwitchComponent, { bool$ })); - - const truthyText = container.firstChild as HTMLParagraphElement; - expect(truthyText).toBeInstanceOf(HTMLParagraphElement); - expect(truthyText.textContent).toEqual('true'); - - expect(container.childNodes.length).toEqual(1); - - bool$.next(false); - - const falsyText = container.firstChild as HTMLParagraphElement; - expect(falsyText).toBeInstanceOf(HTMLParagraphElement); - expect(falsyText.textContent).toEqual('false'); - - expect(container.childNodes.length).toEqual(1); - - unmount(); - - expect(container.firstChild).toBeFalsy(); - }); - - it('should switch between truthy and falsy values', () => { - const container = document.createElement('div'); - - const { render, unmount } = createRoot(container); - - const bool$ = new Subject(); - - render(createElement(SwitchComponent, { bool$ })); - - const truthyText = container.firstChild as HTMLParagraphElement; - expect(truthyText).toBeInstanceOf(HTMLParagraphElement); - expect(truthyText.textContent).toEqual('true'); - - expect(container.childNodes.length).toEqual(1); - - bool$.next(false); - - const falsyText = container.firstChild as HTMLParagraphElement; - expect(falsyText).toBeInstanceOf(HTMLParagraphElement); - expect(falsyText.textContent).toEqual('false'); - - expect(container.childNodes.length).toEqual(1); - - bool$.next(true); - - const truthyText2 = container.firstChild as HTMLParagraphElement; - expect(truthyText2).toBeInstanceOf(HTMLParagraphElement); - expect(truthyText2.textContent).toEqual('true'); - - expect(container.childNodes.length).toEqual(1); - - unmount(); - - expect(container.firstChild).toBeFalsy(); - }); - - it('should not re-create the child if the boolean does not change', () => { - const container = document.createElement('div'); - - const { render, unmount } = createRoot(container); - - const bool$ = new Subject(); - - render(createElement(SwitchComponent, { bool$ })); - - const truthyText = container.firstChild as HTMLParagraphElement; - expect(truthyText).toBeInstanceOf(HTMLParagraphElement); - expect(truthyText.textContent).toEqual('true'); - - bool$.next(true); - - expect(container.firstChild).toEqual(truthyText); - - bool$.next(false); - - expect(container.firstChild).not.toEqual(truthyText); - - unmount(); - - expect(container.firstChild).toBeFalsy(); - }); - - it('should render components with children', () => { - const container = document.createElement('div'); - - const { render, unmount } = createRoot(container); - - const bool$ = new Subject(); - const count$ = new BehaviorSubject(0); - - render(createElement(ComplexSwitchComponent, { bool$, count$ })); - - const truthyDiv = container.firstChild as HTMLDivElement; - expect(truthyDiv).toBeInstanceOf(HTMLDivElement); - - const singleCount = truthyDiv.firstChild as HTMLParagraphElement; - expect(singleCount.textContent).toEqual('0'); - - count$.next(1); - - expect(singleCount.textContent).toEqual('1'); - - expect(container.childNodes.length).toEqual(1); - - bool$.next(false); - - const falsyDiv = container.firstChild as HTMLDivElement; - expect(falsyDiv).toBeInstanceOf(HTMLDivElement); - - const doubleCount = falsyDiv.firstChild as HTMLParagraphElement; - expect(doubleCount.textContent).toEqual('2'); - - count$.next(2); - - expect(doubleCount.textContent).toEqual('4'); - - expect(container.childNodes.length).toEqual(1); - - unmount(); - - expect(container.firstChild).toBeFalsy(); - }); - - it('should render a Ternary as a child', () => { - const container = document.createElement('div'); - - const { render, unmount } = createRoot(container); - - const bool$ = new Subject(); - - render(createElement(TernaryChildComponent, { bool$ })); - - const div = container.firstChild as HTMLDivElement; - const truthyText = div.firstChild as HTMLParagraphElement; - expect(truthyText).toBeInstanceOf(HTMLParagraphElement); - expect(truthyText.textContent).toEqual('true'); - - bool$.next(false); - - const falsyText = div.firstChild as HTMLParagraphElement; - expect(falsyText).toBeInstanceOf(HTMLParagraphElement); - expect(falsyText.textContent).toEqual('false'); - - unmount(); - - expect(container.firstChild).toBeFalsy(); - }); -}); diff --git a/packages/pipe/src/components/ternary.ts b/packages/pipe/src/components/ternary.ts deleted file mode 100644 index 151e256..0000000 --- a/packages/pipe/src/components/ternary.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { - BehaviorSubject, - Observable, - Subject, - distinctUntilChanged, - takeUntil, - tap, - withLatestFrom, -} from 'rxjs'; -import { PipeNode } from '../createElement'; - -export const Ternary = ( - bool$: Observable, - truthyNode: () => PipeNode, - falsyNode: () => PipeNode -): PipeNode => { - const cleanup$ = new Subject(); - const initialNode = truthyNode(); - const node$ = new BehaviorSubject(initialNode); - - const outNode = { - cleanup$, - element: initialNode.element, - }; - - cleanup$.subscribe(initialNode.cleanup$); - - bool$ - .pipe( - takeUntil(cleanup$), - distinctUntilChanged(), - withLatestFrom(node$), - tap(([bool, node]) => { - const parent = node.element.parentNode; - node.cleanup$.next(); - node.cleanup$.complete(); - - const nextNode = bool ? truthyNode() : falsyNode(); - cleanup$.subscribe(nextNode.cleanup$); - outNode.element = nextNode.element; - parent.appendChild(nextNode.element); - - node$.next(nextNode); - }) - ) - .subscribe(); - - return outNode; -}; diff --git a/packages/pipe/src/index.ts b/packages/pipe/src/index.ts index 6dede37..356151c 100644 --- a/packages/pipe/src/index.ts +++ b/packages/pipe/src/index.ts @@ -1,3 +1,2 @@ export * from './createElement'; export * from './createRoot'; -export * from './components'; From 8244ea82e920e36236ee644cc47c07db694d72d3 Mon Sep 17 00:00:00 2001 From: Henry Allen Date: Sun, 21 Jul 2024 12:54:10 -0400 Subject: [PATCH 04/12] Remove log --- packages/pipe/src/createElement.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/pipe/src/createElement.ts b/packages/pipe/src/createElement.ts index 86c08c8..0f6d04a 100644 --- a/packages/pipe/src/createElement.ts +++ b/packages/pipe/src/createElement.ts @@ -25,7 +25,6 @@ export function createElement< cleanup$.subscribe({ complete: () => { - console.log(element.textContent); element.remove(); }, }); From 83ba790878c4a133d179b92df6023e769896864c Mon Sep 17 00:00:00 2001 From: Henry Allen Date: Sun, 21 Jul 2024 13:18:48 -0400 Subject: [PATCH 05/12] createElement --- packages/pipe/src/createElement.ts | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/packages/pipe/src/createElement.ts b/packages/pipe/src/createElement.ts index 0f6d04a..49bb044 100644 --- a/packages/pipe/src/createElement.ts +++ b/packages/pipe/src/createElement.ts @@ -101,12 +101,7 @@ function initializePipeElement< cleanup$: Observable ): HTMLElement { const { element, cleanup$: childCleanup$ } = component(props, cleanup$); - cleanup$.subscribe({ - complete: () => { - childCleanup$.next(); - childCleanup$.complete(); - }, - }); + cleanup$.subscribe(childCleanup$); return element; } From cbb38f37bfc2dc41b76fe3e793fe3cf7f2a86d6d Mon Sep 17 00:00:00 2001 From: Henry Allen Date: Sun, 21 Jul 2024 13:45:55 -0400 Subject: [PATCH 06/12] Add tests --- packages/pipe/src/index.spec.ts | 234 +++++++++++++++++++++++++++++++- 1 file changed, 233 insertions(+), 1 deletion(-) diff --git a/packages/pipe/src/index.spec.ts b/packages/pipe/src/index.spec.ts index bd7db8a..9aeb209 100644 --- a/packages/pipe/src/index.spec.ts +++ b/packages/pipe/src/index.spec.ts @@ -1,6 +1,14 @@ import { Component, createElement } from './createElement'; import { createRoot } from './createRoot'; -import { Observable, Subject, BehaviorSubject, scan, map, tap } from 'rxjs'; +import { + Observable, + Subject, + BehaviorSubject, + scan, + map, + tap, + distinctUntilChanged, +} from 'rxjs'; describe('Pipe', () => { it('should render an HTML element in a component', () => { @@ -274,3 +282,227 @@ describe('Pipe', () => { expect(spy).toHaveBeenCalledWith('2'); }); }); + +describe('Boolean Operator', () => { + const SwitchComponent: Component<{ + bool$: Observable; + }> = ({ bool$ }) => { + return createElement( + 'div', + {}, + bool$.pipe( + map((value) => [ + 'default', + value + ? createElement('p', { + textContent: new BehaviorSubject('true'), + }) + : createElement('p', { + textContent: new BehaviorSubject('false'), + }), + ]) + ) + ); + }; + + const CountListenerComponent: Component<{ count$: Observable }> = ({ + count$, + }) => { + return createElement('p', { + textContent: count$, + }); + }; + + const ComplexSwitchComponent: Component<{ + bool$: Observable; + count$: Observable; + }> = ({ bool$, count$ }) => { + return createElement( + 'div', + {}, + bool$.pipe( + distinctUntilChanged(), + map((value) => [ + 'default', + value + ? createElement(CountListenerComponent, { count$ }) + : createElement(CountListenerComponent, { + count$: count$.pipe(map((count) => count * 2)), + }), + ]) + ) + ); + }; + + it('should not mount a node on initialization', () => { + const container = document.createElement('div'); + + const { render, unmount } = createRoot(container); + + const bool$ = new Subject(); + + render(createElement(SwitchComponent, { bool$ })); + + const div = container.firstChild as HTMLDivElement; + expect(div).toBeTruthy(); + expect(div.firstChild).toBeFalsy(); + + bool$.next(true); + + const truthyText = div.firstChild as HTMLParagraphElement; + expect(truthyText).toBeInstanceOf(HTMLParagraphElement); + expect(truthyText.textContent).toEqual('true'); + + unmount(); + + expect(container.firstChild).toBeFalsy(); + }); + + it('should switch to the falsy value when the bool is updated', () => { + const container = document.createElement('div'); + + const { render, unmount } = createRoot(container); + + const bool$ = new Subject(); + + render(createElement(SwitchComponent, { bool$ })); + + const div = container.firstChild as HTMLDivElement; + expect(div).toBeTruthy(); + expect(div.firstChild).toBeFalsy(); + + bool$.next(true); + + const truthyText = div.firstChild as HTMLParagraphElement; + expect(truthyText).toBeInstanceOf(HTMLParagraphElement); + expect(truthyText.textContent).toEqual('true'); + + expect(div.childNodes.length).toEqual(1); + + bool$.next(false); + + const falsyText = div.firstChild as HTMLParagraphElement; + expect(falsyText).toBeInstanceOf(HTMLParagraphElement); + expect(falsyText.textContent).toEqual('false'); + + expect(div.childNodes.length).toEqual(1); + + unmount(); + + expect(container.firstChild).toBeFalsy(); + }); + + it('should switch between truthy and falsy values', () => { + const container = document.createElement('div'); + + const { render, unmount } = createRoot(container); + + const bool$ = new Subject(); + + render(createElement(SwitchComponent, { bool$ })); + + const div = container.firstChild as HTMLDivElement; + expect(div).toBeTruthy(); + expect(div.firstChild).toBeFalsy(); + + bool$.next(true); + + const truthyText = div.firstChild as HTMLParagraphElement; + expect(truthyText).toBeInstanceOf(HTMLParagraphElement); + expect(truthyText.textContent).toEqual('true'); + + expect(div.childNodes.length).toEqual(1); + + bool$.next(false); + + const falsyText = div.firstChild as HTMLParagraphElement; + expect(falsyText).toBeInstanceOf(HTMLParagraphElement); + expect(falsyText.textContent).toEqual('false'); + + expect(div.childNodes.length).toEqual(1); + + bool$.next(true); + + const truthyText2 = div.firstChild as HTMLParagraphElement; + expect(truthyText2).toBeInstanceOf(HTMLParagraphElement); + expect(truthyText2.textContent).toEqual('true'); + + expect(div.childNodes.length).toEqual(1); + + unmount(); + + expect(container.firstChild).toBeFalsy(); + }); + + it('should not re-create the child if the boolean does not change', () => { + const container = document.createElement('div'); + + const { render, unmount } = createRoot(container); + + const bool$ = new Subject(); + + render(createElement(SwitchComponent, { bool$ })); + + const div = container.firstChild as HTMLDivElement; + expect(div).toBeTruthy(); + expect(div.firstChild).toBeFalsy(); + + bool$.next(true); + + const truthyText = div.firstChild as HTMLParagraphElement; + expect(truthyText).toBeInstanceOf(HTMLParagraphElement); + expect(truthyText.textContent).toEqual('true'); + + bool$.next(true); + + expect(div.firstChild).toEqual(truthyText); + + bool$.next(false); + + expect(div.firstChild).not.toEqual(truthyText); + + unmount(); + + expect(container.firstChild).toBeFalsy(); + }); + + it('should render components with children', () => { + const container = document.createElement('div'); + + const { render, unmount } = createRoot(container); + + const bool$ = new Subject(); + const count$ = new BehaviorSubject(0); + + render(createElement(ComplexSwitchComponent, { bool$, count$ })); + + const div = container.firstChild as HTMLDivElement; + expect(div).toBeInstanceOf(HTMLDivElement); + + bool$.next(true); + + const singleCount = div.firstChild as HTMLParagraphElement; + expect(singleCount.textContent).toEqual('0'); + + count$.next(1); + + expect(singleCount.textContent).toEqual('1'); + + expect(container.childNodes.length).toEqual(1); + + bool$.next(false); + + const doubleCount = div.firstChild as HTMLParagraphElement; + expect(doubleCount.textContent).toEqual('2'); + + count$.next(2); + + expect(doubleCount.textContent).toEqual('4'); + + expect(container.childNodes.length).toEqual(1); + + unmount(); + + expect(container.firstChild).toBeFalsy(); + }); +}); From fd28ca1a6be106b2bd9b452bc4a584266d7b72af Mon Sep 17 00:00:00 2001 From: Henry Allen Date: Sun, 21 Jul 2024 13:52:52 -0400 Subject: [PATCH 07/12] Fix lint --- packages/pipe/src/index.spec.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/pipe/src/index.spec.ts b/packages/pipe/src/index.spec.ts index 9aeb209..e4ae0d4 100644 --- a/packages/pipe/src/index.spec.ts +++ b/packages/pipe/src/index.spec.ts @@ -295,11 +295,11 @@ describe('Boolean Operator', () => { 'default', value ? createElement('p', { - textContent: new BehaviorSubject('true'), - }) + textContent: new BehaviorSubject('true'), + }) : createElement('p', { - textContent: new BehaviorSubject('false'), - }), + textContent: new BehaviorSubject('false'), + }), ]) ) ); @@ -327,8 +327,8 @@ describe('Boolean Operator', () => { value ? createElement(CountListenerComponent, { count$ }) : createElement(CountListenerComponent, { - count$: count$.pipe(map((count) => count * 2)), - }), + count$: count$.pipe(map((count) => count * 2)), + }), ]) ) ); From 15839f34fa4d52a1ebba3db29124f1ed013548f6 Mon Sep 17 00:00:00 2001 From: Henry Allen Date: Sun, 21 Jul 2024 13:53:41 -0400 Subject: [PATCH 08/12] Fix style --- packages/pipe/src/index.spec.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/pipe/src/index.spec.ts b/packages/pipe/src/index.spec.ts index e4ae0d4..9aeb209 100644 --- a/packages/pipe/src/index.spec.ts +++ b/packages/pipe/src/index.spec.ts @@ -295,11 +295,11 @@ describe('Boolean Operator', () => { 'default', value ? createElement('p', { - textContent: new BehaviorSubject('true'), - }) + textContent: new BehaviorSubject('true'), + }) : createElement('p', { - textContent: new BehaviorSubject('false'), - }), + textContent: new BehaviorSubject('false'), + }), ]) ) ); @@ -327,8 +327,8 @@ describe('Boolean Operator', () => { value ? createElement(CountListenerComponent, { count$ }) : createElement(CountListenerComponent, { - count$: count$.pipe(map((count) => count * 2)), - }), + count$: count$.pipe(map((count) => count * 2)), + }), ]) ) ); From 307fa548327207a2ec28318c322788ae9b548e93 Mon Sep 17 00:00:00 2001 From: Henry Allen Date: Sun, 21 Jul 2024 14:03:08 -0400 Subject: [PATCH 09/12] Update prettier config --- .prettierrc | 7 +++++-- packages/pipe/src/index.spec.ts | 12 ++++++------ 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/.prettierrc b/.prettierrc index 27512b6..0a79cf3 100644 --- a/.prettierrc +++ b/.prettierrc @@ -1,5 +1,8 @@ { - "trailingComma": "es5", + "trailingComma": "all", "tabWidth": 4, - "singleQuote": true + "singleQuote": true, + "printWidth": 100, + "semi": true, + "arrowParens": "always" } diff --git a/packages/pipe/src/index.spec.ts b/packages/pipe/src/index.spec.ts index 9aeb209..e4ae0d4 100644 --- a/packages/pipe/src/index.spec.ts +++ b/packages/pipe/src/index.spec.ts @@ -295,11 +295,11 @@ describe('Boolean Operator', () => { 'default', value ? createElement('p', { - textContent: new BehaviorSubject('true'), - }) + textContent: new BehaviorSubject('true'), + }) : createElement('p', { - textContent: new BehaviorSubject('false'), - }), + textContent: new BehaviorSubject('false'), + }), ]) ) ); @@ -327,8 +327,8 @@ describe('Boolean Operator', () => { value ? createElement(CountListenerComponent, { count$ }) : createElement(CountListenerComponent, { - count$: count$.pipe(map((count) => count * 2)), - }), + count$: count$.pipe(map((count) => count * 2)), + }), ]) ) ); From 5cd67108f7dbea1909b5d35b30d7f9ca7aaedd30 Mon Sep 17 00:00:00 2001 From: Henry Allen Date: Sun, 21 Jul 2024 14:09:31 -0400 Subject: [PATCH 10/12] Fix conflicts in configs --- .eslintrc.json | 5 +-- package-lock.json | 13 +++++++ package.json | 1 + packages/pipe/src/createElement.ts | 27 ++++++-------- packages/pipe/src/index.spec.ts | 56 ++++++++++++------------------ 5 files changed, 48 insertions(+), 54 deletions(-) diff --git a/.eslintrc.json b/.eslintrc.json index 10269bf..16c730d 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -3,7 +3,7 @@ "browser": true, "es2021": true }, - "extends": ["eslint:recommended", "plugin:@typescript-eslint/recommended"], + "extends": ["eslint:recommended", "plugin:@typescript-eslint/recommended", "prettier"], "parser": "@typescript-eslint/parser", "parserOptions": { "ecmaVersion": "latest", @@ -11,10 +11,7 @@ }, "plugins": ["@typescript-eslint", "workspaces"], "rules": { - "indent": ["error", 4], - "linebreak-style": ["error", "unix"], "quotes": ["error", "single"], - "semi": ["error", "always"], "no-var": ["error"], "workspaces/no-relative-imports": "error", "workspaces/require-dependency": "warn" diff --git a/package-lock.json b/package-lock.json index f57ce6d..6567512 100644 --- a/package-lock.json +++ b/package-lock.json @@ -25,6 +25,7 @@ "@typescript-eslint/eslint-plugin": "^7.8.0", "@typescript-eslint/parser": "^7.8.0", "eslint": "^8.57.0", + "eslint-config-prettier": "^9.1.0", "eslint-plugin-workspaces": "^0.10.0", "globals": "^15.1.0", "jest": "^29.7.0", @@ -4490,6 +4491,18 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/eslint-config-prettier": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-9.1.0.tgz", + "integrity": "sha512-NSWl5BFQWEPi1j4TjVNItzYV7dZXZ+wP6I6ZhrBGpChQhZRUaElihE9uRRkcbRnNb76UMKDF3r+WTmNcGPKsqw==", + "dev": true, + "bin": { + "eslint-config-prettier": "bin/cli.js" + }, + "peerDependencies": { + "eslint": ">=7.0.0" + } + }, "node_modules/eslint-plugin-workspaces": { "version": "0.10.0", "resolved": "https://registry.npmjs.org/eslint-plugin-workspaces/-/eslint-plugin-workspaces-0.10.0.tgz", diff --git a/package.json b/package.json index 2a5cbc8..f4f3006 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ "@typescript-eslint/eslint-plugin": "^7.8.0", "@typescript-eslint/parser": "^7.8.0", "eslint": "^8.57.0", + "eslint-config-prettier": "^9.1.0", "eslint-plugin-workspaces": "^0.10.0", "globals": "^15.1.0", "jest": "^29.7.0", diff --git a/packages/pipe/src/createElement.ts b/packages/pipe/src/createElement.ts index 49bb044..0446d3a 100644 --- a/packages/pipe/src/createElement.ts +++ b/packages/pipe/src/createElement.ts @@ -2,7 +2,7 @@ import { Observable, Subject, takeUntil } from 'rxjs'; export type Component>> = ( props: Props, - cleanup$: Observable + cleanup$: Observable, ) => PipeNode; export type PipeNode = { @@ -10,12 +10,10 @@ export type PipeNode = { cleanup$: Subject; }; -export function createElement< - Props extends Record>, ->( +export function createElement>>( component: Component | string, props: Props, - children?: PipeNode[] | Observable<[string, PipeNode | null]> + children?: PipeNode[] | Observable<[string, PipeNode | null]>, ): PipeNode { const cleanup$ = new Subject(); const element = @@ -43,10 +41,7 @@ export function createElement< return node; } -const mergeChildNodes = ( - source: Observable<[string, PipeNode | null]>, - parentNode: PipeNode -) => { +const mergeChildNodes = (source: Observable<[string, PipeNode | null]>, parentNode: PipeNode) => { const nodes = new Map(); source.pipe(takeUntil(parentNode.cleanup$)).subscribe({ @@ -80,9 +75,11 @@ const mergeChildNodes = ( }); }; -function initializeDomElement< - Props extends Record>, ->(component: string, props: Props, cleanup$: Observable): HTMLElement { +function initializeDomElement>>( + component: string, + props: Props, + cleanup$: Observable, +): HTMLElement { const element = document.createElement(component); for (const [key, obs$] of Object.entries(props)) { obs$.pipe(takeUntil(cleanup$)).subscribe((value) => { @@ -93,12 +90,10 @@ function initializeDomElement< return element; } -function initializePipeElement< - Props extends Record>, ->( +function initializePipeElement>>( component: Component, props: Props, - cleanup$: Observable + cleanup$: Observable, ): HTMLElement { const { element, cleanup$: childCleanup$ } = component(props, cleanup$); cleanup$.subscribe(childCleanup$); diff --git a/packages/pipe/src/index.spec.ts b/packages/pipe/src/index.spec.ts index e4ae0d4..392144f 100644 --- a/packages/pipe/src/index.spec.ts +++ b/packages/pipe/src/index.spec.ts @@ -1,14 +1,6 @@ import { Component, createElement } from './createElement'; import { createRoot } from './createRoot'; -import { - Observable, - Subject, - BehaviorSubject, - scan, - map, - tap, - distinctUntilChanged, -} from 'rxjs'; +import { Observable, Subject, BehaviorSubject, scan, map, tap, distinctUntilChanged } from 'rxjs'; describe('Pipe', () => { it('should render an HTML element in a component', () => { @@ -19,7 +11,7 @@ describe('Pipe', () => { }); const textContent = click$.pipe( scan((x) => x + 1, 0), - map((x) => String(x)) + map((x) => String(x)), ); return createElement('button', { @@ -56,7 +48,7 @@ describe('Pipe', () => { }); const textContent = click$.pipe( scan((x) => x + 1, 0), - map((x) => String(x)) + map((x) => String(x)), ); return createElement('button', { @@ -65,9 +57,7 @@ describe('Pipe', () => { }); }; - const CounterWrapper: Component< - Record> - > = () => { + const CounterWrapper: Component>> = () => { return createElement(Counter, {}); }; const container = document.createElement('div'); @@ -98,7 +88,7 @@ describe('Pipe', () => { }> = ({ count$ }) => { const textContent = count$.pipe( map((x) => String(x)), - tap((value) => spy(value)) + tap((value) => spy(value)), ); return createElement('p', { @@ -123,7 +113,7 @@ describe('Pipe', () => { render( createElement(TextWrapper, { count$, - }) + }), ); const div = container.firstChild as HTMLDivElement; @@ -172,8 +162,8 @@ describe('Pipe', () => { createElement('p', { textContent: new BehaviorSubject(value), }), - ]) - ) + ]), + ), ); }; const container = document.createElement('div'); @@ -185,7 +175,7 @@ describe('Pipe', () => { render( createElement(TextWrapper, { addChild$, - }) + }), ); const div = container.firstChild as HTMLDivElement; @@ -231,7 +221,7 @@ describe('Pipe', () => { }> = ({ count$ }) => { const textContent = count$.pipe( map((x) => String(x)), - tap((value) => spy(value)) + tap((value) => spy(value)), ); return createElement('p', { @@ -253,7 +243,7 @@ describe('Pipe', () => { render( createElement(TextWrapper, { count$, - }) + }), ); const textElement = container.firstChild as HTMLParagraphElement; @@ -295,19 +285,17 @@ describe('Boolean Operator', () => { 'default', value ? createElement('p', { - textContent: new BehaviorSubject('true'), - }) + textContent: new BehaviorSubject('true'), + }) : createElement('p', { - textContent: new BehaviorSubject('false'), - }), - ]) - ) + textContent: new BehaviorSubject('false'), + }), + ]), + ), ); }; - const CountListenerComponent: Component<{ count$: Observable }> = ({ - count$, - }) => { + const CountListenerComponent: Component<{ count$: Observable }> = ({ count$ }) => { return createElement('p', { textContent: count$, }); @@ -327,10 +315,10 @@ describe('Boolean Operator', () => { value ? createElement(CountListenerComponent, { count$ }) : createElement(CountListenerComponent, { - count$: count$.pipe(map((count) => count * 2)), - }), - ]) - ) + count$: count$.pipe(map((count) => count * 2)), + }), + ]), + ), ); }; From 0394812c572cc50117c9dc5fcd14cac9d1ad021a Mon Sep 17 00:00:00 2001 From: Henry Allen Date: Sun, 21 Jul 2024 14:13:01 -0400 Subject: [PATCH 11/12] Add test --- packages/pipe/src/index.spec.ts | 65 +++++++++++++++++++++++++++++++++ 1 file changed, 65 insertions(+) diff --git a/packages/pipe/src/index.spec.ts b/packages/pipe/src/index.spec.ts index 392144f..8117f07 100644 --- a/packages/pipe/src/index.spec.ts +++ b/packages/pipe/src/index.spec.ts @@ -214,6 +214,71 @@ describe('Pipe', () => { expect(container.firstChild).toBeFalsy(); }); + it('should pop items from the front of an observable list', () => { + const TextWrapper: Component<{ + addChild$: Observable<[string, string]>; + }> = ({ addChild$ }) => { + return createElement( + 'div', + {}, + addChild$.pipe( + map(([key, value]) => [ + key, + createElement('p', { + textContent: new BehaviorSubject(value), + }), + ]), + ), + ); + }; + const container = document.createElement('div'); + + const { render, unmount } = createRoot(container); + + const addChild$ = new Subject<[string, string]>(); + + render( + createElement(TextWrapper, { + addChild$, + }), + ); + + const div = container.firstChild as HTMLDivElement; + expect(div).toBeInstanceOf(HTMLDivElement); + + expect(div.textContent).toEqual(''); + + addChild$.next(['a', 'foo']); + + expect(div.textContent).toEqual('foo'); + const childA = div.firstChild as HTMLParagraphElement; + expect(childA.textContent).toEqual('foo'); + + addChild$.next(['b', 'bar']); + + expect(div.textContent).toEqual('foobar'); + const childB = childA.nextSibling as HTMLParagraphElement; + expect(childB.textContent).toEqual('bar'); + + addChild$.next(['b', 'baz']); + + expect(div.textContent).toEqual('foobaz'); + const childBVersion2 = div.firstChild.nextSibling as HTMLParagraphElement; + expect(childBVersion2.textContent).toEqual('baz'); + + expect(childBVersion2.previousSibling).toEqual(childA); + + addChild$.next(['a', null]); + expect(div.textContent).toEqual('baz'); + + addChild$.next(['b', null]); + expect(div.textContent).toEqual(''); + + unmount(); + + expect(container.firstChild).toBeFalsy(); + }); + it('should clean up observables on unmount', () => { const spy = jest.fn(); const Text: Component<{ From 226b7334421610f618a7d8c83ed8e0d9b2dab3da Mon Sep 17 00:00:00 2001 From: Henry Allen Date: Sun, 21 Jul 2024 14:16:16 -0400 Subject: [PATCH 12/12] Test coverage --- packages/pipe/src/index.spec.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/pipe/src/index.spec.ts b/packages/pipe/src/index.spec.ts index 8117f07..6e77d7f 100644 --- a/packages/pipe/src/index.spec.ts +++ b/packages/pipe/src/index.spec.ts @@ -224,9 +224,11 @@ describe('Pipe', () => { addChild$.pipe( map(([key, value]) => [ key, - createElement('p', { - textContent: new BehaviorSubject(value), - }), + value + ? createElement('p', { + textContent: new BehaviorSubject(value), + }) + : null, ]), ), );