From 2569ed4033e75b9439d1aff213073433b2e3da4f Mon Sep 17 00:00:00 2001 From: mortezaei Date: Sat, 5 Sep 2026 02:05:07 +0330 Subject: [PATCH] perf(information-sheet): render inline play icon synchronously for answer pace sheet to eliminate late load delay --- .../[slug]/answer-pace-sheet.test.tsx | 87 +++++++++++ .../Componentes/information-sheet.test.tsx | 108 +++++++++++++ .../Componentes/information-sheet.tsx | 143 +++++++++++++++--- src/components/Componentes/ui-icon.tsx | 32 +++- 4 files changed, 349 insertions(+), 21 deletions(-) create mode 100644 src/app/questions-list/[slug]/answer-pace-sheet.test.tsx diff --git a/src/app/questions-list/[slug]/answer-pace-sheet.test.tsx b/src/app/questions-list/[slug]/answer-pace-sheet.test.tsx new file mode 100644 index 0000000..d9add41 --- /dev/null +++ b/src/app/questions-list/[slug]/answer-pace-sheet.test.tsx @@ -0,0 +1,87 @@ +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import AnswerPaceSheet, { + isAnswerPaceSheetSeen, + markAnswerPaceSheetSeen, +} from "./answer-pace-sheet"; + +vi.mock("@/translations/provider", () => ({ + useI18n: vi.fn(() => ({ + locale: "fa", + dictionary: { + "Information sheet": "شیت اطلاعات", + Close: "بستن", + }, + })), +})); + +vi.mock("@/lib/first-entry-helper", () => ({ + isFirstEntryCompleted: vi.fn(() => false), +})); + +describe("AnswerPaceSheet", () => { + beforeEach(() => { + window.localStorage.clear(); + }); + + afterEach(() => { + cleanup(); + window.localStorage.clear(); + }); + + it("does not render when activeQuestionIndex is less than 3", () => { + render( + , + ); + + expect(screen.queryByRole("dialog")).toBeNull(); + }); + + it("renders when activeQuestionIndex >= 3 and displays inline play icon synchronously without network img tag", () => { + render( + , + ); + + const dialog = screen.getByRole("dialog"); + expect(dialog).toBeDefined(); + expect(screen.getByText("با آرامش پاسخ دهید")).toBeDefined(); + expect( + screen.getByText( + "می‌توانید هر زمان نظرسنجی را متوقف کرده و بعداً ادامه دهید.", + ), + ).toBeDefined(); + + // Critical: must render inline SVG play icon instantly with NO tag + expect(dialog.querySelector("img")).toBeNull(); + const playSvg = dialog.querySelector('svg[aria-label="Play"]'); + expect(playSvg).not.toBeNull(); + expect(playSvg?.getAttribute("viewBox")).toBe("0 0 50 50"); + }); + + it("marks sheet as seen and closes on button click", async () => { + render( + , + ); + + const continueBtn = screen.getByRole("button", { name: "متوجه شدم" }); + fireEvent.click(continueBtn); + await waitFor(() => { + expect(isAnswerPaceSheetSeen()).toBe(true); + }); + }); +}); diff --git a/src/components/Componentes/information-sheet.test.tsx b/src/components/Componentes/information-sheet.test.tsx index e0f0c0d..14f6f24 100644 --- a/src/components/Componentes/information-sheet.test.tsx +++ b/src/components/Componentes/information-sheet.test.tsx @@ -131,5 +131,113 @@ describe("InformationSheet", () => { expect(section?.style.paddingBottom).toBe(""); }); }); + + it("should render inline SVG for play icon preset without network img tag", () => { + render( + , + ); + + const dialog = screen.getByRole("dialog"); + // Should NOT contain an tag for play + expect(dialog.querySelector("img")).toBeNull(); + + // Should contain inline SVG element with viewBox 0 0 50 50 and aria-label "Play" + const playSvg = dialog.querySelector('svg[aria-label="Play"]'); + expect(playSvg).not.toBeNull(); + expect(playSvg?.getAttribute("viewBox")).toBe("0 0 50 50"); + + // Path must include the play button path + const path = playSvg?.querySelector("path"); + expect(path?.getAttribute("d")).toContain("M20.8568"); + }); + + it("should render default icon as inline play SVG when icon is omitted", () => { + render( + , + ); + + const dialog = screen.getByRole("dialog"); + expect(dialog.querySelector("img")).toBeNull(); + const playSvg = dialog.querySelector('svg[aria-label="Play"]'); + expect(playSvg).not.toBeNull(); + }); + + it("should render inline SVGs for warning, check, and diamond presets", () => { + const { rerender } = render( + , + ); + + let dialog = screen.getByRole("dialog"); + expect(dialog.querySelector("img")).toBeNull(); + expect(dialog.querySelector('svg[viewBox="0 0 36 36"]')).not.toBeNull(); + + rerender( + , + ); + dialog = screen.getByRole("dialog"); + expect(dialog.querySelector("img")).toBeNull(); + expect(dialog.querySelector('svg[viewBox="0 0 36 36"]')).not.toBeNull(); + + rerender( + , + ); + dialog = screen.getByRole("dialog"); + expect(dialog.querySelector("img")).toBeNull(); + expect(dialog.querySelector('svg[viewBox="0 0 24 24"]')).not.toBeNull(); + }); + + it("should render no icon when icon is null", () => { + render( + , + ); + + const dialog = screen.getByRole("dialog"); + // No play SVG and no img + expect(dialog.querySelector('svg[aria-label="Play"]')).toBeNull(); + expect(dialog.querySelector("img")).toBeNull(); + }); + + it("should render custom ReactNode icon directly", () => { + render( + Custom Icon} + title="Custom Icon Title" + description="Custom Icon Description" + />, + ); + + expect(screen.getByTestId("custom-icon")).toBeDefined(); + expect(screen.getByText("Custom Icon")).toBeDefined(); + }); }); diff --git a/src/components/Componentes/information-sheet.tsx b/src/components/Componentes/information-sheet.tsx index c5a9af9..0d064d1 100644 --- a/src/components/Componentes/information-sheet.tsx +++ b/src/components/Componentes/information-sheet.tsx @@ -2,10 +2,11 @@ import Image, { type StaticImageData } from "next/image"; import type { HTMLAttributes, ReactNode } from "react"; -import { useCallback, useEffect, useRef, useState } from "react"; +import { isValidElement, useCallback, useEffect, useRef, useState } from "react"; import { createPortal } from "react-dom"; import Button from "./button"; import { LoadingThreeDot } from "./loading-three-dot"; +import { UiIcon } from "./ui-icon"; import { useHardwareBackHandler } from "@/hooks/use-hardware-back-handler"; import { viewPaddingsBridge } from "@/lib/view-paddings"; import { useI18n } from "@/translations/provider"; @@ -67,7 +68,8 @@ type InformationSheetIcon = alt?: string; width?: number; height?: number; - }; + } + | ReactNode; export type InformationSheetProps = Omit< HTMLAttributes, @@ -177,7 +179,7 @@ const ICON_PRESETS: Record< }; function resolveIcon(icon: InformationSheetIcon | null | undefined) { - if (icon === null) { + if (icon === null || isValidElement(icon)) { return null; } @@ -206,12 +208,121 @@ function resolveIcon(icon: InformationSheetIcon | null | undefined) { ); } - return { - src: icon.src, - alt: icon.alt ?? "Information", - width: icon.width ?? DEFAULT_ICON.width, - height: icon.height ?? DEFAULT_ICON.height, - }; + if (typeof icon === "object" && "src" in icon) { + return { + src: icon.src, + alt: icon.alt ?? "Information", + width: icon.width ?? DEFAULT_ICON.width, + height: icon.height ?? DEFAULT_ICON.height, + }; + } + + return null; +} + +/** + * Renders the sheet icon synchronously. + * Static icons like "play" (used in AnswerPaceSheet) are rendered as inline SVGs + * to eliminate per-icon HTTP network requests, image decoding latencies, and late pop-in. + */ +function renderSheetIcon(icon: InformationSheetProps["icon"]): ReactNode { + if (icon === null) { + return null; + } + + if (isValidElement(icon)) { + return icon; + } + + // Play icon preset (default or explicit "play", as in AnswerPaceSheet) + if ( + icon === undefined || + icon === "play" || + icon === "stash_play-solid.svg" || + icon === "/assets/images/stash_play-solid.svg" + ) { + return ( + + ); + } + + if ( + icon === "warning" || + icon === "warning.svg" || + icon === "/assets/images/Vector.svg" + ) { + return ( + + ); + } + + if ( + icon === "check" || + icon === "Vectorcheck.svg" || + icon === "/assets/images/Vectofdasr.svg" || + icon === "/assets/images/Vectorcheck.svg" + ) { + return ( + + ); + } + + if (typeof icon === "string" && icon.includes("diamond")) { + return ( + + ); + } + + const resolved = resolveIcon(icon); + if (!resolved) return null; + + return ( + {resolved.alt} + ); } export function InformationSheet({ @@ -285,7 +396,7 @@ export function InformationSheet({ } }, [isOpen, isRendered, closeSheet]); - const resolvedIcon = resolveIcon(icon); + const renderedIcon = renderSheetIcon(icon); useHardwareBackHandler(() => { closeSheet(); @@ -521,19 +632,11 @@ export function InformationSheet({ ) : ( <> - {resolvedIcon ? ( - {resolvedIcon.alt} - ) : null} + {renderedIcon}

diff --git a/src/components/Componentes/ui-icon.tsx b/src/components/Componentes/ui-icon.tsx index 160f902..1b73beb 100644 --- a/src/components/Componentes/ui-icon.tsx +++ b/src/components/Componentes/ui-icon.tsx @@ -34,7 +34,8 @@ export type UiIconName = | "personality" | "glasser" | "success" - | "diamond"; + | "diamond" + | "play"; type UiIconProps = SVGProps & { name: UiIconName; @@ -527,6 +528,35 @@ export function UiIcon({ name, ...props }: UiIconProps) { ); + case "play": + return ( + + ); + default: return null; }