Skip to content
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

fix: allow explore state to be updated outside of hashchange events #457

Merged
merged 2 commits into from
Oct 30, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 10 additions & 15 deletions dev/devPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { createRoot } from 'react-dom/client'
import { I18nextProvider, useTranslation } from 'react-i18next'
import 'tachyons'
import i18n from '../src/i18n.js'
import { ExplorePage, StartExploringPage, IpldExploreForm, IpldCarExploreForm, ExploreProvider, HeliaProvider } from '../src/index.js'
import { ExplorePage, StartExploringPage, IpldExploreForm, IpldCarExploreForm, ExploreProvider, HeliaProvider, useExplore } from '../src/index.js'

globalThis.Buffer = globalThis.Buffer ?? Buffer

Expand Down Expand Up @@ -64,27 +64,22 @@ const HeaderComponent: React.FC = () => {
}

const PageRenderer = (): React.ReactElement => {
const [route, setRoute] = useState(window.location.hash.slice(1) ?? '/')
const { setExplorePath, exploreState: { path } } = useExplore()

useEffect(() => {
const onHashChange = (): void => { setRoute(window.location.hash.slice(1) ?? '/') }
const onHashChange = (): void => {
const newRoute = window.location.hash ?? null
setExplorePath(newRoute)
}
window.addEventListener('hashchange', onHashChange)
return () => { window.removeEventListener('hashchange', onHashChange) }
}, [])
}, [setExplorePath])

const RenderPage: React.FC = () => {
switch (true) {
case route.startsWith('/explore'):
return <ExplorePage />
case route === '/':
default:
return <StartExploringPage />
}
if (path == null || path === '') {
return <StartExploringPage />
}

return (
<RenderPage />
)
return <ExplorePage />
}

const App = (): React.ReactElement => {
Expand Down
1 change: 0 additions & 1 deletion src/components/ExplorePage.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ const defaultState: ExploreState = {
path: 'QmdmQXB2mzChmMeKY47C43LxUdg1NDJ5MWcKMKxDu7RgQm',
canonicalPath: 'QmdmQXB2mzChmMeKY47C43LxUdg1NDJ5MWcKMKxDu7RgQm',
error: null,
explorePathFromHash: 'QmdmQXB2mzChmMeKY47C43LxUdg1NDJ5MWcKMKxDu7RgQm',
targetNode: {
type: 'dag-pb',
format: 'unixfs',
Expand Down
4 changes: 2 additions & 2 deletions src/components/ExplorePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,9 @@ export const ExplorePage = ({
const { t, ready: tReady } = useTranslation('explore')

const { exploreState, doExploreLink } = useExplore()
const { explorePathFromHash } = exploreState
const { path } = exploreState

if (explorePathFromHash == null) {
if (path == null) {
// No IPLD path to explore so show the intro page
console.warn('[IPLD Explorer] ExplorePage loaded without a path to explore')
return null
Expand Down
2 changes: 1 addition & 1 deletion src/components/loader/loader.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import React from 'react'
import styles from './loader.module.css'

export const Loader: React.FC<{ color: string }> = ({ color = 'light', ...props }) => {
export const Loader: React.FC<{ color?: string }> = ({ color = 'light', ...props }) => {
const className = `dib ${styles.laBallTrianglePath} la-${color} la-sm`
return (
<div {...props}>
Expand Down
166 changes: 100 additions & 66 deletions src/providers/explore.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,9 @@ import type { NormalizedDagNode } from '../types.js'

interface ExploreContextProps {
exploreState: ExploreState
// explorePathFromHash: string | null
explorePathPrefix: string
isLoading: boolean
setExplorePath(path: string | null): void
doExploreLink(link: any): void
doExploreUserProvidedPath(path: string): void
doUploadUserProvidedCar(file: File, uploadImage: string): Promise<void>
Expand All @@ -37,7 +39,6 @@ export interface ExploreState {
nodes: any[]
pathBoundaries: any[]
error: IpldExploreError | null
explorePathFromHash: string | null
}

export const ExploreContext = createContext<ExploreContextProps | undefined>(undefined)
Expand All @@ -58,51 +59,108 @@ const getCidFromCidOrFqdn = (cidOrFqdn: CID | string): CID => {
return CID.parse(cidOrFqdn)
}

const processPath = (path: string | null, pathPrefix: string): string | null => {
let newPath = path
if (newPath != null) {
if (newPath.includes(pathPrefix)) {
newPath = newPath.slice(pathPrefix.length)
}
if (newPath.startsWith('/')) {
newPath = newPath.slice(1)
}
if (newPath === '') {
newPath = null
} else {
newPath = decodeURIComponent(newPath)
}
}
return newPath
}

const defaultState: ExploreState = {
path: null,
targetNode: null,
canonicalPath: '',
localPath: '',
nodes: [],
pathBoundaries: [],
error: null,
explorePathFromHash: null
error: null
}

export interface ExploreProviderProps {
children?: ReactNode | ReactNode[]
state?: Partial<ExploreState>
explorePathPrefix?: string
}

export const ExploreProvider = ({ children, state = defaultState }: { children?: ReactNode, state?: ExploreState }): React.ReactNode => {
const [exploreState, setExploreState] = useState<ExploreState>({ ...state, explorePathFromHash: window.location.hash.slice('#/explore'.length) })
export const ExploreProvider = ({ children, state, explorePathPrefix = '#/explore' }: ExploreProviderProps): React.ReactNode => {
if (state == null) {
state = {
path: processPath(window.location.hash, explorePathPrefix)
}
} else {
if (state.path === '') {
state.path = null
} else if (state.path != null) {
state.path = processPath(state.path, explorePathPrefix)
}
}
const [exploreState, setExploreState] = useState<ExploreState>({ ...defaultState, ...state })
const { helia } = useHelia()
const { explorePathFromHash } = exploreState
const [isLoading, setIsLoading] = useState<boolean>(false)
const { path } = exploreState

const fetchExploreData = useCallback(async (path: string): Promise<void> => {
// Clear the target node when a new path is requested
setExploreState((exploreState) => ({
...exploreState,
targetNode: null
}))
const pathParts = parseIpldPath(path)
if (pathParts == null || helia == null) return
useEffect(() => {
setIsLoading(true);

const { cidOrFqdn, rest } = pathParts
try {
const cid = getCidFromCidOrFqdn(cidOrFqdn)
const { targetNode, canonicalPath, localPath, nodes, pathBoundaries } = await resolveIpldPath(helia, cid, rest)

setExploreState(({ explorePathFromHash }) => ({
explorePathFromHash,
path,
targetNode,
canonicalPath,
localPath,
nodes,
pathBoundaries,
error: null
(async () => {
if (path == null || helia == null) {
return
}
// Clear the target node when a new path is requested
setExploreState((exploreState) => ({
...exploreState,
targetNode: null
}))
} catch (error: any) {
console.warn('Failed to resolve path', path, error)
setExploreState((prevState) => ({ ...prevState, error }))
const pathParts = parseIpldPath(path)
if (pathParts == null || helia == null) return

const { cidOrFqdn, rest } = pathParts
try {
const cid = getCidFromCidOrFqdn(cidOrFqdn)
const { targetNode, canonicalPath, localPath, nodes, pathBoundaries } = await resolveIpldPath(helia, cid, rest)

setExploreState((curr) => ({
...curr,
targetNode,
canonicalPath,
localPath,
nodes,
pathBoundaries,
error: null
}))
} catch (error: any) {
console.warn('Failed to resolve path', path, error)
setExploreState((prevState) => ({ ...prevState, error }))
}
})().catch((err) => {
console.error('Error fetching explore data', err)
setExploreState((prevState) => ({ ...prevState, error: err }))
}).finally(() => {
setIsLoading(false)
})
}, [helia, path])

const setExplorePath = (path: string | null): void => {
const newPath = processPath(path, explorePathPrefix)
if (newPath != null && !window.location.href.includes(newPath)) {
throw new Error('setExplorePath should only be used to update the state, not the URL. If you are using a routing library that doesn\'t allow you to listen to hashchange events, ensure the URL is updated prior to calling setExplorePath.')
}
}, [helia])
setExploreState((exploreState) => ({
...exploreState,
path: newPath
}))
}

const doExploreLink = (link: LinkObject): void => {
const { nodes, pathBoundaries } = exploreState
Expand All @@ -114,12 +172,16 @@ export const ExploreProvider = ({ children, state = defaultState }: { children?:
}
pathParts.unshift(cid)
const path = pathParts.map((part) => encodeURIComponent(part)).join('/')
const hash = `#/explore/${path}`
const hash = `${explorePathPrefix}/${path}`
window.location.hash = hash
setExplorePath(path)
}

/**
* @deprecated - use setExplorePath instead
*/
const doExploreUserProvidedPath = (path: string): void => {
const hash = path != null ? `#/explore${ensureLeadingSlash(path)}` : '#/explore'
const hash = path != null ? `${explorePathPrefix}${ensureLeadingSlash(path)}` : explorePathPrefix
window.location.hash = hash
}

Expand All @@ -130,7 +192,7 @@ export const ExploreProvider = ({ children, state = defaultState }: { children?:
}
try {
const rootCid = await importCar(file, helia)
const hash = rootCid.toString() != null ? `#/explore${ensureLeadingSlash(rootCid.toString())}` : '#/explore'
const hash = rootCid.toString() != null ? `${explorePathPrefix}${ensureLeadingSlash(rootCid.toString())}` : explorePathPrefix
window.location.hash = hash

const imageFileLoader = document.getElementById('car-loader-image') as HTMLImageElement
Expand All @@ -140,42 +202,14 @@ export const ExploreProvider = ({ children, state = defaultState }: { children?:
} catch (err) {
console.error('Could not import car file', err)
}
}, [helia])

useEffect(() => {
const handleHashChange = (): void => {
const explorePathFromHash = window.location.hash.slice('#/explore'.length)

setExploreState((state) => ({
...state,
explorePathFromHash
}))
}

window.addEventListener('hashchange', handleHashChange)
handleHashChange()

return () => {
window.removeEventListener('hashchange', handleHashChange)
}
}, [])

useEffect(() => {
// if explorePathFromHash or helia change and are not null, fetch the data
// We need to check for helia because the helia provider is async and may not be ready yet
if (explorePathFromHash != null && helia != null) {
void (async () => {
await fetchExploreData(decodeURIComponent(explorePathFromHash))
})()
}
}, [helia, explorePathFromHash])
}, [explorePathPrefix, helia])

if (helia == null) {
return <Loader color='dark' />
}

return (
<ExploreContext.Provider value={{ exploreState, doExploreLink, doExploreUserProvidedPath, doUploadUserProvidedCar }}>
<ExploreContext.Provider value={{ exploreState, explorePathPrefix, isLoading, doExploreLink, doExploreUserProvidedPath, doUploadUserProvidedCar, setExplorePath }} key={path}>
{children}
</ExploreContext.Provider>
)
Expand Down
Loading