Skip to content

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 UserAchievementStats, 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:

  1. The nested object has no id/publicId of its own (not normalized).
  2. Its parent type is normalized (has keyFields).
  3. 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:

FieldWide writerNarrow writer(s)
achievementStatsUserQuery — 10 fields (unlockedCount, totalCount, rarePlusCount, currentXp, currentRankName, nextRankName, xpToNextRank, currentLevel, currentVolume, last7DaysXp)StreakRepairOfferQuery and RepairStreakMutation — 1 field (currentXp)
coachingPreferencesUserQuery — 8 fieldsWelcomeBackOfferQuery — 1 field (rhythm)
goalMotivationProfileUserQuery — 5 fieldsSyncUserMutation — 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:

  • latestEnneagramAssessment is immune because it normalizes. It has an id field (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 though UserQuery selects 5 fields and SyncUserMutation selects only id. Giving a nested type an identifying key field is the other fix for this class of bug, when the schema supports it.
  • onboardingStatus is safe because every writer shares one fragment. All four operations that select it go through the shared UserFragment (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):

ts
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:

ts
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:

graphql
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 (UserFragmentonboardingStatus, 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. UpdateDashboardPreferencesMutation returns it on the mutation's payload type, not on User itself; payload types aren't normalized as User, so there's no shared cache slot to collide on.
  • stats, details, streakRepairOffer, welcomeBackOffer — each has exactly one User-parent writer today. No second writer, no collision.
  • nextOnTheShelfBadge — two writers, but different parents (User vs. the non-normalized PublicProfile) — never the same cache slot.
  • photo — many writers, but every one selects imageUrl only. 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.warn spy (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 no merge: true policies 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.

  • ionic_frontend/src/apollo-type-policies.ts — the merge policies and the PublicProfile comment
  • ionic_frontend/src/apollo-client.ts — imports and consumes typePolicies
  • ionic_frontend/src/constants/graphql/user.jsREPAIR_STREAK_MUTATION, STREAK_REPAIR_OFFER_QUERY, SYNC_USER_MUTATION, USER_QUERY
  • ionic_frontend/src/constants/graphql/social.jsUserByUsernameQuery's achievementStats selection
  • ionic_frontend/src/constants/graphql/fragments.jsUserFragment, the shared-selection pattern that keeps onboardingStatus safe
  • ionic_frontend/tests/unit/apolloCacheMergePolicies.spec.ts — the regression suite
  • Achievement Stats: currentVolume vs currentLevel — a different, unrelated achievementStats gotcha on the same field

Last updated: 2026-08-19 (OBJ-2707: added merge policies for achievementStats/coachingPreferences/goalMotivationProfile)

Loading…