UNPKG

rvx

Version:

A signal based rendering library

998 lines (935 loc) 26.2 kB
import { NODE, NodeTarget } from "./element-common.js"; import { ENV } from "./env.js"; import { createText } from "./internals/create-text.js"; import { NOOP } from "./internals/noop.js"; import { isolate } from "./isolate.js"; import { capture, teardown, TeardownHook } from "./lifecycle.js"; import { $, Expression, ExpressionResult, get, memo, Signal, watch } from "./signals.js"; import type { Component, Content, Falsy } from "./types.js"; /** * Internal utility to create placeholder comments. */ function createPlaceholder(env: typeof globalThis): Node { return env.document.createComment("g"); } /** * Internal utility to create an arbitrary parent node. */ function createParent(env: typeof globalThis): Node { return env.document.createDocumentFragment(); } /** * Internal shorthand for creating the boundary comment of an empty view. */ function empty(setBoundary: ViewSetBoundaryFn, env: typeof globalThis): void { const node = createPlaceholder(env); setBoundary(node, node); } /** * Internal shorthand for using the children of a node as boundary. */ function use(setBoundary: ViewSetBoundaryFn, node: Node, env: typeof globalThis): void { if (node.firstChild === null) { empty(setBoundary, env); } else { setBoundary(node.firstChild, node.lastChild!); } } /** * Render arbitrary content. * * Supported content types are: * + Null and undefined (not displayed). * + Arbitrarily nested arrays/fragments of content. * + DOM nodes. Children will be removed from document fragments. * + {@link View Views}. * + Anything created with rvx's jsx runtime. * + Anything else is displayed as text. * * @param content The content to render. * @returns A view instance or the content itself if it's already a view. * * @example * ```tsx * import { $, render } from "rvx"; * * // Not displayed: * render(null); * render(undefined); * * // Arbitrarily nested arrays/fragments of content: * render([["Hello"], " World!"]); * render(<>{<>"Hello"</>}{" World!"}</>); * * // DOM nodes: * render(<h1>Hello World!</h1>); * render(document.createElement("input")); * render(document.createTextNode("Hello World!")); * render(someTemplate.content.cloneNode(true)); * * // Views: * render(render("Hello World!")); * render(when(true, () => "Hello World!")); * render(<When value={true}>{() => "Hello World!"}</When>); * * // Text: * render("Hello World!"); * render(() => "Hello World!"); * render(42); * render($(42)); * ``` */ export function render(content: Content): View { if (content instanceof View) { return content; } return new View(setBoundary => { const env = ENV.current; if (Array.isArray(content)) { const flat = content.flat(Infinity) as Content[]; if (flat.length > 1) { const parent = createParent(env); for (let i = 0; i < flat.length; i++) { let part = flat[i]; if (part === null || part === undefined) { parent.appendChild(createPlaceholder(env)); } else if (typeof part === "object") { if (NODE in part) { part = (part as NodeTarget)[NODE]; } if (part instanceof env.Node) { if (part.nodeType === 11 && part.childNodes.length === 0) { parent.appendChild(createPlaceholder(env)); } else { parent.appendChild(part); } } else if (part instanceof View) { part.appendTo(parent); if (i === 0) { part.setBoundaryOwner((first, _last) => setBoundary(first, undefined)); } else if (i === flat.length - 1) { part.setBoundaryOwner((_first, last) => setBoundary(undefined, last)); } } else { parent.appendChild(createText(part, env)); } } else { parent.appendChild(createText(part, env)); } } use(setBoundary, parent, env); return; } content = flat[0]; } if (content === null || content === undefined) { empty(setBoundary, env); } else if (typeof content === "object") { if (NODE in content) { content = (content as NodeTarget)[NODE]; } if (content instanceof env.Node) { if (content.nodeType === 11) { use(setBoundary, content, env); } else { setBoundary(content, content); } } else if (content instanceof View) { setBoundary(content.first, content.last); content.setBoundaryOwner(setBoundary); } else { const text = createText(content, env); setBoundary(text, text); } } else { const text = createText(content, env); setBoundary(text, text); } }); } /** * Render arbitrary content and append it to the specified parent until the current lifecycle is disposed. * * @param parent The parent node. * @param content The content to render. See {@link render} for supported types. * @returns The view instance. * * @example * ```tsx * import { mount } from "rvx"; * * mount( * document.body, * <h1>Hello World!</h1> * ); * ``` * * Since the content is removed when the current lifecycle is disposed, this can also be used to temporarily append * content to different elements while some component is rendered: * ```tsx * import { mount, Content } from "rvx"; * * function Popover(props: { text: Content, children: Content }) { * const visible = $(false); * * mount( * document.body, * <Show when={visible}> * {props.children} * </Show> * ); * * return <button on:click={() => { visible.value = !visible.value; }}> * {props.text} * </button>; * } * * mount( * document.body, * <Popover text="Click me!"> * Hello World! * </Popover> * ); * ``` */ export function mount(parent: Node, content: Content): View { const view = render(content); view.appendTo(parent); teardown(() => view.detach()); return view; } /** * A function that is called when the view boundary may have been changed. */ export interface ViewBoundaryOwner { /** * @param first The current first node. * @param last The current last node. */ (first: Node, last: Node): void; } /** * A function that must be called after the view boundary has been changed. */ export interface ViewSetBoundaryFn { /** * @param first The first node if changed. * @param last The last node if changed. */ (first: Node | undefined, last: Node | undefined): void; } interface UninitViewProps { /** * The current first node of this view. * * + This may be undefined while the view is not fully initialized. * + This property is not reactive. */ get first(): Node | undefined; /** * The current last node of this view. * * + This may be undefined while the view is not fully initialized. * + This property is not reactive. */ get last(): Node | undefined; } export type UninitView = UninitViewProps & Omit<View, keyof UninitViewProps>; /** * A function that is called once to initialize a view instance. * * View creation will fail if no first or last node has been set during initialization. */ export interface ViewInitFn { /** * @param setBoundary A function that must be called after the view boundary has been changed. * @param self The current view itself. This can be used to keep track of the current boundary and parent nodes. */ (setBoundary: ViewSetBoundaryFn, self: UninitView): void; } /** * Represents a sequence of at least one DOM node. * * Consumers of the view API need to guarantee that: * + The sequence of nodes is not modified from the outside. * + If there are multiple nodes, all nodes must have a common parent node at all time. */ export class View { #first!: Node; #last!: Node; #owner: ViewBoundaryOwner | undefined; /** * Create a new view. * * View implementations need to guarantee that: * + The view doesn't break when the parent node is replaced or when a view consisting of only a single node is detached from it's parent. * + The boundary is updated immediately after the first or last node has been updated. * + If there are multiple nodes, all nodes remain in the current parent. * + If there are multiple nodes, the initial nodes must have a common parent. */ constructor(init: ViewInitFn) { init((first, last) => { if (first) { this.#first = first; } if (last) { this.#last = last; } this.#owner?.(this.#first, this.#last); }, this); if (!this.#first || !this.#last) { // View boundary was not completely initialized: throw new Error("G1"); } } /** * The current first node of this view. * * Note, that this property is not reactive. */ get first(): Node { return this.#first; } /** * The current last node of this view. * * Note, that this property is not reactive. */ get last(): Node { return this.#last; } /** * The current parent node or undefined if there is none. * * Note, that this property is not reactive. */ get parent(): Node | undefined { return this.#first?.parentNode ?? undefined; } /** * Set the boundary owner for this view until the current lifecycle is disposed. * * @throws An error if there currently is a boundary owner. */ setBoundaryOwner(owner: ViewBoundaryOwner): void { if (this.#owner !== undefined) { // View already has a boundary owner: throw new Error("G2"); } this.#owner = owner; teardown(() => this.#owner = undefined); } /** * Append all nodes of this view to the specified parent. * * @param parent The parent to append to. */ appendTo(parent: Node): void { let node = this.#first; const last = this.#last; for (;;) { const next = node.nextSibling; parent.appendChild(node); if (node === last) { break; } node = next!; } } /** * Insert all nodes of this view before a reference child of the specified parent. * * @param parent The parent to insert into. * @param ref The reference child to insert before. If this is null, the nodes are appended to the parent. */ insertBefore(parent: Node, ref: Node | null): void { if (ref === null) { return this.appendTo(parent); } let node = this.#first; const last = this.#last; for (;;) { const next = node.nextSibling; parent.insertBefore(node, ref); if (node === last) { break; } node = next!; } } /** * Detach all nodes of this view from the current parent if there is one. * * If there are multiple nodes, they are moved into a new document fragment to allow the view implementation to stay alive. * * @returns The single removed node or the document fragment they have been moved into. */ detach(): Node | DocumentFragment { const first = this.#first; const last = this.#last; if (first === last) { first.parentNode?.removeChild(first); return first; } else { const fragment = ENV.current.document.createDocumentFragment(); this.appendTo(fragment); return fragment; } } } /** * Get an iterator over all current top level nodes of a view. * * @param view The view. * @returns The iterator. * * @example * ```tsx * import { render, viewNodes } from "rvx"; * * const view = render(<> * <h1>Hello World!</h1> * </>); * * for (const node of viewNodes(view)) { * console.log(node); * } * ``` */ export function * viewNodes(view: View): IterableIterator<Node> { let node = view.first; for (;;) { const next = node.nextSibling!; yield node; if (node === view.last) { break; } node = next; } } const _nestDefault = ((component: Component | null | undefined) => component?.()) as Component<unknown>; /** * Watch an expression and render content from it's result. * * + If an error is thrown during initialization, the error is re-thrown. * + If an error is thrown during a signal update, the previously rendered content is kept in place and the error is re-thrown. * + Content returned from the component can be directly reused within the same `nest` instance. * * See {@link Nest `<Nest>`} when using JSX. * * @param expr The expression to watch. * @param component The component to render with the expression result. If the expression returns a component, null or undefined, this can be omitted. * * @example * ```tsx * import { $, nest, e } from "rvx"; * * const count = $(0); * * nest(count, count => { * switch (count) { * case 0: return e("h1").append("Hello World!"); * case 1: return "Something else..."; * } * }) * ``` */ export function nest(expr: Expression<Component | null | undefined>): View; export function nest<T>(expr: Expression<T>, component: Component<T>): View; export function nest(expr: Expression<unknown>, component: Component<unknown> = _nestDefault): View { return new View((setBoundary, self) => { watch(expr, value => { const last: Node | undefined = self.last; const parent = last?.parentNode; let view: View; if (parent) { const anchor = last.nextSibling; self.detach(); view = render(component(value)); view.insertBefore(parent, anchor); } else { view = render(component(value)); } setBoundary(view.first, view.last); view.setBoundaryOwner(setBoundary); }); }); } /** * Watch an expression and render content from it's result. * * + If an error is thrown during initialization, the error is re-thrown. * + If an error is thrown during a signal update, the previously rendered content is kept in place and the error is re-thrown. * + Content returned from the component can be directly reused within the same `<Nest>` instance. * * See {@link nest} when not using JSX. * * @example * ```tsx * import { $, Nest } from "rvx"; * * const count = $(0); * * <Nest watch={count}> * {count => { * switch (count) { * case 0: return <h1>Hello World!</h1>; * case 1: return "Something else..."; * } * }} * </Nest> * ``` */ export function Nest<T>(props: { /** * The expression to watch. */ watch: T; /** * The component to render with the expression result. * * If the expression returns a component, null or undefined, this can be omitted. */ children: Component<ExpressionResult<T>>; } | { /** * The expression to watch. */ watch: Expression<Component | null | undefined>; }): View { return nest(props.watch, (props as any).children); } /** * Render conditional content. * * + Content is only re-rendered if the expression result is not strictly equal to the previous one. If this behavior is undesired, use {@link nest} instead. * + If an error is thrown by the expression or component during initialization, the error is re-thrown. * + If an error is thrown by the expression or component during a signal update, the previously rendered content is kept and the error is re-thrown. * * See {@link Show `<Show>`} when using JSX. * * @param condition The condition to watch. * @param truthy The component to render when the condition result is truthy. * @param falsy An optional component to render when the condition is falsy. * * @example * ```tsx * import { $, when, e } from "rvx"; * * const message = $<null | string>("Hello World!"); * * when(message, value => e("h1").append(value), () => "No message...") * ``` */ export function when<T>(condition: Expression<T | Falsy>, truthy: Component<T>, falsy?: Component): View { return nest(memo(condition), value => value ? truthy(value) : falsy?.()); } /** * Render conditional content. * * + Content is only re-rendered if the expression result is not strictly equal to the previous one. If this behavior is undesired, use {@link Nest} instead. * + If an error is thrown by the expression or component during initialization, the error is re-thrown. * + If an error is thrown by the expression or component during a signal update, the previously rendered content is kept and the error is re-thrown. * * See {@link when} when not using JSX. * * @example * ```tsx * import { $, Show } from "rvx"; * * const message = $<null | string>("Hello World!"); * * <Show when={message} else={() => <>No message...</>}> * {value => <h1>{value}</h1>} * </Show> * ``` */ export function Show<T>(props: { /** * The condition to watch. */ when: Expression<T | Falsy>; /** * The component to render when the condition result is truthy. */ children: Component<T>; /** * An optional component to render when the condition result is falsy. */ else?: Component; }): View { return when(props.when, props.children, props.else); } export interface ForContentFn<T> { /** * @param value The value. * @param index An expression to get the current index. * @returns The content. */ (value: T, index: () => number): Content; } /** * Render content for each unique value in an iterable. * * If an error is thrown while iterating or while rendering an item, the update is stopped as if the previous item was the last one and the error is re-thrown. * * See {@link For `<For>`} for use with JSX. * * @param each The expression to watch. Note, that signals accessed during iteration will also trigger updates. * @param component The component to render for each unique value. * * @example * ```tsx * import { $, forEach, e } from "rvx"; * * const items = $([1, 2, 3]); * * forEach(items, value => e("li").append(value)) * ``` */ export function forEach<T>(each: Expression<Iterable<T>>, component: ForContentFn<T>): View { return new View((setBoundary, self) => { interface Instance { /** value */ u: T; /** cycle */ c: number; /** index */ i: Signal<number>; /** dispose */ d: TeardownHook; /** view */ v: View; } function detach(instances: Instance[]) { for (let i = 0; i < instances.length; i++) { instances[i].v.detach(); } } const env = ENV.current; let cycle = 0; const instances: Instance[] = []; const instanceMap = new Map<T, Instance>(); const first: Node = createPlaceholder(env); setBoundary(first, first); teardown(() => { for (let i = 0; i < instances.length; i++) { instances[i].d(); } }); watch(() => { let parent = self.parent; if (!parent) { parent = createParent(env); parent.appendChild(first); } let index = 0; let last = first; try { for (const value of get(each)) { let instance: Instance | undefined = instances[index]; if (instance && Object.is(instance.u, value)) { instance.c = cycle; instance.i.value = index; last = instance.v.last; index++; } else { instance = instanceMap.get(value); if (instance === undefined) { const instance: Instance = { u: value, c: cycle, i: $(index), d: undefined!, v: undefined!, }; instance.d = isolate(capture, () => { instance.v = render(component(value, () => instance.i.value)); instance.v.setBoundaryOwner((_, last) => { if (instances[instances.length - 1] === instance && instance.c === cycle) { setBoundary(undefined, last); } }); }); instance.v.insertBefore(parent, last.nextSibling); instances.splice(index, 0, instance); instanceMap.set(value, instance); last = instance.v.last; index++; } else if (instance.c !== cycle) { instance.i.value = index; instance.c = cycle; const currentIndex = instances.indexOf(instance, index); if (currentIndex < 0) { detach(instances.splice(index, instances.length - index, instance)); instance.v.insertBefore(parent, last.nextSibling); } else { detach(instances.splice(index, currentIndex - index)); } last = instance.v.last; index++; } } } } finally { if (instances.length > index) { detach(instances.splice(index)); } for (const [value, instance] of instanceMap) { if (instance.c !== cycle) { instanceMap.delete(value); instance.v.detach(); instance.d(); } } cycle = (cycle + 1) | 0; setBoundary(undefined, last); } }); }); } /** * Render content for each unique value in an iterable. * * If an error is thrown while iterating or while rendering an item, the update is stopped as if the previous item was the last one and the error is re-thrown. * * See {@link forEach} when not using JSX. * * @example * ```tsx * import { $, For } from "rvx"; * * const items = $([1, 2, 3]); * * <For each={items}> * {value => <li>{value}</li>} * </For> * ``` */ export function For<T>(props: { /** * The expression to watch. Note, that signals accessed during iteration will also trigger updates. */ each: Expression<Iterable<T>>; /** * The component to render for each unique value. */ children: ForContentFn<T>; }): View { return forEach(props.each, props.children); } export interface IndexContentFn<T> { /** * @param value The value. * @param index The index. * @returns The content. */ (value: T, index: number): Content; } /** * Render content for each value in an iterable, keyed by index and value. * * If an error is thrown by iterating or by rendering an item, the update is stopped as if the previous item was the last one and the error is re-thrown. * * See {@link Index `<Index>`} when using JSX. * * @param each The expression to watch. Note, that signals accessed during iteration will also trigger updates. * @param component The component to render for each value/index pair. * * @example * ```tsx * import { $, indexEach, e } from "rvx"; * * const items = $([1, 2, 3]); * * indexEach(items, value => e("li").append(value)) * ``` */ export function indexEach<T>(each: Expression<Iterable<T>>, component: IndexContentFn<T>): View { return new View((setBoundary, self) => { interface Instance { /** value */ u: T; /** dispose */ d: TeardownHook; /** view */ v: View; } const env = ENV.current; const first: Node = createPlaceholder(env); setBoundary(first, first); const instances: Instance[] = []; teardown(() => { for (let i = 0; i < instances.length; i++) { instances[i].d(); } }); watch(() => { let parent = self.parent; if (!parent) { parent = createParent(env); parent.appendChild(first); } let index = 0; let last = first; try { for (const value of get(each)) { if (index < instances.length) { const current = instances[index]; if (Object.is(current.u, value)) { last = current.v.last; index++; continue; } current.v.detach(); current.d(); current.d = NOOP; } const instance: Instance = { u: value, d: undefined!, v: undefined!, }; instance.d = isolate(capture, () => { instance.v = render(component(value, index)); instance.v.setBoundaryOwner((_, last) => { if (instances[instances.length - 1] === instance) { setBoundary(undefined, last); } }); }); instance.v.insertBefore(parent, last.nextSibling); instances[index] = instance; last = instance.v.last; index++; } } finally { if (instances.length > index) { for (let i = index; i < instances.length; i++) { const instance = instances[i]; instance.v.detach(); instance.d(); } instances.length = index; } setBoundary(undefined, last); } }); }); } /** * Render content for each value in an iterable, keyed by index and value. * * If an error is thrown by iterating or by rendering an item, the update is stopped as if the previous item was the last one and the error is re-thrown. * * See {@link indexEach} when not using JSX. * * @example * ```tsx * import { $, Index } from "rvx"; * * const items = $([1, 2, 3]); * * <Index each={items}> * {value => <li>{value}</li>} * </Index> * ``` */ export function Index<T>(props: { /** * The expression to watch.. * * Note, that signals accessed during iteration will also trigger updates. */ each: Expression<Iterable<T>>; /** * The component to render for each value/index pair. */ children: IndexContentFn<T>; }): View { return indexEach(props.each, props.children); } /** * A wrapper that can be used for moving and reusing views. */ export class MovableView { #view: View; #target: Signal<View | void> = $(); constructor(view: View) { this.#view = view; } /** * Create a new view that contains the wrapped view until it is moved again or detached. * * If the lifecycle in which `move` is called is disposed, the created view no longer updates it's boundary and nodes may be silently removed. */ move: Component<void, View> = () => { this.#target.value = undefined; const target = this.#target = $(this.#view); return nest(target, v => v); }; /** * Detach content from the currently active view. */ detach(): void { this.#target.value = undefined; } } /** * Render and wrap arbitrary content so that it can be moved and reused. */ export function movable(content: Content): MovableView { return new MovableView(render(content)); } /** * Attach or detach content depending on an expression. * * Content is kept alive when detached. * * See {@link Attach `<Attach>`} when using JSX. * * @param condition The condition to watch. * @param content The content to attach when the condition result is truthy. * * @example * ```tsx * import { $, attach } from "rvx"; * * const showMessage = $(true); * * attachWhen(showMessage, e("h1").append("Hello World!")) * ``` */ export function attachWhen(condition: Expression<boolean>, content: Content): View { return nest(condition, value => value ? content : undefined); } /** * Attach or detach content depending on an expression. * * Content is kept alive when detached. * * See {@link attachWhen} when not using JSX. * * @example * ```tsx * import { $, Attach } from "rvx"; * * const showMessage = $(true); * * <Attach when={showMessage}> * <h1>Hello World!</h1> * </Attach> * ``` */ export function Attach(props: { /** * The condition to watch. */ when: Expression<boolean>; /** * The content to attach when the condition result is truthy. */ children?: Content; }): View { return attachWhen(props.when, props.children); }