-
Notifications
You must be signed in to change notification settings - Fork 33
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
SuspenseStore refactor to allow caching of any HasBackend instance members #35
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,41 +1,55 @@ | ||
module React.Basic.Hooks.Suspense.Store | ||
( mkSuspenseStore | ||
, SuspenseStore | ||
( SuspenseStore | ||
, mkSuspenseStore | ||
, get | ||
, get' | ||
, class HasBackend | ||
, fromKey | ||
, backend | ||
) where | ||
|
||
import Prelude | ||
import Control.Alt ((<|>)) | ||
import Data.DateTime.Instant (Instant, unInstant) | ||
import Data.DateTime.Instant (unInstant) | ||
import Data.Either (Either(..)) | ||
import Data.Function (on) | ||
import Data.Int (ceil) | ||
import Data.Map (Map) | ||
import Data.Map as Map | ||
import Data.Maybe (Maybe(..)) | ||
import Data.Newtype (un) | ||
import Data.Ord (greaterThan) | ||
import Data.String (joinWith) | ||
import Data.Symbol (class IsSymbol, SProxy(..), reflectSymbol) | ||
import Data.Time.Duration (Milliseconds(..)) | ||
import Data.Tuple (fst, snd) | ||
import Effect (Effect) | ||
import Effect.Aff (Aff, attempt, launchAff, throwError) | ||
import Effect.Class (liftEffect) | ||
import Effect.Console (warn) | ||
import Effect.Exception (try) | ||
import Effect.Now (now) | ||
import Effect.Ref (Ref) | ||
import Effect.Ref as Ref | ||
import React.Basic.Hooks (type (/\), (/\)) | ||
import React.Basic.Hooks ((/\)) | ||
import React.Basic.Hooks.Suspense (Suspended(..), SuspenseResult(..)) | ||
import Unsafe.Coerce (unsafeCoerce) | ||
import Web.HTML (window) | ||
import Web.HTML.Window (requestIdleCallback) | ||
|
||
-- | Simple key-based cache. | ||
get :: forall k v s. HasBackend k v s => SuspenseStore -> k -> Suspended v | ||
get s k = _get s Nothing k | ||
|
||
get' :: forall k v s. HasBackend k v s => SuspenseStore -> Milliseconds -> k -> Suspended v | ||
get' s d k = _get s (Just d) k | ||
|
||
class | ||
IsSymbol s <= HasBackend k v (s :: Symbol) | k -> v s where | ||
fromKey :: k -> String | ||
backend :: k -> Aff v | ||
|
||
mkSuspenseStore :: | ||
forall k v. | ||
Ord k => | ||
Maybe Milliseconds -> | ||
(k -> Aff v) -> | ||
Effect (SuspenseStore k v) | ||
mkSuspenseStore defaultMaxAge backend = do | ||
Effect SuspenseStore | ||
mkSuspenseStore defaultMaxAge = do | ||
ref <- Ref.new mempty | ||
let | ||
isExpired maxAge now' (_ /\ d) = unInstant now' < unInstant d <> maxAge | ||
|
@@ -68,59 +82,92 @@ mkSuspenseStore defaultMaxAge backend = do | |
else | ||
pure (Just r) | ||
|
||
getCacheOrBackend itemMaxAge k = do | ||
insertIfNewer = | ||
Map.insertWith \r' r -> | ||
let | ||
gt = greaterThan `on` snd | ||
in | ||
if r `gt` r' then r else r' | ||
|
||
getCacheOrBackend :: Maybe Milliseconds -> StoreKey -> Effect (SuspenseResult Opaque) | ||
getCacheOrBackend itemMaxAge storable = do | ||
let | ||
k = toKey storable | ||
c <- tryFromCache itemMaxAge k | ||
case c of | ||
Just v -> pure v | ||
Nothing -> do | ||
fiber <- | ||
launchAff do | ||
r <- attempt do backend k | ||
r <- attempt do toAff storable | ||
liftEffect do | ||
let | ||
v = case r of | ||
Left e -> Failed e | ||
Right v' -> Complete v' | ||
d <- now | ||
_ <- | ||
ref | ||
# Ref.modify | ||
( k | ||
# Map.alter case _ of | ||
Nothing -> Just (v /\ d) | ||
Just r'@(v' /\ d') -> | ||
if d > d' then | ||
Just (v /\ d) | ||
else | ||
Just r' | ||
) | ||
_ <- ref # Ref.modify (insertIfNewer k (v /\ d)) | ||
case r of | ||
Left e -> throwError e | ||
Right v' -> pure v' | ||
let | ||
v = InProgress fiber | ||
d <- now | ||
_ <- ref # Ref.modify (Map.insert k (v /\ d)) | ||
pure v | ||
syncV <- map fst <$> Map.lookup k <$> Ref.read ref | ||
case syncV of | ||
-- `Just v` means the backend `Aff` ran synchronously so | ||
-- we just return that result | ||
Just v -> pure v | ||
Nothing -> do | ||
let | ||
v = InProgress fiber | ||
d <- now | ||
_ <- ref # Ref.modify (insertIfNewer k (v /\ d)) | ||
pure v | ||
do | ||
r <- try pruneCache | ||
case r of | ||
Left _ -> warn "Failed to initialize the suspense store cleanup task. Ensure you're using it in a browser with `requestIdleCallback` support." | ||
Right _ -> pure unit | ||
pure | ||
$ SuspenseStore | ||
{ cache: ref | ||
, get: map Suspended <<< getCacheOrBackend | ||
} | ||
pure $ SuspenseStore { get: getCacheOrBackend } | ||
|
||
newtype SuspenseStore k v | ||
newtype SuspenseStore | ||
= SuspenseStore | ||
{ cache :: Ref (Map k (SuspenseResult v /\ Instant)) | ||
, get :: Maybe Milliseconds -> k -> Suspended v | ||
{ get :: Maybe Milliseconds -> StoreKey -> Effect (SuspenseResult Opaque) | ||
} | ||
|
||
get :: forall k v. SuspenseStore k v -> k -> Suspended v | ||
get (SuspenseStore s) = s.get Nothing | ||
_get :: forall k v s. HasBackend k v s => SuspenseStore -> Maybe Milliseconds -> k -> Suspended v | ||
_get (SuspenseStore s) d k = | ||
Suspended do | ||
let | ||
storable = mkStorable k | ||
r <- s.get d storable | ||
pure (map (fromOpaque k) r) | ||
|
||
-- An opaque "cacheable". `StoreKey` packages up a `HasBackend` instance | ||
-- so the cache can use its `k -> String` and `k -> Aff v` functions | ||
-- without knowing about the internal types stored within the cache. | ||
data StoreKey | ||
= StoreKey | ||
(forall x. (forall k v s. HasBackend k v s => k -> x) -> x) | ||
|
||
mkStorable :: forall k v s. HasBackend k v s => k -> StoreKey | ||
mkStorable k = StoreKey \f -> f k | ||
|
||
class Storable k where | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Is the type class doing much for us here? In general I think defining a type class and not exporting it is a bit suspect; would normal functions work? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Same goes for HasOpaque There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Ah, you're right, thanks! |
||
toKey :: k -> String | ||
toAff :: k -> Aff Opaque | ||
|
||
instance storableStoreKey :: Storable StoreKey where | ||
toKey (StoreKey impl) = impl \k -> joinWith "" [ typeKey k, "[ ", fromKey k, " ]" ] | ||
where | ||
typeKey :: forall k v s. HasBackend k v s => k -> String | ||
typeKey _ = reflectSymbol (SProxy :: _ s) | ||
toAff (StoreKey impl) = impl \k -> map (toOpaque k) (backend k) | ||
|
||
data Opaque | ||
|
||
class HasOpaque k v | k -> v where | ||
toOpaque :: k -> v -> Opaque | ||
fromOpaque :: k -> Opaque -> v | ||
|
||
get' :: forall k v. SuspenseStore k v -> Milliseconds -> k -> Suspended v | ||
get' (SuspenseStore s) d = s.get (Just d) | ||
instance hasOpaque :: HasBackend k v s => HasOpaque k v where | ||
toOpaque _ = unsafeCoerce | ||
fromOpaque _ = unsafeCoerce |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,82 @@ | ||
module Test.Main where | ||
|
||
import Prelude | ||
import Data.Maybe (Maybe(..)) | ||
import Effect (Effect) | ||
import Effect.Aff (Milliseconds(..), delay, launchAff_) | ||
import Effect.Class (liftEffect) | ||
import Effect.Console (log) | ||
import React.Basic.Hooks.Suspense (Suspended(..), SuspenseResult) | ||
import React.Basic.Hooks.Suspense.Store (class HasBackend, fromKey, get, mkSuspenseStore) | ||
import Unsafe.Coerce (unsafeCoerce) | ||
|
||
main :: Effect Unit | ||
main = do | ||
store <- mkSuspenseStore (Just $ Milliseconds 200.0) | ||
let | ||
runGet :: forall k v s. HasBackend k v s => k -> Effect (SuspenseResult v) | ||
runGet k = case get store k of Suspended r' -> r' | ||
|
||
c1 = Key "1" :: Key Cat | ||
|
||
c2 = Key "2" :: Key Cat | ||
|
||
d1 = Key "1" :: Key Dog | ||
|
||
d2 = Key "2" :: Key Dog | ||
|
||
go = do | ||
c1' <- runGet c1 | ||
d1' <- runGet d1 | ||
c2' <- runGet c2 | ||
d2' <- runGet d2 | ||
c1'' <- runGet c1 | ||
d1'' <- runGet d1 | ||
c2'' <- runGet c2 | ||
d2'' <- runGet d2 | ||
l c1' | ||
l c1'' | ||
l c2' | ||
l c2'' | ||
l d1' | ||
l d1'' | ||
l d2' | ||
l d2'' | ||
go | ||
launchAff_ do | ||
delay (Milliseconds 100.0) | ||
liftEffect go | ||
delay (Milliseconds 200.0) | ||
liftEffect go | ||
liftEffect go | ||
where | ||
l :: forall v. v -> Effect Unit | ||
l v = do | ||
log (unsafeCoerce v) | ||
|
||
newtype Key v | ||
= Key String | ||
|
||
derive instance eqKey :: Eq (Key v) | ||
|
||
data Cat | ||
= Cat { name :: String } | ||
|
||
derive instance eqCat :: Eq Cat | ||
|
||
data Dog | ||
= Dog { name :: String } | ||
|
||
derive instance eqDog :: Eq Dog | ||
|
||
instance backendCat :: HasBackend (Key Cat) Cat "Cat" where | ||
fromKey (Key key) = key | ||
backend key = do | ||
delay $ Milliseconds 0.0 | ||
pure $ Cat { name: fromKey key } | ||
|
||
instance backendDog :: HasBackend (Key Dog) Dog "Dog" where | ||
fromKey (Key key) = key | ||
backend key = do | ||
delay $ Milliseconds 0.0 | ||
pure $ Dog { name: fromKey key } |
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.
Implementing
HasBackend
and calling the getters above is the external API. The classes and types below should contain the unsafe bits.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'm not sure this will do what you want it to: with this code, I think I can define instances
since we are effectively only saying that there can only be one HasBackend instance for each key type
k
; we aren't preventing you from defining two different instances with differentk
types but the sames
type. A functional dependencys -> k
would prevent you from doing this, but that probably isn't workable because you'll fall foul of orphan instance restrictions there - since in this cases
determines everything, then the instance can only be defined in the same module as the HasBackend class. Normally you'd also be able to define the instance in the same module as the types
, but in this cases
is built in to the compiler so you can't put it there. See https://liamgoodacre.github.io/purescript/type/class/instance/orphan/functional/dependencies/2017/01/22/purescript-orphan-instance-detection.html for more information on how functional dependencies affect orphan instance checking.I think you'd ideally need something like Haskell's Data.Typeable to do this safely, but PureScript doesn't have that. I'm not sure there's a good way of doing this without Data.Typeable, unfortunately.