Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/vue-placeholder-data-fallback.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@effect-app/vue": patch
---

Restore TanStack `placeholderData` / `initialData` as a display-level fallback in query views. While pending with no cached value, `data` now returns the resolved placeholder (with `select` applied when set); neither option is written to the atom cache.
9 changes: 9 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,15 @@ This is the Effect App library repository, focusing on functional programming pa
- The git base branch is `main`
- Use `pnpm` as the package manager

### Pull requests

Always open a PR for agent work that changes the repo — do not leave finished work only on a local branch.

- **Draft early**: open a draft PR as soon as there is a meaningful commit (or when starting multi-step work that will land), so review/CI can track progress while local validation is still in flight.
- **Keep the PR current**: push commits as you go; update the PR body if scope shifts.
- **Ready when done**: after mandatory validation (`pnpm lint-fix`, `pnpm check`, and relevant tests) passes, mark the PR ready for review (undraft / publish). Do not leave a finished, validated change as draft.
- **Base**: target `main` unless the work is explicitly stacked on another branch.

### Core Principles

- **Zero Tolerance for Errors**: All automated checks must pass
Expand Down
55 changes: 52 additions & 3 deletions packages/vue/src/query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import * as Exit from "effect/Exit"
import * as Stream from "effect/Stream"
import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"
import * as Atom from "effect/unstable/reactivity/Atom"
import { computed, type ComputedRef, effectScope, type MaybeRefOrGetter, onBeforeUnmount, onMounted, onScopeDispose, ref, toValue, type WatchSource } from "vue"
import { computed, type ComputedRef, effectScope, type MaybeRefOrGetter, onBeforeUnmount, onMounted, onScopeDispose, type Ref, ref, toValue, type WatchSource } from "vue"
import { type AtomClientRuntime, type AtomQueryOptions, awaitAtomResult, buildQueryFamily, buildStreamQueryFamily, disabledQueryAtom, isStaleResult, refreshAtomWithCurrentSpan, staleTimeMsOf, withQueryOptions } from "./atomQuery.ts"
import { latestDefined } from "./suspense.ts"

Expand Down Expand Up @@ -266,6 +266,50 @@ export interface AtomStreamQueryOptions {
readonly refetchInterval?: number
}

/**
* Layer TanStack `initialData` / `placeholderData` over a raw query result as a DISPLAY fallback.
* Only while the result is still `Initial` (pending, never settled): show `initialData` as resolved
* data, or `placeholderData` as provisional (`waiting: true`). Once real data exists the seed is
* dropped. Failures are never masked — including failures without `previousSuccess` (where
* `AsyncResult.value` is empty). `placeholderData` in function form receives the last seen concrete
* value for basic keep-previous across input changes. Returns the raw ref untouched when neither
* option is set. Neither value is written to the atom cache.
*/
export const withDataFallback = <A, E>(
rawResult: Ref<AsyncResult.AsyncResult<A, E>>,
options?: unknown
): ComputedRef<AsyncResult.AsyncResult<A, E>> => {
const opts = options as
| { readonly initialData?: A | (() => A); readonly placeholderData?: A | ((prev: A | undefined) => A) }
| undefined
const initialData = opts?.initialData
const placeholderData = opts?.placeholderData
if (initialData === undefined && placeholderData === undefined) {
return rawResult as ComputedRef<AsyncResult.AsyncResult<A, E>>
}

let lastData: A | undefined
return computed(() => {
const r = rawResult.value
const concrete = AsyncResult.value(r)
if (Option.isSome(concrete)) {
lastData = concrete.value
}
// Fallback is pending-only. Failure (even without previousSuccess) and Success pass through.
if (!AsyncResult.isInitial(r)) {
return r
}
if (initialData !== undefined) {
const v = typeof initialData === "function" ? (initialData as () => A)() : initialData
return AsyncResult.success(v, { waiting: r.waiting })
}
const v = typeof placeholderData === "function"
? (placeholderData as (prev: A | undefined) => A)(lastData)
: placeholderData
return v === undefined ? r : AsyncResult.success(v, { waiting: true })
})
}

const normalizeQueryOptions = (options?: {
readonly staleTime?: number
readonly gcTime?: number | "infinity"
Expand Down Expand Up @@ -506,10 +550,15 @@ const makeQueryView = <I, A, E, TData>(
const family = getQueryFamily(atomRt, q)
const atomRef = computed(() => enabledRef.value ? observedAtom(family(req.value), options) : disabledQueryAtom)
const rawResult = useAtomValue(() => atomRef.value) as ComputedRef<AsyncResult.AsyncResult<A, E>>
// `initialData` / `placeholderData` display fallback (old `.query()` API). Applied only while the
// result is still Initial; Failures/Success pass through unmasked. Neither is written to cache
// (the base atom stays Initial, so the mount-staleness check still triggers the real fetch). The
// fallback runs PRE-select, matching TanStack (`initialData`/`placeholderData` are TQueryData).
const fallbackResult = withDataFallback(rawResult, options)
const select = options?.select
const result = (select
? computed(() => AsyncResult.map(rawResult.value, select))
: rawResult) as ComputedRef<AsyncResult.AsyncResult<TData, E>>
? computed(() => AsyncResult.map(fallbackResult.value, select))
: fallbackResult) as ComputedRef<AsyncResult.AsyncResult<TData, E>>
const refresh = () => registry.refresh(atomRef.value)
const awaitResult = () =>
select
Expand Down
88 changes: 88 additions & 0 deletions packages/vue/test/queryOptions.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import { expect, it } from "@effect/vitest"
import * as Cause from "effect/Cause"
import * as Option from "effect/Option"
import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"
import { shallowRef } from "vue"
import { withDataFallback } from "../src/query.js"

it("withDataFallback: returns the raw ref untouched when neither option is set", () => {
const raw = shallowRef(AsyncResult.initial<number, never>(false))
expect(withDataFallback(raw, undefined)).toBe(raw)
expect(withDataFallback(raw, {})).toBe(raw)
})

it("withDataFallback: placeholderData is provisional (waiting) and dropped once real data exists", () => {
const raw = shallowRef<AsyncResult.AsyncResult<number, never>>(AsyncResult.initial(true))
const out = withDataFallback(raw, { placeholderData: 1 })

// while pending with no value, surface the placeholder as provisional
expect(out.value).toMatchObject({ _tag: "Success", value: 1, waiting: true })

// real data replaces the placeholder (display-only — raw was never written)
raw.value = AsyncResult.success(2)
expect(out.value).toMatchObject({ _tag: "Success", value: 2, waiting: false })
})

it("withDataFallback: placeholderData function form receives the last seen concrete value", () => {
const raw = shallowRef<AsyncResult.AsyncResult<number, never>>(AsyncResult.success(10))
const out = withDataFallback(raw, { placeholderData: (prev: number | undefined) => (prev ?? 0) + 1 })

// concrete value present -> shown as-is, recorded as "previous"
expect(out.value).toMatchObject({ _tag: "Success", value: 10 })

// input changed -> back to Initial: placeholder fn sees the previous value (10)
raw.value = AsyncResult.initial(true)
expect(out.value).toMatchObject({ _tag: "Success", value: 11, waiting: true })
})

it("withDataFallback: initialData shows as resolved data while Initial, dropped on real success", () => {
const raw = shallowRef<AsyncResult.AsyncResult<number, never>>(AsyncResult.initial(false))
const out = withDataFallback(raw, { initialData: 7 })

expect(out.value).toMatchObject({ _tag: "Success", value: 7, waiting: false })

const out2 = withDataFallback(raw, { initialData: () => 9 })
expect(out2.value).toMatchObject({ _tag: "Success", value: 9 })

raw.value = AsyncResult.success(42)
expect(out.value).toMatchObject({ _tag: "Success", value: 42 })
})

it("withDataFallback: initialData takes precedence over placeholderData", () => {
const raw = shallowRef<AsyncResult.AsyncResult<number, never>>(AsyncResult.initial(false))
const out = withDataFallback(raw, { initialData: 1, placeholderData: 99 })
expect(out.value).toMatchObject({ _tag: "Success", value: 1 })
})

it("withDataFallback: select applies to the placeholder when layered after the fallback", () => {
// mirrors makeQueryView: fallback pre-select, then AsyncResult.map(select)
const raw = shallowRef<AsyncResult.AsyncResult<number, never>>(AsyncResult.initial(true))
const fallback = withDataFallback(raw, { placeholderData: 123 })
const selected = () => AsyncResult.map(fallback.value, (n) => n.toString())

expect(selected()).toMatchObject({ _tag: "Success", value: "123", waiting: true })

raw.value = AsyncResult.success(456)
expect(selected()).toMatchObject({ _tag: "Success", value: "456", waiting: false })
})

it("withDataFallback: does not mask Failure without previousSuccess", () => {
const fail = AsyncResult.failure<number, string>(Cause.fail("boom"))
const raw = shallowRef<AsyncResult.AsyncResult<number, string>>(fail)
const out = withDataFallback(raw, { placeholderData: 1, initialData: 2 })

// Failure has no value — old gate would have fabricated Success; must pass Failure through
expect(out.value).toBe(fail)
expect(AsyncResult.isFailure(out.value)).toBe(true)
})

it("withDataFallback: Failure with previousSuccess still surfaces as Failure", () => {
const fail = AsyncResult.failureWithPrevious<number, string>(Cause.fail("boom"), {
previous: Option.some(AsyncResult.success(10))
})
const raw = shallowRef<AsyncResult.AsyncResult<number, string>>(fail)
const out = withDataFallback(raw, { placeholderData: 99 })

expect(out.value).toBe(fail)
expect(AsyncResult.isFailure(out.value)).toBe(true)
})
Loading