fix(perf): actually produce a React profiling build
DevTools still reported "Profiling not supported" because the build was never a profiling one, despite three settings appearing to enable it. Next applies the React swap through a webpack alias — getReactProfilingInProduction, mapping 'react-dom/client$' to 'react-dom/profiling' — that Turbopack never sees. A Turbopack resolveAlias does not catch it either: the vendored entry requires ./cjs/react-dom-client.production.js relatively, not through a bare specifier. The Dockerfile now performs that same swap on the vendored entry when HOMARR_PROFILING=true, guarded so it fails the build loudly if React's file layout changes rather than silently producing a normal build again. Added `pnpm docker:profiling`, which builds and then verifies, because `docker build . -t homarr:performance` alone yields a non-profiling image and that is almost certainly what was being run. verify-react-profiling.mts checks the bundle rather than the runtime: 2 client chunks carry React's profiling renderer with the flag, 0 without. It replaces a Playwright probe that could not tell the two apart — React sends a reduced inject payload to a synthetic devtools hook, byte-identical for both builds, so that probe reported a false negative for profiling and production alike. The bundle marker discriminates; confirm the Profiler tab itself with the real extension.
This commit is contained in:
5 files changed
+109
-6
No files matched your search
+15
@@ -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 \
|
||||
|
||||
@@ -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 <image>`: 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 |
|
||||
|
||||
@@ -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: {
|
||||
|
||||
+2
-1
@@ -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:"
|
||||
|
||||
@@ -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 <docker-image>");
|
||||
|
||||
/** 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;
|
||||
Reference in new issue
Block a user