From 8e5938fbe519f351360fe252db7a4638772d4d64 Mon Sep 17 00:00:00 2001 From: andylovescode Date: Wed, 27 Aug 2025 21:23:57 -0700 Subject: [PATCH] Core+DOM: Improve `use` attrs --- .changeset/kind-news-sell.md | 7 + packages/example/src/client.tsx | 2 +- packages/vortex-core/src/jsx/jsx-common.ts | 301 ++++++------ packages/vortex-core/src/render/reconciler.ts | 457 +++++++++--------- 4 files changed, 390 insertions(+), 377 deletions(-) create mode 100644 .changeset/kind-news-sell.md diff --git a/.changeset/kind-news-sell.md b/.changeset/kind-news-sell.md new file mode 100644 index 0000000..69ba429 --- /dev/null +++ b/.changeset/kind-news-sell.md @@ -0,0 +1,7 @@ +--- +"@vortexjs/core": major +"@vortexjs/bun-example": major +"@vortexjs/dom": major +--- + +Improve `use` refs diff --git a/packages/example/src/client.tsx b/packages/example/src/client.tsx index 74b4b1a..41dd822 100644 --- a/packages/example/src/client.tsx +++ b/packages/example/src/client.tsx @@ -67,7 +67,7 @@ function App() { on:click={() => { counter.set(getImmediateValue(counter) + 100); }} - use={(element) => console.log("button element: ", element)} + use={({ ref: element }) => console.log("button element: ", element)} type="button" > Increment diff --git a/packages/vortex-core/src/jsx/jsx-common.ts b/packages/vortex-core/src/jsx/jsx-common.ts index 8583cda..a540a24 100644 --- a/packages/vortex-core/src/jsx/jsx-common.ts +++ b/packages/vortex-core/src/jsx/jsx-common.ts @@ -1,86 +1,91 @@ import { getUltraglobalReference } from "@vortexjs/common"; import { - isSignal, - type Signal, - type Store, - toSignal, - useDerived, + isSignal, + type Signal, + type Store, + toSignal, + useDerived, } from "../signal"; +import type { Lifetime } from "~/lifetime"; export type JSXNode = - | JSXElement - | JSXComponent - | JSXFragment - | JSXText - | JSXDynamic - | JSXList - | JSXContext - | undefined; + | JSXElement + | JSXComponent + | JSXFragment + | JSXText + | JSXDynamic + | JSXList + | JSXContext + | undefined; export interface JSXContext { - type: "context"; - id: string; - value: Signal; - children: JSXNode; + type: "context"; + id: string; + value: Signal; + children: JSXNode; } export interface JSXList { - type: "list"; - getKey(item: T, index: number): string; - renderItem(item: T, idx: number): JSXNode; - items: Signal; - key(cb: (item: T, idx: number) => string | number): JSXList; - show(cb: (item: T, idx: number) => JSXNode): JSXList; + type: "list"; + getKey(item: T, index: number): string; + renderItem(item: T, idx: number): JSXNode; + items: Signal; + key(cb: (item: T, idx: number) => string | number): JSXList; + show(cb: (item: T, idx: number) => JSXNode): JSXList; } export interface JSXSource { - fileName?: string; - lineNumber?: number; - columnNumber?: number; + fileName?: string; + lineNumber?: number; + columnNumber?: number; } export interface JSXElement extends JSXSource { - type: "element"; - name: string; - attributes: Record>; - bindings: Record>; - eventHandlers: Record void>; - use: Use; - children: JSXNode[]; - styles: Record>; + type: "element"; + name: string; + attributes: Record>; + bindings: Record>; + eventHandlers: Record void>; + use: Use; + children: JSXNode[]; + styles: Record>; } -export type Use = ((ref: T) => void) | Use[]; +export interface UseProps { + ref: T; + lt: Lifetime; +} +export type Use = ((props: UseProps) => void) | Use[]; export interface JSXComponent extends JSXSource { - type: "component"; - impl: (props: Props) => JSXNode; - props: Props; + type: "component"; + impl: (props: Props) => JSXNode; + props: Props; } export interface JSXFragment extends JSXSource { - type: "fragment"; - children: JSXNode[]; + type: "fragment"; + children: JSXNode[]; } export interface JSXText extends JSXSource { - type: "text"; - value: string; + type: "text"; + value: string; } export interface JSXDynamic extends JSXSource { - type: "dynamic"; - value: Signal; + type: "dynamic"; + value: Signal; } export interface JSXRuntimeProps { - children?: JSXChildren; - [key: string]: any; + children?: JSXChildren; + [key: string]: any; } export const Fragment = getUltraglobalReference({ - name: "Fragment", - package: "@vortexjs/core" + name: "Fragment", + package: "@vortexjs/core" }, Symbol("Fragment")); export type JSXNonSignalChild = JSXNode | string | number | boolean | undefined; @@ -90,111 +95,111 @@ export type JSXChild = JSXNonSignalChild | Signal; export type JSXChildren = JSXChild | JSXChild[]; export function normalizeChildren(children: JSXChildren): JSXNode[] { - if (children === undefined) { - return []; - } - return [children] - .flat() - .filter((child) => child !== null && child !== undefined) - .map((x) => - typeof x === "string" || - typeof x === "number" || - typeof x === "boolean" - ? createTextNode(x) - : isSignal(x) - ? { - type: "dynamic", - value: useDerived((get) => { - const val = get(x); - return typeof val === "number" || - typeof val === "string" || - typeof val === "boolean" - ? createTextNode(val) - : val; - }), - } - : x, - ); + if (children === undefined) { + return []; + } + return [children] + .flat() + .filter((child) => child !== null && child !== undefined) + .map((x) => + typeof x === "string" || + typeof x === "number" || + typeof x === "boolean" + ? createTextNode(x) + : isSignal(x) + ? { + type: "dynamic", + value: useDerived((get) => { + const val = get(x); + return typeof val === "number" || + typeof val === "string" || + typeof val === "boolean" + ? createTextNode(val) + : val; + }), + } + : x, + ); } export function createTextNode(value: any, source?: JSXSource): JSXNode { - return { - type: "text", - value, - ...source, - }; + return { + type: "text", + value, + ...source, + }; } export function createElementInternal( - type: string, - props: Record, - children: JSXChildren, - source?: JSXSource, + type: string, + props: Record, + children: JSXChildren, + source?: JSXSource, ): JSXNode { - const normalizedChildren = normalizeChildren(children).map((child) => { - if ( - typeof child === "string" || - typeof child === "number" || - typeof child === "boolean" - ) { - return createTextNode(child); - } - return child; - }); - - const properAttributes: Record> = {}; - const bindings: Record> = {}; - const eventHandlers: Record void> = {}; - const use: Use = []; - const styles: Record> = {}; - - for (const [key, value] of Object.entries(props)) { - if (value !== undefined) { - if (key.startsWith("bind:")) { - const bindingKey = key.slice(5); - - if (!isSignal(value) || !("set" in value)) { - throw new Error( - `Binding value for "${bindingKey}" must be a writable store.`, - ); - } - - bindings[bindingKey] = value as Store; - } else if (key.startsWith("on:")) { - const eventKey = key.slice(3); - if (typeof value !== "function") { - throw new Error( - `Event handler for "${eventKey}" must be a function.`, - ); - } - eventHandlers[eventKey] = value; - } else if (key === "use") { - if (typeof value !== "function" && !Array.isArray(value)) { - throw new Error( - "Use hook must be a function or an array of functions.", - ); - } - use.push(value); - } else if (key === "style") { - for (const [styleKey, styleValue] of Object.entries(value)) { - if (styleValue !== undefined) { - styles[styleKey] = toSignal(styleValue); - } - } - } else { - properAttributes[key] = toSignal(value); - } - } - } - - return { - type: "element", - name: type, - attributes: properAttributes, - children: normalizedChildren, - bindings, - eventHandlers, - use, - styles, - }; + const normalizedChildren = normalizeChildren(children).map((child) => { + if ( + typeof child === "string" || + typeof child === "number" || + typeof child === "boolean" + ) { + return createTextNode(child); + } + return child; + }); + + const properAttributes: Record> = {}; + const bindings: Record> = {}; + const eventHandlers: Record void> = {}; + const use: Use = []; + const styles: Record> = {}; + + for (const [key, value] of Object.entries(props)) { + if (value !== undefined) { + if (key.startsWith("bind:")) { + const bindingKey = key.slice(5); + + if (!isSignal(value) || !("set" in value)) { + throw new Error( + `Binding value for "${bindingKey}" must be a writable store.`, + ); + } + + bindings[bindingKey] = value as Store; + } else if (key.startsWith("on:")) { + const eventKey = key.slice(3); + if (typeof value !== "function") { + throw new Error( + `Event handler for "${eventKey}" must be a function.`, + ); + } + eventHandlers[eventKey] = value; + } else if (key === "use") { + if (typeof value !== "function" && !Array.isArray(value)) { + throw new Error( + "Use hook must be a function or an array of functions.", + ); + } + use.push(value); + } else if (key === "style") { + for (const [styleKey, styleValue] of Object.entries(value)) { + if (styleValue !== undefined) { + styles[styleKey] = toSignal(styleValue); + } + } + } else { + properAttributes[key] = toSignal(value); + } + } + } + + return { + type: "element", + name: type, + attributes: properAttributes, + children: normalizedChildren, + bindings, + eventHandlers, + use, + styles, + }; } diff --git a/packages/vortex-core/src/render/reconciler.ts b/packages/vortex-core/src/render/reconciler.ts index 61d7624..a7fe562 100644 --- a/packages/vortex-core/src/render/reconciler.ts +++ b/packages/vortex-core/src/render/reconciler.ts @@ -1,238 +1,239 @@ import { trace, unreachable, unwrap } from "@vortexjs/common"; import type { Renderer } from "."; import { ContextScope } from "../context"; -import type { JSXNode } from "../jsx/jsx-common"; +import type { JSXNode, Use, UseProps } from "../jsx/jsx-common"; import { Lifetime } from "../lifetime"; import { FLElement, FLFragment, FLText, type FLNode } from "./fragments"; import { effect, store, type Store } from "../signal"; import { IntrinsicKey } from "../intrinsic"; export class Reconciler { - constructor( - private renderer: Renderer, - private root: RendererNode, - ) { } - - render({ node, hydration, lt, context }: { - node: JSXNode, - hydration: HydrationContext | undefined, - lt: Lifetime, - context: ContextScope, - }): FLNode { - if (node === undefined || node === null) { - return new FLFragment(); - } - - if (Array.isArray(node)) { - return this.render({ - node: { - type: "fragment", - children: node - }, hydration, lt, context - }); - } - - switch (node.type) { - case "fragment": { - const frag = new FLFragment(); - frag.children = node.children.map((child) => - this.render({ node: child, hydration, lt, context }), - ); - return frag; - } - case "text": { - return new FLText( - node.value.toString(), - this.renderer, - hydration, - context - ); - } - case "element": { - const element = new FLElement( - node.name, - this.renderer, - hydration, - ); - - const elmHydration = this.renderer.getHydrationContext( - unwrap(element.rendererNode), - ); - - element.children = node.children.map((child) => - this.render({ node: child, hydration: elmHydration, lt, context }), - ); - - for (const [name, value] of Object.entries(node.attributes)) { - value - .subscribe((next) => { - element.setAttribute(name, next); - }) - .cascadesFrom(lt); - } - - for (const [name, value] of Object.entries(node.bindings)) { - this.renderer - .bindValue(unwrap(element.rendererNode), name, value) - .cascadesFrom(lt); - } - - for (const [name, handler] of Object.entries( - node.eventHandlers, - )) { - this.renderer - .addEventListener( - unwrap(element.rendererNode), - name, - handler, - ) - .cascadesFrom(lt); - } - - for (const [name, value] of Object.entries(node.styles)) { - value - .subscribe((next) => { - this.renderer.setStyle( - unwrap(element.rendererNode), - name, - next, - ); - }) - .cascadesFrom(lt); - } - - const users = [node.use].flat() as (( - ref: RendererNode, - ) => void)[]; - - for (const user of users) { - user(unwrap(element.rendererNode)); - } - - return element; - } - case "component": { - using _hook = Lifetime.changeHookLifetime(lt); - using _context = ContextScope.setCurrent(context); - using _trace = trace(`Rendering ${node.impl.name}`); - - let comp = node.impl; - - if (IntrinsicKey in comp && typeof comp[IntrinsicKey] === "string") { - const intrinsicImpl = this.renderer.implementations?.find( - (impl) => impl.intrinsic === comp, - ); - - if (intrinsicImpl) { - comp = intrinsicImpl.implementation as any; - } - } - - const result = comp(node.props); - - return this.render({ node: result, hydration, lt, context }); - } - case "dynamic": { - const swapContainer = new FLFragment(); - - effect( - (get, { lifetime }) => { - const newRender = this.render({ - node: get(node.value), - hydration, - lt: lifetime, - context, - }); - - swapContainer.children = [newRender]; - }, - undefined, - lt, - ); - - return swapContainer; - } - case "list": { - type ListType = unknown; - - const swapContainer = new FLFragment(); - const renderMap: Map< - string, - { - node: FLNode; - item: Store; - lifetime: Lifetime; - } - > = new Map(); - - const container = new FLFragment(); - let lastKeyOrder = ""; - - effect((get) => { - const items = get(node.items); - const newKeys = items.map((item, idx) => - node.getKey(item, idx), - ); - - for (const key of renderMap.keys()) { - if (!newKeys.includes(key)) { - const entry = unwrap(renderMap.get(key)); - entry.lifetime.close(); - renderMap.delete(key); - } - } - - for (const key of newKeys) { - if (!renderMap.has(key)) { - const item = items[newKeys.indexOf(key)]; - const itemStore = store(item); - const itemLifetime = new Lifetime(); - using _hl = - Lifetime.changeHookLifetime(itemLifetime); - - const renderedItem = this.render({ - node: node.renderItem(item, newKeys.indexOf(key)), - hydration, - lt: itemLifetime, - context, - }); - - renderMap.set(key, { - node: renderedItem, - item: itemStore, - lifetime: itemLifetime, - }); - } - } - - const newKeyOrder = newKeys.join("|||"); - - if (newKeyOrder !== lastKeyOrder) { - lastKeyOrder = newKeyOrder; - container.children = newKeys.map( - (key) => unwrap(renderMap.get(key)).node, - ); - } - }); - - return container; - } - case "context": { - const forked = context.fork(); - using _newScope = ContextScope.setCurrent(forked); - - forked.addContext(node.id, node.value); - - return this.render({ - node: node.children, hydration, lt, context: forked - }); - } - default: { - unreachable( - node, - `No rendering implementation for ${JSON.stringify(node)}`, - ); - } - } - } + constructor( + private renderer: Renderer, + private root: RendererNode, + ) { } + + render({ node, hydration, lt, context }: { + node: JSXNode, + hydration: HydrationContext | undefined, + lt: Lifetime, + context: ContextScope, + }): FLNode { + if (node === undefined || node === null) { + return new FLFragment(); + } + + if (Array.isArray(node)) { + return this.render({ + node: { + type: "fragment", + children: node + }, hydration, lt, context + }); + } + + switch (node.type) { + case "fragment": { + const frag = new FLFragment(); + frag.children = node.children.map((child) => + this.render({ node: child, hydration, lt, context }), + ); + return frag; + } + case "text": { + return new FLText( + node.value.toString(), + this.renderer, + hydration, + context + ); + } + case "element": { + const element = new FLElement( + node.name, + this.renderer, + hydration, + ); + + const elmHydration = this.renderer.getHydrationContext( + unwrap(element.rendererNode), + ); + + element.children = node.children.map((child) => + this.render({ node: child, hydration: elmHydration, lt, context }), + ); + + for (const [name, value] of Object.entries(node.attributes)) { + value + .subscribe((next) => { + element.setAttribute(name, next); + }) + .cascadesFrom(lt); + } + + for (const [name, value] of Object.entries(node.bindings)) { + this.renderer + .bindValue(unwrap(element.rendererNode), name, value) + .cascadesFrom(lt); + } + + for (const [name, handler] of Object.entries( + node.eventHandlers, + )) { + this.renderer + .addEventListener( + unwrap(element.rendererNode), + name, + handler, + ) + .cascadesFrom(lt); + } + + for (const [name, value] of Object.entries(node.styles)) { + value + .subscribe((next) => { + this.renderer.setStyle( + unwrap(element.rendererNode), + name, + next, + ); + }) + .cascadesFrom(lt); + } + + const users = [node.use].flat() as (((props: UseProps) => void))[]; + + for (const user of users) { + user({ + ref: unwrap(element.rendererNode), + lt + }); + } + + return element; + } + case "component": { + using _hook = Lifetime.changeHookLifetime(lt); + using _context = ContextScope.setCurrent(context); + using _trace = trace(`Rendering ${node.impl.name}`); + + let comp = node.impl; + + if (IntrinsicKey in comp && typeof comp[IntrinsicKey] === "string") { + const intrinsicImpl = this.renderer.implementations?.find( + (impl) => impl.intrinsic === comp, + ); + + if (intrinsicImpl) { + comp = intrinsicImpl.implementation as any; + } + } + + const result = comp(node.props); + + return this.render({ node: result, hydration, lt, context }); + } + case "dynamic": { + const swapContainer = new FLFragment(); + + effect( + (get, { lifetime }) => { + const newRender = this.render({ + node: get(node.value), + hydration, + lt: lifetime, + context, + }); + + swapContainer.children = [newRender]; + }, + undefined, + lt, + ); + + return swapContainer; + } + case "list": { + type ListType = unknown; + + const swapContainer = new FLFragment(); + const renderMap: Map< + string, + { + node: FLNode; + item: Store; + lifetime: Lifetime; + } + > = new Map(); + + const container = new FLFragment(); + let lastKeyOrder = ""; + + effect((get) => { + const items = get(node.items); + const newKeys = items.map((item, idx) => + node.getKey(item, idx), + ); + + for (const key of renderMap.keys()) { + if (!newKeys.includes(key)) { + const entry = unwrap(renderMap.get(key)); + entry.lifetime.close(); + renderMap.delete(key); + } + } + + for (const key of newKeys) { + if (!renderMap.has(key)) { + const item = items[newKeys.indexOf(key)]; + const itemStore = store(item); + const itemLifetime = new Lifetime(); + using _hl = + Lifetime.changeHookLifetime(itemLifetime); + + const renderedItem = this.render({ + node: node.renderItem(item, newKeys.indexOf(key)), + hydration, + lt: itemLifetime, + context, + }); + + renderMap.set(key, { + node: renderedItem, + item: itemStore, + lifetime: itemLifetime, + }); + } + } + + const newKeyOrder = newKeys.join("|||"); + + if (newKeyOrder !== lastKeyOrder) { + lastKeyOrder = newKeyOrder; + container.children = newKeys.map( + (key) => unwrap(renderMap.get(key)).node, + ); + } + }); + + return container; + } + case "context": { + const forked = context.fork(); + using _newScope = ContextScope.setCurrent(forked); + + forked.addContext(node.id, node.value); + + return this.render({ + node: node.children, hydration, lt, context: forked + }); + } + default: { + unreachable( + node, + `No rendering implementation for ${JSON.stringify(node)}`, + ); + } + } + } }