Apollo Cache Merge Policies — Non-Normalized Nested Fields
Applies to: Frontend Apollo InMemoryCache configuration (ionic_frontend/src/apollo-type-policies.ts), any GraphQL operation that selects a nested object off User, adding a new operation that selects an existing nested field with a narrower subset than an existing one.
The trap
Apollo's InMemoryCache normalizes an object into its own cache entry only if it has an identifying key field (id, or here publicId via keyFields). User is normalized (keyFields: ['publicId'], ionic_frontend/src/apollo-type-policies.ts:10, consumed by ionic_frontend/src/apollo-client.ts:205). But several objects nested under User — AchievementStats, CoachingPreferences, GoalMotivationProfile — have no id/publicId of their own, so they are not normalized. They're stored inline, as a plain object value, on the parent User cache entry.
When two different GraphQL operations write that same inline field with different field subsets, Apollo's default write behavior is to replace the object wholesale rather than merge it — because with no merge function, Apollo can't tell whether the narrower write is "the field a screen didn't ask for is now undefined" or "the field is genuinely gone." It logs a warning (Cache data may be lost when replacing the <field> field of a <Type> object…) and picks replace.
Detection criterion — three things all have to be true:
- The nested object has no
id/publicIdof its own (not normalized). - Its parent type is normalized (has
keyFields). - Two or more operations select different field subsets on that nested object, so they write different-shaped values into the same cache slot.
What went wrong (OBJ-2707)
Three fields on User hit all three conditions:
| Field | Wide writer | Narrow writer(s) |
|---|---|---|
achievementStats | UserQuery — 10 fields (unlockedCount, totalCount, rarePlusCount, currentXp, currentRankName, nextRankName, xpToNextRank, currentLevel, currentVolume, last7DaysXp) | StreakRepairOfferQuery and RepairStreakMutation — 1 field (currentXp) |
coachingPreferences | UserQuery — 8 fields | WelcomeBackOfferQuery — 1 field (rhythm) |
goalMotivationProfile | UserQuery — 5 fields | SyncUserMutation — 1 field (completedAt) |
All three writers normalize into the same User:{publicId} cache entry, so a RepairStreakMutation (fired from a streak repair) or a SyncUserMutation (fired on every sign-in) could wholesale-replace the ten-field achievementStats (or the 8-/5-field siblings) with a one-field object — dropping currentRankName, currentLevel, last7DaysXp, and the rest for any component still reading from cache, with no network error and no visible failure until something reads the now-missing fields.
See also: Achievement Stats: currentVolume vs currentLevel — a different bug on the same achievementStats field, worth reading together if you're touching this area.
Why some fields are immune
Two existing patterns in the codebase already avoid this trap — useful as the two ways to not hit it:
latestEnneagramAssessmentis immune because it normalizes. It has anidfield (ionic_frontend/src/constants/graphql/user.js:9,:234), so Apollo gives it its own cache entry and merges writes to it by default — even thoughUserQueryselects 5 fields andSyncUserMutationselects onlyid. Giving a nested type an identifying key field is the other fix for this class of bug, when the schema supports it.onboardingStatusis safe because every writer shares one fragment. All four operations that select it go through the sharedUserFragment(ionic_frontend/src/constants/graphql/fragments.js), so every writer selects byte-identical fields — there's no narrower/wider split to collide. Fragment-sharing is the in-repo precedent for keeping a set of operations in lockstep, and was considered and rejected as the fix here (see below).
Fix
ionic_frontend/src/apollo-type-policies.ts (extracted out of apollo-client.ts so it's importable, side-effect-free, by a unit test — apollo-client.ts creates a live ActionCable consumer and pulls in Sentry/auth at module scope, which a test can't boot):
User: {
keyFields: ['publicId'],
fields: {
achievementStats: { merge: true }, // UserQuery 10 fields vs StreakRepairOffer/RepairStreak 1
coachingPreferences: { merge: true }, // UserQuery 8 fields vs WelcomeBackOfferQuery 1
goalMotivationProfile: { merge: true }, // UserQuery 5 fields vs SyncUserMutation 1
},
},merge: true is Apollo's built-in shallow merge: the incoming write's fields win, but any field the incoming write didn't select is kept from the existing cached value rather than dropped. It only affects fields that are absent from the incoming write — an explicit null still overwrites, so this can't mask a legitimate server-side clear.
apollo-client.ts:14 imports the extracted policies:
import { typePolicies } from '@/apollo-type-policies'
// ...
cache: new InMemoryCache({ typePolicies })The staleness trade-off — why RepairStreakMutation was widened
merge: true stops the blanking, but on its own it introduces a different problem: staleness. Repairing a streak deducts XP, which changes the derived fields — currentLevel, xpToNextRank, currentRankName, last7DaysXp — but the pre-fix RepairStreakMutation selection only asked for currentXp. With the merge policy alone, those derived fields would keep their pre-repair cached values indefinitely (correctly not blank, but silently wrong) until something else refetched them.
The fix: REPAIR_STREAK_MUTATION (ionic_frontend/src/constants/graphql/user.js:415-437) was widened to select the same derived rank fields UserQuery does:
user {
publicId
achievementStats {
currentXp
currentRankName
currentLevel
xpToNextRank
last7DaysXp
}
}StreakRepairOfferQuery was deliberately left narrow (currentXp only) — it's a read-only eligibility poll that doesn't change any of those derived values, so there's nothing for it to go stale on.
Rejected alternative: fragment consistency
The codebase already has a working precedent for forcing every writer of a field onto one shared shape (UserFragment → onboardingStatus, above). It was considered and rejected for achievementStats/coachingPreferences/goalMotivationProfile because the narrow selections in StreakRepairOfferQuery/WelcomeBackOfferQuery/SyncUserMutation are deliberately narrow for payload size — SyncUserMutation in particular runs on every sign-in. Forcing a shared fragment would put all 10/8/5 fields on every one of those hot, frequent calls. merge: true is also defensive: it protects any future operation that selects a narrower subset of these fields, without anyone having to remember to add it to a shared fragment.
The latent PublicProfile.achievementStats trap
PublicProfile (returned by UserByUsernameQuery, ionic_frontend/src/generated/graphql.ts:3929) has a publicId field but no keyFields entry in apollo-type-policies.ts — so it is not normalized today, and its achievementStats selection (7 fields: currentRankName, nextRankName, xpToNextRank, currentLevel, unlockedCount, totalCount, currentXp) has exactly one writer (UserByUsernameQuery, ionic_frontend/src/constants/graphql/social.js:732-739). One writer means no collision, so it needs no merge policy today.
It becomes exposed to this same bug the moment PublicProfile: { keyFields: ['publicId'] } is ever added — if PublicProfile starts normalizing and a second operation later selects achievementStats on it with a different field subset, the replace-not-merge trap reappears. apollo-type-policies.ts carries a comment noting this; if you add keyFields for PublicProfile, re-run the detection criterion above against every field it exposes, not just achievementStats.
Fields audited and found not to need a policy
The same three-part criterion was checked against every other nested object selected off User in ionic_frontend/src/generated/graphql.ts. None of these needed a policy:
dashboardPreferences— false positive.UpdateDashboardPreferencesMutationreturns it on the mutation's payload type, not onUseritself; payload types aren't normalized asUser, so there's no shared cache slot to collide on.stats,details,streakRepairOffer,welcomeBackOffer— each has exactly oneUser-parent writer today. No second writer, no collision.nextOnTheShelfBadge— two writers, but different parents (Uservs. the non-normalizedPublicProfile) — never the same cache slot.photo— many writers, but every one selectsimageUrlonly. Identical shape everywhere, so no subset mismatch exists to trigger a replace.
Regression test
ionic_frontend/tests/unit/apolloCacheMergePolicies.spec.ts drives a bare new InMemoryCache({ typePolicies }) directly — no component mount, no network — for all three fixed fields:
- full → partial: write the wide shape, then the narrow shape; reading the wide shape back still returns every field, with the narrow write's field at its newer value.
- partial → full: the reverse order; the full write's values win on every field.
- no cache-loss warning: a
console.warnspy (not eyeballing the console) asserts the "Cache data may be lost" message never fires across either sequence. - negative control: the same full → partial sequence run against a cache configured with
User: { keyFields: ['publicId'] }and nomerge: truepolicies reproduces the pre-fix bug — the wider fields are lost and the warning does fire. This is what proves the suite would have caught the original issue, not just that the fixed code happens to pass.
Run it directly: cd ionic_frontend && npx vitest run tests/unit/apolloCacheMergePolicies.spec.ts (12 tests).
Possible follow-up (not done here)
ionic_frontend/src/composables/useStreakRepair.ts:61-71 has a manual cache.modify that patches achievementStats.currentXp after RepairStreakMutation resolves. Now that merge: true is in place and the mutation's selection is widened, Apollo's automatic cache write already lands the full, correct field set before this update() callback runs, making the manual patch a redundant no-op (not incorrect — just dead weight). Left untouched as out of scope for this fix; worth simplifying away in a future pass.
Related
ionic_frontend/src/apollo-type-policies.ts— the merge policies and thePublicProfilecommentionic_frontend/src/apollo-client.ts— imports and consumestypePoliciesionic_frontend/src/constants/graphql/user.js—REPAIR_STREAK_MUTATION,STREAK_REPAIR_OFFER_QUERY,SYNC_USER_MUTATION,USER_QUERYionic_frontend/src/constants/graphql/social.js—UserByUsernameQuery'sachievementStatsselectionionic_frontend/src/constants/graphql/fragments.js—UserFragment, the shared-selection pattern that keepsonboardingStatussafeionic_frontend/tests/unit/apolloCacheMergePolicies.spec.ts— the regression suite- Achievement Stats: currentVolume vs currentLevel — a different, unrelated
achievementStatsgotcha on the same field
Last updated: 2026-08-19 (OBJ-2707: added merge policies for achievementStats/coachingPreferences/goalMotivationProfile)