diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 23625a0..660d921 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,6 +14,7 @@ jobs: strategy: matrix: node-version: [22, 24] + react-version: [18, 19] steps: - uses: actions/checkout@v7 - uses: actions/setup-node@v7 @@ -21,6 +22,8 @@ jobs: node-version: ${{ matrix.node-version }} cache: yarn - run: yarn install --frozen-lockfile --ignore-scripts + - if: matrix.react-version != 19 + run: yarn add --dev --ignore-scripts react@^${{ matrix.react-version }} react-dom@^${{ matrix.react-version }} - run: yarn lint - run: yarn prettier:check - run: yarn build-and-test diff --git a/package.json b/package.json index 7682a1c..e31049d 100644 --- a/package.json +++ b/package.json @@ -38,8 +38,8 @@ "license": "Apache-2.0", "peerDependencies": { "draft-js": ">=0.7.0", - "react": "^15.0.2 || ^16.0.0-rc || ^16.0.0 || ^17.0.0 || ^18.0.0", - "react-dom": "^15.0.2 || ^16.0.0-rc || ^16.0.0 || ^17.0.0 || ^18.0.0" + "react": "^15.0.2 || ^16.0.0-rc || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^15.0.2 || ^16.0.0-rc || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" }, "dependencies": { "@babel/runtime": "^7.5.5", @@ -66,8 +66,8 @@ "jest": "^30.5.2", "jest-environment-jsdom": "^30.5.2", "prettier": "^3.9.10", - "react": "^18.0.0", - "react-dom": "^18.0.0", + "react": "^19.3.1", + "react-dom": "^19.3.1", "rimraf": "^6.1.3", "rollup": "^4.66.0" }, diff --git a/src/convertToHTML.js b/src/convertToHTML.js index df43e6f..edb1db5 100644 --- a/src/convertToHTML.js +++ b/src/convertToHTML.js @@ -1,7 +1,7 @@ // import Immutable from 'immutable'; // eslint-disable-line no-unused-vars import invariant from 'invariant'; import React from 'react'; -import ReactDOMServer from 'react-dom/server'; +import renderToStaticMarkup from './util/renderToStaticMarkup'; import { convertToRaw } from 'draft-js'; import encodeBlock from './encodeBlock'; @@ -124,7 +124,7 @@ const convertToHTML = Object.prototype.hasOwnProperty.call(blockHTML, 'empty') ) { if (React.isValidElement(blockHTML.empty)) { - html = ReactDOMServer.renderToStaticMarkup(blockHTML.empty); + html = renderToStaticMarkup(blockHTML.empty); } else { html = blockHTML.empty; } diff --git a/src/util/getBlockTags.js b/src/util/getBlockTags.js index 45564b5..71c706b 100644 --- a/src/util/getBlockTags.js +++ b/src/util/getBlockTags.js @@ -1,6 +1,6 @@ import invariant from 'invariant'; import React from 'react'; -import ReactDOMServer from 'react-dom/server'; +import renderToStaticMarkup from './renderToStaticMarkup'; import splitReactElement from './splitReactElement'; function hasChildren(element) { @@ -22,7 +22,7 @@ export default function getBlockTags(blockHTML) { if (React.isValidElement(blockHTML)) { if (hasChildren(blockHTML)) { - return ReactDOMServer.renderToStaticMarkup(blockHTML); + return renderToStaticMarkup(blockHTML); } return splitReactElement(blockHTML); diff --git a/src/util/getElementHTML.js b/src/util/getElementHTML.js index af124d0..de54122 100644 --- a/src/util/getElementHTML.js +++ b/src/util/getElementHTML.js @@ -1,6 +1,6 @@ import invariant from 'invariant'; import React from 'react'; -import ReactDOMServer from 'react-dom/server'; +import renderToStaticMarkup from './renderToStaticMarkup'; import splitReactElement from './splitReactElement'; function hasChildren(element) { @@ -21,7 +21,7 @@ export default function getElementHTML(element, text = null) { if (React.isValidElement(element)) { if (hasChildren(element)) { - return ReactDOMServer.renderToStaticMarkup(element); + return renderToStaticMarkup(element); } const tags = splitReactElement(element); diff --git a/src/util/renderToStaticMarkup.js b/src/util/renderToStaticMarkup.js new file mode 100644 index 0000000..707f527 --- /dev/null +++ b/src/util/renderToStaticMarkup.js @@ -0,0 +1,12 @@ +import React from 'react'; +import ReactDOMServer from 'react-dom/server'; + +const IMAGE_PRELOADS = + /]*\/>/g; + +const hoistsImagePreloads = parseInt(React.version, 10) >= 19; + +export default function renderToStaticMarkup(element) { + const html = ReactDOMServer.renderToStaticMarkup(element); + return hoistsImagePreloads ? html.replace(IMAGE_PRELOADS, '') : html; +} diff --git a/src/util/splitReactElement.js b/src/util/splitReactElement.js index e1c5183..7389b4b 100644 --- a/src/util/splitReactElement.js +++ b/src/util/splitReactElement.js @@ -1,6 +1,6 @@ import invariant from 'invariant'; import React from 'react'; -import ReactDOMServer from 'react-dom/server'; +import renderToStaticMarkup from './renderToStaticMarkup'; // see http://w3c.github.io/html/syntax.html#writing-html-documents-elements const VOID_TAGS = [ @@ -22,10 +22,10 @@ const VOID_TAGS = [ export default function splitReactElement(element) { if (VOID_TAGS.indexOf(element.type) !== -1) { - return ReactDOMServer.renderToStaticMarkup(element); + return renderToStaticMarkup(element); } - const tags = ReactDOMServer.renderToStaticMarkup( + const tags = renderToStaticMarkup( React.cloneElement(element, {}, '\r') ).split('\r'); diff --git a/test/spec/convertToHTML-test.js b/test/spec/convertToHTML-test.js index 27d9495..430bf5c 100644 --- a/test/spec/convertToHTML-test.js +++ b/test/spec/convertToHTML-test.js @@ -1099,4 +1099,116 @@ describe('convertToHTML', () => { /missing HTML definition/ ); }); + + it('renders images in block HTML without preload links', () => { + const contentState = buildContentState([ + { + type: 'atomic', + text: ' ', + }, + ]); + + const result = convertToHTML({ + blockToHTML: { + atomic: ( +
+ +
+ ), + }, + })(contentState); + + expect(result).toBe('
'); + }); + + it('renders several image entities in one block without preload links', () => { + const contentState = buildContentState( + [ + { + type: 'unstyled', + text: 'a b', + entityRanges: [ + { + key: 0, + offset: 0, + length: 1, + }, + { + key: 1, + offset: 2, + length: 1, + }, + ], + }, + ], + { + 0: { + type: 'IMAGE', + mutability: 'IMMUTABLE', + data: { src: 'https://x.test/1.png' }, + }, + 1: { + type: 'IMAGE', + mutability: 'IMMUTABLE', + data: { src: 'https://x.test/2.png' }, + }, + } + ); + + const result = convertToHTML({ + entityToHTML: (entity, originalText) => { + if (entity.type === 'IMAGE') { + return ; + } + return originalText; + }, + })(contentState); + + expect(result).toBe( + '

' + ); + }); + + it('renders an image inside an entity element without preload links', () => { + const contentState = buildContentState( + [ + { + type: 'unstyled', + text: 'Hello', + entityRanges: [ + { + key: 0, + offset: 0, + length: 5, + }, + ], + }, + ], + { + 0: { + type: 'LINK', + mutability: 'MUTABLE', + data: { url: 'https://x.test' }, + }, + } + ); + + const result = convertToHTML({ + entityToHTML: (entity, originalText) => { + if (entity.type === 'LINK') { + return ( + + + {originalText} + + ); + } + return originalText; + }, + })(contentState); + + expect(result).toBe( + '

Hello

' + ); + }); }); diff --git a/test/spec/renderToStaticMarkup-test.js b/test/spec/renderToStaticMarkup-test.js new file mode 100644 index 0000000..454d895 --- /dev/null +++ b/test/spec/renderToStaticMarkup-test.js @@ -0,0 +1,74 @@ +import React from 'react'; +import renderToStaticMarkup from '../../src/util/renderToStaticMarkup'; + +const isReact19 = parseInt(React.version, 10) >= 19; + +describe('renderToStaticMarkup', () => { + it('renders an image without a preload link', () => { + expect(renderToStaticMarkup()).toBe( + '' + ); + }); + + it('renders a srcSet image without a preload link', () => { + expect( + renderToStaticMarkup( + + ) + ).toBe( + '' + ); + }); + + it('renders several images without preload links', () => { + expect( + renderToStaticMarkup( +
+ + +
+ ) + ).toBe( + '
' + ); + }); + + it('keeps links that are not image preloads', () => { + expect( + renderToStaticMarkup( +
+ +
+ ) + ).toContain(''); + }); + + it('keeps an explicit image preload link only before React 19', () => { + const result = renderToStaticMarkup( +
+ +
+ ); + + expect(result).toBe( + isReact19 + ? '
' + : '
' + ); + }); + + it('leaves React 19 blocking javascript: URLs in place', () => { + const consoleError = jest + .spyOn(console, 'error') + .mockImplementation(() => {}); + + const result = renderToStaticMarkup(x); + consoleError.mockRestore(); + + expect(result).toBe( + isReact19 + ? 'x' + : 'x' + ); + }); +}); diff --git a/yarn.lock b/yarn.lock index d8e3a6c..790d313 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3690,7 +3690,7 @@ lodash@^4.17.11: resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.18.1.tgz#ff2b66c1f6326d59513de2407bf881439812771c" integrity sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q== -loose-envify@^1.0.0, loose-envify@^1.1.0: +loose-envify@^1.0.0: version "1.3.1" resolved "https://registry.yarnpkg.com/loose-envify/-/loose-envify-1.3.1.tgz#d1a8ad33fa9ce0e713d65fdd0ac8b748d478c848" dependencies: @@ -4027,20 +4027,17 @@ qified@^0.10.1: dependencies: hookified "^2.1.1" -react-dom@^18.0.0: - version "18.2.0" - resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-18.2.0.tgz#22aaf38708db2674ed9ada224ca4aa708d821e3d" - integrity sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g== +react-dom@^19.3.1: + version "19.3.1" + resolved "https://registry.npmjs.org/react-dom/-/react-dom-19.3.1.tgz#b384c53cfcbe9c2a5d09d863b98455de9d68afe2" + integrity sha512-Y0bARz53lhEC1NMCASs0/j+faB/zqt3R8hpvg1e8+61ZVJ4UL0UdC4cBbXfZMcIuhrfgeILTLe+ZeBCavQPtKQ== dependencies: - loose-envify "^1.1.0" - scheduler "^0.23.0" + scheduler "^0.28.0" -react@^18.0.0: - version "18.2.0" - resolved "https://registry.yarnpkg.com/react/-/react-18.2.0.tgz#555bd98592883255fa00de14f1151a917b5d77d5" - integrity sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ== - dependencies: - loose-envify "^1.1.0" +react@^19.3.1: + version "19.3.1" + resolved "https://registry.npmjs.org/react/-/react-19.3.1.tgz#26e9523fc36ea06be5050ebb780249a133260ad7" + integrity sha512-QNhWdt7nrgVWWQG58QuGsAR3jv3Hbe4mb4vz7w2csnHavciZJgda1CPNaldF6eVxv90ud3jUXpKlHL69gO38yw== readdirp@~3.6.0: version "3.6.0" @@ -4171,12 +4168,10 @@ saxes@^6.0.0: dependencies: xmlchars "^2.2.0" -scheduler@^0.23.0: - version "0.23.0" - resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.23.0.tgz#ba8041afc3d30eb206a487b6b384002e4e61fdfe" - integrity sha512-CtuThmgHNg7zIZWAXi3AsyIzA3n4xx7aNyjwC2VJldO2LMVDhFK+63xGqq6CsJH4rTAt6/M+N4GhZiDYPx9eUw== - dependencies: - loose-envify "^1.1.0" +scheduler@^0.28.0: + version "0.28.0" + resolved "https://registry.npmjs.org/scheduler/-/scheduler-0.28.0.tgz#03d3ca348aeaf8b7502f64468f217b27523f9695" + integrity sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw== semver@^5.6.0: version "5.7.0"