Skip to content

Commit

Permalink
chore: extract and test the datamodel logic from Provider (#602)
Browse files Browse the repository at this point in the history
One of the core goals here was to fix a bug where the client wouldn't
get reset when servicePrincipal or connection changed. The easiest way
to make sure this was tested was to extract all of the datamodel
creation and management logic to a dedicated hook and test that.

Thanks to @gobengo for pointing out the bug and suggesting the fix:
#595 (comment)
  • Loading branch information
travis authored Dec 4, 2023
1 parent af4b819 commit ceda4b6
Show file tree
Hide file tree
Showing 6 changed files with 181 additions and 50 deletions.
1 change: 1 addition & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export type Store = Driver<AgentDataExport>

const DB_NAME = '@w3ui'
const DB_STORE_NAME = 'core'
export const STORE_SAVE_EVENT = 'store:save'

export interface ContextState {
/**
Expand Down
6 changes: 5 additions & 1 deletion packages/react/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,10 @@
"ariakit-react-utils": "0.17.0-next.27"
},
"devDependencies": {
"eslint-plugin-react-hooks": "^4.6.0",
"@ipld/dag-ucan": "^3.2.0",
"@testing-library/react": "^13.4.0",
"@testing-library/react-hooks": "^8.0.1",
"@testing-library/user-event": "^14.4.3",
"@ucanto/interface": "^9.0.0",
"@ucanto/principal": "^9.0.0",
Expand All @@ -47,7 +50,8 @@
},
"eslintConfig": {
"extends": [
"../../eslint.packages.js"
"../../eslint.packages.js",
"plugin:react-hooks/recommended"
]
},
"eslintIgnore": [
Expand Down
77 changes: 77 additions & 0 deletions packages/react/src/hooks.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import type {
Client,
Space,
Account,
ServiceConfig
} from '@w3ui/core'

import { useState, useEffect, useCallback } from 'react'
import { STORE_SAVE_EVENT, createClient } from '@w3ui/core'

export type DatamodelProps = ServiceConfig

export interface Datamodel {
client?: Client
accounts: Account[]
spaces: Space[]
logout: () => Promise<void>
}

export function useDatamodel ({ servicePrincipal, connection }: DatamodelProps): Datamodel {
const [client, setClient] = useState<Client>()
const [events, setEvents] = useState<EventTarget>()
const [accounts, setAccounts] = useState<Account[]>([])
const [spaces, setSpaces] = useState<Space[]>([])

// update this function any time servicePrincipal or connection change
const setupClient = useCallback(
async (): Promise<void> => {
const { client, events } = await createClient({ servicePrincipal, connection })
setClient(client)
setEvents(events)
setAccounts(Object.values(client.accounts()))
setSpaces(client.spaces())
},
[servicePrincipal, connection]
)

// run setupClient once each time it changes
useEffect(
() => {
void setupClient()
},
[setupClient]
)

// set up event listeners to refresh accounts and spaces when
// the store:save event from @w3ui/core happens
useEffect(() => {
if ((client === undefined) || (events === undefined)) return

const handleStoreSave: () => void = () => {
setAccounts(Object.values(client.accounts()))
setSpaces(client.spaces())
}

events.addEventListener(STORE_SAVE_EVENT, handleStoreSave)
return () => {
events?.removeEventListener(STORE_SAVE_EVENT, handleStoreSave)
}
}, [client, events])

const logout = async (): Promise<void> => {
// it's possible that setupClient hasn't been run yet - run createClient here
// to get a reliable handle on the latest store
const { store } = await createClient({ servicePrincipal, connection })
await store.reset()
// set state back to defaults
setClient(undefined)
setEvents(undefined)
setAccounts([])
setSpaces([])
// set state up again
await setupClient()
}

return { client, accounts, spaces, logout }
}
53 changes: 4 additions & 49 deletions packages/react/src/providers/Provider.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,11 @@
import type {
Client,
ContextState,
ContextActions,
ServiceConfig,
Space,
Account
ServiceConfig
} from '@w3ui/core'

import React, { createContext, useState, useContext, useEffect, ReactNode } from 'react'
import { createClient } from '@w3ui/core'
import React, { createContext, useContext, ReactNode } from 'react'
import { useDatamodel } from '../hooks'

export { ContextState, ContextActions }

Expand Down Expand Up @@ -46,49 +43,7 @@ export function Provider ({
servicePrincipal,
connection
}: ProviderProps): ReactNode {
const [client, setClient] = useState<Client>()
const [events, setEvents] = useState<EventTarget>()
const [accounts, setAccounts] = useState<Account[]>([])
const [spaces, setSpaces] = useState<Space[]>([])

useEffect(() => {
if ((client === undefined) || (events === undefined)) return

const handleStoreSave: () => void = () => {
setAccounts(Object.values(client.accounts()))
setSpaces(client.spaces())
}

events.addEventListener('store:save', handleStoreSave)
return () => {
events?.removeEventListener('store:save', handleStoreSave)
}
}, [client, events])

const setupClient = async (): Promise<void> => {
const { client, events } = await createClient({ servicePrincipal, connection })
setClient(client)
setEvents(events)
setAccounts(Object.values(client.accounts()))
setSpaces(client.spaces())
}

const logout = async (): Promise<void> => {
// it's possible that setupClient hasn't been run yet - run createClient here
// to get a reliable handle on the latest store
const { store } = await createClient({ servicePrincipal, connection })
await store.reset()
// set state back to defaults
setClient(undefined)
setEvents(undefined)
setAccounts([])
setSpaces([])
// set state up again
await setupClient()
}

useEffect(() => { void setupClient() }, []) // load client - once.

const { client, accounts, spaces, logout } = useDatamodel({ servicePrincipal, connection })
return (
<Context.Provider value={[{ client, accounts, spaces }, { logout }]}>
{children}
Expand Down
52 changes: 52 additions & 0 deletions packages/react/test/hooks.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { test, expect } from 'vitest'
import 'fake-indexeddb/auto'
import { renderHook } from '@testing-library/react-hooks'
import * as DID from '@ipld/dag-ucan/did'
import { Principal, ConnectionView } from '@ucanto/interface'
import { connect } from '@ucanto/client'
import { CAR, HTTP } from '@ucanto/transport'

import { useDatamodel } from '../src/hooks'

test('should create a new client instance if and only if servicePrincipal or connection change', async () => {
let servicePrincipal: Principal = DID.parse('did:web:web3.storage')
let connection: ConnectionView<any> = connect({
id: servicePrincipal,
codec: CAR.outbound,
channel: HTTP.open<any>({
url: new URL('https://up.web3.storage'),
method: 'POST'
})
})
const { result, rerender, waitForValueToChange } = renderHook(() => useDatamodel({ servicePrincipal, connection }))
// wait for client to be initialized
await waitForValueToChange(() => result.current.client)

const firstClient = result.current.client
expect(firstClient).not.toBeFalsy()

rerender()
expect(result.current.client).toBe(firstClient)

servicePrincipal = DID.parse('did:web:web3.porridge')
rerender()
// wait for the client to change
await waitForValueToChange(() => result.current.client)
// this is a little superfluous - if it's false then the line before this will hang
// I still think it's worth keeping to illustrate the point
expect(result.current.client).not.toBe(firstClient)
const secondClient = result.current.client

connection = connect({
id: servicePrincipal,
codec: CAR.outbound,
channel: HTTP.open<any>({
url: new URL('https://up.web3.porridge'),
method: 'POST'
})
})
rerender()
await waitForValueToChange(() => result.current.client)
expect(result.current.client).not.toBe(firstClient)
expect(result.current.client).not.toBe(secondClient)
})
42 changes: 42 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

0 comments on commit ceda4b6

Please sign in to comment.