diff --git a/packages/bindx-react/src/hooks/useEntity.ts b/packages/bindx-react/src/hooks/useEntity.ts index 3a599ef..e750dcb 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, @@ -18,7 +18,7 @@ import { EntityHandle, resolveSelectionMeta, buildQueryFromSelection, - setEntityData, + refreshServerData, setLoadState, createLoadError, } from '@contember/bindx' @@ -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 { @@ -269,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')) } @@ -282,6 +305,10 @@ export function useEntity( dispatcher.dispatch( setLoadState(entityType, id, 'error', createLoadError(normalizedError)), ) + } finally { + if (!abortController.signal.aborted) { + setIsRefetching(false) + } } } @@ -324,20 +351,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 +378,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..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' @@ -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 { @@ -371,13 +401,15 @@ 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 } }) - listStateRef.current = { status: 'ready', items } + listStateRef.current = { status: 'ready', items, isRefetching: false } versionRef.current++ store.notify() } catch (error) { @@ -388,6 +420,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/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/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/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..2c0d466 100644 --- a/packages/bindx/src/store/SnapshotStore.ts +++ b/packages/bindx/src/store/SnapshotStore.ts @@ -182,6 +182,25 @@ 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, + skipNotify: boolean = false, + ): EntitySnapshot { + const key = this.getEntityKey(entityType, id) + const newSnapshot = this.entitySnapshots.refreshServerData(key, id, entityType, data) + this.meta.setExistsOnServer(key, true) + if (!skipNotify) { + 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 new file mode 100644 index 0000000..3df46d3 --- /dev/null +++ b/tests/react/hooks/useEntity/queryKey.test.tsx @@ -0,0 +1,452 @@ +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 updates a clean (un-edited) 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(), + ) + + 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') + }) + }) + + 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') + }) +}) + +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') + }) +}) diff --git a/tests/react/jsx/EntityQueryKey.test.tsx b/tests/react/jsx/EntityQueryKey.test.tsx new file mode 100644 index 0000000..1a23b6d --- /dev/null +++ b/tests/react/jsx/EntityQueryKey.test.tsx @@ -0,0 +1,265 @@ +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') + }) + }) + + 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)