Skip to content

PART2: feat(settings): Add account local storage state management components#19980

Merged
vbudhram merged 1 commit intomainfrom
FXA-12994
Feb 6, 2026
Merged

PART2: feat(settings): Add account local storage state management components#19980
vbudhram merged 1 commit intomainfrom
FXA-12994

Conversation

@vbudhram
Copy link
Contributor

@vbudhram vbudhram commented Feb 2, 2026

Because

  • fxa-settings currently relies on Apollo Client and GraphQL for state management, which is being removed
  • Need new infrastructure to fetch account data via auth-client REST APIs and manage state in localStorage

This pull request

  • Adds account-storage.ts, unified localStorage abstraction for all account data
  • Adds useAccountData.ts, React hook that fetches account data from 3 sources in parallel (auth-server consolidated endpoint, profile server, attached clients)
  • Adds AccountStateContext.tsx, React context backed by localStorage with cross-tab sync via localStorageChange events
  • Adds AuthStateContext.tsx, React context for authentication state (session token, signed-in status)
  • These are additive-only changes not yet wired into the application

Issue

Closes: https://mozilla-hub.atlassian.net/browse/FXA-12994

Checklist

  • My commit is GPG signed
  • Tests pass locally (if applicable)
  • Documentation updated (if applicable)
  • RTL rendering verified (if UI changed)

Other Information

We won't be able to fully test the flow until #19981 lands, however this PR adds all the state management needed to support that.

@vbudhram vbudhram self-assigned this Feb 2, 2026
@vbudhram vbudhram force-pushed the FXA-12994 branch 3 times, most recently from 6d0b107 to 3bd32ee Compare February 4, 2026 21:01
@vbudhram vbudhram marked this pull request as ready for review February 4, 2026 21:33
@vbudhram vbudhram requested a review from a team as a code owner February 4, 2026 21:33
@vbudhram vbudhram requested review from dschom February 4, 2026 21:39
// and is best covered by integration tests.

describe('InvalidTokenError', () => {
it('has the correct name and message', () => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't really see the point of this test. What about tests for the other functions in userAccountData? Maybe these got lost in the shuffle.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yea I'll remove this, I guess my initial iterations on the approach required tests for each module. To test this would require complicated mocks that is probably better as integration tests.

}
}

function transformAttachedClientsResponse(response: any[]): AttachedClient[] {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It'd be nice to add jsdoc.

const accountData: Partial<AccountState> = {};

if (accountResult.status === 'fulfilled') {
Object.assign(accountData, transformAccountResponse(accountResult.value));
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why object.assign?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No big reason, the object changes three times and this seemed a little cleaner?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The issue I have with doing this is it essentially by passes type checking.


try {
const [accountResult, profileResult, attachedClientsResult] =
await Promise.allSettled([
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why use .allSettled instead of .all?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added note above, but this allows seeing all the errors vs just failing with first. Theres probably cases where we want to still get account data if the profile or attached devices fail for some reason.

Copy link
Contributor

@dschom dschom Feb 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My immediate concern when I see this kind of stuff is that we are essentailly quietly supresing errors for functionality that should not be failing... As a result we might never ever get wind of the failures, and give users a false impressions about the state of their account.

I like that a failure on a single call doesn't brick the page, but I think we should capture a Sentry error, and ideally we should notify the user that some sort of problem has occurred.

getFullAccountData,
} from './account-storage';

const { __mock: store } = require('./storage');
Copy link
Contributor

@dschom dschom Feb 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why use require here? I feel like something is off with how this is being mocked...

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added comment above, this was one of the tricky things I didn't know but found through iterating on the tests.

}

function savedAccount() {
return store.set.mock.calls[0][1][UID];
Copy link
Contributor

@dschom dschom Feb 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I find this function misleading... The function name implies that this will give you the account currently set / saved, but then why calls[0]? How do you know this the call you always want to reference? What if there multiple calls to store.set?

set: jest.fn(),
remove: jest.fn(),
};
return { __esModule: true, __mock: mock, default: { factory: () => mock } };
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

__mock?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks like this was just the testing convention to access mock instance.

const currentAccountUid = useLocalStorageSync('currentAccountUid') as string | undefined;

const accountState = useMemo<AccountState>(() => {
void accountsData; // Reference to trigger recompute on storage change
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this only needed cause we are inside useMemo? Do we realy need to useMemo here? If unifiedToAccountData is an expensive call, we should note that's why we are using useMemo here.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I removed the useMemo, it was hack to trigger recomputing unifiedToAccountState. That function is pretty cheap so it probably does not matter.

clearExtendedAccountState(uid);
}, []);

const value = useMemo<AccountStateContextValue>(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think a comment about why we useMemo here would be helpful.

return context;
}

export function useAccountUid(): string | null {
Copy link
Contributor

@dschom dschom Feb 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't see what these functions get us. Why not just have useAccountState?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good call, I cleaned this up and simplified the code.

return context;
}

export function useIsSignedIn(): boolean {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same comment as before. Why not just have useAuthState?

@vbudhram vbudhram force-pushed the FXA-12994 branch 2 times, most recently from e908f0f to ad0842f Compare February 5, 2026 16:13
@vbudhram vbudhram requested a review from dschom February 5, 2026 20:40
}

/** Transform the consolidated /account endpoint response to AccountState format. */
function transformAccountResponse(response: any): Partial<AccountState> {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we define a type here other than any?

};

const securityEvents: SecurityEvent[] = (response.securityEvents || []).map(
(e: any) => ({
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

More uses of any. Can we clean these up?

@vbudhram vbudhram force-pushed the FXA-12994 branch 2 times, most recently from 06b66a3 to 0eef7c8 Compare February 6, 2026 00:56
const seen = new Set<string>();
bounces = [...normalizedBounces, ...wildcardBounces].filter(
(bounce: any) => {
(bounce: { email: string; createdAt: number }) => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice, thanks for updating these too!

verified: false,
metricsEnabled: true,
sessionVerified: false,
...defaultExtendedState,
Copy link
Contributor

@dschom dschom Feb 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The default state should be spread before the other variables. It's unusual to do it in this order, becuase anything defined above would be overridden by the defaults, which is a bit backwards and not how we humans do it :] I know there are not overrides here, but it's just not idiomatic.

providerUid?: string;
}

export type ExtendedAccountState = Pick<
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I find this name confusing... Extending to me implies adding, but this is actually reducing the 'UnifiedAccountState'... a name that I also think is a litte confusing.

@vbudhram vbudhram merged commit 46cb7a1 into main Feb 6, 2026
21 checks passed
@vbudhram vbudhram deleted the FXA-12994 branch February 6, 2026 03:12
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants