Repository navigation
fix(mobile): recover when the active organization membership is removed - #8103
Conversation
… disappears A stored organization selection can outlive the membership itself: the host removes the member (or the organization is deleted) while the app still holds the id. Home then stays on the dead organization with an access-denied live section, and the profile credits row loses its organization name and balance. OrganizationProvider now compares each settled organizations.list snapshot against the one the current selection was published with. A new snapshot missing the stored selection switches to the first remaining membership (or Personal, with the explicit marker) through the existing selection path and announces the new context with a localized toast. Because the cached list can still name the org after the server already dropped the membership, a permission denial on an org-scoped query now also refetches the shared list once, so the provider sees the change instead of waiting for the next foreground freshness pass.
The permission-loss trigger refetched with a stale filter, which skipped a list inside its freshness window; invalidateQueries marks the payload stale and refetches active observers regardless of age. Also hoists the pure key/error helpers out of the describe block and satisfies the promise lint rules in the touched tests.
|
bot: End-to-end recovery on the Android emulator (local stack, owned fake account, member of two seeded organizations). Signed in with the first organization active; the Profile credits row showed that organization's name with a loaded balance. After the server removed that membership and the app refreshed:
|
Code Review SummaryStatus: No Issues Found | Recommendation: Merge Executive SummaryThe incremental commit is a single test-fixture correction that makes the stored-selection test's list include the saved organization, matching the new first-snapshot recovery behavior; the previously reported findings are resolved in current code and no new subscriptions, timers, or listeners are introduced. Assumptions: Files Reviewed (4 files)
Previous Review Summaries (6 snapshots, latest commit d0a74a3)Current summary above is authoritative. Previous snapshots are kept for context only. Previous review (commit d0a74a3)Status: No Issues Found | Recommendation: Merge Executive SummaryThe incremental commit is test-only: it mocks the toast adapter the provider imports and aligns the PR-review context-line case with the provider's lost-membership recovery, and the earlier findings remain fixed in current code; no new subscriptions, listeners, or timers are added, so there is no additional memory-leak surface. Assumptions: Files Reviewed (4 files)
Previous review (commit 7443c66)Status: No Issues Found | Recommendation: Merge Executive SummaryThe incremental change only seeds the shared memberships list in the organization credits expiry test so the provider keeps the staged selection, matching the provider's current recovery semantics; the four earlier findings are resolved in current code and no new defect or memory leak was introduced. Assumptions: none. Files Reviewed (4 files)
No new subscriptions, listeners, or timers are added by the incremental change, so the PR introduces no additional memory-leak surface. Previous review (commit 00a9f40)Status: No Issues Found | Recommendation: Merge Executive SummaryThe incremental commit only clarifies comments in Files Reviewed (5 files)
Previous review (commit 5124f72)Status: No Issues Found | Recommendation: Merge Executive SummaryThe follow-up commit only adds the Files Reviewed (87 files)
Previous review (commit 2f369b3)Status: No Issues Found | Recommendation: Merge Executive SummaryThe follow-up commits fix all four prior findings: a restored selection is now evaluated against its first settled snapshot, snapshots seen during an error are re-evaluated after it clears, the list invalidation joins an in-flight fetch, and the non-org test now asserts Files Reviewed (4 files)
Previous review (commit 710bf49)Status: 4 Issues Found | Recommendation: Address before merge Executive SummaryThe lost-membership recovery is sound for a running app, but a selection restored after the 24h read-cache expiry becomes its own baseline, so the dead organization is never recovered; three smaller issues cover a snapshot consumed during an error, repeated list-fetch restarts, and a vacuous test assertion. Overview
Issue Details (click to expand)WARNING
SUGGESTION
Files Reviewed (5 files)
No memory leaks found: the provider's new refs hold the shared query array for the provider lifetime only, are reset on sign-out and restore, and the invalidation adds no subscription or listener. Reviewed by deepseek-flash-latest · Input: 41.6K · Output: 3.3K · Cached: 301.7K Review guidance: REVIEW.md from base branch |
A selection restored from storage had no list baseline, so on a cold start without a persisted read cache the first live snapshot already missing the organization became the baseline and recovery never armed. A restored selection now evaluates its very first snapshot; a manual pick records the snapshot it was made against as its baseline. The detection effect records the snapshot only after its guards pass, so a snapshot observed during a save or restore error is re-evaluated once the error clears. The list invalidation now passes cancelRefetch false so concurrent denials cannot cancel each other's shared fetch, and the non-org test asserts the invalidation flag instead of the fetch timestamp.
iscekic
left a comment
There was a problem hiding this comment.
bot: Addressed all four threads in 2f369b3:
- Cold-start recovery (organization-context.tsx): a selection restored from storage now has no list baseline, so its very first settled snapshot is evaluated — a snapshot already missing the stored organization arms the recovery instead of becoming the baseline. A manual pick records the snapshot it was made against as its baseline, so picker-offered orgs stay trusted. Regression test added ("recovers a stored selection whose very first snapshot already misses it").
- Error-state baseline (organization-context.tsx): the snapshot is now recorded only after every guard passes, so a snapshot observed during a save/restore error is re-evaluated once the error clears.
- Concurrent denials (query-client.ts): the invalidation passes cancelRefetch: false, so several org-scoped denials around the same time cannot cancel each other's shared list fetch.
- Test assertion (query-client.test.ts): the non-org case now asserts isInvalidated is false instead of dataUpdatedAt.
Adds organization.boundary.membershipLostNotice to all 86 non-English
catalogs with the {{target}} placeholder kept exactly once per locale,
matching the sibling boundary copy's tone and formality.
A stored organization selection missing from the first fetched list now switches to the first remaining membership (or Personal) instead of rendering the dead selection with a generic credits name and a failed balance; the picker and the boundary surfaces then show the recovered context. A manual selection records the snapshot it was made against as its baseline, and a snapshot observed during a save or restore error is re-evaluated once the error clears. The mounted suites that pinned the old stored-missing contract update to the new one: the provider recovers before the sheet or card renders, so the staged selections now seed the fetched list they are asserted against. The four suites that reach react-native's Flow source through the toast adapter mock the adapter at that edge.
|
bot: Cold-start recovery on the Android emulator (local stack, owned fake account, member of two seeded organizations). Signed in with the first organization active; the Profile credits row showed that organization's name with a loaded balance. Then the app was closed, the server removed that membership, and the app was relaunched:
The earlier comment covers the live removal while the app is open; this run covers the restored-selection case on the current head. |
|
bot: Follow-up evidence on the account selector after a membership disappears (Android emulator, local stack, owned fake account, two seeded organizations). The selector's rows come from the shared membership list, so this run exercised that surface directly.
Commands and observation source: Attachments show only the fake seeded organizations and fake balances on |
…ved-recovery # Conflicts: # apps/mobile/src/i18n/locales/sv.json
…y test The context provider recovers a stored selection that the first fetched list omits, so a test that stages an organization selection must publish that organization in the shared list; otherwise the card renders the personal credit blocks.
…ved-recovery # Conflicts: # apps/mobile/src/components/context-control.mounted.test.tsx # apps/mobile/src/i18n/locales/af.json # apps/mobile/src/i18n/locales/am.json # apps/mobile/src/i18n/locales/ar.json # apps/mobile/src/i18n/locales/az.json # apps/mobile/src/i18n/locales/be.json # apps/mobile/src/i18n/locales/bg.json # apps/mobile/src/i18n/locales/bn.json # apps/mobile/src/i18n/locales/bs.json # apps/mobile/src/i18n/locales/ca.json # apps/mobile/src/i18n/locales/ckb.json # apps/mobile/src/i18n/locales/cs.json # apps/mobile/src/i18n/locales/cy.json # apps/mobile/src/i18n/locales/da.json # apps/mobile/src/i18n/locales/de.json # apps/mobile/src/i18n/locales/el.json # apps/mobile/src/i18n/locales/en.json # apps/mobile/src/i18n/locales/es.json # apps/mobile/src/i18n/locales/et.json # apps/mobile/src/i18n/locales/eu.json # apps/mobile/src/i18n/locales/fa.json # apps/mobile/src/i18n/locales/fi.json # apps/mobile/src/i18n/locales/fil.json # apps/mobile/src/i18n/locales/fr.json # apps/mobile/src/i18n/locales/ga.json # apps/mobile/src/i18n/locales/gl.json # apps/mobile/src/i18n/locales/gu.json # apps/mobile/src/i18n/locales/ha.json # apps/mobile/src/i18n/locales/he.json # apps/mobile/src/i18n/locales/hi.json # apps/mobile/src/i18n/locales/hr.json # apps/mobile/src/i18n/locales/ht.json # apps/mobile/src/i18n/locales/hu.json # apps/mobile/src/i18n/locales/hy.json # apps/mobile/src/i18n/locales/id.json # apps/mobile/src/i18n/locales/ig.json # apps/mobile/src/i18n/locales/is.json # apps/mobile/src/i18n/locales/it.json # apps/mobile/src/i18n/locales/ja.json # apps/mobile/src/i18n/locales/ka.json # apps/mobile/src/i18n/locales/kk.json # apps/mobile/src/i18n/locales/km.json # apps/mobile/src/i18n/locales/kn.json # apps/mobile/src/i18n/locales/ko.json # apps/mobile/src/i18n/locales/lo.json # apps/mobile/src/i18n/locales/lt.json # apps/mobile/src/i18n/locales/lv.json # apps/mobile/src/i18n/locales/mg.json # apps/mobile/src/i18n/locales/mi.json # apps/mobile/src/i18n/locales/mk.json # apps/mobile/src/i18n/locales/ml.json # apps/mobile/src/i18n/locales/mn.json # apps/mobile/src/i18n/locales/mr.json # apps/mobile/src/i18n/locales/ms.json # apps/mobile/src/i18n/locales/mt.json # apps/mobile/src/i18n/locales/my.json # apps/mobile/src/i18n/locales/nb.json # apps/mobile/src/i18n/locales/ne.json # apps/mobile/src/i18n/locales/nl.json # apps/mobile/src/i18n/locales/om.json # apps/mobile/src/i18n/locales/or.json # apps/mobile/src/i18n/locales/pa.json # apps/mobile/src/i18n/locales/pl.json # apps/mobile/src/i18n/locales/ps.json # apps/mobile/src/i18n/locales/pt-BR.json # apps/mobile/src/i18n/locales/pt.json # apps/mobile/src/i18n/locales/ro.json # apps/mobile/src/i18n/locales/ru.json # apps/mobile/src/i18n/locales/si.json # apps/mobile/src/i18n/locales/sk.json # apps/mobile/src/i18n/locales/sl.json # apps/mobile/src/i18n/locales/so.json # apps/mobile/src/i18n/locales/sq.json # apps/mobile/src/i18n/locales/sr.json # apps/mobile/src/i18n/locales/sv.json # apps/mobile/src/i18n/locales/sw.json # apps/mobile/src/i18n/locales/ta.json # apps/mobile/src/i18n/locales/te.json # apps/mobile/src/i18n/locales/th.json # apps/mobile/src/i18n/locales/tr.json # apps/mobile/src/i18n/locales/uk.json # apps/mobile/src/i18n/locales/ur.json # apps/mobile/src/i18n/locales/uz.json # apps/mobile/src/i18n/locales/vi.json # apps/mobile/src/i18n/locales/yo.json # apps/mobile/src/i18n/locales/zh-Hans.json # apps/mobile/src/i18n/locales/zh-Hant.json # apps/mobile/src/i18n/locales/zu.json
…ecovery The provider now switches a stored selection that the settled memberships list no longer names, so the line shows the recovered account instead of the unavailable scope. Mock the toast adapter the provider imports, as the other provider-mounted suites do.
The restoration test asserted the stored selection survives a list that does not name it, which races the provider's lost-membership switch. Give the resolved list the stored organization so the assertion is stable.













Change
When the server removes the user's membership in the active organization, the app previously stayed on the dead organization: Home showed an access-denied live section with only a back action, and the Profile credits row lost its organization name and balance ("Failed to load balance. Tap to retry.") with a disabled organization row.
Two additions make the app recover:
Provider reconciliation (
apps/mobile/src/lib/organization-context.tsx): the provider now compares each settledorganizations.listsnapshot against the one the current selection was published with. A new snapshot that no longer names the stored organization switches to the first remaining membership — or Personal, writing the explicit-Personal marker — through the existingsetOrganizationIdpath (persistence, activity-token cleanup, glanceable blanking all reused), and announces the new context with a localized toast (organization.boundary.membershipLostNotice): "You no longer have access to this organization. Switched to Org Beta."List refresh on permission loss (
apps/mobile/src/lib/query-client.ts): the cached list can still name the org after the server already dropped the membership, so the provider would have nothing to act on until the next foreground freshness pass. A permission-denied error (FORBIDDEN / procedure-level UNAUTHORIZED) on an org-scoped query now invalidates the sharedorganizations.listonce, so the fresh snapshot reaches the provider immediately. A list still holding the org (role-scoped denial, org intact) changes nothing: the fresh snapshot matches the selection's own, and the existing access-denied surface stays.New key
organization.boundary.membershipLostNoticeinen.json, translated into all 86 non-English catalogs (placeholder parity kept).End-to-end verification (Android emulator, local stack, owned fake account)
Signed in as a fake account member of two seeded organizations (Org Alpha active, Org Beta secondary); credits row showed "Org Alpha" with a loaded balance.
After deleting the member's Org Alpha membership row on the local server and returning to Home:
Checks:
pnpm --filter kilo-app typecheckclean;lint0 errors;check:unusedclean;check:pluralsclean; vitest suites fororganization-context(8, including four new recovery cases),query-client(27, including two new list-refresh cases), and the home mounted suite (57) all pass — 92/92.Screenshots: baseline "Org Alpha" credits row, the recovery toast on Home, and the recovered "Org Beta" credits row are attached in a follow-up comment.
Sync with main
The branch merged
main(throughcef32d4d04) so it keeps the newer organization surfaces:organization.boundary.membershipLostNoticeand main's new organization strings, withsvresolved by taking main's retranslated boundary block and inserting the notice in its order.Re-run on the merged tree:
apps/mobilevitest 1241 files / 18439 tests pass,pnpm --filter kilo-app typecheckclean,pnpm check:i18nreports 87 catalogs matchingen.json,format:checkclean.Proxy answers
The owner's proxy gave these answers in the owner's place.
Answer: First remaining membership (Recommended)
Confidence: 0.80 · Precedent: Do the least work possible (session 7f3470e0ba6f: "do the least amount of work possible, with no drop in quality"). This reuses the existing default-resolution rule and matches the brief.
Answer: Toast notice (Recommended)
Confidence: 0.85 · Precedent: Localize every string in the mobile app (session dcb3f9170164: "the mobile app always must respect i18n"). The brief requires a localized notice.
Answer: Write the marker (Recommended)
Confidence: 0.70 · Precedent: Do the least work possible (session 00eb2a1d3e4b: "skip work that doesn't need to be done"). Reuse the existing switch path and do not add a duplicate recovery branch.
Answer: Any org-scoped FORBIDDEN (Recommended)
Confidence: 0.80 · Precedent: Fix the class of issue on every affected surface (session 75f8833fe8c1: "fix this for all ... on all surfaces"). React just in time, not on the next poll (session 55c9c80332a7: "react just in time, no polling").
Answer: Auto-switch on first list (Recommended)
Confidence: 0.75 · Precedent: Autonomy: 'Fix the root cause and the class of the issue, not the single symptom' (session 00eb2a1d3e4b, 'fix the class'); Speed: 'Replace old paths instead of keeping them alongside' (session ad6a3f589364, 'alongside? why not instead?')