@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.
- package/AGENTS.md +77 -0
- package/README.md +2 -2
- package/dist/index.cjs +340 -48
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +114 -9
- package/dist/index.d.cts.map +1 -1
- package/dist/index.d.ts +114 -9
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +339 -51
- package/dist/index.js.map +1 -1
- package/package.json +17 -16
- package/skills/sp00ky-solid2/SKILL.md +1 -1
- package/src/index.ts +36 -11
- package/src/lib/Blurhash.ts +64 -0
- package/src/lib/BucketImage.ts +170 -0
- package/src/lib/__tests__/rc-semantics.test.ts +9 -5
- package/src/lib/create-query.ts +135 -87
- package/src/lib/merge-rows.ts +46 -0
- package/src/lib/use-blurhash.ts +82 -0
- package/src/lib/use-bucket-image.ts +108 -0
- package/tsdown.config.ts +2 -0
- package/LICENSE +0 -21
|
@@ -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.
|
|
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
|
|
341
|
-
// `constructor.name`
|
|
342
|
-
//
|
|
343
|
-
|
|
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
|
});
|
package/src/lib/create-query.ts
CHANGED
|
@@ -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 {
|
|
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
|
|
142
|
-
// keyed by `id
|
|
143
|
-
// on add/remove/reorder
|
|
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
|
-
//
|
|
148
|
-
//
|
|
149
|
-
//
|
|
150
|
-
//
|
|
151
|
-
//
|
|
152
|
-
//
|
|
153
|
-
//
|
|
154
|
-
//
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
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
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
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
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
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
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
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)
|
|
246
|
-
//
|
|
247
|
-
//
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
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
|
+
}
|