From 0d31566bdd2551aa4694232d1cb64efa4ec3a4da Mon Sep 17 00:00:00 2001 From: Josh Vlk Date: Sat, 19 Sep 2026 15:24:36 -0400 Subject: [PATCH 1/4] perf(homepage): opt leaf components into React Compiler Share a client-only React 19 annotation-mode compiler configuration between Vite and Vitest. Opt in the intro, install instructions and trusted-by components while keeping other modules and SSR uncompiled. Verify source annotations and actual production client/server output, and cover install prop changes and copy behavior. Record the 427-byte gzip JavaScript cost with unchanged requests, CSS and DOM. --- .../LandingPageInstallInstructions_.test.res | 24 +++ apps/docs/package.json | 6 +- .../__tests__/homepage-compiler.test.mjs | 197 ++++++++++++++++++ .../LandingPageInstallInstructions.res | 30 +-- .../LandingPageInstallInstructions.resi | 1 + apps/docs/src/components/LandingPageIntro.res | 48 +++-- .../docs/src/components/LandingPageIntro.resi | 2 +- .../src/components/LandingPageTrustedBy.res | 84 ++++---- .../src/components/LandingPageTrustedBy.resi | 2 +- apps/docs/vite-react-compiler.mjs | 18 ++ apps/docs/vite.config.mjs | 2 + apps/docs/vitest.config.mjs | 3 +- yarn.lock | 47 ++++- 13 files changed, 381 insertions(+), 83 deletions(-) create mode 100644 apps/docs/__tests__/LandingPageInstallInstructions_.test.res create mode 100644 apps/docs/scripts/__tests__/homepage-compiler.test.mjs create mode 100644 apps/docs/vite-react-compiler.mjs diff --git a/apps/docs/__tests__/LandingPageInstallInstructions_.test.res b/apps/docs/__tests__/LandingPageInstallInstructions_.test.res new file mode 100644 index 000000000..586db9134 --- /dev/null +++ b/apps/docs/__tests__/LandingPageInstallInstructions_.test.res @@ -0,0 +1,24 @@ +open Vitest + +test("install instructions update their class prop and keep copying available", async () => { + let screen = await render() + let root = switch document->WebAPI.Document.querySelector(".initial-layout") { + | Value(root) => root + | Null => failwith("expected the install instructions root") + } + await element(root)->toHaveAttribute("class", "w-full max-w-400 initial-layout") + + await screen->rerender() + + await element(root)->toHaveAttribute("class", "w-full max-w-400 updated-layout") + let template = await screen->getByLabelText("Copy npx create-rescript-app command") + await element(template)->toBeVisible + + await screen->rerender() + + await element(root)->toHaveAttribute("class", "w-full max-w-400 ") + let copy = await screen->getByLabelText("Copy npm install rescript command") + await copy->click + let feedback = await screen->getByText("Copied!") + await element(feedback)->toBeVisible +}) diff --git a/apps/docs/package.json b/apps/docs/package.json index 7ed76418c..9607334f8 100644 --- a/apps/docs/package.json +++ b/apps/docs/package.json @@ -16,7 +16,7 @@ "check:algolia-public-env": "node _scripts/LogAlgoliaEnvStatus.mjs", "build": "yarn build:res && yarn build:scripts && yarn check:algolia-public-env && yarn build:update-index && yarn build:vite && yarn build:generate-sitemap", "ci:format": "cd ../.. && oxfmt --check", - "ci:homepage-performance": "node scripts/homepage-performance.mjs", + "ci:homepage-performance": "node --test scripts/__tests__/homepage-compiler.test.mjs && node scripts/homepage-performance.mjs", "ci:test": "vitest --run --browser.headless", "ci:test:e2e": "cypress run --config-file cypress.homepage.config.mjs --browser chrome", "ci:test:scripts": "vitest run --config vitest.scripts.config.mjs", @@ -78,14 +78,17 @@ "vfile-matter": "^5.0.1" }, "devDependencies": { + "@babel/core": "^7.29.7", "@react-router/dev": "^8.3.1", "@responsive-image/core": "2.1.0", "@responsive-image/vite-plugin": "3.0.1", + "@rolldown/plugin-babel": "^0.2.4", "@tailwindcss/vite": "^4.3.0", "@types/react": "^19.2.14", "@vitejs/plugin-react": "^6.0.1", "@vitest/browser-playwright": "^5.0.0", "auto-image-converter": "^2.2.0", + "babel-plugin-react-compiler": "^1.0.0", "chokidar": "^4.0.3", "cypress": "^15.13.1", "cypress-real-events": "^1.15.1", @@ -96,6 +99,7 @@ "oxfmt": "^0.46.0", "playwright": "^1.63.0", "remark-cli": "^12.0.1", + "rolldown": "1.2.8", "search-insights": "^2.17.3", "tailwindcss": "^4", "to-vfile": "^8.0.0", diff --git a/apps/docs/scripts/__tests__/homepage-compiler.test.mjs b/apps/docs/scripts/__tests__/homepage-compiler.test.mjs new file mode 100644 index 000000000..374eae492 --- /dev/null +++ b/apps/docs/scripts/__tests__/homepage-compiler.test.mjs @@ -0,0 +1,197 @@ +import assert from "node:assert/strict"; +import { readFile, readdir } from "node:fs/promises"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; +import { parseSync, transformAsync, traverse, types } from "@babel/core"; +import { homepageCompilerOptions } from "../../vite-react-compiler.mjs"; + +const optedInComponents = [ + "LandingPageIntro", + "LandingPageInstallInstructions", + "LandingPageTrustedBy", +]; + +async function transformComponent(name) { + const filename = fileURLToPath( + new URL(`../../app/routes/${name}.jsx`, import.meta.url), + ); + const result = await transformAsync(await readFile(filename, "utf8"), { + filename, + ast: true, + code: false, + babelrc: false, + configFile: false, + parserOpts: { plugins: ["jsx"] }, + presets: homepageCompilerOptions().presets.map((preset) => preset.preset), + }); + assert.ok(result?.ast, `${name} must produce a Babel AST`); + return result.ast; +} + +function cacheBindings(ast) { + return ast.program.body.flatMap((node) => { + if ( + !types.isImportDeclaration(node) || + node.source.value !== "react/compiler-runtime" + ) { + return []; + } + return node.specifiers.flatMap((specifier) => + types.isImportSpecifier(specifier) && + types.isIdentifier(specifier.imported, { name: "c" }) + ? [specifier.local.name] + : [], + ); + }); +} + +function cachedComponentNames(ast) { + const bindings = cacheBindings(ast); + const names = new Set(); + traverse(ast, { + CallExpression(path) { + if ( + types.isIdentifier(path.node.callee) && + bindings.includes(path.node.callee.name) + ) { + const component = path.getFunctionParent(); + assert.ok(component?.isFunctionDeclaration()); + assert.ok(component.node.id); + names.add(component.node.id.name); + } + }, + }); + return [...names].sort(); +} + +function isMemoCacheSentinel(node) { + return ( + types.isStringLiteral(node, { value: "react.memo_cache_sentinel" }) || + (types.isTemplateLiteral(node) && + node.expressions.length === 0 && + node.quasis[0]?.value.cooked === "react.memo_cache_sentinel") + ); +} + +function memoizedFunctionCount(ast) { + const functions = new Set(); + traverse(ast, { + CallExpression(path) { + const { callee, arguments: args } = path.node; + if ( + types.isMemberExpression(callee, { computed: false }) && + types.isIdentifier(callee.object, { name: "Symbol" }) && + types.isIdentifier(callee.property, { name: "for" }) && + args.length === 1 && + isMemoCacheSentinel(args[0]) + ) { + const component = path.getFunctionParent(); + assert.ok(component, "memoization must belong to a component"); + functions.add(component.node); + } + }, + }); + return functions.size; +} + +for (const name of optedInComponents) { + test(`${name} opts in to React 19 compiler memoization`, async () => { + const ast = await transformComponent(name); + assert.deepEqual(cachedComponentNames(ast), [name]); + assert.equal(memoizedFunctionCount(ast), 1); + }); +} + +test("unannotated interactive homepage components remain uncompiled", async () => { + const ast = await transformComponent("LandingPageCopyButton"); + assert.deepEqual(cacheBindings(ast), []); + assert.deepEqual(cachedComponentNames(ast), []); + assert.equal(memoizedFunctionCount(ast), 0); +}); + +test("compiler file filtering includes only generated homepage application modules", () => { + const { include, exclude } = homepageCompilerOptions(); + const matches = (filename) => + include.test(filename) && + !exclude.some((pattern) => pattern.test(filename)); + for (const filename of [ + "/repo/apps/docs/app/routes/LandingPageIntro.jsx", + "/repo/apps/docs/app/routes/LandingPageTrustedBy.jsx?import", + "C:\\repo\\apps\\docs\\app\\routes\\LandingPageIntro.jsx", + ]) { + assert.equal(matches(filename), true, filename); + } + for (const filename of [ + "/repo/apps/docs/app/routes/LandingPageIntro.res", + "/repo/apps/docs/app/routes/LandingPageIntro.mjs", + "/repo/apps/docs/app/routes/LandingPageIntro.jsx.map", + "/repo/apps/docs/app/routes/TryRoute.jsx", + "/repo/apps/guide/app/routes/LandingPageIntro.jsx", + "/repo/node_modules/example/apps/docs/app/routes/LandingPageIntro.jsx", + "C:\\repo\\node_modules\\example\\apps\\docs\\app\\routes\\LandingPageIntro.jsx", + "\0rolldown/runtime.js", + ]) { + assert.equal(matches(filename), false, filename); + } +}); + +test("the shared preset preserves annotation mode and excludes server compilation", () => { + const [{ preset, rolldown }] = homepageCompilerOptions().presets; + assert.deepEqual(preset().plugins, [ + [ + "babel-plugin-react-compiler", + { compilationMode: "annotation", target: "19" }, + ], + ]); + assert.equal( + rolldown.filter.code.test('function Example() { "use memo"; }'), + true, + ); + assert.equal(rolldown.filter.code.test("function Example() {}"), false); + assert.equal( + rolldown.applyToEnvironmentHook({ config: { consumer: "client" } }), + true, + ); + assert.equal( + rolldown.applyToEnvironmentHook({ config: { consumer: "server" } }), + false, + ); + assert.deepEqual(rolldown.optimizeDeps.include, ["react/compiler-runtime"]); +}); + +test("the production homepage bundle contains all three compiled components", async () => { + const directory = new URL("../../build/client/assets/", import.meta.url); + const filenames = (await readdir(directory)).filter((filename) => + /^LandingPageRoute-[^/]+\.js$/.test(filename), + ); + assert.equal(filenames.length, 1, "the production homepage entry must exist"); + const contents = await readFile(new URL(filenames[0], directory), "utf8"); + const ast = parseSync(contents, { babelrc: false, configFile: false }); + assert.ok(ast); + assert.equal(memoizedFunctionCount(ast), optedInComponents.length); +}); + +test("the production server leaves the annotated components uncompiled", async () => { + const contents = await readFile( + new URL("../../build/server/index.js", import.meta.url), + "utf8", + ); + const ast = parseSync(contents, { babelrc: false, configFile: false }); + assert.ok(ast); + const annotatedComponents = []; + traverse(ast, { + FunctionDeclaration(path) { + if ( + optedInComponents.includes(path.node.id?.name) && + path.node.body.directives.some( + (directive) => directive.value.value === "use memo", + ) + ) { + annotatedComponents.push(path.node.id.name); + } + }, + }); + assert.deepEqual(annotatedComponents.sort(), [...optedInComponents].sort()); + assert.deepEqual(cacheBindings(ast), []); + assert.equal(memoizedFunctionCount(ast), 0); +}); diff --git a/apps/docs/src/components/LandingPageInstallInstructions.res b/apps/docs/src/components/LandingPageInstallInstructions.res index f3e4d0562..45bc0cf61 100644 --- a/apps/docs/src/components/LandingPageInstallInstructions.res +++ b/apps/docs/src/components/LandingPageInstallInstructions.res @@ -8,18 +8,20 @@ let copyBox = text => { } @react.component -let make = (~className="") => { -
-

{React.string("Quick Install")}

-
- {React.string( - "You can quickly add ReScript to your existing JavaScript codebase via npm / yarn:", - )} +let make = + @directive("'use memo'") + (~className="") => { +
+

{React.string("Quick Install")}

+
+ {React.string( + "You can quickly add ReScript to your existing JavaScript codebase via npm / yarn:", + )} +
+ {copyBox("npm install rescript")} +
+ {React.string("Or generate a new project from the official template with npx:")} +
+ {copyBox("npx create-rescript-app")}
- {copyBox("npm install rescript")} -
- {React.string("Or generate a new project from the official template with npx:")} -
- {copyBox("npx create-rescript-app")} -
-} + } diff --git a/apps/docs/src/components/LandingPageInstallInstructions.resi b/apps/docs/src/components/LandingPageInstallInstructions.resi index 193c81cf3..92fce9cea 100644 --- a/apps/docs/src/components/LandingPageInstallInstructions.resi +++ b/apps/docs/src/components/LandingPageInstallInstructions.resi @@ -1,2 +1,3 @@ +/** Opt in to React Compiler for the homepage browser build. */ @react.component let make: (~className: string=?) => React.element diff --git a/apps/docs/src/components/LandingPageIntro.res b/apps/docs/src/components/LandingPageIntro.res index 17e368c4d..01767bd75 100644 --- a/apps/docs/src/components/LandingPageIntro.res +++ b/apps/docs/src/components/LandingPageIntro.res @@ -1,27 +1,29 @@ @react.component -let make = () => { -
-
-

- {React.string("JavaScript Made Simple for Humans and AI")} -

-

- {React.string(`Types > Vibes`)} -

-

- {React.string(`ReScript is a strongly typed language that compiles to clean, +let make = + @directive("'use memo'") + () => { +

+
+

+ {React.string("JavaScript Made Simple for Humans and AI")} +

+

+ {React.string(`Types > Vibes`)} +

+

+ {React.string(`ReScript is a strongly typed language that compiles to clean, efficient JavaScript that humans and AI tools can read and understand.`)} -

-

- {React.string(`Its fast compiler and static type system keep feedback loops tight, +

+

+ {React.string(`Its fast compiler and static type system keep feedback loops tight, so you can move quickly with AI assistance while maintaining confidence as your codebase grows.`)} -

- - - -
-
-} +

+ + + +
+
+ } diff --git a/apps/docs/src/components/LandingPageIntro.resi b/apps/docs/src/components/LandingPageIntro.resi index 26c2771b5..ba814dcb8 100644 --- a/apps/docs/src/components/LandingPageIntro.resi +++ b/apps/docs/src/components/LandingPageIntro.resi @@ -1,3 +1,3 @@ -/** Prefetch installation content only after hover or focus intent. */ +/** Prefetch on intent and opt in to React Compiler for the homepage browser build. */ @react.component let make: unit => React.element diff --git a/apps/docs/src/components/LandingPageTrustedBy.res b/apps/docs/src/components/LandingPageTrustedBy.res index 0ee3d8d91..3041a1134 100644 --- a/apps/docs/src/components/LandingPageTrustedBy.res +++ b/apps/docs/src/components/LandingPageTrustedBy.res @@ -1,43 +1,45 @@ @react.component -let make = () => { - let ourUsersSourcePath = "apps/docs/src/data/OurUsers.res" +let make = + @directive("'use memo'") + () => { + let ourUsersSourcePath = "apps/docs/src/data/OurUsers.res" -
-

- {React.string("Trusted by our users")} -

-
- {OurUsers.companies - ->Array.map(company => - switch company { - | Logo({name, path, url, width, height}) => - - nameInt.toString} - height={height->Int.toString} - loading=#lazy - /> - - } - ) - ->React.array} -
- - - - -
-} +
+

+ {React.string("Trusted by our users")} +

+
+ {OurUsers.companies + ->Array.map(company => + switch company { + | Logo({name, path, url, width, height}) => + + nameInt.toString} + height={height->Int.toString} + loading=#lazy + /> + + } + ) + ->React.array} +
+ + + + +
+ } diff --git a/apps/docs/src/components/LandingPageTrustedBy.resi b/apps/docs/src/components/LandingPageTrustedBy.resi index e0905fb23..6a5ddde2e 100644 --- a/apps/docs/src/components/LandingPageTrustedBy.resi +++ b/apps/docs/src/components/LandingPageTrustedBy.resi @@ -1,3 +1,3 @@ -/** Company logos retain their intrinsic aspect ratio at the existing display height. */ +/** Retain intrinsic logo ratios and opt in to React Compiler for the browser build. */ @react.component let make: unit => React.element diff --git a/apps/docs/vite-react-compiler.mjs b/apps/docs/vite-react-compiler.mjs new file mode 100644 index 000000000..bf6e3499a --- /dev/null +++ b/apps/docs/vite-react-compiler.mjs @@ -0,0 +1,18 @@ +import babel from "@rolldown/plugin-babel"; +import { reactCompilerPreset } from "@vitejs/plugin-react"; + +export function homepageCompilerOptions() { + return { + include: + /[/\\]apps[/\\]docs[/\\]app[/\\]routes[/\\]LandingPage[^/\\]*\.jsx(?:$|\?)/, + exclude: [/[/\\]node_modules[/\\]/, /^\0rolldown\/runtime\.js$/], + presets: [ + // Keep the preset's client-only guard and React 19 runtime optimization. + reactCompilerPreset({ compilationMode: "annotation", target: "19" }), + ], + }; +} + +export function homepageReactCompiler() { + return babel(homepageCompilerOptions()); +} diff --git a/apps/docs/vite.config.mjs b/apps/docs/vite.config.mjs index 9ad4544bb..5a2e93a36 100644 --- a/apps/docs/vite.config.mjs +++ b/apps/docs/vite.config.mjs @@ -6,6 +6,7 @@ import { defineConfig } from "vite"; import devtoolsJson from "vite-plugin-devtools-json"; import env from "vite-plugin-env-compatible"; import pageReload from "vite-plugin-page-reload"; +import { homepageReactCompiler } from "./vite-react-compiler.mjs"; const excludedFiles = ["lib/**", "**/*.res", "**/*.resi"]; @@ -24,6 +25,7 @@ export default defineConfig({ include: ["**/*.mjs"], exclude: excludedFiles, }), + homepageReactCompiler(), // this is to make it so babel doesn't break when trying to acess process.env in the client env({ prefix: "PUBLIC_" }), // adds dev scripts for browser devtools diff --git a/apps/docs/vitest.config.mjs b/apps/docs/vitest.config.mjs index 427123952..893c7048f 100644 --- a/apps/docs/vitest.config.mjs +++ b/apps/docs/vitest.config.mjs @@ -3,6 +3,7 @@ import { playwright } from "@vitest/browser-playwright"; import react from "@vitejs/plugin-react"; import tailwindcss from "@tailwindcss/vite"; import { responsiveImage } from "@responsive-image/vite-plugin"; +import { homepageReactCompiler } from "./vite-react-compiler.mjs"; const isUpdatingSnapshots = process.argv.some( (arg) => arg === "-u" || arg === "--update" || arg.startsWith("--update="), @@ -35,7 +36,7 @@ const setupDeps = [ export default defineConfig({ envDir: "../..", - plugins: [responsiveImage(), react(), tailwindcss()], + plugins: [responsiveImage(), react(), homepageReactCompiler(), tailwindcss()], optimizeDeps: { include: setupDeps, }, diff --git a/yarn.lock b/yarn.lock index ccb3767be..d9df19e2d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -224,6 +224,16 @@ __metadata: languageName: node linkType: hard +"@babel/types@npm:^7.26.0": + version: 7.29.8 + resolution: "@babel/types@npm:7.29.8" + dependencies: + "@babel/helper-string-parser": "npm:^7.29.7" + "@babel/helper-validator-identifier": "npm:^7.29.7" + checksum: 10c0/be7c279f0abf2a086c633e21b49c7ca80275d05283cc5a268b67a708c9914bd0c944f1422b3eb3cb37682a2af5d560abf520ccf9b01b53ecbfe6b71fbc3fdde6 + languageName: node + linkType: hard + "@blazediff/core@npm:1.10.0": version: 1.10.0 resolution: "@blazediff/core@npm:1.10.0" @@ -1928,6 +1938,7 @@ __metadata: version: 0.0.0-use.local resolution: "@rescript-lang/docs@workspace:apps/docs" dependencies: + "@babel/core": "npm:^7.29.7" "@cloudflare/pages-plugin-vercel-og": "npm:^0.1.2" "@docsearch/react": "npm:^4.6.2" "@headlessui/react": "npm:^2.2.9" @@ -1941,11 +1952,13 @@ __metadata: "@rescript/webapi": "npm:0.1.0-experimental-29db5f4" "@responsive-image/core": "npm:2.1.0" "@responsive-image/vite-plugin": "npm:3.0.1" + "@rolldown/plugin-babel": "npm:^0.2.4" "@tailwindcss/vite": "npm:^4.3.0" "@types/react": "npm:^19.2.14" "@vitejs/plugin-react": "npm:^6.0.1" "@vitest/browser-playwright": "npm:^5.0.0" auto-image-converter: "npm:^2.2.0" + babel-plugin-react-compiler: "npm:^1.0.0" chokidar: "npm:^4.0.3" cypress: "npm:^15.13.1" cypress-real-events: "npm:^1.15.1" @@ -1976,6 +1989,7 @@ __metadata: remark-gfm: "npm:^4.0.1" remark-validate-links: "npm:^13.1.0" rescript: "npm:^12.2.0" + rolldown: "npm:1.2.8" search-insights: "npm:^2.17.3" tailwindcss: "npm:^4" tinyglobby: "npm:^0.2.15" @@ -2293,6 +2307,28 @@ __metadata: languageName: node linkType: hard +"@rolldown/plugin-babel@npm:^0.2.4": + version: 0.2.4 + resolution: "@rolldown/plugin-babel@npm:0.2.4" + dependencies: + picomatch: "npm:^4.0.7" + peerDependencies: + "@babel/core": ^7.29.0 || ^8.0.0-rc.1 + "@babel/plugin-transform-runtime": ^7.29.0 || ^8.0.0-rc.1 + "@babel/runtime": ^7.27.0 || ^8.0.0-rc.1 + rolldown: ^1.0.0-rc.5 + vite: ^8.0.0 + peerDependenciesMeta: + "@babel/plugin-transform-runtime": + optional: true + "@babel/runtime": + optional: true + vite: + optional: true + checksum: 10c0/1283804c979f32c24adf3e80177ff3ebb7f8db749660235ed0a511cd4e1a3c129bea4209dee5f9ee67b1709a6efc65986349ca10ff2f5557c86623d1eebd7b42 + languageName: node + linkType: hard + "@rolldown/pluginutils@npm:1.0.0-rc.7": version: 1.0.0-rc.7 resolution: "@rolldown/pluginutils@npm:1.0.0-rc.7" @@ -3220,6 +3256,15 @@ __metadata: languageName: node linkType: hard +"babel-plugin-react-compiler@npm:^1.0.0": + version: 1.0.0 + resolution: "babel-plugin-react-compiler@npm:1.0.0" + dependencies: + "@babel/types": "npm:^7.26.0" + checksum: 10c0/9406267ada8d7dbdfe8906b40ecadb816a5f4cee2922bee23f7729293b369624ee135b5a9b0f263851c263c9787522ac5d97016c9a2b82d1668300e42b18aff8 + languageName: node + linkType: hard + "bail@npm:^2.0.0": version: 2.0.2 resolution: "bail@npm:2.0.2" @@ -9390,7 +9435,7 @@ __metadata: languageName: node linkType: hard -"rolldown@npm:~1.2.6": +"rolldown@npm:1.2.8, rolldown@npm:~1.2.6": version: 1.2.8 resolution: "rolldown@npm:1.2.8" dependencies: From f8141810fd681c9d5a77e18ec1be340546ede705 Mon Sep 17 00:00:00 2001 From: Josh Vlk Date: Sat, 19 Sep 2026 18:41:48 -0400 Subject: [PATCH 2/4] fix(build): compile moved homepage components Match generated homepage components in both app/routes and src/components. Update compiler fixtures to their actual locations and retain annotation-only, client-only compilation checks and exclusions. --- .../__tests__/homepage-compiler.test.mjs | 36 ++++++++++++------- apps/docs/vite-react-compiler.mjs | 2 +- 2 files changed, 25 insertions(+), 13 deletions(-) diff --git a/apps/docs/scripts/__tests__/homepage-compiler.test.mjs b/apps/docs/scripts/__tests__/homepage-compiler.test.mjs index 374eae492..fe0847fa1 100644 --- a/apps/docs/scripts/__tests__/homepage-compiler.test.mjs +++ b/apps/docs/scripts/__tests__/homepage-compiler.test.mjs @@ -6,14 +6,15 @@ import { parseSync, transformAsync, traverse, types } from "@babel/core"; import { homepageCompilerOptions } from "../../vite-react-compiler.mjs"; const optedInComponents = [ - "LandingPageIntro", - "LandingPageInstallInstructions", - "LandingPageTrustedBy", + { name: "LandingPageIntro", directory: "src/components" }, + { name: "LandingPageInstallInstructions", directory: "app/routes" }, + { name: "LandingPageTrustedBy", directory: "src/components" }, ]; +const optedInNames = optedInComponents.map(({ name }) => name); -async function transformComponent(name) { +async function transformComponent({ name, directory }) { const filename = fileURLToPath( - new URL(`../../app/routes/${name}.jsx`, import.meta.url), + new URL(`../../${directory}/${name}.jsx`, import.meta.url), ); const result = await transformAsync(await readFile(filename, "utf8"), { filename, @@ -94,16 +95,19 @@ function memoizedFunctionCount(ast) { return functions.size; } -for (const name of optedInComponents) { - test(`${name} opts in to React 19 compiler memoization`, async () => { - const ast = await transformComponent(name); - assert.deepEqual(cachedComponentNames(ast), [name]); +for (const component of optedInComponents) { + test(`${component.name} opts in to React 19 compiler memoization`, async () => { + const ast = await transformComponent(component); + assert.deepEqual(cachedComponentNames(ast), [component.name]); assert.equal(memoizedFunctionCount(ast), 1); }); } test("unannotated interactive homepage components remain uncompiled", async () => { - const ast = await transformComponent("LandingPageCopyButton"); + const ast = await transformComponent({ + name: "LandingPageCopyButton", + directory: "app/routes", + }); assert.deepEqual(cacheBindings(ast), []); assert.deepEqual(cachedComponentNames(ast), []); assert.equal(memoizedFunctionCount(ast), 0); @@ -118,6 +122,9 @@ test("compiler file filtering includes only generated homepage application modul "/repo/apps/docs/app/routes/LandingPageIntro.jsx", "/repo/apps/docs/app/routes/LandingPageTrustedBy.jsx?import", "C:\\repo\\apps\\docs\\app\\routes\\LandingPageIntro.jsx", + "/repo/apps/docs/src/components/LandingPageIntro.jsx", + "/repo/apps/docs/src/components/LandingPageTrustedBy.jsx?import", + "C:\\repo\\apps\\docs\\src\\components\\LandingPageIntro.jsx", ]) { assert.equal(matches(filename), true, filename); } @@ -129,6 +136,11 @@ test("compiler file filtering includes only generated homepage application modul "/repo/apps/guide/app/routes/LandingPageIntro.jsx", "/repo/node_modules/example/apps/docs/app/routes/LandingPageIntro.jsx", "C:\\repo\\node_modules\\example\\apps\\docs\\app\\routes\\LandingPageIntro.jsx", + "/repo/apps/docs/src/components/LandingPageIntro.res", + "/repo/apps/docs/src/components/LandingPageIntro.jsx.map", + "/repo/apps/docs/src/components/Search.jsx", + "/repo/apps/guide/src/components/LandingPageIntro.jsx", + "/repo/node_modules/example/apps/docs/src/components/LandingPageIntro.jsx", "\0rolldown/runtime.js", ]) { assert.equal(matches(filename), false, filename); @@ -182,7 +194,7 @@ test("the production server leaves the annotated components uncompiled", async ( traverse(ast, { FunctionDeclaration(path) { if ( - optedInComponents.includes(path.node.id?.name) && + optedInNames.includes(path.node.id?.name) && path.node.body.directives.some( (directive) => directive.value.value === "use memo", ) @@ -191,7 +203,7 @@ test("the production server leaves the annotated components uncompiled", async ( } }, }); - assert.deepEqual(annotatedComponents.sort(), [...optedInComponents].sort()); + assert.deepEqual(annotatedComponents.sort(), [...optedInNames].sort()); assert.deepEqual(cacheBindings(ast), []); assert.equal(memoizedFunctionCount(ast), 0); }); diff --git a/apps/docs/vite-react-compiler.mjs b/apps/docs/vite-react-compiler.mjs index bf6e3499a..a6b6eed32 100644 --- a/apps/docs/vite-react-compiler.mjs +++ b/apps/docs/vite-react-compiler.mjs @@ -4,7 +4,7 @@ import { reactCompilerPreset } from "@vitejs/plugin-react"; export function homepageCompilerOptions() { return { include: - /[/\\]apps[/\\]docs[/\\]app[/\\]routes[/\\]LandingPage[^/\\]*\.jsx(?:$|\?)/, + /[/\\]apps[/\\]docs[/\\](?:app[/\\]routes|src[/\\]components)[/\\]LandingPage[^/\\]*\.jsx(?:$|\?)/, exclude: [/[/\\]node_modules[/\\]/, /^\0rolldown\/runtime\.js$/], presets: [ // Keep the preset's client-only guard and React 19 runtime optimization. From ee7f53842ec1f83de2ad0713fe7d87a00c6d70c2 Mon Sep 17 00:00:00 2001 From: Josh Vlk Date: Sun, 20 Sep 2026 09:35:18 -0400 Subject: [PATCH 3/4] fix(build): update moved component test paths Point the React Compiler contract tests at the homepage interaction components in src/components. --- apps/docs/scripts/__tests__/homepage-compiler.test.mjs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/docs/scripts/__tests__/homepage-compiler.test.mjs b/apps/docs/scripts/__tests__/homepage-compiler.test.mjs index fe0847fa1..98a9b4220 100644 --- a/apps/docs/scripts/__tests__/homepage-compiler.test.mjs +++ b/apps/docs/scripts/__tests__/homepage-compiler.test.mjs @@ -7,7 +7,7 @@ import { homepageCompilerOptions } from "../../vite-react-compiler.mjs"; const optedInComponents = [ { name: "LandingPageIntro", directory: "src/components" }, - { name: "LandingPageInstallInstructions", directory: "app/routes" }, + { name: "LandingPageInstallInstructions", directory: "src/components" }, { name: "LandingPageTrustedBy", directory: "src/components" }, ]; const optedInNames = optedInComponents.map(({ name }) => name); @@ -106,7 +106,7 @@ for (const component of optedInComponents) { test("unannotated interactive homepage components remain uncompiled", async () => { const ast = await transformComponent({ name: "LandingPageCopyButton", - directory: "app/routes", + directory: "src/components", }); assert.deepEqual(cacheBindings(ast), []); assert.deepEqual(cachedComponentNames(ast), []); From 40abdfc62f30dd43f6152ca2015a92cb800c22ee Mon Sep 17 00:00:00 2001 From: Josh Vlk Date: Sun, 20 Sep 2026 12:37:03 -0400 Subject: [PATCH 4/4] perf(navbar): opt primary navigation into React Compiler Compile the shared primary navbar and its stable left and right subtrees in client builds. Extend the compiler contract to verify the source transform, production client artifact, and unchanged server boundary. --- .../__tests__/homepage-compiler.test.mjs | 67 +++++- apps/docs/src/components/NavbarPrimary.res | 211 +++++++++--------- apps/docs/src/components/NavbarPrimary.resi | 1 + apps/docs/vite-react-compiler.mjs | 2 +- 4 files changed, 170 insertions(+), 111 deletions(-) diff --git a/apps/docs/scripts/__tests__/homepage-compiler.test.mjs b/apps/docs/scripts/__tests__/homepage-compiler.test.mjs index 98a9b4220..20c19a1ba 100644 --- a/apps/docs/scripts/__tests__/homepage-compiler.test.mjs +++ b/apps/docs/scripts/__tests__/homepage-compiler.test.mjs @@ -6,11 +6,34 @@ import { parseSync, transformAsync, traverse, types } from "@babel/core"; import { homepageCompilerOptions } from "../../vite-react-compiler.mjs"; const optedInComponents = [ - { name: "LandingPageIntro", directory: "src/components" }, - { name: "LandingPageInstallInstructions", directory: "src/components" }, - { name: "LandingPageTrustedBy", directory: "src/components" }, + { + name: "LandingPageIntro", + directory: "src/components", + compiledNames: ["LandingPageIntro"], + }, + { + name: "LandingPageInstallInstructions", + directory: "src/components", + compiledNames: ["LandingPageInstallInstructions"], + }, + { + name: "LandingPageTrustedBy", + directory: "src/components", + compiledNames: ["LandingPageTrustedBy"], + }, + { + name: "NavbarPrimary", + directory: "src/components", + compiledNames: [ + "NavbarPrimary", + "NavbarPrimary$LeftContent", + "NavbarPrimary$RightContent", + ], + }, ]; -const optedInNames = optedInComponents.map(({ name }) => name); +const optedInNames = optedInComponents.flatMap( + ({ compiledNames }) => compiledNames, +); async function transformComponent({ name, directory }) { const filename = fileURLToPath( @@ -98,8 +121,8 @@ function memoizedFunctionCount(ast) { for (const component of optedInComponents) { test(`${component.name} opts in to React 19 compiler memoization`, async () => { const ast = await transformComponent(component); - assert.deepEqual(cachedComponentNames(ast), [component.name]); - assert.equal(memoizedFunctionCount(ast), 1); + assert.deepEqual(cachedComponentNames(ast), component.compiledNames); + assert.equal(memoizedFunctionCount(ast), component.compiledNames.length); }); } @@ -113,7 +136,7 @@ test("unannotated interactive homepage components remain uncompiled", async () = assert.equal(memoizedFunctionCount(ast), 0); }); -test("compiler file filtering includes only generated homepage application modules", () => { +test("compiler file filtering includes only opted-in generated application modules", () => { const { include, exclude } = homepageCompilerOptions(); const matches = (filename) => include.test(filename) && @@ -125,6 +148,9 @@ test("compiler file filtering includes only generated homepage application modul "/repo/apps/docs/src/components/LandingPageIntro.jsx", "/repo/apps/docs/src/components/LandingPageTrustedBy.jsx?import", "C:\\repo\\apps\\docs\\src\\components\\LandingPageIntro.jsx", + "/repo/apps/docs/src/components/NavbarPrimary.jsx", + "/repo/apps/docs/src/components/NavbarPrimary.jsx?import", + "C:\\repo\\apps\\docs\\src\\components\\NavbarPrimary.jsx", ]) { assert.equal(matches(filename), true, filename); } @@ -139,6 +165,7 @@ test("compiler file filtering includes only generated homepage application modul "/repo/apps/docs/src/components/LandingPageIntro.res", "/repo/apps/docs/src/components/LandingPageIntro.jsx.map", "/repo/apps/docs/src/components/Search.jsx", + "/repo/apps/docs/src/components/NavbarSecondary.jsx", "/repo/apps/guide/src/components/LandingPageIntro.jsx", "/repo/node_modules/example/apps/docs/src/components/LandingPageIntro.jsx", "\0rolldown/runtime.js", @@ -171,7 +198,7 @@ test("the shared preset preserves annotation mode and excludes server compilatio assert.deepEqual(rolldown.optimizeDeps.include, ["react/compiler-runtime"]); }); -test("the production homepage bundle contains all three compiled components", async () => { +test("the production homepage bundle contains its three compiled components", async () => { const directory = new URL("../../build/client/assets/", import.meta.url); const filenames = (await readdir(directory)).filter((filename) => /^LandingPageRoute-[^/]+\.js$/.test(filename), @@ -180,7 +207,29 @@ test("the production homepage bundle contains all three compiled components", as const contents = await readFile(new URL(filenames[0], directory), "utf8"); const ast = parseSync(contents, { babelrc: false, configFile: false }); assert.ok(ast); - assert.equal(memoizedFunctionCount(ast), optedInComponents.length); + assert.equal(memoizedFunctionCount(ast), 3); +}); + +test("the production client bundle contains the compiled primary navbar", async () => { + const directory = new URL("../../build/client/assets/", import.meta.url); + const assets = await Promise.all( + (await readdir(directory)) + .filter((filename) => filename.endsWith(".js")) + .map(async (filename) => ({ + filename, + contents: await readFile(new URL(filename, directory), "utf8"), + })), + ); + const matches = assets.filter(({ contents }) => + contents.includes("navbar-primary-left-content"), + ); + assert.equal(matches.length, 1, "the production primary navbar must exist"); + const ast = parseSync(matches[0].contents, { + babelrc: false, + configFile: false, + }); + assert.ok(ast); + assert.equal(memoizedFunctionCount(ast), 3); }); test("the production server leaves the annotated components uncompiled", async () => { diff --git a/apps/docs/src/components/NavbarPrimary.res b/apps/docs/src/components/NavbarPrimary.res index 5f586b3f5..a8e5d1c6a 100644 --- a/apps/docs/src/components/NavbarPrimary.res +++ b/apps/docs/src/components/NavbarPrimary.res @@ -9,119 +9,128 @@ let isActive = (~url, ~pathname: Path.t) => { module LeftContent = { @react.component - let make = () => { - let {pathname} = useLocation() -
- - ReScript Home - ReScript Home - - - {React.string("Docs")} - - - {React.string("Playground")} - - - {React.string("Blog")} - - { + let {pathname} = useLocation() +
- {React.string("Community")} - -
- } + + ReScript Home + ReScript Home + + + {React.string("Docs")} + + + {React.string("Playground")} + + + {React.string("Blog")} + + + {React.string("Community")} + +
+ } } module RightContent = { @react.component - let make = () => { - let iconClasses = "w-6 h-6 opacity-50 hover:opacity-100" - let linkClasses = "hidden md:block" - + } } @react.component -let make = () => { - let scrollDirection = Hooks.useScrollDirection(~topMargin=64, ~threshold=32) +let make = + @directive("'use memo'") + () => { + let scrollDirection = Hooks.useScrollDirection(~topMargin=64, ~threshold=32) - let navbarClasses = switch scrollDirection { - | Up(_) => "translate-y-0" - | Down(_) => "-translate-y-full md:translate-y-0" - } + let navbarClasses = switch scrollDirection { + | Up(_) => "translate-y-0" + | Down(_) => "-translate-y-full md:translate-y-0" + } - <> - + + + } diff --git a/apps/docs/src/components/NavbarPrimary.resi b/apps/docs/src/components/NavbarPrimary.resi index 1ca44ce26..6b2d7a887 100644 --- a/apps/docs/src/components/NavbarPrimary.resi +++ b/apps/docs/src/components/NavbarPrimary.resi @@ -1,2 +1,3 @@ +/** Shared primary navigation opts in to React Compiler for client builds. */ @react.component let make: unit => React.element diff --git a/apps/docs/vite-react-compiler.mjs b/apps/docs/vite-react-compiler.mjs index a6b6eed32..17b320e61 100644 --- a/apps/docs/vite-react-compiler.mjs +++ b/apps/docs/vite-react-compiler.mjs @@ -4,7 +4,7 @@ import { reactCompilerPreset } from "@vitejs/plugin-react"; export function homepageCompilerOptions() { return { include: - /[/\\]apps[/\\]docs[/\\](?:app[/\\]routes|src[/\\]components)[/\\]LandingPage[^/\\]*\.jsx(?:$|\?)/, + /[/\\]apps[/\\]docs[/\\](?:app[/\\]routes|src[/\\]components)[/\\](?:LandingPage[^/\\]*|NavbarPrimary)\.jsx(?:$|\?)/, exclude: [/[/\\]node_modules[/\\]/, /^\0rolldown\/runtime\.js$/], presets: [ // Keep the preset's client-only guard and React 19 runtime optimization.