ilha 0.15.1 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/test.d.ts ADDED
@@ -0,0 +1,66 @@
1
+ import { a as Component } from "./types-NhEiBvwy.js";
2
+ import { t as SnapshotValue } from "./snapshot-BDC74QwI.js";
3
+ import { IlhaInspectNode } from "./debug.js";
4
+ import { t as RenderToStringOptions } from "./mount-3Ggg67T0.js";
5
+ //#region src/test.d.ts
6
+ export interface RenderOptions {
7
+ /** Render on the server first and inject the HTML into the container. Default `false`. */
8
+ ssr?: boolean;
9
+ /**
10
+ * Hydrate the container instead of mounting fresh. Default: the value of
11
+ * `ssr`. With `hydrate: true` alone, the container must already hold
12
+ * server markup.
13
+ */
14
+ hydrate?: boolean;
15
+ /** Mount into this element. Default: a new `<div>` appended to `document.body`. */
16
+ container?: Element;
17
+ /** Called for a failing root component and for uncaught nested failures. */
18
+ onError?: (error: Error) => void;
19
+ }
20
+ export interface AtomEntry {
21
+ /** Name of the component that owns the atom. */
22
+ component: string;
23
+ /** Slot index, in registration order. */
24
+ index: number;
25
+ value: SnapshotValue | string;
26
+ }
27
+ export interface TestUi {
28
+ /** Errors reported through `onError` so far. */
29
+ readonly errors: readonly Error[];
30
+ /** Resolves when the mount has no pending work left. Never uses timers. */
31
+ settled: () => Promise<void>;
32
+ click: (selector: string) => Promise<void>;
33
+ /** Set `value` on the element, then fire a bubbling `input` event. */
34
+ input: (selector: string, value: string) => Promise<void>;
35
+ /** Set `value` (or `checked` for a boolean), then fire a bubbling `change` event. */
36
+ change: (selector: string, value: string | boolean) => Promise<void>;
37
+ /** Fire a bubbling, cancelable `submit` event. */
38
+ submit: (selector: string) => Promise<void>;
39
+ /** Dispatch any event on the first element that matches `selector`. */
40
+ dispatch: (selector: string, event: Event) => Promise<void>;
41
+ /** The container's `innerHTML`. */
42
+ html: () => string;
43
+ /** The container's `textContent`. */
44
+ text: () => string;
45
+ query: <E extends Element = Element>(selector: string) => E | null;
46
+ queryAll: <E extends Element = Element>(selector: string) => E[];
47
+ /** Live component tree from `ilha/debug`. */
48
+ tree: () => IlhaInspectNode[];
49
+ /** Every atom in the tree, in render order. */
50
+ atoms: () => AtomEntry[];
51
+ /** Stop the mount and remove a container the harness created. */
52
+ unmount: () => void;
53
+ }
54
+ /**
55
+ * Render `fn` to HTML without a DOM. Same output as `renderToString`, kept
56
+ * here so tests import both helpers from `ilha/test`.
57
+ */
58
+ export declare const renderHtml: (fn: Component, opts?: RenderToStringOptions) => Promise<string>;
59
+ /**
60
+ * Mount `fn` into a DOM container and return a handle that drives it.
61
+ * Needs a DOM global (happy-dom). With `ssr`, the server render is async:
62
+ * `await ui.settled()` before reading `html()`.
63
+ */
64
+ export declare const render: (fn: Component, opts?: RenderOptions) => TestUi;
65
+ //#endregion
66
+ export type { IlhaInspectNode };
package/dist/test.js ADDED
@@ -0,0 +1,179 @@
1
+ import { f as toError, l as isString } from "./shared-vpgYXytd.js";
2
+ import { n as mountWithRuntime, r as renderToString } from "./mount-BwuJsiDH.js";
3
+ import { inspect } from "./debug.js";
4
+
5
+ //#region src/test.ts
6
+ const MICROTASK_TURNS = 4;
7
+ const SNIPPET_LENGTH = 400;
8
+ const requireDocument = () => {
9
+ if (!("document" in globalThis)) throw new Error("ilha/test needs a DOM. Preload happy-dom (bunfig.toml: [test] preload = ['./happydom.ts'] with GlobalRegistrator.register()), or use renderHtml() for DOM-free assertions.");
10
+ };
11
+ const turn = async (left = MICROTASK_TURNS) => {
12
+ await Promise.resolve();
13
+ if (left > 1) await turn(left - 1);
14
+ };
15
+ const snippet = (html) => html.length > SNIPPET_LENGTH ? `${html.slice(0, SNIPPET_LENGTH)}…` : html;
16
+ /** Write `value` (or `checked`) onto a form control; false when it has no such field. */
17
+ const assign = (el, value) => {
18
+ if (isString(value)) {
19
+ if (!("value" in el)) return false;
20
+ el.value = value;
21
+ return true;
22
+ }
23
+ if (!("checked" in el)) return false;
24
+ el.checked = value;
25
+ return true;
26
+ };
27
+ const flatten = (nodes) => nodes.flatMap((node) => [...node.atoms.map(({ index, value }) => ({
28
+ component: node.component,
29
+ index,
30
+ value
31
+ })), ...flatten(node.children)]);
32
+ /**
33
+ * Render `fn` to HTML without a DOM. Same output as `renderToString`, kept
34
+ * here so tests import both helpers from `ilha/test`.
35
+ */
36
+ const renderHtml = (fn, opts) => renderToString(fn, opts);
37
+ /**
38
+ * Mount `fn` into a DOM container and return a handle that drives it.
39
+ * Needs a DOM global (happy-dom). With `ssr`, the server render is async:
40
+ * `await ui.settled()` before reading `html()`.
41
+ */
42
+ const render = (fn, opts = {}) => {
43
+ requireDocument();
44
+ const ssr = opts.ssr === true;
45
+ const hydrate = opts.hydrate ?? ssr;
46
+ const ownsContainer = opts.container === void 0;
47
+ const container = opts.container ?? document.createElement("div");
48
+ if (ownsContainer) document.body.append(container);
49
+ const errors = [];
50
+ let pending = 0;
51
+ let closed = false;
52
+ let started = false;
53
+ let failure;
54
+ let stop;
55
+ let host = container;
56
+ let waiters = [];
57
+ const wake = () => {
58
+ const woken = waiters;
59
+ waiters = [];
60
+ for (const resolve of woken) resolve();
61
+ };
62
+ const track = (runtime) => {
63
+ const { begin, end } = runtime;
64
+ runtime.begin = () => {
65
+ pending += 1;
66
+ begin();
67
+ };
68
+ runtime.end = () => {
69
+ pending -= 1;
70
+ end();
71
+ if (pending === 0) wake();
72
+ };
73
+ };
74
+ const boot = async () => {
75
+ try {
76
+ if (ssr) container.innerHTML = await renderToString(fn);
77
+ if (hydrate || !ssr) {
78
+ host = (hydrate ? container.querySelector("[data-ilha]") : null) ?? container;
79
+ stop = mountWithRuntime(host, fn, {
80
+ hydrate,
81
+ onError: (error) => {
82
+ errors.push(error);
83
+ opts.onError?.(error);
84
+ }
85
+ }, track);
86
+ }
87
+ } catch (error) {
88
+ failure = toError(error);
89
+ }
90
+ started = true;
91
+ };
92
+ const ready = boot();
93
+ const ensureStarted = () => {
94
+ if (failure) throw failure;
95
+ if (!started) throw new Error("ilha/test: the server render is still running. `await ui.settled()` before reading the DOM.");
96
+ };
97
+ const nextIdle = () => {
98
+ const { promise, resolve } = Promise.withResolvers();
99
+ waiters.push(() => resolve(null));
100
+ return promise;
101
+ };
102
+ const quiesce = async () => {
103
+ await turn();
104
+ if (pending === 0 || closed) return;
105
+ await nextIdle();
106
+ await quiesce();
107
+ };
108
+ const settled = async () => {
109
+ await ready;
110
+ ensureStarted();
111
+ await quiesce();
112
+ };
113
+ const requireElement = (selector) => {
114
+ const el = container.querySelector(selector);
115
+ if (!el) throw new Error(`ilha/test: no element matches "${selector}". Current HTML: ${snippet(container.innerHTML)}`);
116
+ return el;
117
+ };
118
+ const dispatch = async (selector, event) => {
119
+ await settled();
120
+ requireElement(selector).dispatchEvent(event);
121
+ await settled();
122
+ };
123
+ const setField = async (selector, type, value) => {
124
+ await settled();
125
+ const el = requireElement(selector);
126
+ if (!assign(el, value)) throw new Error(`ilha/test: "${selector}" is not a form control that takes ${isString(value) ? "a value" : "checked"}.`);
127
+ el.dispatchEvent(new Event(type, { bubbles: true }));
128
+ await settled();
129
+ };
130
+ const inspectRoot = () => {
131
+ ensureStarted();
132
+ return inspect(host);
133
+ };
134
+ return {
135
+ atoms: () => flatten(inspectRoot()),
136
+ change: (selector, value) => setField(selector, "change", value),
137
+ click: (selector) => dispatch(selector, new MouseEvent("click", {
138
+ bubbles: true,
139
+ cancelable: true
140
+ })),
141
+ dispatch,
142
+ get errors() {
143
+ return errors;
144
+ },
145
+ html: () => {
146
+ ensureStarted();
147
+ return container.innerHTML;
148
+ },
149
+ input: (selector, value) => setField(selector, "input", value),
150
+ query: (selector) => {
151
+ ensureStarted();
152
+ return container.querySelector(selector);
153
+ },
154
+ queryAll: (selector) => {
155
+ ensureStarted();
156
+ return [...container.querySelectorAll(selector)];
157
+ },
158
+ settled,
159
+ submit: (selector) => dispatch(selector, new Event("submit", {
160
+ bubbles: true,
161
+ cancelable: true
162
+ })),
163
+ text: () => {
164
+ ensureStarted();
165
+ return container.textContent ?? "";
166
+ },
167
+ tree: inspectRoot,
168
+ unmount: () => {
169
+ if (closed) return;
170
+ closed = true;
171
+ stop?.();
172
+ if (ownsContainer) container.remove();
173
+ wake();
174
+ }
175
+ };
176
+ };
177
+
178
+ //#endregion
179
+ export { render, renderHtml };
@@ -1,23 +1,19 @@
1
- import { Effect, Stream } from "effect";
2
1
  import { AtomRegistry } from "effect/reactivity/AtomRegistry";
3
2
  import { Closeable } from "effect/Scope";
3
+ import { Effect, Stream } from "effect";
4
4
  import { Atom } from "effect/reactivity";
5
5
  //#region src/types.d.ts
6
6
  type JsonText = string | number | bigint | boolean | null | undefined;
7
7
  /** Runtime prop bag values painted onto elements / passed to components. */
8
8
  type PropValue = JsonText | PropBag | StyleObject | EventHandler | AtomHandle<JsonText> | readonly PropValue[];
9
9
  type StyleObject = Readonly<Record<string, string | number | null | undefined>>;
10
- type EventHandler = (event: Event) => void;
10
+ /** Event and ref callbacks. `never` accepts handlers typed for a specific event. */
11
+ type EventHandler = (event: never) => void;
11
12
  /** Indexed bag — interface so PropValue can recurse without a circular type alias. */
12
13
  interface PropBag {
13
14
  readonly [key: string]: PropValue | undefined;
14
15
  }
15
- type ActionArg = JsonText | PropBag | readonly ActionArg[];
16
- interface SsrAction {
17
- readonly k: string;
18
- readonly a: readonly ActionArg[];
19
- }
20
- type View = VNode | UnsafeHtml | JsonText | AtomHandle<unknown> | Stream.Stream<View, unknown, unknown> | GeneratorFn | Component | View[] | Iterable<View>;
16
+ type View = VNode | UnsafeHtml | JsonText | AtomView | Stream.Stream<View, unknown, unknown> | GeneratorFn | Component | View[] | Iterable<View>;
21
17
  declare const Fragment: unique symbol;
22
18
  type Fragment = typeof Fragment;
23
19
  type ComponentFn = (props: PropBag) => View | Promise<View | undefined> | undefined;
@@ -45,14 +41,13 @@ type Component = (() => View | undefined | Promise<View | undefined>) | Generato
45
41
  interface IlhaRuntime {
46
42
  readonly registry: AtomRegistry;
47
43
  readonly scope: Closeable;
44
+ /** Run an effect synchronously with the mount's `AtomRegistry` provided. */
45
+ readonly fork: <A, E>(effect: Effect.Effect<A, E, AtomRegistry>) => A;
48
46
  readonly ssr: boolean;
49
47
  readonly ssrValues: unknown[];
50
- ssrActions: Record<string, SsrAction>;
51
- ssrEventI: number;
52
- ssrCapture: boolean;
53
48
  hydrateValues?: unknown[];
54
49
  hydrateI: number;
55
- nextHole: () => number;
50
+ holeSeq: number;
56
51
  begin: () => void;
57
52
  end: () => void;
58
53
  later: (fn: () => void) => void;
@@ -64,6 +59,11 @@ interface IlhaRuntime {
64
59
  */
65
60
  onError?: (error: Error) => void;
66
61
  }
62
+ /** Any atom handle as a view: the painter subscribes to `atom`, whatever it holds. */
63
+ interface AtomView {
64
+ readonly $$atom: 1;
65
+ readonly atom: Atom.Atom<unknown>;
66
+ }
67
67
  interface AtomHandle<A> {
68
68
  readonly $$atom: 1;
69
69
  readonly atom: Atom.Atom<A>;
@@ -76,6 +76,19 @@ type AtomEquality<A> = "structural" | ((prev: A, next: A) => boolean);
76
76
  interface AtomOptions<A = unknown> {
77
77
  /** Skip subscriber notify when `equals(prev, next)` holds. Plain values only. */
78
78
  readonly equals?: AtomEquality<A>;
79
+ /**
80
+ * Set `false` to keep a plain-value slot out of the SSR snapshot. The slot
81
+ * starts from its initial value on hydration. Default `true`.
82
+ */
83
+ readonly snapshot?: boolean;
84
+ }
85
+ /** Options for `atom.lazy()`. */
86
+ interface AtomLazyOptions {
87
+ /**
88
+ * Set `false` to keep the slot out of the SSR snapshot, for timers,
89
+ * observers, and other mutable refs. Default `true`.
90
+ */
91
+ readonly snapshot?: boolean;
79
92
  }
80
93
  /** Optional disposer returned from `watch.once`. */
81
94
  type WatchOnceCleanup = () => void;
@@ -89,39 +102,19 @@ interface WatchContext {
89
102
  onCleanup: (fn: () => void) => void;
90
103
  }
91
104
  type WatchCallback<A> = (value: A, ctx: WatchContext) => WatchOnceCleanup | undefined | Promise<WatchOnceCleanup | undefined>;
92
- /** Options for `resource()`. */
93
- interface ResourceOptions {
94
- /** Serve cached data while revalidating in the background. Default `true`. */
95
- readonly staleWhileRevalidate?: boolean;
96
- }
97
- /** Async fetch scoped to a resource key. Receives an abort signal. */
98
- type ResourceFetcher<T> = (key: string, ctx: {
99
- signal: AbortSignal;
100
- }) => Promise<T>;
101
- /** Handles returned by `resource()`. */
102
- interface Resource<T> {
103
- readonly data: AtomHandle<T | undefined>;
104
- readonly error: AtomHandle<unknown>;
105
- readonly loading: AtomHandle<boolean>;
106
- refetch: () => Promise<T | undefined>;
107
- }
108
- /** Connection state of a `fromEventSource` feed. */
109
- type EventSourceStatus = "connecting" | "open" | "retrying" | "closed";
110
- /** Options for `fromEventSource()`. */
111
- interface EventSourceOptions<T = string> {
112
- /** Named event to listen for. Default: the `message` event. */
113
- readonly event?: string;
114
- /** Decode (and validate) `data` into `T`. Throwing skips the message. */
115
- readonly schema?: (data: string) => T;
116
- /** Base backoff between reconnects. Default `1000`. Capped at 30s. */
117
- readonly retryBaseMs?: number;
118
- }
119
- /** Live server-sent-events feed. */
120
- interface EventSourceFeed<T = string> {
121
- /** Paint with `yield Stream.map(feed.stream, ...)`; watch `latest` instead in sync components. */
122
- readonly stream: Stream.Stream<T, never, never>;
123
- readonly status: AtomHandle<EventSourceStatus>;
124
- readonly latest: AtomHandle<T | undefined>;
105
+ interface MountOptions {
106
+ hydrate?: boolean;
107
+ /**
108
+ * Called with a failing root component, and with a nested component no
109
+ * `ErrorBoundary` caught, before the error view paints.
110
+ */
111
+ onError?: (error: Error) => void;
112
+ /**
113
+ * Called each time the mount has no pending work left: after the first
114
+ * paint and again after every rerender, async component, and watcher
115
+ * settles. Use it to learn when the DOM reflects an atom update.
116
+ */
117
+ onIdle?: () => void;
125
118
  }
126
119
  //#endregion
127
- export { WatchOnceCleanup as C, WatchContext as S, ResourceOptions as _, ComponentFn as a, View as b, EventSourceStatus as c, Instruction as d, JsxComponent as f, ResourceFetcher as g, Resource as h, Component as i, Fragment as l, PropValue as m, AtomHandle as n, EventSourceFeed as o, PropBag as p, AtomOptions as r, EventSourceOptions as s, AtomEquality as t, IlhaRuntime as u, UnsafeHtml as v, Yielded as w, WatchCallback as x, VNode as y };
120
+ export { View as _, Component as a, WatchOnceCleanup as b, IlhaRuntime as c, JsxComponent as d, MountOptions as f, VNode as g, UnsafeHtml as h, AtomOptions as i, Instruction as l, PropValue as m, AtomHandle as n, ComponentFn as o, PropBag as p, AtomLazyOptions as r, Fragment as s, AtomEquality as t, JsonText as u, WatchCallback as v, Yielded as x, WatchContext as y };
@@ -0,0 +1,22 @@
1
+ import { a as hasBrand } from "./shared-vpgYXytd.js";
2
+
3
+ //#region src/types.ts
4
+ const Fragment = Symbol("ilha.Fragment");
5
+
6
+ //#endregion
7
+ //#region src/vnode.ts
8
+ const createVNode = (type, props, ...rest) => {
9
+ const { key, children, ...own } = props ?? {};
10
+ return {
11
+ $$ilha: 1,
12
+ children: (children === void 0 ? rest : [children, ...rest]).flat(Number.POSITIVE_INFINITY),
13
+ key,
14
+ props: own,
15
+ type
16
+ };
17
+ };
18
+ const h = createVNode;
19
+ const isVNode = (x) => hasBrand(x, "$$ilha");
20
+
21
+ //#endregion
22
+ export { isVNode as n, Fragment as r, h as t };
@@ -1,4 +1,4 @@
1
- import { a as ComponentFn, b as View, f as JsxComponent, l as Fragment, n as AtomHandle, p as PropBag, y as VNode } from "./types-DsdtZMhG.js";
1
+ import { _ as View, d as JsxComponent, g as VNode, n as AtomHandle, o as ComponentFn, p as PropBag, s as Fragment } from "./types-NhEiBvwy.js";
2
2
  //#region src/jsx-types.d.ts
3
3
  declare namespace JSX {
4
4
  export type Element = View;
@@ -592,6 +592,14 @@ declare namespace JSX {
592
592
  }
593
593
  //#endregion
594
594
  //#region src/vnode.d.ts
595
- declare const h: (type: string | Fragment | ComponentFn | JsxComponent, props: PropBag | null | undefined, ...rest: View[]) => VNode;
595
+ /**
596
+ * Build a vnode. A component with typed props gets its props checked against
597
+ * that type; elements and untyped components take a plain prop bag.
598
+ */
599
+ interface HFn {
600
+ <P>(type: (props: P) => ReturnType<JsxComponent>, props: P | null | undefined, ...rest: View[]): VNode;
601
+ (type: string | Fragment | ComponentFn | JsxComponent, props: PropBag | null | undefined, ...rest: View[]): VNode;
602
+ }
603
+ declare const h: HFn;
596
604
  //#endregion
597
605
  export { JSX as n, h as t };
package/llms.txt ADDED
@@ -0,0 +1,37 @@
1
+ # ilha
2
+
3
+ > Tiny, isomorphic islands UI library on Effect 4. Function components, order-based `atom()` slots, JSX, SSR via `renderToString`, hydration via `mount(host, C, { hydrate: true })`. Version 0.16.0.
4
+
5
+ Start with the canonical-usage table: exactly one API per task, plus a never-do list.
6
+
7
+ ## Docs
8
+
9
+ - [Choose an API](https://ilha.build/guide/getting-started/choose-an-api): task to API table and never-do list
10
+ - [Core concepts](https://ilha.build/guide/getting-started/core-concepts): components, atoms, mount, renderToString
11
+ - [Full docs for LLMs](https://ilha.build/llms-full.txt): every page as one Markdown file
12
+ - [Reference](https://ilha.build/reference/ilha): API reference for ilha
13
+
14
+ ## Diagnostics
15
+
16
+ - [Error codes](https://ilha.build/errors): ILHA001..ILHA007: cause and fix for each IlhaError
17
+
18
+ ## Tooling
19
+
20
+ - [Testing](https://ilha.build/guide/recipes/testing): ilha/test render harness
21
+ - [Devtools](https://ilha.build/guide/devtools): ilha/debug inspect() and @ilha/devtools
22
+ - [Oxlint plugin](https://ilha.build/guide/recipes/oxlint): ilha/oxlint rules
23
+
24
+ ## Recipes
25
+
26
+ - [Data fetching](https://ilha.build/guide/recipes/data-fetching): Effect Cache + atom(Effect)
27
+ - [Server-sent events](https://ilha.build/guide/recipes/server-sent-events): Stream.callback + retry
28
+ - [Custom elements](https://ilha.build/guide/recipes/custom-elements): wrap a component as a custom element
29
+
30
+ ## Optional
31
+
32
+ - [Migrations](https://github.com/ilhajs/ilha/blob/main/packages/ilha/MIGRATIONS.md): removed or changed APIs with replacements
33
+
34
+ ## Package files
35
+
36
+ - AGENTS.md: agent guide shipped in the npm tarball (public API from source JSDoc)
37
+ - MIGRATIONS.md, MIGRATIONS.json: removed or changed APIs with replacements