From 41d676f9bbcfd1f5fc422fce6e88cb003ac6311b Mon Sep 17 00:00:00 2001 From: Marco Falkenberg Date: Tue, 27 Oct 2026 10:27:05 +0100 Subject: [PATCH] perf(components): memoize the slot context value SlotContextProvider built a new context object on every render, so every slotted component re-rendered all slot context consumers below it, even when the slot was unchanged. Co-Authored-By: Claude Opus 5.5 --- .../SlotContextProvider.browser.test.tsx | 46 +++++++++++++++++++ .../lib/slotContext/SlotContextProvider.tsx | 13 ++++-- 2 files changed, 54 insertions(+), 5 deletions(-) create mode 100644 packages/components/src/lib/slotContext/SlotContextProvider.browser.test.tsx diff --git a/packages/components/src/lib/slotContext/SlotContextProvider.browser.test.tsx b/packages/components/src/lib/slotContext/SlotContextProvider.browser.test.tsx new file mode 100644 index 0000000000..72f91fae9c --- /dev/null +++ b/packages/components/src/lib/slotContext/SlotContextProvider.browser.test.tsx @@ -0,0 +1,46 @@ +import type { ComponentType, PropsWithChildren } from "react"; +import { memo } from "react"; +import { beforeEach, expect, test } from "vitest"; +import { render } from "vitest-browser-react"; +import { page } from "vitest/browser"; +import { flowComponent } from "@/lib/componentFactory/flowComponent"; +import { useSlotContext } from "@/lib/slotContext/slotContext"; + +type SlottedComponentProps = PropsWithChildren<{ + slot?: string; + testProp?: string; +}>; + +const SlottedComponent = flowComponent("TestComponent", (props) => ( + <>{props.children} +)) as ComponentType; + +let consumerRenderCount: number; + +const SlotContextConsumer = memo(() => { + consumerRenderCount++; + return {useSlotContext().TestComponent}; +}); + +const renderSlotted = (slot: string, testProp: string) => ( + + + +); + +beforeEach(() => { + consumerRenderCount = 0; +}); + +test("slot context consumers do not re-render when an unrelated prop changes", async () => { + const { rerender } = await render(renderSlotted("primary", "a")); + await expect.element(page.getByTestId("slot")).toHaveTextContent("primary"); + expect(consumerRenderCount).toBe(1); + + await rerender(renderSlotted("primary", "b")); + expect(consumerRenderCount).toBe(1); + + await rerender(renderSlotted("secondary", "b")); + await expect.element(page.getByTestId("slot")).toHaveTextContent("secondary"); + expect(consumerRenderCount).toBe(2); +}); diff --git a/packages/components/src/lib/slotContext/SlotContextProvider.tsx b/packages/components/src/lib/slotContext/SlotContextProvider.tsx index fe71a1b503..a4e4de97a0 100644 --- a/packages/components/src/lib/slotContext/SlotContextProvider.tsx +++ b/packages/components/src/lib/slotContext/SlotContextProvider.tsx @@ -1,5 +1,5 @@ import type { FC, PropsWithChildren } from "react"; -import { memo } from "react"; +import { memo, useMemo } from "react"; import type { FlowComponentName } from "@/components/propTypes"; import { slotContext, useSlotContext } from "@/lib/slotContext/slotContext"; @@ -15,10 +15,13 @@ export const SlotContextProvider: FC = memo((props) => { const { slot, component, children } = props; const parentContext = useSlotContext(); - const mergedContext = { - ...parentContext, - [component]: slot, - }; + const mergedContext = useMemo( + () => ({ + ...parentContext, + [component]: slot, + }), + [parentContext, component, slot], + ); return {children}; });