@spooky-sync/client-solid2 0.0.1-canary.200 → 0.0.1-canary.201

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.
@@ -0,0 +1,64 @@
1
+ import { createEffect, type Element } from 'solid-js';
2
+ import { decodeBlurhash } from '@spooky-sync/core';
3
+
4
+ export interface BlurhashProps {
5
+ /** The blurhash string. Nullish paints nothing (transparent canvas). */
6
+ hash: string | null | undefined;
7
+ /** Decode resolution. 32x32 is plenty: blurhash carries at most 9x9 DCT
8
+ * components, the canvas is meant to be CSS-scaled to fill. */
9
+ width?: number;
10
+ height?: number;
11
+ /** Contrast punch, see the blurhash reference decoder. Defaults to 1. */
12
+ punch?: number;
13
+ class?: string;
14
+ style?: string;
15
+ }
16
+
17
+ /**
18
+ * A blurhash painted onto a canvas, once per hash change. Size the canvas via
19
+ * `class`/`style` (e.g. `absolute inset-0 w-full h-full`); the internal decode
20
+ * resolution stays tiny regardless of the displayed size.
21
+ */
22
+ export function Blurhash(props: BlurhashProps): Element {
23
+ if (typeof document === 'undefined') return null;
24
+ const canvas = document.createElement('canvas');
25
+
26
+ createEffect(
27
+ () => props.class ?? '',
28
+ (className) => {
29
+ canvas.className = className;
30
+ }
31
+ );
32
+ createEffect(
33
+ () => props.style ?? '',
34
+ (style) => {
35
+ canvas.style.cssText = style;
36
+ }
37
+ );
38
+
39
+ createEffect(
40
+ () => ({
41
+ width: props.width ?? 32,
42
+ height: props.height ?? 32,
43
+ hash: props.hash,
44
+ punch: props.punch ?? 1,
45
+ }),
46
+ ({ width, height, hash, punch }) => {
47
+ canvas.width = width;
48
+ canvas.height = height;
49
+ if (!hash) return;
50
+ try {
51
+ const pixels = decodeBlurhash(hash, width, height, punch);
52
+ const ctx = canvas.getContext('2d');
53
+ if (!ctx) return;
54
+ const imageData = ctx.createImageData(width, height);
55
+ imageData.data.set(pixels);
56
+ ctx.putImageData(imageData, 0, 0);
57
+ } catch {
58
+ // An invalid hash paints nothing; the layer below stays visible.
59
+ }
60
+ }
61
+ );
62
+
63
+ return canvas as unknown as Element;
64
+ }
@@ -0,0 +1,170 @@
1
+ import { createSignal, createEffect, onSettled, children, type Element } from 'solid-js';
2
+ import type { BucketNames, SchemaStructure } from '@spooky-sync/query-builder';
3
+ import { useBucketImage, type UseBucketImageOptions } from './use-bucket-image';
4
+ import { Blurhash } from './Blurhash';
5
+
6
+ export interface BucketImageProps {
7
+ /** Bucket name from the schema. */
8
+ bucket: string;
9
+ /** Path within the bucket. Nullish renders only the fallback layers. */
10
+ path: string | null | undefined;
11
+ alt?: string;
12
+ /** Classes for the container element (sizing/positioning). */
13
+ class?: string;
14
+ /** Classes for the inner `<img>` (the layout styles are inline). */
15
+ imgClass?: string;
16
+ /** `object-fit` for the image. Defaults to `cover`. */
17
+ fit?: 'cover' | 'contain' | 'fill' | 'none' | 'scale-down';
18
+ /**
19
+ * Bottom placeholder layer (your own plate/skeleton), shown until the image
20
+ * settles. The blurhash layer paints on top of it once the sidecar resolves.
21
+ */
22
+ fallback?: Element;
23
+ /** Crossfade duration in ms. Defaults to 300. */
24
+ transition?: number;
25
+ /** Crossfade easing. Defaults to an ease-out-expo curve. */
26
+ easing?: string;
27
+ /** Resolve the blurhash sidecar. Defaults to true. */
28
+ blurhash?: boolean;
29
+ /** Download tuning, forwarded to the underlying `useDownloadFile`. */
30
+ options?: UseBucketImageOptions;
31
+ }
32
+
33
+ const LAYER_STYLE = 'position:absolute;inset:0;width:100%;height:100%;';
34
+
35
+ /**
36
+ * A bucket image that never pops in: it layers (bottom to top) your `fallback`
37
+ * plate, the automatically stored blurhash, and the real image, which stays
38
+ * transparent until the bitmap is DECODED and then crossfades over the
39
+ * placeholders. Placeholder layers unmount once the fade settles. Respects
40
+ * prefers-reduced-motion (instant swap). The container is made
41
+ * `position: relative` unless your `class` positions it already.
42
+ *
43
+ * ```tsx
44
+ * <BucketImage bucket="covers" path={row.cover_key} class="absolute inset-0"
45
+ * fallback={<MyPlate />} alt="" />
46
+ * ```
47
+ */
48
+ export function BucketImage(props: BucketImageProps): Element {
49
+ if (typeof document === 'undefined') return null;
50
+
51
+ const image = useBucketImage(
52
+ props.bucket as BucketNames<SchemaStructure>,
53
+ () => props.path,
54
+ { ...props.options, blurhash: props.blurhash !== false }
55
+ );
56
+
57
+ const reducedMotion =
58
+ typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches;
59
+
60
+ const root = document.createElement('div');
61
+ createEffect(
62
+ () => props.class ?? '',
63
+ (className) => {
64
+ root.className = className;
65
+ }
66
+ );
67
+ // Layers are absolutely positioned; give them an anchor without stomping on
68
+ // a caller class that already positions the container (inline would win).
69
+ onSettled(() => {
70
+ if (getComputedStyle(root).position === 'static') root.style.position = 'relative';
71
+ });
72
+
73
+ const placeholder = document.createElement('div');
74
+ placeholder.style.cssText = LAYER_STYLE;
75
+ // Solid hands JSX props over as lazy thunks; `children` resolves them (and
76
+ // nested arrays/functions) to real nodes and keeps them alive reactively.
77
+ const fallbackHolder = document.createElement('div');
78
+ fallbackHolder.style.cssText = LAYER_STYLE;
79
+ placeholder.append(fallbackHolder);
80
+ const resolvedFallback = children(() => props.fallback);
81
+ createEffect(
82
+ () => resolvedFallback.toArray().filter((node) => node instanceof Node) as Node[],
83
+ (nodes) => {
84
+ fallbackHolder.replaceChildren(...nodes);
85
+ }
86
+ );
87
+ const hashCanvas = Blurhash({
88
+ get hash() {
89
+ return image.blurhash();
90
+ },
91
+ style: LAYER_STYLE,
92
+ });
93
+ if (hashCanvas instanceof Node) placeholder.append(hashCanvas);
94
+
95
+ const img = document.createElement('img');
96
+ img.decoding = 'async';
97
+ img.style.cssText = `${LAYER_STYLE}opacity:0;`;
98
+ createEffect(
99
+ () => props.imgClass ?? '',
100
+ (className) => {
101
+ img.className = className;
102
+ }
103
+ );
104
+ createEffect(
105
+ () => props.fit ?? 'cover',
106
+ (fit) => {
107
+ img.style.objectFit = fit;
108
+ }
109
+ );
110
+ createEffect(
111
+ () => props.alt ?? '',
112
+ (alt) => {
113
+ img.alt = alt;
114
+ }
115
+ );
116
+ createEffect(
117
+ () =>
118
+ reducedMotion
119
+ ? 'none'
120
+ : `opacity ${props.transition ?? 300}ms ${props.easing ?? 'cubic-bezier(0.16, 1, 0.3, 1)'}`,
121
+ (transition) => {
122
+ img.style.transition = transition;
123
+ }
124
+ );
125
+ createEffect(
126
+ () => image.url(),
127
+ (url) => {
128
+ if (!url) {
129
+ img.removeAttribute('src');
130
+ return;
131
+ }
132
+ img.src = url;
133
+ image.gate(img);
134
+ }
135
+ );
136
+ createEffect(
137
+ () => image.ready(),
138
+ (ready) => {
139
+ img.style.opacity = ready ? '1' : '0';
140
+ }
141
+ );
142
+
143
+ // Placeholders leave the DOM once the fade is over (a shelf of covers should
144
+ // not composite three layers each forever) and come back when the path
145
+ // changes mid-life (`ready` re-arms via the hook). `settled` is written from
146
+ // the apply phase and a timer, outside any tracking scope.
147
+ const [settled, setSettled] = createSignal(false, { ownedWrite: true });
148
+ createEffect(
149
+ () => ({ ready: image.ready(), transition: props.transition ?? 300 }),
150
+ ({ ready, transition }) => {
151
+ if (!ready) {
152
+ setSettled(false);
153
+ return;
154
+ }
155
+ const wait = (reducedMotion ? 0 : transition) + 120;
156
+ const timer = setTimeout(() => setSettled(true), wait);
157
+ return () => clearTimeout(timer);
158
+ }
159
+ );
160
+ createEffect(
161
+ () => settled(),
162
+ (isSettled) => {
163
+ if (isSettled) placeholder.remove();
164
+ else if (!placeholder.isConnected) root.insertBefore(placeholder, img);
165
+ }
166
+ );
167
+
168
+ root.append(placeholder, img);
169
+ return root as unknown as Element;
170
+ }
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Probes against solid-js 2.0.0-rc.0 semantics this package's design depends
2
+ * Probes against solid-js 2.0.0-rc.1 semantics this package's design depends
3
3
  * on. If any of these fail after a Solid version bump, the binding's
4
4
  * assumptions are broken — fix the binding, don't delete the probe.
5
5
  *
@@ -337,16 +337,20 @@ describe('probe 5: class instances inside store rows', () => {
337
337
  const rid = rows[0].rid;
338
338
  // Solid 2 wraps class instances (isWrappable true — unlike Solid 1).
339
339
  expect(isWrappable(new FakeRecordId('t', 'i'))).toBe(true);
340
- // Document what survives through the proxy: methods are served BOUND, so
341
- // `constructor.name` gains a 'bound ' prefix. SyncedDb.delete's
342
- // cross-package RecordId detection must strip it.
343
- expect((rid as any).constructor?.name).toBe('bound FakeRecordId');
340
+ // Document what survives through the proxy. rc.0 served methods BOUND
341
+ // and `constructor.name` read 'bound FakeRecordId'; rc.1 hides the
342
+ // prototype entirely: no `instanceof`, no `constructor`, but own fields
343
+ // and prototype methods still resolve. SyncedDb.delete therefore
344
+ // unwraps with `snapshot` BEFORE any RecordId detection.
345
+ expect(rid instanceof FakeRecordId).toBe(false);
346
+ expect((rid as any).constructor?.name).toBeUndefined();
344
347
  expect(rid.toString()).toBe('game:a');
345
348
  expect(`${rid.tb}:${rid.id}`).toBe('game:a');
346
349
  // `snapshot` must unwrap back to the raw instance so payloads passed to
347
350
  // surrealdb (which checks instanceof) can be de-proxied at the boundary.
348
351
  const snap = snapshot(rows[0]);
349
352
  expect(snap.rid instanceof FakeRecordId).toBe(true);
353
+ expect(snapshot(rid) instanceof FakeRecordId).toBe(true);
350
354
  dispose();
351
355
  });
352
356
  });
@@ -6,16 +6,17 @@ import type {
6
6
  QueryResult,
7
7
  } from '@spooky-sync/query-builder';
8
8
  import {
9
+ createEffect,
9
10
  createMemo,
10
- createProjection,
11
11
  createSignal,
12
+ createStore,
12
13
  onCleanup,
13
14
  type Accessor,
14
15
  } from 'solid-js';
15
16
  import { SyncedDb } from '..';
16
17
  import type { Sp00kyQueryResultPromise } from '@spooky-sync/core';
17
18
  import { useDb } from './context';
18
- import { conflate } from './conflate';
19
+ import { mergeRows } from './merge-rows';
19
20
 
20
21
  type QueryArg<
21
22
  S extends SchemaStructure,
@@ -138,96 +139,135 @@ export function createQuery<
138
139
  // dispose (see `deregisterOnCleanup`).
139
140
  let activeHash: string | undefined;
140
141
 
141
- // Results live in a projection: each yielded emission is reconciled in place
142
- // keyed by `id` (unchanged rows keep identity; coarse readers are notified
143
- // on add/remove/reorder — probed in rc-semantics.test.ts, replacing the
144
- // Solid 1 reconcile + version-signal hack). `seedLoadingValue` births the
145
- // store committed, so `data()` reads never suspend.
142
+ // Results live in a plain store, written from the engine's subscription
143
+ // callback and reconciled in place keyed by `id`, so unchanged rows keep
144
+ // identity and coarse readers (`<For>`) are notified on add/remove/reorder.
146
145
  //
147
- // The compute's tracked reads (enabled, query thunk) all happen before the
148
- // first await — Solid 2 only creates dependency edges for pre-await reads. A
149
- // dep change restarts the generator; the superseded one is ABANDONED by
150
- // Solid (no return()/finally — probed), so the onCleanup registered
151
- // synchronously below is what tears down its subscriptions. This also
152
- // replaces the Solid 1 hook's runId/prevQueryString supersede machinery:
153
- // Solid dedupes re-runs whose tracked reads are unchanged, and identical
154
- // query identity means an identical hash means the same compute inputs.
155
- const store = createProjection(
156
- async function* (): AsyncGenerator<{ value: TData }> {
157
- const enabled = options?.enabled?.() ?? true;
158
- const query = typeof finalQuery === 'function' ? finalQuery() : finalQuery;
146
+ // NOT an async-generator projection. A live query's generator never returns:
147
+ // it awaits the next emission forever, which leaves its node permanently
148
+ // PENDING. Solid 2 holds a navigation transition until every pending node in
149
+ // the new tree settles, so with a generator here the first client-side
150
+ // navigation into a screen that opens a query never commits: the URL and
151
+ // effects update while the old DOM stays on screen, with no error and no
152
+ // <Loading> fallback that can rescue it. Subscription writes settle
153
+ // immediately, which is what the Solid 1 binding did too.
154
+ //
155
+ // Wrapped in an object so `one()` queries (row object or null) and list
156
+ // queries share one store shape; `mergeRows` keys `value`'s contents by id.
157
+ const [store, setStore] = createStore<{ value: TData }>({ value: null as TData });
159
158
 
160
- if (!enabled || !query) {
161
- setIsFetched(false);
162
- setError(undefined);
163
- return;
164
- }
159
+ // Identity of the installed subscription, so a superseded run cannot write
160
+ // results for a query the caller has already moved off.
161
+ let runId = 0;
162
+
163
+ // Woken by the first result or error, for the suspending `ready()` read.
164
+ let readyWaiters: (() => void)[] = [];
165
+ const wakeReady = () => {
166
+ const waiters = readyWaiters;
167
+ readyWaiters = [];
168
+ for (const w of waiters) w();
169
+ };
165
170
 
171
+ // Tracked: the query identity and the `enabled` gate. Untracked apply: the
172
+ // registration + subscription, whose teardown is the returned cleanup.
173
+ createEffect(
174
+ () => {
175
+ const enabled = options?.enabled?.() ?? true;
176
+ const query = typeof finalQuery === 'function' ? finalQuery() : finalQuery;
177
+ return { enabled, query };
178
+ },
179
+ ({ enabled, query }) => {
180
+ const myRun = ++runId;
166
181
  // A new identity starts clean: a previous identity's failure must not
167
182
  // keep this one out of its loading state.
168
183
  setIsFetched(false);
169
184
  setError(undefined);
185
+ if (!enabled || !query) return;
170
186
 
171
- const iterators: AsyncIterator<any>[] = [];
172
187
  const cleanups: (() => void)[] = [];
173
- onCleanup(() => {
174
- for (const it of iterators) void it.return?.();
175
- for (const c of cleanups) c();
176
- });
188
+ let disposed = false;
189
+ // `sp00ky.subscribe` resolves its unsubscribe asynchronously; the status
190
+ // subscription returns one directly. Accept both, and if teardown already
191
+ // happened while the promise was in flight, unsubscribe immediately.
192
+ const addCleanup = (c: (() => void) | Promise<(() => void) | undefined> | undefined) => {
193
+ if (!c) return;
194
+ if (typeof c === 'function') {
195
+ if (disposed) c();
196
+ else cleanups.push(c);
197
+ return;
198
+ }
199
+ void Promise.resolve(c).then((fn) => {
200
+ if (typeof fn !== 'function') return;
201
+ if (disposed) fn();
202
+ else cleanups.push(fn);
203
+ });
204
+ };
177
205
 
178
- try {
179
- /**
180
- * Registration can fail — the canonical case is the SSP answering 503
181
- * NOT_READY while it bootstraps. Surface it as `error()` instead of
182
- * throwing into the graph: the sync scheduler retries the
183
- * registration underneath, so a transient failure still recovers, and
184
- * a spinner driven by `isLoading()` resolves via `error()`.
185
- */
186
- const { hash } = await query.run();
187
- activeHash = hash;
206
+ /**
207
+ * Registration can fail — the canonical case is the SSP answering 503
208
+ * NOT_READY while it bootstraps. Surface it as `error()` instead of
209
+ * throwing into the graph: the sync scheduler retries the registration
210
+ * underneath, so a transient failure still recovers, and a spinner
211
+ * driven by `isLoading()` resolves via `error()`.
212
+ */
213
+ query
214
+ .run()
215
+ .then(({ hash }: { hash: string }) => {
216
+ if (disposed || myRun !== runId) return;
217
+ activeHash = hash;
188
218
 
189
- // Mirror the query's fetch status so the UI can show a "loading more"
190
- // state while the sync engine pulls missing records in the background.
191
- cleanups.push(
192
- sp00ky.subscribeQueryStatus(hash, (status) => setIsFetching(status === 'fetching'), {
193
- immediate: true,
194
- })
195
- );
219
+ // Mirror the query's fetch status so the UI can show a "loading
220
+ // more" state while the sync engine pulls records in the background.
221
+ addCleanup(
222
+ sp00ky.subscribeQueryStatus(hash, (status) => setIsFetching(status === 'fetching'), {
223
+ immediate: true,
224
+ })
225
+ );
196
226
 
197
- const it = conflate<Record<string, any>[]>((cb) =>
198
- sp00ky.subscribe(hash, cb, { immediate: true })
199
- )[Symbol.asyncIterator]();
200
- iterators.push(it);
227
+ let isFirstCall = true;
228
+ addCleanup(
229
+ sp00ky.subscribe(
230
+ hash,
231
+ (rows: Record<string, any>[]) => {
232
+ if (disposed || myRun !== runId) return;
233
+ const queryData = (query.isOne ? (rows[0] ?? null) : rows) as TData;
234
+ // The first (immediate) callback with no data likely means the
235
+ // local DB has not synced yet — don't mark as fetched, so the
236
+ // UI keeps showing its loading state.
237
+ const hasData = query.isOne
238
+ ? queryData !== null && queryData !== undefined
239
+ : rows.length > 0;
240
+ if (!isFirstCall || hasData) {
241
+ setIsFetched(true);
242
+ wakeReady();
243
+ }
244
+ isFirstCall = false;
201
245
 
202
- let isFirstCall = true;
203
- while (true) {
204
- const r = await it.next();
205
- if (r.done) break;
206
- const e = r.value;
207
- const queryData = (query.isOne ? (e[0] ?? null) : e) as TData;
208
- // The first (immediate) callback with no data likely means the local
209
- // DB hasn't synced yet — don't mark as fetched so UI shows loading.
210
- const hasData = query.isOne
211
- ? queryData !== null && queryData !== undefined
212
- : e.length > 0;
213
- if (!isFirstCall || hasData) setIsFetched(true);
214
- isFirstCall = false;
246
+ const t0 = performance.now();
247
+ setStore((s) => {
248
+ if (query.isOne || queryData === null || !Array.isArray(s.value)) {
249
+ s.value = queryData;
250
+ } else {
251
+ mergeRows(s.value as any[], queryData as any[]);
252
+ }
253
+ });
254
+ sp00ky.reportFrontendTiming(hash, performance.now() - t0);
255
+ },
256
+ { immediate: true }
257
+ )
258
+ );
259
+ })
260
+ .catch((err: unknown) => {
261
+ if (disposed || myRun !== runId) return;
262
+ setError(err instanceof Error ? err : new Error(String(err)));
263
+ wakeReady();
264
+ });
215
265
 
216
- // Time the store commit (yield → resume) and report it as the
217
- // "frontend" phase for DevTools/MCP. Approximate: Solid reconciles
218
- // the yielded value before resuming the generator.
219
- const t0 = performance.now();
220
- yield { value: queryData };
221
- sp00ky.reportFrontendTiming(hash, performance.now() - t0);
222
- }
223
- } catch (err) {
224
- setError(err instanceof Error ? err : new Error(String(err)));
225
- }
226
- },
227
- // Wrapped in an object so `one()` queries (row object or null) and list
228
- // queries share one store shape; `key` reconciles `value`'s contents.
229
- { value: null as TData },
230
- { key: 'id', seedLoadingValue: true }
266
+ return () => {
267
+ disposed = true;
268
+ for (const c of cleanups) c();
269
+ };
270
+ }
231
271
  );
232
272
 
233
273
  // Fallback empty value served before the first emission of a list query.
@@ -242,15 +282,23 @@ export function createQuery<
242
282
  return v as TData;
243
283
  };
244
284
 
245
- // Suspending read: pends until the first real result (or error) via an async
246
- // memo that resolves when isFetched/error flips. Reading it inside <Loading>
247
- // integrates with Solid 2's boundary protocol; `data` stays non-throwing.
248
- const readyGate = createMemo(async (): Promise<true> => {
249
- if (isFetched() || error()) return true;
250
- // Tracked reads above registered the deps; park until one flips.
251
- await new Promise<void>(() => {});
252
- return true;
253
- });
285
+ // Suspending read: pends until the first real result (or error). Reading it
286
+ // inside <Loading> integrates with Solid 2's boundary protocol; `data` stays
287
+ // non-throwing.
288
+ //
289
+ // `lazy` matters, and so does the promise actually resolving: an unresolved
290
+ // async node stays PENDING, every queue flush carries pending nodes forward,
291
+ // and a navigation transition waits on them. Lazy means only a query whose
292
+ // `ready()` is read creates the node at all, and the deferred below is
293
+ // settled by the same emission that flips `isFetched`.
294
+ const readyGate = createMemo(
295
+ async (): Promise<true> => {
296
+ if (isFetched() || error()) return true;
297
+ await new Promise<void>((resolve) => readyWaiters.push(resolve));
298
+ return true;
299
+ },
300
+ { lazy: true }
301
+ );
254
302
  const ready: Accessor<TData> = () => {
255
303
  readyGate();
256
304
  return data();
@@ -0,0 +1,46 @@
1
+ import { snapshot } from 'solid-js';
2
+
3
+ /**
4
+ * Keyed in-place merge of a fresh row list into a store draft array.
5
+ *
6
+ * Why not `reconcile(rows, 'id')`: in Solid 2 rc.1 a reconcile REPLACES the row
7
+ * objects, so a component that captured `data()[0]` sees a different object
8
+ * after the next emission, and anything keyed on row identity (`<For>` without
9
+ * an explicit key, a memo comparing rows) re-creates its subtree on every live
10
+ * update. Mutating the draft in place keeps the identity AND keeps updates
11
+ * fine-grained: only the fields that actually changed are written, so a row
12
+ * whose data is unchanged notifies nobody.
13
+ *
14
+ * Rows are matched by `key` (default `id`); unmatched incoming rows are
15
+ * inserted as-is and trailing rows are dropped, so add / remove / reorder all
16
+ * reach coarse readers.
17
+ */
18
+ export function mergeRows(draft: any[], next: any[], key = 'id'): void {
19
+ const byKey = new Map<any, any>();
20
+ for (const row of draft) {
21
+ const k = row?.[key];
22
+ if (k !== undefined) byKey.set(String(k), row);
23
+ }
24
+
25
+ for (let i = 0; i < next.length; i++) {
26
+ const incoming = next[i];
27
+ const k = incoming?.[key];
28
+ const reuse = k !== undefined ? byKey.get(String(k)) : undefined;
29
+
30
+ if (reuse) {
31
+ for (const field of Object.keys(incoming)) {
32
+ if (reuse[field] !== incoming[field]) reuse[field] = incoming[field];
33
+ }
34
+ // `snapshot` first: iterating the proxy's own keys while deleting from it
35
+ // is not safe, and the raw object is what carries the stale fields.
36
+ for (const field of Object.keys(snapshot(reuse))) {
37
+ if (!(field in incoming)) delete reuse[field];
38
+ }
39
+ if (draft[i] !== reuse) draft[i] = reuse;
40
+ } else if (draft[i] !== incoming) {
41
+ draft[i] = incoming;
42
+ }
43
+ }
44
+
45
+ if (draft.length > next.length) draft.splice(next.length);
46
+ }
@@ -0,0 +1,82 @@
1
+ import { createSignal, createEffect, type Accessor } from 'solid-js';
2
+ import type { SchemaStructure, BucketNames } from '@spooky-sync/query-builder';
3
+ import type { SyncedDb } from '../index';
4
+ import { useDb } from './context';
5
+
6
+ export interface UseBlurhashResult {
7
+ /** The stored blurhash for the path, or null while loading / when none exists. */
8
+ hash: Accessor<string | null>;
9
+ isLoading: Accessor<boolean>;
10
+ }
11
+
12
+ /**
13
+ * The blurhash sidecar for a bucket image (written automatically by
14
+ * `bucket.put`, see `Sp00kyConfig.blurhash`). Resolves from OPFS instantly on
15
+ * warm clients; a miss is remembered per tab. Use this directly when the hash
16
+ * belongs to a different rendition than the displayed image; otherwise
17
+ * `useBucketImage` / `BucketImage` bundle it with the download.
18
+ */
19
+ export function useBlurhash<S extends SchemaStructure>(
20
+ bucketName: BucketNames<S>,
21
+ path: Accessor<string | null | undefined>
22
+ ): UseBlurhashResult;
23
+ export function useBlurhash<S extends SchemaStructure>(
24
+ db: SyncedDb<S>,
25
+ bucketName: BucketNames<S>,
26
+ path: Accessor<string | null | undefined>
27
+ ): UseBlurhashResult;
28
+ export function useBlurhash<S extends SchemaStructure>(
29
+ dbOrBucketName: SyncedDb<S> | BucketNames<S>,
30
+ bucketNameOrPath?: BucketNames<S> | Accessor<string | null | undefined>,
31
+ maybePath?: Accessor<string | null | undefined>
32
+ ): UseBlurhashResult {
33
+ let db: SyncedDb<S>;
34
+ let bucketName: BucketNames<S>;
35
+ let path: Accessor<string | null | undefined>;
36
+
37
+ if (typeof dbOrBucketName === 'string') {
38
+ db = useDb<S>();
39
+ bucketName = dbOrBucketName as BucketNames<S>;
40
+ path = bucketNameOrPath as Accessor<string | null | undefined>;
41
+ } else {
42
+ db = dbOrBucketName as SyncedDb<S>;
43
+ bucketName = bucketNameOrPath as BucketNames<S>;
44
+ path = maybePath as Accessor<string | null | undefined>;
45
+ }
46
+
47
+ // Written from the lookup continuation, outside any tracking scope.
48
+ const [hash, setHash] = createSignal<string | null>(null, { ownedWrite: true });
49
+ const [isLoading, setIsLoading] = createSignal(false, { ownedWrite: true });
50
+
51
+ // Compute tracks the path; apply runs the lookup and returns the cancel
52
+ // cleanup, which runs before the next apply and on unmount.
53
+ createEffect(
54
+ () => path(),
55
+ (filePath) => {
56
+ if (!filePath) {
57
+ setHash(null);
58
+ setIsLoading(false);
59
+ return;
60
+ }
61
+ let cancelled = false;
62
+ setIsLoading(true);
63
+ db.bucket(bucketName)
64
+ .blurhash(filePath)
65
+ .then((result) => {
66
+ if (cancelled) return;
67
+ setHash(result);
68
+ setIsLoading(false);
69
+ })
70
+ .catch(() => {
71
+ if (cancelled) return;
72
+ setHash(null);
73
+ setIsLoading(false);
74
+ });
75
+ return () => {
76
+ cancelled = true;
77
+ };
78
+ }
79
+ );
80
+
81
+ return { hash, isLoading };
82
+ }