diff --git a/Dockerfile b/Dockerfile index 7181fafd5..d270a1110 100644 --- a/Dockerfile +++ b/Dockerfile @@ -45,6 +45,21 @@ ARG TARGETPLATFORM ARG HOMARR_PROFILING='false' ENV HOMARR_PROFILING=${HOMARR_PROFILING} +# React ships a separate profiling renderer; the production one omits the hooks +# DevTools needs, which is what makes it report "Profiling not supported". Next only +# swaps it via a webpack alias (create-compiler-aliases.js), so under Turbopack both +# `next build --profile` and `reactProductionProfiling` silently produce a normal +# build. Point the vendored client entry at the profiling bundle instead — the same +# swap webpack's alias performs, done where Turbopack cannot miss it. +RUN if [ "$HOMARR_PROFILING" = "true" ]; then \ + set -e; \ + entry=node_modules/next/dist/compiled/react-dom/client.js; \ + grep -q "react-dom-client.production.js" "$entry" || { echo "profiling patch: unexpected $entry"; exit 1; }; \ + sed -i "s#./cjs/react-dom-client.production.js#./cjs/react-dom-profiling.profiling.js#" "$entry"; \ + grep -q "react-dom-profiling.profiling.js" "$entry" || { echo "profiling patch failed"; exit 1; }; \ + echo "profiling patch applied to $entry"; \ + fi + RUN --mount=type=secret,id=TURBO_API,env=TURBO_API \ --mount=type=secret,id=TURBO_TEAM,env=TURBO_TEAM \ --mount=type=secret,id=TURBO_TOKEN,env=TURBO_TOKEN \ diff --git a/PHASE2-FINDINGS.md b/PHASE2-FINDINGS.md index c1062d0b9..2bb4b9031 100644 --- a/PHASE2-FINDINGS.md +++ b/PHASE2-FINDINGS.md @@ -178,6 +178,41 @@ Recording it here rather than quietly folding it into the total: the whole point harness is to stop plausible-sounding changes from being credited with wins they did not produce. +## Getting a build React DevTools will profile + +```bash +pnpm docker:profiling # builds homarr:performance and verifies it +``` + +`docker build . -t homarr:performance` on its own produces a **normal** build — the +profiling renderer is opt-in, so DevTools will keep reporting "Profiling not supported". + +Three separate things have to line up, and each fails silently on its own: + +1. `reactProductionProfiling` in `next.config.ts` is read **only by the webpack path**. +2. Under Turbopack the equivalent is `next build --profile`, which + `apps/nextjs/package.json` passes when `HOMARR_PROFILING=true`. +3. Turbo strips any env var not declared in the task's `env`, so `HOMARR_PROFILING` + had to be added to `turbo.json`. + +Even with all three, the build was still not a profiling one: Next applies the React +swap through a **webpack alias** (`getReactProfilingInProduction` → +`'react-dom/client$': 'react-dom/profiling'`) that Turbopack never sees, and a +Turbopack `resolveAlias` does not catch it either because the vendored entry requires +`./cjs/react-dom-client.production.js` relatively. The Dockerfile now performs that +same swap directly on the vendored entry, guarded so it fails loudly if React's +layout changes. + +Verify with `scripts/benchmarks/verify-react-profiling.mts `: 2 client chunks +carry the profiling renderer with the flag, 0 without. + +**A caveat worth stating**: this is verified at the *bundle* level, not by driving the +real DevTools extension. A Playwright probe that injects a synthetic +`__REACT_DEVTOOLS_GLOBAL_HOOK__` cannot settle it — React sends a reduced inject +payload to a stub hook that is byte-identical for profiling and production builds, so +that probe returns a false negative for both. Confirm the Profiler tab with the real +extension. + ## Measured but not pursued | lever | measured cost | why not pursued | diff --git a/apps/nextjs/next.config.ts b/apps/nextjs/next.config.ts index ba94c9c90..e485cdd5b 100644 --- a/apps/nextjs/next.config.ts +++ b/apps/nextjs/next.config.ts @@ -81,15 +81,16 @@ const nextConfig: NextConfig = { }, turbopack: { root: path.resolve(import.meta.dirname, "../.."), - // Development runs tasks and WebSocket as separate processes. These aliases - // keep their production-only instrumentation imports out of the dev graph. - resolveAlias: - process.env.NODE_ENV === "development" + resolveAlias: { + // Development runs tasks and WebSocket as separate processes. These aliases + // keep their production-only instrumentation imports out of the dev graph. + ...(process.env.NODE_ENV === "development" ? { "@homarr/tasks": path.resolve(import.meta.dirname, "src/instrumentation-noop.ts"), "@homarr/websocket": path.resolve(import.meta.dirname, "src/instrumentation-noop.ts"), } - : undefined, + : {}), + }, }, transpilePackages: ["@homarr/ui", "@homarr/notifications", "@homarr/modals", "@homarr/spotlight", "@homarr/widgets"], images: { diff --git a/package.json b/package.json index 25d23c1f0..cb85cdd19 100644 --- a/package.json +++ b/package.json @@ -45,7 +45,8 @@ "test:e2e": "cross-env NODE_ENV=development CI=true vitest e2e", "test:ui": "cross-env NODE_ENV=development CI=true vitest --exclude e2e --ui --coverage.enabled", "typecheck": "turbo typecheck", - "with-env": "dotenv -e .env --" + "with-env": "dotenv -e .env --", + "docker:profiling": "docker build --build-arg HOMARR_PROFILING=true -t homarr:performance . && node --experimental-strip-types scripts/benchmarks/verify-react-profiling.mts homarr:performance" }, "dependencies": { "jsonpath-plus": "catalog:" diff --git a/scripts/benchmarks/verify-react-profiling.mts b/scripts/benchmarks/verify-react-profiling.mts new file mode 100644 index 000000000..2d9d53898 --- /dev/null +++ b/scripts/benchmarks/verify-react-profiling.mts @@ -0,0 +1,51 @@ +/** + * Verifies a built image actually ships React's *profiling* renderer, which is what + * React DevTools needs before it will enable the Profiler tab instead of reporting + * "Profiling support requires either a development or profiling build of React". + * + * node --experimental-strip-types scripts/benchmarks/verify-react-profiling.mts homarr:performance + * + * Why a bundle check and not a runtime one: DevTools decides from the renderer object + * React passes to `__REACT_DEVTOOLS_GLOBAL_HOOK__.inject()`, but a synthetic hook makes + * React send a reduced payload that is byte-identical for profiling and production + * builds — so a Playwright probe cannot tell them apart and gives a false negative. + * The marker below can: it exists only in React's profiling bundle. + */ +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; + +const execFileAsync = promisify(execFile); + +const image = process.argv[2]; +if (!image) throw new Error("usage: verify-react-profiling.mts "); + +/** Present in react-dom-profiling.profiling.js, absent from react-dom-client.production.js. */ +const PROFILING_MARKER = "TracingMarker"; + +const run = async (script: string) => { + const { stdout } = await execFileAsync("docker", ["run", "--rm", "--entrypoint", "sh", image, "-c", script], { + maxBuffer: 16 * 1024 * 1024, + }); + return stdout.trim(); +}; + +const clientChunks = Number( + await run(`grep -rl "${PROFILING_MARKER}" /app/apps/nextjs/.next/static/chunks 2>/dev/null | wc -l`), +); +const vendoredEntry = await run( + `grep -o "react-dom-[a-z.]*\\.js" /app/node_modules/next/dist/compiled/react-dom/client.js 2>/dev/null | tail -1 || echo "(entry not present in runtime image)"`, +); +const sourceMaps = Number(await run(`find /app/apps/nextjs/.next/static -name "*.map" 2>/dev/null | wc -l`)); + +console.log(`image: ${image}`); +console.log(`client chunks with React profiling renderer: ${clientChunks}`); +console.log(`vendored react-dom client entry points at: ${vendoredEntry}`); +console.log(`browser source maps: ${sourceMaps}`); + +const ok = clientChunks > 0; +console.log( + ok + ? "\nPROFILING BUILD CONFIRMED — React's profiling renderer is in the client bundle" + : "\nNOT A PROFILING BUILD — rebuild with: docker build --build-arg HOMARR_PROFILING=true .", +); +if (!ok) process.exitCode = 1;