Skip to content

feat(react): expose queryKey on Entity/EntityList with stale-while-revalidate refetch#41

Merged
vparys merged 3 commits into
mainfrom
feat/entity-query-key
Jun 1, 2026
Merged

feat(react): expose queryKey on Entity/EntityList with stale-while-revalidate refetch#41
vparys merged 3 commits into
mainfrom
feat/entity-query-key

Conversation

@vparys
Copy link
Copy Markdown
Member

@vparys vparys commented Jun 1, 2026

Closes #40.

Summary

Adds a queryKey prop to <Entity> and <EntityList> that re-runs the query against the server, swaps in the new data, and keeps the accessor identity stable. No remount, no loading fallback, no flicker — UI state below the component (open dialogs, scroll position, undo history, in-flight form drafts) survives the refresh.

This is the missing primitive every fetch layer ships from day one. Its absence forced a <Entity key={refreshKey}> workaround for workflow-RPC interop, which restarted the entire subtree from scratch on every external mutation (cf. the issue).

<Entity entity={schema.Foo} by={{ id }} queryKey={refreshKey}>
  {foo => /* … */}
</Entity>
<EntityList entity={schema.Foo} filter={} queryKey={refreshKey}>
  {foo => /* … */}
</EntityList>

Bump refreshKey after a workflow RPC → bindx silently re-fetches, swaps the data in, the consumer rerenders with new field values, nothing remounts.

What changed

  • refreshServerData core primitive (@contember/bindx) — a new store action / SnapshotStore.refreshServerData() / EntitySnapshotStore.refreshServerData() used by revalidation. Unlike setEntityData(…, isServerData=true), which merges the fresh server payload over the working data and clobbers any local edit, refreshServerData advances the server baseline for every incoming field but only overwrites the working value for fields that are not locally dirty. Edited fields keep the user's value; their baseline still moves, so $reset drops to the new server value and isDirty stays correct. This is what makes the issue's "should NOT silently overwrite dirty fields" contract real.
  • useEntity / useEntityList — stale-while-revalidate semantics. If ready data is already in the store for the entity, a triggered refetch does NOT dispatch setLoadState('loading'); instead it sets an isRefetching flag and the ready accessor exposes $isRefetching: true until the new data lands. First load (no data yet) still uses the explicit loading state. Both hooks now dispatch refreshServerData (not setEntityData) on success, so refetches preserve dirty edits.
  • <Entity> / <EntityList> — new optional queryKey?: string prop. The user-supplied key is combined with the internal selection-derived key (`${selectionKey}::${userKey}`), so both selection changes and explicit refresh requests trigger refetches.
  • TypesReadyEntityResult / ReadyEntityListResult gain readonly $isRefetching: boolean. Non-ready states get $isRefetching: false.

Dirty-edit contract

Issue #40 asks: "If the user has dirty fields when refetch lands, does bindx overwrite them or merge? Probably should NOT silently overwrite." This PR implements exactly that:

  • Refetch advances the server baseline (serverData) for every field it returns.
  • A field the user has not edited picks up the fresh server value.
  • A field the user has edited keeps the local working value and stays isDirty: true. Its baseline still moves, so a later $reset reveals the new server value (not the stale one).

Test plan

tests/react/hooks/useEntity/queryKey.test.tsx, tests/react/jsx/EntityQueryKey.test.tsx, tests/unit/store/snapshotStore.test.ts:

  • changing queryKey refetches and swaps in new field values
  • subtree does NOT unmount during refetch (mount sentinel)
  • DOM node identity preserved across refetch
  • $isRefetching flips true during refetch, back to false after
  • locally-held React/DOM state (uncontrolled input draft) survives a queryKey bump
  • refetch preserves a locally edited (dirty) bindx field while updating a clean sibling
  • dirty field kept on refetch, but server baseline advanced — $reset reveals the new server value
  • <EntityList queryKey> refetch preserves a locally edited item field
  • store-level unit tests for refreshServerData (clean update / dirty preserve / baseline advance / no-snapshot fallback)
  • first load still uses explicit loading state (no SWR when no data yet)
  • <EntityList queryKey> refetches without the "Loading..." fallback appearing
  • full suite: same 10 pre-existing baseline failures, no new regressions

Notes

  • $refetch() imperative escape hatch is intentionally out of scope — queryKey prop alone covers the workflow-RPC use case. Easy to add later as a follow-up.
  • refreshServerData is intentionally a dedicated path rather than a change to the shared setEntityData primitive, which is also used by initial load and the pessimistic-persist restore path where overwrite-all is the correct behavior.
  • Issue entity:persisting / entity:persisted events never emitted (silent no-op) #28 (entity:persisted events) is orthogonal — queryKey doesn't replace it, just unblocks the workflow side independently.

vparys and others added 3 commits June 1, 2026 11:42
…validate refetch

Closes #40

Adds a `queryKey` prop to `<Entity>` and `<EntityList>` 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 `<Entity key={refreshKey}>`, which forces a remount and
loses everything beneath it. `<Entity queryKey={refreshKey}>` replaces
that with a transparent refresh.

- `useEntity` / `useEntityList`: skip the `loading` dispatch when ready
  data already exists; expose `$isRefetching` on the ready result.
- `<Entity>` / `<EntityList>`: 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.
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.
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) <noreply@anthropic.com>
@vparys vparys merged commit cc7d61d into main Jun 1, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add public refetch path for <Entity> / useEntity so workflow RPC consumers don't have to remount

2 participants