Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: react scan #445

Merged
merged 29 commits into from
Dec 19, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
9f82c27
feat: react scan and monitoring scripts
JBR90 Dec 11, 2024
c51c93d
text: playwright test for chat performance
JBR90 Dec 12, 2024
e39d0ef
Merge branch 'main' into feat-react-scan
JBR90 Dec 12, 2024
673ca87
chore: update lock file
JBR90 Dec 12, 2024
ad996b6
chore: remove unused import
JBR90 Dec 12, 2024
42a112e
fix: add missing env in test
JBR90 Dec 12, 2024
c942968
test: wait for window object
JBR90 Dec 12, 2024
0c2543b
chore: add logs for missing env
JBR90 Dec 12, 2024
def8117
chore: additional logs
JBR90 Dec 12, 2024
6028c38
chore: more logs
JBR90 Dec 12, 2024
733a131
chore: check window for env
JBR90 Dec 17, 2024
c862a12
chore: check for process and window
JBR90 Dec 17, 2024
e6add52
chore: remove window logs
JBR90 Dec 17, 2024
c4a5ac5
chore: try window object
JBR90 Dec 17, 2024
ebe31a9
fix: types
JBR90 Dec 17, 2024
3fb672f
chore: add csp wait
JBR90 Dec 17, 2024
51075df
chore: try without env
JBR90 Dec 17, 2024
2109477
chore : add env earlier
JBR90 Dec 17, 2024
2aadf15
chore: increase wait time
JBR90 Dec 17, 2024
91b018c
chore: remove state
JBR90 Dec 17, 2024
b648db7
chore: use react scan with out env
JBR90 Dec 17, 2024
76f3f81
chore: remove test
JBR90 Dec 18, 2024
e4cfa52
Merge branch 'main' into feat-react-scan
JBR90 Dec 18, 2024
c61de27
refactor: react scan hook
JBR90 Dec 18, 2024
2a4fd67
chore: add in disabled test
JBR90 Dec 18, 2024
b72488c
chore: change logger name
JBR90 Dec 18, 2024
87b04e0
chore: update package json
JBR90 Dec 18, 2024
94b2bad
chore: add comment to skipped test
JBR90 Dec 18, 2024
6161178
chore: alphabetical order
JBR90 Dec 18, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/nextjs/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@
"react-hot-toast": "^2.4.1",
"react-intersection-observer": "^9.6.0",
"react-markdown": "^9.0.0",
"react-scan": "^0.0.43",
"react-syntax-highlighter": "^15.5.0",
"react-textarea-autosize": "^8.5.3",
"remark-gfm": "^4.0.0",
Expand Down
5 changes: 5 additions & 0 deletions apps/nextjs/src/app/aila/page-contents.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,17 @@

import React from "react";

import { useReactScan } from "hooks/useReactScan";

import { Chat } from "@/components/AppComponents/Chat/Chat/chat";
import LessonPlanDisplay from "@/components/AppComponents/Chat/chat-lessonPlanDisplay";
import Layout from "@/components/AppComponents/Layout";
import { ChatProvider } from "@/components/ContextProviders/ChatProvider";
import LessonPlanTrackingProvider from "@/lib/analytics/lessonPlanTrackingContext";

const ChatPageContents = ({ id }: { readonly id: string }) => {
useReactScan({ component: LessonPlanDisplay, interval: 10000 });

return (
<Layout>
<LessonPlanTrackingProvider chatId={id}>
Expand Down
13 changes: 13 additions & 0 deletions apps/nextjs/src/app/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import React from "react";
import { Toaster } from "react-hot-toast";
import { Monitoring } from "react-scan/dist/core/monitor/params/next";

import { ClerkProvider } from "@clerk/nextjs";
import "@fontsource/lexend";
Expand Down Expand Up @@ -38,6 +39,11 @@ const provided_vercel_url =

const vercel_url = `https://${provided_vercel_url}`;

const reactScanApiKey = process.env.NEXT_PUBLIC_REACT_SCAN_KEY;
const addReactScanMonitor =
process.env.NEXT_PUBLIC_RENDER_MONITOR === "true" &&
reactScanApiKey !== undefined;

const lexend = Lexend({
subsets: ["latin"],
display: "swap",
Expand Down Expand Up @@ -74,6 +80,7 @@ export default async function RootLayout({
children,
}: Readonly<RootLayoutProps>) {
const nonce = headers().get("x-nonce");

if (!nonce) {
// Our middleware path matching excludes static paths like /_next/static/...
// If a static path becomes a 404, CSP headers aren't set
Expand All @@ -93,6 +100,12 @@ export default async function RootLayout({
GeistMono.variable,
)}
>
{addReactScanMonitor && (
<Monitoring
apiKey={reactScanApiKey}
url={process.env.NEXT_PUBLIC_REACT_SCAN_URL}
/>
)}
<Theme
accentColor="blue"
grayColor="olive"
Expand Down
149 changes: 149 additions & 0 deletions apps/nextjs/src/hooks/useReactScan.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
import { useEffect } from "react";
import { getReport, scan } from "react-scan";

import { aiLogger } from "@oakai/logger";

const log = aiLogger("ui:performance");

declare global {
interface Window {
NEXT_PUBLIC_ENABLE_RENDER_SCAN?: string;
}
}

const isRenderScanEnabled =
(typeof process !== "undefined" &&
process.env.NEXT_PUBLIC_ENABLE_RENDER_SCAN === "true") ||
(typeof window !== "undefined" &&
window.NEXT_PUBLIC_ENABLE_RENDER_SCAN === "true");

const getSingleReport = <T>(
report: RenderData,
component: React.ComponentType<T>,
) => {
return transformReport([component?.name, report]);
};

const getSortedAndFilteredReports = (reports: Map<string, RenderData>) => {
const reportsArray = Array.from(reports.entries())
.filter(([componentName]) => {
// Exclude styled components and other library-generated components
const isCustomComponent =
// Check if name exists and doesn't start with known library prefixes
componentName &&
!componentName.startsWith("Styled") &&
!componentName.includes("styled") &&
!componentName.startsWith("_") &&
!componentName.includes("$") &&
componentName !== "div" &&
componentName !== "span";

return isCustomComponent;
})
.map(transformReport);

const sortedReports = reportsArray.toSorted(
(a, b) => b.renderCount - a.renderCount,
);
return sortedReports;
};

function getWindowPropertyName<T>(component: React.ComponentType<T>): string {
return `reactScan${component.displayName ?? component.name ?? "UnknownComponent"}`;
}

function setWindowObjectForPlaywright<T>(
component: React.ComponentType<T> | undefined,
report: sortedReport,
) {
if (component) {
const windowPropertyName = getWindowPropertyName(component);
window[windowPropertyName] = report;
}
}

function transformReport([componentName, report]: [
string | undefined,
RenderData,
]) {
return {
name: componentName,
renderCount: report.renders.length,
totalRenderTime: report.time,
};
}

interface RenderData {
count: number;
time: number;
renders: Array<unknown>;
displayName: string | null;
type: React.ComponentType<unknown> | null;
}

type sortedReport = {
name: string | undefined;
renderCount: number;
totalRenderTime: number;
};

// Enable React Scan for performance monitoring - use with dev:react-scan
// Pass in component to get report for specific component
// Pass in interval to get reports at regular intervals
// - useReactScan({ component: LessonPlanDisplay, interval: 10000 });
// When a component is passed it will be added to window object for Playwright testing

export const useReactScan = <T extends object>({
component,
interval,
}: {
component?: React.ComponentType<T>;
interval?: number;
}) => {
useEffect(() => {
if (isRenderScanEnabled) {
try {
log.info("Initializing React Scan...");
if (typeof window !== "undefined") {
scan({
enabled: true,
log: true,
report: true,
renderCountThreshold: 0,
showToolbar: true,
});
}
log.info("React Scan initialized successfully");
} catch (error) {
log.error("React Scan initialization error:", error);
}

const getRenderReport = () => {
try {
log.info("Attempting to get render reports...");

const report = component ? getReport(component) : getReport();

if (report instanceof Map) {
const allComponentReport = getSortedAndFilteredReports(report);
log.table(allComponentReport);
} else if (report !== null && component) {
const singleComponentReport = getSingleReport(report, component);
setWindowObjectForPlaywright(component, singleComponentReport);
log.info("Single Report:,", singleComponentReport);
}
} catch (error) {
log.error("Performance Monitoring Error:", error);
}
};
// If interval is provided, get reports at regular intervals
if (interval) {
const performanceInterval = setInterval(getRenderReport, interval);

return () => clearInterval(performanceInterval);
} else {
getRenderReport();
}
}
}, [component, interval]);
};
66 changes: 66 additions & 0 deletions apps/nextjs/tests-e2e/tests/chat-performance.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { expect, test, type Page } from "@playwright/test";

import { TEST_BASE_URL } from "../config/config";
import { prepareUser } from "../helpers/auth";
import { cspSafeWaitForFunction } from "../helpers/auth/clerkHelpers";
import { bypassVercelProtection } from "../helpers/vercel";
import { isFinished } from "./aila-chat/helpers";

declare global {
interface Window {
reactScanLessonPlanDisplay: { renderCount: number };
NEXT_PUBLIC_ENABLE_RENDER_SCAN?: string;
}
}

test.describe("Component renders during lesson chat", () => {
test.beforeEach(async ({ page }) => {
await page.addInitScript(() => {
window.NEXT_PUBLIC_ENABLE_RENDER_SCAN = "true";
});
await test.step("Setup", async () => {
await bypassVercelProtection(page);
const login = await prepareUser(page, "typical");

await page.goto(`${TEST_BASE_URL}/aila/${login.chatId}`);
await isFinished(page);
});
});

// this is disabled because react scan is not currently working in preview deplyments.
test.skip("There are no unnecessary rerenders across left and right side of chat", async ({
page,
}) => {
await test.step("Chat box keyboard input does not create rerenders in lesson plan", async () => {
await verifyChatInputRenders(page);
});
});

async function verifyChatInputRenders(page: Page) {
await page.waitForTimeout(10000);

await cspSafeWaitForFunction(
page,
() =>
window.reactScanLessonPlanDisplay &&
typeof window.reactScanLessonPlanDisplay.renderCount === "number",
);

const textbox = page.getByTestId("chat-input");
const message = "Create a KS1 lesson on the end of Roman Britain";
const initialRenderAmount: number = await page.evaluate(
JBR90 marked this conversation as resolved.
Show resolved Hide resolved
() => window.reactScanLessonPlanDisplay.renderCount,
);
await page.keyboard.type(message);
await expect(textbox).toContainText(message);
await page.waitForTimeout(10000);
const finalRenderAmount: number = await page.evaluate(
() => window.reactScanLessonPlanDisplay.renderCount,
);

expect(initialRenderAmount).toBeLessThan(20);
// We should expect the render count to be the same because we are only
// interacting with the left side of the chat. This should be fixed and updated
expect(finalRenderAmount).toBeLessThan(400);
}
});
5 changes: 2 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,9 @@
"db-push": "turbo db-push",
"db-push-force": "turbo db-push-force",
"db-seed": "turbo db-seed",
"deps": "NODE_OPTIONS=\"--max-old-space-size=8192\" dependency-cruiser --progress --max-depth 10 --output-type err --config .dependency-cruiser.cjs apps packages",
"deps:graph": "NODE_OPTIONS=\"--max-old-space-size=8192\" dependency-cruiser --progress --max-depth 10 --config .dependency-cruiser.cjs --output-type dot apps packages | dot -T svg > dependency-graph.svg",
"dev": "FORCE_COLOR=1 turbo dev --parallel --ui=stream --log-prefix=none --filter=!@oakai/db",
"dev:react-scan": "NEXT_PUBLIC_ENABLE_RENDER_SCAN=true pnpm dev",
"dev:react-scan-monitor-cloud": "NEXT_PUBLIC_RENDER_MONITOR=true pnpm dev",
"doppler:pull:dev": "doppler secrets download --config dev --no-file --format env > .env",
"doppler:pull:stg": "doppler secrets download --config stg --no-file --format env > .env",
"doppler:run:stg": "doppler run -c stg --silent",
Expand Down Expand Up @@ -57,7 +57,6 @@
"@semantic-release/git": "^10.0.1",
"@types/jest": "^29.5.14",
"autoprefixer": "^10.4.16",
"dependency-cruiser": "^16.7.0",
"husky": "^8.0.3",
"lint-staged": "^15.2.0",
"next": "14.2.18",
Expand Down
13 changes: 10 additions & 3 deletions packages/logger/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,14 +31,14 @@ type ChildKey =
| "aila:stream"
| "aila:rag"
| "aila:testing"
| "aila:chat"
| "aila:experimental-patches"
| "analytics"
| "app"
| "auth"
| "chat"
| "cloudinary"
| "consent"
| "cron"
| "db"
| "demo"
| "exports"
Expand All @@ -64,8 +64,8 @@ type ChildKey =
| "transcripts"
| "trpc"
| "ui"
| "webhooks"
| "cron";
| "ui:performance"
| "webhooks";

const errorLogger =
typeof window === "undefined"
Expand All @@ -88,10 +88,17 @@ const errorLogger =
*/
export function aiLogger(childKey: ChildKey) {
const debugLogger = debugBase.extend(childKey);

const tableLogger = (tabularData: unknown[], columns?: string[]) => {
if (typeof console !== "undefined" && console.table) {
console.table(tabularData, columns);
}
};
return {
info: debugLogger,
warn: debugLogger,
error: errorLogger.bind(structuredLogger),
table: tableLogger,
};
}

Expand Down
Loading
Loading