diff --git a/src/components/DrugInfo.tsx b/src/components/DrugInfo.tsx index 3ab65a0..32523ec 100644 --- a/src/components/DrugInfo.tsx +++ b/src/components/DrugInfo.tsx @@ -1,4 +1,4 @@ -import React, { ReactNode } from "react"; +import React, { memo, ReactNode } from "react"; import { Card, CardContent, Container, Grid, Typography } from "@mui/material"; import { Dosage, @@ -199,6 +199,9 @@ const addDurations = (drugData: Drug) => { const option = { chart: { id: "durationChart", + // The grow-in animation keeps the browser drawing frames for about two seconds after a + // factsheet row is expanded. Draw the chart once instead. + animations: { enabled: false }, }, plotOptions: { bar: { @@ -525,6 +528,7 @@ const addDosages = (drugData: Drug) => { const option = { chart: { id: "doseChart", + animations: { enabled: false }, // See the duration chart above }, plotOptions: { bar: { @@ -660,11 +664,7 @@ const addDosages = (drugData: Drug) => { ); }; -export default function DrugInfoCard({ - drugData, -}: { - drugData: Drug; -}): ReactNode { +function DrugInfoCard({ drugData }: { drugData: Drug }): ReactNode { // This function goes through all the optional data in the drug and creates a nice and fancy expandable row // This is the array that will hold all the elements that will be displayed in the expanded row @@ -1159,3 +1159,9 @@ export default function DrugInfoCard({ ); } + +// The factsheets table re-renders on every state change (each keystroke in a filter, for +// example) and asks for the detail panel of every row each time. Without memo, an open card +// re-renders too, and react-apexcharts treats the fresh options object (its tooltip functions +// never compare equal) as a change and redraws both charts from scratch. +export default memo(DrugInfoCard); diff --git a/src/components/Factsheets.tsx b/src/components/Factsheets.tsx index fb06e6d..4e633b5 100644 --- a/src/components/Factsheets.tsx +++ b/src/components/Factsheets.tsx @@ -373,7 +373,11 @@ const Factsheets = () => { {InfoBar} ), - renderDetailPanel: ({ row }) => DrugInfoCard({ drugData: row.original }), + // MRT calls this for every row on the page on every render, expanded or not, and only + // mounts the result once the row is expanded. Return an element rather than calling + // DrugInfoCard directly, so the card (its charts, combos and dictionary tooltips) is only + // built for expanded rows. + renderDetailPanel: ({ row }) => , }); return ; diff --git a/src/components/__tests__/DrugInfo.test.tsx b/src/components/__tests__/DrugInfo.test.tsx index c1a962d..6e7af45 100644 --- a/src/components/__tests__/DrugInfo.test.tsx +++ b/src/components/__tests__/DrugInfo.test.tsx @@ -8,6 +8,9 @@ import DrugInfo from "../DrugInfo"; jest.mock("next/router", () => ({ useRouter: jest.fn() })); jest.mock("@/utils/drugs", () => ({ useDrugs: jest.fn() })); jest.mock("../Header", () => () => null); +// Called on every chart render. In the browser, react-apexcharts redraws the chart whenever it +// re-renders with a new options object, so a render here stands for a redraw there. +const mockChartRender = jest.fn(); jest.mock( "next/dynamic", () => () => @@ -17,6 +20,7 @@ jest.mock( }: { series: unknown; options: { + chart: unknown; tooltip: { custom: (input: { seriesIndex: number; @@ -26,6 +30,7 @@ jest.mock( }; }; }) { + mockChartRender(options.chart); return ( <> {JSON.stringify(series)} @@ -127,6 +132,25 @@ test("keeps dose text and charts the ranges for each route", () => { ]); }); +test("draws an open card's charts once, without animation", () => { + mockChartRender.mockClear(); + const drugData = { + ...drug, + formatted_dose: { oral: { common: "1-2mg" } }, + formatted_duration: { oral: "1-2", _unit: "hours" as Unit }, + } as Drug; + const { rerender } = render(); + expect(mockChartRender).toHaveBeenCalledTimes(2); + expect(mockChartRender.mock.calls).toEqual([ + [expect.objectContaining({ animations: { enabled: false } })], + [expect.objectContaining({ animations: { enabled: false } })], + ]); + + // The factsheets table re-renders the panel of an expanded row on every keystroke in a filter + rerender(); + expect(mockChartRender).toHaveBeenCalledTimes(2); +}); + test("keeps unplottable doses in the table", () => { render( ({ Accordion: () => null })); +const mockDrugInfoCard = jest.fn(({ drugData }: { drugData: Drug }) => ( +

Details for {drugData.pretty_name}

+)); +jest.mock("../DrugInfo", () => ({ + __esModule: true, + default: (props: { drugData: Drug }) => mockDrugInfoCard(props), +})); + +const originalFetch = global.fetch; + +afterEach(() => { + global.fetch = originalFetch; +}); + +test("builds a factsheet card only for the expanded row", async () => { + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + first: { name: "first", pretty_name: "First compound", properties: {} }, + second: { + name: "second", + pretty_name: "Second compound", + properties: {}, + }, + }), + }); + render( + + + , + ); + expect(await screen.findByText("Second compound")).toBeVisible(); + // The table asks for every row's detail panel on each render; collapsed rows must not build one + expect(mockDrugInfoCard).not.toHaveBeenCalled(); + + fireEvent.click(screen.getAllByRole("button", { name: "Expand" })[1]); + expect(await screen.findByText("Details for Second compound")).toBeVisible(); + expect( + new Set(mockDrugInfoCard.mock.calls.map(([props]) => props.drugData.name)), + ).toEqual(new Set(["second"])); +});