Skip to content

HasOne $connect(id) never loads the connected entity's selected fields; they read null until the parent is refetched #131

Description

@matej21

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.

  1. 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.
  2. 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.
  3. 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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions