@kontsedal/olas-core 0.0.2 → 0.0.3
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/dist/index.cjs +34 -1
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +52 -2
- package/dist/index.d.cts.map +1 -1
- package/dist/index.d.mts +52 -2
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +33 -2
- package/dist/index.mjs.map +1 -1
- package/dist/{root-De-6KWIZ.mjs → root-Cnkb3I--.mjs} +248 -18
- package/dist/root-Cnkb3I--.mjs.map +1 -0
- package/dist/{root-XKEsSmcd.cjs → root-D_xAdcom.cjs} +248 -18
- package/dist/root-D_xAdcom.cjs.map +1 -0
- package/dist/testing.cjs +2 -1
- package/dist/testing.cjs.map +1 -1
- package/dist/testing.d.cts +2 -1
- package/dist/testing.d.cts.map +1 -1
- package/dist/testing.d.mts +2 -1
- package/dist/testing.d.mts.map +1 -1
- package/dist/testing.mjs +2 -1
- package/dist/testing.mjs.map +1 -1
- package/dist/{types-C-zV1JZA.d.mts → types-CRn4UoLn.d.mts} +64 -6
- package/dist/types-CRn4UoLn.d.mts.map +1 -0
- package/dist/{types-DKfpkm17.d.cts → types-r_TVaRkD.d.cts} +64 -6
- package/dist/types-r_TVaRkD.d.cts.map +1 -0
- package/package.json +1 -1
- package/src/controller/types.ts +20 -0
- package/src/forms/field.ts +42 -9
- package/src/forms/form-types.ts +37 -0
- package/src/forms/form.ts +118 -0
- package/src/forms/index.ts +12 -1
- package/src/forms/standard-schema.ts +37 -0
- package/src/forms/validators.ts +31 -0
- package/src/index.ts +13 -3
- package/src/query/entry.ts +10 -3
- package/src/query/infinite.ts +8 -1
- package/src/query/structural-share.ts +114 -0
- package/src/query/types.ts +15 -2
- package/src/query/use.ts +47 -13
- package/src/testing.ts +2 -0
- package/dist/root-De-6KWIZ.mjs.map +0 -1
- package/dist/root-XKEsSmcd.cjs.map +0 -1
- package/dist/types-C-zV1JZA.d.mts.map +0 -1
- package/dist/types-DKfpkm17.d.cts.map +0 -1
package/src/forms/form-types.ts
CHANGED
|
@@ -90,6 +90,22 @@ export type Form<S extends FormSchema> = {
|
|
|
90
90
|
readonly touched: ReadSignal<boolean>
|
|
91
91
|
readonly isValidating: ReadSignal<boolean>
|
|
92
92
|
|
|
93
|
+
/**
|
|
94
|
+
* `true` while a `submit(...)` is in flight. Clears when the handler
|
|
95
|
+
* resolves, throws, or pre-submit validation fails.
|
|
96
|
+
*/
|
|
97
|
+
readonly isSubmitting: ReadSignal<boolean>
|
|
98
|
+
/** Number of times `submit(...)` has been called. Bumps before the handler runs. */
|
|
99
|
+
readonly submitCount: ReadSignal<number>
|
|
100
|
+
/**
|
|
101
|
+
* The thrown value from the most recent failed submission, if any.
|
|
102
|
+
* Cleared at the start of each new `submit(...)` call and on `reset()`.
|
|
103
|
+
* Note that a validation failure ("submit blocked because the form is
|
|
104
|
+
* invalid") is NOT a thrown error — `submitError` stays whatever it
|
|
105
|
+
* was, and the returned promise resolves with `{ ok: false }`.
|
|
106
|
+
*/
|
|
107
|
+
readonly submitError: ReadSignal<unknown>
|
|
108
|
+
|
|
93
109
|
/** Deep-merge a partial value into the form, batched. */
|
|
94
110
|
set(partial: DeepPartial<FormValue<S>>): void
|
|
95
111
|
/**
|
|
@@ -105,6 +121,27 @@ export type Form<S extends FormSchema> = {
|
|
|
105
121
|
markAllTouched(): void
|
|
106
122
|
/** Re-run every leaf's validators. Resolves with true if all leaves are valid. */
|
|
107
123
|
validate(): Promise<boolean>
|
|
124
|
+
/**
|
|
125
|
+
* Run a submission. Pre-validates the form (unless `validateBeforeSubmit: false`),
|
|
126
|
+
* then calls `handler(value)`. Maintains `isSubmitting` / `submitCount` /
|
|
127
|
+
* `submitError`. Returns `{ ok, data?, error? }` — see `FormImpl.submit`
|
|
128
|
+
* for the full contract.
|
|
129
|
+
*/
|
|
130
|
+
submit(
|
|
131
|
+
handler: (value: FormValue<S>) => unknown | Promise<unknown>,
|
|
132
|
+
options?: {
|
|
133
|
+
validateBeforeSubmit?: boolean
|
|
134
|
+
resetOnSuccess?: boolean
|
|
135
|
+
onError?: 'rethrow' | 'capture'
|
|
136
|
+
},
|
|
137
|
+
): Promise<{ ok: boolean; data?: unknown; error?: unknown }>
|
|
138
|
+
/**
|
|
139
|
+
* Pin externally-sourced errors on specific fields. Keys are dot-separated
|
|
140
|
+
* paths through nested forms / field arrays (numeric segments are array
|
|
141
|
+
* indices). Errors land in each field's `serverErrors` channel — kept
|
|
142
|
+
* separate from validator output and auto-cleared on the next user write.
|
|
143
|
+
*/
|
|
144
|
+
setErrors(errors: Record<string, ReadonlyArray<string>>): void
|
|
108
145
|
/** Idempotent. Called by the owning controller's dispose. */
|
|
109
146
|
dispose(): void
|
|
110
147
|
}
|
package/src/forms/form.ts
CHANGED
|
@@ -51,6 +51,14 @@ class FormImpl<S extends FormSchema> implements Form<S> {
|
|
|
51
51
|
readonly topLevelErrors: ReadSignal<string[]> = this.topLevelErrors$
|
|
52
52
|
private readonly topLevelValidating$: Signal<boolean> = signal(false)
|
|
53
53
|
|
|
54
|
+
// Submission lifecycle.
|
|
55
|
+
private readonly isSubmitting$: Signal<boolean> = signal(false)
|
|
56
|
+
private readonly submitCount$: Signal<number> = signal(0)
|
|
57
|
+
private readonly submitError$: Signal<unknown> = signal(undefined)
|
|
58
|
+
readonly isSubmitting: ReadSignal<boolean> = this.isSubmitting$
|
|
59
|
+
readonly submitCount: ReadSignal<number> = this.submitCount$
|
|
60
|
+
readonly submitError: ReadSignal<unknown> = this.submitError$
|
|
61
|
+
|
|
54
62
|
private readonly validators: ReadonlyArray<FormValidator<S>>
|
|
55
63
|
private readonly options: FormOptions<S> | undefined
|
|
56
64
|
private validatorDispose: (() => void) | null = null
|
|
@@ -313,6 +321,116 @@ class FormImpl<S extends FormSchema> implements Form<S> {
|
|
|
313
321
|
return this.isValid.peek()
|
|
314
322
|
}
|
|
315
323
|
|
|
324
|
+
/**
|
|
325
|
+
* Run a submission against this form. Wraps `handler(value)` with:
|
|
326
|
+
* - `isSubmitting` set true while the handler is in flight.
|
|
327
|
+
* - `submitCount` incremented before the handler runs.
|
|
328
|
+
* - `submitError` set to the throw, if any.
|
|
329
|
+
* - Optional pre-submit `validate()` (default true). When invalid every
|
|
330
|
+
* field is marked touched and the handler is skipped — the returned
|
|
331
|
+
* promise resolves with `{ ok: false }` and `submitError` is left
|
|
332
|
+
* untouched (validation failure is not a thrown error).
|
|
333
|
+
*
|
|
334
|
+
* The handler may return a value (synchronously or via Promise); it's
|
|
335
|
+
* captured in the resolved object's `data` field. Throws are captured
|
|
336
|
+
* unless `onError: 'rethrow'`. A `resetOnSuccess: true` option calls
|
|
337
|
+
* `reset()` after the handler resolves successfully.
|
|
338
|
+
*/
|
|
339
|
+
async submit(
|
|
340
|
+
handler: (value: FormValue<S>) => unknown | Promise<unknown>,
|
|
341
|
+
options?: {
|
|
342
|
+
validateBeforeSubmit?: boolean
|
|
343
|
+
resetOnSuccess?: boolean
|
|
344
|
+
onError?: 'rethrow' | 'capture'
|
|
345
|
+
},
|
|
346
|
+
): Promise<{ ok: boolean; data?: unknown; error?: unknown }> {
|
|
347
|
+
if (this.disposed) return { ok: false, error: new Error('form is disposed') }
|
|
348
|
+
|
|
349
|
+
// Double-submit guard — refusing to start a second submission while one
|
|
350
|
+
// is in flight matches RHF / TanStack-Form. Consumers wanting parallel
|
|
351
|
+
// submits should run them off the form directly.
|
|
352
|
+
if (this.isSubmitting$.peek()) {
|
|
353
|
+
return { ok: false, error: new Error('submit already in progress') }
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
const validateFirst = options?.validateBeforeSubmit ?? true
|
|
357
|
+
const onErrorMode = options?.onError ?? 'capture'
|
|
358
|
+
|
|
359
|
+
batch(() => {
|
|
360
|
+
this.submitCount$.update((n) => n + 1)
|
|
361
|
+
this.submitError$.set(undefined)
|
|
362
|
+
this.isSubmitting$.set(true)
|
|
363
|
+
})
|
|
364
|
+
|
|
365
|
+
try {
|
|
366
|
+
if (validateFirst) {
|
|
367
|
+
const ok = await this.validate()
|
|
368
|
+
if (!ok) {
|
|
369
|
+
this.markAllTouched()
|
|
370
|
+
this.isSubmitting$.set(false)
|
|
371
|
+
return { ok: false }
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
const result = await handler(this.value.peek())
|
|
375
|
+
if (options?.resetOnSuccess) this.reset()
|
|
376
|
+
this.isSubmitting$.set(false)
|
|
377
|
+
return { ok: true, data: result }
|
|
378
|
+
} catch (err) {
|
|
379
|
+
batch(() => {
|
|
380
|
+
this.submitError$.set(err)
|
|
381
|
+
this.isSubmitting$.set(false)
|
|
382
|
+
})
|
|
383
|
+
if (onErrorMode === 'rethrow') throw err
|
|
384
|
+
return { ok: false, error: err }
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* Pin externally-sourced errors on specific fields — typically server-side
|
|
390
|
+
* validation results from a failed submit. Paths are dot-separated and
|
|
391
|
+
* traverse nested `Form` / `FieldArray` children (numeric segments are
|
|
392
|
+
* array indices). Errors land in the field's `serverErrors` channel and
|
|
393
|
+
* clear automatically on the next user write to that field. Passing an
|
|
394
|
+
* empty array for a path clears that field's server errors immediately.
|
|
395
|
+
*/
|
|
396
|
+
setErrors(errors: Record<string, ReadonlyArray<string>>): void {
|
|
397
|
+
if (this.disposed) return
|
|
398
|
+
batch(() => {
|
|
399
|
+
for (const [path, msgs] of Object.entries(errors)) {
|
|
400
|
+
const target = this.resolvePath(path)
|
|
401
|
+
if (target === undefined) continue
|
|
402
|
+
if ((target as { setErrors?: unknown }).setErrors === undefined) continue
|
|
403
|
+
;(target as { setErrors: (e: ReadonlyArray<string>) => void }).setErrors(msgs)
|
|
404
|
+
}
|
|
405
|
+
})
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
private resolvePath(path: string): unknown {
|
|
409
|
+
if (path === '') return undefined
|
|
410
|
+
const segments = path.split('.')
|
|
411
|
+
let cursor: unknown = this
|
|
412
|
+
for (const seg of segments) {
|
|
413
|
+
if (cursor === undefined || cursor === null) return undefined
|
|
414
|
+
if (isForm(cursor)) {
|
|
415
|
+
cursor = (cursor.fields as Record<string, unknown>)[seg]
|
|
416
|
+
continue
|
|
417
|
+
}
|
|
418
|
+
if (isFieldArray(cursor)) {
|
|
419
|
+
const idx = Number(seg)
|
|
420
|
+
if (!Number.isInteger(idx) || idx < 0) return undefined
|
|
421
|
+
cursor = (cursor as { at(i: number): unknown }).at(idx)
|
|
422
|
+
continue
|
|
423
|
+
}
|
|
424
|
+
// Top-level dispatch — `this` is the FormImpl; walk via `fields`.
|
|
425
|
+
if (cursor === this) {
|
|
426
|
+
cursor = (this.fields as Record<string, unknown>)[seg]
|
|
427
|
+
continue
|
|
428
|
+
}
|
|
429
|
+
return undefined
|
|
430
|
+
}
|
|
431
|
+
return cursor
|
|
432
|
+
}
|
|
433
|
+
|
|
316
434
|
dispose(): void {
|
|
317
435
|
if (this.disposed) return
|
|
318
436
|
this.disposed = true
|
package/src/forms/index.ts
CHANGED
|
@@ -1,2 +1,13 @@
|
|
|
1
|
+
export type { StandardSchemaV1 } from './standard-schema'
|
|
2
|
+
export { isStandardSchema } from './standard-schema'
|
|
1
3
|
export type { Validator } from './types'
|
|
2
|
-
export {
|
|
4
|
+
export {
|
|
5
|
+
email,
|
|
6
|
+
max,
|
|
7
|
+
maxLength,
|
|
8
|
+
min,
|
|
9
|
+
minLength,
|
|
10
|
+
pattern,
|
|
11
|
+
required,
|
|
12
|
+
validator,
|
|
13
|
+
} from './validators'
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Standard Schema v1 — the cross-library validation contract adopted by
|
|
3
|
+
* Zod 4, Valibot 1, ArkType 2, and others. See https://standardschema.dev.
|
|
4
|
+
*
|
|
5
|
+
* We type-only-import the shape so consumers don't take a new runtime dep:
|
|
6
|
+
* any object with a `~standard.validate(value)` method conforming to this
|
|
7
|
+
* structure works.
|
|
8
|
+
*/
|
|
9
|
+
export type StandardSchemaV1Issue = {
|
|
10
|
+
readonly message: string
|
|
11
|
+
readonly path?: ReadonlyArray<PropertyKey | { readonly key: PropertyKey }>
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export type StandardSchemaV1Result<O> =
|
|
15
|
+
| { readonly value: O; readonly issues?: undefined }
|
|
16
|
+
| { readonly issues: ReadonlyArray<StandardSchemaV1Issue> }
|
|
17
|
+
|
|
18
|
+
export type StandardSchemaV1<I = unknown, O = I> = {
|
|
19
|
+
readonly '~standard': {
|
|
20
|
+
readonly version: 1
|
|
21
|
+
readonly vendor: string
|
|
22
|
+
validate(value: unknown): StandardSchemaV1Result<O> | Promise<StandardSchemaV1Result<O>>
|
|
23
|
+
readonly types?: { readonly input: I; readonly output: O } | undefined
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Heuristic: does `x` look like a Standard Schema?
|
|
29
|
+
*/
|
|
30
|
+
export function isStandardSchema(x: unknown): x is StandardSchemaV1<unknown, unknown> {
|
|
31
|
+
return (
|
|
32
|
+
x !== null &&
|
|
33
|
+
typeof x === 'object' &&
|
|
34
|
+
'~standard' in x &&
|
|
35
|
+
typeof (x as { '~standard': { validate?: unknown } })['~standard']?.validate === 'function'
|
|
36
|
+
)
|
|
37
|
+
}
|
package/src/forms/validators.ts
CHANGED
|
@@ -1,5 +1,36 @@
|
|
|
1
|
+
import { isStandardSchema, type StandardSchemaV1 } from './standard-schema'
|
|
1
2
|
import type { Validator } from './types'
|
|
2
3
|
|
|
4
|
+
/**
|
|
5
|
+
* Wrap any Standard-Schema-compatible schema (Zod 4, Valibot 1, ArkType 2,
|
|
6
|
+
* …) as an Olas validator. The validator returns the first issue's message
|
|
7
|
+
* on failure (or `'Invalid'` if no issues are produced), `null` on success.
|
|
8
|
+
*
|
|
9
|
+
* Standard Schema validators may be sync or async; this wrapper threads
|
|
10
|
+
* through whichever the schema returns — `Promise<string|null>` only when
|
|
11
|
+
* the underlying validate call is itself async.
|
|
12
|
+
*
|
|
13
|
+
* `signal` is accepted to match the `Validator<T>` shape but isn't forwarded
|
|
14
|
+
* — Standard Schema v1 has no cancellation surface.
|
|
15
|
+
*/
|
|
16
|
+
export function validator<I, O>(schema: StandardSchemaV1<I, O>): Validator<I> {
|
|
17
|
+
return (value, signal) => {
|
|
18
|
+
void signal
|
|
19
|
+
const result = schema['~standard'].validate(value)
|
|
20
|
+
if (result instanceof Promise) {
|
|
21
|
+
return result.then(messageFromResult)
|
|
22
|
+
}
|
|
23
|
+
return messageFromResult(result)
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function messageFromResult(result: { issues?: ReadonlyArray<{ message: string }> }): string | null {
|
|
28
|
+
if (result.issues === undefined || result.issues.length === 0) return null
|
|
29
|
+
return result.issues[0]?.message ?? 'Invalid'
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export { isStandardSchema, type StandardSchemaV1 } from './standard-schema'
|
|
33
|
+
|
|
3
34
|
const isEmpty = (value: unknown): boolean => {
|
|
4
35
|
if (value === undefined || value === null) return true
|
|
5
36
|
if (typeof value === 'string') return value.length === 0
|
package/src/index.ts
CHANGED
|
@@ -25,9 +25,19 @@ export type { DebugBus, DebugCacheEntry, DebugEvent } from './devtools'
|
|
|
25
25
|
export type { Emitter, EmitterErrorReporter } from './emitter'
|
|
26
26
|
export { createEmitter } from './emitter'
|
|
27
27
|
export type { ErrorContext } from './errors'
|
|
28
|
-
// Forms — stdlib validators + debouncedValidator
|
|
29
|
-
export type { Validator } from './forms'
|
|
30
|
-
export {
|
|
28
|
+
// Forms — stdlib validators + Standard Schema adapter + debouncedValidator
|
|
29
|
+
export type { StandardSchemaV1, Validator } from './forms'
|
|
30
|
+
export {
|
|
31
|
+
email,
|
|
32
|
+
isStandardSchema,
|
|
33
|
+
max,
|
|
34
|
+
maxLength,
|
|
35
|
+
min,
|
|
36
|
+
minLength,
|
|
37
|
+
pattern,
|
|
38
|
+
required,
|
|
39
|
+
validator,
|
|
40
|
+
} from './forms'
|
|
31
41
|
export { debouncedValidator } from './forms/field'
|
|
32
42
|
export type {
|
|
33
43
|
DeepPartial,
|
package/src/query/entry.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { batch, type Signal, signal } from '../signals'
|
|
2
2
|
import { abortableSleep, isAbortError } from '../utils'
|
|
3
|
+
import { structuralShare } from './structural-share'
|
|
3
4
|
import type { AsyncStatus, RetryDelay, RetryPolicy, Snapshot } from './types'
|
|
4
5
|
|
|
5
6
|
export type EntryEvents = {
|
|
@@ -180,8 +181,14 @@ export class Entry<T> {
|
|
|
180
181
|
}
|
|
181
182
|
|
|
182
183
|
private applySuccess(result: T): T {
|
|
184
|
+
// Structurally share with the previous value so unchanged sub-trees
|
|
185
|
+
// keep their `===` identity. Downstream `computed`s and React snapshots
|
|
186
|
+
// stop thrashing on no-op refetches. Bails on Maps/Sets/class instances
|
|
187
|
+
// — see `structural-share.ts`.
|
|
188
|
+
const prev = this.data.peek() as T | undefined
|
|
189
|
+
const shared = prev === undefined ? result : structuralShare(prev, result)
|
|
183
190
|
batch(() => {
|
|
184
|
-
this.data.set(
|
|
191
|
+
this.data.set(shared)
|
|
185
192
|
this.error.set(undefined)
|
|
186
193
|
this.status.set('success')
|
|
187
194
|
this.isLoading.set(false)
|
|
@@ -195,8 +202,8 @@ export class Entry<T> {
|
|
|
195
202
|
} catch {
|
|
196
203
|
// devtools handlers must not break the program.
|
|
197
204
|
}
|
|
198
|
-
this.onSuccessData?.(
|
|
199
|
-
return
|
|
205
|
+
this.onSuccessData?.(shared)
|
|
206
|
+
return shared
|
|
200
207
|
}
|
|
201
208
|
|
|
202
209
|
private applyFailure(err: unknown): never {
|
package/src/query/infinite.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { batch, computed, type Signal, signal } from '../signals'
|
|
2
2
|
import type { ReadSignal } from '../signals/types'
|
|
3
3
|
import { abortableSleep, isAbortError } from '../utils'
|
|
4
|
+
import { structuralShare } from './structural-share'
|
|
4
5
|
import type { AsyncState, AsyncStatus, RetryDelay, RetryPolicy, Snapshot } from './types'
|
|
5
6
|
|
|
6
7
|
/**
|
|
@@ -205,8 +206,14 @@ export class InfiniteEntry<TPage, TItem, PageParam> {
|
|
|
205
206
|
this.initialPageParam,
|
|
206
207
|
(page, param) => {
|
|
207
208
|
if (myId !== this.currentFetchId || this.disposed) return
|
|
209
|
+
// Structurally share with the previous first-page on refresh, so
|
|
210
|
+
// unchanged pages keep their refs. We only share the head page —
|
|
211
|
+
// initial fetch wipes the rest of the array by definition.
|
|
212
|
+
const prevPages = this.pages.peek()
|
|
213
|
+
const sharedPage =
|
|
214
|
+
prevPages.length > 0 ? structuralShare(prevPages[0] as TPage, page) : page
|
|
208
215
|
batch(() => {
|
|
209
|
-
this.pages.set([
|
|
216
|
+
this.pages.set([sharedPage])
|
|
210
217
|
this.pageParams.set([param])
|
|
211
218
|
this.error.set(undefined)
|
|
212
219
|
this.status.set('success')
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Walk `prev` and `next` in parallel. Wherever a sub-tree in `next` is
|
|
3
|
+
* structurally equal to the corresponding sub-tree in `prev`, return `prev`'s
|
|
4
|
+
* reference for that sub-tree. Otherwise return `next`'s.
|
|
5
|
+
*
|
|
6
|
+
* Result: a value that is `===` to `prev` on every refetch where the payload
|
|
7
|
+
* didn't actually change, and shares maximum ref-identity on partial changes.
|
|
8
|
+
* Downstream `computed`s and React `useSyncExternalStore` snapshots stop
|
|
9
|
+
* thrashing because reference equality holds where content equality holds.
|
|
10
|
+
*
|
|
11
|
+
* Bails (returns the `next` ref unchanged, no recursion) on:
|
|
12
|
+
* - Mismatched constructors / different `typeof` between `prev` and `next`
|
|
13
|
+
* - `Map`, `Set`, `Date`, `RegExp`, class instances (anything where the
|
|
14
|
+
* plain-object / array fast path isn't safe)
|
|
15
|
+
* - Functions, symbols, promises
|
|
16
|
+
*
|
|
17
|
+
* Handles cycles via a `WeakSet` of in-progress objects — a self-referential
|
|
18
|
+
* payload that compares structurally identical against itself won't loop.
|
|
19
|
+
*/
|
|
20
|
+
export function structuralShare<T>(prev: T, next: T): T {
|
|
21
|
+
// Identity short-circuit — both branches see the exact same allocation.
|
|
22
|
+
if (Object.is(prev, next)) return prev
|
|
23
|
+
return walk(prev, next, new WeakSet<object>()) as T
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function walk(prev: unknown, next: unknown, seen: WeakSet<object>): unknown {
|
|
27
|
+
if (Object.is(prev, next)) return prev
|
|
28
|
+
if (prev === null || next === null) return next
|
|
29
|
+
if (typeof prev !== 'object' || typeof next !== 'object') return next
|
|
30
|
+
|
|
31
|
+
// Cycle guard. If either side is already on the in-flight stack, we can't
|
|
32
|
+
// safely recurse — fall back to `next`'s ref. Real cyclic payloads are
|
|
33
|
+
// exceedingly rare in HTTP responses; defensive bail.
|
|
34
|
+
if (seen.has(prev as object) || seen.has(next as object)) return next
|
|
35
|
+
|
|
36
|
+
// Arrays — only matched against arrays.
|
|
37
|
+
if (Array.isArray(prev) && Array.isArray(next)) {
|
|
38
|
+
return walkArray(prev, next, seen)
|
|
39
|
+
}
|
|
40
|
+
if (Array.isArray(prev) !== Array.isArray(next)) return next
|
|
41
|
+
|
|
42
|
+
// Constructor / prototype check. Plain objects have `Object.prototype`
|
|
43
|
+
// (and a Map/Set/Date/RegExp/class instance does not). We require an exact
|
|
44
|
+
// prototype match on both sides AND `Object.prototype` so we never deep-
|
|
45
|
+
// walk into class instances whose identity might encode hidden state.
|
|
46
|
+
const prevProto = Object.getPrototypeOf(prev)
|
|
47
|
+
if (prevProto !== Object.getPrototypeOf(next)) return next
|
|
48
|
+
if (prevProto !== Object.prototype && prevProto !== null) return next
|
|
49
|
+
|
|
50
|
+
return walkPlainObject(prev as Record<string, unknown>, next as Record<string, unknown>, seen)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function walkArray(
|
|
54
|
+
prev: ReadonlyArray<unknown>,
|
|
55
|
+
next: ReadonlyArray<unknown>,
|
|
56
|
+
seen: WeakSet<object>,
|
|
57
|
+
): ReadonlyArray<unknown> {
|
|
58
|
+
if (prev.length !== next.length) {
|
|
59
|
+
// Length changed — we can still preserve refs for matching prefixes via
|
|
60
|
+
// index-aligned walking. That's the right trade-off for tables / lists:
|
|
61
|
+
// appending an item keeps the head's refs stable, prepending invalidates
|
|
62
|
+
// everything (which it does anyway — items shifted).
|
|
63
|
+
}
|
|
64
|
+
seen.add(prev)
|
|
65
|
+
seen.add(next)
|
|
66
|
+
try {
|
|
67
|
+
const out: unknown[] = new Array(next.length)
|
|
68
|
+
let changed = next.length !== prev.length
|
|
69
|
+
for (let i = 0; i < next.length; i++) {
|
|
70
|
+
const prevItem = i < prev.length ? prev[i] : undefined
|
|
71
|
+
const shared = walk(prevItem, next[i], seen)
|
|
72
|
+
out[i] = shared
|
|
73
|
+
if (shared !== prev[i]) changed = true
|
|
74
|
+
}
|
|
75
|
+
if (!changed) return prev
|
|
76
|
+
return out
|
|
77
|
+
} finally {
|
|
78
|
+
seen.delete(prev)
|
|
79
|
+
seen.delete(next)
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function walkPlainObject(
|
|
84
|
+
prev: Record<string, unknown>,
|
|
85
|
+
next: Record<string, unknown>,
|
|
86
|
+
seen: WeakSet<object>,
|
|
87
|
+
): Record<string, unknown> {
|
|
88
|
+
const prevKeys = Object.keys(prev)
|
|
89
|
+
const nextKeys = Object.keys(next)
|
|
90
|
+
let changed = prevKeys.length !== nextKeys.length
|
|
91
|
+
|
|
92
|
+
seen.add(prev)
|
|
93
|
+
seen.add(next)
|
|
94
|
+
try {
|
|
95
|
+
const out: Record<string, unknown> = {}
|
|
96
|
+
// Iterate `next`'s keys in order so the output preserves payload's
|
|
97
|
+
// key ordering (matters for downstream `JSON.stringify` callers and
|
|
98
|
+
// for predictable React reconciliation when an object is rendered).
|
|
99
|
+
for (const key of nextKeys) {
|
|
100
|
+
const shared = walk(prev[key], next[key], seen)
|
|
101
|
+
out[key] = shared
|
|
102
|
+
if (shared !== prev[key]) changed = true
|
|
103
|
+
else if (!(key in prev)) changed = true
|
|
104
|
+
}
|
|
105
|
+
// Keys present in `prev` but not in `next` are dropped — that's already
|
|
106
|
+
// expressed by `next.keys`. But the length-mismatch flag above catches
|
|
107
|
+
// the changed shape.
|
|
108
|
+
if (!changed) return prev
|
|
109
|
+
return out
|
|
110
|
+
} finally {
|
|
111
|
+
seen.delete(prev)
|
|
112
|
+
seen.delete(next)
|
|
113
|
+
}
|
|
114
|
+
}
|
package/src/query/types.ts
CHANGED
|
@@ -159,10 +159,23 @@ export type QuerySubscription<T> = AsyncState<T>
|
|
|
159
159
|
|
|
160
160
|
/**
|
|
161
161
|
* Options passed to `ctx.use(query, opts)` to control the subscription
|
|
162
|
-
* (reactive key, enabled-gating). The `key` thunk reads signals —
|
|
163
|
-
* when they change re-keys the subscription.
|
|
162
|
+
* (reactive key, enabled-gating). The `key` thunk reads signals —
|
|
163
|
+
* re-evaluating when they change re-keys the subscription.
|
|
164
|
+
*
|
|
165
|
+
* A `select` projection that maps the underlying data shape to a view
|
|
166
|
+
* shape is accepted via a dedicated overload on `Ctx.use` rather than this
|
|
167
|
+
* options bag — the overload threads `T → U` types through cleanly.
|
|
164
168
|
*/
|
|
165
169
|
export type UseOptions<Args extends readonly unknown[]> = {
|
|
166
170
|
key?: () => Args
|
|
167
171
|
enabled?: () => boolean
|
|
168
172
|
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Internal shape — what `createUse` accepts. Includes the optional `select`
|
|
176
|
+
* field used by the `select` overload on `Ctx.use`. Not exported on the
|
|
177
|
+
* public surface; consumers use the typed overload.
|
|
178
|
+
*/
|
|
179
|
+
export type UseInternalOptions<Args extends readonly unknown[], T, U> = UseOptions<Args> & {
|
|
180
|
+
select?: (data: T) => U
|
|
181
|
+
}
|
package/src/query/use.ts
CHANGED
|
@@ -2,17 +2,24 @@ import { computed, effect, type Signal, signal, untracked } from '../signals'
|
|
|
2
2
|
import type { ReadSignal } from '../signals/types'
|
|
3
3
|
import type { ClientEntry, InfiniteClientEntry, QueryClient } from './client'
|
|
4
4
|
import type { InfiniteQuery, InfiniteQuerySpec, InfiniteQuerySubscription } from './infinite'
|
|
5
|
-
import type {
|
|
5
|
+
import type {
|
|
6
|
+
AsyncStatus,
|
|
7
|
+
Query,
|
|
8
|
+
QuerySpec,
|
|
9
|
+
QuerySubscription,
|
|
10
|
+
UseInternalOptions,
|
|
11
|
+
UseOptions,
|
|
12
|
+
} from './types'
|
|
6
13
|
|
|
7
14
|
type QueryInternal<Args extends unknown[], T> = Query<Args, T> & {
|
|
8
15
|
readonly __spec: QuerySpec<Args, T>
|
|
9
16
|
}
|
|
10
17
|
|
|
11
|
-
class SubscriptionImpl<T> implements QuerySubscription<
|
|
18
|
+
class SubscriptionImpl<T, U = T> implements QuerySubscription<U> {
|
|
12
19
|
private readonly current$: Signal<ClientEntry<T> | null> = signal(null)
|
|
13
20
|
private readonly previousData$: Signal<T | undefined> = signal(undefined)
|
|
14
21
|
|
|
15
|
-
readonly data: ReadSignal<
|
|
22
|
+
readonly data: ReadSignal<U | undefined>
|
|
16
23
|
readonly error: ReadSignal<unknown | undefined>
|
|
17
24
|
readonly status: ReadSignal<AsyncStatus>
|
|
18
25
|
readonly isLoading: ReadSignal<boolean>
|
|
@@ -21,14 +28,30 @@ class SubscriptionImpl<T> implements QuerySubscription<T> {
|
|
|
21
28
|
readonly lastUpdatedAt: ReadSignal<number | undefined>
|
|
22
29
|
readonly hasPendingMutations: ReadSignal<boolean>
|
|
23
30
|
|
|
24
|
-
constructor(
|
|
25
|
-
|
|
31
|
+
constructor(
|
|
32
|
+
private readonly keepPreviousData: boolean,
|
|
33
|
+
private readonly select?: (data: T) => U,
|
|
34
|
+
) {
|
|
35
|
+
// The underlying entry stores `T`. The subscription's `data` is `U`
|
|
36
|
+
// (or `T` when no projection). We compute the raw `T` once, then layer
|
|
37
|
+
// `select` in a second computed so the projection's `Object.is` dedup
|
|
38
|
+
// applies BEFORE downstream subscribers run — combined with structural
|
|
39
|
+
// sharing on the entry, an unchanged payload + a stable `select`
|
|
40
|
+
// outputs the same `U` reference and doesn't churn the React tree.
|
|
41
|
+
const rawData = computed(() => {
|
|
26
42
|
const cur = this.current$.value
|
|
27
43
|
const curData = cur?.entry.data.value
|
|
28
44
|
if (curData !== undefined) return curData
|
|
29
45
|
if (keepPreviousData) return this.previousData$.value
|
|
30
46
|
return undefined
|
|
31
47
|
})
|
|
48
|
+
this.data =
|
|
49
|
+
select === undefined
|
|
50
|
+
? (rawData as unknown as ReadSignal<U | undefined>)
|
|
51
|
+
: computed<U | undefined>(() => {
|
|
52
|
+
const raw = rawData.value
|
|
53
|
+
return raw === undefined ? undefined : select(raw)
|
|
54
|
+
})
|
|
32
55
|
this.error = computed(() => this.current$.value?.entry.error.value)
|
|
33
56
|
this.status = computed<AsyncStatus>(() => this.current$.value?.entry.status.value ?? 'idle')
|
|
34
57
|
this.isLoading = computed(() => {
|
|
@@ -59,33 +82,42 @@ class SubscriptionImpl<T> implements QuerySubscription<T> {
|
|
|
59
82
|
this.current$.set(null)
|
|
60
83
|
}
|
|
61
84
|
|
|
62
|
-
refetch = (): Promise<
|
|
85
|
+
refetch = (): Promise<U> => {
|
|
63
86
|
const cur = this.current$.peek()
|
|
64
87
|
if (!cur) return Promise.reject(new Error('[olas] no active subscription'))
|
|
65
|
-
return cur.entry.refetch()
|
|
88
|
+
return cur.entry.refetch().then((v) => this.project(v))
|
|
66
89
|
}
|
|
67
90
|
|
|
68
91
|
reset = (): void => {
|
|
69
92
|
this.current$.peek()?.entry.reset()
|
|
70
93
|
}
|
|
71
94
|
|
|
72
|
-
firstValue = (): Promise<
|
|
95
|
+
firstValue = (): Promise<U> => {
|
|
73
96
|
const cur = this.current$.peek()
|
|
74
97
|
if (!cur) return Promise.reject(new Error('[olas] no active subscription'))
|
|
75
|
-
return cur.entry.firstValue()
|
|
98
|
+
return cur.entry.firstValue().then((v) => this.project(v))
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
private project(v: T): U {
|
|
102
|
+
return this.select === undefined ? (v as unknown as U) : this.select(v)
|
|
76
103
|
}
|
|
77
104
|
}
|
|
78
105
|
|
|
79
106
|
/**
|
|
80
107
|
* Build a subscription + the effect that keeps it bound to the right entry.
|
|
81
108
|
* The controller container wires the disposer into the lifecycle.
|
|
109
|
+
*
|
|
110
|
+
* `keyOrOptions` may carry an optional `select` projection that maps the
|
|
111
|
+
* underlying `T` to a view `U`; the returned subscription's data shape
|
|
112
|
+
* widens accordingly. Without `select`, `U = T` and the projection
|
|
113
|
+
* computed is skipped.
|
|
82
114
|
*/
|
|
83
|
-
export function createUse<Args extends unknown[], T>(
|
|
115
|
+
export function createUse<Args extends unknown[], T, U = T>(
|
|
84
116
|
client: QueryClient,
|
|
85
117
|
query: Query<Args, T>,
|
|
86
|
-
keyOrOptions?: (() => Args) |
|
|
118
|
+
keyOrOptions?: (() => Args) | UseInternalOptions<Args, T, U>,
|
|
87
119
|
): {
|
|
88
|
-
subscription: QuerySubscription<
|
|
120
|
+
subscription: QuerySubscription<U>
|
|
89
121
|
dispose: () => void
|
|
90
122
|
/** Suspend the subscription — release the entry (its refetchInterval +
|
|
91
123
|
* focus/online listeners pause) without disposing it. Spec §4.1. */
|
|
@@ -100,8 +132,10 @@ export function createUse<Args extends unknown[], T>(
|
|
|
100
132
|
const keyFn = typeof keyOrOptions === 'function' ? keyOrOptions : keyOrOptions?.key
|
|
101
133
|
const enabledFn =
|
|
102
134
|
typeof keyOrOptions === 'object' && keyOrOptions !== null ? keyOrOptions.enabled : undefined
|
|
135
|
+
const select =
|
|
136
|
+
typeof keyOrOptions === 'object' && keyOrOptions !== null ? keyOrOptions.select : undefined
|
|
103
137
|
|
|
104
|
-
const sub = new SubscriptionImpl<T>(keepPreviousData)
|
|
138
|
+
const sub = new SubscriptionImpl<T, U>(keepPreviousData, select)
|
|
105
139
|
let currentEntry: ClientEntry<T> | null = null
|
|
106
140
|
let suspended = false
|
|
107
141
|
|
package/src/testing.ts
CHANGED
|
@@ -47,6 +47,7 @@ export function fakeField<T>(
|
|
|
47
47
|
reset: () => void
|
|
48
48
|
markTouched: () => void
|
|
49
49
|
revalidate: () => Promise<boolean>
|
|
50
|
+
setErrors: (errors: ReadonlyArray<string>) => void
|
|
50
51
|
dispose: () => void
|
|
51
52
|
}>,
|
|
52
53
|
): Field<T> {
|
|
@@ -85,6 +86,7 @@ export function fakeField<T>(
|
|
|
85
86
|
reset: overrides?.reset ?? (() => value$.set(currentInitial)),
|
|
86
87
|
markTouched: overrides?.markTouched ?? (() => touched$.set(true)),
|
|
87
88
|
revalidate: overrides?.revalidate ?? (async () => errors$.peek().length === 0),
|
|
89
|
+
setErrors: overrides?.setErrors ?? ((errs) => errors$.set([...errs])),
|
|
88
90
|
dispose: overrides?.dispose ?? (() => {}),
|
|
89
91
|
}
|
|
90
92
|
return fake
|