Version
Summary
Reading a hasMany relationship can throw an internal invariant error:
Error: Expected localState to be present
This happens when the relationship's edge in the graph has been left in the state { isDirty: false, localState: null }. Merely reading the relationship (materializing the related records) is enough to trigger it — the consumer does nothing unusual.
Where it throws
graph/-private.js, computeLocalState:
function computeLocalState(storage) {
if (!storage.isDirty) {
assert(Array.isArray(storage.localState)); // throws "Expected localState to be present"
return storage.localState;
}
const state = storage.remoteState.slice();
storage.removals?.forEach(v => { /* ... */ });
storage.additions?.forEach(v => { /* ... */ });
storage.localState = state;
storage.isDirty = false;
return state;
}
The function relies on the invariant: if an edge is isDirty === false, then localState must already be a materialized array. When isDirty is true, localState is (re)computed from remoteState + additions/removals and cached. But when isDirty is false, it assumes localState is already an array and asserts.
The failing edge violates this invariant: it is { isDirty: false, localState: null }. Because isDirty is false, the recompute branch is skipped and the assertion fires.
How the edge reaches { isDirty: false, localState: null }
The inconsistent state is observed after an inverse member of the relationship is dematerialized — for example when a route reloads its model and a record on the inverse side is unloaded/dematerialized. After this, the collection edge ends up flagged not-dirty but with a null (un-materialized) localState, so the next read of the relationship throws instead of recomputing.
So the bug is an internal graph-cache inconsistency: an edge is marked isDirty: false without localState having been materialized. Either:
- dematerializing an inverse member should not leave the edge as
{ isDirty: false, localState: null } (it should either keep localState materialized or mark the edge dirty so it recomputes), or
computeLocalState should treat a null localState while not-dirty as needing recomputation rather than asserting.
Expected behavior
Reading a hasMany relationship should never throw an internal invariant error, regardless of inverse-side dematerialization. It should return the current local state (recomputing from remote state if necessary).
Actual behavior
Reading the relationship throws Expected localState to be present.
Reproduction sketch
We hit this from a rollback routine that recursively reads hasMany relationships after a route reloaded its model (which dematerialized an inverse member). A minimal repro would be roughly:
- Load a parent record with a
hasMany to children, where the children have an inverse belongsTo/hasMany back to the parent.
- Materialize the relationship at least once.
- Cause an inverse-side member to be dematerialized (e.g. unload it, or reload the route's model so the inverse record is dropped).
- Read the parent's
hasMany again → throws Expected localState to be present.
I'm happy to put together a runnable reproduction if that would help confirm the exact trigger.
Workaround
Guarding the read and skipping recursion works for us, because a separate cache-level relationship rollback still resets the edge to its remote state:
let children;
try {
children = record[key]?.slice();
} catch {
children = undefined;
}
But this only hides the symptom; the underlying graph state { isDirty: false, localState: null } is still inconsistent.
Version
@warp-drive/core@5.8.2Summary
Reading a
hasManyrelationship can throw an internal invariant error:This happens when the relationship's edge in the graph has been left in the state
{ isDirty: false, localState: null }. Merely reading the relationship (materializing the related records) is enough to trigger it — the consumer does nothing unusual.Where it throws
graph/-private.js,computeLocalState:The function relies on the invariant: if an edge is
isDirty === false, thenlocalStatemust already be a materialized array. WhenisDirtyistrue,localStateis (re)computed fromremoteState+additions/removalsand cached. But whenisDirtyisfalse, it assumeslocalStateis already an array and asserts.The failing edge violates this invariant: it is
{ isDirty: false, localState: null }. BecauseisDirtyisfalse, the recompute branch is skipped and the assertion fires.How the edge reaches
{ isDirty: false, localState: null }The inconsistent state is observed after an inverse member of the relationship is dematerialized — for example when a route reloads its model and a record on the inverse side is unloaded/dematerialized. After this, the collection edge ends up flagged not-dirty but with a
null(un-materialized)localState, so the next read of the relationship throws instead of recomputing.So the bug is an internal graph-cache inconsistency: an edge is marked
isDirty: falsewithoutlocalStatehaving been materialized. Either:{ isDirty: false, localState: null }(it should either keeplocalStatematerialized or mark the edge dirty so it recomputes), orcomputeLocalStateshould treat anulllocalStatewhile not-dirty as needing recomputation rather than asserting.Expected behavior
Reading a
hasManyrelationship should never throw an internal invariant error, regardless of inverse-side dematerialization. It should return the current local state (recomputing from remote state if necessary).Actual behavior
Reading the relationship throws
Expected localState to be present.Reproduction sketch
We hit this from a rollback routine that recursively reads
hasManyrelationships after a route reloaded its model (which dematerialized an inverse member). A minimal repro would be roughly:hasManyto children, where the children have an inversebelongsTo/hasManyback to the parent.hasManyagain → throwsExpected localState to be present.I'm happy to put together a runnable reproduction if that would help confirm the exact trigger.
Workaround
Guarding the read and skipping recursion works for us, because a separate cache-level relationship rollback still resets the edge to its remote state:
But this only hides the symptom; the underlying graph state
{ isDirty: false, localState: null }is still inconsistent.