From ea8ae4a8f27d7a10910a2d701b3794c215f03fa4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vojt=C4=9Bch=20P=C3=A1rys?= Date: Mon, 1 Jun 2026 11:42:18 +0200 Subject: [PATCH 1/3] feat(react): expose queryKey on Entity/EntityList with stale-while-revalidate refetch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #40 Adds a `queryKey` prop to `` and `` that triggers a re-fetch under the same accessor identity — no remount, no flicker, no loss of local UI state (scroll, dialogs, drafts, undo history). The plumbing for `queryKey` already existed in `useEntity` / `useEntityList` options; the JSX components just didn't surface it. The missing half was stale-while-revalidate semantics: previously a refetch unconditionally dispatched `setLoadState('loading')`, sending the subtree back to the loading fallback even when ready data was already in the store. Now, if the entity is already in the ready state, the refetch runs in the background and the consumer sees `$isRefetching: true` on the ready accessor instead. Use case: workflow RPCs mutate entities behind bindx's back. The previous escape hatch was ``, which forces a remount and loses everything beneath it. `` replaces that with a transparent refresh. - `useEntity` / `useEntityList`: skip the `loading` dispatch when ready data already exists; expose `$isRefetching` on the ready result. - `` / ``: accept `queryKey` prop, combined with the internal selection key so both selection changes and explicit refreshes trigger refetches. - Tests covering: refetch swaps in new data, subtree stays mounted, DOM node identity preserved, locally-held React state (input drafts) survives. --- packages/bindx-react/src/hooks/useEntity.ts | 41 +++- .../bindx-react/src/hooks/useEntityList.ts | 37 +++- .../bindx-react/src/jsx/components/Entity.tsx | 20 +- .../src/jsx/components/EntityList.tsx | 16 +- tests/react/hooks/useEntity/queryKey.test.tsx | 197 +++++++++++++++++ tests/react/jsx/EntityQueryKey.test.tsx | 209 ++++++++++++++++++ 6 files changed, 506 insertions(+), 14 deletions(-) create mode 100644 tests/react/hooks/useEntity/queryKey.test.tsx create mode 100644 tests/react/jsx/EntityQueryKey.test.tsx diff --git a/packages/bindx-react/src/hooks/useEntity.ts b/packages/bindx-react/src/hooks/useEntity.ts index 3a599ef..be3efc4 100644 --- a/packages/bindx-react/src/hooks/useEntity.ts +++ b/packages/bindx-react/src/hooks/useEntity.ts @@ -1,4 +1,4 @@ -import { useRef, useEffect, useMemo, useCallback } from 'react' +import { useRef, useEffect, useMemo, useCallback, useState } from 'react' import type { EntityDef, EntityUniqueWhere, @@ -63,6 +63,7 @@ interface UseEntityResultBase { export type LoadingEntityResult = UseEntityResultBase & { readonly $status: 'loading' readonly $isLoading: true + readonly $isRefetching: false readonly $isError: false readonly $isNotFound: false readonly $error: null @@ -72,6 +73,7 @@ export type LoadingEntityResult = UseEntityResultBase & { export type ErrorEntityResult = UseEntityResultBase & { readonly $status: 'error' readonly $isLoading: false + readonly $isRefetching: false readonly $isError: true readonly $isNotFound: false readonly $error: FieldError @@ -81,6 +83,7 @@ export type ErrorEntityResult = UseEntityResultBase & { export type NotFoundEntityResult = UseEntityResultBase & { readonly $status: 'not_found' readonly $isLoading: false + readonly $isRefetching: false readonly $isError: false readonly $isNotFound: true readonly $error: null @@ -89,11 +92,18 @@ export type NotFoundEntityResult = UseEntityResultBase & { /** * Ready state — full EntityAccessor with status metadata. + * + * `$isRefetching` is `true` while a background re-fetch is in flight + * (triggered by a `queryKey` change while ready data is already present). + * The accessor identity and field values remain stable until the new data + * arrives, so the subtree does not unmount — useful for stale-while-revalidate + * indicators (subtle spinner, "stale" badge, etc.). */ export type ReadyEntityResult = UseEntityResultBase & EntityAccessor & { readonly $status: 'ready' readonly $isLoading: false + readonly $isRefetching: boolean readonly $isError: false readonly $isNotFound: false readonly $error: null @@ -236,6 +246,7 @@ export function useEntity( // --- Data loading --- const fetchingRef = useRef(null) + const [isRefetching, setIsRefetching] = useState(false) useEffect(() => { // Check cache first @@ -253,7 +264,17 @@ export function useEntity( const abortController = new AbortController() - dispatcher.dispatch(setLoadState(entityType, id, 'loading')) + // Stale-while-revalidate: if we already have ready data for this entity, + // keep the ready state visible (no flicker) and only flag a background refetch. + // Otherwise, fall back to the explicit loading state. + const existing = store.getLoadState(entityType, id) + const haveReadyData = existing?.status === 'success' && store.hasEntity(entityType, id) + + if (haveReadyData) { + setIsRefetching(true) + } else { + dispatcher.dispatch(setLoadState(entityType, id, 'loading')) + } const fetchData = async (): Promise => { try { @@ -282,6 +303,10 @@ export function useEntity( dispatcher.dispatch( setLoadState(entityType, id, 'error', createLoadError(normalizedError)), ) + } finally { + if (!abortController.signal.aborted) { + setIsRefetching(false) + } } } @@ -324,20 +349,20 @@ export function useEntity( // --- Build result --- const result = useMemo((): UseEntityResult => { if (!loadState || loadState.status === 'loading' || (!snapshot && loadState.status === 'success')) { - return { $status: 'loading', $isLoading: true, $isError: false, $isNotFound: false, $error: null, id, $persist: persist, $reset: reset } + return { $status: 'loading', $isLoading: true, $isRefetching: false, $isError: false, $isNotFound: false, $error: null, id, $persist: persist, $reset: reset } } if (loadState.status === 'error') { - return { $status: 'error', $isLoading: false, $isError: true, $isNotFound: false, $error: loadState.error!, id, $persist: persist, $reset: reset } + return { $status: 'error', $isLoading: false, $isRefetching: false, $isError: true, $isNotFound: false, $error: loadState.error!, id, $persist: persist, $reset: reset } } if (loadState.status === 'not_found') { - return { $status: 'not_found', $isLoading: false, $isError: false, $isNotFound: true, $error: null, id, $persist: persist, $reset: reset } + return { $status: 'not_found', $isLoading: false, $isRefetching: false, $isError: false, $isNotFound: true, $error: null, id, $persist: persist, $reset: reset } } // Ready — layer status metadata on top of EntityHandle proxy - return createReadyResult(handle, persist, reset) - }, [snapshot, loadState, isPersisting, id, handle, persist, reset]) + return createReadyResult(handle, persist, reset, isRefetching) + }, [snapshot, loadState, isPersisting, id, handle, persist, reset, isRefetching]) // Proxy-based result satisfies the full UseEntityResult at runtime via field access delegation return result as UseEntityResult @@ -351,10 +376,12 @@ function createReadyResult( handle: EntityAccessor, persist: () => Promise, reset: () => void, + isRefetching: boolean, ): UseEntityResult { const meta: Record = { $status: 'ready' as const, $isLoading: false as const, + $isRefetching: isRefetching, $isError: false as const, $isNotFound: false as const, $error: null, diff --git a/packages/bindx-react/src/hooks/useEntityList.ts b/packages/bindx-react/src/hooks/useEntityList.ts index 0a4de0e..f9084a9 100644 --- a/packages/bindx-react/src/hooks/useEntityList.ts +++ b/packages/bindx-react/src/hooks/useEntityList.ts @@ -43,6 +43,7 @@ interface EntityListResultBase { export type LoadingEntityListResult = EntityListResultBase & { readonly $status: 'loading' readonly $isLoading: true + readonly $isRefetching: false readonly $isError: false readonly $error: null } @@ -50,13 +51,21 @@ export type LoadingEntityListResult = EntityListResultBase & { export type ErrorEntityListResult = EntityListResultBase & { readonly $status: 'error' readonly $isLoading: false + readonly $isRefetching: false readonly $isError: true readonly $error: FieldError } +/** + * `$isRefetching` is `true` while a background re-fetch is in flight + * (triggered by a `queryKey` change while ready data is already present). + * The accessor identity stays stable so the subtree does not unmount — + * stale-while-revalidate semantics. + */ export type ReadyEntityListResult = EntityListResultBase & { readonly $status: 'ready' readonly $isLoading: false + readonly $isRefetching: boolean readonly $isError: false readonly $error: null readonly $isDirty: boolean @@ -77,6 +86,7 @@ function createLoadingListResult(): LoadingEntityListResult { return { $status: 'loading', $isLoading: true, + $isRefetching: false, $isError: false, $error: null, $add() { throw new Error('Cannot add items while loading') }, @@ -89,6 +99,7 @@ function createErrorListResult(error: FieldError): ErrorEntityListResult { return { $status: 'error', $isLoading: false, + $isRefetching: false, $isError: true, $error: error, $add() { throw new Error('Cannot add items after error') }, @@ -199,9 +210,11 @@ export function useEntityList( status: 'loading' | 'error' | 'ready' items: Array<{ id: string; data: object }> error?: FieldError + isRefetching: boolean }>({ status: 'loading', items: [], + isRefetching: false, }) const versionRef = useRef(0) @@ -210,6 +223,7 @@ export function useEntityList( version: number storeVersion: number status: string + isRefetching: boolean result: UseEntityListResult } | null>(null) @@ -276,7 +290,13 @@ export function useEntityList( const storeVersion = store.getVersion() const cache = listCacheRef.current - if (cache && cache.version === version && cache.storeVersion === storeVersion && cache.status === state.status) { + if ( + cache && + cache.version === version && + cache.storeVersion === storeVersion && + cache.status === state.status && + cache.isRefetching === state.isRefetching + ) { return cache.result } @@ -300,6 +320,7 @@ export function useEntityList( result = { $status: 'ready', $isLoading: false, + $isRefetching: state.isRefetching, $isError: false, $error: null, $isDirty: false, @@ -315,6 +336,7 @@ export function useEntityList( version, storeVersion, status: state.status, + isRefetching: state.isRefetching, result, } @@ -338,8 +360,16 @@ export function useEntityList( useEffect(() => { const abortController = new AbortController() - listStateRef.current = { status: 'loading', items: [] } + // Stale-while-revalidate: if we already have ready items, keep them visible + // and only flag a background refetch. Otherwise, show explicit loading. + const prev = listStateRef.current + if (prev.status === 'ready') { + listStateRef.current = { ...prev, isRefetching: true } + } else { + listStateRef.current = { status: 'loading', items: [], isRefetching: false } + } versionRef.current++ + store.notify() const fetchData = async (): Promise => { try { @@ -377,7 +407,7 @@ export function useEntityList( return { id, data: data as object } }) - listStateRef.current = { status: 'ready', items } + listStateRef.current = { status: 'ready', items, isRefetching: false } versionRef.current++ store.notify() } catch (error) { @@ -388,6 +418,7 @@ export function useEntityList( status: 'error', items: [], error: createLoadError(normalizedError), + isRefetching: false, } versionRef.current++ store.notify() diff --git a/packages/bindx-react/src/jsx/components/Entity.tsx b/packages/bindx-react/src/jsx/components/Entity.tsx index ee48b88..c0e459c 100644 --- a/packages/bindx-react/src/jsx/components/Entity.tsx +++ b/packages/bindx-react/src/jsx/components/Entity.tsx @@ -27,6 +27,13 @@ interface EntityBaseProps> { interface EntityByProps> extends EntityBaseProps { /** Unique field(s) to identify the entity (e.g., { id: '...' } or { slug: '...' }) */ by: EntityUniqueWhere + /** + * Cache-invalidation key. Changing this value re-fetches the entity from the + * server without unmounting the subtree (stale-while-revalidate). Use this + * instead of `key={...}` when external mutations (e.g. workflow RPC) need + * the bindx view refreshed but UI state (scroll, dialogs, drafts) must survive. + */ + queryKey?: string create?: never onPersisted?: never /** Loading fallback */ @@ -60,6 +67,7 @@ export type EntityProps = Record) => React.ReactNode loading?: React.ReactNode error?: (error: FieldError) => React.ReactNode @@ -81,6 +89,7 @@ interface EntityCreateModeProps { function EntityByMode({ entityType, by, + queryKey: userQueryKey, children, loading, error: errorFallback, @@ -93,17 +102,23 @@ function EntityByMode({ const byKey = useMemo(() => JSON.stringify(by), [by]) // Phase 1: Collect JSX selection - const { selection, queryKey } = useSelectionCollection({ + const { selection, queryKey: selectionQueryKey } = useSelectionCollection({ entityType, depsKey: byKey, collect: collector => children(collector as EntityAccessor), }) + // Combine internal selection key with user-supplied invalidation key so + // both selection changes and explicit refreshes trigger a refetch. + const effectiveQueryKey = userQueryKey !== undefined + ? `${selectionQueryKey}::${userQueryKey}` + : selectionQueryKey + // Phase 2: Load data using unified hook const result = useEntity({ $name: entityType } as EntityDef, { by, selection, - queryKey, + queryKey: effectiveQueryKey, }) // Render based on status @@ -347,6 +362,7 @@ function EntityImpl>( ) => React.ReactNode} loading={byProps.loading} error={byProps.error} diff --git a/packages/bindx-react/src/jsx/components/EntityList.tsx b/packages/bindx-react/src/jsx/components/EntityList.tsx index 009178b..a87f801 100644 --- a/packages/bindx-react/src/jsx/components/EntityList.tsx +++ b/packages/bindx-react/src/jsx/components/EntityList.tsx @@ -19,6 +19,12 @@ export interface EntityListProps = Recor limit?: number /** Optional offset */ offset?: number + /** + * Cache-invalidation key. Changing this value re-fetches the list without + * unmounting the subtree (stale-while-revalidate). Use this when external + * mutations need the bindx view refreshed but UI state must survive. + */ + queryKey?: string /** Render function receiving typed entity accessor with direct field access */ children: (entity: EntityAccessor>, index: number) => React.ReactNode /** Loading fallback */ @@ -53,6 +59,7 @@ function EntityListComponent>({ orderBy, limit, offset, + queryKey: userQueryKey, children, loading, error: errorFallback, @@ -69,13 +76,18 @@ function EntityListComponent>({ }) // Phase 1: Collect JSX selection - const { selection, queryKey } = useSelectionCollection({ + const { selection, queryKey: selectionQueryKey } = useSelectionCollection({ entityType, depsKey: optionsKey, collect: collector => children(collector as unknown as EntityAccessor>, 0), queryKeyExtra: { filter, orderBy, limit, offset }, }) + // Combine internal selection key with user-supplied invalidation key + const effectiveQueryKey = userQueryKey !== undefined + ? `${selectionQueryKey}::${userQueryKey}` + : selectionQueryKey + // Phase 2: Load data using unified hook const result = useEntityList(entity, { filter, @@ -83,7 +95,7 @@ function EntityListComponent>({ limit, offset, selection, - queryKey, + queryKey: effectiveQueryKey, }) // Render based on status diff --git a/tests/react/hooks/useEntity/queryKey.test.tsx b/tests/react/hooks/useEntity/queryKey.test.tsx new file mode 100644 index 0000000..db7c466 --- /dev/null +++ b/tests/react/hooks/useEntity/queryKey.test.tsx @@ -0,0 +1,197 @@ +import '../../../setup' +import { describe, test, expect, afterEach } from 'bun:test' +import { render, waitFor, cleanup, act } from '@testing-library/react' +import React, { useState } from 'react' +import { BindxProvider, MockAdapter, useEntity } from '@contember/bindx-react' +import { getByTestId, queryByTestId, createMockData, schema, testSchema } from '../../../shared' + +afterEach(() => { + cleanup() +}) + +describe('useEntity hook - queryKey refetch (stale-while-revalidate)', () => { + test('changing queryKey refetches without flicker — ready state survives', async () => { + const mockData = createMockData() + const adapter = new MockAdapter(mockData, { delay: 20 }) + + let setKey: (key: string) => void = () => {} + + function TestComponent() { + const [key, setLocalKey] = useState('initial') + setKey = setLocalKey + + const article = useEntity( + schema.Article, + { by: { id: 'article-1' }, queryKey: key }, + e => e.title(), + ) + + if (article.$isLoading) { + return
Loading...
+ } + if (article.$isError || article.$isNotFound) { + return
Error
+ } + + return ( +
+
{article.title.value}
+
{article.$isRefetching ? 'yes' : 'no'}
+
+ ) + } + + const { container } = render( + + + , + ) + + // Wait for initial load + await waitFor(() => { + expect(queryByTestId(container, 'title')).not.toBeNull() + }) + expect(getByTestId(container, 'title').textContent).toBe('Hello World') + expect(getByTestId(container, 'refetching').textContent).toBe('no') + + // Mutate "server" data behind bindx's back (simulates workflow RPC) + mockData.Article['article-1']!.title = 'Updated By RPC' + + // Bump queryKey — should refetch WITHOUT showing the loading fallback + act(() => setKey('after-rpc')) + + // During refetch: loading fallback must NOT mount, title stays visible, + // $isRefetching must flip to true + expect(queryByTestId(container, 'loading')).toBeNull() + expect(queryByTestId(container, 'title')).not.toBeNull() + expect(getByTestId(container, 'title').textContent).toBe('Hello World') + expect(getByTestId(container, 'refetching').textContent).toBe('yes') + + // After refetch lands: new data + refetching flips back + await waitFor(() => { + expect(getByTestId(container, 'title').textContent).toBe('Updated By RPC') + }) + expect(getByTestId(container, 'refetching').textContent).toBe('no') + }) + + test('initial load still uses loading state (no data yet → no SWR)', async () => { + const adapter = new MockAdapter(createMockData(), { delay: 20 }) + + function TestComponent() { + const article = useEntity( + schema.Article, + { by: { id: 'article-1' }, queryKey: 'k1' }, + e => e.title(), + ) + + if (article.$status !== 'ready') { + return
Loading...
+ } + return
{article.title.value}
+ } + + const { container } = render( + + + , + ) + + // First render before data: loading fallback present + expect(queryByTestId(container, 'loading')).not.toBeNull() + + await waitFor(() => { + expect(queryByTestId(container, 'title')).not.toBeNull() + }) + }) + + test('subtree DOM identity preserved across queryKey refetch', async () => { + const mockData = createMockData() + const adapter = new MockAdapter(mockData, { delay: 20 }) + + let setKey: (key: string) => void = () => {} + + function TestComponent() { + const [key, setLocalKey] = useState('initial') + setKey = setLocalKey + + const article = useEntity( + schema.Article, + { by: { id: 'article-1' }, queryKey: key }, + e => e.title(), + ) + + if (article.$status !== 'ready') { + return
Loading
+ } + return
{article.title.value}
+ } + + const { container } = render( + + + , + ) + + await waitFor(() => { + expect(queryByTestId(container, 'title')).not.toBeNull() + }) + + const elementBeforeRefetch = getByTestId(container, 'title') + + mockData.Article['article-1']!.title = 'Refreshed' + + act(() => setKey('refresh-1')) + + // Same DOM node persists (no remount) during refetch + const elementDuringRefetch = getByTestId(container, 'title') + expect(elementDuringRefetch).toBe(elementBeforeRefetch) + + await waitFor(() => { + expect(getByTestId(container, 'title').textContent).toBe('Refreshed') + }) + + // And the same DOM node still after refetch lands + const elementAfterRefetch = getByTestId(container, 'title') + expect(elementAfterRefetch).toBe(elementBeforeRefetch) + }) + + test('refetch overwrites local serverData (dirty markers cleared)', async () => { + const mockData = createMockData() + const adapter = new MockAdapter(mockData, { delay: 20 }) + + let setKey: (key: string) => void = () => {} + + function TestComponent() { + const [key, setLocalKey] = useState('v1') + setKey = setLocalKey + + const article = useEntity( + schema.Article, + { by: { id: 'article-1' }, queryKey: key }, + e => e.title(), + ) + + if (article.$status !== 'ready') { + return null + } + return
{article.title.value}
+ } + + const { container } = render( + + + , + ) + + await waitFor(() => { + expect(queryByTestId(container, 'title')).not.toBeNull() + }) + + mockData.Article['article-1']!.title = 'Server Edit' + act(() => setKey('v2')) + + await waitFor(() => { + expect(getByTestId(container, 'title').textContent).toBe('Server Edit') + }) + }) +}) diff --git a/tests/react/jsx/EntityQueryKey.test.tsx b/tests/react/jsx/EntityQueryKey.test.tsx new file mode 100644 index 0000000..c919bb1 --- /dev/null +++ b/tests/react/jsx/EntityQueryKey.test.tsx @@ -0,0 +1,209 @@ +import '../../setup' +import { describe, test, expect, afterEach } from 'bun:test' +import { render, waitFor, cleanup, act } from '@testing-library/react' +import React, { useEffect, useRef, useState } from 'react' +import { BindxProvider, MockAdapter, Entity, EntityList, Field } from '@contember/bindx-react' +import { getByTestId, queryByTestId, createMockData, schema, testSchema } from '../../shared' + +afterEach(() => { + cleanup() +}) + +describe(' queryKey prop', () => { + test('changing queryKey refetches without unmounting the subtree', async () => { + const mockData = createMockData() + const adapter = new MockAdapter(mockData, { delay: 20 }) + + let setKey: (key: string) => void = () => {} + + // Sibling component used purely to assert that the subtree does not unmount. + // useEffect with empty deps fires once per mount — counts (re)mounts. + let mountCount = 0 + function MountSentinel() { + useEffect(() => { + mountCount++ + }, []) + return + } + + function App() { + const [key, setLocalKey] = useState('v1') + setKey = setLocalKey + + return ( + + {article => ( +
+ +
+
+ )} +
+ ) + } + + const { container } = render( + + + , + ) + + await waitFor(() => { + expect(queryByTestId(container, 'title')).not.toBeNull() + }) + expect(getByTestId(container, 'title').textContent).toBe('Hello World') + const mountsAfterInitialLoad = mountCount + expect(mountsAfterInitialLoad).toBeGreaterThanOrEqual(1) + + mockData.Article['article-1']!.title = 'After RPC' + act(() => setKey('v2')) + + // During refetch: subtree still mounted, no remount happened + expect(mountCount).toBe(mountsAfterInitialLoad) + expect(queryByTestId(container, 'title')).not.toBeNull() + + await waitFor(() => { + expect(getByTestId(container, 'title').textContent).toBe('After RPC') + }) + + // After refetch lands: still no remount + expect(mountCount).toBe(mountsAfterInitialLoad) + }) + + test('preserves DOM node identity across queryKey refetch', async () => { + const mockData = createMockData() + const adapter = new MockAdapter(mockData, { delay: 20 }) + + let setKey: (key: string) => void = () => {} + + function App() { + const [key, setLocalKey] = useState('v1') + setKey = setLocalKey + + return ( + + {article =>
} +
+ ) + } + + const { container } = render( + + + , + ) + + await waitFor(() => expect(queryByTestId(container, 'title')).not.toBeNull()) + const elBefore = getByTestId(container, 'title') + + mockData.Article['article-1']!.title = 'Refreshed' + act(() => setKey('v2')) + + expect(getByTestId(container, 'title')).toBe(elBefore) + + await waitFor(() => { + expect(getByTestId(container, 'title').textContent).toBe('Refreshed') + }) + expect(getByTestId(container, 'title')).toBe(elBefore) + }) + + test('preserves locally-held React state across queryKey refetch', async () => { + // Concrete proxy for "scroll position / open dialogs / form drafts survive". + const mockData = createMockData() + const adapter = new MockAdapter(mockData, { delay: 20 }) + + let setKey: (key: string) => void = () => {} + const inputRef = { current: null as HTMLInputElement | null } + + function LocalStateChild() { + const ref = useRef(null) + useEffect(() => { + inputRef.current = ref.current + }) + return + } + + function App() { + const [key, setLocalKey] = useState('v1') + setKey = setLocalKey + + return ( + + {article => ( +
+
+ +
+ )} +
+ ) + } + + const { container } = render( + + + , + ) + + await waitFor(() => expect(queryByTestId(container, 'title')).not.toBeNull()) + expect(inputRef.current).not.toBeNull() + inputRef.current!.value = 'user typed draft' + + mockData.Article['article-1']!.title = 'Refreshed' + act(() => setKey('v2')) + + await waitFor(() => { + expect(getByTestId(container, 'title').textContent).toBe('Refreshed') + }) + + // Input still mounted with user's draft intact — would be lost with key={} remount + const inputAfter = getByTestId(container, 'local-input') as HTMLInputElement + expect(inputAfter.value).toBe('user typed draft') + }) +}) + +describe(' queryKey prop', () => { + test('changing queryKey refetches list without showing loading fallback', async () => { + const mockData = createMockData() + const adapter = new MockAdapter(mockData, { delay: 20 }) + + let setKey: (key: string) => void = () => {} + + function App() { + const [key, setLocalKey] = useState('v1') + setKey = setLocalKey + + return ( + + {article => ( +
+ +
+ )} +
+ ) + } + + const { container } = render( + + + , + ) + + await waitFor(() => { + expect(queryByTestId(container, 'item-article-1')).not.toBeNull() + }) + expect(getByTestId(container, 'item-article-1').textContent).toBe('Hello World') + + mockData.Article['article-1']!.title = 'List Refreshed' + act(() => setKey('v2')) + + // During refetch: the items stay visible, no "Loading..." fallback + expect(container.textContent ?? '').not.toContain('Loading...') + expect(getByTestId(container, 'item-article-1').textContent).toBe('Hello World') + + await waitFor(() => { + expect(getByTestId(container, 'item-article-1').textContent).toBe('List Refreshed') + }) + }) +}) From 689cfdf74693243604be09f86a71d3394fa5891e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vojt=C4=9Bch=20P=C3=A1rys?= Date: Mon, 1 Jun 2026 12:00:22 +0200 Subject: [PATCH 2/3] fix(bindx): preserve local dirty edits on queryKey refetch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refetch dispatched setEntityData(...,true), whose merge overwrote the working data slot with the fresh server payload — silently clobbering local dirty edits and clearing isDirty. That contradicts the issue #40 contract ('should NOT silently overwrite dirty fields'). Add a dedicated refreshServerData store action/primitive used by the SWR refetch in useEntity/useEntityList: it advances the server baseline for every returned field but only overwrites working values for fields that are not locally dirty. Edited fields keep the user's value and stay dirty; their baseline still moves so $reset reveals the new server value. setEntityData is left untouched for initial load and the pessimistic persist restore path, where overwrite-all is correct. Adds dirty-preservation tests (useEntity, EntityList, store unit) and fixes the misleadingly-named 'dirty markers cleared' test that never created a dirty field. --- packages/bindx-react/src/hooks/useEntity.ts | 6 +- .../bindx-react/src/hooks/useEntityList.ts | 6 +- packages/bindx/src/core/ActionDispatcher.ts | 8 ++ .../bindx/src/core/actionClassification.ts | 1 + packages/bindx/src/core/actions.ts | 25 ++++ packages/bindx/src/index.ts | 1 + .../bindx/src/store/EntitySnapshotStore.ts | 50 ++++++++ packages/bindx/src/store/SnapshotStore.ts | 16 +++ tests/react/hooks/useEntity/queryKey.test.tsx | 121 +++++++++++++++++- tests/react/jsx/EntityQueryKey.test.tsx | 56 ++++++++ tests/unit/store/snapshotStore.test.ts | 47 +++++++ 11 files changed, 332 insertions(+), 5 deletions(-) diff --git a/packages/bindx-react/src/hooks/useEntity.ts b/packages/bindx-react/src/hooks/useEntity.ts index be3efc4..e750dcb 100644 --- a/packages/bindx-react/src/hooks/useEntity.ts +++ b/packages/bindx-react/src/hooks/useEntity.ts @@ -18,7 +18,7 @@ import { EntityHandle, resolveSelectionMeta, buildQueryFromSelection, - setEntityData, + refreshServerData, setLoadState, createLoadError, } from '@contember/bindx' @@ -290,8 +290,10 @@ export function useEntity( if (result.type === 'get' && result.data === null) { dispatcher.dispatch(setLoadState(entityType, id, 'not_found')) } else if (result.type === 'get' && result.data) { + // Revalidation: advance the server baseline but keep local dirty + // edits intact (see EntitySnapshotStore.refreshServerData). dispatcher.dispatch( - setEntityData(entityType, id, result.data, true), + refreshServerData(entityType, id, result.data), ) dispatcher.dispatch(setLoadState(entityType, id, 'success')) } diff --git a/packages/bindx-react/src/hooks/useEntityList.ts b/packages/bindx-react/src/hooks/useEntityList.ts index f9084a9..db02261 100644 --- a/packages/bindx-react/src/hooks/useEntityList.ts +++ b/packages/bindx-react/src/hooks/useEntityList.ts @@ -1,6 +1,6 @@ import { useRef, useEffect, useMemo, useCallback } from 'react' import type { EntityDef, EntityAccessor, SelectionInput, SelectionMeta, FieldError, SchemaRegistry, CommonEntity, EntityForRoles, RoleNames } from '@contember/bindx' -import { EntityHandle, isTempId, resolveSelectionMeta, buildQueryFromSelection, setEntityData, createLoadError } from '@contember/bindx' +import { EntityHandle, isTempId, resolveSelectionMeta, buildQueryFromSelection, refreshServerData, createLoadError } from '@contember/bindx' import { useBindxContext, useSchemaRegistry } from './BackendAdapterContext.js' import { useStoreSubscription } from './useStoreSubscription.js' @@ -401,8 +401,10 @@ export function useEntityList( const items = result.data.map((data: Record) => { const id = data['id'] as string + // Revalidation: advance the server baseline but keep local dirty + // edits intact (see EntitySnapshotStore.refreshServerData). dispatcher.dispatch( - setEntityData(entityType, id, data, true), + refreshServerData(entityType, id, data), ) return { id, data: data as object } }) diff --git a/packages/bindx/src/core/ActionDispatcher.ts b/packages/bindx/src/core/ActionDispatcher.ts index 77336df..50e9826 100644 --- a/packages/bindx/src/core/ActionDispatcher.ts +++ b/packages/bindx/src/core/ActionDispatcher.ts @@ -208,6 +208,14 @@ export class ActionDispatcher { ) break + case 'REFRESH_SERVER_DATA': + this.store.refreshServerData( + action.entityType, + action.entityId, + action.data, + ) + break + case 'CONNECT_RELATION': this.store.setRelation( action.entityType, diff --git a/packages/bindx/src/core/actionClassification.ts b/packages/bindx/src/core/actionClassification.ts index 2b13613..7ea06f2 100644 --- a/packages/bindx/src/core/actionClassification.ts +++ b/packages/bindx/src/core/actionClassification.ts @@ -104,6 +104,7 @@ export function getAffectedKeys(action: Action): StoreAffectedKeys { switch (action.type) { case 'SET_FIELD': case 'SET_ENTITY_DATA': + case 'REFRESH_SERVER_DATA': case 'RESET_ENTITY': case 'COMMIT_ENTITY': entityKeys.push(createEntityKey(action.entityType, action.entityId)) diff --git a/packages/bindx/src/core/actions.ts b/packages/bindx/src/core/actions.ts index 0958efa..4042d84 100644 --- a/packages/bindx/src/core/actions.ts +++ b/packages/bindx/src/core/actions.ts @@ -50,6 +50,16 @@ export interface SetEntityDataAction { readonly isServerData: boolean } +/** + * Refreshes entity data from a server revalidation, preserving local dirty edits. + */ +export interface RefreshServerDataAction { + readonly type: 'REFRESH_SERVER_DATA' + readonly entityType: string + readonly entityId: string + readonly data: Record +} + // ==================== Relation Actions ==================== /** @@ -288,6 +298,7 @@ export type Action = | ResetEntityAction | CommitEntityAction | SetEntityDataAction + | RefreshServerDataAction | ConnectRelationAction | DisconnectRelationAction | DeleteRelationAction @@ -349,6 +360,20 @@ export function setEntityData( return { type: 'SET_ENTITY_DATA', entityType, entityId, data, isServerData } } +/** + * Creates a REFRESH_SERVER_DATA action. + * + * Use for server revalidation (refetch) instead of {@link setEntityData}: the + * server baseline is advanced while local dirty edits are preserved. + */ +export function refreshServerData( + entityType: string, + entityId: string, + data: Record, +): RefreshServerDataAction { + return { type: 'REFRESH_SERVER_DATA', entityType, entityId, data } +} + /** * Creates a CONNECT_RELATION action */ diff --git a/packages/bindx/src/index.ts b/packages/bindx/src/index.ts index 8452075..e743502 100644 --- a/packages/bindx/src/index.ts +++ b/packages/bindx/src/index.ts @@ -178,6 +178,7 @@ export { createEntityLoader, resolveSelectionMeta, buildQuery } from './core/ind // Actions export { setEntityData, + refreshServerData, setLoadState, setField, resetEntity, diff --git a/packages/bindx/src/store/EntitySnapshotStore.ts b/packages/bindx/src/store/EntitySnapshotStore.ts index fb2e5fc..47b487b 100644 --- a/packages/bindx/src/store/EntitySnapshotStore.ts +++ b/packages/bindx/src/store/EntitySnapshotStore.ts @@ -56,6 +56,56 @@ export class EntitySnapshotStore { return newSnapshot } + /** + * Refreshes entity data from a fresh server read (revalidation). + * + * Unlike {@link setData}, this preserves the user's local dirty edits: for + * every incoming field the server baseline (`serverData`) is always advanced + * to the new value, but the working `data` is only updated when the field was + * NOT locally dirty (its working value still equals the previous baseline). + * Edited fields keep their working value, so `isDirty` keeps reporting + * "differs from the new server value" after the refresh. + * + * When no snapshot exists yet this behaves like a plain server load. + */ + refreshServerData( + key: string, + id: string, + entityType: string, + data: T, + ): EntitySnapshot { + const existing = this.snapshots.get(key) + if (!existing) { + return this.setData(key, id, entityType, data, true) + } + + const prevData = existing.data as Record + const prevServer = (existing.serverData ?? existing.data) as Record + const incoming = data as Record + + const newServerData: Record = { ...prevServer } + const newData: Record = { ...prevData } + + for (const field of Object.keys(incoming)) { + newServerData[field] = incoming[field] + const isFieldDirty = !Object.is(prevData[field], prevServer[field]) + if (!isFieldDirty) { + newData[field] = incoming[field] + } + } + + const newSnapshot = createEntitySnapshot( + id, + entityType, + newData as T, + newServerData as T, + existing.version + 1, + ) + + this.snapshots.set(key, newSnapshot) + return newSnapshot + } + /** * Updates specific fields on an existing entity snapshot. * Returns the new snapshot, or undefined if entity doesn't exist. diff --git a/packages/bindx/src/store/SnapshotStore.ts b/packages/bindx/src/store/SnapshotStore.ts index a02a6cc..01ddd05 100644 --- a/packages/bindx/src/store/SnapshotStore.ts +++ b/packages/bindx/src/store/SnapshotStore.ts @@ -182,6 +182,22 @@ export class SnapshotStore implements SnapshotVersionBumper { return newSnapshot as EntitySnapshot } + /** + * Refreshes server data from a revalidation read while preserving the user's + * local dirty edits. See {@link EntitySnapshotStore.refreshServerData}. + */ + refreshServerData( + entityType: string, + id: string, + data: T, + ): EntitySnapshot { + const key = this.getEntityKey(entityType, id) + const newSnapshot = this.entitySnapshots.refreshServerData(key, id, entityType, data) + this.meta.setExistsOnServer(key, true) + this.notifyEntitySubscribers(key) + return newSnapshot as EntitySnapshot + } + updateEntityFields( entityType: string, id: string, diff --git a/tests/react/hooks/useEntity/queryKey.test.tsx b/tests/react/hooks/useEntity/queryKey.test.tsx index db7c466..fdfa82f 100644 --- a/tests/react/hooks/useEntity/queryKey.test.tsx +++ b/tests/react/hooks/useEntity/queryKey.test.tsx @@ -155,7 +155,7 @@ describe('useEntity hook - queryKey refetch (stale-while-revalidate)', () => { expect(elementAfterRefetch).toBe(elementBeforeRefetch) }) - test('refetch overwrites local serverData (dirty markers cleared)', async () => { + test('refetch updates a clean (un-edited) field to the new server value', async () => { const mockData = createMockData() const adapter = new MockAdapter(mockData, { delay: 20 }) @@ -194,4 +194,123 @@ describe('useEntity hook - queryKey refetch (stale-while-revalidate)', () => { expect(getByTestId(container, 'title').textContent).toBe('Server Edit') }) }) + + test('refetch preserves a locally edited (dirty) field while updating a clean sibling', async () => { + // The issue's contract: a refetch must NOT silently overwrite local dirty + // edits. The user edits `title`; the server changes `content`. After the + // refetch the edit on `title` survives and stays dirty, while `content` + // (untouched locally) picks up the fresh server value. + const mockData = createMockData() + const adapter = new MockAdapter(mockData, { delay: 20 }) + + let setKey: (key: string) => void = () => {} + + function TestComponent() { + const [key, setLocalKey] = useState('v1') + setKey = setLocalKey + + const article = useEntity( + schema.Article, + { by: { id: 'article-1' }, queryKey: key }, + e => e.title().content(), + ) + + if (article.$status !== 'ready') return null + return ( +
+
{article.title.value}
+
{article.content.value}
+
{String(article.title.isDirty)}
+
+ ) + } + + const { container } = render( + + + , + ) + + await waitFor(() => expect(queryByTestId(container, 'title')).not.toBeNull()) + + // User edits title locally -> dirty + act(() => (getByTestId(container, 'edit') as HTMLButtonElement).click()) + expect(getByTestId(container, 'title').textContent).toBe('LOCAL DRAFT') + expect(getByTestId(container, 'title-dirty').textContent).toBe('true') + + // Server changes a DIFFERENT field, then a queryKey bump refetches + mockData.Article['article-1']!.content = 'fresh server content' + act(() => setKey('v2')) + + await waitFor(() => { + expect(getByTestId(container, 'content').textContent).toBe('fresh server content') + }) + + // The local edit on title survived and is still dirty + expect(getByTestId(container, 'title').textContent).toBe('LOCAL DRAFT') + expect(getByTestId(container, 'title-dirty').textContent).toBe('true') + }) + + test('refetch keeps the dirty edit but advances the server baseline ($reset reveals new server value)', async () => { + // The user edits `title`; the server ALSO changes `title` to a different + // value. The local edit wins (not clobbered), but the baseline moves: a + // subsequent $reset drops to the NEW server value, not the stale one. + const mockData = createMockData() + const adapter = new MockAdapter(mockData, { delay: 20 }) + + let setKey: (key: string) => void = () => {} + + function TestComponent() { + const [key, setLocalKey] = useState('v1') + setKey = setLocalKey + + const article = useEntity( + schema.Article, + { by: { id: 'article-1' }, queryKey: key }, + e => e.title().content(), + ) + + if (article.$status !== 'ready') return null + return ( +
+
{article.title.value}
+ {/* clean sibling — used purely as a "refetch landed" signal */} +
{article.content.value}
+
{String(article.title.isDirty)}
+
+ ) + } + + const { container } = render( + + + , + ) + + await waitFor(() => expect(queryByTestId(container, 'title')).not.toBeNull()) + + act(() => (getByTestId(container, 'edit') as HTMLButtonElement).click()) + expect(getByTestId(container, 'title').textContent).toBe('LOCAL DRAFT') + + // Server changes the SAME field (plus a clean sibling as a landed-signal), then refetch + mockData.Article['article-1']!.title = 'SERVER WINS' + mockData.Article['article-1']!.content = 'landed' + act(() => setKey('v2')) + + // The clean sibling updating proves the refetch landed + await waitFor(() => { + expect(getByTestId(container, 'content').textContent).toBe('landed') + }) + // The dirty edit on title survived the refetch + expect(getByTestId(container, 'title').textContent).toBe('LOCAL DRAFT') + expect(getByTestId(container, 'dirty').textContent).toBe('true') + + // Reset drops to the NEW server baseline, proving it was advanced + act(() => (getByTestId(container, 'reset') as HTMLButtonElement).click()) + expect(getByTestId(container, 'title').textContent).toBe('SERVER WINS') + expect(getByTestId(container, 'dirty').textContent).toBe('false') + }) }) diff --git a/tests/react/jsx/EntityQueryKey.test.tsx b/tests/react/jsx/EntityQueryKey.test.tsx index c919bb1..1a23b6d 100644 --- a/tests/react/jsx/EntityQueryKey.test.tsx +++ b/tests/react/jsx/EntityQueryKey.test.tsx @@ -206,4 +206,60 @@ describe(' queryKey prop', () => { expect(getByTestId(container, 'item-article-1').textContent).toBe('List Refreshed') }) }) + + test('queryKey refetch preserves a locally edited item field', async () => { + // Same dirty-preservation contract as : a list refetch must not + // silently clobber a local edit on one of its items. + const mockData = createMockData() + const adapter = new MockAdapter(mockData, { delay: 20 }) + + let setKey: (key: string) => void = () => {} + + function App() { + const [key, setLocalKey] = useState('v1') + setKey = setLocalKey + + return ( + + {article => ( +
+
{article.title.value}
+
{String(article.title.isDirty)}
+ {article.id === 'article-1' && ( +
+ )} +
+ ) + } + + const { container } = render( + + + , + ) + + await waitFor(() => expect(queryByTestId(container, 'title-article-1')).not.toBeNull()) + + act(() => (getByTestId(container, 'edit') as HTMLButtonElement).click()) + expect(getByTestId(container, 'title-article-1').textContent).toBe('LOCAL DRAFT') + + // Server changes article-1's title behind bindx's back; article-2 (clean) + // changes too and serves as the "refetch landed" signal. + mockData.Article['article-1']!.title = 'server changed' + mockData.Article['article-2']!.title = 'landed' + act(() => setKey('v2')) + + await waitFor(() => { + expect(getByTestId(container, 'title-article-2').textContent).toBe('landed') + }) + + // Local edit on article-1 survived the list refetch + expect(getByTestId(container, 'title-article-1').textContent).toBe('LOCAL DRAFT') + expect(getByTestId(container, 'dirty-article-1').textContent).toBe('true') + }) }) diff --git a/tests/unit/store/snapshotStore.test.ts b/tests/unit/store/snapshotStore.test.ts index 8676064..262726d 100644 --- a/tests/unit/store/snapshotStore.test.ts +++ b/tests/unit/store/snapshotStore.test.ts @@ -146,6 +146,53 @@ describe('SnapshotStore', () => { }) }) + describe('refreshServerData', () => { + test('updates a clean (un-edited) field to the new server value', () => { + store.setEntityData('Article', 'a-1', { id: 'a-1', title: 'Original' }, true) + store.refreshServerData('Article', 'a-1', { id: 'a-1', title: 'Refreshed' }) + + const snapshot = store.getEntitySnapshot('Article', 'a-1') + expect(snapshot?.data).toHaveProperty('title', 'Refreshed') + expect(snapshot?.serverData).toHaveProperty('title', 'Refreshed') + }) + + test('preserves a locally edited (dirty) field but advances the server baseline', () => { + store.setEntityData('Article', 'a-1', { id: 'a-1', title: 'Original', content: 'C0' }, true) + store.setFieldValue('Article', 'a-1', ['title'], 'Local Edit') + + store.refreshServerData('Article', 'a-1', { id: 'a-1', title: 'Server Edit', content: 'C1' }) + + const snapshot = store.getEntitySnapshot('Article', 'a-1') + // dirty field keeps the local working value... + expect(snapshot?.data).toHaveProperty('title', 'Local Edit') + // ...but its server baseline moves to the fresh value + expect(snapshot?.serverData).toHaveProperty('title', 'Server Edit') + // clean sibling is updated in both slots + expect(snapshot?.data).toHaveProperty('content', 'C1') + expect(snapshot?.serverData).toHaveProperty('content', 'C1') + }) + + test('reset after refresh drops the dirty field to the new server baseline', () => { + store.setEntityData('Article', 'a-1', { id: 'a-1', title: 'Original' }, true) + store.setFieldValue('Article', 'a-1', ['title'], 'Local Edit') + store.refreshServerData('Article', 'a-1', { id: 'a-1', title: 'Server Edit' }) + + store.resetEntity('Article', 'a-1') + + const snapshot = store.getEntitySnapshot('Article', 'a-1') + expect(snapshot?.data).toHaveProperty('title', 'Server Edit') + }) + + test('behaves like a plain server load when no snapshot exists yet', () => { + store.refreshServerData('Article', 'a-1', { id: 'a-1', title: 'First' }) + + const snapshot = store.getEntitySnapshot('Article', 'a-1') + expect(snapshot?.data).toHaveProperty('title', 'First') + expect(snapshot?.serverData).toHaveProperty('title', 'First') + expect(store.existsOnServer('Article', 'a-1')).toBe(true) + }) + }) + describe('removeEntity', () => { test('should remove entity from store', () => { store.setEntityData('Article', 'a-1', { id: 'a-1', title: 'Test' }, true) From dd7f8e9674133150c1699db074beef71262b6e69 Mon Sep 17 00:00:00 2001 From: David Matejka Date: Mon, 1 Jun 2026 14:36:03 +0200 Subject: [PATCH 3/3] fix: preserve dirty related-entity edits on queryKey refetch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The embedded-data propagation in HasOneHandle/HasManyListHandle used setEntityData (overwrite-all), so a refetch silently clobbered local dirty edits on related entities — violating the dirty-preservation contract. Route propagation through refreshServerData instead, advancing the child's server baseline while keeping local edits intact. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../bindx/src/handles/HasManyListHandle.ts | 10 +- packages/bindx/src/handles/HasOneHandle.ts | 10 +- packages/bindx/src/store/SnapshotStore.ts | 5 +- tests/react/hooks/useEntity/queryKey.test.tsx | 136 ++++++++++++++++++ 4 files changed, 152 insertions(+), 9 deletions(-) diff --git a/packages/bindx/src/handles/HasManyListHandle.ts b/packages/bindx/src/handles/HasManyListHandle.ts index b867895..e92b00f 100644 --- a/packages/bindx/src/handles/HasManyListHandle.ts +++ b/packages/bindx/src/handles/HasManyListHandle.ts @@ -185,13 +185,15 @@ export class HasManyListHandle return } - // Create or update snapshot from embedded data - // Skip notification to avoid triggering React state updates during render - this.store.setEntityData( + // Create or update snapshot from embedded data. + // Use refreshServerData (not setEntityData) so a re-fetch advances the + // child's server baseline without clobbering the user's local dirty edits + // on the related entity. Skip notification to avoid triggering React state + // updates during render. + this.store.refreshServerData( this.targetType, id, embeddedData as Record, - true, // isServerData true, // skipNotify - called during render, data already exists embedded in parent ) this.store.markEmbeddedDataPropagated(this.entityType, this.entityId, this.fieldName, embeddedData) diff --git a/packages/bindx/src/store/SnapshotStore.ts b/packages/bindx/src/store/SnapshotStore.ts index 01ddd05..2c0d466 100644 --- a/packages/bindx/src/store/SnapshotStore.ts +++ b/packages/bindx/src/store/SnapshotStore.ts @@ -190,11 +190,14 @@ export class SnapshotStore implements SnapshotVersionBumper { entityType: string, id: string, data: T, + skipNotify: boolean = false, ): EntitySnapshot { const key = this.getEntityKey(entityType, id) const newSnapshot = this.entitySnapshots.refreshServerData(key, id, entityType, data) this.meta.setExistsOnServer(key, true) - this.notifyEntitySubscribers(key) + if (!skipNotify) { + this.notifyEntitySubscribers(key) + } return newSnapshot as EntitySnapshot } diff --git a/tests/react/hooks/useEntity/queryKey.test.tsx b/tests/react/hooks/useEntity/queryKey.test.tsx index fdfa82f..3df46d3 100644 --- a/tests/react/hooks/useEntity/queryKey.test.tsx +++ b/tests/react/hooks/useEntity/queryKey.test.tsx @@ -314,3 +314,139 @@ describe('useEntity hook - queryKey refetch (stale-while-revalidate)', () => { expect(getByTestId(container, 'dirty').textContent).toBe('false') }) }) + +describe('useEntity hook - queryKey refetch with relations', () => { + test('refetch updates a has-one relation field to the new server value', async () => { + const mockData = createMockData() + const adapter = new MockAdapter(mockData, { delay: 20 }) + + let setKey: (key: string) => void = () => {} + + function TestComponent() { + const [key, setLocalKey] = useState('v1') + setKey = setLocalKey + + const article = useEntity( + schema.Article, + { by: { id: 'article-1' }, queryKey: key }, + e => e.title().author(a => a.id().name()), + ) + + if (article.$status !== 'ready') return null + return
{article.author.name.value}
+ } + + const { container } = render( + + + , + ) + + await waitFor(() => expect(queryByTestId(container, 'author')).not.toBeNull()) + expect(getByTestId(container, 'author').textContent).toBe('John Doe') + + // Server renames the related author behind bindx's back + mockData.Article['article-1']!.author = { id: 'author-1', name: 'Renamed Author', email: 'john@example.com', bio: 'Writer' } + act(() => setKey('v2')) + + await waitFor(() => { + expect(getByTestId(container, 'author').textContent).toBe('Renamed Author') + }) + }) + + test('refetch updates a has-many relation to the new server items', async () => { + const mockData = createMockData() + const adapter = new MockAdapter(mockData, { delay: 20 }) + + let setKey: (key: string) => void = () => {} + + function TestComponent() { + const [key, setLocalKey] = useState('v1') + setKey = setLocalKey + + const article = useEntity( + schema.Article, + { by: { id: 'article-1' }, queryKey: key }, + e => e.title().tags(t => t.id().name()), + ) + + if (article.$status !== 'ready') return null + return
{article.$data!.tags!.map(t => t.name).join(',')}
+ } + + const { container } = render( + + + , + ) + + await waitFor(() => expect(queryByTestId(container, 'tags')).not.toBeNull()) + expect(getByTestId(container, 'tags').textContent).toBe('JavaScript,React') + + mockData.Article['article-1']!.tags = [{ id: 'tag-3', name: 'TypeScript', color: '#3178c6' }] + act(() => setKey('v2')) + + await waitFor(() => { + expect(getByTestId(container, 'tags').textContent).toBe('TypeScript') + }) + }) + + test('refetch preserves a locally edited has-one relation field while updating a clean parent sibling', async () => { + // The dirty-edit contract extends to related entities: editing a related + // entity's field locally must survive a parent refetch, even when the + // server changes that same related field. (Regression: the embedded-data + // propagation used to overwrite the child snapshot wholesale.) + const mockData = createMockData() + const adapter = new MockAdapter(mockData, { delay: 20 }) + + let setKey: (key: string) => void = () => {} + + function TestComponent() { + const [key, setLocalKey] = useState('v1') + setKey = setLocalKey + + const article = useEntity( + schema.Article, + { by: { id: 'article-1' }, queryKey: key }, + e => e.title().author(a => a.id().name()), + ) + + if (article.$status !== 'ready') return null + return ( +
+
{article.title.value}
+
{article.author.name.value}
+
{String(article.author.name.isDirty)}
+
+ ) + } + + const { container } = render( + + + , + ) + + await waitFor(() => expect(queryByTestId(container, 'author')).not.toBeNull()) + + // Edit the related author's name locally -> dirty + act(() => (getByTestId(container, 'edit') as HTMLButtonElement).click()) + expect(getByTestId(container, 'author').textContent).toBe('LOCAL AUTHOR DRAFT') + expect(getByTestId(container, 'author-dirty').textContent).toBe('true') + + // Server changes the parent's title (clean -> landed signal) AND the + // author's name (same field the user is editing). + mockData.Article['article-1']!.title = 'landed' + mockData.Article['article-1']!.author = { id: 'author-1', name: 'SERVER AUTHOR', email: 'john@example.com', bio: 'Writer' } + act(() => setKey('v2')) + + await waitFor(() => { + expect(getByTestId(container, 'title').textContent).toBe('landed') + }) + + // The local edit on the related author survived and is still dirty + expect(getByTestId(container, 'author').textContent).toBe('LOCAL AUTHOR DRAFT') + expect(getByTestId(container, 'author-dirty').textContent).toBe('true') + }) +})