@effect-app/vue 4.0.0-beta.291 → 4.0.0-beta.292
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/CHANGELOG.md +11 -0
- package/dist/atomQuery.d.ts +5 -0
- package/dist/atomQuery.d.ts.map +1 -1
- package/dist/atomQuery.js +37 -4
- package/dist/makeClient.d.ts +24 -0
- package/dist/makeClient.d.ts.map +1 -1
- package/dist/makeClient.js +32 -1
- package/package.json +2 -2
- package/src/atomQuery.ts +64 -2
- package/src/makeClient.ts +37 -0
- package/test/dist/interrupt-refetch-repro.test.d.ts.map +1 -0
- package/test/interrupt-refetch-repro.test.ts +616 -0
package/src/atomQuery.ts
CHANGED
|
@@ -130,7 +130,6 @@ export const invalidateAndAwait = (keys: ReadonlyArray<unknown>): Effect.Effect<
|
|
|
130
130
|
const atoms = atomsForKeys(keys)
|
|
131
131
|
yield* Effect.forEach(atoms, captureAtomQueryParentSpan, { discard: true, concurrency: "inherit" })
|
|
132
132
|
yield* Reactivity.invalidate(keys) // invalidates everything but only refreshes what's mounted
|
|
133
|
-
// for (const a of atoms) defaultRegistry.refresh(a) // refreshes everything even when not mounted
|
|
134
133
|
if (atoms.length === 0) return
|
|
135
134
|
yield* Effect.forEach(atoms, (a) => awaitAtomResult(defaultRegistry, a).pipe(Effect.exit))
|
|
136
135
|
})
|
|
@@ -306,6 +305,49 @@ export const isStaleResult = (r: AsyncResult.AsyncResult<any, any>, staleTimeMs:
|
|
|
306
305
|
export const staleTimeMsOf = (opts: AtomQueryOptions): number =>
|
|
307
306
|
Duration.toMillis(Duration.fromInputUnsafe(opts.staleTime ?? defaults.staleTime))
|
|
308
307
|
|
|
308
|
+
// A query fetch that is interrupted (a subscriber lost interest / a refresh superseded it / the
|
|
309
|
+
// component navigated) leaves the family atom `waiting=true` with NO fiber running — effect-core
|
|
310
|
+
// `makeEffect` removes its result-observer before interrupting, so the interrupt is never written
|
|
311
|
+
// back; it stays `waitingFrom(previous)`. `swr`/`isStaleResult` both short-circuit
|
|
312
|
+
// `if (waiting) return false`, so that stuck `waiting` is neither completing nor considered stale —
|
|
313
|
+
// mount/focus/staleness all skip it. Left alone the interrupt is TERMINAL: any current or future
|
|
314
|
+
// subscriber inherits the `waiting` forever (cold → `latestDefined` throws → white screen; warm →
|
|
315
|
+
// the Mako Bauhaus PickList stale wedge).
|
|
316
|
+
//
|
|
317
|
+
// The invariant we restore in this layer (no effect-core change): an interrupt must never be a
|
|
318
|
+
// terminal state. We do NOT auto-retry the interrupted fetch — an interrupt is intentional (that
|
|
319
|
+
// caller lost interest), and the first observer recovers through its normal refresh/staleness rules.
|
|
320
|
+
// We only guarantee that a *genuine* new/parallel/future observer is never stuck on a departed
|
|
321
|
+
// observer's interrupt: per family atom we count live computes (`inFlight`), so "stuck" = a `waiting`
|
|
322
|
+
// result with `inFlight === 0` (nothing is actually fetching). On mount a stuck atom triggers one
|
|
323
|
+
// fresh fetch instead of adopting the dangling `waiting`; a genuinely in-flight fetch (`inFlight > 0`)
|
|
324
|
+
// is joined, not superseded (dedup / re-entrancy preserved). `recovering` dedupes concurrent mounts
|
|
325
|
+
// so exactly one recovery fetch is issued until a fetch is running again.
|
|
326
|
+
export interface QueryFetchState {
|
|
327
|
+
inFlight: number
|
|
328
|
+
recovering: boolean
|
|
329
|
+
}
|
|
330
|
+
export const queryFetchStates = new WeakMap<Atom.Atom<any>, QueryFetchState>()
|
|
331
|
+
|
|
332
|
+
const recoverStuckWaitingOnMount =
|
|
333
|
+
<A, E>(familyAtom: Atom.Atom<AsyncResult.AsyncResult<A, E>>) =>
|
|
334
|
+
(wrapped: Atom.Atom<AsyncResult.AsyncResult<A, E>>): Atom.Atom<AsyncResult.AsyncResult<A, E>> =>
|
|
335
|
+
Atom.transform(wrapped, (get) => {
|
|
336
|
+
const current = get.once(wrapped)
|
|
337
|
+
get.subscribe(wrapped, (value) => get.setSelf(value))
|
|
338
|
+
const state = queryFetchStates.get(familyAtom)
|
|
339
|
+
const waiting = current?.waiting === true
|
|
340
|
+
// Stuck: the result says `waiting` yet nothing is in-flight — an interrupt was hidden and left
|
|
341
|
+
// `waiting` dangling. Treat it as not-yet-fetched and refetch; a live fetch (`inFlight > 0`) is
|
|
342
|
+
// joined, not superseded. `recovering` prevents concurrent mounts from issuing more than one
|
|
343
|
+
// recovery fetch (cleared once a fetch is actually running again).
|
|
344
|
+
if (state && waiting && state.inFlight === 0 && !state.recovering) {
|
|
345
|
+
state.recovering = true
|
|
346
|
+
get.refresh(familyAtom)
|
|
347
|
+
}
|
|
348
|
+
return current
|
|
349
|
+
}, { initialValueTarget: wrapped })
|
|
350
|
+
|
|
309
351
|
export const withQueryOptions = <A, E>(
|
|
310
352
|
self: Atom.Atom<AsyncResult.AsyncResult<A, E>>,
|
|
311
353
|
opts: AtomQueryOptions = {}
|
|
@@ -319,6 +361,7 @@ export const withQueryOptions = <A, E>(
|
|
|
319
361
|
revalidateOnFocus,
|
|
320
362
|
focusSignal: revalidateOnFocus ? focusOrReconnectSignal : undefined
|
|
321
363
|
})(atom)
|
|
364
|
+
atom = recoverStuckWaitingOnMount(self)(atom)
|
|
322
365
|
if (opts.refetchInterval) atom = Atom.withRefresh(Duration.millis(opts.refetchInterval))(atom)
|
|
323
366
|
if (opts.structuralSharing ?? true) atom = structuralShare(atom)
|
|
324
367
|
return atom
|
|
@@ -388,9 +431,16 @@ export const buildQueryFamily = <I, A, E>(
|
|
|
388
431
|
// The last recorded reads are retained on the (memoized) family atom so `trackReadDependencies`
|
|
389
432
|
// can re-assert them on a cache-hit remount that never re-runs the handler.
|
|
390
433
|
let lastReads: DataDependencies.DataDependencies = DataDependencies.empty()
|
|
434
|
+
// Fetch bookkeeping read by `recoverStuckWaitingOnMount`. `inFlight` counts live computes, so a
|
|
435
|
+
// `waiting` result with `inFlight === 0` is a stuck/hidden interrupt. `recovering` guards the
|
|
436
|
+
// recovery refresh against concurrent mounts; a running fetch clears it.
|
|
437
|
+
const fetchState: QueryFetchState = { inFlight: 0, recovering: false }
|
|
391
438
|
let atom: Atom.Atom<AsyncResult.AsyncResult<A, E>>
|
|
392
439
|
atom = rt.runtime.atom(
|
|
393
440
|
Effect.suspend(() => {
|
|
441
|
+
// A fetch is now running: the atom is not stuck, and any pending recovery is fulfilled.
|
|
442
|
+
fetchState.recovering = false
|
|
443
|
+
fetchState.inFlight++
|
|
394
444
|
const recordReads = Effect.gen(function*() {
|
|
395
445
|
const readsRef = yield* Ref.make(DataDependencies.empty())
|
|
396
446
|
const writesRef = yield* Ref.make(DataDependencies.empty())
|
|
@@ -405,6 +455,15 @@ export const buildQueryFamily = <I, A, E>(
|
|
|
405
455
|
const effect = recordReads.pipe(
|
|
406
456
|
Effect.retry({ times: 5, while: isRetryable }),
|
|
407
457
|
Effect.tapCauseIf(Cause.hasDies, (cause) => reportRuntimeError(cause)),
|
|
458
|
+
// On exit, the compute is no longer in-flight. An interrupt (subscriber lost interest / a
|
|
459
|
+
// superseding refresh) may leave the result at `waiting`; with `inFlight` back at 0 that
|
|
460
|
+
// reads as "stuck", so the next mount recovers it (`recoverStuckWaitingOnMount`). We do not
|
|
461
|
+
// re-fire here — the interrupt was intentional; recovery is driven by a genuine (re)mount.
|
|
462
|
+
Effect.onExit(() =>
|
|
463
|
+
Effect.sync(() => {
|
|
464
|
+
fetchState.inFlight = Math.max(0, fetchState.inFlight - 1)
|
|
465
|
+
})
|
|
466
|
+
),
|
|
408
467
|
Effect.withSpan(`query ${self.id}`, {}, { captureStackTrace: false })
|
|
409
468
|
)
|
|
410
469
|
const parentSpan = takeAtomQueryParentSpan(atom)
|
|
@@ -426,7 +485,10 @@ export const buildQueryFamily = <I, A, E>(
|
|
|
426
485
|
// gcTime LAST so the whole chain (incl. the registration + tracking) stays alive through the
|
|
427
486
|
// idle window, letting invalidation reach a cached-but-unmounted query.
|
|
428
487
|
atom = Atom.setIdleTTL(atom, defaults.gcTime)
|
|
429
|
-
|
|
488
|
+
const registered = setAtomQueryMetadata(Atom.withLabel(`query:${self.id}`)(atom))
|
|
489
|
+
// Key the fetch state by the atom `withQueryOptions` receives, so its mount hook can find it.
|
|
490
|
+
queryFetchStates.set(registered, fetchState)
|
|
491
|
+
return registered
|
|
430
492
|
})
|
|
431
493
|
}
|
|
432
494
|
|
package/src/makeClient.ts
CHANGED
|
@@ -8,6 +8,8 @@ import * as Context from "effect-app/Context"
|
|
|
8
8
|
import * as Effect from "effect-app/Effect"
|
|
9
9
|
import * as Layer from "effect-app/Layer"
|
|
10
10
|
import * as S from "effect-app/Schema"
|
|
11
|
+
import * as Cause from "effect/Cause"
|
|
12
|
+
import * as Data from "effect/Data"
|
|
11
13
|
import * as Exit from "effect/Exit"
|
|
12
14
|
import { type Fiber } from "effect/Fiber"
|
|
13
15
|
import * as Hash from "effect/Hash"
|
|
@@ -53,6 +55,31 @@ const useScopedSuspenseSetup = <A>(setup: () => A) => {
|
|
|
53
55
|
return [value, isMounted, controller.signal] as const
|
|
54
56
|
}
|
|
55
57
|
|
|
58
|
+
/**
|
|
59
|
+
* A suspense query settled with an interrupt-only cause while its component was still MOUNTED.
|
|
60
|
+
*
|
|
61
|
+
* That combination classifies the interrupt's origin: a navigation/unmount cancel interrupts the
|
|
62
|
+
* suspense *fiber* (via the scope's AbortSignal) or is hidden by effect-core before it can settle —
|
|
63
|
+
* it never yields a still-mounted `Failure` exit. A `Failure(interrupt-only)` observed while mounted
|
|
64
|
+
* therefore came from the QUERY side — e.g. the server returned an RPC interrupt `Exit` (over a 200),
|
|
65
|
+
* or the fetch was superseded — and silencing it leaves the user staring at a blank page.
|
|
66
|
+
*
|
|
67
|
+
* Error boundaries can rely on this split: keep silencing raw interrupt-only causes (those are
|
|
68
|
+
* genuinely "user navigated away"), and render this error as "the request was interrupted — reload?".
|
|
69
|
+
*/
|
|
70
|
+
export class SuspenseInterruptedError extends Data.TaggedError("SuspenseInterruptedError")<{
|
|
71
|
+
readonly originalCause: Cause.Cause<unknown>
|
|
72
|
+
readonly source: "suspense" | "suspenseNew"
|
|
73
|
+
}> {
|
|
74
|
+
override get message() {
|
|
75
|
+
return "The request was interrupted while the page was still active"
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Structural check (by `_tag`) so consumers compiled against older published types can detect it. */
|
|
80
|
+
export const isSuspenseInterruptedError = (e: unknown): e is SuspenseInterruptedError =>
|
|
81
|
+
typeof e === "object" && e !== null && "_tag" in e && e._tag === "SuspenseInterruptedError"
|
|
82
|
+
|
|
56
83
|
// TODO: optimize - work from encoded shape directly
|
|
57
84
|
const projectHandler = <
|
|
58
85
|
I,
|
|
@@ -532,6 +559,12 @@ export class QueryImpl<R> {
|
|
|
532
559
|
return yield* Effect.interrupt
|
|
533
560
|
}
|
|
534
561
|
if (Exit.isFailure(exit)) {
|
|
562
|
+
// Still mounted, so this interrupt came from the query side (e.g. a server RPC interrupt
|
|
563
|
+
// Exit), not from navigation/unmount — surface it as a renderable typed error instead of a
|
|
564
|
+
// raw interrupt the error boundary would silence into a blank page.
|
|
565
|
+
if (Cause.hasInterruptsOnly(exit.cause)) {
|
|
566
|
+
return yield* new SuspenseInterruptedError({ originalCause: exit.cause, source: "suspense" })
|
|
567
|
+
}
|
|
535
568
|
return yield* Exit.failCause(exit.cause)
|
|
536
569
|
}
|
|
537
570
|
|
|
@@ -579,6 +612,10 @@ export class QueryImpl<R> {
|
|
|
579
612
|
return yield* Effect.interrupt
|
|
580
613
|
}
|
|
581
614
|
if (Exit.isFailure(exit)) {
|
|
615
|
+
// Still mounted → query-side interrupt (see SuspenseInterruptedError) — render, don't silence.
|
|
616
|
+
if (Cause.hasInterruptsOnly(exit.cause)) {
|
|
617
|
+
return yield* new SuspenseInterruptedError({ originalCause: exit.cause, source: "suspenseNew" })
|
|
618
|
+
}
|
|
582
619
|
return yield* Exit.failCause(exit.cause)
|
|
583
620
|
}
|
|
584
621
|
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"interrupt-refetch-repro.test.d.ts","sourceRoot":"","sources":["../interrupt-refetch-repro.test.ts"],"names":[],"mappings":""}
|