Skip to content

Commit 59a57a7

Browse files
chargomeclaude
andauthored
feat(remix): Add Remix 3 export subpaths and optional peer deps (#24697)
Prepares `@sentry/remix` to hold Remix 3 support. No instrumentation yet, the v3 entries are placeholders. - All peer deps are now optional, and `remix` is added as one. They were required, so npm would install React and `@remix-run/node` into a Remix 3 app. - Three ESM-only subpaths: `./v3`, `./v3/client`, `./v3/node`. Remix 3's asset server rejects CommonJS, so a `require` condition could never load. - `engines` is unchanged, because `remix` already requires Node 24.3 or newer. - The `remix` peer is `>=3.0.0-rc.1 <4`, not `3.x`. Semver ranges exclude prereleases, so `3.x` would not match the release candidate the alpha targets. - `./v3/client` does not yet export `init` - `preserveModulesRoot: 'src'` is needed or the v3 entries land outside `build/esm/v3/`. A clean-build diff confirms it moves nothing else. Not addressed here: React still arrives transitively via `@sentry/react`, and CJS output ships unreferenced for the v3 entries. The e2e app is stacked on top in #24698. Fixes #24662 Co-authored-by: Claude Opus 5 <[email protected]>
1 parent 3a9647c commit 59a57a7

6 files changed

Lines changed: 134 additions & 2 deletions

File tree

‎packages/remix/package.json‎

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,13 +51,31 @@
5151
"import": {
5252
"default": "./build/import-hook.mjs"
5353
}
54+
},
55+
"./v3": {
56+
"import": {
57+
"types": "./build/types/v3/index.server.d.ts",
58+
"default": "./build/esm/v3/index.server.js"
59+
}
60+
},
61+
"./v3/client": {
62+
"import": {
63+
"types": "./build/types/v3/index.client.d.ts",
64+
"default": "./build/esm/v3/index.client.js"
65+
}
66+
},
67+
"./v3/node": {
68+
"import": {
69+
"default": "./build/v3-node.mjs"
70+
}
5471
}
5572
},
5673
"publishConfig": {
5774
"access": "public"
5875
},
5976
"dependencies": {
6077
"@remix-run/router": "^1.23.4",
78+
"@sentry/browser": "11.0.0",
6179
"@sentry/bundler-plugins": "11.0.0",
6280
"@sentry/conventions": "^0.24.0",
6381
"@sentry/core": "11.0.0",
@@ -81,7 +99,8 @@
8199
"@remix-run/node": "2.x",
82100
"@remix-run/react": "2.x",
83101
"@remix-run/server-runtime": "2.x",
84-
"react": "18.x"
102+
"react": "18.x",
103+
"remix": ">=3.0.0-rc.1 <4"
85104
},
86105
"scripts": {
87106
"build": "run-p build:transpile build:types",
@@ -115,9 +134,27 @@
115134
"{projectRoot}/build/cjs",
116135
"{projectRoot}/build/npm/esm",
117136
"{projectRoot}/build/npm/cjs",
118-
"{projectRoot}/build/import-hook.mjs"
137+
"{projectRoot}/build/import-hook.mjs",
138+
"{projectRoot}/build/v3-node.mjs"
119139
]
120140
}
121141
}
142+
},
143+
"peerDependenciesMeta": {
144+
"@remix-run/node": {
145+
"optional": true
146+
},
147+
"@remix-run/react": {
148+
"optional": true
149+
},
150+
"@remix-run/server-runtime": {
151+
"optional": true
152+
},
153+
"react": {
154+
"optional": true
155+
},
156+
"remix": {
157+
"optional": true
158+
}
122159
}
123160
}

‎packages/remix/rollup.npm.config.mjs‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,19 @@
1+
import { defineConfig } from 'rollup';
12
import { makeBaseNPMConfig, makeNPMConfigVariants, makeOrchestrionLoader } from '@sentry-internal/rollup-utils';
23

4+
// `external: /.*/` keeps the `remix` import a runtime resolution against the installed package.
5+
const v3NodeEntry = defineConfig({
6+
input: 'src/v3/node.mjs',
7+
external: /.*/,
8+
output: { format: 'esm', file: 'build/v3-node.mjs' },
9+
});
10+
311
// We rely on esbuild's defaults for JSX (`jsx: 'transform'` = classic runtime, no
412
// __self/__source attributes). React 19 prefers the new automatic transform, but switching
513
// to it would break React 17 support — so we intentionally stay on classic for now.
614
// https://legacy.reactjs.org/blog/2020/09/22/introducing-the-new-jsx-transform.html
715
export default [
16+
v3NodeEntry,
817
...makeNPMConfigVariants(
918
makeBaseNPMConfig({
1019
entrypoints: [
@@ -14,12 +23,17 @@ export default [
1423
'src/server/index.ts',
1524
'src/cloudflare/index.ts',
1625
'src/vite/index.ts',
26+
'src/v3/index.server.ts',
27+
'src/v3/index.client.ts',
1728
],
1829
packageSpecificConfig: {
1930
external: ['react-router', 'react-router-dom', 'react', 'react/jsx-runtime'],
2031
output: {
2132
// make it so Rollup calms down about the fact that we're combining default and named exports
2233
exports: 'named',
34+
// Without this, Rollup infers a common root from the whole module graph, which here reaches
35+
// into sibling workspace packages, and the v3 entries land outside `build/esm/v3/`.
36+
preserveModulesRoot: 'src',
2337
},
2438
},
2539
}),
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
// A named list rather than `export * from '@sentry/browser'`: Remix 3 has no bundler, so a wildcard
2+
// makes every export reachable and nothing can be tree shaken out of the served module graph.
3+
//
4+
// `init` is withheld until the Remix 3 browser SDK lands. Re-exporting `@sentry/browser`'s would give
5+
// an app History based tracing, which yields no navigation spans in Remix 3, so it would look
6+
// configured while reporting nothing.
7+
export { captureException, captureMessage } from '@sentry/browser';
8+
export type { BrowserOptions } from '@sentry/browser';
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
// Placeholder until the Remix 3 server instrumentation lands. `@sentry/node`'s `init` already emits
2+
// `http.server` spans, so this is useful on its own; route parameterisation and router error capture
3+
// are what is still missing.
4+
export * from '@sentry/node';

‎packages/remix/src/v3/node.mjs‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
// Replaces `--import remix/node-tsx` rather than adding a second flag. Sentry's module hook is
2+
// registered here once the server instrumentation lands, so for now nothing is instrumented.
3+
await import('remix/node-tsx');
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import * as fs from 'node:fs';
2+
import * as path from 'node:path';
3+
import * as semver from 'semver';
4+
import { describe, expect, it } from 'vitest';
5+
6+
import packageJson from '../package.json';
7+
8+
const packageRoot = path.resolve(__dirname, '..');
9+
10+
// `exports` mixes shapes (`"./package.json"` is a bare string), so it does not fit one index signature.
11+
const exportsMap = packageJson.exports as unknown as Record<string, { import?: Record<string, string> }>;
12+
13+
/**
14+
* A mismatch between the `exports` map and the build output is invisible: the build and lint both
15+
* pass, and the package is broken only for whoever installs it. Rollup infers its output layout from
16+
* the module graph, so an unrelated change can relocate these files.
17+
*/
18+
describe('Remix 3 exports', () => {
19+
const subpaths = ['./v3', './v3/client', './v3/node'];
20+
21+
it.each(subpaths)('%s is declared in the exports map', subpath => {
22+
expect(packageJson.exports).toHaveProperty([subpath]);
23+
});
24+
25+
it.each(subpaths)('%s only exposes an import condition', subpath => {
26+
// Remix 3's asset server rejects CommonJS outright, so a `require` condition could never load.
27+
expect(Object.keys(exportsMap[subpath] ?? {})).toEqual(['import']);
28+
});
29+
30+
it.each(subpaths)('%s points at files that exist in the build output', subpath => {
31+
const targets = Object.values(exportsMap[subpath]?.import ?? {});
32+
33+
expect(targets.length).toBeGreaterThan(0);
34+
35+
for (const target of targets) {
36+
expect(fs.existsSync(path.join(packageRoot, target)), `${subpath} -> ${target} is missing`).toBe(true);
37+
}
38+
});
39+
40+
describe('the Remix 3 peer range', () => {
41+
const range = packageJson.peerDependencies.remix;
42+
43+
// Asserted by behaviour, so the range can change after Remix 3 reaches GA without failing here.
44+
it('accepts release candidates', () => {
45+
// `3.x` does not match `3.0.0-rc.1`: semver ranges exclude prereleases unless they name one.
46+
expect(semver.satisfies('3.0.0-rc.1', range)).toBe(true);
47+
expect(semver.satisfies('3.0.0-rc.2', range)).toBe(true);
48+
});
49+
50+
it('accepts stable 3.x', () => {
51+
expect(semver.satisfies('3.0.0', range)).toBe(true);
52+
expect(semver.satisfies('3.4.2', range)).toBe(true);
53+
});
54+
55+
it('rejects the next major', () => {
56+
expect(semver.satisfies('4.0.0', range)).toBe(false);
57+
});
58+
});
59+
60+
it('marks every peer dependency optional', () => {
61+
// A Remix 3 app must not have the Remix 2 packages or React installed on its behalf.
62+
for (const name of Object.keys(packageJson.peerDependencies)) {
63+
expect(packageJson.peerDependenciesMeta, `${name} should be optional`).toHaveProperty([name, 'optional'], true);
64+
}
65+
});
66+
});

0 commit comments

Comments
 (0)