Skip to content

fix: patch paper so bundlers stop resolving its undeclared jsdom require - #2024

Merged
OliverDudgeon merged 3 commits into
devfrom
fix/2020-paper-patch
Aug 27, 2026
Merged

fix: patch paper so bundlers stop resolving its undeclared jsdom require#2024
OliverDudgeon merged 3 commits into
devfrom
fix/2020-paper-patch

Conversation

@OliverDudgeon

@OliverDudgeon OliverDudgeon commented Aug 27, 2026

Copy link
Copy Markdown
Collaborator

Closes #2020. Targets dev and lands before #2015 — the React Compiler / Turbopack migration should be rebased on top of this once it merges.

Why this goes first

#2015 currently has to carry a turbopack.resolveAlias workaround pointing a real module specifier at a hand-written no-op stub, and #2020 records that as unacceptable as a permanent state. Landing the real fix first means #2015 never has to introduce the workaround at all — it just drops those lines from its own diff.

Root cause

paper requires a package it does not depend on. Its published manifest declares no dependencies, optionalDependencies or peerDependencies at all — jsdom and canvas are devDependencies, which consumers never install. dist/node/canvas.js requires both anyway; the canvas require sits in a try/catch, the jsdom one does not, so bundlers resolve jsdom/lib/jsdom/living/generated/utils statically and nothing in this tree provides it.

ketcher-core depends on paper, which is how it reaches us. The code never runs — it is guarded on canvas, which is not installed — but the specifier is still resolved at build time.

pnpm is behaving correctly. Flat hoisting would let an unrelated jsdom anywhere in the tree silently satisfy the unqualified require; pnpm's isolated node_modules cannot, so the upstream bug is unmaskable.

On dev today the bug is latent, because webpack never pulls the sketcher into the server compilation. It becomes fatal under Turbopack, which keeps it in the SSR graph — every failing import trace is an SSR one, never the browser graph.

The fix

patches/paper@0.12.18.patch, one changed line:

-        idlUtils = require('jsdom/lib/jsdom/living/generated/utils');
+        idlUtils = require(/* webpackIgnore: true */ 'jsdom/lib/jsdom/living/generated/utils');

webpackIgnore is honoured by both webpack and Turbopack on require() expressions (Turbopack API reference), so the specifier is left for Node to resolve at runtime rather than bundled. Runtime behaviour is unchanged: the line is only reached once require('canvas') has succeeded, and installing canvas is what brings jsdom into the tree.

Scoping is what makes this better than the alias #2015 carries. resolveAlias redirects the specifier for every importer in the graph, so a genuine jsdom consumer would silently receive a no-op stub. The patch touches only paper.

If a ketcher bump moves paper off 0.12.18, pnpm fails the install with ERR_PNPM_UNUSED_PATCH rather than silently reverting to a broken build. CI installs with pnpm ci (clean + install --frozen-lockfile), so that guard is genuinely exercised.

Considered and rejected

  • App Router, and updating ketcher — both tested and disproven in Complete the Turbopack migration and remove the paper.js/jsdom resolveAlias workaround #2020. 'use client' marks a boundary; it does not remove a module from the server graph.
  • Declare jsdom as a devDependency, or repair paper's manifest via pnpm.packageExtensions — both work, and both install a large tree and pin it to <=27 (the target is a jsdom internal, dropped in 28 with no semver obligation) to satisfy a require that never executes.

Also here

  • src/stubs/jsdom-utils.stub.js deleted. It is already dead code on dev: 84a9309 removed the alias that referenced it, and nothing has pointed at it since. The patch means nothing needs it again.
  • patches/ketcher-standalone.patch removed. In the tree since 795e23c but wired to nothing — no patchedDependencies entry, no patch-package — and obsolete anyway: ketcher-standalone@3.12.0 already ships the types export condition it added.
  • The nextjs-agent-rules block in AGENTS.md, which next dev rewrites on every run, committed separately so the tree stops showing it as a pending change.

Verification

Verified twice — once on the Turbopack base this was originally written against, and again after rebasing onto dev, where the build is still webpack:

  • Turbopack (on feat: compile the application with the React Compiler #2015's base): reproduced the failure first with no patch, then pnpm build passes with the patch and no alias, emitting no bundler warning about the require.
  • webpack (on dev, this branch): pnpm build passes with no jsdom/paper warnings and no critical dependency warning.
  • pnpm tsc and pnpm lint clean on both.
  • pnpm test 808 passed on both bases.
  • pnpm install --frozen-lockfile clean, and the file in the patch_hash=… store directory carries the patched line.
  • The Dockerfile already does COPY patches ./patches/ alongside pnpm-workspace.yaml, so the image build picks the patch up.

Follow-up, not in this PR

  • No test covers the sketcher. Sketcher.tsx / SMILESInput.tsx have no story and no acceptance journey, so nothing would catch a runtime regression in the one component this change concerns. Tracked as the main pre-merge question in Complete the Turbopack migration and remove the paper.js/jsdom resolveAlias workaround #2020.
  • An orphan-patch guard. ketcher-standalone.patch sat dead in the tree for months and nothing reported it. A contract test asserting every file in patches/ has a patchedDependencies entry would prevent a repeat.
  • docs/testing.md is stale — two references to "the webpack production build" that feat: compile the application with the React Compiler #2015 makes false.
  • Upstream: paper.js should declare jsdom as an optionalDependency next to canvas, or not require it statically. Reporting that is what gives this patch an exit route.

🤖 Generated with Claude Code

OliverDudgeon and others added 3 commits August 27, 2026 22:39
`next dev` writes this block into AGENTS.md on every run
(node_modules/next/dist/server/lib/generate-agent-files.js), so leaving it
out of the tree only re-creates the same uncommitted change each time.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The file has been in the tree since 795e23c but has never been wired to
anything: there is no `patchedDependencies` entry for it and this repository
does not use patch-package, so it has not been applied.

It is also no longer needed. It added a `types` condition to
ketcher-standalone's `exports["."]` map, and ketcher-standalone@3.12.0 — the
version installed here — already ships that condition upstream.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
paper's published manifest declares no dependencies, optionalDependencies or
peerDependencies at all — jsdom and canvas are devDependencies, which
consumers never install. `dist/node/canvas.js` requires both anyway. The
canvas require sits in a try/catch; the jsdom one does not, so bundlers
resolve `jsdom/lib/jsdom/living/generated/utils` statically and nothing in
this tree provides it.

pnpm is behaving correctly. Flat hoisting would let any unrelated jsdom in
the tree silently satisfy the unqualified require; pnpm's isolated
node_modules cannot, so the upstream bug is unmaskable.

The bug is latent under the current webpack build, because the sketcher never
reaches the server compilation. It becomes fatal under Turbopack, which keeps
it in the SSR graph — every failing import trace is an SSR one, never the
browser graph. Landing the patch first means the Turbopack migration (#2015)
never has to introduce the `turbopack.resolveAlias` workaround it currently
carries.

Patch the require with a `webpackIgnore: true` magic comment, which both
webpack and Turbopack honour on `require()` expressions, so the specifier is
left for Node to resolve at runtime instead of being bundled. Runtime
behaviour is unchanged: the line is only reached once `require('canvas')` has
succeeded, and installing canvas is what brings jsdom into the tree.

Scoping is what makes this better than the alias. `resolveAlias` redirects
the specifier for every importer in the graph, so a genuine jsdom consumer
would silently receive a no-op stub. The patch touches only paper.

Also delete `src/stubs/jsdom-utils.stub.js`, which the alias used to point
at. It has been dead code here since 84a9309 removed the alias that
referenced it, and the patch means nothing needs it again.

Considered and rejected: declaring jsdom as a devDependency, and repairing
paper's manifest via pnpm.packageExtensions. Both work, and both install a
large tree and pin it to <=27 — the target is a jsdom internal, removed in
28 with no semver obligation — to satisfy a require that never executes.

If a ketcher bump moves paper off 0.12.18, pnpm fails the install with
ERR_PNPM_UNUSED_PATCH rather than silently reverting to a broken build.

Worth fixing upstream regardless: paper should declare jsdom as an
optionalDependency next to canvas, or not require it statically.

Closes #2020

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@OliverDudgeon
OliverDudgeon changed the base branch from t3code/enable-react-compiler-eslint to dev August 27, 2026 21:42
@OliverDudgeon OliverDudgeon changed the title fix: remove the paper.js/jsdom resolveAlias workaround via a pnpm patch fix: patch paper so bundlers stop resolving its undeclared jsdom require Aug 27, 2026
@OliverDudgeon
OliverDudgeon marked this pull request as ready for review August 27, 2026 21:44
@OliverDudgeon
OliverDudgeon merged commit 97c3d95 into dev Aug 27, 2026
4 checks passed
OliverDudgeon added a commit that referenced this pull request Aug 28, 2026
The paper patch in #2024 removed the last thing keeping the sketcher out of
a Turbopack server graph, so `--webpack` can come off `dev`, `dev:debug` and
`build`. `analyze*` keeps it, since @next/bundle-analyzer is webpack-only.

Switching bundlers exposed a second defect, unrelated to paper. The Pages
Router leaves server-side node_modules unbundled, so Turbopack ends up with two
copies of @emotion/react: the one Node loads at runtime for an externalised
package, and the one bundled into the SSR graph. @mui/material-nextjs is
externalised, so the cache AppCacheProvider writes lands in a different emotion
context than the bundled MUI components read; they fall back to emotion's
default "css" cache instead of the "mui" one, and every server-rendered class
name mismatched on hydration, on every page, in development and production
alike. Webpack never split them because it bundles the server dependencies
itself, so bundlePagesRouterDependencies restores what it was doing — and what
the App Router does by default. SSR emits only "mui" keys again and the
hydrated DOM matches the webpack production baseline exactly.

Bundling rather than transpiling @mui/material-nextjs alone, because the
externalisation boundary is what splits emotion, and @mui/lab, @rjsf/mui,
material-ui-popup-state and @squonk/mui-theme sit on the far side of it too: a
page rendering a @mui/lab timeline still emitted "css" class names with only
that package transpiled.

This is open upstream as vercel/next.js#82607 and vercel/next.js#91411, with no
fix released and no workaround recorded. Upgrading @mui/material-nextjs to
9.4.0, externalising emotion with serverExternalPackages, and aliasing emotion
onto its collapsed cjs.mjs build with turbopack.resolveAlias were each tested
and each left the mismatch in place.

Closes #2020

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
OliverDudgeon added a commit that referenced this pull request Aug 28, 2026
The paper patch in #2024 removed the last thing keeping the sketcher out of
a Turbopack server graph, so `--webpack` can come off `dev`, `dev:debug` and
`build`. `analyze*` keeps it, since @next/bundle-analyzer is webpack-only.

Switching bundlers exposed a second defect, unrelated to paper. The Pages
Router leaves server-side node_modules unbundled, so Turbopack ends up with two
copies of @emotion/react: the one Node loads at runtime for an externalised
package, and the one bundled into the SSR graph. @mui/material-nextjs is
externalised, so the cache AppCacheProvider writes lands in a different emotion
context than the bundled MUI components read; they fall back to emotion's
default "css" cache instead of the "mui" one, and every server-rendered class
name mismatched on hydration, on every page, in development and production
alike. Webpack never split them because it bundles the server dependencies
itself, so bundlePagesRouterDependencies restores what it was doing — and what
the App Router does by default. SSR emits only "mui" keys again and the
hydrated DOM matches the webpack production baseline exactly.

Bundling rather than transpiling @mui/material-nextjs alone, because the
externalisation boundary is what splits emotion, and @mui/lab, @rjsf/mui,
material-ui-popup-state and @squonk/mui-theme sit on the far side of it too: a
page rendering a @mui/lab timeline still emitted "css" class names with only
that package transpiled.

This is open upstream as vercel/next.js#82607 and vercel/next.js#91411, with no
fix released and no workaround recorded. Upgrading @mui/material-nextjs to
9.4.0, externalising emotion with serverExternalPackages, and aliasing emotion
onto its collapsed cjs.mjs build with turbopack.resolveAlias were each tested
and each left the mismatch in place.

Closes #2020

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
im-semantic-release Bot pushed a commit that referenced this pull request Aug 28, 2026
# [7.0.0-dev.19](7.0.0-dev.18...7.0.0-dev.19) (2026-08-28)

### Bug Fixes

* build and serve the app with Turbopack ([ae2539d](ae2539d)), closes [#2024](#2024) [vercel/next.js#82607](vercel/next.js#82607) [vercel/next.js#91411](vercel/next.js#91411) [#2020](#2020)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant