@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/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
- return setAtomQueryMetadata(Atom.withLabel(`query:${self.id}`)(atom))
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":""}