@ozsarman/clarityjs 0.6.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/README.md +178 -0
- package/package.json +168 -0
- package/src/analyze.js +534 -0
- package/src/async-state.js +555 -0
- package/src/bundle-runtime.js +35 -0
- package/src/clarity-bundle.js +332 -0
- package/src/clarity-test.js +622 -0
- package/src/cli.js +453 -0
- package/src/codegen.js +1934 -0
- package/src/dev-server.js +362 -0
- package/src/devtools.js +765 -0
- package/src/edge.js +606 -0
- package/src/error-overlay.js +535 -0
- package/src/file-conventions.js +472 -0
- package/src/font.js +513 -0
- package/src/game-loop.js +106 -0
- package/src/head.js +393 -0
- package/src/hydrate.js +292 -0
- package/src/i18n.js +403 -0
- package/src/image.js +352 -0
- package/src/index.js +193 -0
- package/src/islands.js +284 -0
- package/src/isr.js +306 -0
- package/src/layout.js +342 -0
- package/src/lexer.js +572 -0
- package/src/linter.js +547 -0
- package/src/pages-router.js +229 -0
- package/src/parser.js +1108 -0
- package/src/router.js +732 -0
- package/src/runtime.js +1465 -0
- package/src/scoped-css.js +641 -0
- package/src/server-actions.js +439 -0
- package/src/server-data.js +225 -0
- package/src/sourcemap.js +130 -0
- package/src/ssg.js +310 -0
- package/src/ssr.js +621 -0
- package/src/store.js +276 -0
- package/src/transitions.js +438 -0
- package/src/ts-plugin.js +613 -0
- package/src/typegen.js +240 -0
- package/src/vite-plugin.js +447 -0
- package/types/index.d.ts +366 -0
package/src/runtime.js
ADDED
|
@@ -0,0 +1,1465 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Clarity.js Runtime — Signal-based Reactivity Core
|
|
3
|
+
*
|
|
4
|
+
* Designed for AI and humans alike.
|
|
5
|
+
* Three concepts only: signal, effect, computed.
|
|
6
|
+
* No magic. No implicit behaviour. Everything is explicit.
|
|
7
|
+
*
|
|
8
|
+
* Author: Claude (Anthropic)
|
|
9
|
+
* Version: 0.1.0
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
// ─── Reactive Context ────────────────────────────────────────────────────────
|
|
13
|
+
// The currently executing effect, if any.
|
|
14
|
+
// This is how signals know who is "reading" them.
|
|
15
|
+
let _currentEffect = null;
|
|
16
|
+
let _batchDepth = 0;
|
|
17
|
+
const _pendingEffects = new Set();
|
|
18
|
+
|
|
19
|
+
// ─── Signal ──────────────────────────────────────────────────────────────────
|
|
20
|
+
/**
|
|
21
|
+
* Creates a reactive value.
|
|
22
|
+
*
|
|
23
|
+
* Usage:
|
|
24
|
+
* const count = signal(0)
|
|
25
|
+
* count.get() // read
|
|
26
|
+
* count.set(n + 1) // write
|
|
27
|
+
* count.update(n => n + 1) // update with function
|
|
28
|
+
*
|
|
29
|
+
* When a signal is read inside an effect, the effect subscribes
|
|
30
|
+
* and will re-run whenever the signal changes.
|
|
31
|
+
*/
|
|
32
|
+
export function signal(initialValue) {
|
|
33
|
+
let _value = initialValue;
|
|
34
|
+
const _subscribers = new Set();
|
|
35
|
+
|
|
36
|
+
const sig = {
|
|
37
|
+
// Read the value — tracks if inside an effect
|
|
38
|
+
get() {
|
|
39
|
+
if (_currentEffect) {
|
|
40
|
+
_subscribers.add(_currentEffect);
|
|
41
|
+
_currentEffect._deps.add(sig);
|
|
42
|
+
}
|
|
43
|
+
return _value;
|
|
44
|
+
},
|
|
45
|
+
|
|
46
|
+
// Write a new value — notifies all subscribers
|
|
47
|
+
set(newValue) {
|
|
48
|
+
if (Object.is(_value, newValue)) return; // no-op if same value
|
|
49
|
+
_value = newValue;
|
|
50
|
+
_notify(_subscribers);
|
|
51
|
+
},
|
|
52
|
+
|
|
53
|
+
// Update with a function — sugar for set(fn(get()))
|
|
54
|
+
update(fn) {
|
|
55
|
+
sig.set(fn(_value));
|
|
56
|
+
},
|
|
57
|
+
|
|
58
|
+
// Peek without tracking — useful in effects that shouldn't re-run
|
|
59
|
+
peek() {
|
|
60
|
+
return _value;
|
|
61
|
+
},
|
|
62
|
+
|
|
63
|
+
// Internal: for debugging and tooling
|
|
64
|
+
_type: 'signal',
|
|
65
|
+
_subscribers,
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
return sig;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// ─── Effect ──────────────────────────────────────────────────────────────────
|
|
72
|
+
/**
|
|
73
|
+
* Runs a function reactively — re-runs whenever its signal dependencies change.
|
|
74
|
+
*
|
|
75
|
+
* Usage:
|
|
76
|
+
* effect(() => {
|
|
77
|
+
* document.title = count.get() + ' items'
|
|
78
|
+
* })
|
|
79
|
+
*
|
|
80
|
+
* The effect runs immediately, then re-runs when any read signal changes.
|
|
81
|
+
* Returns a cleanup/dispose function.
|
|
82
|
+
*/
|
|
83
|
+
export function effect(fn) {
|
|
84
|
+
const _effect = {
|
|
85
|
+
_fn: fn,
|
|
86
|
+
_deps: new Set(),
|
|
87
|
+
_cleanup: null,
|
|
88
|
+
_type: 'effect',
|
|
89
|
+
_disposed: false,
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
function run() {
|
|
93
|
+
if (_effect._disposed) return;
|
|
94
|
+
|
|
95
|
+
// Cleanup previous subscriptions
|
|
96
|
+
_cleanup(_effect);
|
|
97
|
+
|
|
98
|
+
// Run with this effect as the current tracker
|
|
99
|
+
const prev = _currentEffect;
|
|
100
|
+
_currentEffect = _effect;
|
|
101
|
+
try {
|
|
102
|
+
const cleanup = fn();
|
|
103
|
+
if (typeof cleanup === 'function') {
|
|
104
|
+
_effect._cleanup = cleanup;
|
|
105
|
+
}
|
|
106
|
+
} finally {
|
|
107
|
+
_currentEffect = prev;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
_effect._run = run;
|
|
112
|
+
run(); // Run immediately
|
|
113
|
+
|
|
114
|
+
// Return dispose function
|
|
115
|
+
return function dispose() {
|
|
116
|
+
_effect._disposed = true;
|
|
117
|
+
_cleanup(_effect);
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// ─── Computed ────────────────────────────────────────────────────────────────
|
|
122
|
+
/**
|
|
123
|
+
* A derived signal — computes a value from other signals.
|
|
124
|
+
* Lazy: only recomputes when read and dependencies have changed.
|
|
125
|
+
*
|
|
126
|
+
* Usage:
|
|
127
|
+
* const doubled = computed(() => count.get() * 2)
|
|
128
|
+
* doubled.get() // always up to date
|
|
129
|
+
*/
|
|
130
|
+
export function computed(fn) {
|
|
131
|
+
let _dirty = true;
|
|
132
|
+
let _value;
|
|
133
|
+
const _subscribers = new Set();
|
|
134
|
+
|
|
135
|
+
const _effect = {
|
|
136
|
+
_fn: fn,
|
|
137
|
+
_deps: new Set(),
|
|
138
|
+
_cleanup: null,
|
|
139
|
+
_type: 'computed',
|
|
140
|
+
_disposed: false,
|
|
141
|
+
_run() {
|
|
142
|
+
_dirty = true;
|
|
143
|
+
_notify(_subscribers);
|
|
144
|
+
},
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
const comp = {
|
|
148
|
+
get() {
|
|
149
|
+
// Track who is reading this computed
|
|
150
|
+
if (_currentEffect) {
|
|
151
|
+
_subscribers.add(_currentEffect);
|
|
152
|
+
_currentEffect._deps.add(comp);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (_dirty) {
|
|
156
|
+
// Recompute
|
|
157
|
+
_cleanup(_effect);
|
|
158
|
+
const prev = _currentEffect;
|
|
159
|
+
_currentEffect = _effect;
|
|
160
|
+
try {
|
|
161
|
+
_value = fn();
|
|
162
|
+
_dirty = false;
|
|
163
|
+
} finally {
|
|
164
|
+
_currentEffect = prev;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return _value;
|
|
169
|
+
},
|
|
170
|
+
|
|
171
|
+
peek() {
|
|
172
|
+
return _value;
|
|
173
|
+
},
|
|
174
|
+
|
|
175
|
+
_type: 'computed',
|
|
176
|
+
_subscribers,
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
return comp;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// ─── Batch ───────────────────────────────────────────────────────────────────
|
|
183
|
+
/**
|
|
184
|
+
* Batch multiple signal writes — effects run once after all updates.
|
|
185
|
+
*
|
|
186
|
+
* Usage:
|
|
187
|
+
* batch(() => {
|
|
188
|
+
* firstName.set('Ada')
|
|
189
|
+
* lastName.set('Lovelace')
|
|
190
|
+
* })
|
|
191
|
+
* // name effect runs once, not twice
|
|
192
|
+
*/
|
|
193
|
+
export function batch(fn) {
|
|
194
|
+
_batchDepth++;
|
|
195
|
+
try {
|
|
196
|
+
fn();
|
|
197
|
+
} finally {
|
|
198
|
+
_batchDepth--;
|
|
199
|
+
if (_batchDepth === 0) {
|
|
200
|
+
// Flush all pending effects
|
|
201
|
+
const toRun = new Set(_pendingEffects);
|
|
202
|
+
_pendingEffects.clear();
|
|
203
|
+
toRun.forEach(e => e._run());
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// ─── DOM Helpers ─────────────────────────────────────────────────────────────
|
|
209
|
+
// ─── AI Registry ─────────────────────────────────────────────────────────────
|
|
210
|
+
/**
|
|
211
|
+
* Global registry of all mounted Clarity components.
|
|
212
|
+
* AI agents can call aiDiscover() to enumerate all live components
|
|
213
|
+
* and their declared permissions.
|
|
214
|
+
*/
|
|
215
|
+
const _aiRegistry = new Set();
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Query the AI contract for a DOM element (or its nearest Clarity ancestor).
|
|
219
|
+
*
|
|
220
|
+
* Usage (by an AI agent):
|
|
221
|
+
* const contract = aiQuery(document.getElementById('card'));
|
|
222
|
+
* contract.snapshot() // { name: 'Ada', email: '...' }
|
|
223
|
+
* contract.act('follow') // triggers declared action
|
|
224
|
+
* contract.forbidden // ['balance', 'token']
|
|
225
|
+
*/
|
|
226
|
+
export function aiQuery(element) {
|
|
227
|
+
let el = element;
|
|
228
|
+
while (el) {
|
|
229
|
+
if (el.__clarity_ai__) return el.__clarity_ai__;
|
|
230
|
+
el = el.parentElement;
|
|
231
|
+
}
|
|
232
|
+
return null;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Discover all mounted Clarity components with AI contracts.
|
|
237
|
+
*
|
|
238
|
+
* Usage:
|
|
239
|
+
* aiDiscover().forEach(c => console.log(c.component, c.snapshot()))
|
|
240
|
+
*/
|
|
241
|
+
export function aiDiscover() {
|
|
242
|
+
return [..._aiRegistry].filter(el => el.isConnected && el.__clarity_ai__)
|
|
243
|
+
.map(el => el.__clarity_ai__);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Mount a Clarity component into a DOM element.
|
|
248
|
+
*
|
|
249
|
+
* - Calls component.__clarity_mount__() after DOM insertion (onMount hook)
|
|
250
|
+
* - Returns an unmount() function that calls __clarity_cleanup__() and removes the node
|
|
251
|
+
*
|
|
252
|
+
* Usage:
|
|
253
|
+
* const unmount = mount(Counter, document.getElementById('app'))
|
|
254
|
+
* unmount() // later: removes component, disposes all effects
|
|
255
|
+
*/
|
|
256
|
+
export function mount(ComponentFn, container, props = {}) {
|
|
257
|
+
if (!container) throw new Error(`[Clarity] mount: container element not found`);
|
|
258
|
+
container.innerHTML = '';
|
|
259
|
+
const el = ComponentFn(props);
|
|
260
|
+
|
|
261
|
+
if (!(el instanceof Node)) {
|
|
262
|
+
console.error('[Clarity] mount: component must return a DOM Node');
|
|
263
|
+
return () => {};
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
container.appendChild(el);
|
|
267
|
+
|
|
268
|
+
// Register in AI discovery registry
|
|
269
|
+
if (el.__clarity_ai__) _aiRegistry.add(el);
|
|
270
|
+
|
|
271
|
+
// Run onMount hooks across the whole inserted subtree (nested components too)
|
|
272
|
+
_runMountHooks(el);
|
|
273
|
+
|
|
274
|
+
// Return unmount function for cleanup
|
|
275
|
+
return function unmount() {
|
|
276
|
+
_aiRegistry.delete(el);
|
|
277
|
+
_runCleanupHooks(el);
|
|
278
|
+
if (el.parentNode) {
|
|
279
|
+
el.parentNode.removeChild(el);
|
|
280
|
+
}
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* @internal — invoke __clarity_mount__ on a node and every descendant, once each.
|
|
286
|
+
* mount() and the pages router call this so nested components' onMount runs.
|
|
287
|
+
*/
|
|
288
|
+
export function _runMountHooks(root) {
|
|
289
|
+
if (!root) return;
|
|
290
|
+
const nodes = (typeof root.querySelectorAll === 'function')
|
|
291
|
+
? [root, ...root.querySelectorAll('*')]
|
|
292
|
+
: [root];
|
|
293
|
+
for (const n of nodes) {
|
|
294
|
+
if (typeof n.__clarity_mount__ === 'function' && !n.__clarity_mounted__) {
|
|
295
|
+
n.__clarity_mounted__ = true;
|
|
296
|
+
if (n.__clarity_ai__) _aiRegistry.add(n);
|
|
297
|
+
try { n.__clarity_mount__(); } catch (e) { console.error('[Clarity onMount]', e); }
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* @internal — invoke __clarity_cleanup__ on a node and every descendant, once each.
|
|
304
|
+
*/
|
|
305
|
+
export function _runCleanupHooks(root) {
|
|
306
|
+
if (!root) return;
|
|
307
|
+
const nodes = (typeof root.querySelectorAll === 'function')
|
|
308
|
+
? [root, ...root.querySelectorAll('*')]
|
|
309
|
+
: [root];
|
|
310
|
+
for (const n of nodes) {
|
|
311
|
+
if (typeof n.__clarity_cleanup__ === 'function' && !n.__clarity_cleaned__) {
|
|
312
|
+
n.__clarity_cleaned__ = true;
|
|
313
|
+
_aiRegistry.delete(n);
|
|
314
|
+
try { n.__clarity_cleanup__(); } catch (e) { console.error('[Clarity onCleanup]', e); }
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* Create a DOM element with reactive bindings.
|
|
321
|
+
* This is what the code generator produces — never write it manually.
|
|
322
|
+
*
|
|
323
|
+
* Usage (generated code):
|
|
324
|
+
* const el = h('div', { class: 'card' }, [child1, child2])
|
|
325
|
+
*/
|
|
326
|
+
export function h(tag, props = {}, children = []) {
|
|
327
|
+
const el = document.createElement(tag);
|
|
328
|
+
|
|
329
|
+
for (const [key, value] of Object.entries(props)) {
|
|
330
|
+
if (key.startsWith('on:')) {
|
|
331
|
+
// Event binding: on:click, on:input, on:change, etc.
|
|
332
|
+
const eventName = key.slice(3);
|
|
333
|
+
el.addEventListener(eventName, typeof value === 'function' ? value : () => value);
|
|
334
|
+
} else if (key === 'class') {
|
|
335
|
+
// Reactive class
|
|
336
|
+
if (typeof value === 'function') {
|
|
337
|
+
effect(() => { el.className = value(); });
|
|
338
|
+
} else {
|
|
339
|
+
el.className = value;
|
|
340
|
+
}
|
|
341
|
+
} else if (key === 'style' && typeof value === 'object') {
|
|
342
|
+
// Style object
|
|
343
|
+
Object.assign(el.style, value);
|
|
344
|
+
} else if (key.startsWith('bind:')) {
|
|
345
|
+
// Two-way binding: bind:value, bind:checked
|
|
346
|
+
const attr = key.slice(5);
|
|
347
|
+
const sig = value;
|
|
348
|
+
// Read: update element when signal changes
|
|
349
|
+
effect(() => { el[attr] = sig.get(); });
|
|
350
|
+
// Write: update signal when element changes
|
|
351
|
+
const eventName = attr === 'checked' ? 'change' : 'input';
|
|
352
|
+
el.addEventListener(eventName, () => {
|
|
353
|
+
sig.set(attr === 'checked' ? el.checked : el.value);
|
|
354
|
+
});
|
|
355
|
+
} else if (key === 'ref') {
|
|
356
|
+
// Ref: expose the DOM node
|
|
357
|
+
if (typeof value === 'function') value(el);
|
|
358
|
+
else if (value && '_type' in value) value.set(el);
|
|
359
|
+
} else {
|
|
360
|
+
// Static or reactive attribute
|
|
361
|
+
if (typeof value === 'function') {
|
|
362
|
+
effect(() => {
|
|
363
|
+
const v = value();
|
|
364
|
+
if (v === false || v === null || v === undefined) {
|
|
365
|
+
el.removeAttribute(key);
|
|
366
|
+
} else if (v === true) {
|
|
367
|
+
el.setAttribute(key, '');
|
|
368
|
+
} else {
|
|
369
|
+
el.setAttribute(key, v);
|
|
370
|
+
}
|
|
371
|
+
});
|
|
372
|
+
} else {
|
|
373
|
+
el.setAttribute(key, value);
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
for (const child of children) {
|
|
379
|
+
appendChild(el, child);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
return el;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* Append a child to an element — handles text, signals, nodes, and arrays.
|
|
387
|
+
*/
|
|
388
|
+
export function appendChild(parent, child) {
|
|
389
|
+
if (child === null || child === undefined || child === false) return;
|
|
390
|
+
|
|
391
|
+
if (Array.isArray(child)) {
|
|
392
|
+
child.forEach(c => appendChild(parent, c));
|
|
393
|
+
return;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
if (child instanceof Node) {
|
|
397
|
+
parent.appendChild(child);
|
|
398
|
+
return;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
if (typeof child === 'object' && child._type === 'signal') {
|
|
402
|
+
// Reactive text node
|
|
403
|
+
const textNode = document.createTextNode(String(child.get()));
|
|
404
|
+
effect(() => { textNode.textContent = String(child.get()); });
|
|
405
|
+
parent.appendChild(textNode);
|
|
406
|
+
return;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
if (typeof child === 'function') {
|
|
410
|
+
// Reactive content — could return different nodes
|
|
411
|
+
const anchor = document.createComment('clarity:reactive');
|
|
412
|
+
parent.appendChild(anchor);
|
|
413
|
+
let currentNodes = [];
|
|
414
|
+
effect(() => {
|
|
415
|
+
const result = child();
|
|
416
|
+
// Remove old nodes
|
|
417
|
+
currentNodes.forEach(n => n.parentNode?.removeChild(n));
|
|
418
|
+
currentNodes = [];
|
|
419
|
+
// Add new
|
|
420
|
+
if (result instanceof Node) {
|
|
421
|
+
anchor.parentNode.insertBefore(result, anchor.nextSibling);
|
|
422
|
+
currentNodes = [result];
|
|
423
|
+
} else if (result !== null && result !== undefined && result !== false) {
|
|
424
|
+
const textNode = document.createTextNode(String(result));
|
|
425
|
+
anchor.parentNode.insertBefore(textNode, anchor.nextSibling);
|
|
426
|
+
currentNodes = [textNode];
|
|
427
|
+
}
|
|
428
|
+
});
|
|
429
|
+
return;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
// Static text
|
|
433
|
+
parent.appendChild(document.createTextNode(String(child)));
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/**
|
|
437
|
+
* Conditional rendering — like a ternary but for DOM nodes.
|
|
438
|
+
*
|
|
439
|
+
* Usage:
|
|
440
|
+
* when(() => isLoggedIn.get(), () => h('div', {}, ['Welcome!']), () => h('div', {}, ['Please log in']))
|
|
441
|
+
*/
|
|
442
|
+
export function when(conditionFn, thenFn, elseFn = null) {
|
|
443
|
+
const anchor = document.createComment('clarity:when');
|
|
444
|
+
|
|
445
|
+
// We need a container; return a function that resolves post-mount
|
|
446
|
+
// In practice, the code generator wraps this in appendChild
|
|
447
|
+
return () => {
|
|
448
|
+
const parent = anchor.parentNode;
|
|
449
|
+
if (!parent) return anchor;
|
|
450
|
+
|
|
451
|
+
let currentNode = null;
|
|
452
|
+
effect(() => {
|
|
453
|
+
if (currentNode) {
|
|
454
|
+
parent.removeChild(currentNode);
|
|
455
|
+
currentNode = null;
|
|
456
|
+
}
|
|
457
|
+
if (conditionFn()) {
|
|
458
|
+
currentNode = thenFn();
|
|
459
|
+
parent.insertBefore(currentNode, anchor.nextSibling);
|
|
460
|
+
} else if (elseFn) {
|
|
461
|
+
currentNode = elseFn();
|
|
462
|
+
parent.insertBefore(currentNode, anchor.nextSibling);
|
|
463
|
+
}
|
|
464
|
+
});
|
|
465
|
+
|
|
466
|
+
return anchor;
|
|
467
|
+
};
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/**
|
|
471
|
+
* Reactive list rendering.
|
|
472
|
+
*
|
|
473
|
+
* Usage:
|
|
474
|
+
* list(items, item => h('li', {}, [item.name.get()]))
|
|
475
|
+
*/
|
|
476
|
+
export function list(itemsSignal, renderFn, keyFn = (item, i) => i) {
|
|
477
|
+
const anchor = document.createComment('clarity:list');
|
|
478
|
+
const nodeMap = new Map();
|
|
479
|
+
|
|
480
|
+
return () => {
|
|
481
|
+
const parent = anchor.parentNode;
|
|
482
|
+
if (!parent) return anchor;
|
|
483
|
+
|
|
484
|
+
effect(() => {
|
|
485
|
+
const items = itemsSignal.get();
|
|
486
|
+
const newKeys = items.map(keyFn);
|
|
487
|
+
|
|
488
|
+
// Remove nodes no longer in list
|
|
489
|
+
for (const [key, node] of nodeMap) {
|
|
490
|
+
if (!newKeys.includes(key)) {
|
|
491
|
+
parent.removeChild(node);
|
|
492
|
+
nodeMap.delete(key);
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
// Insert/reorder nodes
|
|
497
|
+
let refNode = anchor.nextSibling;
|
|
498
|
+
for (let i = 0; i < items.length; i++) {
|
|
499
|
+
const key = keyFn(items[i], i);
|
|
500
|
+
let node = nodeMap.get(key);
|
|
501
|
+
|
|
502
|
+
if (!node) {
|
|
503
|
+
node = renderFn(items[i], i);
|
|
504
|
+
nodeMap.set(key, node);
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
if (node !== refNode) {
|
|
508
|
+
parent.insertBefore(node, refNode);
|
|
509
|
+
} else {
|
|
510
|
+
refNode = refNode.nextSibling;
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
});
|
|
514
|
+
|
|
515
|
+
return anchor;
|
|
516
|
+
};
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
// ─── Internal Helpers ────────────────────────────────────────────────────────
|
|
520
|
+
function _notify(subscribers) {
|
|
521
|
+
// Snapshot before iterating — subscribers may be removed+re-added during _run()
|
|
522
|
+
// (e.g. _cleanup removes, then re-read re-adds). Without snapshot, the Set
|
|
523
|
+
// iterator revisits re-added items causing an infinite loop.
|
|
524
|
+
const snapshot = [...subscribers];
|
|
525
|
+
for (const sub of snapshot) {
|
|
526
|
+
if (_batchDepth > 0) {
|
|
527
|
+
_pendingEffects.add(sub);
|
|
528
|
+
} else {
|
|
529
|
+
sub._run();
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
function _cleanup(eff) {
|
|
535
|
+
if (eff._cleanup) {
|
|
536
|
+
eff._cleanup();
|
|
537
|
+
eff._cleanup = null;
|
|
538
|
+
}
|
|
539
|
+
for (const dep of eff._deps) {
|
|
540
|
+
if (dep._subscribers) dep._subscribers.delete(eff);
|
|
541
|
+
}
|
|
542
|
+
eff._deps.clear();
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
// ─── Lifecycle Cleanup Helper ────────────────────────────────────────────────
|
|
546
|
+
/**
|
|
547
|
+
* Recursively calls __clarity_cleanup__ on a node and its children.
|
|
548
|
+
*
|
|
549
|
+
* Used by <when> and <list> to dispose effect subscriptions when nodes
|
|
550
|
+
* are removed from the DOM. Without this, effects keep running even after
|
|
551
|
+
* the component is gone — a silent memory / CPU leak.
|
|
552
|
+
*
|
|
553
|
+
* Handles DocumentFragment by iterating its childNodes before removal,
|
|
554
|
+
* since fragment.childNodes is live and empties once the fragment is inserted.
|
|
555
|
+
*/
|
|
556
|
+
export function _callCleanup(node) {
|
|
557
|
+
if (!node) return;
|
|
558
|
+
if (typeof node.__clarity_cleanup__ === 'function') {
|
|
559
|
+
node.__clarity_cleanup__();
|
|
560
|
+
}
|
|
561
|
+
// DocumentFragment: iterate children (they're moved to DOM on appendChild/insertBefore,
|
|
562
|
+
// so we check childNodes while they still belong to the fragment)
|
|
563
|
+
if (node.nodeType === 11 /* DOCUMENT_FRAGMENT_NODE */) {
|
|
564
|
+
const kids = [...node.childNodes]; // snapshot — live collection mutates on insert
|
|
565
|
+
kids.forEach(child => _callCleanup(child));
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
// ─── Lazy / Suspense ──────────────────────────────────────────────────────────
|
|
570
|
+
//
|
|
571
|
+
// Suspense context stack — the innermost <Suspense> component pushes a context
|
|
572
|
+
// object here. lazy() components check the top of the stack and register their
|
|
573
|
+
// load-promises with it so <Suspense> knows when all children are loaded.
|
|
574
|
+
const _suspenseStack = [];
|
|
575
|
+
|
|
576
|
+
/** @internal — used by generated <Suspense> code */
|
|
577
|
+
export function _pushSuspense(ctx) { _suspenseStack.push(ctx); }
|
|
578
|
+
/** @internal */
|
|
579
|
+
export function _popSuspense() { _suspenseStack.pop(); }
|
|
580
|
+
/** @internal — called by lazy() when inside a <Suspense> boundary */
|
|
581
|
+
export function _registerLazy(promise) {
|
|
582
|
+
if (_suspenseStack.length > 0) {
|
|
583
|
+
_suspenseStack[_suspenseStack.length - 1]._pending.push(promise);
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
/**
|
|
588
|
+
* Lazily loads a component from a dynamic import.
|
|
589
|
+
*
|
|
590
|
+
* Usage:
|
|
591
|
+
* import { lazy } from './clarity-runtime.js'
|
|
592
|
+
* const HeavyChart = lazy(() => import('./HeavyChart.clarity'))
|
|
593
|
+
*
|
|
594
|
+
* // In a component render:
|
|
595
|
+
* <Suspense fallback="Loading chart…">
|
|
596
|
+
* <HeavyChart data={myData} />
|
|
597
|
+
* </Suspense>
|
|
598
|
+
*
|
|
599
|
+
* // Or without Suspense (self-managed):
|
|
600
|
+
* <HeavyChart data={myData} /> // shows a comment while loading, then swaps in
|
|
601
|
+
*
|
|
602
|
+
* The return value is a component function — it can be passed to
|
|
603
|
+
* <Component is={...} /> for dynamic dispatch too.
|
|
604
|
+
*/
|
|
605
|
+
export function lazy(importFn) {
|
|
606
|
+
const _loaded = signal(null); // null while loading; component fn when ready
|
|
607
|
+
let _errored = false;
|
|
608
|
+
|
|
609
|
+
// Start loading immediately
|
|
610
|
+
const _promise = importFn().then(mod => {
|
|
611
|
+
const comp = mod.default ?? Object.values(mod)[0];
|
|
612
|
+
// Delay one tick so DOM is settled before any reactive swaps fire
|
|
613
|
+
setTimeout(() => _loaded.set(comp), 0);
|
|
614
|
+
}).catch(err => {
|
|
615
|
+
console.error('[Clarity lazy] Failed to load component:', err);
|
|
616
|
+
_errored = true;
|
|
617
|
+
setTimeout(() => _loaded.set(() => {
|
|
618
|
+
const el = document.createElement('span');
|
|
619
|
+
el.style.cssText = 'color:#f87171;font-size:0.8em;padding:4px 8px;';
|
|
620
|
+
el.textContent = `[lazy load failed: ${err.message}]`;
|
|
621
|
+
return el;
|
|
622
|
+
}), 0);
|
|
623
|
+
});
|
|
624
|
+
|
|
625
|
+
const LazyWrapper = function LazyComponent(props = {}) {
|
|
626
|
+
// Already loaded — render immediately, no overhead
|
|
627
|
+
const comp = _loaded.peek();
|
|
628
|
+
if (comp) return comp(props);
|
|
629
|
+
|
|
630
|
+
// Register with nearest Suspense boundary (if any)
|
|
631
|
+
const insideSuspense = _suspenseStack.length > 0;
|
|
632
|
+
if (insideSuspense) _registerLazy(_promise);
|
|
633
|
+
|
|
634
|
+
// Return a comment placeholder; swap it with the real component when loaded
|
|
635
|
+
const placeholder = document.createComment('clarity:lazy');
|
|
636
|
+
|
|
637
|
+
// Self-managed swap: fires after setTimeout inside _promise .then
|
|
638
|
+
_promise.then(() => {
|
|
639
|
+
// Extra setTimeout so we're always one tick after the _loaded.set() call above
|
|
640
|
+
setTimeout(() => {
|
|
641
|
+
if (placeholder.parentNode) {
|
|
642
|
+
const c = _loaded.peek();
|
|
643
|
+
if (c) {
|
|
644
|
+
const el = c(props);
|
|
645
|
+
placeholder.parentNode.insertBefore(el, placeholder);
|
|
646
|
+
placeholder.parentNode.removeChild(placeholder);
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
}, 0);
|
|
650
|
+
});
|
|
651
|
+
|
|
652
|
+
return placeholder;
|
|
653
|
+
};
|
|
654
|
+
|
|
655
|
+
LazyWrapper._clarityLazy = true;
|
|
656
|
+
LazyWrapper._loaded = _loaded;
|
|
657
|
+
LazyWrapper._promise = _promise;
|
|
658
|
+
return LazyWrapper;
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
// ─── Context API ─────────────────────────────────────────────────────────────
|
|
662
|
+
/**
|
|
663
|
+
* Context API — provides a way to pass data through the component tree
|
|
664
|
+
* without passing props manually at every level (equivalent to React Context
|
|
665
|
+
* or Vue provide/inject).
|
|
666
|
+
*
|
|
667
|
+
* Usage:
|
|
668
|
+
* // 1. Create a context (usually in its own file)
|
|
669
|
+
* export const ThemeCtx = createContext('light')
|
|
670
|
+
*
|
|
671
|
+
* // 2. Provide a value with <Provider ctx={ThemeCtx} value={theme}> in .clarity
|
|
672
|
+
* // The codegen emits _pushContext / _popContext around the children.
|
|
673
|
+
*
|
|
674
|
+
* // 3. Consume in any descendant component
|
|
675
|
+
* const theme = useContext(ThemeCtx) // 'light' | 'dark' | or a Signal
|
|
676
|
+
*
|
|
677
|
+
* If `value` passed to Provider is a Signal, useContext returns that signal's
|
|
678
|
+
* current value AND subscribes the calling effect to future changes.
|
|
679
|
+
*
|
|
680
|
+
* Dynamic scoping: context is pushed/popped during the synchronous initial
|
|
681
|
+
* render. Subsequent reactive reads go through the signal returned by the
|
|
682
|
+
* Provider and stay reactive automatically.
|
|
683
|
+
*/
|
|
684
|
+
const _ctxRegistry = new Map(); // Symbol → any[]
|
|
685
|
+
|
|
686
|
+
/**
|
|
687
|
+
* Creates a context token with an optional default value.
|
|
688
|
+
* @param {*} defaultValue — returned by useContext() when no Provider wraps the caller
|
|
689
|
+
* @returns {{ id: symbol, _type: 'context', _default: * }}
|
|
690
|
+
*/
|
|
691
|
+
export function createContext(defaultValue) {
|
|
692
|
+
const id = Symbol('clarity:context');
|
|
693
|
+
_ctxRegistry.set(id, []);
|
|
694
|
+
return { id, _type: 'context', _default: defaultValue };
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
/** @internal — emitted by <Provider ctx={} value={}> codegen */
|
|
698
|
+
export function _pushContext(ctx, value) {
|
|
699
|
+
const stack = _ctxRegistry.get(ctx.id);
|
|
700
|
+
if (stack) stack.push(value);
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
/** @internal — emitted by <Provider> codegen after children render */
|
|
704
|
+
export function _popContext(ctx) {
|
|
705
|
+
const stack = _ctxRegistry.get(ctx.id);
|
|
706
|
+
if (stack && stack.length > 0) stack.pop();
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
/**
|
|
710
|
+
* Reads the nearest provided context value.
|
|
711
|
+
*
|
|
712
|
+
* If the value on the stack is a Signal, its .get() is called so the
|
|
713
|
+
* current effect subscribes to future changes.
|
|
714
|
+
*
|
|
715
|
+
* @param {{ id: symbol, _default: * }} ctx — context token from createContext()
|
|
716
|
+
* @returns {*} current context value
|
|
717
|
+
*/
|
|
718
|
+
export function useContext(ctx) {
|
|
719
|
+
const stack = _ctxRegistry.get(ctx.id);
|
|
720
|
+
const raw = (stack && stack.length > 0) ? stack[stack.length - 1] : ctx._default;
|
|
721
|
+
// Unwrap signal — subscriber will re-run when the signal changes
|
|
722
|
+
if (raw !== null && raw !== undefined && typeof raw === 'object' && raw._type === 'signal') {
|
|
723
|
+
return raw.get();
|
|
724
|
+
}
|
|
725
|
+
return raw;
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
// ─── reactive() ──────────────────────────────────────────────────────────────
|
|
729
|
+
/**
|
|
730
|
+
* Creates a reactive proxy around a plain object.
|
|
731
|
+
* Every own enumerable key is backed by a signal so reading a property
|
|
732
|
+
* inside an effect creates a subscription and writing triggers re-renders.
|
|
733
|
+
*
|
|
734
|
+
* Equivalent to Vue 3's reactive().
|
|
735
|
+
*
|
|
736
|
+
* Usage:
|
|
737
|
+
* const state = reactive({ count: 0, name: 'Clarity' })
|
|
738
|
+
* effect(() => console.log(state.count)) // logs 0
|
|
739
|
+
* state.count++ // logs 1
|
|
740
|
+
*
|
|
741
|
+
* Notes:
|
|
742
|
+
* • Deep nesting is NOT tracked — nested objects require their own reactive()
|
|
743
|
+
* • Adding new keys after creation creates a new signal for that key
|
|
744
|
+
* • Symbol keys are passed through to the target without signal wrapping
|
|
745
|
+
*
|
|
746
|
+
* @param {object} obj — plain object to make reactive
|
|
747
|
+
* @returns {Proxy}
|
|
748
|
+
*/
|
|
749
|
+
export function reactive(obj) {
|
|
750
|
+
if (obj === null || typeof obj !== 'object') {
|
|
751
|
+
throw new Error('[Clarity reactive] argument must be a plain object');
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
const _signals = {};
|
|
755
|
+
// Seed signals from initial keys
|
|
756
|
+
for (const key of Object.keys(obj)) {
|
|
757
|
+
_signals[key] = signal(obj[key]);
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
// Pending signals for keys read before they were set — keeps ownKeys clean
|
|
761
|
+
const _pending = {};
|
|
762
|
+
|
|
763
|
+
return new Proxy(obj, {
|
|
764
|
+
get(target, key, receiver) {
|
|
765
|
+
// Expose _signals for tooling (DevTools, spySignal)
|
|
766
|
+
if (key === '__clarity_signals__') return _signals;
|
|
767
|
+
if (typeof key === 'string') {
|
|
768
|
+
if (key in _signals) return _signals[key].get(); // tracked read
|
|
769
|
+
// Track reads of unknown keys via a pending signal so effects re-run
|
|
770
|
+
// when the key is later written via set().
|
|
771
|
+
if (!(key in _pending)) _pending[key] = signal(undefined);
|
|
772
|
+
return _pending[key].get();
|
|
773
|
+
}
|
|
774
|
+
return Reflect.get(target, key, receiver);
|
|
775
|
+
},
|
|
776
|
+
set(target, key, value) {
|
|
777
|
+
if (typeof key === 'string') {
|
|
778
|
+
if (key in _signals) {
|
|
779
|
+
_signals[key].set(value);
|
|
780
|
+
} else if (key in _pending) {
|
|
781
|
+
// Promote pending → real signal and notify subscribed effects
|
|
782
|
+
_pending[key].set(value);
|
|
783
|
+
_signals[key] = _pending[key];
|
|
784
|
+
delete _pending[key];
|
|
785
|
+
} else {
|
|
786
|
+
// Brand-new key with no prior reads
|
|
787
|
+
_signals[key] = signal(value);
|
|
788
|
+
}
|
|
789
|
+
return true;
|
|
790
|
+
}
|
|
791
|
+
return Reflect.set(target, key, value);
|
|
792
|
+
},
|
|
793
|
+
has(target, key) {
|
|
794
|
+
return (typeof key === 'string' && key in _signals) || key in target;
|
|
795
|
+
},
|
|
796
|
+
ownKeys() {
|
|
797
|
+
return Object.keys(_signals);
|
|
798
|
+
},
|
|
799
|
+
getOwnPropertyDescriptor(target, key) {
|
|
800
|
+
if (typeof key === 'string' && key in _signals) {
|
|
801
|
+
return { enumerable: true, configurable: true, writable: true, value: _signals[key].get() };
|
|
802
|
+
}
|
|
803
|
+
return Reflect.getOwnPropertyDescriptor(target, key);
|
|
804
|
+
},
|
|
805
|
+
});
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
// ─── Component lifecycle tracking ────────────────────────────────────────────
|
|
809
|
+
/**
|
|
810
|
+
* Internal: tracks the component currently being constructed so onMount /
|
|
811
|
+
* onCleanup registrations are attached to the right node.
|
|
812
|
+
*
|
|
813
|
+
* The codegen wraps each component body in _withComponent(() => { ... }) so
|
|
814
|
+
* that onMount() / onCleanup() calls inside the function body are captured.
|
|
815
|
+
*/
|
|
816
|
+
let _currentComponent = null;
|
|
817
|
+
|
|
818
|
+
/** @internal */
|
|
819
|
+
export function _withComponent(renderFn) {
|
|
820
|
+
const comp = { _befores: [], _mounts: [], _cleanups: [], _updates: [] };
|
|
821
|
+
const prev = _currentComponent;
|
|
822
|
+
_currentComponent = comp;
|
|
823
|
+
let node;
|
|
824
|
+
try {
|
|
825
|
+
node = renderFn();
|
|
826
|
+
} finally {
|
|
827
|
+
_currentComponent = prev;
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
// beforeMount: run synchronously now — the node has been rendered but is not
|
|
831
|
+
// yet inserted into the DOM (Vue's onBeforeMount semantics).
|
|
832
|
+
if (comp._befores.length) {
|
|
833
|
+
comp._befores.forEach(f => {
|
|
834
|
+
try { f(); } catch (e) { console.error('[Clarity beforeMount]', e); }
|
|
835
|
+
});
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
// Use duck-typing so this works in both browser and Node.js test environments.
|
|
839
|
+
// Any object with a numeric nodeType (real DOM node or test mock) is accepted.
|
|
840
|
+
const isNodeLike = node !== null && typeof node === 'object' && typeof node.nodeType === 'number';
|
|
841
|
+
if (isNodeLike) {
|
|
842
|
+
if (comp._mounts.length) {
|
|
843
|
+
const prevMount = node.__clarity_mount__;
|
|
844
|
+
node.__clarity_mount__ = () => {
|
|
845
|
+
if (typeof prevMount === 'function') prevMount();
|
|
846
|
+
comp._mounts.forEach(f => f());
|
|
847
|
+
};
|
|
848
|
+
}
|
|
849
|
+
if (comp._cleanups.length) {
|
|
850
|
+
const prevClean = node.__clarity_cleanup__;
|
|
851
|
+
node.__clarity_cleanup__ = () => {
|
|
852
|
+
if (typeof prevClean === 'function') prevClean();
|
|
853
|
+
comp._cleanups.forEach(f => f());
|
|
854
|
+
};
|
|
855
|
+
}
|
|
856
|
+
// onUpdate: schedule callbacks after every reactive update (not on first mount)
|
|
857
|
+
if (comp._updates.length) {
|
|
858
|
+
let _mounted = false;
|
|
859
|
+
const prevMount = node.__clarity_mount__;
|
|
860
|
+
node.__clarity_mount__ = () => {
|
|
861
|
+
if (typeof prevMount === 'function') prevMount();
|
|
862
|
+
// Mark as mounted — effects running after this point trigger onUpdate
|
|
863
|
+
Promise.resolve().then(() => { _mounted = true; });
|
|
864
|
+
};
|
|
865
|
+
// Attach update runner to node for the effect scheduler to call
|
|
866
|
+
node.__clarity_update__ = () => {
|
|
867
|
+
if (_mounted) comp._updates.forEach(f => { try { f(); } catch (e) { console.error('[Clarity onUpdate]', e); } });
|
|
868
|
+
};
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
return node;
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
// ─── createRef ────────────────────────────────────────────────────────────────
|
|
875
|
+
|
|
876
|
+
/**
|
|
877
|
+
* Create an imperative ref — a mutable container for a DOM node or any value.
|
|
878
|
+
*
|
|
879
|
+
* Unlike signals, refs do NOT trigger reactivity. They are used for:
|
|
880
|
+
* • Storing a reference to a DOM element (for focus, animations, measurements)
|
|
881
|
+
* • Holding mutable instance values that don't affect rendering
|
|
882
|
+
*
|
|
883
|
+
* Usage in a component:
|
|
884
|
+
* const inputRef = createRef();
|
|
885
|
+
*
|
|
886
|
+
* // In JSX:
|
|
887
|
+
* <input ref={inputRef} />
|
|
888
|
+
*
|
|
889
|
+
* // In onMount or an effect:
|
|
890
|
+
* onMount(() => inputRef.current?.focus());
|
|
891
|
+
*
|
|
892
|
+
* Usage as a callback ref:
|
|
893
|
+
* const inputRef = createRef();
|
|
894
|
+
* <input ref={(el) => inputRef.current = el} />
|
|
895
|
+
*
|
|
896
|
+
* @template T
|
|
897
|
+
* @param {T} [initialValue=null] — initial value (default: null)
|
|
898
|
+
* @returns {{ current: T | null, readonly __isRef: true }}
|
|
899
|
+
*/
|
|
900
|
+
export function createRef(initialValue = null) {
|
|
901
|
+
return {
|
|
902
|
+
current: initialValue,
|
|
903
|
+
__isRef: true,
|
|
904
|
+
};
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
/** Alias — same as createRef() */
|
|
908
|
+
export const useRef = createRef;
|
|
909
|
+
|
|
910
|
+
// ─── onUpdate ─────────────────────────────────────────────────────────────────
|
|
911
|
+
|
|
912
|
+
/**
|
|
913
|
+
* Register a callback to run after EACH reactive update of the component
|
|
914
|
+
* (but NOT on the initial mount — use onMount for that).
|
|
915
|
+
*
|
|
916
|
+
* Equivalent to React's useEffect with deps, or Vue's onUpdated hook.
|
|
917
|
+
*
|
|
918
|
+
* Must be called at the top level of a component function.
|
|
919
|
+
*
|
|
920
|
+
* @param {() => void} fn
|
|
921
|
+
*/
|
|
922
|
+
export function onUpdate(fn) {
|
|
923
|
+
if (_currentComponent) {
|
|
924
|
+
_currentComponent._updates = _currentComponent._updates ?? [];
|
|
925
|
+
_currentComponent._updates.push(fn);
|
|
926
|
+
} else if (typeof console !== 'undefined') {
|
|
927
|
+
console.warn('[Clarity onUpdate] called outside of a component render. Did you forget _withComponent()?');
|
|
928
|
+
}
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
/**
|
|
932
|
+
* Register a callback to run AFTER the component has rendered but BEFORE its
|
|
933
|
+
* root node is inserted into the DOM (equivalent to Vue's onBeforeMount).
|
|
934
|
+
*
|
|
935
|
+
* Runs synchronously: state is initialised and the DOM subtree exists, but it
|
|
936
|
+
* is not yet attached to the document — so measurements that require layout
|
|
937
|
+
* are not available (use onMount for those). Good for last-moment state setup,
|
|
938
|
+
* reading initial props, or kicking off data loads before paint.
|
|
939
|
+
*
|
|
940
|
+
* Must be called at the top level of a component function.
|
|
941
|
+
*
|
|
942
|
+
* @param {() => void} fn
|
|
943
|
+
*/
|
|
944
|
+
export function beforeMount(fn) {
|
|
945
|
+
if (_currentComponent) {
|
|
946
|
+
_currentComponent._befores = _currentComponent._befores ?? [];
|
|
947
|
+
_currentComponent._befores.push(fn);
|
|
948
|
+
} else if (typeof console !== 'undefined') {
|
|
949
|
+
console.warn('[Clarity beforeMount] called outside of a component render. Did you forget _withComponent()?');
|
|
950
|
+
}
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
/**
|
|
954
|
+
* Register a callback to run after the component's root node is inserted
|
|
955
|
+
* into the DOM (equivalent to React useEffect(fn, []) or Vue onMounted).
|
|
956
|
+
*
|
|
957
|
+
* Must be called at the top level of a component function, NOT inside an
|
|
958
|
+
* effect or async callback.
|
|
959
|
+
*
|
|
960
|
+
* @param {() => void} fn
|
|
961
|
+
*/
|
|
962
|
+
export function onMount(fn) {
|
|
963
|
+
if (_currentComponent) {
|
|
964
|
+
_currentComponent._mounts.push(fn);
|
|
965
|
+
} else if (typeof console !== 'undefined') {
|
|
966
|
+
console.warn('[Clarity onMount] called outside of a component render. Did you forget _withComponent()?');
|
|
967
|
+
}
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
/**
|
|
971
|
+
* Register a callback to run when the component is removed from the DOM
|
|
972
|
+
* (equivalent to the cleanup return from useEffect, or Vue onUnmounted).
|
|
973
|
+
*
|
|
974
|
+
* Must be called at the top level of a component function.
|
|
975
|
+
*
|
|
976
|
+
* @param {() => void} fn
|
|
977
|
+
*/
|
|
978
|
+
export function onCleanup(fn) {
|
|
979
|
+
if (_currentComponent) {
|
|
980
|
+
_currentComponent._cleanups.push(fn);
|
|
981
|
+
} else if (typeof console !== 'undefined') {
|
|
982
|
+
console.warn('[Clarity onCleanup] called outside of a component render. Did you forget _withComponent()?');
|
|
983
|
+
}
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
// ─── AI Audit Log ────────────────────────────────────────────────────────────
|
|
987
|
+
/**
|
|
988
|
+
* Append-only audit trail for every AI ↔ component interaction.
|
|
989
|
+
* Each entry is an immutable record; listeners are notified synchronously.
|
|
990
|
+
*
|
|
991
|
+
* Entry shape:
|
|
992
|
+
* { type: 'read'|'act'|'forbidden_write', component, field|action, args?, value?, timestamp }
|
|
993
|
+
*/
|
|
994
|
+
|
|
995
|
+
const _aiAuditLog = []; // all-time log (grows until clearAIAuditLog())
|
|
996
|
+
const _aiListeners = new Set(); // onAIAction subscribers
|
|
997
|
+
let _inAIAction = false; // true while act() is executing → guards forbidden writes
|
|
998
|
+
|
|
999
|
+
/** @internal — called by generated AI contract code on every read / act */
|
|
1000
|
+
export function _aiAuditRecord(entry) {
|
|
1001
|
+
const rec = Object.freeze({ ...entry, timestamp: Date.now() });
|
|
1002
|
+
_aiAuditLog.push(rec);
|
|
1003
|
+
_aiListeners.forEach(fn => { try { fn(rec); } catch { /* never throw from listener */ } });
|
|
1004
|
+
return rec;
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
/** @internal — set/clear the AI-action execution context */
|
|
1008
|
+
export function _setAIActionContext(val) { _inAIAction = Boolean(val); }
|
|
1009
|
+
/** @internal — read the AI-action context flag */
|
|
1010
|
+
export function _isInAIAction() { return _inAIAction; }
|
|
1011
|
+
|
|
1012
|
+
/**
|
|
1013
|
+
* Returns a copy of the full audit log.
|
|
1014
|
+
* @returns {ReadonlyArray<object>}
|
|
1015
|
+
*/
|
|
1016
|
+
export function getAIAuditLog() { return [..._aiAuditLog]; }
|
|
1017
|
+
|
|
1018
|
+
/**
|
|
1019
|
+
* Clears the in-memory audit log (does not affect future entries).
|
|
1020
|
+
*/
|
|
1021
|
+
export function clearAIAuditLog() { _aiAuditLog.length = 0; }
|
|
1022
|
+
|
|
1023
|
+
/**
|
|
1024
|
+
* Subscribe to real-time AI action events.
|
|
1025
|
+
* The listener is called synchronously for every read, act, or forbidden attempt.
|
|
1026
|
+
* @param {(entry: object) => void} fn
|
|
1027
|
+
* @returns {() => void} unsubscribe function
|
|
1028
|
+
*/
|
|
1029
|
+
export function onAIAction(fn) {
|
|
1030
|
+
_aiListeners.add(fn);
|
|
1031
|
+
return () => _aiListeners.delete(fn);
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
/**
|
|
1035
|
+
* Distinct error thrown when an AI agent (any code running inside an `act()`
|
|
1036
|
+
* call, or a contract read of a non-readable field) touches `ai:forbidden`
|
|
1037
|
+
* state. Catchable by type so hosts can handle agent violations specifically.
|
|
1038
|
+
*/
|
|
1039
|
+
export class ClarityAIForbiddenError extends Error {
|
|
1040
|
+
constructor(message, info = {}) {
|
|
1041
|
+
super(message);
|
|
1042
|
+
this.name = 'ClarityAIForbiddenError';
|
|
1043
|
+
this.component = info.component;
|
|
1044
|
+
this.field = info.field;
|
|
1045
|
+
this.kind = info.kind; // 'read' | 'write' | 'access'
|
|
1046
|
+
}
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
/**
|
|
1050
|
+
* @internal — record a forbidden-access violation in the audit log and throw.
|
|
1051
|
+
* Used by `_protectedSignal` (runtime guard) and by the generated contract
|
|
1052
|
+
* `read()` method. Never returns.
|
|
1053
|
+
*/
|
|
1054
|
+
export function _aiThrowForbidden(componentName, fieldName, kind = 'access') {
|
|
1055
|
+
_aiAuditRecord({ type: 'forbidden_' + kind, component: componentName, field: fieldName });
|
|
1056
|
+
throw new ClarityAIForbiddenError(
|
|
1057
|
+
`[Clarity AI] Forbidden: an agent attempted to ${kind} '${fieldName}' on ${componentName}. ` +
|
|
1058
|
+
`This field is declared ai:forbidden. Declare it ai:readable / ai:actionable, or do not access it.`,
|
|
1059
|
+
{ component: componentName, field: fieldName, kind }
|
|
1060
|
+
);
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
/**
|
|
1064
|
+
* Wraps a signal declared `ai:forbidden` so that BOTH reads and writes are
|
|
1065
|
+
* blocked while inside an AI `act()` call (i.e. while _inAIAction === true).
|
|
1066
|
+
*
|
|
1067
|
+
* Normal component, user, and effect code is completely unaffected — the guard
|
|
1068
|
+
* only fires in AI-action context. Together with the contract (which never
|
|
1069
|
+
* exposes forbidden fields through `readable`/`snapshot`/`read`), this makes
|
|
1070
|
+
* `ai:forbidden` a real, enforced boundary rather than a declaration.
|
|
1071
|
+
*
|
|
1072
|
+
* @param {object} sig — original signal created by signal()
|
|
1073
|
+
* @param {string} componentName — component name for error / audit messages
|
|
1074
|
+
* @param {string} fieldName — signal name for error / audit messages
|
|
1075
|
+
*/
|
|
1076
|
+
export function _protectedSignal(sig, componentName, fieldName) {
|
|
1077
|
+
return {
|
|
1078
|
+
get: () => { if (_inAIAction) _aiThrowForbidden(componentName, fieldName, 'read'); return sig.get(); },
|
|
1079
|
+
peek: () => { if (_inAIAction) _aiThrowForbidden(componentName, fieldName, 'read'); return sig.peek(); },
|
|
1080
|
+
set: (v) => { if (_inAIAction) _aiThrowForbidden(componentName, fieldName, 'write'); return sig.set(v); },
|
|
1081
|
+
update: (fn) => { if (_inAIAction) _aiThrowForbidden(componentName, fieldName, 'write'); return sig.update(fn); },
|
|
1082
|
+
_type: 'signal',
|
|
1083
|
+
_forbidden: true,
|
|
1084
|
+
_component: componentName,
|
|
1085
|
+
_field: fieldName,
|
|
1086
|
+
};
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1089
|
+
// ─── Shared duck-typing helper ────────────────────────────────────────────────
|
|
1090
|
+
// Avoids `instanceof Node` which is only available in browser environments.
|
|
1091
|
+
// Used by ErrorBoundary and (via hydrate.js) by hydrateRoot.
|
|
1092
|
+
function _isNodeLike(val) {
|
|
1093
|
+
return val !== null && typeof val === 'object' && typeof val.nodeType === 'number';
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
// ─── AI Registry helper (used by hydrate.js) ─────────────────────────────────
|
|
1097
|
+
/** @internal — allows hydrate.js to register adopted elements without circular import */
|
|
1098
|
+
export function _aiRegistryAdd(el) { _aiRegistry.add(el); }
|
|
1099
|
+
|
|
1100
|
+
// ─── Error Boundary ───────────────────────────────────────────────────────────
|
|
1101
|
+
/**
|
|
1102
|
+
* ErrorBoundary — catches errors thrown during child component rendering and
|
|
1103
|
+
* displays a fallback UI instead of crashing the whole application.
|
|
1104
|
+
*
|
|
1105
|
+
* Equivalent to React's class-based componentDidCatch / getDerivedStateFromError,
|
|
1106
|
+
* but implemented as a plain Clarity component function.
|
|
1107
|
+
*
|
|
1108
|
+
* Usage:
|
|
1109
|
+
*
|
|
1110
|
+
* mount(
|
|
1111
|
+
* () => ErrorBoundary({
|
|
1112
|
+
* children: () => MyComponent({ data }),
|
|
1113
|
+
* fallback: (err) => h('div', { class: 'error-box' }, [err.message]),
|
|
1114
|
+
* onError: (err) => console.error('[App] render error:', err),
|
|
1115
|
+
* }),
|
|
1116
|
+
* document.getElementById('app')
|
|
1117
|
+
* );
|
|
1118
|
+
*
|
|
1119
|
+
* In .clarity files (once the compiler supports it):
|
|
1120
|
+
*
|
|
1121
|
+
* <ErrorBoundary fallback={<p>Something went wrong.</p>}>
|
|
1122
|
+
* <MyComponent />
|
|
1123
|
+
* </ErrorBoundary>
|
|
1124
|
+
*
|
|
1125
|
+
* @param {object} options
|
|
1126
|
+
* @param {Function|Node} options.children - Component factory or pre-rendered node
|
|
1127
|
+
* @param {Function|Node} [options.fallback] - Fallback: function(Error)→Node, or static Node
|
|
1128
|
+
* @param {Function} [options.onError] - Called with the Error when one is caught
|
|
1129
|
+
* @returns {Element}
|
|
1130
|
+
*/
|
|
1131
|
+
export function ErrorBoundary({ children, fallback, onError } = {}) {
|
|
1132
|
+
const _error = signal(null);
|
|
1133
|
+
const _wrapper = document.createElement('div');
|
|
1134
|
+
_wrapper.setAttribute('data-clarity-boundary', '');
|
|
1135
|
+
|
|
1136
|
+
// ── Helper: clear wrapper children safely (works in browser + test env) ──────
|
|
1137
|
+
function _clearWrapper() {
|
|
1138
|
+
if (typeof _wrapper.replaceChildren === 'function') {
|
|
1139
|
+
_wrapper.replaceChildren(); // Modern browsers
|
|
1140
|
+
} else {
|
|
1141
|
+
_wrapper.innerHTML = '';
|
|
1142
|
+
// For test stubs that don't implement innerHTML setter:
|
|
1143
|
+
if (Array.isArray(_wrapper.children)) _wrapper.children.length = 0;
|
|
1144
|
+
}
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
// ── Helper: render the fallback UI ──────────────────────────────────────────
|
|
1148
|
+
function _showFallback(err) {
|
|
1149
|
+
_clearWrapper();
|
|
1150
|
+
let fallbackNode;
|
|
1151
|
+
|
|
1152
|
+
if (typeof fallback === 'function') {
|
|
1153
|
+
try {
|
|
1154
|
+
fallbackNode = fallback(err);
|
|
1155
|
+
} catch (fallbackErr) {
|
|
1156
|
+
// Fallback itself threw — show a minimal default
|
|
1157
|
+
console.error('[Clarity ErrorBoundary] fallback threw:', fallbackErr);
|
|
1158
|
+
fallbackNode = _defaultErrorUI(err);
|
|
1159
|
+
}
|
|
1160
|
+
} else if (_isNodeLike(fallback)) {
|
|
1161
|
+
fallbackNode = fallback;
|
|
1162
|
+
} else {
|
|
1163
|
+
fallbackNode = _defaultErrorUI(err);
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
if (_isNodeLike(fallbackNode)) {
|
|
1167
|
+
_wrapper.appendChild(fallbackNode);
|
|
1168
|
+
}
|
|
1169
|
+
}
|
|
1170
|
+
|
|
1171
|
+
// ── Helper: render the child content ────────────────────────────────────────
|
|
1172
|
+
function _showContent() {
|
|
1173
|
+
_clearWrapper();
|
|
1174
|
+
try {
|
|
1175
|
+
let content;
|
|
1176
|
+
if (typeof children === 'function') {
|
|
1177
|
+
content = children();
|
|
1178
|
+
} else {
|
|
1179
|
+
content = children;
|
|
1180
|
+
}
|
|
1181
|
+
if (_isNodeLike(content)) {
|
|
1182
|
+
_wrapper.appendChild(content);
|
|
1183
|
+
} else if (content != null) {
|
|
1184
|
+
_wrapper.appendChild(document.createTextNode(String(content)));
|
|
1185
|
+
}
|
|
1186
|
+
} catch (err) {
|
|
1187
|
+
// Report the error
|
|
1188
|
+
if (typeof onError === 'function') {
|
|
1189
|
+
try { onError(err); } catch { /* never let onError crash the boundary */ }
|
|
1190
|
+
}
|
|
1191
|
+
_error.set(err);
|
|
1192
|
+
_showFallback(err);
|
|
1193
|
+
}
|
|
1194
|
+
}
|
|
1195
|
+
|
|
1196
|
+
// ── Initial render ───────────────────────────────────────────────────────────
|
|
1197
|
+
_showContent();
|
|
1198
|
+
|
|
1199
|
+
// ── reset() — allows parent code to retry rendering after fixing the error ──
|
|
1200
|
+
_wrapper.reset = function () {
|
|
1201
|
+
_error.set(null);
|
|
1202
|
+
_showContent();
|
|
1203
|
+
};
|
|
1204
|
+
|
|
1205
|
+
// ── Expose current error (null when healthy) ─────────────────────────────────
|
|
1206
|
+
_wrapper.getError = function () { return _error.peek(); };
|
|
1207
|
+
|
|
1208
|
+
return _wrapper;
|
|
1209
|
+
}
|
|
1210
|
+
|
|
1211
|
+
/**
|
|
1212
|
+
* @internal — default error UI shown when no fallback is provided
|
|
1213
|
+
*/
|
|
1214
|
+
function _defaultErrorUI(err) {
|
|
1215
|
+
const box = document.createElement('div');
|
|
1216
|
+
box.setAttribute('role', 'alert');
|
|
1217
|
+
box.setAttribute('data-clarity-default-error', '');
|
|
1218
|
+
box.style.cssText = [
|
|
1219
|
+
'padding:12px 16px',
|
|
1220
|
+
'background:#fef2f2',
|
|
1221
|
+
'border:1.5px solid #fecaca',
|
|
1222
|
+
'border-radius:6px',
|
|
1223
|
+
'color:#991b1b',
|
|
1224
|
+
'font-family:ui-monospace,monospace',
|
|
1225
|
+
'font-size:13px',
|
|
1226
|
+
'line-height:1.5',
|
|
1227
|
+
].join(';');
|
|
1228
|
+
|
|
1229
|
+
const title = document.createElement('strong');
|
|
1230
|
+
title.textContent = '⚠ Component Error';
|
|
1231
|
+
title.style.cssText = 'display:block;margin-bottom:4px';
|
|
1232
|
+
|
|
1233
|
+
const msg = document.createElement('span');
|
|
1234
|
+
msg.textContent = err instanceof Error ? err.message : String(err);
|
|
1235
|
+
|
|
1236
|
+
// Stack trace (only in development)
|
|
1237
|
+
const isDev = typeof process !== 'undefined'
|
|
1238
|
+
? (process.env?.NODE_ENV !== 'production')
|
|
1239
|
+
: (typeof location !== 'undefined' && location.hostname === 'localhost');
|
|
1240
|
+
|
|
1241
|
+
if (isDev && err instanceof Error && err.stack) {
|
|
1242
|
+
const pre = document.createElement('pre');
|
|
1243
|
+
pre.style.cssText = 'margin-top:8px;font-size:11px;overflow:auto;white-space:pre-wrap;opacity:0.7';
|
|
1244
|
+
pre.textContent = err.stack;
|
|
1245
|
+
box.appendChild(title);
|
|
1246
|
+
box.appendChild(msg);
|
|
1247
|
+
box.appendChild(pre);
|
|
1248
|
+
} else {
|
|
1249
|
+
box.appendChild(title);
|
|
1250
|
+
box.appendChild(msg);
|
|
1251
|
+
}
|
|
1252
|
+
|
|
1253
|
+
return box;
|
|
1254
|
+
}
|
|
1255
|
+
|
|
1256
|
+
// ─── Portal ───────────────────────────────────────────────────────────────────
|
|
1257
|
+
/**
|
|
1258
|
+
* Renders `children` into a DOM node that exists outside the component's
|
|
1259
|
+
* own DOM hierarchy — equivalent to React's `createPortal`.
|
|
1260
|
+
*
|
|
1261
|
+
* Primary use-cases: modals, drawers, tooltips, toasts, dropdowns.
|
|
1262
|
+
*
|
|
1263
|
+
* Usage in .clarity files:
|
|
1264
|
+
*
|
|
1265
|
+
* import { createPortal } from '@ozsarman/clarityjs/runtime';
|
|
1266
|
+
*
|
|
1267
|
+
* component Modal(open: Boolean) {
|
|
1268
|
+
* render {
|
|
1269
|
+
* <when cond={open}>
|
|
1270
|
+
* { createPortal(
|
|
1271
|
+
* <div class="modal-backdrop">...</div>,
|
|
1272
|
+
* document.body
|
|
1273
|
+
* )
|
|
1274
|
+
* }
|
|
1275
|
+
* </when>
|
|
1276
|
+
* }
|
|
1277
|
+
* }
|
|
1278
|
+
*
|
|
1279
|
+
* Or use the <Portal to="body"> JSX directive (compiler sugar):
|
|
1280
|
+
*
|
|
1281
|
+
* <Portal to="body">
|
|
1282
|
+
* <div class="modal">Hello</div>
|
|
1283
|
+
* </Portal>
|
|
1284
|
+
*
|
|
1285
|
+
* Returns an anchor comment node that acts as a placeholder in the original
|
|
1286
|
+
* tree. Cleanup removes the portal children from the target element.
|
|
1287
|
+
*
|
|
1288
|
+
* @param {Node | Node[] | Function} children - Node(s) or factory function
|
|
1289
|
+
* @param {Element | string} [target] - Target element or CSS selector (default: document.body)
|
|
1290
|
+
* @returns {Comment} - Anchor comment in the original tree (for unmount tracking)
|
|
1291
|
+
*/
|
|
1292
|
+
export function createPortal(children, target) {
|
|
1293
|
+
// Resolve target
|
|
1294
|
+
const resolveTarget = () => {
|
|
1295
|
+
if (!target) return document.body;
|
|
1296
|
+
if (typeof target === 'string') {
|
|
1297
|
+
return document.querySelector(target) ?? document.body;
|
|
1298
|
+
}
|
|
1299
|
+
return target;
|
|
1300
|
+
};
|
|
1301
|
+
|
|
1302
|
+
const anchor = document.createComment('clarity:portal');
|
|
1303
|
+
|
|
1304
|
+
// Collect portal nodes
|
|
1305
|
+
let portalNodes = [];
|
|
1306
|
+
|
|
1307
|
+
function _mount() {
|
|
1308
|
+
const dest = resolveTarget();
|
|
1309
|
+
|
|
1310
|
+
// Resolve children
|
|
1311
|
+
let nodes = [];
|
|
1312
|
+
if (Array.isArray(children)) {
|
|
1313
|
+
nodes = children.flat();
|
|
1314
|
+
} else if (typeof children === 'function') {
|
|
1315
|
+
const result = children();
|
|
1316
|
+
nodes = Array.isArray(result) ? result.flat() : [result];
|
|
1317
|
+
} else if (children instanceof Node) {
|
|
1318
|
+
nodes = [children];
|
|
1319
|
+
} else if (children != null) {
|
|
1320
|
+
nodes = [document.createTextNode(String(children))];
|
|
1321
|
+
}
|
|
1322
|
+
|
|
1323
|
+
for (const node of nodes) {
|
|
1324
|
+
if (node instanceof Node) {
|
|
1325
|
+
dest.appendChild(node);
|
|
1326
|
+
portalNodes.push(node);
|
|
1327
|
+
}
|
|
1328
|
+
}
|
|
1329
|
+
}
|
|
1330
|
+
|
|
1331
|
+
function _unmount() {
|
|
1332
|
+
for (const node of portalNodes) {
|
|
1333
|
+
node.parentNode?.removeChild(node);
|
|
1334
|
+
}
|
|
1335
|
+
portalNodes = [];
|
|
1336
|
+
}
|
|
1337
|
+
|
|
1338
|
+
// Attach cleanup to the anchor so the parent component's onCleanup fires it
|
|
1339
|
+
anchor.__clarity_cleanup__ = _unmount;
|
|
1340
|
+
|
|
1341
|
+
// Schedule mount after the current render cycle (so the anchor is in the DOM)
|
|
1342
|
+
queueMicrotask(_mount);
|
|
1343
|
+
|
|
1344
|
+
return anchor;
|
|
1345
|
+
}
|
|
1346
|
+
|
|
1347
|
+
// ─── Suspense ─────────────────────────────────────────────────────────────────
|
|
1348
|
+
/**
|
|
1349
|
+
* Suspense boundary — wraps lazy-loaded children and shows a fallback while
|
|
1350
|
+
* they are loading.
|
|
1351
|
+
*
|
|
1352
|
+
* Usage (component function):
|
|
1353
|
+
*
|
|
1354
|
+
* import { Suspense, lazy } from '@ozsarman/clarityjs/runtime';
|
|
1355
|
+
* const HeavyChart = lazy(() => import('./HeavyChart.clarity'));
|
|
1356
|
+
*
|
|
1357
|
+
* render {
|
|
1358
|
+
* <Suspense fallback={ <Spinner /> }>
|
|
1359
|
+
* <HeavyChart data={data} />
|
|
1360
|
+
* </Suspense>
|
|
1361
|
+
* }
|
|
1362
|
+
*
|
|
1363
|
+
* @param {object} options
|
|
1364
|
+
* @param {Function | Node} options.children - Component factory or node
|
|
1365
|
+
* @param {Function | Node} [options.fallback] - Shown while loading
|
|
1366
|
+
* @returns {Element}
|
|
1367
|
+
*/
|
|
1368
|
+
export function Suspense({ children, fallback } = {}) {
|
|
1369
|
+
const wrapper = document.createElement('div');
|
|
1370
|
+
wrapper.setAttribute('data-clarity-suspense', '');
|
|
1371
|
+
const _pending = [];
|
|
1372
|
+
|
|
1373
|
+
const ctx = { _pending };
|
|
1374
|
+
_pushSuspense(ctx);
|
|
1375
|
+
|
|
1376
|
+
try {
|
|
1377
|
+
// Render children (lazy() calls inside register their promises)
|
|
1378
|
+
let content;
|
|
1379
|
+
if (typeof children === 'function') {
|
|
1380
|
+
content = children();
|
|
1381
|
+
} else {
|
|
1382
|
+
content = children;
|
|
1383
|
+
}
|
|
1384
|
+
|
|
1385
|
+
if (_isNodeLike(content)) {
|
|
1386
|
+
wrapper.appendChild(content);
|
|
1387
|
+
}
|
|
1388
|
+
} catch (err) {
|
|
1389
|
+
console.error('[Clarity Suspense] child threw during render', err);
|
|
1390
|
+
} finally {
|
|
1391
|
+
_popSuspense();
|
|
1392
|
+
}
|
|
1393
|
+
|
|
1394
|
+
// If any lazy children registered pending promises, show fallback first
|
|
1395
|
+
if (_pending.length > 0) {
|
|
1396
|
+
// Show fallback
|
|
1397
|
+
wrapper.innerHTML = '';
|
|
1398
|
+
let fallbackNode;
|
|
1399
|
+
if (typeof fallback === 'function') {
|
|
1400
|
+
try { fallbackNode = fallback(); } catch {}
|
|
1401
|
+
} else if (_isNodeLike(fallback)) {
|
|
1402
|
+
fallbackNode = fallback;
|
|
1403
|
+
} else if (fallback != null) {
|
|
1404
|
+
fallbackNode = document.createTextNode(String(fallback));
|
|
1405
|
+
}
|
|
1406
|
+
|
|
1407
|
+
if (fallbackNode) wrapper.appendChild(fallbackNode);
|
|
1408
|
+
|
|
1409
|
+
// When all promises resolve, swap in the real content
|
|
1410
|
+
Promise.all(_pending).then(() => {
|
|
1411
|
+
wrapper.innerHTML = '';
|
|
1412
|
+
let content;
|
|
1413
|
+
if (typeof children === 'function') {
|
|
1414
|
+
try { content = children(); } catch {}
|
|
1415
|
+
} else {
|
|
1416
|
+
content = children;
|
|
1417
|
+
}
|
|
1418
|
+
if (_isNodeLike(content)) wrapper.appendChild(content);
|
|
1419
|
+
}).catch(err => {
|
|
1420
|
+
console.error('[Clarity Suspense] lazy load failed', err);
|
|
1421
|
+
});
|
|
1422
|
+
}
|
|
1423
|
+
|
|
1424
|
+
return wrapper;
|
|
1425
|
+
}
|
|
1426
|
+
|
|
1427
|
+
// ─── TypeScript Types ─────────────────────────────────────────────────────────
|
|
1428
|
+
// These are exported so the TypeGenerator can reference them in .d.ts files.
|
|
1429
|
+
// They mirror the shape of signal() and computed() return values.
|
|
1430
|
+
|
|
1431
|
+
/**
|
|
1432
|
+
* @template T
|
|
1433
|
+
* @typedef {{ get(): T, set(v: T): void, update(fn: (v:T)=>T): void, peek(): T }} Signal
|
|
1434
|
+
*/
|
|
1435
|
+
|
|
1436
|
+
/**
|
|
1437
|
+
* @template T
|
|
1438
|
+
* @typedef {{ get(): T, peek(): T }} Computed
|
|
1439
|
+
*/
|
|
1440
|
+
|
|
1441
|
+
// ─── Dev Tools ───────────────────────────────────────────────────────────────
|
|
1442
|
+
/**
|
|
1443
|
+
* Expose internals to developer tools and AI assistants.
|
|
1444
|
+
* LLM-friendly: structured, inspectable, auditable.
|
|
1445
|
+
*/
|
|
1446
|
+
export const __dev__ = {
|
|
1447
|
+
version: '0.0.1',
|
|
1448
|
+
inspect(sig) {
|
|
1449
|
+
if (sig._type === 'signal') {
|
|
1450
|
+
return {
|
|
1451
|
+
type: 'signal',
|
|
1452
|
+
value: sig.peek(),
|
|
1453
|
+
subscriberCount: sig._subscribers.size,
|
|
1454
|
+
};
|
|
1455
|
+
}
|
|
1456
|
+
if (sig._type === 'computed') {
|
|
1457
|
+
return {
|
|
1458
|
+
type: 'computed',
|
|
1459
|
+
value: sig.peek(),
|
|
1460
|
+
subscriberCount: sig._subscribers.size,
|
|
1461
|
+
};
|
|
1462
|
+
}
|
|
1463
|
+
return { type: 'unknown' };
|
|
1464
|
+
},
|
|
1465
|
+
};
|