Browse Source

perf(config): optimize caching strategy and font loading

- Update `next.config.ts` to use `stale-while-revalidate` for application HTML shells to improve WebView rendering speed.
- Implement immutable caching for static assets in `/assets/fonts/`.
- Add `.woff2` versions of various fonts to `public/assets/fonts/` and `public/fonts/` to improve performance and reduce payload size.
- Update `globals.css` to prioritize `.woff2` font formats.
- Increase `staleTime` from 30s to 3m for various marriage-related queries in `use-form-schema.ts` and client components to reduce redundant network requests.
- Refactor `QuestionSheet` to use CSS-based `min-h-[52px]` instead of dynamic inline style calculations for option buttons, improving layout stability and simplifying the component.
- Improve loading state handling in `QuestionDetailClient` by incorporating `isPending` from TanStack Query.
- Update test assertions in `question-sheet.test.tsx` to reflect the shift from inline styles to Tailwind CSS classes.
master
mortezaei 2 weeks ago
parent
commit
ee238ad8cf
  1. 18
      next.config.ts
  2. BIN
      public/assets/fonts/ArabicYekanXRegular.woff2
  3. BIN
      public/assets/fonts/Faminela.woff2
  4. BIN
      public/assets/fonts/NotoNastaliqUrdu.woff2
  5. BIN
      public/assets/fonts/SegoeUIBold.woff2
  6. BIN
      public/assets/fonts/YekanXFaNum-R.woff2
  7. BIN
      public/assets/fonts/faminela.woff2
  8. BIN
      public/assets/fonts/segoeui.woff2
  9. BIN
      public/fonts/Amiri/Amiri-Bold.woff2
  10. BIN
      public/fonts/Amiri/Amiri-BoldItalic.woff2
  11. BIN
      public/fonts/Amiri/Amiri-Italic.woff2
  12. BIN
      public/fonts/Amiri/Amiri-Regular.woff2
  13. BIN
      public/fonts/Faminela/Faminela.woff2
  14. BIN
      public/fonts/segoeui/SegoeUIBold.woff2
  15. BIN
      public/fonts/segoeui/segoeui.woff2
  16. BIN
      public/fonts/urdu/NotoNastaliqUrdu.woff2
  17. BIN
      public/fonts/yekanx-arabic/ArabicYekanXRegular.woff2
  18. BIN
      public/fonts/yekanx/YekanXFaNum-R.woff2
  19. 19
      src/app/globals.css
  20. 20
      src/app/questions-list/[slug]/question-detail-client.tsx
  21. 11
      src/app/questions-list/questions-list-client.tsx
  22. 18
      src/components/Componentes/question-sheet.test.tsx
  23. 7
      src/components/Componentes/question-sheet.tsx
  24. 8
      src/hooks/marriage/use-form-schema.ts

18
next.config.ts

@ -82,16 +82,12 @@ const nextConfig: NextConfig = {
], ],
}, },
{ {
// Application HTML pages – no-store to prevent HTML/shell caching in WebView
// Application HTML shell – App Shell + Stale-While-Revalidate pattern for instant WebView rendering
source: "/:path((?!api/|_next/|fonts/|assets/).*)", source: "/:path((?!api/|_next/|fonts/|assets/).*)",
headers: [ headers: [
{ {
key: "Cache-Control", key: "Cache-Control",
value: "no-store, no-cache, must-revalidate, max-age=0",
},
{
key: "Pragma",
value: "no-cache",
value: "public, max-age=0, stale-while-revalidate=86400",
}, },
], ],
}, },
@ -115,6 +111,16 @@ const nextConfig: NextConfig = {
}, },
], ],
}, },
{
// Static fonts inside assets directory are immutable
source: "/assets/fonts/:path*",
headers: [
{
key: "Cache-Control",
value: "public, max-age=31536000, immutable",
},
],
},
]; ];
}, },
}; };

BIN
public/assets/fonts/ArabicYekanXRegular.woff2

BIN
public/assets/fonts/Faminela.woff2

BIN
public/assets/fonts/NotoNastaliqUrdu.woff2

BIN
public/assets/fonts/SegoeUIBold.woff2

BIN
public/assets/fonts/YekanXFaNum-R.woff2

BIN
public/assets/fonts/faminela.woff2

BIN
public/assets/fonts/segoeui.woff2

BIN
public/fonts/Amiri/Amiri-Bold.woff2

BIN
public/fonts/Amiri/Amiri-BoldItalic.woff2

BIN
public/fonts/Amiri/Amiri-Italic.woff2

BIN
public/fonts/Amiri/Amiri-Regular.woff2

BIN
public/fonts/Faminela/Faminela.woff2

BIN
public/fonts/segoeui/SegoeUIBold.woff2

BIN
public/fonts/segoeui/segoeui.woff2

BIN
public/fonts/urdu/NotoNastaliqUrdu.woff2

BIN
public/fonts/yekanx-arabic/ArabicYekanXRegular.woff2

BIN
public/fonts/yekanx/YekanXFaNum-R.woff2

19
src/app/globals.css

@ -3,7 +3,9 @@
/* ─── App fonts matching Flutter Najm & Hosseinieh ─── */ /* ─── App fonts matching Flutter Najm & Hosseinieh ─── */
@font-face { @font-face {
font-family: 'Faminela'; font-family: 'Faminela';
src: url('/fonts/Faminela/Faminela.otf') format('opentype'),
src: url('/fonts/Faminela/Faminela.woff2') format('woff2'),
url('/assets/fonts/Faminela.woff2') format('woff2'),
url('/fonts/Faminela/Faminela.otf') format('opentype'),
url('/assets/fonts/Faminela.otf') format('opentype'), url('/assets/fonts/Faminela.otf') format('opentype'),
url('/assets/fonts/faminela.ttf') format('truetype'); url('/assets/fonts/faminela.ttf') format('truetype');
font-weight: 400 900; font-weight: 400 900;
@ -12,35 +14,40 @@
} }
@font-face { @font-face {
font-family: 'YekanX'; font-family: 'YekanX';
src: url('/assets/fonts/YekanXFaNum-R.ttf') format('truetype');
src: url('/assets/fonts/YekanXFaNum-R.woff2') format('woff2'),
url('/assets/fonts/YekanXFaNum-R.ttf') format('truetype');
font-weight: 400; font-weight: 400;
font-style: normal; font-style: normal;
font-display: swap; font-display: swap;
} }
@font-face { @font-face {
font-family: 'YekanXArabic'; font-family: 'YekanXArabic';
src: url('/assets/fonts/ArabicYekanXRegular.ttf') format('truetype');
src: url('/assets/fonts/ArabicYekanXRegular.woff2') format('woff2'),
url('/assets/fonts/ArabicYekanXRegular.ttf') format('truetype');
font-weight: 400; font-weight: 400;
font-style: normal; font-style: normal;
font-display: swap; font-display: swap;
} }
@font-face { @font-face {
font-family: 'NastaliqUrdu'; font-family: 'NastaliqUrdu';
src: url('/assets/fonts/NotoNastaliqUrdu.ttf') format('truetype');
src: url('/assets/fonts/NotoNastaliqUrdu.woff2') format('woff2'),
url('/assets/fonts/NotoNastaliqUrdu.ttf') format('truetype');
font-weight: 400; font-weight: 400;
font-style: normal; font-style: normal;
font-display: swap; font-display: swap;
} }
@font-face { @font-face {
font-family: 'Segoeui'; font-family: 'Segoeui';
src: url('/assets/fonts/segoeui.ttf') format('truetype');
src: url('/assets/fonts/segoeui.woff2') format('woff2'),
url('/assets/fonts/segoeui.ttf') format('truetype');
font-weight: 400 500; font-weight: 400 500;
font-style: normal; font-style: normal;
font-display: swap; font-display: swap;
} }
@font-face { @font-face {
font-family: 'Segoeui'; font-family: 'Segoeui';
src: url('/assets/fonts/SegoeUIBold.ttf') format('truetype');
src: url('/assets/fonts/SegoeUIBold.woff2') format('woff2'),
url('/assets/fonts/SegoeUIBold.ttf') format('truetype');
font-weight: 600 900; font-weight: 600 900;
font-style: normal; font-style: normal;
font-display: swap; font-display: swap;

20
src/app/questions-list/[slug]/question-detail-client.tsx

@ -349,8 +349,13 @@ export default function QuestionDetailClient({
} catch {} } catch {}
return false; return false;
}, [isAssessment, overview, itemSlug, profileId]); }, [isAssessment, overview, itemSlug, profileId]);
const { data: sectionResponse, isLoading: isSectionLoading, isError: isSectionError, refetch: refetchSection } =
useFormSectionQuery(
const {
data: sectionResponse,
isLoading: isSectionLoading,
isPending: isSectionPending,
isError: isSectionError,
refetch: refetchSection,
} = useFormSectionQuery(
"profile", "profile",
itemSlug, itemSlug,
locale, locale,
@ -368,14 +373,17 @@ export default function QuestionDetailClient({
sectionResponse.section_progress.completion_percent, sectionResponse.section_progress.completion_percent,
); );
} }
if (isAssessment) {
return overviewItem; return overviewItem;
}
return null;
}, [isAssessment, overviewItem, sectionResponse]); }, [isAssessment, overviewItem, sectionResponse]);
const isSchemaError = isAssessment const isSchemaError = isAssessment
? isOverviewError ? isOverviewError
: (isOverviewError || isSectionError) && !sectionResponse; : (isOverviewError || isSectionError) && !sectionResponse;
const isSchemaLoading = isAssessment const isSchemaLoading = isAssessment
? isOverviewLoading ? isOverviewLoading
: !sectionResponse && (isOverviewLoading || isSectionLoading);
: !sectionResponse && !isSchemaError && (isOverviewLoading || isSectionLoading || isSectionPending);
const cattellQuery = useCattellQuestionsQuery(locale, { const cattellQuery = useCattellQuestionsQuery(locale, {
enabled: isCattellSlug && isTestStarted, enabled: isCattellSlug && isTestStarted,
@ -395,13 +403,13 @@ export default function QuestionDetailClient({
void queryClient.prefetchQuery({ void queryClient.prefetchQuery({
queryKey: marriageQueryKeys.cattellQuestions(locale), queryKey: marriageQueryKeys.cattellQuestions(locale),
queryFn: () => getCattellQuestions(locale), queryFn: () => getCattellQuestions(locale),
staleTime: 30 * 1000,
staleTime: 3 * 60 * 1000,
}); });
} else if (isGlasserSlug) { } else if (isGlasserSlug) {
void queryClient.prefetchQuery({ void queryClient.prefetchQuery({
queryKey: marriageQueryKeys.glasserQuestions(locale), queryKey: marriageQueryKeys.glasserQuestions(locale),
queryFn: () => getGlasserQuestions(locale), queryFn: () => getGlasserQuestions(locale),
staleTime: 30 * 1000,
staleTime: 3 * 60 * 1000,
}); });
} }
}, [ }, [
@ -534,7 +542,7 @@ export default function QuestionDetailClient({
<div className="flex min-h-0 flex-1 items-center justify-center pb-[76px]"> <div className="flex min-h-0 flex-1 items-center justify-center pb-[76px]">
<span <span
role="status" role="status"
className="size-5 animate-spin rounded-full border-2 border-[#E03950]/25 border-t-[#E03950] motion-reduce:animate-none"
className="size-8 animate-spin rounded-full border-[2.5px] border-[#E03950]/25 border-t-[#E03950] motion-reduce:animate-none"
/> />
</div> </div>

11
src/app/questions-list/questions-list-client.tsx

@ -543,13 +543,13 @@ export default function QuestionsListClient({
void queryClient.prefetchQuery({ void queryClient.prefetchQuery({
queryKey: marriageQueryKeys.cattellQuestions(locale), queryKey: marriageQueryKeys.cattellQuestions(locale),
queryFn: () => getCattellQuestions(locale), queryFn: () => getCattellQuestions(locale),
staleTime: 30 * 1000,
staleTime: 3 * 60 * 1000,
}); });
} else { } else {
void queryClient.prefetchQuery({ void queryClient.prefetchQuery({
queryKey: marriageQueryKeys.glasserQuestions(locale), queryKey: marriageQueryKeys.glasserQuestions(locale),
queryFn: () => getGlasserQuestions(locale), queryFn: () => getGlasserQuestions(locale),
staleTime: 30 * 1000,
staleTime: 3 * 60 * 1000,
}); });
} }
return; return;
@ -558,7 +558,7 @@ export default function QuestionsListClient({
void queryClient.prefetchQuery({ void queryClient.prefetchQuery({
queryKey: marriageQueryKeys.formSection("profile", item.slug, locale), queryKey: marriageQueryKeys.formSection("profile", item.slug, locale),
queryFn: () => getFormSection("profile", item.slug, locale), queryFn: () => getFormSection("profile", item.slug, locale),
staleTime: 30 * 1000,
staleTime: 3 * 60 * 1000,
}); });
}, },
[locale, queryClient, router], [locale, queryClient, router],
@ -608,7 +608,7 @@ export default function QuestionsListClient({
void queryClient.prefetchQuery({ void queryClient.prefetchQuery({
queryKey: marriageQueryKeys.formSection("profile", profileSections[0].slug, locale), queryKey: marriageQueryKeys.formSection("profile", profileSections[0].slug, locale),
queryFn: () => getFormSection("profile", profileSections[0].slug, locale), queryFn: () => getFormSection("profile", profileSections[0].slug, locale),
staleTime: 30 * 1000,
staleTime: 3 * 60 * 1000,
}); });
} }
@ -626,7 +626,7 @@ export default function QuestionsListClient({
queryClient.fetchQuery({ queryClient.fetchQuery({
queryKey: marriageQueryKeys.formSection("profile", item.slug, locale), queryKey: marriageQueryKeys.formSection("profile", item.slug, locale),
queryFn: () => getFormSection("profile", item.slug, locale), queryFn: () => getFormSection("profile", item.slug, locale),
staleTime: 30 * 1000,
staleTime: 3 * 60 * 1000,
}), }),
() => cancelled, () => cancelled,
); );
@ -1007,6 +1007,7 @@ export default function QuestionsListClient({
> >
{activeSectionItem ? ( {activeSectionItem ? (
<QuestionDetailClient <QuestionDetailClient
key={activeSectionItem.slug}
closeLabel={t["Close questions list"] ?? "Close"} closeLabel={t["Close questions list"] ?? "Close"}
continueLabel={t["Continue"] ?? "Continue"} continueLabel={t["Continue"] ?? "Continue"}
description={activeSectionItem.summary} description={activeSectionItem.summary}

18
src/components/Componentes/question-sheet.test.tsx

@ -1169,18 +1169,18 @@ describe("QuestionSheet component", () => {
const shortBtn = screen.getByRole("button", { name: "موافق" }); const shortBtn = screen.getByRole("button", { name: "موافق" });
const longBtn = screen.getByRole("button", { name: longText }); const longBtn = screen.getByRole("button", { name: longText });
// Short button has baseline min-height: 52px
expect(shortBtn.style.minHeight).toBe("52px");
// Long button dynamically scales based on character count (>= 114px)
const expectedMinHeight = `${calculateOptionMinHeight(longText)}px`;
expect(longBtn.style.minHeight).toBe(expectedMinHeight);
expect(calculateOptionMinHeight(longText)).toBeGreaterThanOrEqual(114);
// Card container has h-auto and shrink-0 to prevent flex clamping
// Short button has baseline min-height: min-h-[52px]
expect(shortBtn).toHaveClass("min-h-[52px]");
expect(shortBtn).toHaveClass("h-auto");
expect(shortBtn).toHaveClass("shrink-0");
expect(shortBtn).toHaveClass("box-border");
// Long button uses natural content-driven sizing (h-auto, shrink-0, min-h-[52px]) without forced inflated inline minHeight
expect(longBtn).toHaveClass("min-h-[52px]");
expect(longBtn).toHaveClass("h-auto"); expect(longBtn).toHaveClass("h-auto");
expect(longBtn).toHaveClass("shrink-0"); expect(longBtn).toHaveClass("shrink-0");
expect(longBtn).toHaveClass("box-border"); expect(longBtn).toHaveClass("box-border");
expect(longBtn.style.minHeight).toBe("");
}); });
}); });
}); });

7
src/components/Componentes/question-sheet.tsx

@ -987,8 +987,6 @@ export function QuestionSheet({ question, disabled }: QuestionSheetProps) {
!isSelected && !isSelected &&
Boolean(maxSelect && localSelectedList.length >= maxSelect); Boolean(maxSelect && localSelectedList.length >= maxSelect);
const minHeightPx = calculateOptionMinHeight(option.label);
return ( return (
<button <button
key={option.id} key={option.id}
@ -1003,11 +1001,8 @@ export function QuestionSheet({ question, disabled }: QuestionSheetProps) {
handleSelectSingle(option.id); handleSelectSingle(option.id);
} }
}} }}
style={{
minHeight: `${minHeightPx}px`,
}}
className={[ className={[
"flex h-auto w-full shrink-0 items-start gap-3 rounded-[12px] border px-3.5 py-3 text-start transition-colors select-none box-border",
"flex min-h-[52px] h-auto w-full shrink-0 items-start gap-3 rounded-[12px] border px-3.5 py-3 text-start transition-colors select-none box-border",
isOptionDisabled isOptionDisabled
? "opacity-35 cursor-not-allowed bg-[#F9FAFB] border-[#F2F4F7] text-[#98A2B3]" ? "opacity-35 cursor-not-allowed bg-[#F9FAFB] border-[#F2F4F7] text-[#98A2B3]"
: isSelected : isSelected

8
src/hooks/marriage/use-form-schema.ts

@ -110,7 +110,7 @@ export function useFormOverviewQuery(formId: string, locale: string) {
return useQuery({ return useQuery({
queryKey: marriageQueryKeys.formOverview(formId, locale), queryKey: marriageQueryKeys.formOverview(formId, locale),
queryFn: () => getFormOverview(formId, locale), queryFn: () => getFormOverview(formId, locale),
staleTime: 30 * 1000,
staleTime: 3 * 60 * 1000,
refetchOnMount: "always", refetchOnMount: "always",
refetchOnWindowFocus: false, refetchOnWindowFocus: false,
}); });
@ -137,8 +137,8 @@ export function useFormSectionQuery(
queryKey: marriageQueryKeys.formSection(formId, slug, locale), queryKey: marriageQueryKeys.formSection(formId, slug, locale),
queryFn: () => getFormSection(formId, slug, locale), queryFn: () => getFormSection(formId, slug, locale),
enabled, enabled,
staleTime: 30 * 1000,
refetchOnMount: false,
staleTime: 3 * 60 * 1000,
refetchOnMount: true,
refetchOnWindowFocus: false, refetchOnWindowFocus: false,
}); });
} }
@ -159,7 +159,7 @@ export function useFormSchemaQuery<TData = FormSchemaResponse>(
options?: QueryOptions<FormSchemaResponse, TData>, options?: QueryOptions<FormSchemaResponse, TData>,
) { ) {
return useQuery({ return useQuery({
staleTime: 30 * 1000,
staleTime: 3 * 60 * 1000,
refetchOnMount: false, refetchOnMount: false,
refetchOnWindowFocus: false, refetchOnWindowFocus: false,
...options, ...options,

Loading…
Cancel
Save