@kontsedal/olas-core 0.0.2 → 0.0.4

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.
Files changed (44) hide show
  1. package/dist/index.cjs +34 -1
  2. package/dist/index.cjs.map +1 -1
  3. package/dist/index.d.cts +52 -2
  4. package/dist/index.d.cts.map +1 -1
  5. package/dist/index.d.mts +52 -2
  6. package/dist/index.d.mts.map +1 -1
  7. package/dist/index.mjs +33 -2
  8. package/dist/index.mjs.map +1 -1
  9. package/dist/{root-De-6KWIZ.mjs → root-BBSlzvJ2.mjs} +251 -18
  10. package/dist/root-BBSlzvJ2.mjs.map +1 -0
  11. package/dist/{root-XKEsSmcd.cjs → root-CoafhkTg.cjs} +251 -18
  12. package/dist/root-CoafhkTg.cjs.map +1 -0
  13. package/dist/testing.cjs +23 -11
  14. package/dist/testing.cjs.map +1 -1
  15. package/dist/testing.d.cts +3 -1
  16. package/dist/testing.d.cts.map +1 -1
  17. package/dist/testing.d.mts +3 -1
  18. package/dist/testing.d.mts.map +1 -1
  19. package/dist/testing.mjs +23 -11
  20. package/dist/testing.mjs.map +1 -1
  21. package/dist/{types-C-zV1JZA.d.mts → types-BCf2nB2N.d.mts} +73 -8
  22. package/dist/types-BCf2nB2N.d.mts.map +1 -0
  23. package/dist/{types-DKfpkm17.d.cts → types-Ijeun3qo.d.cts} +73 -8
  24. package/dist/types-Ijeun3qo.d.cts.map +1 -0
  25. package/package.json +1 -1
  26. package/src/controller/types.ts +26 -2
  27. package/src/forms/field.ts +42 -9
  28. package/src/forms/form-types.ts +37 -0
  29. package/src/forms/form.ts +118 -0
  30. package/src/forms/index.ts +12 -1
  31. package/src/forms/standard-schema.ts +37 -0
  32. package/src/forms/validators.ts +31 -0
  33. package/src/index.ts +13 -3
  34. package/src/query/entry.ts +10 -3
  35. package/src/query/infinite.ts +8 -1
  36. package/src/query/local.ts +1 -0
  37. package/src/query/structural-share.ts +114 -0
  38. package/src/query/types.ts +22 -2
  39. package/src/query/use.ts +53 -13
  40. package/src/testing.ts +5 -0
  41. package/dist/root-De-6KWIZ.mjs.map +0 -1
  42. package/dist/root-XKEsSmcd.cjs.map +0 -1
  43. package/dist/types-C-zV1JZA.d.mts.map +0 -1
  44. package/dist/types-DKfpkm17.d.cts.map +0 -1
@@ -42,6 +42,10 @@ export interface AmbientDeps {
42
42
  * `ctx.field(initial, validators?)`. Spec §8, §20.7.
43
43
  */
44
44
  export type Field<T> = ReadSignal<T> & {
45
+ /**
46
+ * All errors currently surfaced on this field — validator errors first,
47
+ * server errors after. See `setErrors` for the server-error channel.
48
+ */
45
49
  errors: ReadSignal<string[]>
46
50
  isValid: ReadSignal<boolean>
47
51
  isDirty: ReadSignal<boolean>
@@ -59,6 +63,15 @@ export type Field<T> = ReadSignal<T> & {
59
63
  reset(): void
60
64
  markTouched(): void
61
65
  revalidate(): Promise<boolean>
66
+ /**
67
+ * Pin externally-sourced errors on the field — typically server-side
68
+ * validation results returned from a failed submit. These errors live in
69
+ * a separate channel from validator output, so a re-run of local
70
+ * validators (triggered by a new value or `revalidate()`) does NOT clear
71
+ * them. They're cleared automatically the next time the user writes to
72
+ * the field (via `set`), or explicitly via `setErrors([])` / `reset()`.
73
+ */
74
+ setErrors(errors: ReadonlyArray<string>): void
62
75
  /** Idempotent. Called by the owning controller's dispose. */
63
76
  dispose(): void
64
77
  }
@@ -172,13 +185,24 @@ export type Ctx<TDeps = AmbientDeps> = {
172
185
  },
173
186
  ): LocalCache<T>
174
187
 
188
+ // Select-projecting overload — picked when the options object has a
189
+ // required `select` field. `key`'s return is `readonly [...Args]` so
190
+ // callers writing `() => [id] as const` flow through cleanly.
191
+ use<Args extends unknown[], T, U>(
192
+ source: Query<Args, T>,
193
+ options: {
194
+ key?: () => readonly [...Args]
195
+ enabled?: () => boolean
196
+ select: (data: T) => U
197
+ },
198
+ ): QuerySubscription<U>
175
199
  use<Args extends unknown[], T>(
176
200
  source: Query<Args, T>,
177
- keyOrOptions?: (() => Args) | UseOptions<Args>,
201
+ keyOrOptions?: (() => readonly [...Args]) | UseOptions<Args>,
178
202
  ): QuerySubscription<T>
179
203
  use<Args extends unknown[], TPage, TItem>(
180
204
  source: InfiniteQuery<Args, TPage, TItem>,
181
- keyOrOptions?: (() => Args) | UseOptions<Args>,
205
+ keyOrOptions?: (() => readonly [...Args]) | UseOptions<Args>,
182
206
  ): InfiniteQuerySubscription<TPage, TItem>
183
207
 
184
208
  mutation<V, R>(spec: MutationSpec<V, R>): Mutation<V, R>
@@ -36,7 +36,18 @@ export type ValidatorErrorReporter = (err: unknown) => void
36
36
 
37
37
  class FieldImpl<T> implements Field<T> {
38
38
  private readonly value$: Signal<T>
39
- private readonly errors$: Signal<string[]>
39
+ /**
40
+ * Validator-produced errors. The public `errors` getter merges this with
41
+ * `serverErrors$` so consumers see a single flat array. Kept separate so a
42
+ * re-run of validators (after a new value) doesn't clobber server errors.
43
+ */
44
+ private readonly validatorErrors$: Signal<string[]>
45
+ /**
46
+ * Externally-injected errors — see `setErrors`. Cleared on the next user
47
+ * `set()`, on `reset()`, or via an explicit `setErrors([])`.
48
+ */
49
+ private readonly serverErrors$: Signal<string[]>
50
+ private readonly errors$: Computed<string[]>
40
51
  private readonly touched$: Signal<boolean>
41
52
  private readonly dirty$: Signal<boolean>
42
53
  private readonly validating$: Signal<boolean>
@@ -67,11 +78,19 @@ class FieldImpl<T> implements Field<T> {
67
78
  // post-construct hook so it can't catch the first run).
68
79
  this.onValidatorError = options?.onValidatorError ?? null
69
80
  this.value$ = signal(initial)
70
- this.errors$ = signal<string[]>([])
81
+ this.validatorErrors$ = signal<string[]>([])
82
+ this.serverErrors$ = signal<string[]>([])
71
83
  this.touched$ = signal(false)
72
84
  this.dirty$ = signal(false)
73
85
  this.validating$ = signal(false)
74
86
  this.revalidateTrigger$ = signal(0)
87
+ this.errors$ = computed(() => {
88
+ const v = this.validatorErrors$.value
89
+ const s = this.serverErrors$.value
90
+ if (s.length === 0) return v
91
+ if (v.length === 0) return s
92
+ return [...v, ...s]
93
+ })
75
94
  this.isValid$ = computed(() => this.errors$.value.length === 0 && !this.validating$.value)
76
95
 
77
96
  if (validators.length > 0) {
@@ -126,8 +145,21 @@ class FieldImpl<T> implements Field<T> {
126
145
  // --- mutating methods ---
127
146
  set(value: T): void {
128
147
  if (this.disposed) return
129
- this.value$.set(value)
130
- this.dirty$.set(true)
148
+ batch(() => {
149
+ this.value$.set(value)
150
+ this.dirty$.set(true)
151
+ // Server errors are pinned externally and survive validator re-runs,
152
+ // but they MUST clear when the user edits the field — otherwise a
153
+ // server error like "username taken" would persist after the user
154
+ // typed a different username.
155
+ if (this.serverErrors$.peek().length > 0) this.serverErrors$.set([])
156
+ })
157
+ }
158
+
159
+ setErrors(errors: ReadonlyArray<string>): void {
160
+ if (this.disposed) return
161
+ const next = errors.length === 0 ? [] : [...errors]
162
+ this.serverErrors$.set(next)
131
163
  }
132
164
 
133
165
  /**
@@ -154,7 +186,8 @@ class FieldImpl<T> implements Field<T> {
154
186
  this.value$.set(this.initial)
155
187
  this.dirty$.set(false)
156
188
  this.touched$.set(false)
157
- this.errors$.set([])
189
+ this.validatorErrors$.set([])
190
+ this.serverErrors$.set([])
158
191
  this.validating$.set(false)
159
192
  })
160
193
  }
@@ -262,7 +295,7 @@ class FieldImpl<T> implements Field<T> {
262
295
 
263
296
  if (syncErrors.length > 0) {
264
297
  batch(() => {
265
- this.errors$.set(syncErrors)
298
+ this.validatorErrors$.set(syncErrors)
266
299
  this.validating$.set(false)
267
300
  })
268
301
  this.emitValidated(false, syncErrors)
@@ -271,7 +304,7 @@ class FieldImpl<T> implements Field<T> {
271
304
 
272
305
  if (asyncPromises.length === 0) {
273
306
  batch(() => {
274
- this.errors$.set([])
307
+ this.validatorErrors$.set([])
275
308
  this.validating$.set(false)
276
309
  })
277
310
  this.emitValidated(true, [])
@@ -279,7 +312,7 @@ class FieldImpl<T> implements Field<T> {
279
312
  }
280
313
 
281
314
  batch(() => {
282
- this.errors$.set([])
315
+ this.validatorErrors$.set([])
283
316
  this.validating$.set(true)
284
317
  })
285
318
 
@@ -295,7 +328,7 @@ class FieldImpl<T> implements Field<T> {
295
328
  }
296
329
  }
297
330
  batch(() => {
298
- this.errors$.set(asyncErrors)
331
+ this.validatorErrors$.set(asyncErrors)
299
332
  this.validating$.set(false)
300
333
  })
301
334
  this.emitValidated(asyncErrors.length === 0, asyncErrors)
@@ -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
@@ -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 { email, max, maxLength, min, minLength, pattern, required } from './validators'
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
+ }
@@ -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 { email, max, maxLength, min, minLength, pattern, required } from './forms'
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,
@@ -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(result)
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?.(result)
199
- return result
205
+ this.onSuccessData?.(shared)
206
+ return shared
200
207
  }
201
208
 
202
209
  private applyFailure(err: unknown): never {
@@ -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([page])
216
+ this.pages.set([sharedPage])
210
217
  this.pageParams.set([param])
211
218
  this.error.set(undefined)
212
219
  this.status.set('success')
@@ -83,6 +83,7 @@ class LocalCacheImpl<T> implements LocalCache<T> {
83
83
  refetch = (): Promise<T> => this.entry.refetch()
84
84
  reset = (): void => this.entry.reset()
85
85
  firstValue = (): Promise<T> => this.entry.firstValue()
86
+ promise = (): Promise<T> => this.entry.firstValue()
86
87
  invalidate = (): void => {
87
88
  this.entry.invalidate().catch(() => {})
88
89
  }
@@ -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
+ }
@@ -32,6 +32,13 @@ export type AsyncState<T> = {
32
32
  refetch: () => Promise<T>
33
33
  reset: () => void
34
34
  firstValue: () => Promise<T>
35
+ /**
36
+ * Alias of `firstValue()` — clearer name for Suspense / `React.use(...)`
37
+ * use cases. Resolves with `data` on first success (short-circuits if
38
+ * already settled), rejects with `error` on the first failure. Use this
39
+ * to suspend a React tree until the query lands its first value.
40
+ */
41
+ promise: () => Promise<T>
35
42
  }
36
43
 
37
44
  /**
@@ -159,10 +166,23 @@ export type QuerySubscription<T> = AsyncState<T>
159
166
 
160
167
  /**
161
168
  * Options passed to `ctx.use(query, opts)` to control the subscription
162
- * (reactive key, enabled-gating). The `key` thunk reads signals — re-evaluating
163
- * when they change re-keys the subscription.
169
+ * (reactive key, enabled-gating). The `key` thunk reads signals —
170
+ * re-evaluating when they change re-keys the subscription.
171
+ *
172
+ * A `select` projection that maps the underlying data shape to a view
173
+ * shape is accepted via a dedicated overload on `Ctx.use` rather than this
174
+ * options bag — the overload threads `T → U` types through cleanly.
164
175
  */
165
176
  export type UseOptions<Args extends readonly unknown[]> = {
166
177
  key?: () => Args
167
178
  enabled?: () => boolean
168
179
  }
180
+
181
+ /**
182
+ * Internal shape — what `createUse` accepts. Includes the optional `select`
183
+ * field used by the `select` overload on `Ctx.use`. Not exported on the
184
+ * public surface; consumers use the typed overload.
185
+ */
186
+ export type UseInternalOptions<Args extends readonly unknown[], T, U> = UseOptions<Args> & {
187
+ select?: (data: T) => U
188
+ }