Browse Source

feat(storage): implement user-scoped profile persistence with instant 0ms hydration

master
mortezaei 2 weeks ago
parent
commit
62696680d6
  1. 4
      src/components/Componentes/auth-data-boundary.tsx
  2. 35
      src/hooks/marriage/use-profile-main.ts
  3. 50
      src/lib/user-scoped-storage.test.ts
  4. 82
      src/lib/user-scoped-storage.ts

4
src/components/Componentes/auth-data-boundary.tsx

@ -5,6 +5,7 @@ import { useEffect, useRef, type ReactNode } from "react";
import { marriageQueryKeys } from "@/hooks/marriage/query-keys"; import { marriageQueryKeys } from "@/hooks/marriage/query-keys";
import { HABIB_AUTH_TOKEN_CHANGED_EVENT } from "@/lib/auth-bridge"; import { HABIB_AUTH_TOKEN_CHANGED_EVENT } from "@/lib/auth-bridge";
import { import {
clearScopedMarriageProfiles,
isLegacyMigrationDone, isLegacyMigrationDone,
migrateLegacyStorageKeys, migrateLegacyStorageKeys,
} from "@/lib/user-scoped-storage"; } from "@/lib/user-scoped-storage";
@ -83,6 +84,9 @@ export default function AuthDataBoundary({
return kind !== "config"; return kind !== "config";
}, },
}); });
// 3. Clear persisted local profile cache
clearScopedMarriageProfiles();
}; };
window.addEventListener(HABIB_AUTH_TOKEN_CHANGED_EVENT, handleAuthChange); window.addEventListener(HABIB_AUTH_TOKEN_CHANGED_EVENT, handleAuthChange);

35
src/hooks/marriage/use-profile-main.ts

@ -9,6 +9,10 @@ import {
hasCompletedMarriageProfileBasics, hasCompletedMarriageProfileBasics,
} from "@/lib/get-submit-path"; } from "@/lib/get-submit-path";
import { http } from "@/lib/http"; import { http } from "@/lib/http";
import {
getScopedMarriageProfile,
saveScopedMarriageProfile,
} from "@/lib/user-scoped-storage";
import type { QueryOptions } from "./options"; import type { QueryOptions } from "./options";
import { marriageQueryKeys } from "./query-keys"; import { marriageQueryKeys } from "./query-keys";
import type { MarriageProfileResponse } from "./types"; import type { MarriageProfileResponse } from "./types";
@ -16,7 +20,33 @@ import type { MarriageProfileResponse } from "./types";
export function getInitialMarriageProfile( export function getInitialMarriageProfile(
dataOverride?: any, dataOverride?: any,
): MarriageProfileResponse | undefined { ): MarriageProfileResponse | undefined {
const data = dataOverride ?? authBridge.getMarriageData();
if (dataOverride) {
const data = dataOverride;
return {
id: data.id ?? 0,
status: data.status,
gender: data.gender,
age: data.age ?? null,
is_registering_for_self: data.is_registering_for_self ?? null,
can_edit_profile: data.can_edit_profile ?? true,
can_message_expert: data.can_message_expert ?? true,
active_subscription: data.active_subscription ?? null,
is_ready_for_match: data.is_ready_for_match ?? true,
active_case: data.active_case ?? null,
needs_subscription: data.needs_subscription ?? false,
recommended_plan: data.recommended_plan ?? null,
match_summary: data.match_summary ?? null,
} as MarriageProfileResponse;
}
// Priority 2: Use locally persisted profile from previous successful server response
const persisted = getScopedMarriageProfile();
if (persisted) {
return persisted;
}
// Priority 3: Fallback to Flutter native bridge data
const data = authBridge.getMarriageData();
if (!data) return undefined; if (!data) return undefined;
return { return {
@ -55,6 +85,9 @@ export async function getMarriageProfile() {
raw_match_summary: data?.match_summary, raw_match_summary: data?.match_summary,
}); });
// Persist latest verified profile response locally for instant 0ms hydration
saveScopedMarriageProfile(data);
setCachedMarriageEntryPath( setCachedMarriageEntryPath(
hasCompletedMarriageProfileBasics(data) ? getSubmitPath(data) : "/intro", hasCompletedMarriageProfileBasics(data) ? getSubmitPath(data) : "/intro",
); );

50
src/lib/user-scoped-storage.test.ts

@ -14,6 +14,9 @@ import {
isDraftOwnedBy, isDraftOwnedBy,
getAllScopedSectionDraftKeys, getAllScopedSectionDraftKeys,
extractSlugFromScopedKey, extractSlugFromScopedKey,
saveScopedMarriageProfile,
getScopedMarriageProfile,
clearScopedMarriageProfiles,
} from "./user-scoped-storage"; } from "./user-scoped-storage";
describe("user-scoped-storage", () => { describe("user-scoped-storage", () => {
@ -160,4 +163,51 @@ describe("user-scoped-storage", () => {
expect(isDraftOwnedBy(undefined, 99)).toBe(false); expect(isDraftOwnedBy(undefined, 99)).toBe(false);
}); });
}); });
describe("Scoped marriage profile caching", () => {
const mockProfile = {
id: 241,
status: "in_case",
gender: "male",
age: 30,
is_registering_for_self: true,
can_edit_profile: true,
can_message_expert: true,
active_subscription: { is_active: true, plan: "gold" },
is_ready_for_match: true,
active_case: null,
needs_subscription: false,
recommended_plan: null,
match_summary: null,
} as any;
it("saves and retrieves scoped marriage profile correctly", () => {
saveScopedMarriageProfile(mockProfile);
// Read latest
const latest = getScopedMarriageProfile();
expect(latest).not.toBeNull();
expect(latest?.id).toBe(241);
expect(latest?.status).toBe("in_case");
expect(latest?.active_subscription?.plan).toBe("gold");
// Read by expected profile ID
const byId = getScopedMarriageProfile(241);
expect(byId?.id).toBe(241);
// Read by wrong profile ID returns null
const wrongId = getScopedMarriageProfile(999);
expect(wrongId).toBeNull();
}); });
it("clears persisted profiles on auth/token change", () => {
saveScopedMarriageProfile(mockProfile);
expect(getScopedMarriageProfile()).not.toBeNull();
clearScopedMarriageProfiles();
expect(getScopedMarriageProfile()).toBeNull();
expect(getScopedMarriageProfile(241)).toBeNull();
});
});
});

82
src/lib/user-scoped-storage.ts

@ -8,6 +8,8 @@
* The raw auth token is NEVER used as a key component. * The raw auth token is NEVER used as a key component.
*/ */
import type { MarriageProfileResponse } from "@/hooks/marriage/types";
export const SCOPED_STORAGE_VERSION = 3; export const SCOPED_STORAGE_VERSION = 3;
// ─── Types ─────────────────────────────────────────────────────────── // ─── Types ───────────────────────────────────────────────────────────
@ -283,3 +285,83 @@ export function extractSlugFromScopedKey(key: string): string | null {
const match = key.match(/^marriage:user:\d+:sections:([^:]+):draft:v\d+$/); const match = key.match(/^marriage:user:\d+:sections:([^:]+):draft:v\d+$/);
return match ? match[1] : null; return match ? match[1] : null;
} }
// ─── Scoped Marriage Profile Cache ─────────────────────────────────────
export interface ScopedMarriageProfileData {
version: typeof SCOPED_STORAGE_VERSION;
ownerProfileId: number;
profile: MarriageProfileResponse;
savedAt: number;
}
export function getScopedMarriageProfileKey(profileId: number): string {
return `marriage:user:${profileId}:profile:v${SCOPED_STORAGE_VERSION}`;
}
export const LATEST_PROFILE_STORAGE_KEY = `marriage:profile:latest:v${SCOPED_STORAGE_VERSION}`;
/**
* Persist the latest full MarriageProfileResponse from the server.
*/
export function saveScopedMarriageProfile(profile: MarriageProfileResponse): void {
if (typeof window === "undefined" || !profile?.id) return;
try {
const payload: ScopedMarriageProfileData = {
version: SCOPED_STORAGE_VERSION,
ownerProfileId: profile.id,
profile,
savedAt: Date.now(),
};
const serialized = JSON.stringify(payload);
window.localStorage.setItem(getScopedMarriageProfileKey(profile.id), serialized);
window.localStorage.setItem(LATEST_PROFILE_STORAGE_KEY, serialized);
} catch {}
}
/**
* Retrieve the persisted MarriageProfileResponse if valid.
*/
export function getScopedMarriageProfile(
expectedProfileId?: number,
): MarriageProfileResponse | null {
if (typeof window === "undefined") return null;
try {
const key = expectedProfileId
? getScopedMarriageProfileKey(expectedProfileId)
: LATEST_PROFILE_STORAGE_KEY;
const raw = window.localStorage.getItem(key);
if (!raw) return null;
const parsed = JSON.parse(raw) as ScopedMarriageProfileData;
if (!parsed || parsed.version !== SCOPED_STORAGE_VERSION || !parsed.profile) {
return null;
}
if (expectedProfileId && parsed.ownerProfileId !== expectedProfileId) {
return null;
}
return parsed.profile;
} catch {
return null;
}
}
/**
* Clear all persisted marriage profile caches (used on auth token/account change).
*/
export function clearScopedMarriageProfiles(): void {
if (typeof window === "undefined") return;
try {
window.localStorage.removeItem(LATEST_PROFILE_STORAGE_KEY);
const keysToRemove: string[] = [];
for (let i = 0; i < window.localStorage.length; i++) {
const key = window.localStorage.key(i);
if (key && key.startsWith("marriage:user:") && key.includes(":profile:")) {
keysToRemove.push(key);
}
}
for (const key of keysToRemove) {
window.localStorage.removeItem(key);
}
} catch {}
}
Loading…
Cancel
Save