diff --git a/packages/core/src/common/logger.ts b/packages/core/src/common/logger.ts index 808d6f8..6eb9932 100644 --- a/packages/core/src/common/logger.ts +++ b/packages/core/src/common/logger.ts @@ -30,3 +30,24 @@ class Logger { } export const logger = new Logger(); + +export const logMethod = + (prefix: string): MethodDecorator => + ( + target: object, + methodName: string | symbol, + descriptor: PropertyDescriptor + ) => { + const targetMethod = descriptor.value; + + descriptor.value = function (...args: never[]) { + console.group(`${prefix}.${methodName.toString()}`); + + const res = targetMethod.apply(this, args); + + console.groupEnd(); + return res; + }; + + return descriptor; + }; diff --git a/packages/core/src/component/Show.ts b/packages/core/src/component/Show.ts index b47a43c..9aa3cbb 100644 --- a/packages/core/src/component/Show.ts +++ b/packages/core/src/component/Show.ts @@ -10,6 +10,7 @@ import { renderChildren, } from '../render-utils/render-children'; import { logger } from '../common'; +import { logMethod } from '../common/logger'; function buildPlaceholderComment() { const commentText = `placeholder--${crypto.randomUUID()}`; @@ -26,10 +27,14 @@ class ConditionallyRenderedComponent implements VComponent { ) {} readonly __type = 'V_COMPONENT'; + + private readonly __subtype = 'Show'; + private _html!: MaybeArray; get html() { return this._html; } + renderOnce() { if (toValue(this.condition)) { this.fallback?.destroy?.(); @@ -37,15 +42,20 @@ class ConditionallyRenderedComponent implements VComponent { return this._html; } - destroyChildren(this.children); + try { + destroyChildren(this.children); + } catch (err) { + console.error(err); + } if (!this.fallback) { this._html = buildPlaceholderComment(); + return this._html; } - this._html = this.fallback.renderOnce(); + return this._html; } @@ -66,11 +76,14 @@ class ConditionallyRenderedComponent implements VComponent { init(parent: WithHtml) { this.parent = parent; this._initChild(parent); + if (!isReactive(this.condition)) return; const sub = this.condition.valueChanges$.subscribe(() => { const oldNodes = this._html; + this._initChild(parent); + const newNodes = this.renderOnce(); removeOldNodesAndRenderNewNodes({ @@ -78,8 +91,6 @@ class ConditionallyRenderedComponent implements VComponent { newNodes, parent, }); - - this._initChild(parent); }); this.subs.add(sub); @@ -109,4 +120,3 @@ export const Show = ({ }) => { return new ConditionallyRenderedComponent(when, children, fallback); }; - diff --git a/packages/core/src/component/for-loop.ts b/packages/core/src/component/for-loop.ts index c940293..f68cb28 100644 --- a/packages/core/src/component/for-loop.ts +++ b/packages/core/src/component/for-loop.ts @@ -17,6 +17,8 @@ class ForLoopComponent implements VComponent { ) {} readonly __type = 'V_COMPONENT'; + readonly __subtype = 'For'; + private _html!: HTML[]; get html() { return this._html; diff --git a/packages/core/src/component/render-new-nodes.ts b/packages/core/src/component/render-new-nodes.ts index d43a813..59d2519 100644 --- a/packages/core/src/component/render-new-nodes.ts +++ b/packages/core/src/component/render-new-nodes.ts @@ -43,6 +43,11 @@ function safelyInsertNode( // eslint-disable-next-line @typescript-eslint/no-explicit-any newNode: any ) { + if (!newNode) + throw new Error( + '[TINAF] Error while adding new node. newNode is undefined', + { cause: { parent } } + ); try { parent.html.insertBefore( newNode, diff --git a/packages/core/src/component/switch.ts b/packages/core/src/component/switch.ts index e635618..342a6a1 100644 --- a/packages/core/src/component/switch.ts +++ b/packages/core/src/component/switch.ts @@ -3,6 +3,13 @@ import type { VComponent, WithHtml } from './component'; import { Subscription, distinctUntilChanged, skip, startWith } from 'rxjs'; import { removeOldNodesAndRenderNewNodes } from './render-new-nodes'; import type { AddClassesArgs } from '../dom/create-dom-element'; +import { logMethod } from '../common/logger'; + +function buildPlaceholderComment() { + const commentText = `placeholder--${crypto.randomUUID()}`; + const comment = document.createComment(commentText); + return comment; +} class SwitchComponent implements VComponent { constructor( @@ -22,8 +29,14 @@ class SwitchComponent implements VComponent { } private _currentComponent: VComponent | null = null; + + @logMethod('') renderOnce() { - const newHtml = this._currentComponent?.renderOnce?.() || []; + const newHtml = + this._currentComponent?.renderOnce?.() || + [ + // buildPlaceholderComment(), + ]; this._html = newHtml; return this._html; } diff --git a/packages/core/src/component/v-component.ts b/packages/core/src/component/v-component.ts index f0fb2fc..7b400b5 100644 --- a/packages/core/src/component/v-component.ts +++ b/packages/core/src/component/v-component.ts @@ -1,4 +1,5 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ +import { logMethod } from '../common/logger'; import { mergeClasses } from '../dom/classes'; import type { AddClassesArgs, @@ -77,6 +78,8 @@ export class SimpleVComponent private get children() { return toArray(this.child); } + + @logMethod('VComponent') renderOnce(): MaybeArray { const newHtml = this.children.map((child) => { if (isVComponent(child)) { diff --git a/packages/core/src/dom/create-dom-element.ts b/packages/core/src/dom/create-dom-element.ts index adefa05..1f5eee8 100644 --- a/packages/core/src/dom/create-dom-element.ts +++ b/packages/core/src/dom/create-dom-element.ts @@ -151,7 +151,7 @@ export class VDomComponent implements VComponent { renderOnce(): HTMLElementTagNameMap[T] { this._html = this._doc.createElement(this.type); - this.html.setAttribute('x-id', crypto.randomUUID()); + // this.html.setAttribute('x-id', crypto.randomUUID()); this.children?.forEach((child) => { if (isVComponent(child)) { // NOTE: this function breaks state when rerendering a div parent with children with inner state !! diff --git a/packages/core/src/dom/input.ts b/packages/core/src/dom/input.ts index 4c889d1..2f5bde4 100644 --- a/packages/core/src/dom/input.ts +++ b/packages/core/src/dom/input.ts @@ -109,7 +109,7 @@ export class VInputComponent implements VComponent { private _renderOnce(): HTMLInputElement { const input = document.createElement('input'); - input.setAttribute('x-id', crypto.randomUUID()); + // input.setAttribute('x-id', crypto.randomUUID()); objectKeys(this.options).forEach((key) => { const value = this.options[key]; diff --git a/packages/core/src/http/use-query.spec.tsx b/packages/core/src/http/use-query.spec.tsx index cdf681d..e27de2f 100644 --- a/packages/core/src/http/use-query.spec.tsx +++ b/packages/core/src/http/use-query.spec.tsx @@ -38,6 +38,7 @@ describe('useQuery', () => { }, ); + // @ts-expect-error _data data = _data; error = _error; diff --git a/packages/core/src/http/use-query.ts b/packages/core/src/http/use-query.ts index db0648f..f297d98 100644 --- a/packages/core/src/http/use-query.ts +++ b/packages/core/src/http/use-query.ts @@ -9,33 +9,47 @@ async function toPromise(value: T | Promise) { return value; } -type QueryState = { - data: Reactive; +type QueryState = { + data: Reactive; error: Reactive; isPending: Reactive; isFetching: boolean; }; + +type QueryClientPrepareResponse< + T, + InitialValue extends T | undefined +> = undefined extends InitialValue + ? QueryState + : QueryState; export class QueryClient { // eslint-disable-next-line @typescript-eslint/no-explicit-any private keyToState = new Map>(); private _id = crypto.randomUUID(); - prepare(queryKey: string, initialValue?: T) { - const state = this.keyToState.get(queryKey) as QueryState | undefined; + prepare( + queryKey: string, + initialValue?: InitialValue + ): QueryClientPrepareResponse { + const state = this.keyToState.get(queryKey) as + | QueryState + | undefined; - if (state) return state; + if (state) return state as QueryClientPrepareResponse; - const newState: QueryState = { - data: reactive(initialValue), + const newState: QueryState = { + // @ts-expect-error initialValue + data: reactive(initialValue), error: reactive(undefined), isPending: reactive(false), isFetching: false, }; + // @ts-expect-error newState this.keyToState.set(queryKey, newState); - return newState; + return newState as QueryClientPrepareResponse; } getData(queryFn: QueryFn, queryKey: string) { const state = this.keyToState.get(queryKey); @@ -75,7 +89,7 @@ function injectQueryClient() { return queryClient; } -export function useQuery( +export function useQuery( { queryFn, queryKey, @@ -83,13 +97,13 @@ export function useQuery( }: { queryFn: QueryFn; queryKey: string; - initialValue?: T; + initialValue?: InitialValue; }, injections: { queryClient?: QueryClient } = {} ) { const queryClient = injections?.queryClient || injectQueryClient(); - const { isPending, data, error } = queryClient.prepare( + const { isPending, data, error } = queryClient.prepare( queryKey, initialValue ); diff --git a/packages/core/src/reactive/boolean.ts b/packages/core/src/reactive/boolean.ts index 19b1efb..d75c071 100644 --- a/packages/core/src/reactive/boolean.ts +++ b/packages/core/src/reactive/boolean.ts @@ -20,7 +20,7 @@ export function bool(initialValue: boolean): [Reactive, () => void] { return [rx, toggle]; } -export function not(condition: MaybeReactive) { +export function not(condition: MaybeReactive | undefined) { if (isReactive(condition)) { return computed(() => !condition.value); } diff --git a/packages/core/src/utils/array.ts b/packages/core/src/utils/array.ts index 96d55cb..12a1394 100644 --- a/packages/core/src/utils/array.ts +++ b/packages/core/src/utils/array.ts @@ -1,6 +1,8 @@ export type MaybeArray = T | T[]; export function toArray(maybeArr: T | T[]): T[] { + if (maybeArr === undefined) return []; + return Array.isArray(maybeArr) ? maybeArr : [maybeArr]; } diff --git a/packages/core/tsconfig.json b/packages/core/tsconfig.json index 04be57a..5ed02c3 100644 --- a/packages/core/tsconfig.json +++ b/packages/core/tsconfig.json @@ -1,6 +1,6 @@ { - "extends": ["../../tsconfig.json"], - "compilerOptions": { + "extends": ["../../tsconfig.json"], + "compilerOptions": { "baseUrl": ".", "declaration": true, "declarationDir": "./dist/types", @@ -9,12 +9,11 @@ "jsx": "preserve", "jsxImportSource": "~", "types": ["node"], - - "paths": { - "~/*": ["src/*"] - } + "~/*": ["src/*"] }, - "include": ["src"], - "exclude": ["src/**/*.test.ts", "src/**/*.spec.ts", "dist", "node_modules"] -} \ No newline at end of file + "experimentalDecorators": true + }, + "include": ["src"], + "exclude": ["src/**/*.test.ts", "src/**/*.spec.ts", "dist", "node_modules"] +} diff --git a/packages/demo-todo-app/main.ts b/packages/demo-todo-app/main.ts index 3fa8aa7..1482a9d 100644 --- a/packages/demo-todo-app/main.ts +++ b/packages/demo-todo-app/main.ts @@ -4,10 +4,12 @@ import { createRouter, ROUTER_PROVIDER_KEY } from 'tinaf/router'; import { ProductListPage } from './src/pages/ProductList.page'; import { ProductPage } from './src/pages/Product.page'; import { DashboardRoutes } from './src/dashboard/routes'; +import { createQueryClientProvider } from 'tinaf/http'; const app = createApp(App); -ProductPage({}); +const queryClientProvider = createQueryClientProvider(); +app.use(queryClientProvider); const router = createRouter([ { diff --git a/packages/demo-todo-app/src/Header/SearchBar.tsx b/packages/demo-todo-app/src/Header/SearchBar.tsx index b9aaf52..ad2ab82 100644 --- a/packages/demo-todo-app/src/Header/SearchBar.tsx +++ b/packages/demo-todo-app/src/Header/SearchBar.tsx @@ -1,8 +1,8 @@ import type { Product } from 'src/models/product'; import { component, onDestroy } from 'tinaf/component'; import { inputReactive } from 'tinaf/reactive'; -import { PRODUCTS } from '../data/products.mock'; import { debounceTime, distinctUntilChanged, map } from 'rxjs'; +import { PRODUCTS } from '../api/products'; export const SearchBar = component<{ updateProducts: (products: Product[]) => void; @@ -31,7 +31,7 @@ export const SearchBar = component<{ }); return
- +
diff --git a/packages/demo-todo-app/src/ProductList/ProductList.tsx b/packages/demo-todo-app/src/ProductList/ProductList.tsx index c258cf0..cce291d 100644 --- a/packages/demo-todo-app/src/ProductList/ProductList.tsx +++ b/packages/demo-todo-app/src/ProductList/ProductList.tsx @@ -1,11 +1,14 @@ import type { Product } from 'src/models/product'; -import { component, For } from 'tinaf/component'; +import { component, For, Show } from 'tinaf/component'; import { computed, + reactive, toReactiveProps, toValue, + not, } from 'tinaf/reactive'; import { injectRouter } from 'tinaf/router'; +import { Skeleton } from '../ui/Skeleton'; // #F3F3F5 const ProductCard = component<{ product: Product; onClick: () => void }>( @@ -14,45 +17,57 @@ const ProductCard = component<{ product: Product; onClick: () => void }>( const priceText = computed(() => `${toValue(price)} EUR`); - return
  • - - - -
  • - - }) - - -export const ProductList = component<{ products: Product[] }>( - ({ products }) => { - const router = injectRouter(); - - const goToProductPage = (p: Product) => router.navigate(`/product/${p.id}`); - - return
    -
      - - p.id}> - {(product: Product) => ProductCard({ product, onClick: () => goToProductPage(product) }) } - -
    - - - - + return ( +
  • + +
  • + ); + } +); + +const ProductListSkeleton = component(() => { + const fakeCardsCount = 20; + + const fakeCards = Array.from({ length: fakeCardsCount }).fill(''); + + return ( + {() => } + ); +}); + +export const ProductList = component<{ + products: Product[]; + pending?: boolean; +}>(({ products, pending }) => { + const router = injectRouter(); + + const goToProductPage = (p: Product) => router.navigate(`/product/${p.id}`); + + + return ( +
    +
      + }> + p.id}> + {(product: Product) => + ProductCard({ product, onClick: () => goToProductPage(product) }) + } + + + +
    - - }) - - + ); +}); diff --git a/packages/demo-todo-app/src/api/products.mock.ts b/packages/demo-todo-app/src/api/products.mock.ts new file mode 100644 index 0000000..e69de29 diff --git a/packages/demo-todo-app/src/data/products.mock.ts b/packages/demo-todo-app/src/api/products.ts similarity index 97% rename from packages/demo-todo-app/src/data/products.mock.ts rename to packages/demo-todo-app/src/api/products.ts index 282bbee..29cc8b2 100644 --- a/packages/demo-todo-app/src/data/products.mock.ts +++ b/packages/demo-todo-app/src/api/products.ts @@ -269,8 +269,20 @@ export const PRODUCTS: Product[] = [ }, ] as const; +const randomInt = ({ min, max }: { min: number; max: number }) => + min + Math.floor(Math.random() * (max - min)); +const wait = () => + new Promise((resolve) => { + const ms = randomInt({ min: 200, max: 3000 }); + setTimeout(() => resolve(), ms); + }); export function getProduct(productId: string) { const p = PRODUCTS.find((p) => p.id === Number.parseInt(productId)); if (!p) throw new Error(`product ${productId} not found`); return p; } + +export async function getAllProducts() { + await wait(); + return PRODUCTS; +} diff --git a/packages/demo-todo-app/src/examples/ShowExample.tsx b/packages/demo-todo-app/src/examples/ShowExample.tsx index 308d26b..2316d07 100644 --- a/packages/demo-todo-app/src/examples/ShowExample.tsx +++ b/packages/demo-todo-app/src/examples/ShowExample.tsx @@ -6,7 +6,7 @@ import { bool } from "tinaf/reactive"; const Card = component<{ title : string, subtitle : string }>((props) => { - useInterval(() => console.log('hello'), 1000) + // useInterval(() => console.log('hello'), 1000) return
    {props.title}
    {props.subtitle}
    diff --git a/packages/demo-todo-app/src/pages/Product.page.tsx b/packages/demo-todo-app/src/pages/Product.page.tsx index 578d004..33c9596 100644 --- a/packages/demo-todo-app/src/pages/Product.page.tsx +++ b/packages/demo-todo-app/src/pages/Product.page.tsx @@ -1,8 +1,8 @@ -import { getProduct } from '../data/products.mock'; import { component, type VComponent } from 'tinaf/component'; import { div, img, span } from 'tinaf/dom'; import { computed, toReactiveProps } from 'tinaf/reactive'; import { injectRouter, type PageComponent} from 'tinaf/router'; +import { getProduct } from '../api/products'; export const ProductPage: PageComponent = component(() => { const router = injectRouter(); diff --git a/packages/demo-todo-app/src/pages/ProductList.page.ts b/packages/demo-todo-app/src/pages/ProductList.page.ts deleted file mode 100644 index 30a73f8..0000000 --- a/packages/demo-todo-app/src/pages/ProductList.page.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { div } from 'tinaf/dom'; -import { SearchBar } from '../Header/SearchBar'; -import { PRODUCTS } from '../data/products.mock'; -import { ProductList } from '../ProductList/ProductList'; -import type { Product } from '../models/product'; -import { component } from 'tinaf/component'; -import { reactiveList } from 'tinaf/reactive'; - -export const ProductListPage = component(() => { - const products = reactiveList(PRODUCTS); - - return div( - SearchBar({ - updateProducts: (ps) => products.update(ps), - }), - ProductList({ products }) - ); -}); diff --git a/packages/demo-todo-app/src/pages/ProductList.page.tsx b/packages/demo-todo-app/src/pages/ProductList.page.tsx new file mode 100644 index 0000000..1ab1861 --- /dev/null +++ b/packages/demo-todo-app/src/pages/ProductList.page.tsx @@ -0,0 +1,21 @@ +import { div } from 'tinaf/dom'; +import { SearchBar } from '../Header/SearchBar'; +import { ProductList } from '../ProductList/ProductList'; +import { component } from 'tinaf/component'; +import { useQuery } from '../../../core/src/http'; +import { getAllProducts } from '../api/products'; +import type { Product } from '../models/product'; + +export const ProductListPage = component(() => { + const { data: products, isPending } = useQuery({ + queryKey: 'products', + queryFn: getAllProducts, + initialValue: [] as Product[], + }); + + return
    + products.update(ps)} /> + + +
    +}); diff --git a/packages/demo-todo-app/src/ui/Skeleton.tsx b/packages/demo-todo-app/src/ui/Skeleton.tsx new file mode 100644 index 0000000..06d4bdc --- /dev/null +++ b/packages/demo-todo-app/src/ui/Skeleton.tsx @@ -0,0 +1,5 @@ +import { component } from "tinaf/component"; + +export const Skeleton = component(() => { + return
    +}) \ No newline at end of file