Browse Source

perf(information-sheet): render inline play icon synchronously for answer pace sheet to eliminate late load delay

master
mortezaei 2 weeks ago
parent
commit
2569ed4033
  1. 87
      src/app/questions-list/[slug]/answer-pace-sheet.test.tsx
  2. 108
      src/components/Componentes/information-sheet.test.tsx
  3. 131
      src/components/Componentes/information-sheet.tsx
  4. 32
      src/components/Componentes/ui-icon.tsx

87
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(
<AnswerPaceSheet
activeQuestionIndex={2}
title="با آرامش پاسخ دهید"
description="می‌توانید هر زمان نظرسنجی را متوقف کرده و بعداً ادامه دهید."
continueLabel="متوجه شدم"
/>,
);
expect(screen.queryByRole("dialog")).toBeNull();
});
it("renders when activeQuestionIndex >= 3 and displays inline play icon synchronously without network img tag", () => {
render(
<AnswerPaceSheet
activeQuestionIndex={3}
title="با آرامش پاسخ دهید"
description="می‌توانید هر زمان نظرسنجی را متوقف کرده و بعداً ادامه دهید."
continueLabel="متوجه شدم"
/>,
);
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 <img> 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(
<AnswerPaceSheet
activeQuestionIndex={3}
title="با آرامش پاسخ دهید"
description="می‌توانید هر زمان نظرسنجی را متوقف کرده و بعداً ادامه دهید."
continueLabel="متوجه شدم"
/>,
);
const continueBtn = screen.getByRole("button", { name: "متوجه شدم" });
fireEvent.click(continueBtn);
await waitFor(() => {
expect(isAnswerPaceSheetSeen()).toBe(true);
});
});
});

108
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(
<InformationSheet
isOpen={true}
icon="play"
title="Answer at Your Own Pace"
description="Test pace description"
/>,
);
const dialog = screen.getByRole("dialog");
// Should NOT contain an <img> 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(
<InformationSheet
isOpen={true}
title="Default Icon Test"
description="Test description"
/>,
);
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(
<InformationSheet
isOpen={true}
icon="warning"
title="Warning Title"
description="Warning Description"
/>,
);
let dialog = screen.getByRole("dialog");
expect(dialog.querySelector("img")).toBeNull();
expect(dialog.querySelector('svg[viewBox="0 0 36 36"]')).not.toBeNull();
rerender(
<InformationSheet
isOpen={true}
icon="check"
title="Check Title"
description="Check Description"
/>,
);
dialog = screen.getByRole("dialog");
expect(dialog.querySelector("img")).toBeNull();
expect(dialog.querySelector('svg[viewBox="0 0 36 36"]')).not.toBeNull();
rerender(
<InformationSheet
isOpen={true}
icon="diamond"
title="Diamond Title"
description="Diamond Description"
/>,
);
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(
<InformationSheet
isOpen={true}
icon={null}
title="No Icon Title"
description="No Icon Description"
/>,
);
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(
<InformationSheet
isOpen={true}
icon={<div data-testid="custom-icon">Custom Icon</div>}
title="Custom Icon Title"
description="Custom Icon Description"
/>,
);
expect(screen.getByTestId("custom-icon")).toBeDefined();
expect(screen.getByText("Custom Icon")).toBeDefined();
});
});

131
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<HTMLDivElement>,
@ -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,6 +208,7 @@ function resolveIcon(icon: InformationSheetIcon | null | undefined) {
);
}
if (typeof icon === "object" && "src" in icon) {
return {
src: icon.src,
alt: icon.alt ?? "Information",
@ -214,6 +217,114 @@ function resolveIcon(icon: InformationSheetIcon | null | undefined) {
};
}
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 (
<UiIcon
name="play"
width={50}
height={50}
className="shrink-0"
aria-label="Play"
/>
);
}
if (
icon === "warning" ||
icon === "warning.svg" ||
icon === "/assets/images/Vector.svg"
) {
return (
<svg
width={36}
height={36}
viewBox="0 0 36 36"
fill="none"
aria-hidden="true"
className="shrink-0"
>
<path
d="M18 0C8.1 0 0 8.1 0 18C0 27.9 8.1 36 18 36C27.9 36 36 27.9 36 18C36 8.1 27.9 0 18 0ZM16.5857 7.71429H19.4143V21.8571H16.5857V7.71429ZM18 29.5714C16.9714 29.5714 16.0714 28.6714 16.0714 27.6429C16.0714 26.6143 16.9714 25.7143 18 25.7143C19.0286 25.7143 19.9286 26.6143 19.9286 27.6429C19.9286 28.6714 19.0286 29.5714 18 29.5714Z"
fill="#D54747"
/>
</svg>
);
}
if (
icon === "check" ||
icon === "Vectorcheck.svg" ||
icon === "/assets/images/Vectofdasr.svg" ||
icon === "/assets/images/Vectorcheck.svg"
) {
return (
<svg
width={36}
height={36}
viewBox="0 0 36 36"
fill="none"
aria-hidden="true"
className="shrink-0"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M0 18C0 13.2261 1.89642 8.64773 5.27208 5.27208C8.64773 1.89642 13.2261 0 18 0C22.7739 0 27.3523 1.89642 30.7279 5.27208C34.1036 8.64773 36 13.2261 36 18C36 22.7739 34.1036 27.3523 30.7279 30.7279C27.3523 34.1036 22.7739 36 18 36C13.2261 36 8.64773 34.1036 5.27208 30.7279C1.89642 27.3523 0 22.7739 0 18ZM16.9728 25.704L27.336 12.7488L25.464 11.2512L16.6272 22.2936L10.368 17.0784L8.832 18.9216L16.9728 25.704Z"
fill="#F0445B"
/>
</svg>
);
}
if (typeof icon === "string" && icon.includes("diamond")) {
return (
<UiIcon
name="diamond"
width={48}
height={48}
className="shrink-0"
/>
);
}
const resolved = resolveIcon(icon);
if (!resolved) return null;
return (
<Image
src={resolved.src}
alt={resolved.alt}
width={resolved.width}
height={resolved.height}
priority
unoptimized={typeof resolved.src === "string" && resolved.src.endsWith(".svg")}
/>
);
}
export function InformationSheet({
isOpen = true,
icon,
@ -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({
</div>
) : (
<>
{resolvedIcon ? (
<Image
src={resolvedIcon.src}
alt={resolvedIcon.alt}
width={resolvedIcon.width}
height={resolvedIcon.height}
priority
/>
) : null}
{renderedIcon}
<h2
className={[
resolvedIcon ? "mt-2.5" : "mt-0",
renderedIcon ? "mt-2.5" : "mt-0",
"w-full group-16 leading-[1.2] font-bold tracking-[-0.03em] text-[#171717]",
].join(" ")}
>

32
src/components/Componentes/ui-icon.tsx

@ -34,7 +34,8 @@ export type UiIconName =
| "personality"
| "glasser"
| "success"
| "diamond";
| "diamond"
| "play";
type UiIconProps = SVGProps<SVGSVGElement> & {
name: UiIconName;
@ -527,6 +528,35 @@ export function UiIcon({ name, ...props }: UiIconProps) {
</svg>
);
case "play":
return (
<svg
viewBox="0 0 50 50"
width={props.width ?? 50}
height={props.height ?? 50}
fill="none"
aria-hidden="true"
{...props}
>
<path
d="M20.8568 12.264C17.3839 10.239 13.0234 12.7432 13.0234 16.764V33.2348C13.0234 37.2557 17.3839 39.7598 20.8568 37.7348L34.9755 29.4973C38.4193 27.489 38.4193 22.5098 34.9755 20.5015L20.8568 12.264Z"
fill={`url(#${gradientId})`}
/>
<defs>
<linearGradient
id={gradientId}
x1="37.7375"
y1="38.5196"
x2="16.7157"
y2="14.8915"
gradientUnits="userSpaceOnUse"
>
{PINK_GRADIENT_STOPS}
</linearGradient>
</defs>
</svg>
);
default:
return null;
}

Loading…
Cancel
Save