diff --git a/src/app/hooks/index.ts b/src/app/hooks/index.ts index f3596569..ff65a4b7 100644 --- a/src/app/hooks/index.ts +++ b/src/app/hooks/index.ts @@ -1,4 +1,6 @@ export * from './useActiveTimout' +export * from './useAsync' +export * from './useAsyncFn' export * from './useCanvas' export * from './useFocus' export * from './useHash' diff --git a/src/app/hooks/useAsync.ts b/src/app/hooks/useAsync.ts new file mode 100644 index 00000000..e0499022 --- /dev/null +++ b/src/app/hooks/useAsync.ts @@ -0,0 +1,17 @@ +import type { Inputs } from 'preact/hooks' +import { useEffect } from 'preact/hooks' +import type { AsyncState } from './useAsyncFn' +import { useAsyncFn } from './useAsyncFn' + +export function useAsync( + fn: () => Promise, + inputs: Inputs = [], +): AsyncState { + const [state, callback] = useAsyncFn Promise>(fn, inputs, { loading: true }) + + useEffect(() => { + callback() + }, [callback]) + + return state +} diff --git a/src/app/hooks/useAsyncFn.ts b/src/app/hooks/useAsyncFn.ts new file mode 100644 index 00000000..5fe877e5 --- /dev/null +++ b/src/app/hooks/useAsyncFn.ts @@ -0,0 +1,59 @@ +import type { Inputs } from 'preact/hooks' +import { useCallback, useEffect, useRef, useState } from 'preact/hooks' + + +export type AsyncState = { + loading: boolean, + error?: undefined, + value?: undefined, +} | { + loading: true, + error?: Error | undefined, + value?: T, +} | { + loading: false, + error: Error, + value?: undefined, +} | { + loading: false, + error?: undefined, + value: T, +} + +export function useAsyncFn Promise>( + fn: T, + inputs: Inputs = [], + initialState: AsyncState = { loading: false }, +): [AsyncState, (...args: Parameters) => Promise] { + const [state, setState] = useState>(initialState) + const isMounted = useRef(false) + const lastCallId = useRef(0) + + useEffect(() => { + isMounted.current = true + return () => isMounted.current = false + }, []) + + const callback = useCallback((...args: Parameters): Promise => { + const callId = ++lastCallId.current + if (!state.loading) { + setState(prev => ({ ...prev, loading: true })) + } + + return fn(...args).then( + value => { + if (isMounted.current && callId === lastCallId.current) { + setState({ value, loading: false }) + } + return value + }, + error => { + if (isMounted.current && callId === lastCallId.current) { + setState({ error, loading: false }) + } + return undefined + }) + }, inputs) + + return [state, callback] +} diff --git a/src/app/pages/Changelog.tsx b/src/app/pages/Changelog.tsx index ce31aac4..66d03582 100644 --- a/src/app/pages/Changelog.tsx +++ b/src/app/pages/Changelog.tsx @@ -1,7 +1,6 @@ -import { useEffect, useState } from 'preact/hooks' import { Ad, ChangelogList, ErrorPanel } from '../components' import { useLocale, useTitle } from '../contexts' -import type { Change } from '../services' +import { useAsync } from '../hooks' import { getChangelogs } from '../services' interface Props { @@ -9,20 +8,13 @@ interface Props { } export function Changelog({}: Props) { const { locale } = useLocale() - const [error, setError] = useState(null) useTitle(locale('title.changelog')) - const [changelogs, setChangelogs] = useState([]) - useEffect(() => { - getChangelogs() - .then(changelogs => setChangelogs(changelogs)) - .catch(e => { console.error(e); setError(e) }) - }, []) - + const { value: changelogs, error } = useAsync(getChangelogs, []) return
- {error && setError(null)} />} + {error && }
diff --git a/src/app/pages/Guide.tsx b/src/app/pages/Guide.tsx index 069ef6d3..b382d14e 100644 --- a/src/app/pages/Guide.tsx +++ b/src/app/pages/Guide.tsx @@ -6,7 +6,7 @@ import { useCallback, useEffect, useMemo, useState } from 'preact/hooks' import config from '../../config.json' import { Ad, Btn, BtnMenu, ChangelogTag, Giscus, Octicon } from '../components' import { useLocale, useTitle, useVersion } from '../contexts' -import { useActiveTimeout, useHash } from '../hooks' +import { useActiveTimeout, useAsync, useHash } from '../hooks' import type { VersionId } from '../services' import { parseFrontMatter, versionContent } from '../Utils' @@ -30,7 +30,10 @@ export function Guide({ id }: Props) { const { version, changeVersion } = useVersion() const { changeTitle } = useTitle() - const [content, setContent] = useState(undefined) + const { value: content } = useAsync(async () => { + const res = await fetch(`../../guides/${id}.md`) + return await res.text() + }, [id]) const frontMatter = useMemo(() => { if (!content) return undefined @@ -51,14 +54,8 @@ export function Guide({ id }: Props) { return allowedVersions[0] }, [version, frontMatter?.versions]) - const versionedContent = useMemo(() => { - if (!content) return undefined - const guide = content.substring(content.indexOf('---', 3) + 3) - return versionContent(guide, guideVersion) - }, [guideVersion, content]) - const html = useMemo(() => { - if (!versionedContent) return undefined + if (!content) return undefined marked.use({ renderer: { link(href, title, text) { if (href === null) return text @@ -72,8 +69,10 @@ export function Guide({ id }: Props) { return `${link}${text}` }, }}) + const guide = content.substring(content.indexOf('---', 3) + 3) + const versionedContent = versionContent(guide, guideVersion) return marked(versionedContent, { version: '1.19' } as any) - }, [versionedContent]) + }, [guideVersion, content]) const [hash, setHash] = useHash() @@ -100,14 +99,6 @@ export function Guide({ id }: Props) { } }, [scrollToHeading, hash, version]) - useEffect(() => { - (async () => { - const res = await fetch(`../../guides/${id}.md`) - const text = await res.text() - setContent(text) - })() - }, [id]) - const [shareActive, shareSuccess] = useActiveTimeout() const onShare = useCallback(() => { @@ -116,9 +107,9 @@ export function Guide({ id }: Props) { shareSuccess() }, [id, version]) - const onClickTag = (tag: string) => { + const onClickTag = useCallback((tag: string) => { route(`/guides/?tags=${tag}`) - } + }, []) const [largeWidth] = useState(window.innerWidth > 600) diff --git a/src/app/pages/Project.tsx b/src/app/pages/Project.tsx index 055c70ff..b6df91fd 100644 --- a/src/app/pages/Project.tsx +++ b/src/app/pages/Project.tsx @@ -1,4 +1,4 @@ -import { useMemo } from 'preact/hooks' +import { useCallback, useMemo } from 'preact/hooks' import { Ad, TreeView } from '../components' import { getFilePath, useLocale, useProject, useTitle } from '../contexts' @@ -11,10 +11,10 @@ export function Project({}: Props) { useTitle(locale('title.project', project.name)) const entries = useMemo(() => project.files.map(getFilePath), project.files) - const selectFile = (entry: string) => { + const selectFile = useCallback((entry: string) => { const [, namespace, type, ...id] = entry.split('/') openFile(type, `${namespace}:${id}`) - } + }, [openFile]) return
diff --git a/src/app/pages/Sounds.tsx b/src/app/pages/Sounds.tsx index 97ee7545..b8072640 100644 --- a/src/app/pages/Sounds.tsx +++ b/src/app/pages/Sounds.tsx @@ -1,9 +1,10 @@ import type { Howl, HowlOptions } from 'howler' -import { useEffect, useRef, useState } from 'preact/hooks' +import { useEffect, useMemo, useRef, useState } from 'preact/hooks' import config from '../../config.json' import { Btn, BtnMenu, ErrorPanel, SoundConfig, TextInput } from '../components' import { useLocale, useTitle, useVersion } from '../contexts' -import type { SoundEvents, VersionId } from '../services' +import { useAsync } from '../hooks' +import type { VersionId } from '../services' import { fetchSounds } from '../services' import { hexId } from '../Utils' @@ -13,7 +14,6 @@ interface Props { export function Sounds({}: Props) { const { locale } = useLocale() const { version, changeVersion } = useVersion() - const [error, setError] = useState(null) useTitle(locale('title.sounds')) const [howler, setHowler] = useState Howl)>(undefined) @@ -24,13 +24,10 @@ export function Sounds({}: Props) { })() }, []) - const [sounds, setSounds] = useState({}) - const soundKeys = Object.keys(sounds ?? {}) - useEffect(() => { - fetchSounds(version) - .then(setSounds) - .catch(e => { console.error(e); setError(e) }) + const { value: sounds, error } = useAsync(async () => { + return await fetchSounds(version) }, [version]) + const soundKeys = useMemo(() => Object.keys(sounds ?? {}), [sounds]) const [search, setSearch] = useState('') const [configs, setConfigs] = useState([]) @@ -63,7 +60,7 @@ export function Sounds({}: Props) { } return
- {error && setError(null)} />} + {error && } {soundKeys.length > 0 && <>
@@ -73,7 +70,7 @@ export function Sounds({}: Props) {
{configs.length > 1 && }
- + {config.versions.slice().reverse().map(v => changeVersion(v.id as VersionId)} /> @@ -81,14 +78,14 @@ export function Sounds({}: Props) {
- {howler && configs.map(c => + {sounds && howler && configs.map(c => )}
+ + {soundKeys.map(s => } - - {soundKeys.map(s =>
} diff --git a/src/app/pages/Versions.tsx b/src/app/pages/Versions.tsx index 5e38b6fd..319f365b 100644 --- a/src/app/pages/Versions.tsx +++ b/src/app/pages/Versions.tsx @@ -1,7 +1,6 @@ -import { useEffect, useState } from 'preact/hooks' import { Ad, ErrorPanel, Octicon, VersionDetail, VersionList } from '../components' import { useLocale, useTitle } from '../contexts' -import { useSearchParam } from '../hooks' +import { useAsync, useSearchParam } from '../hooks' import type { VersionMeta } from '../services' import { fetchVersions } from '../services' @@ -10,27 +9,21 @@ interface Props { } export function Versions({}: Props) { const { locale } = useLocale() - const [error, setError] = useState(null) useTitle(locale('title.versions')) - const [versions, setVersions] = useState([]) - useEffect(() => { - fetchVersions() - .then(versions => setVersions(versions)) - .catch(e => { console.error(e); setError(e) }) - }, []) + const { value: versions, error } = useAsync(fetchVersions, []) const [selectedId] = useSearchParam('id') - const selected = versions.find(v => v.id === selectedId) + const selected = (versions ?? []).find(v => v.id === selectedId) useTitle(selected ? selected.name : 'Versions Explorer', selected ? [] : undefined) - const nextVersion = selected && getOffsetVersion(versions, selected, -1) - const previousVersion = selected && getOffsetVersion(versions, selected, 1) + const nextVersion = selected && getOffsetVersion(versions ?? [], selected, -1) + const previousVersion = selected && getOffsetVersion(versions ?? [], selected, 1) return
- {error && setError(null)} />} + {error && }
{selectedId ? <>
} - : `/versions/?id=${id}`} />} + : `/versions/?id=${id}`} />}
}