diff --git a/CHANGELOG.md b/CHANGELOG.md index b30fcef3..12a4c582 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## Unreleased + +- Fix: ESM consumers under `moduleResolution: node16`/`nodenext` no longer resolve the CommonJS type declaration. The `exports` map carried a single condition-independent `"types"` key, so the `import` entry was typed by `build/cjs/cjs.d.ts` — a CJS declaration, since the package has no `"type": "module"` — which made a default import resolve to the module namespace and `` fail with TS2604/TS2786. Each condition now declares its own `types`, pointing the ESM entry at the `cjs.d.mts` tsup already emits. Regression in 4.6.0; types-only, no runtime change. (closes [#816](https://github.com/react-grid-layout/react-draggable/issues/816)) +- Internal: the build contract check now asserts that `exports` types each condition separately, and type-checks a real ESM consumer against the built package under `nodenext` — resolving through `exports` rather than a `paths` mapping, which is what let this regression through. + ### 4.7.1 (Jul 28, 2026) - Fix: props are no longer marked required under React 18 TypeScript. Regression in 4.6.0. The `propTypes` static was a required member of the public type; React 18's JSX `LibraryManagedAttributes` consults `propTypes` when it is required, and doing so cancels the optionality `defaultProps` normally grants. React 19 ignores `propTypes`, which is why the v19-only type check missed it. `make lint` now also type-checks the public surface against `@types/react@18`. ([#809](https://github.com/react-grid-layout/react-draggable/pull/809), closes [#807](https://github.com/react-grid-layout/react-draggable/issues/807)) diff --git a/package.json b/package.json index 58d215a2..81b7f2c3 100644 --- a/package.json +++ b/package.json @@ -7,9 +7,14 @@ "unpkg": "build/web/react-draggable.min.js", "exports": { ".": { - "types": "./build/cjs/cjs.d.ts", - "import": "./build/cjs/cjs.mjs", - "require": "./build/cjs/cjs.js" + "import": { + "types": "./build/cjs/cjs.d.mts", + "default": "./build/cjs/cjs.mjs" + }, + "require": { + "types": "./build/cjs/cjs.d.ts", + "default": "./build/cjs/cjs.js" + } }, "./package.json": "./package.json" }, diff --git a/scripts/verify-build.cjs b/scripts/verify-build.cjs index ddd3494e..05aac5c3 100644 --- a/scripts/verify-build.cjs +++ b/scripts/verify-build.cjs @@ -3,15 +3,17 @@ // Post-build contract verification. // -// The published artifacts carry two historical public contracts that the build -// toolchain (tsup for CJS/ESM, webpack for UMD) must preserve. They are easy to -// break invisibly — a wrong export-interop shape or a missing global only shows -// up when a consumer installs the package. This script asserts both right after -// the build, so a regression fails CI instead of shipping. It is wired into the -// Makefile `build` target (runs after build-lib + build-web). +// The published artifacts carry public contracts that the build toolchain (tsup +// for CJS/ESM, webpack for UMD) must preserve. They are easy to break invisibly — +// a wrong export-interop shape, a missing global or a mistyped `exports` map only +// shows up when a consumer installs the package. This script asserts them right +// after the build, so a regression fails CI instead of shipping. It is wired into +// the Makefile `build` target (runs after build-lib + build-web). const assert = require('node:assert/strict'); +const {execFileSync} = require('node:child_process'); const fs = require('node:fs'); +const os = require('node:os'); const path = require('node:path'); const root = path.resolve(__dirname, '..'); @@ -105,6 +107,105 @@ assert.equal( `Wrap the read in \`typeof process !== 'undefined' && process.env.NAME\` (plain member access, no optional chaining).` ); + +// ── Contract 5: ESM consumers resolve ESM types (issue #816) ───────────────── +// `exports` must carry a `types` INSIDE each condition. A single condition- +// independent `"types"` key types both the `import` and the `require` entry with +// the same file, and since this package has no `"type": "module"` that file is a +// CommonJS declaration — so under `moduleResolution: node16/nodenext` a default +// import is typed as the whole `module.exports` object and `` stops +// being a valid JSX element (TS2604/TS2786), while attw reports the ESM entry as +// "masquerading as CJS". tsup already emits the correct `.d.mts`; only the map +// has to point at it. Bundler resolution ignores all of this, which is why the +// existing type checks (all `moduleResolution: node` against lib/ source) could +// not see the breakage. +const pkg = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8')); +const rootExport = pkg.exports['.']; + +assert.ok( + !('types' in rootExport), + 'exports["."] must not carry a condition-independent "types" key: it types the ESM entry with the CJS declaration. Put a "types" inside each condition instead.' +); +for (const [condition, expectedExt] of [ + ['import', '.d.mts'], + ['require', '.d.ts'], +]) { + const entry = rootExport[condition]; + assert.equal( + typeof entry, + 'object', + `exports["."].${condition} must be an object declaring its own "types" and "default"` + ); + assert.ok( + entry.types && entry.types.endsWith(expectedExt), + `exports["."].${condition}.types must be a ${expectedExt} declaration, got ${entry.types}` + ); + for (const field of ['types', 'default']) { + const target = path.join(root, entry[field]); + assert.ok( + fs.existsSync(target), + `exports["."].${condition}.${field} points at a missing file: ${entry[field]}` + ); + } +} + +// The map can be shaped correctly and still resolve to the wrong flavour of +// declaration, so type-check a real consumer against the built package: an ESM +// file importing 'react-draggable' by name under nodenext, resolved through the +// exports map via a node_modules symlink (NOT a tsconfig `paths` mapping, which +// would bypass `exports` and hide exactly the bug this guards). +const consumerDir = fs.mkdtempSync(path.join(os.tmpdir(), 'react-draggable-esm-types-')); +try { + const nodeModules = path.join(consumerDir, 'node_modules'); + fs.mkdirSync(nodeModules); + fs.symlinkSync(root, path.join(nodeModules, 'react-draggable'), 'dir'); + for (const dep of ['react', '@types']) { + fs.symlinkSync(path.join(root, 'node_modules', dep), path.join(nodeModules, dep), 'dir'); + } + fs.writeFileSync( + path.join(consumerDir, 'package.json'), + JSON.stringify({name: 'esm-consumer', type: 'module', version: '0.0.0'}) + ); + fs.writeFileSync( + path.join(consumerDir, 'tsconfig.json'), + JSON.stringify({ + compilerOptions: { + module: 'NodeNext', + moduleResolution: 'NodeNext', + target: 'ES2019', + jsx: 'react-jsx', + esModuleInterop: true, + strict: true, + noEmit: true, + lib: ['ES2019', 'DOM'], + types: ['react'], + }, + files: ['consumer.tsx'], + }) + ); + fs.writeFileSync( + path.join(consumerDir, 'consumer.tsx'), + `import Draggable, {DraggableCore} from 'react-draggable';\n` + + `export const Dragged = () =>
;\n` + + `export const Core = () =>
;\n` + ); + + try { + execFileSync( + process.execPath, + [path.join(root, 'node_modules', 'typescript', 'bin', 'tsc'), '-p', consumerDir], + {cwd: root, stdio: 'pipe'} + ); + } catch (err) { + const output = `${err.stdout || ''}${err.stderr || ''}`.trim(); + assert.fail( + `An ESM consumer cannot type-check against the built package under moduleResolution nodenext:\n${output}` + ); + } +} finally { + fs.rmSync(consumerDir, {recursive: true, force: true}); +} + console.log( - '✓ build contract OK: CJS module.exports===Draggable (+.default, .DraggableCore); UMD global ReactDraggable; no prop-types leak in .d.ts; no unguarded process' + '✓ build contract OK: CJS module.exports===Draggable (+.default, .DraggableCore); UMD global ReactDraggable; no prop-types leak in .d.ts; no unguarded process; ESM consumers get ESM types' );