feat(react): expose queryKey on Entity/EntityList with stale-while-revalidate refetch#41
Merged
Conversation
…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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #40.
Summary
Adds a
queryKeyprop 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).Bump
refreshKeyafter a workflow RPC → bindx silently re-fetches, swaps the data in, the consumer rerenders with new field values, nothing remounts.What changed
refreshServerDatacore primitive (@contember/bindx) — a new store action /SnapshotStore.refreshServerData()/EntitySnapshotStore.refreshServerData()used by revalidation. UnlikesetEntityData(…, isServerData=true), which merges the fresh server payload over the working data and clobbers any local edit,refreshServerDataadvances 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$resetdrops to the new server value andisDirtystays 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 dispatchsetLoadState('loading'); instead it sets anisRefetchingflag and the ready accessor exposes$isRefetching: trueuntil the new data lands. First load (no data yet) still uses the explicit loading state. Both hooks now dispatchrefreshServerData(notsetEntityData) on success, so refetches preserve dirty edits.<Entity>/<EntityList>— new optionalqueryKey?: stringprop. The user-supplied key is combined with the internal selection-derived key (`${selectionKey}::${userKey}`), so both selection changes and explicit refresh requests trigger refetches.ReadyEntityResult/ReadyEntityListResultgainreadonly $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:
serverData) for every field it returns.isDirty: true. Its baseline still moves, so a later$resetreveals 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:queryKeyrefetches and swaps in new field values$isRefetchingflipstrueduring refetch, back tofalseafterqueryKeybump$resetreveals the new server value<EntityList queryKey>refetch preserves a locally edited item fieldrefreshServerData(clean update / dirty preserve / baseline advance / no-snapshot fallback)<EntityList queryKey>refetches without the "Loading..." fallback appearingNotes
$refetch()imperative escape hatch is intentionally out of scope —queryKeyprop alone covers the workflow-RPC use case. Easy to add later as a follow-up.refreshServerDatais intentionally a dedicated path rather than a change to the sharedsetEntityDataprimitive, which is also used by initial load and the pessimistic-persist restore path where overwrite-all is the correct behavior.entity:persistedevents) is orthogonal —queryKeydoesn't replace it, just unblocks the workflow side independently.