Skip to content

Commit 85ff963

Browse files
authored
Feat/abortable events (#26)
* fix(on): make dom events abortable * docs(menu): move signals up * chore(pr): improvements * fix(ilha): use weakmap for invocationControllers
1 parent ea7e41d commit 85ff963

19 files changed

Lines changed: 2362 additions & 381 deletions

File tree

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
["mount", "context", "html", "raw", "css"]
1+
["signals", "mount", "html", "raw", "css"]

apps/website/docs/guide/helpers/context.md

Lines changed: 0 additions & 106 deletions
This file was deleted.
Lines changed: 269 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,269 @@
1+
---
2+
title: Signals
3+
description: Create free-standing reactive signals, share state across islands, batch writes, and peek at values without creating dependencies.
4+
---
5+
6+
# Signals
7+
8+
Reactive signals are the primitive that powers state in ilha. In addition to `.state()` (local to an island), ilha exports four signal helpers for cross-island sharing, performance, and control:
9+
10+
| Helper | Purpose |
11+
| ----------- | ------------------------------------------------------------ |
12+
| `signal()` | Create a free-standing signal for one-off shared state |
13+
| `context()` | Create a named global signal accessible from anywhere by key |
14+
| `batch()` | Group multiple writes into a single propagation pass |
15+
| `untrack()` | Read a signal without subscribing the surrounding scope |
16+
17+
---
18+
19+
## `signal(initial)`
20+
21+
Creates a free-standing reactive signal that lives outside any island. Useful for sharing state across multiple islands without prop drilling, or for binding form inputs to module-level state.
22+
23+
### Basic usage
24+
25+
```ts twoslash
26+
import { signal } from "ilha";
27+
28+
const count = signal(0);
29+
30+
count(); // → 0 (read)
31+
count(5); // → sets to 5 (write)
32+
```
33+
34+
Reading the signal inside any reactive scope — `.render()`, `.derived()`, `.effect()` — automatically subscribes that scope, so when the signal changes, dependents re-run as if it were local state.
35+
36+
### Sharing state between islands
37+
38+
Because `signal()` returns a plain accessor, you can import it into any island. When one island writes to it, all others that read it re-render automatically:
39+
40+
```ts twoslash
41+
import ilha, { html, signal } from "ilha";
42+
43+
const cartCount = signal(0);
44+
45+
const CartButton = ilha
46+
.on("button@click", () => cartCount(cartCount() + 1)) // [!code highlight]
47+
.render(() => html` <button>Add to cart</button> `);
48+
49+
const CartBadge = ilha
50+
// [!code highlight]
51+
.render(() => html`<span>${cartCount()}</span>`);
52+
```
53+
54+
Both islands share the same `cartCount` signal. Clicking the button in `CartButton` updates the badge in `CartBadge` without any wiring between them.
55+
56+
### Using signals in [`.bind()`](/guide/island/bind)
57+
58+
Pass a signal directly to `.bind()` to sync a form element across islands:
59+
60+
```ts twoslash
61+
import ilha, { html, signal } from "ilha";
62+
63+
const query = signal("");
64+
65+
const SearchInput = ilha
66+
// [!code highlight]
67+
.bind("input", query)
68+
.render(() => html`<input type="search" />`);
69+
70+
const SearchResults = ilha.render(() => html`<p>Results for: ${query()}</p>`);
71+
```
72+
73+
---
74+
75+
## `context(key, initial)`
76+
77+
Creates a **named global signal** — a reactive signal shared across all islands. Identical keys always return the same signal instance, which makes it useful for app-wide singletons (theme, locale, current user) where you want registry semantics.
78+
79+
```ts twoslash
80+
import { context } from "ilha";
81+
82+
const theme = context("app.theme", "light");
83+
84+
theme(); // → "light"
85+
theme("dark"); // → sets to "dark"
86+
```
87+
88+
### `signal()` vs `context()`
89+
90+
Both return the same accessor shape and can be passed to `.bind()`. Reach for `signal()` when you hold the reference yourself and import it where needed. Reach for `context()` when you want a name-keyed registry so the same signal can be looked up from anywhere by string key — for example, when the consumer lives in a different package or module from where the signal is defined.
91+
92+
### Sharing state between islands
93+
94+
Any island that calls `context()` with the same key gets the same signal. When one island writes to it, all others that read it re-render automatically:
95+
96+
```ts twoslash
97+
import ilha, { html, context } from "ilha";
98+
99+
const cartCount = context("cart.count", 0);
100+
101+
const CartButton = ilha
102+
.on("button@click", () => cartCount(cartCount() + 1)) // [!code highlight]
103+
.render(() => html` <button>Add to cart</button> `);
104+
105+
const CartBadge = ilha
106+
// [!code highlight]
107+
.render(() => html`<span>${cartCount()}</span>`);
108+
```
109+
110+
### Using context in [`.bind()`](/guide/island/bind)
111+
112+
Pass a context signal directly to `.bind()` to sync a form element across islands:
113+
114+
```ts twoslash
115+
import ilha, { html, context } from "ilha";
116+
117+
const query = context("search.query", "");
118+
119+
const SearchInput = ilha
120+
// [!code highlight]
121+
.bind("input", query)
122+
.render(() => html`<input type="search" />`);
123+
124+
const SearchResults = ilha.render(() => html`<p>Results for: ${query()}</p>`);
125+
```
126+
127+
### Initializing with a type
128+
129+
The second argument sets the initial value and infers the signal type. The type is fixed at first call — subsequent calls with the same key return the existing signal regardless of what initial value is passed:
130+
131+
```ts twoslash
132+
import { context } from "ilha";
133+
134+
const count = context("ui.count", 0); // creates signal<number>
135+
const same = context("ui.count", 999); // returns same signal, ignores 999
136+
```
137+
138+
This means context initialization is effectively first-write-wins. Define context signals in a shared module to ensure consistent initialization across your app:
139+
140+
```ts
141+
// contexts.ts
142+
import { context } from "ilha";
143+
144+
export const theme = context("app.theme", "light");
145+
export const userId = context("app.userId", null as string | null);
146+
export const sidebar = context("ui.sidebar", true);
147+
```
148+
149+
### Reading context inside effects and derived
150+
151+
Context signals are reactive — reading them inside [`.effect()`](/guide/island/effect) or [`.derived()`](/guide/island/derived) creates a dependency just like reading local state:
152+
153+
```ts twoslash
154+
import ilha, { context } from "ilha";
155+
156+
const theme = context("app.theme", "light");
157+
158+
const Island = ilha
159+
.effect(() => {
160+
document.documentElement.dataset["theme"] = theme();
161+
})
162+
.render(() => `<div>content</div>`);
163+
```
164+
165+
Whenever `theme` is updated anywhere in the app, this effect re-runs.
166+
167+
### SSR behavior
168+
169+
`context()` is safe to call during SSR. The registry is module-level, so signals persist for the lifetime of the process. In a server environment where requests share the same module instance, be careful not to store user-specific state in context signals — use [`.input()`](/guide/island/input) and [`.state()`](/guide/island/state) for per-request data instead.
170+
171+
---
172+
173+
## `batch(fn)`
174+
175+
Runs `fn` as an atomic batch — multiple signal writes inside the callback produce a single propagation pass, so dependents (effects, deriveds, island re-renders) see the final state and run once instead of once per write. Returns whatever `fn` returns.
176+
177+
### Before and after
178+
179+
Without batch, each write triggers its own propagation pass:
180+
181+
```ts twoslash
182+
import { signal } from "ilha";
183+
184+
const a = signal(0);
185+
const b = signal(0);
186+
187+
a(1); // → effects re-run
188+
b(2); // → effects re-run again
189+
```
190+
191+
With batch, both writes flush together:
192+
193+
```ts twoslash
194+
import { signal, batch } from "ilha";
195+
196+
const a = signal(0);
197+
const b = signal(0);
198+
199+
batch(() => {
200+
a(10);
201+
b(20);
202+
}); // → effects re-run once
203+
```
204+
205+
### Implicit batching
206+
207+
`.on()` handlers and `.effect()` runs are batched implicitly, so you only need `batch()` when triggering multiple writes from outside an island — for example from a top-level event listener, a `setTimeout` callback, or a WebSocket message handler.
208+
209+
### Nesting
210+
211+
Nested `batch()` calls are safe and only flush when the outermost batch ends:
212+
213+
```ts twoslash
214+
import { signal, batch } from "ilha";
215+
216+
const count = signal(0);
217+
218+
batch(() => {
219+
batch(() => {
220+
count(1);
221+
}); // still inside outer batch — no flush yet
222+
count(2);
223+
}); // outermost batch ends — single flush
224+
```
225+
226+
---
227+
228+
## `untrack(fn)`
229+
230+
Runs `fn` with reactive tracking suspended. Reading signals inside `fn` returns their current value without subscribing the surrounding scope. Use this in effects or deriveds when you want to peek at state without causing a re-run on its changes.
231+
232+
### React to A, peek at B
233+
234+
The canonical pattern: an effect should re-run when `tracked` changes, but read `peeked` only as a one-off value:
235+
236+
```ts twoslash
237+
import ilha, { signal, untrack } from "ilha";
238+
239+
const tracked = signal(0);
240+
const peeked = signal("hello");
241+
242+
const Island = ilha
243+
.effect(() => {
244+
// Re-runs when `tracked` changes, but NOT when `peeked` changes.
245+
console.log(
246+
tracked(),
247+
untrack(() => peeked()),
248+
);
249+
})
250+
.render(() => `<p>x</p>`);
251+
```
252+
253+
`untrack()` returns whatever `fn` returns, so it also works for peeking at derived values or any other reactive read:
254+
255+
```ts twoslash
256+
import { signal, untrack } from "ilha";
257+
258+
const s = signal(42);
259+
const value = untrack(() => s()); // → 42, no subscription created
260+
```
261+
262+
---
263+
264+
## Notes
265+
266+
- `signal()` vs `context()` — both return the same accessor shape and can be passed to `.bind()`. Use `signal()` for one-off shared state where you hold the reference; use `context()` when you want a name-keyed registry.
267+
- Keys are global strings. Use namespaced keys like `"app.theme"` or `"cart.count"` to avoid accidental collisions across different parts of your app.
268+
- There is no way to delete or reset a context signal once created short of reloading the module.
269+
- Context signals are not included in [`.hydratable()`](/guide/island/hydratable) snapshots. If you need server-rendered context values on the client, pass them as island props via [`.input()`](/guide/island/input) and initialize the context signal inside [`.onMount()`](/guide/island/onmount).

apps/website/docs/guide/island/_meta.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
"bind",
77
"effect",
88
"onmount",
9+
"onerror",
910
"transition",
1011
"css",
1112
"render",

0 commit comments

Comments
 (0)