-
Notifications
You must be signed in to change notification settings - Fork 2k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
@uppy/react: deprecate useUppy
#4223
Conversation
```js | ||
import React, { useState, useEffect } from 'react' | ||
import Uppy from '@uppy/core' | ||
import { Dashboard } from '@uppy/react' |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Do we want to highlight import Dashboard from '@uppy/react/lib/Dashboard.js'
? We probably want to introduce an exports map to sugar code it, but importing the individual modules makes the most sense otherwise you have to fight with peer dependencies.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I was hoping to do a separate PR for the exports map next to this so we don't have to document that workaround
I'm not sure how the current function MyComponent () {
const [tusEndpoint, setTusEndpoint] = useState('https://tusd.tusdemo.net/files' )
const uppy = useUppy(() => new Uppy().use(Tus, { endpoint: tusEndpoint}))
return <DashboardModal uppy={uppy} />
} According to useUppy, if someone calls As for removing I still think that the hook could have some value for ease of use, and it allows the user to just think in the "React way" and expect Uppy to work with the options provided. However this assumes that Uppy is properly encapsulated and can handle multiple coexisting instances (e.g. there is no conflicting global state leaking between Uppy instances). For example I know one place where we store global state, and there might be others:
I believe my original implementation was 1775410#diff-e522340b6dabc4b8f14b055b742c5c1adca74d15a06f06467956a59b7e106cc2R1248 note that it's not about cancelling uploads, but about cancelling assemblies. uploads will cancel regardless of this option afaik |
I'm not sure how. Here is a codesandbox I made with the two best possible options for the hook to our knowledge. Both don't work in dev mode.
Exactly, and I stand by that fact because assemblies are not known to core and are handled in uppy/packages/@uppy/core/src/Uppy.js Lines 809 to 827 in bffd5dc
So in fact we do keep all uploads running it seems. Which is not intention revealing. |
I have a code sandbox where I'm playing around with some things. https://codesandbox.io/s/optimistic-water-twsgft?file=/src/App.js It mostly works, but I can't seem to get Uppy to play nicely with HMR (I think?). This is of course, a major BC break for the React components. |
Oh. I think this is because if the user is uploading via Google Drive or similar, we don't want the uploads to cancel just because the user navigates away (we don't want the users to sit around and wait for something they really don't have to wait for). The ideal const [maxNumberOfFiles, setMaxNumberOfFiles] = useState()
const [tusEndpoint, setTusEndpoint] = useState()
const uppy = useUppy({
options: { restrictions: { maxNumberOfFiles } },
plugins: [
{ plugin: Tus, options: { endpoint: tusEndpoint } },
]
})
return <Dashboard uppy={uppy} /> I experimented a bit with such an implementation. obviously it's really far from done but it shows the concept. You can copy this into the codesandbox: import { useRef, useEffect, useState, useMemo, useCallback } from "react";
import { Dashboard } from "@uppy/react";
import Tus from '@uppy/tus'
import Uppy from "@uppy/core";
import "@uppy/dashboard/dist/style.css";
import "@uppy/core/dist/style.css";
function useUppy(props) {
const lastProps = useRef()
const uppyRef = useRef()
const makeUppy = useCallback(() => {
const { options, plugins } = props
if (!lastProps.current) {
console.log('creating uppy')
uppyRef.current = new Uppy(options)
} else {
const { restrictions } = options
const lastOptions = lastProps.current.options
if (lastOptions.restrictions !== restrictions) {
console.log('updating uppy options')
uppyRef.current.setOptions({ restrictions })
}
}
const lastPlugins = lastProps.current ? lastProps.current.plugins : []
lastPlugins.forEach(({ plugin }) => {
if (!plugins.find((p) => p.plugin === plugin)) {
console.log('removing plugin', plugin.prototype.constructor.name)
const instance = uppyRef.current.getPlugin(plugin.prototype.constructor.name)
uppyRef.current.removePlugin(instance)
}
})
plugins.forEach(({ plugin, options }) => {
const lastPlugin = lastPlugins.find((p) => p.plugin === plugin)
if (!lastPlugin) {
console.log('adding plugin', plugin.prototype.constructor.name)
uppyRef.current.use(plugin, options)
} else {
if (lastPlugin.options.endpoint !== options.endpoint) {
console.log('updating plugin options', plugin.prototype.constructor.name, options)
const instance = uppyRef.current.getPlugin(plugin.prototype.constructor.name)
console.log(instance)
instance.setOptions(options)
}
}
})
lastProps.current = props
return uppyRef.current
}, [props])
const [uppy, setUppy] = useState();
useEffect(() => {
setUppy(makeUppy())
}, [makeUppy])
useEffect(() => () => {
if (uppy != null) uppy.close()
}, [uppy])
return uppy;
}
function Component({ uppy }) {
return (
<div className="App">
<h1>Hello CodeSandbox</h1>
<Dashboard uppy={uppy} />
</div>
);
}
// const uppy = new Uppy({ id: "dashboard" })
export default function App(props) {
const [tusEndpoint, setTusEndpoint] = useState('https://tusd.tusdemo.net/files');
const [maxNumberOfFiles, setMaxNumberOfFiles] = useState(1);
const uppy = useUppy({
options: { restrictions: { maxNumberOfFiles } },
plugins: [
{ plugin: Tus, options: { endpoint: tusEndpoint } },
]
});
if (!uppy) return null // todo
return (
<>
<div id="drag-drop-area" />
<select onChange={(e) => setMaxNumberOfFiles(parseInt(e.target.value, 10))} value={maxNumberOfFiles}>
{[1,2,3,4].map((v) => <option key={v} value={v}>{v}</option>)}
</select>
<select onChange={(e) => setTusEndpoint(e.target.value)} value={tusEndpoint}>
<option value="https://tusd.tusdemo.net/files">https://tusd.tusdemo.net/files</option>
<option value="https://tusd2.tusdemo.net/files">https://tusd2.tusdemo.net/files</option>
</select>
<Component uppy={uppy} />
</>
);
} |
For now, this seems a bit over-engineered and deviates from the way we use plugins. I think the way you use Uppy shouldn't change too much. If we continue with a hook, I'd say we keep it simple for the 90% use case and think about it again if people start complaining. So far, no one has (except for that it's broken of course). @aheimlich thank you for looking into this. I'll give your solution a closer look, but it looks promising on a first glance. |
The caveat here is that the factory function that Relying on that How about something like function useUppy(onCreateOrChange: (uppyInstance: Uppy) => Uppy, deps: any[]) {
const [uppy, setUppy] = useState(() => {
const uppy = new Uppy()
return onCreateOrChange(uppy)
});
useEffect(() => {
setUppy(onCreateOrChange(uppy))
}, deps);
return uppy;
} This can probably be improved a lot API-wise, but to me this follows React paradigms. This could be used almost like a |
Not really familiar with this code but what @nickrttn said sounds correct to me– Moving to state+effect would avoid that issue. |
Alternatively this version, which would allow eslint rule of hooks ( function useUppy(onCreateOrChange: (uppyInstance: Uppy) => Uppy) {
const [uppy, setUppy] = useState(() => {
const uppy = new Uppy()
return onCreateOrChange(uppy)
});
useEffect(() => {
setUppy(onCreateOrChange(uppy))
}, [onCreateOrChange]);
return uppy;
} ..then use the hook like this and eslint would warn about using deps not in the useCallback deps list: const [restrictions, setRestrictions] = useState();
const onChangeOrCreate = useCallback((uppy) => {
return uppy.setOptions({ restrictions })
}, []) // eslint error: react hook has a missing dependency restrictions
uppy = useUppy(onChangeOrCreate) |
In my opinion, we either make the hook sufficient that you don't need to add more state and memoization or we just write docs how to do it yourself. Otherwise, what's the gain of the hook? The answer to whether we like developers to control whether to continue or cancel uploads on unmount would also play a large part in that decision I'd say, as that's harder to do if you hide it inside an |
Here's an example of how to use @nickrttn's hook to enable/disable plugins and change restrictions on the fly: const [maxNumberOfFiles, setMaxNumberOfFiles] = useState();
const [showWebcam, setShowWebcam] = useState(true);
const uppy = useUppy((uppy) => {
if (showWebcam && !uppy.getPlugin('Webcam')) {
uppy.use(Webcam)
}
if (!showWebcam && uppy.getPlugin('Webcam')) {
uppy.removePlugin(uppy.getPlugin('Webcam'))
}
uppy.setOptions({ restrictions: { maxNumberOfFiles } })
return uppy
}, [maxNumberOfFiles, showWebcam])
return (
<div>
<button onClick={() => setShowWebcam((v) => !v)}>{showWebcam ? 'webcam on' : 'webcam off'}</button>
<select onChange={(e) => setMaxNumberOfFiles(parseInt(e.target.value, 10))}>
{Array(10).fill(0).map((v, i) => <option key={i} value={i}>{i}</option>)}
</select>
<Dashboard uppy={uppy} plugins={showWebcam ? ["Webcam"] : []} />
</div>
); it could be made much more compact and easier to use if Uppy's API was idempotent (if calling One potential issue with Nick's hook is that
We could optimize this, but because Uppy's react components expect |
A couple things need to change for the hook to be acceptable in my opinion. This increases the scope outside of the hook.
Put all together, using // Simple use case
const dashboardRef = useRef();
const uppy = useUppy(() => new Uppy().use(Webcam, { target: dashboardRef }), []);
useEffect(() => {
return () => {
// you are in control whether you want to cancel uploads or not.
uppy.close({ cancelUploads: false });
};
}, []); const dashboardRef = useRef();
const [maxNumberOfFiles, setMaxNumberOfFiles] = useState();
const [showWebcam, setShowWebcam] = useState(true);
// Do things conditionally
const uppy = useUppy(() => {
const uppy = new Uppy();
if (showWebcam) uppy.use(Webcam, { target: dashboardRef });
else uppy.removePlugin(uppy.getPlugin("Webcam"));
uppy.setOptions({ restrictions: { maxNumberOfFiles } });
return uppy;
}, [maxNumberOfFiles, showWebcam]);
// useEffect to cleanup... |
I think the problem with your approach of the user providing new Uppy themselves is that every time deps changes, a new Uppy would be recreated, unless the user adds an if-check, but that’s not very elegant. Other than that I agree with most points, but I think closing uppy on unmount could be incorporated into the hook (with an option) |
But that's what we want and need? Because of the clean up function of the effect, we need a new instance because the old is now closed. If you want to keep Uppy from closing, you would do it as you would with any other React component: prevent it from re-rendering. |
Whenever a new Uppy is created, the UI state will reset. Like which files you selected etc. I think that’s not ideal. I think ideally when any deps of the useUppy hook change, the same uppy instance should remain, if possible, and then the user can setOptions to change whatever they want. Only when the component unmounts shall close be called |
Tricky thing here is that
Looking at the examples, the potential solutions we are coming up with are pretty standard forms of React hooks and do not manage any complexities inherent to Uppy, ie. they are not really necessary imo. The only thing about them is the close on unmount, but as @mifi points out, that might not be desirable |
But |
I think if we add this inside nick’s hook: useEffect(() => () => uppy.close({ reason }), [uppy]); Then it will clean up whenever the hook unmounts. This would allow the hook to create new Uppy only once in the beginning, and then cleanup on unmount. |
I'm not sure I understand. Why would it leak if you create a single instance in the global scope? Leaks in React apps are commonly introduced by side effects that are not cleaned up. In this case, you only have a single instance (per use case) and you wouldn't need to clean it up except maybe on an unload event. It's like instantiating a worker outside of a React component, or a client for error monitoring. Both are commonly done, simplify the structure of your code and reduce the surface area for bugs. |
* main: meta: upgrade to Vite 4 and ESBuild 0.16 (#4243) @uppy/audio fix typo in readme (#4240) fix: add https:// to digital oceans link (#4165) website: Simplify Dashboard code sample (#4197) @uppy/transloadit: introduce `assemblyOptions`, deprecate other options (#4059) @uppy/core: fix typo in Uppy.test.js (#4235) @uppy/aws-s3-multipart: fix singPart type (#4224) Release: uppy@3.3.1 (#4222)
Alright, I thought it made sense to clean up event listeners as you probably only use Uppy on one page and then they live on. But we can ignore it, it's probably harmless. |
Docs now ready for final review. |
Could you clean up the listeners that are attached to the rendered unmounting DOM elements without closing the Uppy instance? |
They live in Uppy core, not the UI elements (although they could have their own). Don't think we can do that without having an effect that would enable them again. Looking into the code it's actually less than I thought, we have a massive |
Co-authored-by: Mikael Finstad <finstaden@gmail.com>
| Package | Version | Package | Version | | ---------------------- | ------- | ---------------------- | ------- | | @uppy/audio | 1.0.3 | @uppy/locales | 3.0.5 | | @uppy/aws-s3 | 3.0.5 | @uppy/react | 3.1.0 | | @uppy/aws-s3-multipart | 3.1.2 | @uppy/react-native | 0.5.0 | | @uppy/companion | 4.2.0 | @uppy/transloadit | 3.1.0 | | @uppy/core | 3.0.5 | @uppy/utils | 5.1.2 | | @uppy/dashboard | 3.2.1 | uppy | 3.4.0 | - @uppy/utils: better fallbacks for the drag & drop API (Antoine du Hamel / #4260) - @uppy/core: fix metafields validation when used as function (Merlijn Vos / #4276) - @uppy/companion: allow customizing express session prefix (Mikael Finstad / #4249) - meta: Fix comment about COMPANION_PATH (Collin Allen / #4279) - @uppy/companion: Fix typo in KUBERNETES.md (Collin Allen / #4277) - @uppy/locales: update zh_TW.js (5idereal / #4270) - meta: ci: make sure Yarn's global cache is disabled (Antoine du Hamel / #4268) - @uppy/aws-s3-multipart: fix metadata shape (Antoine du Hamel / #4267) - meta: example: add multipart support to `aws-nodejs` (Antoine du Hamel / #4257) - @uppy/react-native: example: revive React Native example (Giacomo Cerquone / #4164) - @uppy/utils: Fix getSpeed type (referenced `bytesTotal` instead of `uploadStarted`) (Pascal Wengerter / #4263) - @uppy/companion: document how to run many instances (Mikael Finstad / #4227) - @uppy/aws-s3-multipart: add support for `allowedMetaFields` option (Antoine du Hamel / #4215) - meta: Fix indentation in generate-test.mjs (Youssef Victor / #4181) - @uppy/react: deprecate `useUppy` (Merlijn Vos / #4223) - meta: fix typo in README.md (Fuad Herac / #4254) - meta: Don’t close stale issues automatically (Artur Paikin / #4246) - meta: upgrade to Vite 4 and ESBuild 0.16 (Antoine du Hamel / #4243) - @uppy/audio: @uppy/audio fix typo in readme (elliotsayes / #4240) - @uppy/aws-s3: fix: add https:// to digital oceans link (Le Gia Hoang / #4165) - website: Simplify Dashboard code sample (Artur Paikin / #4197) - @uppy/transloadit: introduce `assemblyOptions`, deprecate other options (Merlijn Vos / #4059) - @uppy/core: fix typo in Uppy.test.js (Ikko Ashimine / #4235) - @uppy/aws-s3-multipart: fix singPart type (Stefan Schonert / #4224)
Am I incorrect in believing that some of the discussion above should lead to a solution for #4230 as Murderlon seemed to indicate? This PR is exclusively for deprecating Also, many/all of the React docs pages still link to https://uppy.io/docs/react/initializing, which was removed in this PR. I would be happy to submit a PR to fix that, but I'm fairly puzzled at this point. |
Are you seeing the style issues still? Even without using the hook? I think you can initialize uppy ouside the react component and then have a useEffect that reacts to state that will call setOptions on the uppy instance. Does that help? Yea we need to fix the broken docs. I think we wanted to possibly design a new, better hook, but that was out of the scope of this PR, so we just wanted to deprecate this broken version first, so people stop using it. |
Thanks @mifi. Yes - that approach appears to work. Thank you! The only thing that looks wrong is the "browseFiles" link in the drag/drop area of the dashboard remains fully clickable after using You can see this on the example page of the docs: https://uppy.io/examples/dashboard/ Just check the "disabled" checkbox and then click the grayed out "browse files" link. This is probably outside the scope of this PR (also doesn't seem limited to React). Shall I open an issue for this? |
Yes please open am issue for that, thanks! |
| Package | Version | Package | Version | | ---------------------- | ------- | ---------------------- | ------- | | @uppy/audio | 1.0.3 | @uppy/locales | 3.0.5 | | @uppy/aws-s3 | 3.0.5 | @uppy/react | 3.1.0 | | @uppy/aws-s3-multipart | 3.1.2 | @uppy/react-native | 0.5.0 | | @uppy/companion | 4.2.0 | @uppy/transloadit | 3.1.0 | | @uppy/core | 3.0.5 | @uppy/utils | 5.1.2 | | @uppy/dashboard | 3.2.1 | uppy | 3.4.0 | - @uppy/utils: better fallbacks for the drag & drop API (Antoine du Hamel / transloadit#4260) - @uppy/core: fix metafields validation when used as function (Merlijn Vos / transloadit#4276) - @uppy/companion: allow customizing express session prefix (Mikael Finstad / transloadit#4249) - meta: Fix comment about COMPANION_PATH (Collin Allen / transloadit#4279) - @uppy/companion: Fix typo in KUBERNETES.md (Collin Allen / transloadit#4277) - @uppy/locales: update zh_TW.js (5idereal / transloadit#4270) - meta: ci: make sure Yarn's global cache is disabled (Antoine du Hamel / transloadit#4268) - @uppy/aws-s3-multipart: fix metadata shape (Antoine du Hamel / transloadit#4267) - meta: example: add multipart support to `aws-nodejs` (Antoine du Hamel / transloadit#4257) - @uppy/react-native: example: revive React Native example (Giacomo Cerquone / transloadit#4164) - @uppy/utils: Fix getSpeed type (referenced `bytesTotal` instead of `uploadStarted`) (Pascal Wengerter / transloadit#4263) - @uppy/companion: document how to run many instances (Mikael Finstad / transloadit#4227) - @uppy/aws-s3-multipart: add support for `allowedMetaFields` option (Antoine du Hamel / transloadit#4215) - meta: Fix indentation in generate-test.mjs (Youssef Victor / transloadit#4181) - @uppy/react: deprecate `useUppy` (Merlijn Vos / transloadit#4223) - meta: fix typo in README.md (Fuad Herac / transloadit#4254) - meta: Don’t close stale issues automatically (Artur Paikin / transloadit#4246) - meta: upgrade to Vite 4 and ESBuild 0.16 (Antoine du Hamel / transloadit#4243) - @uppy/audio: @uppy/audio fix typo in readme (elliotsayes / transloadit#4240) - @uppy/aws-s3: fix: add https:// to digital oceans link (Le Gia Hoang / transloadit#4165) - website: Simplify Dashboard code sample (Artur Paikin / transloadit#4197) - @uppy/transloadit: introduce `assemblyOptions`, deprecate other options (Merlijn Vos / transloadit#4059) - @uppy/core: fix typo in Uppy.test.js (Ikko Ashimine / transloadit#4235) - @uppy/aws-s3-multipart: fix singPart type (Stefan Schonert / transloadit#4224)
Closes #3935
Closes #4220
Closes #4209
Turns out we've been kidding ourselves, a hook for Uppy causes more problems than it solves. At first I thought that most people do not even need
Uppy
defined inside their component. That's only required if you want dynamic options. But the reality is that there is no use case at all for putting Uppy inside the component because we havesetOptions
!The only downside is that implementers have to think about adding this:
I'd argue the same amount of people that used to forget adding
useUppy
will also forget this, this doesn't chance anything.This also begs the question again about
reason: 'unmount'
. This was introduced to let uploads continue in the background, even if the component is unmounted. That should be a decision by the user I'd say, but currently that's baked into the hook. And the API for that is currently not obvious.reason: 'unmount'
and suddenly uploads will continue? Am I missing something or why don't we have a more intention revealing API, such asclose({ cancelUploads: false })
?