diff --git a/.zcode/plans/plan-sess_02d1b36a-d03c-400c-8d4c-a423d16e661f.md b/.zcode/plans/plan-sess_02d1b36a-d03c-400c-8d4c-a423d16e661f.md
new file mode 100644
index 0000000..d34e5f4
--- /dev/null
+++ b/.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` → صفحه کامل.
+ - حفظ اسکرول لیست پس از بستن؛ قفل اسکرول پشت پنل هنگام باز بودن.
\ No newline at end of file
diff --git a/src/app/[lang]/questions-list/[slug]/loading.tsx b/src/app/[lang]/questions-list/[slug]/loading.tsx
new file mode 100644
index 0000000..d4534a7
--- /dev/null
+++ b/src/app/[lang]/questions-list/[slug]/loading.tsx
@@ -0,0 +1 @@
+export { default } from "@/app/questions-list/[slug]/loading";
diff --git a/src/app/globals.css b/src/app/globals.css
index c2b9939..a377990 100644
--- a/src/app/globals.css
+++ b/src/app/globals.css
@@ -229,10 +229,74 @@ body[data-page-background="custom"] .app-shell {
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;
}
+/* ── 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 {
max-height: 120px;
overflow: hidden;
diff --git a/src/app/questions-list/[slug]/question-detail-client.tsx b/src/app/questions-list/[slug]/question-detail-client.tsx
index c224bef..0949d43 100644
--- a/src/app/questions-list/[slug]/question-detail-client.tsx
+++ b/src/app/questions-list/[slug]/question-detail-client.tsx
@@ -3,6 +3,7 @@
import { useRouter } from "next/navigation";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useHardwareBackHandler } from "@/hooks/use-hardware-back-handler";
+import { useSectionOverlay } from "@/components/Componentes/section-overlay-host";
import Button from "@/components/Componentes/button";
import DataErrorState from "@/components/Componentes/data-error-state";
import { FixToTheEnd } from "@/components/Componentes/fix-to-the-end";
@@ -61,6 +62,7 @@ type QuestionDetailClientProps = {
locale?: Locale;
questionsListHref: string;
title: string;
+ onClose?: () => void;
};
type StoredQuestionField = {
@@ -90,6 +92,7 @@ function QuestionFlowWrapper({
dobQuestion,
continueLabel,
questionsListHref,
+ onExit,
}: {
visibleQuestions: QuestionField[];
itemSlug: string;
@@ -97,6 +100,7 @@ function QuestionFlowWrapper({
requiredQuestionsCount: number;
continueLabel: string;
questionsListHref: string;
+ onExit?: () => void;
}) {
const { getAnswerValue } = useQuestionAnswers();
@@ -114,6 +118,7 @@ function QuestionFlowWrapper({
total={requiredCount}
continueLabel={continueLabel}
exitHref={questionsListHref}
+ onExit={onExit}
optionalQuestionIndexes={dynamicQuestions.flatMap((question, index) =>
question.required ? [] : [index],
)}
@@ -168,6 +173,7 @@ export default function QuestionDetailClient({
locale = defaultLocale,
questionsListHref,
title,
+ onClose,
}: QuestionDetailClientProps) {
const router = useRouter();
const { dictionary: t } = useI18n();
@@ -176,13 +182,21 @@ export default function QuestionDetailClient({
const queryClient = useQueryClient();
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.
// QuestionAnswersProvider's pagehide/unmount safety net will flush
// any pending answers automatically when the component unmounts.
const handleHardwareBack = useCallback(async () => {
- router.replace(questionsListHref);
+ handleExit();
return true; // handled — keep WebView open
- }, [router, questionsListHref]);
+ }, [handleExit]);
useHardwareBackHandler(handleHardwareBack);
@@ -352,9 +366,9 @@ export default function QuestionDetailClient({
useEffect(() => {
if (!isSchemaLoading && !isSchemaError && !item) {
- router.replace(questionsListHref);
+ handleExit();
}
- }, [isSchemaLoading, isSchemaError, item, questionsListHref, router]);
+ }, [isSchemaLoading, isSchemaError, item, handleExit]);
if (isSchemaLoading) {
if (!isAssessment) {
@@ -374,7 +388,7 @@ export default function QuestionDetailClient({
variant="transparent"
icon="close"
iconLabel={closeLabel}
- onClick={() => router.replace(questionsListHref)}
+ onClick={handleExit}
/>
{loadingTitle}
@@ -426,7 +440,7 @@ export default function QuestionDetailClient({
variant="transparent"
icon="close"
iconLabel={closeLabel}
- onClick={() => router.replace(questionsListHref)}
+ onClick={handleExit}
/>
{errorTitle}
@@ -469,7 +483,7 @@ export default function QuestionDetailClient({
variant="transparent"
icon="close"
iconLabel={closeLabel}
- onClick={() => router.replace(questionsListHref)}
+ onClick={handleExit}
/>
{errorTitle}
@@ -653,6 +667,7 @@ export default function QuestionDetailClient({
variant="transparent"
icon="close"
iconLabel={closeLabel}
+ onClick={handleExit}
/>
{item.title}
@@ -827,6 +842,7 @@ export default function QuestionDetailClient({
icon="close"
iconLabel={closeLabel}
exitHref={questionsListHref}
+ onExit={handleExit}
/>
{item.title}
@@ -850,6 +866,7 @@ export default function QuestionDetailClient({
requiredQuestionsCount={requiredQuestionsCount}
continueLabel={continueLabel}
questionsListHref={questionsListHref}
+ onExit={handleExit}
/>
diff --git a/src/app/questions-list/questions-list-client.tsx b/src/app/questions-list/questions-list-client.tsx
index 1253e88..b838db4 100644
--- a/src/app/questions-list/questions-list-client.tsx
+++ b/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 type { MarriageField } from "@/hooks/marriage/types";
import { marriageQueryKeys } from "@/hooks/marriage/query-keys";
+import { getCattellQuestions } from "@/hooks/marriage/use-cattell";
+import { getGlasserQuestions } from "@/hooks/marriage/use-glasser";
import {
getFormSection,
useFormOverviewQuery,
@@ -51,6 +53,8 @@ import { useI18n } from "@/translations/provider";
import { prefetchSectionsWithBoundedConcurrency } from "./section-prefetch";
import DevTapInstrumentation from "@/components/Componentes/dev-tap-instrumentation";
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() {
// 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
// (meaning "I didn't handle it — you should close").
useHardwareBackHandler(() => {
+ if (activeSectionSlug) {
+ handleCloseSection();
+ return true; // Handled: closed the section sheet, do not close WebView
+ }
if (typeof window !== "undefined" && (window as any).HabibApp?.postMessage) {
(window as any).HabibApp.postMessage(
JSON.stringify({ action: "close_service" }),
@@ -111,10 +119,63 @@ export default function QuestionsListClient() {
const [isOptionalInfoSheetOpen, setIsOptionalInfoSheetOpen] = useState(false);
const [selectedSection, setSelectedSection] =
useState(null);
+ const [activeSectionSlug, setActiveSectionSlug] = useState(
+ 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(
() => convertOverviewToFrontendItems(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<
Map
>(new Map());
@@ -310,22 +371,64 @@ export default function QuestionsListClient() {
} finally {
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(
(item: QuestionListItem) => {
+ const sectionUrl = localizePath(`/questions-list/${item.slug}`, locale);
+ router.prefetch(sectionUrl);
+
if (
item.slug === "personality_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;
+ }
+
void queryClient.prefetchQuery({
queryKey: marriageQueryKeys.formSection("profile", item.slug, locale),
queryFn: () => getFormSection("profile", item.slug, locale),
staleTime: 30 * 1000,
});
},
- [locale, queryClient],
+ [locale, queryClient, router],
);
const prefetchQueueStarted = useRef(false);
@@ -353,10 +456,39 @@ export default function QuestionsListClient() {
if (profileSections.length === 0) return;
prefetchQueueStarted.current = true;
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;
void prefetchSectionsWithBoundedConcurrency(
- profileSections,
+ remaining,
(item) =>
queryClient.fetchQuery({
queryKey: marriageQueryKeys.formSection("profile", item.slug, locale),
@@ -366,16 +498,17 @@ export default function QuestionsListClient() {
() => cancelled,
);
};
+
const idle = typeof requestIdleCallback === "function"
- ? requestIdleCallback(startPrefetch)
- : setTimeout(startPrefetch, 200);
+ ? requestIdleCallback(startRemainingPrefetch, { timeout: 3000 })
+ : setTimeout(startRemainingPrefetch, 200);
return () => {
cancelled = true;
if (typeof cancelIdleCallback === "function" && typeof idle === "number") {
cancelIdleCallback(idle);
}
};
- }, [locale, overview, queryClient, questionListItems, sectionProgressBySlug]);
+ }, [locale, overview, queryClient, questionListItems, router, sectionProgressBySlug]);
useEffect(() => {
void syncPendingAnswers().catch((err) => {
@@ -703,6 +836,8 @@ export default function QuestionsListClient() {
progress={sectionProgressBySlug.get(item.slug) ?? null}
onInfoClick={(section) => setSelectedSection(section)}
onPrefetch={prefetchSection}
+ onNearViewport={prefetchSection}
+ onSelect={(item) => handleOpenSection(item.slug)}
/>
))}
@@ -740,6 +875,25 @@ export default function QuestionsListClient() {
+
+
+ {activeSectionItem ? (
+
+ ) : null}
+
>
);
}
diff --git a/src/app/questions-list/section-prefetch-race.test.ts b/src/app/questions-list/section-prefetch-race.test.ts
new file mode 100644
index 0000000..823f388
--- /dev/null
+++ b/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 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);
+ });
+});
diff --git a/src/components/Componentes/question-card.tsx b/src/components/Componentes/question-card.tsx
index 5229a13..8eee18f 100644
--- a/src/components/Componentes/question-card.tsx
+++ b/src/components/Componentes/question-card.tsx
@@ -12,6 +12,7 @@ type QuestionCardProps = {
onInfoClick?: (item: QuestionListItem) => void;
onNearViewport?: (item: QuestionListItem) => void;
onPrefetch?: (item: QuestionListItem) => void;
+ onSelect?: (item: QuestionListItem) => void;
};
const RADIUS = 8;
@@ -34,6 +35,7 @@ export function QuestionCard({
onInfoClick,
onNearViewport,
onPrefetch,
+ onSelect,
}: QuestionCardProps) {
const { dictionary: t, locale } = useI18n();
const hasProgress = typeof progress === "number" && Number.isFinite(progress);
@@ -69,10 +71,18 @@ export function QuestionCard({
return (
{
+ if (onSelect && !e.ctrlKey && !e.metaKey && !e.shiftKey && e.button === 0) {
+ e.preventDefault();
+ onSelect(item);
+ }
+ }}
onFocus={() => onPrefetch?.(item)}
onPointerEnter={() => onPrefetch?.(item)}
+ onPointerDown={() => onPrefetch?.(item)}
>
void;
};
export function QuestionExitNavigationButton({
exitHref,
+ onExit,
...props
}: QuestionExitNavigationButtonProps) {
const router = useRouter();
@@ -44,8 +46,12 @@ export function QuestionExitNavigationButton({
} catch {
// ignore
} 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);
+ }
}
}}
/>
diff --git a/src/components/Componentes/question-section-flow.tsx b/src/components/Componentes/question-section-flow.tsx
index a73e1a1..9570803 100644
--- a/src/components/Componentes/question-section-flow.tsx
+++ b/src/components/Componentes/question-section-flow.tsx
@@ -22,6 +22,7 @@ type QuestionSectionFlowProps = {
children: ReactNode;
continueLabel: string;
exitHref: string;
+ onExit?: () => void;
total: number;
optionalQuestionIndexes: readonly number[];
questions?: readonly QuestionField[];
@@ -31,12 +32,14 @@ function SectionFlowContent({
children,
continueLabel,
exitHref,
+ onExit,
optionalQuestionIndexes,
questions,
}: {
children: ReactNode;
continueLabel: string;
exitHref: string;
+ onExit?: () => void;
optionalQuestionIndexes: readonly number[];
questions?: readonly QuestionField[];
}) {
@@ -63,10 +66,14 @@ function SectionFlowContent({
} catch {
// ignore
} 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(
(currentIndex: number, nextIndex: number) => {
@@ -143,6 +150,7 @@ export function QuestionSectionFlow({
children,
continueLabel,
exitHref,
+ onExit,
total,
optionalQuestionIndexes,
questions,
@@ -152,6 +160,7 @@ export function QuestionSectionFlow({
diff --git a/src/components/Componentes/section-overlay-host.test.tsx b/src/components/Componentes/section-overlay-host.test.tsx
new file mode 100644
index 0000000..61c8510
--- /dev/null
+++ b/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(
+
+ {null}
+ ,
+ );
+
+ 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(
+
+
+ Detail Page Content
+
+ ,
+ );
+
+ // 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(
+
+
+ محتوای جزئیات
+
+ ,
+ );
+
+ 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(
+
+
+ Detail Page Content
+
+ ,
+ );
+
+ 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(
+
+
+ Detail Page Content
+
+ ,
+ );
+
+ // 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();
+ });
+});
diff --git a/src/components/Componentes/section-overlay-host.tsx b/src/components/Componentes/section-overlay-host.tsx
new file mode 100644
index 0000000..074bea2
--- /dev/null
+++ b/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(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(
+ 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 | 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(
+ () => ({
+ isOverlay: true,
+ onClose,
+ }),
+ [onClose],
+ );
+
+ if (!mounted && !open && state === "closed") {
+ return null;
+ }
+
+ const contentToRender = open ? children || activeChild : activeChild;
+
+ return (
+
+
+
+ );
+}
+
+export default SectionOverlayHost;