From 881e702787f2f6a2882ff710eb32c40275561f2d Mon Sep 17 00:00:00 2001 From: "omegent-app[bot]" <306514130+omegent-app[bot]@users.noreply.github.com> Date: Fri, 24 Jul 2026 21:13:19 +0000 Subject: [PATCH 1/3] fix(vue): restore placeholderData/initialData display fallback TanStack-compatible withDataFallback in makeQueryView: while pending with no cached value, surface placeholder/initial data (pre-select) without writing to the atom cache. Also document that agents must open PRs (draft while validating, ready when done). --- .changeset/vue-placeholder-data-fallback.md | 5 ++ AGENTS.md | 9 +++ packages/vue/src/query.ts | 52 ++++++++++++++++- packages/vue/test/queryOptions.test.ts | 65 +++++++++++++++++++++ 4 files changed, 128 insertions(+), 3 deletions(-) create mode 100644 .changeset/vue-placeholder-data-fallback.md create mode 100644 packages/vue/test/queryOptions.test.ts diff --git a/.changeset/vue-placeholder-data-fallback.md b/.changeset/vue-placeholder-data-fallback.md new file mode 100644 index 000000000..629fabaa4 --- /dev/null +++ b/.changeset/vue-placeholder-data-fallback.md @@ -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. diff --git a/AGENTS.md b/AGENTS.md index 636fa7606..a152daf98 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 diff --git a/packages/vue/src/query.ts b/packages/vue/src/query.ts index f12625c3f..7cea3d03d 100644 --- a/packages/vue/src/query.ts +++ b/packages/vue/src/query.ts @@ -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" @@ -266,6 +266,46 @@ export interface AtomStreamQueryOptions { readonly refetchInterval?: number } +/** + * Layer TanStack `initialData` / `placeholderData` over a raw query result as a DISPLAY fallback. + * When the result has no concrete value yet (Initial — a Failure keeps its `previousSuccess`), show + * the seed value: `initialData` as resolved data, `placeholderData` as provisional (`waiting: true`, + * dropped once real data exists). `placeholderData` in function form receives the last seen concrete + * value, giving basic keep-previous behaviour across refetches/input changes. Returns the raw ref + * untouched when neither option is set. Neither value is written to the atom cache. + */ +export const withDataFallback = ( + rawResult: Ref>, + options?: unknown +): ComputedRef> => { + 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> + } + + let lastData: A | undefined + return computed(() => { + const r = rawResult.value + const concrete = AsyncResult.value(r) + if (Option.isSome(concrete)) { + lastData = concrete.value + 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" @@ -506,10 +546,16 @@ const makeQueryView = ( const family = getQueryFamily(atomRt, q) const atomRef = computed(() => enabledRef.value ? observedAtom(family(req.value), options) : disabledQueryAtom) const rawResult = useAtomValue(() => atomRef.value) as ComputedRef> + // `initialData` / `placeholderData` display fallback (old `.query()` API). Both surface a value + // while the query has none yet; `initialData` is shown as resolved data, `placeholderData` stays + // provisional (`waiting`) and is dropped the moment real data arrives. 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> + ? computed(() => AsyncResult.map(fallbackResult.value, select)) + : fallbackResult) as ComputedRef> const refresh = () => registry.refresh(atomRef.value) const awaitResult = () => select diff --git a/packages/vue/test/queryOptions.test.ts b/packages/vue/test/queryOptions.test.ts new file mode 100644 index 000000000..14fdd5ec6 --- /dev/null +++ b/packages/vue/test/queryOptions.test.ts @@ -0,0 +1,65 @@ +import { expect, it } from "@effect/vitest" +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(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.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.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.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.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.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 }) +}) From fa389f7747c90912f7576da8e7470ae2b9e20181 Mon Sep 17 00:00:00 2001 From: "omegent-app[bot]" <306514130+omegent-app[bot]@users.noreply.github.com> Date: Fri, 24 Jul 2026 21:25:38 +0000 Subject: [PATCH 2/3] fix(vue): apply withDataFallback only on Initial Failures without previousSuccess have no AsyncResult.value and were incorrectly replaced by placeholder/initialData. Gate the display fallback on isInitial so errors pass through unmasked. --- packages/vue/src/query.ts | 19 +++++++++++-------- packages/vue/test/queryOptions.test.ts | 22 ++++++++++++++++++++++ 2 files changed, 33 insertions(+), 8 deletions(-) diff --git a/packages/vue/src/query.ts b/packages/vue/src/query.ts index 7cea3d03d..372b02c1c 100644 --- a/packages/vue/src/query.ts +++ b/packages/vue/src/query.ts @@ -268,11 +268,12 @@ export interface AtomStreamQueryOptions { /** * Layer TanStack `initialData` / `placeholderData` over a raw query result as a DISPLAY fallback. - * When the result has no concrete value yet (Initial — a Failure keeps its `previousSuccess`), show - * the seed value: `initialData` as resolved data, `placeholderData` as provisional (`waiting: true`, - * dropped once real data exists). `placeholderData` in function form receives the last seen concrete - * value, giving basic keep-previous behaviour across refetches/input changes. Returns the raw ref - * untouched when neither option is set. Neither value is written to the atom cache. + * 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 = ( rawResult: Ref>, @@ -293,6 +294,9 @@ export const withDataFallback = ( 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) { @@ -546,9 +550,8 @@ const makeQueryView = ( const family = getQueryFamily(atomRt, q) const atomRef = computed(() => enabledRef.value ? observedAtom(family(req.value), options) : disabledQueryAtom) const rawResult = useAtomValue(() => atomRef.value) as ComputedRef> - // `initialData` / `placeholderData` display fallback (old `.query()` API). Both surface a value - // while the query has none yet; `initialData` is shown as resolved data, `placeholderData` stays - // provisional (`waiting`) and is dropped the moment real data arrives. Neither is written to cache + // `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) diff --git a/packages/vue/test/queryOptions.test.ts b/packages/vue/test/queryOptions.test.ts index 14fdd5ec6..82ebe5fc0 100644 --- a/packages/vue/test/queryOptions.test.ts +++ b/packages/vue/test/queryOptions.test.ts @@ -1,4 +1,5 @@ import { expect, it } from "@effect/vitest" +import * as Cause from "effect/Cause" import * as AsyncResult from "effect/unstable/reactivity/AsyncResult" import { shallowRef } from "vue" import { withDataFallback } from "../src/query.js" @@ -63,3 +64,24 @@ it("withDataFallback: select applies to the placeholder when layered after the f 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(Cause.fail("boom")) + const raw = shallowRef>(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(Cause.fail("boom"), { + previous: AsyncResult.success(10) + }) + const raw = shallowRef>(fail) + const out = withDataFallback(raw, { placeholderData: 99 }) + + expect(out.value).toBe(fail) + expect(AsyncResult.isFailure(out.value)).toBe(true) +}) From 9dc3c1b74205597f3c477a9a88a7b437e0b271ea Mon Sep 17 00:00:00 2001 From: "omegent-app[bot]" <306514130+omegent-app[bot]@users.noreply.github.com> Date: Fri, 24 Jul 2026 21:38:07 +0000 Subject: [PATCH 3/3] fix(vue): type failureWithPrevious previous as Option in test --- packages/vue/test/queryOptions.test.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/vue/test/queryOptions.test.ts b/packages/vue/test/queryOptions.test.ts index 82ebe5fc0..ac1279e28 100644 --- a/packages/vue/test/queryOptions.test.ts +++ b/packages/vue/test/queryOptions.test.ts @@ -1,5 +1,6 @@ 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" @@ -76,8 +77,8 @@ it("withDataFallback: does not mask Failure without previousSuccess", () => { }) it("withDataFallback: Failure with previousSuccess still surfaces as Failure", () => { - const fail = AsyncResult.failureWithPrevious(Cause.fail("boom"), { - previous: AsyncResult.success(10) + const fail = AsyncResult.failureWithPrevious(Cause.fail("boom"), { + previous: Option.some(AsyncResult.success(10)) }) const raw = shallowRef>(fail) const out = withDataFallback(raw, { placeholderData: 99 })