Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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 `<Draggable>` 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))
Expand Down
11 changes: 8 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
115 changes: 108 additions & 7 deletions scripts/verify-build.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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, '..');
Expand Down Expand Up @@ -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 `<Draggable>` 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 = () => <Draggable><div /></Draggable>;\n` +
`export const Core = () => <DraggableCore><div /></DraggableCore>;\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'
);