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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 38 additions & 9 deletions packages/bindx-react/src/hooks/useEntity.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useRef, useEffect, useMemo, useCallback } from 'react'
import { useRef, useEffect, useMemo, useCallback, useState } from 'react'
import type {
EntityDef,
EntityUniqueWhere,
Expand All @@ -18,7 +18,7 @@ import {
EntityHandle,
resolveSelectionMeta,
buildQueryFromSelection,
setEntityData,
refreshServerData,
setLoadState,
createLoadError,
} from '@contember/bindx'
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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<TEntity extends object, TSelected extends object = TEntity> =
UseEntityResultBase & EntityAccessor<TEntity, TSelected> & {
readonly $status: 'ready'
readonly $isLoading: false
readonly $isRefetching: boolean
readonly $isError: false
readonly $isNotFound: false
readonly $error: null
Expand Down Expand Up @@ -236,6 +246,7 @@ export function useEntity(

// --- Data loading ---
const fetchingRef = useRef<string | null>(null)
const [isRefetching, setIsRefetching] = useState(false)

useEffect(() => {
// Check cache first
Expand All @@ -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<void> => {
try {
Expand All @@ -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'))
}
Expand All @@ -282,6 +305,10 @@ export function useEntity(
dispatcher.dispatch(
setLoadState(entityType, id, 'error', createLoadError(normalizedError)),
)
} finally {
if (!abortController.signal.aborted) {
setIsRefetching(false)
}
}
}

Expand Down Expand Up @@ -324,20 +351,20 @@ export function useEntity(
// --- Build result ---
const result = useMemo((): UseEntityResult<object, object> => {
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<T> at runtime via field access delegation
return result as UseEntityResult<any, any>
Expand All @@ -351,10 +378,12 @@ function createReadyResult(
handle: EntityAccessor<object, object>,
persist: () => Promise<void>,
reset: () => void,
isRefetching: boolean,
): UseEntityResult<object, object> {
const meta: Record<string, unknown> = {
$status: 'ready' as const,
$isLoading: false as const,
$isRefetching: isRefetching,
$isError: false as const,
$isNotFound: false as const,
$error: null,
Expand Down
43 changes: 38 additions & 5 deletions packages/bindx-react/src/hooks/useEntityList.ts
Original file line number Diff line number Diff line change
@@ -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'

Expand Down Expand Up @@ -43,20 +43,29 @@ interface EntityListResultBase {
export type LoadingEntityListResult = EntityListResultBase & {
readonly $status: 'loading'
readonly $isLoading: true
readonly $isRefetching: false
readonly $isError: false
readonly $error: null
}

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<T extends object> = EntityListResultBase & {
readonly $status: 'ready'
readonly $isLoading: false
readonly $isRefetching: boolean
readonly $isError: false
readonly $error: null
readonly $isDirty: boolean
Expand All @@ -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') },
Expand All @@ -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') },
Expand Down Expand Up @@ -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)
Expand All @@ -210,6 +223,7 @@ export function useEntityList(
version: number
storeVersion: number
status: string
isRefetching: boolean
result: UseEntityListResult<any>
} | null>(null)

Expand Down Expand Up @@ -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
}

Expand All @@ -300,6 +320,7 @@ export function useEntityList(
result = {
$status: 'ready',
$isLoading: false,
$isRefetching: state.isRefetching,
$isError: false,
$error: null,
$isDirty: false,
Expand All @@ -315,6 +336,7 @@ export function useEntityList(
version,
storeVersion,
status: state.status,
isRefetching: state.isRefetching,
result,
}

Expand All @@ -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<void> => {
try {
Expand Down Expand Up @@ -371,13 +401,15 @@ export function useEntityList(

const items = result.data.map((data: Record<string, unknown>) => {
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) {
Expand All @@ -388,6 +420,7 @@ export function useEntityList(
status: 'error',
items: [],
error: createLoadError(normalizedError),
isRefetching: false,
}
versionRef.current++
store.notify()
Expand Down
20 changes: 18 additions & 2 deletions packages/bindx-react/src/jsx/components/Entity.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,13 @@ interface EntityBaseProps<TRoleMap extends Record<string, object>> {
interface EntityByProps<TRoleMap extends Record<string, object>> extends EntityBaseProps<TRoleMap> {
/** 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 */
Expand Down Expand Up @@ -60,6 +67,7 @@ export type EntityProps<TRoleMap extends Record<string, object> = Record<string,
interface EntityByModeProps {
entityType: string
by: EntityUniqueWhere
queryKey?: string
children: (entity: EntityAccessor<unknown>) => React.ReactNode
loading?: React.ReactNode
error?: (error: FieldError) => React.ReactNode
Expand All @@ -81,6 +89,7 @@ interface EntityCreateModeProps {
function EntityByMode({
entityType,
by,
queryKey: userQueryKey,
children,
loading,
error: errorFallback,
Expand All @@ -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<unknown>),
})

// 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
Expand Down Expand Up @@ -347,6 +362,7 @@ function EntityImpl<TRoleMap extends Record<string, object>>(
<EntityByMode
entityType={byProps.entity.$name}
by={byProps.by}
queryKey={byProps.queryKey}
children={byProps.children as (entity: EntityAccessor<unknown>) => React.ReactNode}
loading={byProps.loading}
error={byProps.error}
Expand Down
Loading