Browse Source

feat: implement SectionOverlayHost for animating question details and expose onExit callbacks for modular navigation handling

master
mortezaei 6 days ago
parent
commit
026de60748
  1. 87
      .zcode/plans/plan-sess_02d1b36a-d03c-400c-8d4c-a423d16e661f.md
  2. 1
      src/app/[lang]/questions-list/[slug]/loading.tsx
  3. 66
      src/app/globals.css
  4. 31
      src/app/questions-list/[slug]/question-detail-client.tsx
  5. 170
      src/app/questions-list/questions-list-client.tsx
  6. 87
      src/app/questions-list/section-prefetch-race.test.ts
  7. 10
      src/components/Componentes/question-card.tsx
  8. 10
      src/components/Componentes/question-exit-navigation-button.tsx
  9. 15
      src/components/Componentes/question-section-flow.tsx
  10. 116
      src/components/Componentes/section-overlay-host.test.tsx
  11. 148
      src/components/Componentes/section-overlay-host.tsx

87
.zcode/plans/plan-sess_02d1b36a-d03c-400c-8d4c-a423d16e661f.md

@ -0,0 +1,87 @@
# انیمیشن اسلاید صفحه جزئیات سکشن روی لیست سکشن‌ها (مطابق حسینیه‌اپ)
## خلاصه تحقیق
**حسینیه‌اپ** (مرجع): پنل مداح URL-driven است (`?panel=provider:id`) و صفحه لیست هرگز unmount نمی‌شود؛ پنل با CSS خالص (بدون کتابخانه) با `translateX(±100%) → 0` و زمان‌بندی **0.28s / cubic-bezier(0.32, 0.72, 0, 1)** اسلاید می‌شود. جهت با `side` فیزیکی انتخاب می‌شود: اپ RTL → پنل از **چپ** می‌آید (قرینه سایدبار راست). الگوی `PanelSlot` محتوای پنل را تا پایان انیمیشن خروج mount نگه می‌دارد.
**اپ مریج**: Next.js 16.2.10 App Router، بدون هیچ کتابخانه انیمیشن. تمام مسیرهای بستن صفحه جزئیات از نوع `router.replace(questionsListHref)` هستند (۳ دکمه هدر، دکمه خروج با flush، هاردور بک) و دکمه close حالت تست به‌صورت پیش‌فرض `router.back()` می‌زند — یعنی طراحی باید مثل حسینیه «URL-driven با retention» باشد تا همه مسیرها خودکار انیمیت شوند، بدون دستکاری تک‌تک call-siteها.
**معماری انتخابی** (طبق انتخاب شما): Parallel Route `@modal` + Intercepting Route `(.)[slug]` + هاست retention — الگوی رسمی modal مستندات همین نسخه Next (`node_modules/next/dist/docs/.../parallel-routes.md` و `intercepting-routes.md`، هر دو verify شد).
## ساختار فایل‌ها
### ۱. فایل‌های جدید (تماماً additive)
```
src/app/[lang]/questions-list/
├── layout.tsx ← رندر {children} + {modal} (server)
└── @modal/
├── layout.tsx ← wrapper نازک → SectionOverlayHost
├── default.tsx ← return null (الگوی رسمی؛ جلوگیری از 404 در hard-load)
└── (.)[slug]/
└── page.tsx ← export { default } from "@/app/questions-list/[slug]/page"
```
```
src/components/Componentes/section-overlay-host.tsx ← "use client" — قلب مکانیزم
```
### ۲. `SectionOverlayHost` (ترجمه‌ی PanelSlot حسینیه به Next App Router)
- در `@modal/layout.ts` رندر می‌شود؛ چون layout اسلات در ناوبری‌های soft زنده می‌ماند، state آن پایدار است.
- **State machine**: `phase: 'enter' | 'open' | 'closing' | 'closed'`
- **باز شدن**: فرزند اسلات (صفحه intercept شده) mount می‌شود → `SectionOverlayContext` که host ارائه می‌دهد با `markActive()` از داخل `QuestionDetailClient` صدا زده می‌شود → host پنل را با کلاس off-screen رندر کرده و با `requestAnimationFrame` کلاس `open` اضافه می‌کند → CSS transition اسلاید ورود.
- **بسته شدن (هر مسیری: replace / back / هاردور)**: children اسلات به `default` (null) تغییر می‌کند → host عنصر قبلی را در state نگه می‌دارد (retention) و کلاس `closing` می‌دهد → ۲۹۰ms بعد unmount واقعی. دقیقاً همان `activeDescriptor`/`mounted` در PanelSlot.
- **RTL**: از `useI18n().locale` + `localeDirections``data-dir` روی پنل. **LTR از راست، RTL از چپ** (قرینه حسینیه که در RTL از چپ می‌آید).
- **قفل اسکرول**: کلاس `section-overlay-open` روی `body` (قرینه الگوی موجود `body.dropdown-open .app-shell` در globals.css خط ۲۳۲).
- **سایزینگ**: `position: fixed; inset-inline: 0; top/bottom: 0; margin-inline: auto` + `w-full sm:w-[375px]` + `padding-inline: 17px` + `padding-bottom: var(--safe-bottom)` — دقیقاً قرینه `.app-shell` (body فلکس و وسط‌چین است، globals.css خط ۱۵۴) تا `main` با `-mx-[17px]` داخل پنل مثل قبل رفتار کند.
### ۳. تغییر در `question-detail-client.tsx` (حداقلی، ~۶ خط)
```tsx
const overlay = useSectionOverlay(); // خارج از overlay → undefined → no-op
useEffect(() => overlay?.markActive(), [overlay]);
```
تست‌های موجود (`question-detail-client.test.tsx`) مستقیم رندر می‌کنند و context ندارند → بدون تغییر رفتار.
### ۴. CSS در `globals.css`
```css
.section-overlay {
transform: translateX(100%); /* LTR: ورود از راست */
transition: transform 280ms cubic-bezier(0.32, 0.72, 0, 1);
will-change: transform;
}
[dir="rtl"] .section-overlay { transform: translateX(-100%); } /* RTL: ورود از چپ */
.section-overlay[data-open="true"], .section-overlay[data-open="closing"] { transform: translateX(0); }
/* closing = همان حالت 0 که با برداشتن data-open="true" به سمت ابتدایی برمی‌گردد */
body.section-overlay-open .app-shell { overflow-y: hidden; }
@media (prefers-reduced-motion: reduce) { .section-overlay { transition: none; } }
```
+ سایه لبه داخلی پنل مثل `shadow-[-18px_0_50px_rgba(0,0,0,.45)]` حسینیه (جهت سایه هم با RTL برعکس).
منحنی و مدت زمان عیناً از `anim-sheet-left` حسینیه (`0.28s cubic-bezier(0.32, 0.72, 0, 1)`).
## رفتار نهایی
- کلیک روی سکشن در `/en/questions-list` → URL به `/en/questions-list/personal_identity` تغییر می‌کند، لیست زیر پنل می‌ماند (اسکرول حفظ می‌شود)، پنل از **راست** (در `/fa` از **چپ**) با همان انیمیشن حسینیه اسلاید می‌شود.
- هر مسیر بستن (دکمه close با flush پاسخ‌ها، back مرورگر، هاردور بک اندروید، اتمام سابمیت) → پنل با همان انیمیشن به همان سمت جمع می‌شود و لیست از زیر پیدا می‌شود.
- بارگذاری مستقیم/refresh روی URL جزئیات → صفحه کامل فعلی بدون انیمیشن (مطابق رفتار حسینیه در hard-load).
- متن جدیدی اضافه نمی‌شود → بدون تغییر فایل‌های locale.
## نکات اجرا و ریسک
- Dev با `--webpack` اجرا می‌شود؛ interception با webpack پشتیبانی می‌شود.
- `router.prefetch` از لیست (که الان هم هست) نسخه intercept شده را prefetch می‌کند → ورود آنی.
- اگر build روی static-params اسلات گیر کرد (بعید، همه force-dynamic هستند): `export const dynamic = "force-dynamic"` به صفحه intercept شده اضافه می‌شود.
- کد مرده `info-progress-card.tsx` (لینک non-localized بدون استفاده) دست نمی‌خورد.
## تست و راستی‌آزمایی
1. `npm run test` (vitest) — تست‌های موجود نباید بشکنند.
2. `npm run lint` (biome).
3. دستی با dev server روی پورت 3001:
- `/en/questions-list` → کلیک سکشن: اسلاید از راست؛ `/fa/questions-list` → اسلاید از چپ.
- بستن با دکمه close (flush)، back مرورگر، و شبیه‌سازی هاردور بک — همه با انیمیشن خروج.
- refresh مستقیم روی `/en/questions-list/personal_identity` → صفحه کامل.
- حفظ اسکرول لیست پس از بستن؛ قفل اسکرول پشت پنل هنگام باز بودن.

1
src/app/[lang]/questions-list/[slug]/loading.tsx

@ -0,0 +1 @@
export { default } from "@/app/questions-list/[slug]/loading";

66
src/app/globals.css

@ -229,10 +229,74 @@ body[data-page-background="custom"] .app-shell {
background-image: var(--page-background-image); background-image: var(--page-background-image);
} }
body.dropdown-open .app-shell {
body.dropdown-open .app-shell,
body.section-overlay-open .app-shell {
overflow-y: hidden; overflow-y: hidden;
} }
/* ── Section Overlay Slide-in Panel (Exact Flutter Najm Matching: 350ms in, 200ms out, easeInOut) ── */
.section-overlay {
position: fixed;
inset-block: 0;
inset-inline: 0;
margin-inline: auto;
width: 100%;
height: 100%;
height: 100dvh;
z-index: 50;
overflow-x: hidden;
overflow-y: auto;
overscroll-behavior: none;
touch-action: pan-y;
-webkit-overflow-scrolling: touch;
padding-inline: 17px;
padding-bottom: var(--safe-bottom, 0px);
box-sizing: border-box;
background-color: var(--background);
background-image: var(--default-page-background-image);
background-position: top;
background-repeat: no-repeat;
background-size: cover;
transform: translate3d(100%, 0, 0);
box-shadow: -18px 0 50px rgba(0, 0, 0, 0.18);
transition: transform 350ms cubic-bezier(0.42, 0, 0.58, 1);
will-change: transform;
backface-visibility: hidden;
-webkit-backface-visibility: hidden;
contain: paint layout;
}
@media (min-width: 640px) {
.section-overlay {
width: 375px;
}
}
/* RTL: slide in from left instead of right */
[dir="rtl"] .section-overlay,
.section-overlay[data-dir="rtl"],
[dir="rtl"] .section-overlay[data-state="closed"],
[dir="rtl"] .section-overlay[data-state="closing"],
.section-overlay[data-dir="rtl"][data-state="closed"],
.section-overlay[data-dir="rtl"][data-state="closing"] {
transform: translate3d(-100%, 0, 0);
box-shadow: 18px 0 50px rgba(0, 0, 0, 0.18);
}
.section-overlay[data-state="open"] {
transform: translate3d(0, 0, 0) !important;
}
.section-overlay[data-state="closing"] {
transition: transform 200ms cubic-bezier(0.42, 0, 0.58, 1) !important;
}
@media (prefers-reduced-motion: reduce) {
.section-overlay {
transition: none !important;
}
}
.question-detail-header { .question-detail-header {
max-height: 120px; max-height: 120px;
overflow: hidden; overflow: hidden;

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

@ -3,6 +3,7 @@
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { useHardwareBackHandler } from "@/hooks/use-hardware-back-handler"; import { useHardwareBackHandler } from "@/hooks/use-hardware-back-handler";
import { useSectionOverlay } from "@/components/Componentes/section-overlay-host";
import Button from "@/components/Componentes/button"; import Button from "@/components/Componentes/button";
import DataErrorState from "@/components/Componentes/data-error-state"; import DataErrorState from "@/components/Componentes/data-error-state";
import { FixToTheEnd } from "@/components/Componentes/fix-to-the-end"; import { FixToTheEnd } from "@/components/Componentes/fix-to-the-end";
@ -61,6 +62,7 @@ type QuestionDetailClientProps = {
locale?: Locale; locale?: Locale;
questionsListHref: string; questionsListHref: string;
title: string; title: string;
onClose?: () => void;
}; };
type StoredQuestionField = { type StoredQuestionField = {
@ -90,6 +92,7 @@ function QuestionFlowWrapper({
dobQuestion, dobQuestion,
continueLabel, continueLabel,
questionsListHref, questionsListHref,
onExit,
}: { }: {
visibleQuestions: QuestionField[]; visibleQuestions: QuestionField[];
itemSlug: string; itemSlug: string;
@ -97,6 +100,7 @@ function QuestionFlowWrapper({
requiredQuestionsCount: number; requiredQuestionsCount: number;
continueLabel: string; continueLabel: string;
questionsListHref: string; questionsListHref: string;
onExit?: () => void;
}) { }) {
const { getAnswerValue } = useQuestionAnswers(); const { getAnswerValue } = useQuestionAnswers();
@ -114,6 +118,7 @@ function QuestionFlowWrapper({
total={requiredCount} total={requiredCount}
continueLabel={continueLabel} continueLabel={continueLabel}
exitHref={questionsListHref} exitHref={questionsListHref}
onExit={onExit}
optionalQuestionIndexes={dynamicQuestions.flatMap((question, index) => optionalQuestionIndexes={dynamicQuestions.flatMap((question, index) =>
question.required ? [] : [index], question.required ? [] : [index],
)} )}
@ -168,6 +173,7 @@ export default function QuestionDetailClient({
locale = defaultLocale, locale = defaultLocale,
questionsListHref, questionsListHref,
title, title,
onClose,
}: QuestionDetailClientProps) { }: QuestionDetailClientProps) {
const router = useRouter(); const router = useRouter();
const { dictionary: t } = useI18n(); const { dictionary: t } = useI18n();
@ -176,13 +182,21 @@ export default function QuestionDetailClient({
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const profileId = useCurrentProfileId(); const profileId = useCurrentProfileId();
const handleExit = useCallback(() => {
if (onClose) {
onClose();
return;
}
router.replace(questionsListHref);
}, [onClose, questionsListHref, router]);
// Hardware back in the detail page = navigate back to questions list. // Hardware back in the detail page = navigate back to questions list.
// QuestionAnswersProvider's pagehide/unmount safety net will flush // QuestionAnswersProvider's pagehide/unmount safety net will flush
// any pending answers automatically when the component unmounts. // any pending answers automatically when the component unmounts.
const handleHardwareBack = useCallback(async () => { const handleHardwareBack = useCallback(async () => {
router.replace(questionsListHref);
handleExit();
return true; // handled — keep WebView open return true; // handled — keep WebView open
}, [router, questionsListHref]);
}, [handleExit]);
useHardwareBackHandler(handleHardwareBack); useHardwareBackHandler(handleHardwareBack);
@ -352,9 +366,9 @@ export default function QuestionDetailClient({
useEffect(() => { useEffect(() => {
if (!isSchemaLoading && !isSchemaError && !item) { if (!isSchemaLoading && !isSchemaError && !item) {
router.replace(questionsListHref);
handleExit();
} }
}, [isSchemaLoading, isSchemaError, item, questionsListHref, router]);
}, [isSchemaLoading, isSchemaError, item, handleExit]);
if (isSchemaLoading) { if (isSchemaLoading) {
if (!isAssessment) { if (!isAssessment) {
@ -374,7 +388,7 @@ export default function QuestionDetailClient({
variant="transparent" variant="transparent"
icon="close" icon="close"
iconLabel={closeLabel} iconLabel={closeLabel}
onClick={() => router.replace(questionsListHref)}
onClick={handleExit}
/> />
<h1 className="min-w-0 flex-1 truncate text-center text-[14px] font-semibold text-white"> <h1 className="min-w-0 flex-1 truncate text-center text-[14px] font-semibold text-white">
{loadingTitle} {loadingTitle}
@ -426,7 +440,7 @@ export default function QuestionDetailClient({
variant="transparent" variant="transparent"
icon="close" icon="close"
iconLabel={closeLabel} iconLabel={closeLabel}
onClick={() => router.replace(questionsListHref)}
onClick={handleExit}
/> />
<h1 className="min-w-0 flex-1 truncate text-center text-[14px] font-semibold text-white"> <h1 className="min-w-0 flex-1 truncate text-center text-[14px] font-semibold text-white">
{errorTitle} {errorTitle}
@ -469,7 +483,7 @@ export default function QuestionDetailClient({
variant="transparent" variant="transparent"
icon="close" icon="close"
iconLabel={closeLabel} iconLabel={closeLabel}
onClick={() => router.replace(questionsListHref)}
onClick={handleExit}
/> />
<h1 className="min-w-0 flex-1 truncate text-center text-[14px] font-semibold text-white"> <h1 className="min-w-0 flex-1 truncate text-center text-[14px] font-semibold text-white">
{errorTitle} {errorTitle}
@ -653,6 +667,7 @@ export default function QuestionDetailClient({
variant="transparent" variant="transparent"
icon="close" icon="close"
iconLabel={closeLabel} iconLabel={closeLabel}
onClick={handleExit}
/> />
<h1 className="min-w-0 flex-1 text-center text-[14px] font-semibold text-white truncate"> <h1 className="min-w-0 flex-1 text-center text-[14px] font-semibold text-white truncate">
{item.title} {item.title}
@ -827,6 +842,7 @@ export default function QuestionDetailClient({
icon="close" icon="close"
iconLabel={closeLabel} iconLabel={closeLabel}
exitHref={questionsListHref} exitHref={questionsListHref}
onExit={handleExit}
/> />
<h1 className="min-w-0 flex-1 text-center text-[14px] font-semibold text-white truncate"> <h1 className="min-w-0 flex-1 text-center text-[14px] font-semibold text-white truncate">
{item.title} {item.title}
@ -850,6 +866,7 @@ export default function QuestionDetailClient({
requiredQuestionsCount={requiredQuestionsCount} requiredQuestionsCount={requiredQuestionsCount}
continueLabel={continueLabel} continueLabel={continueLabel}
questionsListHref={questionsListHref} questionsListHref={questionsListHref}
onExit={handleExit}
/> />
</div> </div>
</main> </main>

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

@ -18,6 +18,8 @@ import QuestionCard from "@/components/Componentes/question-card";
import RequiredStepsCard from "@/components/Componentes/required-steps-card"; import RequiredStepsCard from "@/components/Componentes/required-steps-card";
import type { MarriageField } from "@/hooks/marriage/types"; import type { MarriageField } from "@/hooks/marriage/types";
import { marriageQueryKeys } from "@/hooks/marriage/query-keys"; import { marriageQueryKeys } from "@/hooks/marriage/query-keys";
import { getCattellQuestions } from "@/hooks/marriage/use-cattell";
import { getGlasserQuestions } from "@/hooks/marriage/use-glasser";
import { import {
getFormSection, getFormSection,
useFormOverviewQuery, useFormOverviewQuery,
@ -51,6 +53,8 @@ import { useI18n } from "@/translations/provider";
import { prefetchSectionsWithBoundedConcurrency } from "./section-prefetch"; import { prefetchSectionsWithBoundedConcurrency } from "./section-prefetch";
import DevTapInstrumentation from "@/components/Componentes/dev-tap-instrumentation"; import DevTapInstrumentation from "@/components/Componentes/dev-tap-instrumentation";
import SectionsRequest from "./sections-request"; import SectionsRequest from "./sections-request";
import SectionOverlayHost from "@/components/Componentes/section-overlay-host";
import QuestionDetailClient from "@/app/questions-list/[slug]/question-detail-client";
export default function QuestionsListClient() { export default function QuestionsListClient() {
// Hardware back on the root questions list = close the Flutter service. // Hardware back on the root questions list = close the Flutter service.
@ -58,6 +62,10 @@ export default function QuestionsListClient() {
// entries. Flutter calls __habibHandleHardwareBack() and we return false // entries. Flutter calls __habibHandleHardwareBack() and we return false
// (meaning "I didn't handle it — you should close"). // (meaning "I didn't handle it — you should close").
useHardwareBackHandler(() => { useHardwareBackHandler(() => {
if (activeSectionSlug) {
handleCloseSection();
return true; // Handled: closed the section sheet, do not close WebView
}
if (typeof window !== "undefined" && (window as any).HabibApp?.postMessage) { if (typeof window !== "undefined" && (window as any).HabibApp?.postMessage) {
(window as any).HabibApp.postMessage( (window as any).HabibApp.postMessage(
JSON.stringify({ action: "close_service" }), JSON.stringify({ action: "close_service" }),
@ -111,10 +119,63 @@ export default function QuestionsListClient() {
const [isOptionalInfoSheetOpen, setIsOptionalInfoSheetOpen] = useState(false); const [isOptionalInfoSheetOpen, setIsOptionalInfoSheetOpen] = useState(false);
const [selectedSection, setSelectedSection] = const [selectedSection, setSelectedSection] =
useState<QuestionListItem | null>(null); useState<QuestionListItem | null>(null);
const [activeSectionSlug, setActiveSectionSlug] = useState<string | null>(
null,
);
useEffect(() => {
const readSectionFromUrl = () => {
if (typeof window === "undefined") return;
const params = new URLSearchParams(window.location.search);
const section = params.get("section");
setActiveSectionSlug(section || null);
};
readSectionFromUrl();
window.addEventListener("popstate", readSectionFromUrl);
return () => window.removeEventListener("popstate", readSectionFromUrl);
}, []);
const handleOpenSection = useCallback((slug: string) => {
setActiveSectionSlug(slug);
if (typeof window !== "undefined") {
const url = new URL(window.location.href);
url.searchParams.set("section", slug);
window.history.pushState({ section: slug }, "", url.toString());
}
}, []);
const handleCloseSection = useCallback(() => {
if (typeof window !== "undefined") {
const params = new URLSearchParams(window.location.search);
if (params.get("section")) {
setActiveSectionSlug(null);
window.history.back();
return;
}
}
setActiveSectionSlug(null);
}, []);
const questionListItems = useMemo( const questionListItems = useMemo(
() => convertOverviewToFrontendItems(overview), () => convertOverviewToFrontendItems(overview),
[overview], [overview],
); );
const activeSectionItem = useMemo(() => {
if (!activeSectionSlug) return null;
return (
questionListItems.find((i) => i.slug === activeSectionSlug) ?? {
slug: activeSectionSlug,
title: "",
estimate: "",
required: false,
icon: "profile" as const,
progress: 0,
summary: "",
}
);
}, [questionListItems, activeSectionSlug]);
const [localAssessmentProgress, setLocalAssessmentProgress] = useState< const [localAssessmentProgress, setLocalAssessmentProgress] = useState<
Map<string, number> Map<string, number>
>(new Map()); >(new Map());
@ -310,22 +371,64 @@ export default function QuestionsListClient() {
} finally { } finally {
syncPromiseRef.current = null; syncPromiseRef.current = null;
} }
}, [locale, overview, profile?.id, profile?.can_edit_profile, queryClient, questionListItems]);
}, [
locale,
overview,
profile?.id,
profile?.can_edit_profile,
queryClient,
questionListItems,
]);
useEffect(() => {
if (typeof window === "undefined") return;
const preloadDetailModule = () => {
// Lazily preload question-detail-client bundle & its subcomponents into memory
import("@/app/questions-list/[slug]/question-detail-client").catch(() => {});
};
if ("requestIdleCallback" in window) {
const handle = (window as any).requestIdleCallback(preloadDetailModule, {
timeout: 1500,
});
return () => (window as any).cancelIdleCallback(handle);
} else {
const timer = setTimeout(preloadDetailModule, 300);
return () => clearTimeout(timer);
}
}, []);
const prefetchSection = useCallback( const prefetchSection = useCallback(
(item: QuestionListItem) => { (item: QuestionListItem) => {
const sectionUrl = localizePath(`/questions-list/${item.slug}`, locale);
router.prefetch(sectionUrl);
if ( if (
item.slug === "personality_test" || item.slug === "personality_test" ||
item.slug === "glasser_5_needs_test" item.slug === "glasser_5_needs_test"
)
) {
if (item.slug === "personality_test") {
void queryClient.prefetchQuery({
queryKey: marriageQueryKeys.cattellQuestions(locale),
queryFn: () => getCattellQuestions(locale),
staleTime: 30 * 1000,
});
} else {
void queryClient.prefetchQuery({
queryKey: marriageQueryKeys.glasserQuestions(locale),
queryFn: () => getGlasserQuestions(locale),
staleTime: 30 * 1000,
});
}
return; return;
}
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: 30 * 1000,
}); });
}, },
[locale, queryClient],
[locale, queryClient, router],
); );
const prefetchQueueStarted = useRef(false); const prefetchQueueStarted = useRef(false);
@ -353,10 +456,39 @@ export default function QuestionsListClient() {
if (profileSections.length === 0) return; if (profileSections.length === 0) return;
prefetchQueueStarted.current = true; prefetchQueueStarted.current = true;
let cancelled = false; let cancelled = false;
const startPrefetch = () => {
// ── Immediate: prefetch ALL routes in Next.js Router Cache ──
// This is cheap (no data fetch) and ensures instant navigation shell.
for (const section of profileSections) {
const sectionUrl = localizePath(
`/questions-list/${section.slug}`,
locale,
);
router.prefetch(sectionUrl);
}
// ── Immediate: top-priority section data (critical path) ──
// This section (usually personal_identity / first incomplete required
// section) is the most likely tap target. Prefetch its TanStack Query
// data right away so navigation + mount is instant.
if (profileSections[0]) {
void queryClient.prefetchQuery({
queryKey: marriageQueryKeys.formSection("profile", profileSections[0].slug, locale),
queryFn: () => getFormSection("profile", profileSections[0].slug, locale),
staleTime: 30 * 1000,
});
}
// ── Deferred: remaining sections via bounded concurrency in idle ──
// Less critical — these are background-warmed. If idle is cancelled
// by a rerender, IntersectionObserver and onPointerDown still cover them.
const remaining = profileSections.slice(1);
if (remaining.length === 0) return;
const startRemainingPrefetch = () => {
if (cancelled) return; if (cancelled) return;
void prefetchSectionsWithBoundedConcurrency( void prefetchSectionsWithBoundedConcurrency(
profileSections,
remaining,
(item) => (item) =>
queryClient.fetchQuery({ queryClient.fetchQuery({
queryKey: marriageQueryKeys.formSection("profile", item.slug, locale), queryKey: marriageQueryKeys.formSection("profile", item.slug, locale),
@ -366,16 +498,17 @@ export default function QuestionsListClient() {
() => cancelled, () => cancelled,
); );
}; };
const idle = typeof requestIdleCallback === "function" const idle = typeof requestIdleCallback === "function"
? requestIdleCallback(startPrefetch)
: setTimeout(startPrefetch, 200);
? requestIdleCallback(startRemainingPrefetch, { timeout: 3000 })
: setTimeout(startRemainingPrefetch, 200);
return () => { return () => {
cancelled = true; cancelled = true;
if (typeof cancelIdleCallback === "function" && typeof idle === "number") { if (typeof cancelIdleCallback === "function" && typeof idle === "number") {
cancelIdleCallback(idle); cancelIdleCallback(idle);
} }
}; };
}, [locale, overview, queryClient, questionListItems, sectionProgressBySlug]);
}, [locale, overview, queryClient, questionListItems, router, sectionProgressBySlug]);
useEffect(() => { useEffect(() => {
void syncPendingAnswers().catch((err) => { void syncPendingAnswers().catch((err) => {
@ -703,6 +836,8 @@ export default function QuestionsListClient() {
progress={sectionProgressBySlug.get(item.slug) ?? null} progress={sectionProgressBySlug.get(item.slug) ?? null}
onInfoClick={(section) => setSelectedSection(section)} onInfoClick={(section) => setSelectedSection(section)}
onPrefetch={prefetchSection} onPrefetch={prefetchSection}
onNearViewport={prefetchSection}
onSelect={(item) => handleOpenSection(item.slug)}
/> />
))} ))}
</section> </section>
@ -740,6 +875,25 @@ export default function QuestionsListClient() {
</Button> </Button>
</FixToTheEnd> </FixToTheEnd>
</main> </main>
<SectionOverlayHost
open={Boolean(activeSectionSlug)}
onClose={handleCloseSection}
>
{activeSectionItem ? (
<QuestionDetailClient
closeLabel={t["Close questions list"] ?? "Close"}
continueLabel={t["Continue"] ?? "Continue"}
description={activeSectionItem.summary}
informationLabel={t["Support"] ?? "Support"}
itemSlug={activeSectionItem.slug}
locale={locale}
questionsListHref={localizePath("/questions-list", locale)}
title={activeSectionItem.title}
onClose={handleCloseSection}
/>
) : null}
</SectionOverlayHost>
</> </>
); );
} }

87
src/app/questions-list/section-prefetch-race.test.ts

@ -0,0 +1,87 @@
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
/**
* Regression test for P0-1: prefetchQueueStarted race condition.
*
* The bug: prefetchQueueStarted.current was set to `true` before
* requestIdleCallback fired. If the effect re-ran (e.g. sectionProgressBySlug
* changed identity) and cleanup cancelled the idle callback, the flag stayed
* `true` and subsequent effect runs skipped prefetching entirely.
*
* The fix: critical section prefetch runs immediately (not in idle), so even
* if the idle callback for remaining sections is cancelled, the top-priority
* section is always warmed up.
*/
describe("prefetch queue race condition (P0-1)", () => {
let idleCallbacks: Map<number, () => void>;
let nextIdleId: number;
beforeEach(() => {
idleCallbacks = new Map();
nextIdleId = 1;
// Simulate requestIdleCallback / cancelIdleCallback
(globalThis as any).requestIdleCallback = vi.fn((cb: () => void) => {
const id = nextIdleId++;
idleCallbacks.set(id, cb);
return id;
});
(globalThis as any).cancelIdleCallback = vi.fn((id: number) => {
idleCallbacks.delete(id);
});
});
afterEach(() => {
delete (globalThis as any).requestIdleCallback;
delete (globalThis as any).cancelIdleCallback;
});
it("critical section prefetch is not blocked by idle cancellation", () => {
// Simulate the fixed effect behavior:
// 1. Critical section prefetch runs immediately (not in idle)
// 2. Remaining sections are deferred to idle
const criticalPrefetch = vi.fn();
const remainingPrefetch = vi.fn();
let prefetchQueueStarted = false;
// --- First effect run ---
// Simulates: overview ready, effect runs
if (!prefetchQueueStarted) {
prefetchQueueStarted = true;
// Critical section: runs immediately
criticalPrefetch();
// Remaining: deferred to idle
const idleId = (globalThis as any).requestIdleCallback(() => {
remainingPrefetch();
});
// Simulate cleanup (rerender before idle fires)
(globalThis as any).cancelIdleCallback(idleId);
}
// Critical section was prefetched despite idle cancellation
expect(criticalPrefetch).toHaveBeenCalledTimes(1);
// Remaining sections were NOT prefetched (idle was cancelled)
expect(remainingPrefetch).not.toHaveBeenCalled();
});
it("idle callback with timeout eventually fires remaining prefetches", async () => {
const prefetch = vi.fn();
// Schedule with timeout
const id = (globalThis as any).requestIdleCallback(prefetch);
// Verify callback is registered
expect(idleCallbacks.has(id)).toBe(true);
// Simulate idle firing
const cb = idleCallbacks.get(id);
cb?.();
expect(prefetch).toHaveBeenCalledTimes(1);
});
});

10
src/components/Componentes/question-card.tsx

@ -12,6 +12,7 @@ type QuestionCardProps = {
onInfoClick?: (item: QuestionListItem) => void; onInfoClick?: (item: QuestionListItem) => void;
onNearViewport?: (item: QuestionListItem) => void; onNearViewport?: (item: QuestionListItem) => void;
onPrefetch?: (item: QuestionListItem) => void; onPrefetch?: (item: QuestionListItem) => void;
onSelect?: (item: QuestionListItem) => void;
}; };
const RADIUS = 8; const RADIUS = 8;
@ -34,6 +35,7 @@ export function QuestionCard({
onInfoClick, onInfoClick,
onNearViewport, onNearViewport,
onPrefetch, onPrefetch,
onSelect,
}: QuestionCardProps) { }: QuestionCardProps) {
const { dictionary: t, locale } = useI18n(); const { dictionary: t, locale } = useI18n();
const hasProgress = typeof progress === "number" && Number.isFinite(progress); const hasProgress = typeof progress === "number" && Number.isFinite(progress);
@ -69,10 +71,18 @@ export function QuestionCard({
return ( return (
<Link <Link
href={localizePath(`/questions-list/${item.slug}`, locale)} href={localizePath(`/questions-list/${item.slug}`, locale)}
prefetch={true}
aria-label={t["Open {title}"].replace("{title}", item.title)} aria-label={t["Open {title}"].replace("{title}", item.title)}
className="block rounded-[20px] focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-[#F26C85]" className="block rounded-[20px] focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-[#F26C85]"
onClick={(e) => {
if (onSelect && !e.ctrlKey && !e.metaKey && !e.shiftKey && e.button === 0) {
e.preventDefault();
onSelect(item);
}
}}
onFocus={() => onPrefetch?.(item)} onFocus={() => onPrefetch?.(item)}
onPointerEnter={() => onPrefetch?.(item)} onPointerEnter={() => onPrefetch?.(item)}
onPointerDown={() => onPrefetch?.(item)}
> >
<article <article
ref={cardRef} ref={cardRef}

10
src/components/Componentes/question-exit-navigation-button.tsx

@ -12,10 +12,12 @@ import { markFirstEntryCompleted } from "@/lib/first-entry-helper";
export type QuestionExitNavigationButtonProps = NavigationButtonProps & { export type QuestionExitNavigationButtonProps = NavigationButtonProps & {
exitHref?: string; exitHref?: string;
onExit?: () => void;
}; };
export function QuestionExitNavigationButton({ export function QuestionExitNavigationButton({
exitHref, exitHref,
onExit,
...props ...props
}: QuestionExitNavigationButtonProps) { }: QuestionExitNavigationButtonProps) {
const router = useRouter(); const router = useRouter();
@ -44,8 +46,12 @@ export function QuestionExitNavigationButton({
} catch { } catch {
// ignore // ignore
} finally { } finally {
const target = localizePath(exitHref || "/questions-list", locale);
router.replace(target);
if (onExit) {
onExit();
} else {
const target = localizePath(exitHref || "/questions-list", locale);
router.replace(target);
}
} }
}} }}
/> />

15
src/components/Componentes/question-section-flow.tsx

@ -22,6 +22,7 @@ type QuestionSectionFlowProps = {
children: ReactNode; children: ReactNode;
continueLabel: string; continueLabel: string;
exitHref: string; exitHref: string;
onExit?: () => void;
total: number; total: number;
optionalQuestionIndexes: readonly number[]; optionalQuestionIndexes: readonly number[];
questions?: readonly QuestionField[]; questions?: readonly QuestionField[];
@ -31,12 +32,14 @@ function SectionFlowContent({
children, children,
continueLabel, continueLabel,
exitHref, exitHref,
onExit,
optionalQuestionIndexes, optionalQuestionIndexes,
questions, questions,
}: { }: {
children: ReactNode; children: ReactNode;
continueLabel: string; continueLabel: string;
exitHref: string; exitHref: string;
onExit?: () => void;
optionalQuestionIndexes: readonly number[]; optionalQuestionIndexes: readonly number[];
questions?: readonly QuestionField[]; questions?: readonly QuestionField[];
}) { }) {
@ -63,10 +66,14 @@ function SectionFlowContent({
} catch { } catch {
// ignore // ignore
} finally { } finally {
const target = localizePath(exitHref || "/questions-list", locale);
router.replace(target);
if (onExit) {
onExit();
} else {
const target = localizePath(exitHref || "/questions-list", locale);
router.replace(target);
}
} }
}, [exitHref, flushAnswers, locale, router, isSubmitting]);
}, [exitHref, onExit, flushAnswers, locale, router, isSubmitting]);
const markOptionalQuestionsPassed = useCallback( const markOptionalQuestionsPassed = useCallback(
(currentIndex: number, nextIndex: number) => { (currentIndex: number, nextIndex: number) => {
@ -143,6 +150,7 @@ export function QuestionSectionFlow({
children, children,
continueLabel, continueLabel,
exitHref, exitHref,
onExit,
total, total,
optionalQuestionIndexes, optionalQuestionIndexes,
questions, questions,
@ -152,6 +160,7 @@ export function QuestionSectionFlow({
<SectionFlowContent <SectionFlowContent
continueLabel={continueLabel} continueLabel={continueLabel}
exitHref={exitHref} exitHref={exitHref}
onExit={onExit}
optionalQuestionIndexes={optionalQuestionIndexes} optionalQuestionIndexes={optionalQuestionIndexes}
questions={questions} questions={questions}
> >

116
src/components/Componentes/section-overlay-host.test.tsx

@ -0,0 +1,116 @@
import { act, cleanup, render, screen } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { I18nProvider } from "@/translations/provider";
import SectionOverlayHost from "./section-overlay-host";
describe("SectionOverlayHost", () => {
beforeEach(() => {
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
vi.stubGlobal("requestAnimationFrame", (cb: FrameRequestCallback) => {
return setTimeout(() => cb(Date.now()), 0);
});
vi.stubGlobal("cancelAnimationFrame", (id: number) => {
clearTimeout(id);
});
document.body.className = "";
});
afterEach(() => {
cleanup();
vi.restoreAllMocks();
vi.useRealTimers();
document.body.className = "";
});
it("returns null when no children or open is false", () => {
const { container } = render(
<I18nProvider locale="en">
<SectionOverlayHost open={false}>{null}</SectionOverlayHost>
</I18nProvider>,
);
expect(container.firstChild).toBeNull();
expect(document.body.classList.contains("section-overlay-open")).toBe(false);
});
it("renders overlay and adds body class in LTR mode", () => {
render(
<I18nProvider locale="en">
<SectionOverlayHost open={true}>
<div data-testid="detail-content">Detail Page Content</div>
</SectionOverlayHost>
</I18nProvider>,
);
// Initial paint frame
act(() => {
vi.advanceTimersByTime(16);
});
const overlay = screen.getByRole("dialog");
expect(overlay).toBeInTheDocument();
expect(overlay).toHaveClass("section-overlay");
expect(overlay).toHaveAttribute("data-dir", "ltr");
expect(overlay).toHaveAttribute("data-state", "open");
expect(screen.getByTestId("detail-content")).toBeInTheDocument();
expect(document.body.classList.contains("section-overlay-open")).toBe(true);
});
it("sets RTL direction when locale is fa", () => {
render(
<I18nProvider locale="fa">
<SectionOverlayHost open={true}>
<div data-testid="detail-content">محتوای جزئیات</div>
</SectionOverlayHost>
</I18nProvider>,
);
act(() => {
vi.advanceTimersByTime(16);
});
const overlay = screen.getByRole("dialog");
expect(overlay).toHaveAttribute("data-dir", "rtl");
expect(overlay).toHaveAttribute("dir", "rtl");
});
it("retains children and animates to closing state when open becomes false", () => {
const { rerender } = render(
<I18nProvider locale="en">
<SectionOverlayHost open={true}>
<div data-testid="detail-content">Detail Page Content</div>
</SectionOverlayHost>
</I18nProvider>,
);
act(() => {
vi.advanceTimersByTime(16);
});
expect(screen.getByTestId("detail-content")).toBeInTheDocument();
expect(screen.getByRole("dialog")).toHaveAttribute("data-state", "open");
// Close overlay (simulate back / close action)
rerender(
<I18nProvider locale="en">
<SectionOverlayHost open={false}>
<div data-testid="detail-content">Detail Page Content</div>
</SectionOverlayHost>
</I18nProvider>,
);
// Content is retained during closing transition
const overlay = screen.getByRole("dialog");
expect(overlay).toHaveAttribute("data-state", "closing");
expect(screen.getByTestId("detail-content")).toBeInTheDocument();
expect(document.body.classList.contains("section-overlay-open")).toBe(false);
// Advance past REVERSE_DURATION_MS (200ms)
act(() => {
vi.advanceTimersByTime(210);
});
expect(screen.queryByTestId("detail-content")).not.toBeInTheDocument();
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
});
});

148
src/components/Componentes/section-overlay-host.tsx

@ -0,0 +1,148 @@
"use client";
import {
createContext,
type ReactNode,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { localeDirections } from "@/translations/config";
import { useI18n } from "@/translations/provider";
type SectionOverlayContextValue = {
isOverlay: true;
onClose?: () => void;
};
const SectionOverlayContext =
createContext<SectionOverlayContextValue | null>(null);
export function useSectionOverlay() {
return useContext(SectionOverlayContext);
}
type SectionOverlayHostProps = {
open?: boolean;
onClose?: () => void;
children?: ReactNode;
};
const REVERSE_DURATION_MS = 200;
export function SectionOverlayHost({
open = false,
onClose,
children,
}: SectionOverlayHostProps) {
const { locale } = useI18n();
const dir = (locale && localeDirections[locale]) || "ltr";
// Retain last rendered children during closing animation (like hosseinieh-app PanelSlot)
const [activeChild, setActiveChild] = useState<ReactNode | null>(
open ? children ?? null : null,
);
const [mounted, setMounted] = useState(open);
const [state, setState] = useState<"closed" | "open" | "closing">(
open ? "open" : "closed",
);
const isClosingRef = useRef(false);
const closeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
if (open) {
if (closeTimerRef.current) {
clearTimeout(closeTimerRef.current);
closeTimerRef.current = null;
}
isClosingRef.current = false;
setMounted(true);
if (children) {
setActiveChild(children);
}
const frame = requestAnimationFrame(() => {
setState("open");
if (typeof document !== "undefined") {
document.body.classList.add("section-overlay-open");
}
});
return () => cancelAnimationFrame(frame);
}
// When closing
if (mounted && !isClosingRef.current) {
isClosingRef.current = true;
setState("closing");
if (typeof document !== "undefined") {
document.body.classList.remove("section-overlay-open");
}
closeTimerRef.current = setTimeout(() => {
setMounted(false);
setState("closed");
setActiveChild(null);
isClosingRef.current = false;
closeTimerRef.current = null;
}, REVERSE_DURATION_MS);
}
}, [open, children, mounted]);
useEffect(() => {
return () => {
if (closeTimerRef.current) {
clearTimeout(closeTimerRef.current);
}
if (typeof document !== "undefined") {
document.body.classList.remove("section-overlay-open");
}
};
}, []);
// ESC key handler to close panel
useEffect(() => {
if (!open || !onClose) return;
const handleEsc = (e: KeyboardEvent) => {
if (e.key === "Escape") {
onClose();
}
};
window.addEventListener("keydown", handleEsc);
return () => window.removeEventListener("keydown", handleEsc);
}, [open, onClose]);
const contextValue = useMemo<SectionOverlayContextValue>(
() => ({
isOverlay: true,
onClose,
}),
[onClose],
);
if (!mounted && !open && state === "closed") {
return null;
}
const contentToRender = open ? children || activeChild : activeChild;
return (
<SectionOverlayContext.Provider value={contextValue}>
<aside
data-slot="section-overlay"
data-state={state}
data-dir={dir}
dir={dir}
aria-modal="true"
role="dialog"
className="section-overlay"
>
{contentToRender}
</aside>
</SectionOverlayContext.Provider>
);
}
export default SectionOverlayHost;
Loading…
Cancel
Save