diff --git a/client/js/selfoss-base.js b/client/js/selfoss-base.js index ad5b5d45a..10336b42b 100644 --- a/client/js/selfoss-base.js +++ b/client/js/selfoss-base.js @@ -152,6 +152,10 @@ const selfoss = { basePath, appRef: (app) => { selfoss.app = app; + + return () => { + selfoss.app = null; + }; }, configuration, }), diff --git a/client/js/templates/App.jsx b/client/js/templates/App.jsx index cc74753e8..92f498174 100644 --- a/client/js/templates/App.jsx +++ b/client/js/templates/App.jsx @@ -172,6 +172,11 @@ function PureApp({ const entriesRef = useCallback((entriesPage) => { setEntriesPage(entriesPage); selfoss.entriesPage = entriesPage; + + return () => { + setEntriesPage(null); + selfoss.entriesPage = null; + }; }, []); const [title, setTitle] = useState(null); @@ -812,8 +817,8 @@ export default class App extends React.Component { render() { return ( - - + + - - + + ); } } diff --git a/client/js/templates/EntriesPage.jsx b/client/js/templates/EntriesPage.jsx index 2cb67840c..1a5f6cc80 100644 --- a/client/js/templates/EntriesPage.jsx +++ b/client/js/templates/EntriesPage.jsx @@ -4,7 +4,6 @@ import React, { useEffect, useMemo, useState, - forwardRef, } from 'react'; import PropTypes from 'prop-types'; import { Link, useLocation, useParams } from 'react-router'; @@ -1255,16 +1254,14 @@ StateHolder.propTypes = { unreadItemsCount: PropTypes.number.isRequired, }; -const StateHolderOuter = forwardRef(function StateHolderOuter( - { - configuration, - setNavExpanded, - navSourcesExpanded, - setGlobalUnreadCount, - unreadItemsCount, - }, +export default function StateHolderOuter({ + configuration, + setNavExpanded, + navSourcesExpanded, + setGlobalUnreadCount, + unreadItemsCount, ref, -) { +}) { const location = useLocation(); const navigate = useNavigate(); const params = useParams(); @@ -1282,14 +1279,13 @@ const StateHolderOuter = forwardRef(function StateHolderOuter( unreadItemsCount={unreadItemsCount} /> ); -}); +} StateHolderOuter.propTypes = { + ref: PropTypes.func.isRequired, configuration: PropTypes.object.isRequired, setNavExpanded: PropTypes.func.isRequired, navSourcesExpanded: PropTypes.bool.isRequired, setGlobalUnreadCount: PropTypes.func.isRequired, unreadItemsCount: PropTypes.number.isRequired, }; - -export default StateHolderOuter; diff --git a/client/js/templates/HashPassword.jsx b/client/js/templates/HashPassword.jsx index 5b2047bce..24746e65f 100644 --- a/client/js/templates/HashPassword.jsx +++ b/client/js/templates/HashPassword.jsx @@ -1,45 +1,48 @@ import PropTypes from 'prop-types'; -import React, { useCallback, useEffect, useState } from 'react'; +import React, { + startTransition, + useActionState, + useCallback, + useEffect, +} from 'react'; import { useNavigate } from 'react-router'; -import { useInput } from 'rooks'; -import { LoadingState } from '../requests/LoadingState'; import { HttpError } from '../errors'; import { hashPassword } from '../requests/common'; export default function HashPassword({ setTitle }) { - const [state, setState] = useState(LoadingState.INITIAL); - const [hashedPassword, setHashedPassword] = useState(''); - const [error, setError] = useState(null); - const passwordEntry = useInput(''); - const navigate = useNavigate(); + const [ + /** @type {({} | { hashedPassword: string } | { error: Error })} */ + state, + submitAction, + isPending, + ] = useActionState(async (_previousState, formData) => { + try { + const password = formData.get('password').trim(); + const hashedPassword = await hashPassword(password); + return { hashedPassword }; + } catch (error) { + if (error instanceof HttpError && error.response.status === 403) { + navigate('/sign/in', { + error: 'Generating a new password hash requires being logged in or not setting “password” in selfoss configuration.', + returnLocation: '/password', + }); + return {}; + } + return { error }; + } + }, {}); + const submit = useCallback( (event) => { + // Unlike `action` prop, `onSubmit` avoids clearing the form on submit. + // https://github.com/facebook/react/issues/29034#issuecomment-2143595195 event.preventDefault(); - - setState(LoadingState.LOADING); - hashPassword(passwordEntry.value.trim()) - .then((hashedPassword) => { - setHashedPassword(hashedPassword); - setState(LoadingState.SUCCESS); - }) - .catch((error) => { - if ( - error instanceof HttpError && - error.response.status === 403 - ) { - navigate('/sign/in', { - error: 'Generating a new password hash requires being logged in or not setting “password” in selfoss configuration.', - returnLocation: '/password', - }); - return; - } - setError(error); - setState(LoadingState.ERROR); - }); + const formData = new FormData(event.target); + startTransition(() => submitAction(formData)); }, - [navigate, passwordEntry.value], + [submitAction], ); useEffect(() => { @@ -50,22 +53,21 @@ export default function HashPassword({ setTitle }) { }; }, [setTitle]); - const message = - state === LoadingState.SUCCESS ? ( -

- -

- ) : state === LoadingState.ERROR ? ( -

- Unexpected happened. -

-
${JSON.stringify(error)}
-
-

- ) : null; + const message = isPending ? null : 'hashedPassword' in state ? ( +

+ +

+ ) : 'error' in state ? ( +

+ Unexpected happened. +

+
${JSON.stringify(state.error)}
+
+

+ ) : null; return (
@@ -80,7 +82,6 @@ export default function HashPassword({ setTitle }) { name="password" autoComplete="new-password" accessKey="p" - {...passwordEntry} />
  • @@ -91,13 +92,9 @@ export default function HashPassword({ setTitle }) {
  • diff --git a/client/js/templates/LoginForm.jsx b/client/js/templates/LoginForm.jsx index a5bf823f5..27ab61420 100644 --- a/client/js/templates/LoginForm.jsx +++ b/client/js/templates/LoginForm.jsx @@ -1,4 +1,9 @@ -import React, { useCallback, useContext, useState } from 'react'; +import React, { + startTransition, + useCallback, + useContext, + useActionState, +} from 'react'; import PropTypes from 'prop-types'; import classNames from 'classnames'; import { SpinnerBig } from './Spinner'; @@ -7,95 +12,73 @@ import { HttpError, LoginError } from '../errors'; import { ConfigurationContext } from '../helpers/configuration'; import { LocalizationContext } from '../helpers/i18n'; -function handleLogIn({ - event, +async function handleLogIn({ configuration, navigate, - setLoading, username, password, enableOffline, returnLocation, }) { - event.preventDefault(); - - setLoading(true); - - selfoss - .login({ configuration, username, password, enableOffline }) - .then(() => { - navigate(returnLocation); - }) - .catch((err) => { - const message = - err instanceof LoginError - ? selfoss.app._('login_invalid_credentials') - : selfoss.app._('login_error_generic', { - errorMessage: - err instanceof HttpError - ? `HTTP ${err.response.status} ${err.message}` - : err.message, - }); - navigate('/sign/in', { - replace: true, - state: { - error: message, - }, - }); - }) - .finally(() => { - setLoading(false); + try { + await selfoss.login({ + configuration, + username, + password, + enableOffline, + }); + navigate(returnLocation); + } catch (err) { + const message = + err instanceof LoginError + ? selfoss.app._('login_invalid_credentials') + : selfoss.app._('login_error_generic', { + errorMessage: + err instanceof HttpError + ? `HTTP ${err.response.status} ${err.message}` + : err.message, + }); + navigate('/sign/in', { + replace: true, + state: { + error: message, + }, }); + } } export default function LoginForm({ offlineEnabled }) { - const [username, setUsername] = useState(''); - const [password, setPassword] = useState(''); - const [loading, setLoading] = useState(false); - const [enableOffline, setEnableOffline] = useState(offlineEnabled); - const configuration = useContext(ConfigurationContext); const navigate = useNavigate(); const location = useLocation(); const error = location?.state?.error; const returnLocation = location?.state?.returnLocation ?? '/'; - const formOnSubmit = useCallback( - (event) => - handleLogIn({ - event, + const [, submitAction, loading] = useActionState( + async (_previousState, formData) => { + const username = formData.get('username'); + const password = formData.get('password'); + const enableOffline = formData.get('enableoffline'); + await handleLogIn({ configuration, navigate, - setLoading, username, password, enableOffline, returnLocation, - }), - [ - configuration, - navigate, - username, - password, - enableOffline, - returnLocation, - ], - ); - - const usernameOnChange = useCallback( - (event) => setUsername(event.target.value), - [], - ); - - const passwordOnChange = useCallback( - (event) => setPassword(event.target.value), - [], + }); + return null; + }, + null, ); - const offlineOnChange = useCallback( - (event) => setEnableOffline(event.target.checked), - [setEnableOffline], - ); + const formOnSubmit = useCallback((event) => { + // Unlike `action` prop, `onSubmit` avoids clearing the form on submit. + // https://github.com/facebook/react/issues/29034#issuecomment-2143595195 + event.preventDefault(); + const formData = new FormData(event.target); + startTransition(() => submitAction(formData)); + }, []); const _ = useContext(LocalizationContext); @@ -120,8 +103,6 @@ export default function LoginForm({ offlineEnabled }) { id="username" accessKey="u" autoComplete="username" - onChange={usernameOnChange} - value={username} autoFocus required /> @@ -134,8 +115,6 @@ export default function LoginForm({ offlineEnabled }) { id="password" accessKey="p" autoComplete="current-password" - onChange={passwordOnChange} - value={password} />
  • @@ -148,8 +127,7 @@ export default function LoginForm({ offlineEnabled }) { name="enableoffline" id="enableoffline" accessKey="o" - onChange={offlineOnChange} - checked={enableOffline} + defaultChecked={offlineEnabled} />{' '} {_('experimental')} diff --git a/client/js/templates/OpmlImport.jsx b/client/js/templates/OpmlImport.jsx index 15a95389d..f4c2d3d9b 100644 --- a/client/js/templates/OpmlImport.jsx +++ b/client/js/templates/OpmlImport.jsx @@ -1,98 +1,95 @@ -import React, { useCallback, useEffect, useRef, useState } from 'react'; +import React, { + startTransition, + useActionState, + useCallback, + useEffect, + useRef, +} from 'react'; import PropTypes from 'prop-types'; import { useOnline } from 'rooks'; import { Link, useNavigate } from 'react-router'; -import { LoadingState } from '../requests/LoadingState'; import { HttpError, UnexpectedStateError } from '../errors'; import { importOpml } from '../requests/common'; export default function OpmlImport({ setTitle }) { - const [state, setState] = useState(LoadingState.INITIAL); - const [message, setMessage] = useState(null); - const fileEntry = useRef(); + const fileEntry = useRef(undefined); const navigate = useNavigate(); + const [message, submitAction, isPending] = useActionState(async () => { + const file = fileEntry.current.files[0]; + try { + const { response, data } = await importOpml(file); + const { messages } = data; + + if (response.status === 200) { + return ( +

    +

      + {messages.map((msg, i) => ( +
    • {msg}
    • + ))} +
    + You might want to update now or{' '} + view your feeds. +

    + ); + } else if (response.status === 202) { + return ( +

    + The following feeds could not be imported: +
    +

      + {messages.map((msg, i) => ( +
    • {msg}
    • + ))} +
    +

    + ); + } else if (response.status === 400) { + return ( +

    + There was a problem importing your OPML file: +
    +

      + {messages.map((msg, i) => ( +
    • {msg}
    • + ))} +
    +

    + ); + } else { + throw new UnexpectedStateError( + `OPML import handler received status ${response.status}. This should not happen.`, + ); + } + } catch (error) { + if (error instanceof HttpError && error.response.status === 403) { + navigate('/sign/in', { + error: 'Importing OPML file requires being logged in or not setting “password” in selfoss configuration.', + returnLocation: '/opml', + }); + return null; + } else { + return ( +
    + Unexpected error occurred. +
    +
    {error.message}
    +
    +
    + ); + } + } + }, null); + const submit = useCallback( (event) => { + // We cannot use `action` prop with `enctype`. event.preventDefault(); - - setState(LoadingState.LOADING); - const file = fileEntry.current.files[0]; - importOpml(file) - .then(({ response, data }) => { - const { messages } = data; - - if (response.status === 200) { - setState(LoadingState.SUCCESS); - setMessage( -

    -

      - {messages.map((msg, i) => ( -
    • {msg}
    • - ))} -
    - You might want to{' '} - update now or{' '} - view your feeds. -

    , - ); - } else if (response.status === 202) { - setState(LoadingState.ERROR); - setMessage( -

    - The following feeds could not be imported: -
    -

      - {messages.map((msg, i) => ( -
    • {msg}
    • - ))} -
    -

    , - ); - } else if (response.status === 400) { - setState(LoadingState.ERROR); - setMessage( -

    - There was a problem importing your OPML file: -
    -

      - {messages.map((msg, i) => ( -
    • {msg}
    • - ))} -
    -

    , - ); - } else { - throw new UnexpectedStateError( - `OPML import handler received status ${response.status}. This should not happen.`, - ); - } - }) - .catch((error) => { - if ( - error instanceof HttpError && - error.response.status === 403 - ) { - navigate('/sign/in', { - error: 'Importing OPML file requires being logged in or not setting “password” in selfoss configuration.', - returnLocation: '/opml', - }); - return; - } else { - setState(LoadingState.ERROR); - setMessage( -
    - Unexpected error occurred. -
    -
    {error.message}
    -
    -
    , - ); - } - }); + startTransition(() => submitAction()); }, - [navigate], + [submitAction], ); useEffect(() => { @@ -131,7 +128,7 @@ export default function OpmlImport({ setTitle }) { reconnect before proceeding.
  • )} - {message} + {!isPending && message}
  • @@ -147,13 +144,9 @@ export default function OpmlImport({ setTitle }) {
  • diff --git a/client/package-lock.json b/client/package-lock.json index 4d28e844f..6d7d83885 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -24,8 +24,8 @@ "prop-types": "^15.7.2", "prop-types-nullable": "^1.0.1", "ramda": "^0.30.0", - "react": "^18.2.0", - "react-dom": "^18.2.0", + "react": "^19.0.0", + "react-dom": "^19.0.0", "react-router": "^7.1.1", "reset-css": "^5.0.1", "rooks": "^7.1.1", @@ -6352,28 +6352,24 @@ } }, "node_modules/react": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", - "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "version": "19.0.0", + "resolved": "https://registry.npmjs.org/react/-/react-19.0.0.tgz", + "integrity": "sha512-V8AVnmPIICiWpGfm6GLzCR/W5FXLchHop40W4nXBmdlEceh16rCN8O8LNWm5bh5XUX91fh7KpA+W0TgMKmgTpQ==", "license": "MIT", - "dependencies": { - "loose-envify": "^1.1.0" - }, "engines": { "node": ">=0.10.0" } }, "node_modules/react-dom": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", - "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "version": "19.0.0", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.0.0.tgz", + "integrity": "sha512-4GV5sHFG0e/0AD4X+ySy6UJd3jVl1iNsNHdpad0qhABJ11twS3TTBnseqsKurKcsNqCEFeGL3uLpVChpIO3QfQ==", "license": "MIT", "dependencies": { - "loose-envify": "^1.1.0", - "scheduler": "^0.23.2" + "scheduler": "^0.25.0" }, "peerDependencies": { - "react": "^18.3.1" + "react": "^19.0.0" } }, "node_modules/react-error-overlay": { @@ -6682,13 +6678,10 @@ } }, "node_modules/scheduler": { - "version": "0.23.2", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", - "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", - "license": "MIT", - "dependencies": { - "loose-envify": "^1.1.0" - } + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.25.0.tgz", + "integrity": "sha512-xFVuu11jh+xcO7JOAGJNOXld8/TcEHK/4CituBUeUb5hqxJLj9YuemAEuvm9gQ/+pgXYfbQuqAkiYu+u7YEsNA==", + "license": "MIT" }, "node_modules/semver": { "version": "7.6.3", diff --git a/client/package.json b/client/package.json index f2be6ebda..996115682 100644 --- a/client/package.json +++ b/client/package.json @@ -20,8 +20,8 @@ "prop-types": "^15.7.2", "prop-types-nullable": "^1.0.1", "ramda": "^0.30.0", - "react": "^18.2.0", - "react-dom": "^18.2.0", + "react": "^19.0.0", + "react-dom": "^19.0.0", "react-router": "^7.1.1", "reset-css": "^5.0.1", "rooks": "^7.1.1",