Summary
HasOneRef.$connect(id) does not load the fields that the has-one's selection needs from the connected entity. Every selected field that the store does not already hold for that id reads as null. It reads null again after persistAll() and keeps doing so until the parent is fetched again. Nothing throws: no UnfetchedFieldError and no loading state. The only signal is a wrong value.
Environment
Reproduction
Board { assignments: hasMany(Assignment) }, Assignment { primary, label: hasOne(Label) }, Label { name, kind }. A picker lists labels by a separate useEntityList(Label, {}, e => e.id().name()). The user picks one, and the board adds a row that connects it:
const board = useEntity(entityDefs.Board, { by: { id: 'board-1' } }, e =>
e.id().assignments(a => a.id().primary().label(l => l.id().name().kind())),
)
const assignmentId = board.assignments.add({})
const assignment = board.assignments.items.find(it => it.id === assignmentId)
assignment?.label.$connect('label-1')
await persistAll()
assignment.label.$entity.$fields.kind.value // null, expected 'priority'
What the store holds for label-1 when $connect runs |
name |
kind |
name and kind (a list loaded both) |
Urgent |
priority |
only name (the picker's selection) |
Urgent |
null |
| nothing |
null |
null |
The test's first case is the control and passes. The other two fail before and after persist.
Expected behavior
After $connect(id), every field in the has-one's selection is available, the same way it is for a relation that was loaded with the parent. Until the fields are available, the relation is marked as loading. Code that filters or groups the parent's rows by a field of the connected entity (for example, "show the assignments whose label is of kind X") then keeps the row it just added.
Actual behavior
- A selected field that the store lacks reads
null. A read of a field outside the selection throws UnfetchedFieldError (packages/bindx/src/handles/EntityHandle.ts:489), but a field inside the selection with no data is indistinguishable from a real null.
- Persisting does not change this. The mutation connects the row, and bindx never reads the connected entity back.
- Applications therefore remember the picked entity's display data themselves, next to the relation, and read it as a fallback. The same workaround has to be repeated for each picker.
Root cause
HasOneHandle.connect() (packages/bindx/src/handles/HasOneHandle.ts:495) only dispatches connectRelation(...). It records the new id and does not look at the target's snapshot.
HasOneHandle.entity (HasOneHandle.ts:359) wraps whatever snapshot the store has for the target id. Embedded-data propagation (HasOneHandle.ts:385-441) applies only when the parent's embedded object has the same id. After a connect it has the old id, or none.
- No layer compares the has-one's nested selection with the fields that the target snapshot holds, and a handle has no adapter to fetch with. Only
useEntity / useEntityList fetch.
Proposal (needs a maintainer decision)
This is a missing feature rather than a one-line bug, so the design is open.
- Fetch on connect. When
$connect(id) runs and the target snapshot lacks fields of the has-one's nested selection, queue a by-id fetch of that selection. Batch it with other connects in the same tick. Until the fetch resolves, $entity.$isLoading (or a has-one $state such as 'connecting') is true. This needs a fetch path that is reachable from the handle layer, such as a loader service that the provider registers with the dispatcher. It also needs a rule for errors and for a target that the user cannot read.
- Refresh after persist. After the mutation succeeds, re-read the persisted parent with its selection. This is simpler, but the value stays wrong until the save, and it costs a query on every persist.
- Let the caller hand over the data. Add
$connect(id, { data }) or $connect(entityAccessor), where the caller passes an accessor it already holds (for example, a picker's row), and merge its snapshot into the store. This is cheap and explicit, but it covers only the fields the caller loaded.
In every variant, a selected field that the store lacks should stop reading as a silent null. It should report as loading or unfetched.
Workaround shipped downstream
We keep a map from the picked entity's id to its display name, which the picker's onSelect fills. The rows filter and label from that map when the connected entity's fields read null. We will remove it once this issue is resolved.
Summary
HasOneRef.$connect(id)does not load the fields that the has-one's selection needs from the connected entity. Every selected field that the store does not already hold for that id reads asnull. It readsnullagain afterpersistAll()and keeps doing so until the parent is fetched again. Nothing throws: noUnfetchedFieldErrorand no loading state. The only signal is a wrong value.Environment
@contember/bindx@0.1.52contember/bindx@mainas of78c1712tests/react/relations/hasOne/connectByIdTargetFields.test.tsxbug/connect-by-id-target-fields-not-fetchedReproduction
Board { assignments: hasMany(Assignment) },Assignment { primary, label: hasOne(Label) },Label { name, kind }. A picker lists labels by a separateuseEntityList(Label, {}, e => e.id().name()). The user picks one, and the board adds a row that connects it:label-1when$connectrunsnamekindnameandkind(a list loaded both)Urgentpriorityname(the picker's selection)UrgentnullnullnullThe test's first case is the control and passes. The other two fail before and after persist.
Expected behavior
After
$connect(id), every field in the has-one's selection is available, the same way it is for a relation that was loaded with the parent. Until the fields are available, the relation is marked as loading. Code that filters or groups the parent's rows by a field of the connected entity (for example, "show the assignments whose label is of kind X") then keeps the row it just added.Actual behavior
null. A read of a field outside the selection throwsUnfetchedFieldError(packages/bindx/src/handles/EntityHandle.ts:489), but a field inside the selection with no data is indistinguishable from a realnull.Root cause
HasOneHandle.connect()(packages/bindx/src/handles/HasOneHandle.ts:495) only dispatchesconnectRelation(...). It records the new id and does not look at the target's snapshot.HasOneHandle.entity(HasOneHandle.ts:359) wraps whatever snapshot the store has for the target id. Embedded-data propagation (HasOneHandle.ts:385-441) applies only when the parent's embedded object has the same id. After a connect it has the old id, or none.useEntity/useEntityListfetch.Proposal (needs a maintainer decision)
This is a missing feature rather than a one-line bug, so the design is open.
$connect(id)runs and the target snapshot lacks fields of the has-one's nested selection, queue a by-id fetch of that selection. Batch it with other connects in the same tick. Until the fetch resolves,$entity.$isLoading(or a has-one$statesuch as'connecting') istrue. This needs a fetch path that is reachable from the handle layer, such as a loader service that the provider registers with the dispatcher. It also needs a rule for errors and for a target that the user cannot read.$connect(id, { data })or$connect(entityAccessor), where the caller passes an accessor it already holds (for example, a picker's row), and merge its snapshot into the store. This is cheap and explicit, but it covers only the fields the caller loaded.In every variant, a selected field that the store lacks should stop reading as a silent
null. It should report as loading or unfetched.Workaround shipped downstream
We keep a map from the picked entity's id to its display name, which the picker's
onSelectfills. The rows filter and label from that map when the connected entity's fields readnull. We will remove it once this issue is resolved.