@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
|
@@ -0,0 +1,555 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Clarity.js — Async State Management
|
|
3
|
+
*
|
|
4
|
+
* Reactive data fetching with cache, stale-while-revalidate, optimistic updates,
|
|
5
|
+
* and automatic deduplication. Inspired by TanStack Query / SWR but built
|
|
6
|
+
* natively on Clarity signals.
|
|
7
|
+
*
|
|
8
|
+
* Quick reference:
|
|
9
|
+
*
|
|
10
|
+
* const users = createQuery(() => fetch('/api/users').then(r => r.json()))
|
|
11
|
+
*
|
|
12
|
+
* // In a component render:
|
|
13
|
+
* users.data.get() // current data (or undefined while loading)
|
|
14
|
+
* users.loading.get() // true while the first fetch is in-flight
|
|
15
|
+
* users.error.get() // Error | null
|
|
16
|
+
* users.stale.get() // true when showing cached data, refetch in progress
|
|
17
|
+
*
|
|
18
|
+
* users.refetch() // manual refetch
|
|
19
|
+
* users.mutate(newData, { revalidate: true }) // optimistic update + sync
|
|
20
|
+
* users.invalidate() // mark cache stale and trigger background refetch
|
|
21
|
+
*
|
|
22
|
+
* Author: Claude (Anthropic) + Özdemir Sarman
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { signal, effect, batch } from './runtime.js';
|
|
26
|
+
|
|
27
|
+
// ─── Global query cache ───────────────────────────────────────────────────────
|
|
28
|
+
// Key → { data, error, timestamp, subscribers, inFlight }
|
|
29
|
+
|
|
30
|
+
const _cache = new Map();
|
|
31
|
+
const _DEFAULT_STALE_TIME = 30_000; // 30 s
|
|
32
|
+
const _DEFAULT_CACHE_TIME = 5 * 60_000; // 5 min
|
|
33
|
+
const _DEFAULT_RETRY = 3;
|
|
34
|
+
const _DEFAULT_RETRY_DELAY = (attempt) => Math.min(1000 * 2 ** attempt, 30_000);
|
|
35
|
+
|
|
36
|
+
// ─── QueryState ───────────────────────────────────────────────────────────────
|
|
37
|
+
|
|
38
|
+
class QueryState {
|
|
39
|
+
constructor(key, fetchFn, options) {
|
|
40
|
+
this._key = key;
|
|
41
|
+
this._fetchFn = fetchFn;
|
|
42
|
+
this._opts = options;
|
|
43
|
+
|
|
44
|
+
// Public signals (readable from components)
|
|
45
|
+
this.data = signal(undefined);
|
|
46
|
+
this.error = signal(null);
|
|
47
|
+
this.loading = signal(false);
|
|
48
|
+
this.stale = signal(false);
|
|
49
|
+
|
|
50
|
+
// Internal
|
|
51
|
+
this._inFlight = false;
|
|
52
|
+
this._retryCount = 0;
|
|
53
|
+
this._lastFetched = 0;
|
|
54
|
+
this._gcTimer = null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// ── Core fetch logic ────────────────────────────────────────────────────────
|
|
58
|
+
|
|
59
|
+
async fetch({ force = false } = {}) {
|
|
60
|
+
const opts = this._opts;
|
|
61
|
+
|
|
62
|
+
// Deduplicate: if already in-flight, skip
|
|
63
|
+
if (this._inFlight && !force) return;
|
|
64
|
+
|
|
65
|
+
// Stale-while-revalidate: use cached data if fresh
|
|
66
|
+
const now = Date.now();
|
|
67
|
+
const staleMs = opts.staleTime ?? _DEFAULT_STALE_TIME;
|
|
68
|
+
const isFresh = this._lastFetched > 0 && (now - this._lastFetched) < staleMs;
|
|
69
|
+
|
|
70
|
+
if (isFresh && !force) return; // still fresh, no need to refetch
|
|
71
|
+
|
|
72
|
+
// If we have data but it's stale, mark as stale (SWR)
|
|
73
|
+
if (this.data.peek() !== undefined) {
|
|
74
|
+
this.stale.set(true);
|
|
75
|
+
} else {
|
|
76
|
+
this.loading.set(true);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
this._inFlight = true;
|
|
80
|
+
this._retryCount = 0;
|
|
81
|
+
|
|
82
|
+
await this._fetchWithRetry();
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async _fetchWithRetry() {
|
|
86
|
+
const opts = this._opts;
|
|
87
|
+
const maxRetry = opts.retry ?? _DEFAULT_RETRY;
|
|
88
|
+
const delay = opts.retryDelay ?? _DEFAULT_RETRY_DELAY;
|
|
89
|
+
|
|
90
|
+
while (this._retryCount <= maxRetry) {
|
|
91
|
+
try {
|
|
92
|
+
const result = await this._fetchFn(this._key);
|
|
93
|
+
|
|
94
|
+
// Success — update signals atomically
|
|
95
|
+
batch(() => {
|
|
96
|
+
this.data.set(result);
|
|
97
|
+
this.error.set(null);
|
|
98
|
+
this.loading.set(false);
|
|
99
|
+
this.stale.set(false);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
this._lastFetched = Date.now();
|
|
103
|
+
this._inFlight = false;
|
|
104
|
+
this._retryCount = 0;
|
|
105
|
+
|
|
106
|
+
// Notify cache
|
|
107
|
+
_cache.set(this._key, {
|
|
108
|
+
data: result,
|
|
109
|
+
timestamp: this._lastFetched,
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
// Schedule garbage collection
|
|
113
|
+
this._scheduleGC();
|
|
114
|
+
return;
|
|
115
|
+
|
|
116
|
+
} catch (err) {
|
|
117
|
+
this._retryCount++;
|
|
118
|
+
|
|
119
|
+
if (this._retryCount > maxRetry) {
|
|
120
|
+
// All retries exhausted
|
|
121
|
+
batch(() => {
|
|
122
|
+
this.error.set(err);
|
|
123
|
+
this.loading.set(false);
|
|
124
|
+
this.stale.set(false);
|
|
125
|
+
});
|
|
126
|
+
this._inFlight = false;
|
|
127
|
+
|
|
128
|
+
opts.onError?.(err, this._key);
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Wait before retry
|
|
133
|
+
const waitMs = typeof delay === 'function'
|
|
134
|
+
? delay(this._retryCount)
|
|
135
|
+
: delay;
|
|
136
|
+
|
|
137
|
+
await _sleep(waitMs);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// ── Public API ──────────────────────────────────────────────────────────────
|
|
143
|
+
|
|
144
|
+
/** Force a refetch regardless of cache freshness. */
|
|
145
|
+
refetch() {
|
|
146
|
+
return this.fetch({ force: true });
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Optimistic update — set data immediately, optionally revalidate.
|
|
151
|
+
*
|
|
152
|
+
* @param {* | ((current: *) => *)} updater - New value or updater function
|
|
153
|
+
* @param {{ revalidate?: boolean }} options
|
|
154
|
+
*/
|
|
155
|
+
mutate(updater, { revalidate = false } = {}) {
|
|
156
|
+
const current = this.data.peek();
|
|
157
|
+
const next = typeof updater === 'function' ? updater(current) : updater;
|
|
158
|
+
this.data.set(next);
|
|
159
|
+
|
|
160
|
+
_cache.set(this._key, { data: next, timestamp: Date.now() });
|
|
161
|
+
|
|
162
|
+
if (revalidate) {
|
|
163
|
+
// Sync in background
|
|
164
|
+
this.fetch({ force: true }).catch(err => {
|
|
165
|
+
console.error('[Clarity createQuery] revalidate after mutate failed:', err);
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Mark as stale — triggers a background refetch on next read or immediately.
|
|
172
|
+
*/
|
|
173
|
+
invalidate() {
|
|
174
|
+
this._lastFetched = 0;
|
|
175
|
+
if (this._opts.invalidateImmediately !== false) {
|
|
176
|
+
this.fetch({ force: false });
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Cancel any pending in-flight request (best-effort via AbortController). */
|
|
181
|
+
cancel() {
|
|
182
|
+
this._inFlight = false;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** Clean up timers and listeners. */
|
|
186
|
+
dispose() {
|
|
187
|
+
if (this._gcTimer) clearTimeout(this._gcTimer);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// ── Internals ───────────────────────────────────────────────────────────────
|
|
191
|
+
|
|
192
|
+
_scheduleGC() {
|
|
193
|
+
const cacheTime = this._opts.cacheTime ?? _DEFAULT_CACHE_TIME;
|
|
194
|
+
if (this._gcTimer) clearTimeout(this._gcTimer);
|
|
195
|
+
this._gcTimer = setTimeout(() => {
|
|
196
|
+
_cache.delete(this._key);
|
|
197
|
+
}, cacheTime);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// ─── createQuery ─────────────────────────────────────────────────────────────
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Create a reactive, cached, auto-fetching data source.
|
|
205
|
+
*
|
|
206
|
+
* @param {string | (() => string)} key - Cache key (string or reactive function)
|
|
207
|
+
* @param {(key: string) => Promise<*>} fetchFn - Async data fetcher
|
|
208
|
+
* @param {CreateQueryOptions} [options]
|
|
209
|
+
* @returns {QueryState}
|
|
210
|
+
*
|
|
211
|
+
* @typedef {object} CreateQueryOptions
|
|
212
|
+
* @property {boolean} [enabled=true] - Set false to disable auto-fetch
|
|
213
|
+
* @property {number} [staleTime=30000] - Ms until cached data is stale
|
|
214
|
+
* @property {number} [cacheTime=300000] - Ms to keep data in cache
|
|
215
|
+
* @property {number} [retry=3] - Retry count on failure
|
|
216
|
+
* @property {number|Function} [retryDelay] - Ms or fn(attempt)→ms
|
|
217
|
+
* @property {boolean} [refetchOnWindowFocus=true] - Refetch on tab focus
|
|
218
|
+
* @property {number|false} [refetchInterval=false] - Polling interval (ms)
|
|
219
|
+
* @property {Function} [onSuccess] - (data, key) → void
|
|
220
|
+
* @property {Function} [onError] - (err, key) → void
|
|
221
|
+
* @property {*} [initialData] - Pre-seed data (skips first fetch)
|
|
222
|
+
* @property {boolean} [invalidateImmediately=true] - Fetch on invalidate()
|
|
223
|
+
*
|
|
224
|
+
* @example
|
|
225
|
+
* const todos = createQuery(
|
|
226
|
+
* 'todos',
|
|
227
|
+
* () => fetch('/api/todos').then(r => r.json()),
|
|
228
|
+
* { staleTime: 60_000 }
|
|
229
|
+
* );
|
|
230
|
+
*
|
|
231
|
+
* // Dynamic key (re-fetches when userId changes)
|
|
232
|
+
* const post = createQuery(
|
|
233
|
+
* () => `post/${userId.get()}`,
|
|
234
|
+
* (key) => fetch(`/api/${key}`).then(r => r.json())
|
|
235
|
+
* );
|
|
236
|
+
*/
|
|
237
|
+
export function createQuery(key, fetchFn, options = {}) {
|
|
238
|
+
// Support key-less API: createQuery(fetchFn, options)
|
|
239
|
+
if (typeof key === 'function' && typeof fetchFn !== 'function') {
|
|
240
|
+
options = fetchFn ?? {};
|
|
241
|
+
fetchFn = key;
|
|
242
|
+
key = null; // computed key
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const isReactiveKey = typeof key === 'function';
|
|
246
|
+
const resolvedKey = isReactiveKey ? key() : (key ?? crypto.randomUUID?.() ?? Math.random().toString(36));
|
|
247
|
+
|
|
248
|
+
const state = new QueryState(resolvedKey, fetchFn, options);
|
|
249
|
+
|
|
250
|
+
// Seed initial data if provided
|
|
251
|
+
if (options.initialData !== undefined) {
|
|
252
|
+
state.data.set(options.initialData);
|
|
253
|
+
state._lastFetched = Date.now();
|
|
254
|
+
} else {
|
|
255
|
+
// Restore from cache if available
|
|
256
|
+
const cached = _cache.get(resolvedKey);
|
|
257
|
+
if (cached) {
|
|
258
|
+
state.data.set(cached.data);
|
|
259
|
+
state._lastFetched = cached.timestamp;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// Auto-fetch if enabled
|
|
264
|
+
const enabled = options.enabled !== false;
|
|
265
|
+
if (enabled) {
|
|
266
|
+
// Defer to microtask so the component can finish rendering first
|
|
267
|
+
Promise.resolve().then(() => state.fetch());
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// Re-fetch when reactive key changes
|
|
271
|
+
if (isReactiveKey) {
|
|
272
|
+
effect(() => {
|
|
273
|
+
const newKey = key();
|
|
274
|
+
if (newKey !== state._key) {
|
|
275
|
+
state._key = newKey;
|
|
276
|
+
state._lastFetched = 0;
|
|
277
|
+
state.fetch({ force: false });
|
|
278
|
+
}
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// Polling support
|
|
283
|
+
if (options.refetchInterval && options.refetchInterval > 0) {
|
|
284
|
+
const intervalId = setInterval(() => {
|
|
285
|
+
state.fetch({ force: false });
|
|
286
|
+
}, options.refetchInterval);
|
|
287
|
+
|
|
288
|
+
// Return dispose function attached to state
|
|
289
|
+
const origDispose = state.dispose.bind(state);
|
|
290
|
+
state.dispose = () => {
|
|
291
|
+
clearInterval(intervalId);
|
|
292
|
+
origDispose();
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// Window focus refetch
|
|
297
|
+
if (options.refetchOnWindowFocus !== false &&
|
|
298
|
+
typeof window !== 'undefined' &&
|
|
299
|
+
typeof window.addEventListener === 'function') {
|
|
300
|
+
const onFocus = () => state.fetch({ force: false });
|
|
301
|
+
window.addEventListener('focus', onFocus);
|
|
302
|
+
const origDispose = state.dispose.bind(state);
|
|
303
|
+
state.dispose = () => {
|
|
304
|
+
window.removeEventListener('focus', onFocus);
|
|
305
|
+
origDispose();
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
return state;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// ─── useFetch ─────────────────────────────────────────────────────────────────
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* useFetch — Nuxt-style alias over createQuery().
|
|
316
|
+
*
|
|
317
|
+
* A familiar entry point for developers coming from Nuxt. It is a thin wrapper
|
|
318
|
+
* around createQuery() with one ergonomic addition: if the first argument is a
|
|
319
|
+
* string or URL, it is treated as a URL to fetch (JSON), so the common case
|
|
320
|
+
* needs no fetcher function at all.
|
|
321
|
+
*
|
|
322
|
+
* @example — URL shorthand (auto fetch + JSON parse)
|
|
323
|
+
* const posts = useFetch('/api/posts');
|
|
324
|
+
* // posts.data() · posts.loading() · posts.error() · posts.refetch()
|
|
325
|
+
*
|
|
326
|
+
* @example — Reactive URL (re-fetches when the signal changes)
|
|
327
|
+
* const post = useFetch(() => `/api/posts/${id.get()}`);
|
|
328
|
+
*
|
|
329
|
+
* @example — Custom fetcher / options (identical to createQuery)
|
|
330
|
+
* const users = useFetch('users', () => api.getUsers(), { staleTime: 60_000 });
|
|
331
|
+
*
|
|
332
|
+
* @param {string | URL | Function} keyOrUrl — URL string, reactive URL fn, or query key
|
|
333
|
+
* @param {Function | object} [fetchFn] — fetcher (defaults to fetch+JSON for URLs)
|
|
334
|
+
* @param {object} [options] — same options as createQuery()
|
|
335
|
+
* @returns {QueryState}
|
|
336
|
+
*/
|
|
337
|
+
export function useFetch(keyOrUrl, fetchFn, options = {}) {
|
|
338
|
+
// URL shorthand: useFetch('/api/x'[, options]) or useFetch(() => url[, options])
|
|
339
|
+
const looksLikeUrl =
|
|
340
|
+
typeof keyOrUrl === 'string' ||
|
|
341
|
+
keyOrUrl instanceof URL ||
|
|
342
|
+
(typeof keyOrUrl === 'function' && typeof fetchFn !== 'function');
|
|
343
|
+
|
|
344
|
+
if (looksLikeUrl && typeof fetchFn !== 'function') {
|
|
345
|
+
// Second arg (if any) is the options object.
|
|
346
|
+
options = fetchFn ?? {};
|
|
347
|
+
const urlOf = typeof keyOrUrl === 'function' ? keyOrUrl : () => String(keyOrUrl);
|
|
348
|
+
const key = typeof keyOrUrl === 'function' ? keyOrUrl : String(keyOrUrl);
|
|
349
|
+
const fetcher = async () => {
|
|
350
|
+
const res = await fetch(urlOf());
|
|
351
|
+
if (!res.ok) throw new Error(`[Clarity useFetch] HTTP ${res.status} for ${urlOf()}`);
|
|
352
|
+
const ct = res.headers?.get?.('content-type') ?? '';
|
|
353
|
+
return ct.includes('application/json') ? res.json() : res.text();
|
|
354
|
+
};
|
|
355
|
+
return createQuery(key, fetcher, options);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// Full pass-through to createQuery (key, fetchFn, options).
|
|
359
|
+
return createQuery(keyOrUrl, fetchFn, options);
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// ─── createMutation ───────────────────────────────────────────────────────────
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* Create a reactive mutation — for POST, PUT, DELETE operations.
|
|
366
|
+
* Supports TanStack Query-style optimistic updates with automatic rollback.
|
|
367
|
+
*
|
|
368
|
+
* @param {(variables: *) => Promise<*>} mutateFn — async operation
|
|
369
|
+
* @param {object} [options]
|
|
370
|
+
* @param {Function} [options.onMutate] — (variables) → Promise<context> | context
|
|
371
|
+
* Called before mutation fires.
|
|
372
|
+
* Return a context object for rollback.
|
|
373
|
+
* @param {Function} [options.optimistic] — (variables, currentData) → newData
|
|
374
|
+
* Instantly update data before server responds.
|
|
375
|
+
* Rolled back automatically on error.
|
|
376
|
+
* @param {Function} [options.onSuccess] — (data, variables, context) → void
|
|
377
|
+
* @param {Function} [options.onError] — (err, variables, context) → void
|
|
378
|
+
* Called with the rollback context.
|
|
379
|
+
* @param {Function} [options.onSettled] — (data|null, err|null, variables, context) → void
|
|
380
|
+
* @param {string | string[]} [options.invalidates] — query keys to invalidate on success
|
|
381
|
+
* @returns {{ mutate, mutateAsync, loading, error, data, reset, variables }}
|
|
382
|
+
*
|
|
383
|
+
* @example — Basic
|
|
384
|
+
* const addTodo = createMutation(
|
|
385
|
+
* (title) => fetch('/api/todos', { method: 'POST', body: JSON.stringify({ title }) }).then(r => r.json()),
|
|
386
|
+
* { onSuccess: () => todos.invalidate() }
|
|
387
|
+
* );
|
|
388
|
+
* addTodo.mutate('Buy milk');
|
|
389
|
+
*
|
|
390
|
+
* @example — Optimistic update
|
|
391
|
+
* const toggleTodo = createMutation(
|
|
392
|
+
* ({ id, done }) => fetch(`/api/todos/${id}`, { method: 'PATCH', body: JSON.stringify({ done }) }).then(r => r.json()),
|
|
393
|
+
* {
|
|
394
|
+
* optimistic: ({ id, done }, currentTodos) =>
|
|
395
|
+
* currentTodos.map(t => t.id === id ? { ...t, done } : t),
|
|
396
|
+
* onError: (err, vars, ctx) => {
|
|
397
|
+
* todos.mutate(ctx.previousData); // manual rollback if needed
|
|
398
|
+
* },
|
|
399
|
+
* }
|
|
400
|
+
* );
|
|
401
|
+
*
|
|
402
|
+
* @example — onMutate context
|
|
403
|
+
* const deleteTodo = createMutation(deleteApi, {
|
|
404
|
+
* onMutate: async (id) => {
|
|
405
|
+
* const prev = todos.data.peek();
|
|
406
|
+
* todos.mutate(prev.filter(t => t.id !== id)); // optimistic remove
|
|
407
|
+
* return { previousTodos: prev }; // rollback context
|
|
408
|
+
* },
|
|
409
|
+
* onError: (err, id, ctx) => {
|
|
410
|
+
* todos.mutate(ctx.previousTodos); // undo on failure
|
|
411
|
+
* },
|
|
412
|
+
* });
|
|
413
|
+
*/
|
|
414
|
+
export function createMutation(mutateFn, options = {}) {
|
|
415
|
+
const loading = signal(false);
|
|
416
|
+
const error = signal(null);
|
|
417
|
+
const data = signal(undefined);
|
|
418
|
+
const variables = signal(undefined); // last invocation variables
|
|
419
|
+
|
|
420
|
+
// Track optimistic snapshot for rollback
|
|
421
|
+
let _prevData = undefined;
|
|
422
|
+
|
|
423
|
+
/**
|
|
424
|
+
* Async version — returns Promise, throws on failure.
|
|
425
|
+
*/
|
|
426
|
+
async function mutateAsync(vars) {
|
|
427
|
+
variables.set(vars);
|
|
428
|
+
|
|
429
|
+
batch(() => {
|
|
430
|
+
loading.set(true);
|
|
431
|
+
error.set(null);
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
// ── onMutate (may return rollback context) ─────────────────────────────
|
|
435
|
+
let context;
|
|
436
|
+
try {
|
|
437
|
+
if (typeof options.onMutate === 'function') {
|
|
438
|
+
context = await options.onMutate(vars);
|
|
439
|
+
}
|
|
440
|
+
} catch (mutateErr) {
|
|
441
|
+
// onMutate failure aborts the mutation
|
|
442
|
+
batch(() => { error.set(mutateErr); loading.set(false); });
|
|
443
|
+
throw mutateErr;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
// ── Optimistic update ──────────────────────────────────────────────────
|
|
447
|
+
if (typeof options.optimistic === 'function') {
|
|
448
|
+
_prevData = data.peek();
|
|
449
|
+
data.set(options.optimistic(vars, _prevData));
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// ── Execute mutation ───────────────────────────────────────────────────
|
|
453
|
+
try {
|
|
454
|
+
const result = await mutateFn(vars);
|
|
455
|
+
|
|
456
|
+
batch(() => {
|
|
457
|
+
data.set(result);
|
|
458
|
+
loading.set(false);
|
|
459
|
+
});
|
|
460
|
+
_prevData = undefined;
|
|
461
|
+
|
|
462
|
+
// Callbacks
|
|
463
|
+
options.onSuccess?.(result, vars, context);
|
|
464
|
+
options.onSettled?.(result, null, vars, context);
|
|
465
|
+
|
|
466
|
+
// Invalidate related queries
|
|
467
|
+
if (options.invalidates) {
|
|
468
|
+
const keys = Array.isArray(options.invalidates) ? options.invalidates : [options.invalidates];
|
|
469
|
+
for (const k of keys) invalidateQuery(k);
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
return result;
|
|
473
|
+
|
|
474
|
+
} catch (err) {
|
|
475
|
+
// ── Rollback optimistic update ───────────────────────────────────────
|
|
476
|
+
if (typeof options.optimistic === 'function' && _prevData !== undefined) {
|
|
477
|
+
data.set(_prevData);
|
|
478
|
+
_prevData = undefined;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
batch(() => {
|
|
482
|
+
error.set(err);
|
|
483
|
+
loading.set(false);
|
|
484
|
+
});
|
|
485
|
+
|
|
486
|
+
options.onError?.(err, vars, context);
|
|
487
|
+
options.onSettled?.(null, err, vars, context);
|
|
488
|
+
|
|
489
|
+
throw err;
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
/**
|
|
494
|
+
* Trigger the mutation. Returns the underlying promise so callers may
|
|
495
|
+
* `await` it to read the resolved value; it rejects on failure (the error is
|
|
496
|
+
* ALSO captured in the `error` signal, so awaiting is optional for UI code
|
|
497
|
+
* that just renders from the signals).
|
|
498
|
+
*/
|
|
499
|
+
function mutate(vars) {
|
|
500
|
+
return mutateAsync(vars);
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
function reset() {
|
|
504
|
+
_prevData = undefined;
|
|
505
|
+
batch(() => {
|
|
506
|
+
loading.set(false);
|
|
507
|
+
error.set(null);
|
|
508
|
+
data.set(undefined);
|
|
509
|
+
variables.set(undefined);
|
|
510
|
+
});
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
return { mutate, mutateAsync, loading, error, data, variables, reset };
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
// ─── Cache utilities ──────────────────────────────────────────────────────────
|
|
517
|
+
|
|
518
|
+
/**
|
|
519
|
+
* Manually invalidate a cached query by key.
|
|
520
|
+
* @param {string} key
|
|
521
|
+
*/
|
|
522
|
+
export function invalidateQuery(key) {
|
|
523
|
+
_cache.delete(key);
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
/**
|
|
527
|
+
* Pre-populate the cache with data (useful for SSR → client handoff).
|
|
528
|
+
* @param {string} key
|
|
529
|
+
* @param {*} data
|
|
530
|
+
*/
|
|
531
|
+
export function prefetchQuery(key, data) {
|
|
532
|
+
_cache.set(key, { data, timestamp: Date.now() });
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
/**
|
|
536
|
+
* Clear the entire query cache.
|
|
537
|
+
*/
|
|
538
|
+
export function clearQueryCache() {
|
|
539
|
+
_cache.clear();
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
/**
|
|
543
|
+
* Read raw cache entry (useful for testing or SSR state transfer).
|
|
544
|
+
* @param {string} key
|
|
545
|
+
* @returns {{ data: *, timestamp: number } | undefined}
|
|
546
|
+
*/
|
|
547
|
+
export function getCacheEntry(key) {
|
|
548
|
+
return _cache.get(key);
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
552
|
+
|
|
553
|
+
function _sleep(ms) {
|
|
554
|
+
return new Promise(resolve => setTimeout(resolve, ms));
|
|
555
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bundles clarity-runtime.js into a browser-ready IIFE
|
|
3
|
+
* Output: dist/clarity-runtime.browser.js
|
|
4
|
+
* Usage: <script src="clarity-runtime.browser.js"></script>
|
|
5
|
+
* window.ClarityRuntime.signal(0) etc.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { readFileSync, writeFileSync, mkdirSync } from 'fs';
|
|
9
|
+
|
|
10
|
+
const runtimeSrc = readFileSync('./src/runtime.js', 'utf8');
|
|
11
|
+
|
|
12
|
+
// Strip ES module exports — convert to IIFE
|
|
13
|
+
const iife = `
|
|
14
|
+
// Clarity.js Runtime v0.0.1 — Browser Build
|
|
15
|
+
// AI-Native UI Framework by Claude (Anthropic)
|
|
16
|
+
(function(global) {
|
|
17
|
+
'use strict';
|
|
18
|
+
|
|
19
|
+
${runtimeSrc
|
|
20
|
+
.replace(/^export function /gm, 'function ')
|
|
21
|
+
.replace(/^export const /gm, 'const ')
|
|
22
|
+
.replace(/^export \{[^}]+\};?/gm, '')
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Expose to global scope
|
|
26
|
+
global.ClarityRuntime = {
|
|
27
|
+
signal, effect, computed, batch, mount, h, appendChild, when, list, __dev__
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
})(typeof window !== 'undefined' ? window : globalThis);
|
|
31
|
+
`.trim();
|
|
32
|
+
|
|
33
|
+
mkdirSync('./dist', { recursive: true });
|
|
34
|
+
writeFileSync('./dist/clarity-runtime.browser.js', iife, 'utf8');
|
|
35
|
+
console.log('✓ dist/clarity-runtime.browser.js written');
|