diff --git a/bin/build-lib.js b/bin/build-lib.js index e1a2a03bb..e185ebb54 100644 --- a/bin/build-lib.js +++ b/bin/build-lib.js @@ -10,7 +10,7 @@ const { mkdir, stat, writeFile } = fs.promises const PACKAGE_JSON_IMPORT = /^\..*\/package.json$/ const SOURCE = 'packages/{*,@uppy/*}/src/**/*.{js,ts}?(x)' // Files not to build (such as tests) -const IGNORE = /\.test\.[jt]s$|__mocks__|svelte|angular|companion\// +const IGNORE = /\.test\.jsx?$|\.test\.tsx?$|__mocks__|svelte|angular|companion\//; // Files that should trigger a rebuild of everything on change const META_FILES = [ 'babel.config.js', diff --git a/docs/framework-integrations/react.mdx b/docs/framework-integrations/react.mdx index de84311b5..e04684f24 100644 --- a/docs/framework-integrations/react.mdx +++ b/docs/framework-integrations/react.mdx @@ -61,9 +61,6 @@ The following components are exported from `@uppy/react`: - `` renders [`@uppy/progress-bar`](/docs/progress-bar) - `` renders [`@uppy/status-bar`](/docs/status-bar) -{/* prettier-ignore */} -{/* Commented out until the hook is live - ### Hooks `useUppyState(uppy, selector)` @@ -86,12 +83,10 @@ const metaFields = useUppyState( ``` You can see all the values you can access on the -[`State`](https://github.com/transloadit/uppy/blob/main/packages/%40uppy/core/types/index.d.ts#L190) +[`State`](https://github.com/transloadit/uppy/blob/c45407d099d87e25cecaf03c5d9ce59c582ca0dc/packages/%40uppy/core/src/Uppy.ts#L155-L181) type. If you are accessing plugin state, you would have to look at the types of the plugin. -\*/} - ## Examples ### Example: basic component diff --git a/e2e/clients/react/App.jsx b/e2e/clients/react/App.jsx index cfd68bcc5..4818918fe 100644 --- a/e2e/clients/react/App.jsx +++ b/e2e/clients/react/App.jsx @@ -10,24 +10,35 @@ import '@uppy/core/dist/style.css' import '@uppy/dashboard/dist/style.css' import '@uppy/drag-drop/dist/style.css' -export default function App () { - const RemoteSourcesOptions = { - companionUrl: 'http://companion.uppy.io', - sources: ['GoogleDrive', 'OneDrive', 'Unsplash', 'Zoom', 'Url'], - } - const uppyDashboard = new Uppy({ id: 'dashboard' }).use(RemoteSources, { ...RemoteSourcesOptions }) - const uppyModal = new Uppy({ id: 'modal' }) - const uppyDragDrop = new Uppy({ id: 'drag-drop' }).use(ThumbnailGenerator) +const uppyDashboard = new Uppy({ id: 'dashboard' }).use(RemoteSources, { + companionUrl: 'http://companion.uppy.io', + sources: ['GoogleDrive', 'OneDrive', 'Unsplash', 'Zoom', 'Url'], +}) +const uppyModal = new Uppy({ id: 'modal' }) +const uppyDragDrop = new Uppy({ id: 'drag-drop' }).use(ThumbnailGenerator) + +export default function App() { const [open, setOpen] = useState(false) + // TODO: Parcel is having a bad time resolving React inside @uppy/react for some reason. + // We are using Parcel in an odd way and I don't think there is an easy fix. + // const files = useUppyState(uppyDashboard, (state) => state.files) // drag-drop has no visual output so we test it via the uppy instance window.uppy = uppyDragDrop return ( -
+
+ {/*

Dashboard file count: {Object.keys(files).length}

*/} diff --git a/package.json b/package.json index 6803491a6..9fa7b5171 100644 --- a/package.json +++ b/package.json @@ -168,7 +168,7 @@ }, "resolutions": { "@types/eslint@^7.2.13": "^8.2.0", - "@types/react": "^17", + "@types/react": "^18", "@types/webpack-dev-server": "^4", "@vitest/utils": "patch:@vitest/utils@npm%3A1.2.1#./.yarn/patches/@vitest-utils-npm-1.2.1-3028846845.patch", "p-queue": "patch:p-queue@npm%3A7.4.1#./.yarn/patches/p-queue-npm-7.4.1-e0cf0a6f17.patch", diff --git a/packages/@uppy/react/package.json b/packages/@uppy/react/package.json index 189ca8b38..a52b27c11 100644 --- a/packages/@uppy/react/package.json +++ b/packages/@uppy/react/package.json @@ -21,11 +21,16 @@ "url": "git+https://github.com/transloadit/uppy.git" }, "dependencies": { - "@uppy/utils": "workspace:^" + "@uppy/utils": "workspace:^", + "use-sync-external-store": "^1.2.0" }, "devDependencies": { + "@testing-library/jest-dom": "^6.4.2", + "@testing-library/react": "^14.2.2", "@types/react": "^18.0.8", - "react": "^18.1.0" + "@types/use-sync-external-store": "^0.0.6", + "react": "^18.2.0", + "react-dom": "^18.2.0" }, "peerDependencies": { "@uppy/core": "workspace:^", @@ -34,7 +39,7 @@ "@uppy/file-input": "workspace:^", "@uppy/progress-bar": "workspace:^", "@uppy/status-bar": "workspace:^", - "react": "^16.0.0 || ^17.0.0 || ^18.0.0" + "react": "^18.0.0" }, "peerDependenciesMeta": { "@uppy/dashboard": { diff --git a/packages/@uppy/react/src/index.ts b/packages/@uppy/react/src/index.ts index f241b05d8..e57425608 100644 --- a/packages/@uppy/react/src/index.ts +++ b/packages/@uppy/react/src/index.ts @@ -4,4 +4,4 @@ export { default as DragDrop } from './DragDrop.ts' export { default as ProgressBar } from './ProgressBar.ts' export { default as StatusBar } from './StatusBar.ts' export { default as FileInput } from './FileInput.ts' -export { default as useUppy } from './useUppy.ts' +export { default as useUppyState } from './useUppyState.ts' diff --git a/packages/@uppy/react/src/useUppy.ts b/packages/@uppy/react/src/useUppy.ts deleted file mode 100644 index e549d8207..000000000 --- a/packages/@uppy/react/src/useUppy.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { useEffect, useRef } from 'react' -import { Uppy as UppyCore } from '@uppy/core' -import type { Body, Meta } from '@uppy/utils/lib/UppyFile' - -/** - * @deprecated Initialize Uppy outside of the component. - */ -export default function useUppy( - factory: () => UppyCore, -): UppyCore | undefined { - if (typeof factory !== 'function') { - throw new TypeError( - 'useUppy: expected a function that returns a new Uppy instance', - ) - } - - const uppy = useRef | undefined>(undefined) - if (uppy.current === undefined) { - uppy.current = factory() - - if (!(uppy.current instanceof UppyCore)) { - throw new TypeError( - `useUppy: factory function must return an Uppy instance, got ${typeof uppy.current}`, - ) - } - } - - useEffect(() => { - return () => { - uppy.current?.close({ reason: 'unmount' }) - uppy.current = undefined - } - }, [uppy]) - - return uppy.current -} diff --git a/packages/@uppy/react/src/useUppyState.test.tsx b/packages/@uppy/react/src/useUppyState.test.tsx new file mode 100644 index 000000000..7a8d37631 --- /dev/null +++ b/packages/@uppy/react/src/useUppyState.test.tsx @@ -0,0 +1,46 @@ +/* eslint-disable react/react-in-jsx-scope */ +/* eslint-disable import/no-extraneous-dependencies */ +import React from 'react' +import { describe, expect, expectTypeOf, it } from 'vitest' +import { renderHook, render, act } from '@testing-library/react' +import Uppy from '@uppy/core' + +import useUppyState from './useUppyState.ts' + +describe('useUppyState', () => { + it('should return and update value with the correct type', () => { + const uppy = new Uppy() + const { result, rerender } = renderHook(() => + useUppyState(uppy, (state) => state.totalProgress), + ) + expectTypeOf(result.current).toEqualTypeOf() + expect(result.current).toBe(0) + act(() => uppy.setState({ totalProgress: 50 })) + rerender() + expect(result.current).toBe(50) + rerender() + expect(result.current).toBe(50) + }) + + it('does not re-render unnecessarily', () => { + const uppy = new Uppy() + let renderCount = 0 + + const Component = React.memo((props: { uppy: Uppy }) => { + const files = useUppyState(props.uppy, (state) => + Object.values(state.files), + ) + renderCount++ + return
{files.length}
+ }) + + const { rerender } = render() + expect(renderCount).toBe(1) + // Re-render without updating Uppy's state + rerender() + // It should have been memoized + expect(renderCount).toBe(1) + act(() => uppy.addFile({ name: 'file', data: new Blob() })) + expect(renderCount).toBe(2) + }) +}) diff --git a/packages/@uppy/react/src/useUppyState.ts b/packages/@uppy/react/src/useUppyState.ts new file mode 100644 index 000000000..e69a63a42 --- /dev/null +++ b/packages/@uppy/react/src/useUppyState.ts @@ -0,0 +1,23 @@ +import type { Uppy, State } from '@uppy/core' +import type { Body, Meta } from '@uppy/utils/lib/UppyFile' +import { useMemo, useCallback } from 'react' +import { useSyncExternalStoreWithSelector } from 'use-sync-external-store/with-selector' + +export default function useUppyState< + M extends Meta = Meta, + B extends Body = Body, + T = any, +>(uppy: Uppy, selector: (state: State) => T): T { + const subscribe = useMemo( + () => uppy.store.subscribe.bind(uppy.store), + [uppy.store], + ) + const getSnapshot = useCallback(() => uppy.store.getState(), [uppy.store]) + + return useSyncExternalStoreWithSelector( + subscribe, + getSnapshot, + null, + selector, + ) +} diff --git a/packages/@uppy/react/tsconfig.build.json b/packages/@uppy/react/tsconfig.build.json index 151f7261b..584a85bc3 100644 --- a/packages/@uppy/react/tsconfig.build.json +++ b/packages/@uppy/react/tsconfig.build.json @@ -3,6 +3,8 @@ "compilerOptions": { "noImplicitAny": false, "outDir": "./lib", + "jsxImportSource": "react", + "jsx": "react-jsx", "paths": { "@uppy/utils/lib/*": ["../utils/src/*"], "@uppy/core": ["../core/src/index.js"], @@ -23,7 +25,7 @@ "skipLibCheck": true }, "include": ["./src/**/*.*"], - "exclude": ["./src/**/*.test.ts"], + "exclude": ["./src/**/*.test.tsx"], "references": [ { "path": "../utils/tsconfig.build.json" diff --git a/packages/@uppy/react/tsconfig.json b/packages/@uppy/react/tsconfig.json index aae4219b7..9c3fc403c 100644 --- a/packages/@uppy/react/tsconfig.json +++ b/packages/@uppy/react/tsconfig.json @@ -3,6 +3,8 @@ "compilerOptions": { "emitDeclarationOnly": false, "noEmit": true, + "jsxImportSource": "react", + "jsx": "react-jsx", "paths": { "@uppy/utils/lib/*": ["../utils/src/*"], "@uppy/core": ["../core/src/index.js"], diff --git a/yarn.lock b/yarn.lock index b8986aacb..ba0fc7a28 100644 --- a/yarn.lock +++ b/yarn.lock @@ -12,6 +12,13 @@ __metadata: languageName: node linkType: hard +"@adobe/css-tools@npm:^4.3.2": + version: 4.3.3 + resolution: "@adobe/css-tools@npm:4.3.3" + checksum: d21f3786b84911fee59c995a146644a85c98692979097b26484ffa9e442fb1a92ccd68ce984e3e7cf8d5933c3560fbc0ad3e3cd1de50b9a723d1c012e793bbcb + languageName: node + linkType: hard + "@ampproject/remapping@npm:2.3.0": version: 2.3.0 resolution: "@ampproject/remapping@npm:2.3.0" @@ -3833,6 +3840,15 @@ __metadata: languageName: node linkType: hard +"@babel/runtime@npm:^7.12.5": + version: 7.24.1 + resolution: "@babel/runtime@npm:7.24.1" + dependencies: + regenerator-runtime: ^0.14.0 + checksum: 5c8f3b912ba949865f03b3cf8395c60e1f4ebd1033fbd835bdfe81b6cac8a87d85bc3c7aded5fcdf07be044c9ab8c818f467abe0deca50020c72496782639572 + languageName: node + linkType: hard + "@babel/runtime@npm:^7.14.0, @babel/runtime@npm:^7.20.7, @babel/runtime@npm:^7.8.4, @babel/runtime@npm:^7.9.2": version: 7.24.4 resolution: "@babel/runtime@npm:7.24.4" @@ -7594,6 +7610,69 @@ __metadata: languageName: node linkType: hard +"@testing-library/dom@npm:^9.0.0": + version: 9.3.4 + resolution: "@testing-library/dom@npm:9.3.4" + dependencies: + "@babel/code-frame": ^7.10.4 + "@babel/runtime": ^7.12.5 + "@types/aria-query": ^5.0.1 + aria-query: 5.1.3 + chalk: ^4.1.0 + dom-accessibility-api: ^0.5.9 + lz-string: ^1.5.0 + pretty-format: ^27.0.2 + checksum: dfd6fb0d6c7b4dd716ba3c47309bc9541b4a55772cb61758b4f396b3785efe2dbc75dc63423545c039078c7ffcc5e4b8c67c2db1b6af4799580466036f70026f + languageName: node + linkType: hard + +"@testing-library/jest-dom@npm:^6.4.2": + version: 6.4.2 + resolution: "@testing-library/jest-dom@npm:6.4.2" + dependencies: + "@adobe/css-tools": ^4.3.2 + "@babel/runtime": ^7.9.2 + aria-query: ^5.0.0 + chalk: ^3.0.0 + css.escape: ^1.5.1 + dom-accessibility-api: ^0.6.3 + lodash: ^4.17.15 + redent: ^3.0.0 + peerDependencies: + "@jest/globals": ">= 28" + "@types/bun": "*" + "@types/jest": ">= 28" + jest: ">= 28" + vitest: ">= 0.32" + peerDependenciesMeta: + "@jest/globals": + optional: true + "@types/bun": + optional: true + "@types/jest": + optional: true + jest: + optional: true + vitest: + optional: true + checksum: 631aeadbf4e738080ae095242cf1a29a0b4ee2f09c8bdd0d3f00a923707da64c1617e088ba9a961d098481afabdc1d19149fb7ef98edf15132348eb222f345ae + languageName: node + linkType: hard + +"@testing-library/react@npm:^14.2.2": + version: 14.2.2 + resolution: "@testing-library/react@npm:14.2.2" + dependencies: + "@babel/runtime": ^7.12.5 + "@testing-library/dom": ^9.0.0 + "@types/react-dom": ^18.0.0 + peerDependencies: + react: ^18.0.0 + react-dom: ^18.0.0 + checksum: cb73df588592d9101429f057eaa6f320fc12524d5eb2acc8a16002c1ee2d9422a49e44841003bba42974c9ae1ced6b134f0d647826eca42ab8f19e4592971b16 + languageName: node + linkType: hard + "@tootallnate/once@npm:2": version: 2.0.0 resolution: "@tootallnate/once@npm:2.0.0" @@ -7646,6 +7725,13 @@ __metadata: languageName: node linkType: hard +"@types/aria-query@npm:^5.0.1": + version: 5.0.4 + resolution: "@types/aria-query@npm:5.0.4" + checksum: ad8b87e4ad64255db5f0a73bc2b4da9b146c38a3a8ab4d9306154334e0fc67ae64e76bfa298eebd1e71830591fb15987e5de7111bdb36a2221bdc379e3415fb0 + languageName: node + linkType: hard + "@types/babel__core@npm:^7.1.14": version: 7.1.19 resolution: "@types/babel__core@npm:7.1.19" @@ -8189,14 +8275,22 @@ __metadata: languageName: node linkType: hard -"@types/react@npm:^17": - version: 17.0.49 - resolution: "@types/react@npm:17.0.49" +"@types/react-dom@npm:^18.0.0": + version: 18.2.23 + resolution: "@types/react-dom@npm:18.2.23" + dependencies: + "@types/react": "*" + checksum: ebed13ffc393f96b3dbe2f959c857c3124c20e84718cded13016eaa2e3b650590557ea75584c4d3d3f226b0aae206167c9c81e971632c1f24bbd258f1d295b96 + languageName: node + linkType: hard + +"@types/react@npm:^18": + version: 18.2.75 + resolution: "@types/react@npm:18.2.75" dependencies: "@types/prop-types": "*" - "@types/scheduler": "*" csstype: ^3.0.2 - checksum: e2e8001bb16fdf52dc92dbc2f64c8e49b1c6ab5d5da4725c0f27e3d925939e42b835554fecc306ca6b0951801eb784288b63c924bd703834662bbaccf89cb68a + checksum: f77322720744beba6650462c0d75ccc0be27c176bd7b2aff32039ea30e498a78fe6dbce4a1c3fdbf592ccc01286059f332a294a3b6975dbb0d8d5999718ece38 languageName: node linkType: hard @@ -8260,13 +8354,6 @@ __metadata: languageName: node linkType: hard -"@types/scheduler@npm:*": - version: 0.16.2 - resolution: "@types/scheduler@npm:0.16.2" - checksum: b6b4dcfeae6deba2e06a70941860fb1435730576d3689225a421280b7742318d1548b3d22c1f66ab68e414f346a9542f29240bc955b6332c5b11e561077583bc - languageName: node - linkType: hard - "@types/semver@npm:^7.3.12": version: 7.3.13 resolution: "@types/semver@npm:7.3.13" @@ -8375,6 +8462,13 @@ __metadata: languageName: node linkType: hard +"@types/use-sync-external-store@npm:^0.0.6": + version: 0.0.6 + resolution: "@types/use-sync-external-store@npm:0.0.6" + checksum: a95ce330668501ad9b1c5b7f2b14872ad201e552a0e567787b8f1588b22c7040c7c3d80f142cbb9f92d13c4ea41c46af57a20f2af4edf27f224d352abcfe4049 + languageName: node + linkType: hard + "@types/webpack@npm:^5.28.0": version: 5.28.0 resolution: "@types/webpack@npm:5.28.0" @@ -9604,9 +9698,14 @@ __metadata: version: 0.0.0-use.local resolution: "@uppy/react@workspace:packages/@uppy/react" dependencies: + "@testing-library/jest-dom": ^6.4.2 + "@testing-library/react": ^14.2.2 "@types/react": ^18.0.8 + "@types/use-sync-external-store": ^0.0.6 "@uppy/utils": "workspace:^" - react: ^18.1.0 + react: ^18.2.0 + react-dom: ^18.2.0 + use-sync-external-store: ^1.2.0 peerDependencies: "@uppy/core": "workspace:^" "@uppy/dashboard": "workspace:^" @@ -9614,7 +9713,7 @@ __metadata: "@uppy/file-input": "workspace:^" "@uppy/progress-bar": "workspace:^" "@uppy/status-bar": "workspace:^" - react: ^16.0.0 || ^17.0.0 || ^18.0.0 + react: ^18.0.0 peerDependenciesMeta: "@uppy/dashboard": optional: true @@ -10893,16 +10992,7 @@ __metadata: languageName: node linkType: hard -"aria-query@npm:5.3.0, aria-query@npm:^5.3.0": - version: 5.3.0 - resolution: "aria-query@npm:5.3.0" - dependencies: - dequal: ^2.0.3 - checksum: 305bd73c76756117b59aba121d08f413c7ff5e80fa1b98e217a3443fcddb9a232ee790e24e432b59ae7625aebcf4c47cb01c2cac872994f0b426f5bdfcd96ba9 - languageName: node - linkType: hard - -"aria-query@npm:^5.1.3": +"aria-query@npm:5.1.3, aria-query@npm:^5.1.3": version: 5.1.3 resolution: "aria-query@npm:5.1.3" dependencies: @@ -10911,6 +11001,15 @@ __metadata: languageName: node linkType: hard +"aria-query@npm:5.3.0, aria-query@npm:^5.0.0, aria-query@npm:^5.3.0": + version: 5.3.0 + resolution: "aria-query@npm:5.3.0" + dependencies: + dequal: ^2.0.3 + checksum: 305bd73c76756117b59aba121d08f413c7ff5e80fa1b98e217a3443fcddb9a232ee790e24e432b59ae7625aebcf4c47cb01c2cac872994f0b426f5bdfcd96ba9 + languageName: node + linkType: hard + "arr-diff@npm:^4.0.0": version: 4.0.0 resolution: "arr-diff@npm:4.0.0" @@ -13387,6 +13486,13 @@ __metadata: languageName: node linkType: hard +"css.escape@npm:^1.5.1": + version: 1.5.1 + resolution: "css.escape@npm:1.5.1" + checksum: f6d38088d870a961794a2580b2b2af1027731bb43261cfdce14f19238a88664b351cc8978abc20f06cc6bbde725699dec8deb6fe9816b139fc3f2af28719e774 + languageName: node + linkType: hard + "cssesc@npm:^3.0.0": version: 3.0.0 resolution: "cssesc@npm:3.0.0" @@ -14096,6 +14202,20 @@ __metadata: languageName: node linkType: hard +"dom-accessibility-api@npm:^0.5.9": + version: 0.5.16 + resolution: "dom-accessibility-api@npm:0.5.16" + checksum: 005eb283caef57fc1adec4d5df4dd49189b628f2f575af45decb210e04d634459e3f1ee64f18b41e2dcf200c844bc1d9279d80807e686a30d69a4756151ad248 + languageName: node + linkType: hard + +"dom-accessibility-api@npm:^0.6.3": + version: 0.6.3 + resolution: "dom-accessibility-api@npm:0.6.3" + checksum: c325b5144bb406df23f4affecffc117dbaec9af03daad9ee6b510c5be647b14d28ef0a4ea5ca06d696d8ab40bb777e5fed98b985976fdef9d8790178fa1d573f + languageName: node + linkType: hard + "dom-serialize@npm:^2.2.1": version: 2.2.1 resolution: "dom-serialize@npm:2.2.1" @@ -21327,6 +21447,15 @@ __metadata: languageName: node linkType: hard +"lz-string@npm:^1.5.0": + version: 1.5.0 + resolution: "lz-string@npm:1.5.0" + bin: + lz-string: bin/bin.js + checksum: 1ee98b4580246fd90dd54da6e346fb1caefcf05f677c686d9af237a157fdea3fd7c83a4bc58f858cd5b10a34d27afe0fdcbd0505a47e0590726a873dc8b8f65d + languageName: node + linkType: hard + "magic-string@npm:0.30.8, magic-string@npm:^0.30.7": version: 0.30.8 resolution: "magic-string@npm:0.30.8" @@ -25739,6 +25868,17 @@ __metadata: languageName: node linkType: hard +"pretty-format@npm:^27.0.2": + version: 27.5.1 + resolution: "pretty-format@npm:27.5.1" + dependencies: + ansi-regex: ^5.0.1 + ansi-styles: ^5.0.0 + react-is: ^17.0.1 + checksum: cf610cffcb793885d16f184a62162f2dd0df31642d9a18edf4ca298e909a8fe80bdbf556d5c9573992c102ce8bf948691da91bf9739bee0ffb6e79c8a8a6e088 + languageName: node + linkType: hard + "pretty-format@npm:^29.5.0": version: 29.5.0 resolution: "pretty-format@npm:29.5.0" @@ -26126,7 +26266,7 @@ __metadata: languageName: node linkType: hard -"react-dom@npm:^18.0.0, react-dom@npm:^18.1.0": +"react-dom@npm:^18.0.0, react-dom@npm:^18.1.0, react-dom@npm:^18.2.0": version: 18.2.0 resolution: "react-dom@npm:18.2.0" dependencies: @@ -26282,7 +26422,7 @@ __metadata: languageName: node linkType: hard -"react@npm:^18.0.0, react@npm:^18.1.0": +"react@npm:^18.0.0, react@npm:^18.1.0, react@npm:^18.2.0": version: 18.2.0 resolution: "react@npm:18.2.0" dependencies: @@ -26480,6 +26620,16 @@ __metadata: languageName: node linkType: hard +"redent@npm:^3.0.0": + version: 3.0.0 + resolution: "redent@npm:3.0.0" + dependencies: + indent-string: ^4.0.0 + strip-indent: ^3.0.0 + checksum: fa1ef20404a2d399235e83cc80bd55a956642e37dd197b4b612ba7327bf87fa32745aeb4a1634b2bab25467164ab4ed9c15be2c307923dd08b0fe7c52431ae6b + languageName: node + linkType: hard + "redent@npm:^4.0.0": version: 4.0.0 resolution: "redent@npm:4.0.0"