diff --git a/packages.dhall b/packages.dhall index 423c3d4..1d6ac30 100644 --- a/packages.dhall +++ b/packages.dhall @@ -1,6 +1,6 @@ let upstream = - https://github.com/purescript/package-sets/releases/download/psc-0.15.0-20220527/packages.dhall - sha256:15dd8041480502850e4043ea2977ed22d6ab3fc24d565211acde6f8c5152a799 + https://github.com/purescript/package-sets/releases/download/psc-0.15.2-20220531/packages.dhall + sha256:278d3608439187e51136251ebf12fabda62d41ceb4bec9769312a08b56f853e3 in upstream with react-testing-library = diff --git a/spago.test.dhall b/spago.test.dhall index d137a75..0f21444 100644 --- a/spago.test.dhall +++ b/spago.test.dhall @@ -6,5 +6,12 @@ in conf // { [ "react-testing-library" , "react-basic-dom" , "spec" + , "spec-discovery" + , "foreign-object" + , "web-dom" + , "arrays" + , "strings" + , "debug" + , "tailrec" ] } diff --git a/src/React/Basic/Hooks.js b/src/React/Basic/Hooks.js index 01de237..255e7d4 100644 --- a/src/React/Basic/Hooks.js +++ b/src/React/Basic/Hooks.js @@ -44,6 +44,15 @@ export function useLayoutEffectAlways_(effect) { return React.useLayoutEffect(effect); } +export function useInsertionEffect_(eq, deps, effect) { + const memoizedKey = useEqCache(eq, deps); + React.useInsertionEffect(effect, [memoizedKey]); +} + +export function useInsertionEffectAlways_(effect) { + React.useInsertionEffect(effect); +} + export function useReducer_(tuple, reducer, initialState) { const [state, dispatch] = React.useReducer(reducer, initialState); if (!dispatch.hasOwnProperty("$$reactBasicHooks$$cachedDispatch")) { @@ -73,6 +82,19 @@ export function useMemo_(eq, deps, computeA) { export const useDebugValue_ = React.useDebugValue; +export const useId_ = React.useId + +export function useTransition_(tuple) { + const [isPending, startTransitionImpl] = React.useTransition() + const startTransition = (update) => () => startTransitionImpl(update) + return tuple(isPending, startTransition); +} + +export const useDeferredValue_ = React.useDeferredValue + +export const useSyncExternalStore2_ = React.useSyncExternalStore +export const useSyncExternalStore3_ = React.useSyncExternalStore + export function unsafeSetDisplayName(displayName, component) { component.displayName = displayName; component.toString = () => displayName; diff --git a/src/React/Basic/Hooks.purs b/src/React/Basic/Hooks.purs index 5cea930..64537d5 100644 --- a/src/React/Basic/Hooks.purs +++ b/src/React/Basic/Hooks.purs @@ -20,6 +20,10 @@ module React.Basic.Hooks , useLayoutEffectOnce , useLayoutEffectAlways , UseLayoutEffect + , useInsertionEffect + , useInsertionEffectOnce + , useInsertionEffectAlways + , UseInsertionEffect , Reducer , mkReducer , runReducer @@ -38,6 +42,15 @@ module React.Basic.Hooks , UseMemo , useDebugValue , UseDebugValue + , useId + , UseId + , useTransition + , UseTransition + , useDeferredValue + , UseDeferredValue + , useSyncExternalStore + , useSyncExternalStore' + , UseSyncExternalStore , UnsafeReference(..) , displayName , module React.Basic.Hooks.Internal @@ -268,6 +281,26 @@ useLayoutEffectAlways effect = unsafeHook (runEffectFn1 useLayoutEffectAlways_ e foreign import data UseLayoutEffect :: Type -> Type -> Type +useInsertionEffect :: + forall deps. + Eq deps => + deps -> + Effect (Effect Unit) -> + Hook (UseInsertionEffect deps) Unit +useInsertionEffect deps effect = unsafeHook (runEffectFn3 useInsertionEffect_ (mkFn2 eq) deps effect) + +--| Like `useInsertionEffect`, but the effect is only performed a single time per component +--| instance. Prefer `useInsertionEffect` with a proper dependency list whenever possible! +useInsertionEffectOnce :: Effect (Effect Unit) -> Hook (UseInsertionEffect Unit) Unit +useInsertionEffectOnce effect = unsafeHook (runEffectFn3 useInsertionEffect_ (mkFn2 \_ _ -> true) unit effect) + +--| Like `useInsertionEffect`, but the effect is performed on every render. Prefer `useInsertionEffect` +--| with a proper dependency list whenever possible! +useInsertionEffectAlways :: Effect (Effect Unit) -> Hook (UseInsertionEffect Unit) Unit +useInsertionEffectAlways effect = unsafeHook (runEffectFn1 useInsertionEffectAlways_ effect) + +foreign import data UseInsertionEffect :: Type -> Type -> Type + newtype Reducer state action = Reducer (Fn2 state action state) @@ -354,6 +387,39 @@ useDebugValue debugValue display = unsafeHook (runEffectFn2 useDebugValue_ debug foreign import data UseDebugValue :: Type -> Type -> Type +foreign import data UseId :: Type -> Type +useId :: Hook UseId String +useId = unsafeHook useId_ + +foreign import data UseTransition :: Type -> Type +useTransition :: + Hook UseTransition (Boolean /\ ((Effect Unit) -> Effect Unit)) +useTransition = unsafeHook $ runEffectFn1 useTransition_ (mkFn2 Tuple) + +foreign import data UseDeferredValue :: Type -> Type -> Type +useDeferredValue :: forall a. a -> Hook (UseDeferredValue a) a +useDeferredValue a = unsafeHook $ runEffectFn1 useDeferredValue_ a + +foreign import data UseSyncExternalStore :: Type -> Type -> Type +useSyncExternalStore :: forall a. + ((Effect Unit) -> Effect (Effect Unit)) + -> (Effect a) + -> (Effect a) + -> Hook (UseSyncExternalStore a) a +useSyncExternalStore subscribe getSnapshot getServerSnapshot = + unsafeHook $ + runEffectFn3 useSyncExternalStore3_ + (mkEffectFn1 subscribe) + getSnapshot + getServerSnapshot +useSyncExternalStore' :: forall a. + ((Effect Unit) -> Effect (Effect Unit)) + -> (Effect a) + -> Hook (UseSyncExternalStore a) a +useSyncExternalStore' subscribe getSnapshot = + unsafeHook $ + runEffectFn2 useSyncExternalStore2_ (mkEffectFn1 subscribe) getSnapshot + newtype UnsafeReference a = UnsafeReference a @@ -424,6 +490,19 @@ foreign import useLayoutEffectAlways_ :: (Effect (Effect Unit)) Unit +foreign import useInsertionEffect_ :: + forall deps. + EffectFn3 + (Fn2 deps deps Boolean) + deps + (Effect (Effect Unit)) + Unit + +foreign import useInsertionEffectAlways_ :: + EffectFn1 + (Effect (Effect Unit)) + Unit + foreign import useReducer_ :: forall state action. EffectFn3 @@ -478,3 +557,22 @@ foreign import useDebugValue_ :: a (a -> String) Unit + +foreign import useId_ :: Effect String + +foreign import useTransition_ + :: forall a b. EffectFn1 (Fn2 a b (a /\ b)) + (Boolean /\ ((Effect Unit) -> Effect Unit)) + +foreign import useDeferredValue_ :: forall a. EffectFn1 a a + +foreign import useSyncExternalStore2_ :: forall a. EffectFn2 + (EffectFn1 (Effect Unit) (Effect Unit)) -- subscribe + (Effect a) -- getSnapshot + a + +foreign import useSyncExternalStore3_ :: forall a. EffectFn3 + (EffectFn1 (Effect Unit) (Effect Unit)) -- subscribe + (Effect a) -- getSnapshot + (Effect a) -- getServerSnapshot + a \ No newline at end of file diff --git a/test/Discovery.js b/test/Discovery.js deleted file mode 100644 index 2001d2a..0000000 --- a/test/Discovery.js +++ /dev/null @@ -1,26 +0,0 @@ -import fs from "fs"; -import path from "path"; -import url from "url"; - -const __dirname = url.fileURLToPath(new URL(".", import.meta.url)); - -async function getMatchingModules(pattern) { - const directories = await fs.promises.readdir(path.join(__dirname, "..")); - const modules = await Promise.all( - directories - .filter((directory) => new RegExp(pattern).test(directory)) - .map(async (name) => { - const module = await import( - path.join(__dirname, "..", name, "index.js") - ); - return module && typeof module.spec !== "undefined" - ? module.spec - : null; - }) - ); - return modules.filter((x) => x); -} - -export function getSpecs(pattern) { - return () => getMatchingModules(pattern); -} diff --git a/test/Discovery.purs b/test/Discovery.purs deleted file mode 100644 index 3757c5e..0000000 --- a/test/Discovery.purs +++ /dev/null @@ -1,21 +0,0 @@ --- Vendored in because of --- https://github.com/purescript-spec/purescript-spec-discovery/issues/18 -module Test.Discovery (discover) where - -import Prelude - -import Control.Promise (Promise, toAffE) -import Data.Traversable (sequence_) -import Effect (Effect) -import Effect.Aff (Aff) -import Effect.Aff.Class (liftAff) -import Test.Spec (Spec) - -foreign import getSpecs ∷ - String -> - Effect (Promise (Array (Spec Unit))) - -discover ∷ String -> Aff (Spec Unit) -discover pattern = liftAff do - specsPromise <- toAffE $ getSpecs pattern - pure $ sequence_ specsPromise \ No newline at end of file diff --git a/test/Main.purs b/test/Main.purs index 3a07c63..dd1e96a 100644 --- a/test/Main.purs +++ b/test/Main.purs @@ -6,7 +6,7 @@ import Data.Maybe (Maybe(..)) import Data.Time.Duration (Seconds(..), fromDuration) import Effect (Effect) import Effect.Aff (delay, launchAff_) -import Test.Discovery (discover) +import Test.Spec.Discovery (discover) import Test.Spec.Reporter (consoleReporter) import Test.Spec.Runner (defaultConfig, runSpec') diff --git a/test/Spec/React18HooksSpec.purs b/test/Spec/React18HooksSpec.purs new file mode 100644 index 0000000..31ae9bc --- /dev/null +++ b/test/Spec/React18HooksSpec.purs @@ -0,0 +1,143 @@ +module Test.Spec.React18HooksSpec where + +import Prelude + +import Control.Monad.Rec.Class (forever) +import Data.Array as Array +import Data.Foldable (for_, traverse_) +import Data.Maybe (fromMaybe) +import Data.Monoid (guard, power) +import Data.String as String +import Data.Tuple.Nested ((/\)) +import Effect.Aff (Milliseconds(..), apathize, delay, launchAff_) +import Effect.Class (liftEffect) +import Effect.Ref as Ref +import Foreign.Object as Object +import React.Basic (fragment) +import React.Basic.DOM as R +import React.Basic.DOM.Events (targetValue) +import React.Basic.Events (handler, handler_) +import React.Basic.Hooks (reactComponent) +import React.Basic.Hooks as Hooks +import React.TestingLibrary (cleanup, fireEventClick, renderComponent, typeText) +import Test.Spec (Spec, after_, before, describe, it) +import Test.Spec.Assertions (shouldNotEqual) +import Test.Spec.Assertions.DOM (textContentShouldEqual) +import Web.DOM.Element (getAttribute) +import Web.HTML.HTMLElement as HTMLElement + +spec ∷ Spec Unit +spec = + after_ cleanup do + before setup do + describe "React 18 hooks" do + it "useId works" \{ useId } -> do + { findByTestId } <- renderComponent useId {} + elem <- findByTestId "use-id" + idʔ <- getAttribute "id" (HTMLElement.toElement elem) # liftEffect + let id = idʔ # fromMaybe "" + id `shouldNotEqual` "" + elem `textContentShouldEqual` id + + it "useTransition works" \{ useTransition } -> do + { findByText } <- renderComponent useTransition {} + elem <- findByText "0" + fireEventClick elem + elem `textContentShouldEqual` "1" + + it "useDeferredValue hopefully works" \{ useDeferredValue } -> do + { findByTestId } <- renderComponent useDeferredValue {} + spanElem <- findByTestId "span" + spanElem `textContentShouldEqual` "0" + findByTestId "input" >>= typeText (power "text" 100) + spanElem `textContentShouldEqual` "400" + + it "useSyncExternalStore" \{ useSyncExternalStore } -> do + { findByTestId } <- renderComponent useSyncExternalStore {} + spanElem <- findByTestId "span" + spanElem `textContentShouldEqual` "0" + delay (350.0 # Milliseconds) + spanElem `textContentShouldEqual` "3" + + it "useInsertionEffect works" \{ useInsertionEffect } -> do + { findByText } <- renderComponent useInsertionEffect {} + void $ findByText "insertion-done" + + where + setup = liftEffect ado + + useId <- + reactComponent "UseIDExample" \(_ :: {}) -> Hooks.do + id <- Hooks.useId + pure $ R.div + { id + , _data: Object.singleton "testid" "use-id" + , children: [ R.text id ] + } + + useTransition <- + reactComponent "UseTransitionExample" \(_ :: {}) -> Hooks.do + isPending /\ startTransition <- Hooks.useTransition + count /\ setCount <- Hooks.useState 0 + let handleClick = startTransition do setCount (_ + 1) + pure $ R.div + { children: + [ guard isPending (R.text "Pending") + , R.button + { onClick: handler_ handleClick + , children: [ R.text (show count) ] + } + ] + } + + useDeferredValue <- + reactComponent "UseDeferredValueExample" \(_ :: {}) -> Hooks.do + text /\ setText <- Hooks.useState' "" + textLength <- Hooks.useDeferredValue (String.length text) + pure $ fragment + [ R.input + { onChange: handler targetValue (traverse_ setText) + , _data: Object.singleton "testid" "input" + } + , R.span + { _data: Object.singleton "testid" "span" + , children: [ R.text (show textLength) ] + } + ] + + useInsertionEffect <- + reactComponent "UseInsertionEffectExample" \(_ :: {}) -> Hooks.do + text /\ setText <- Hooks.useState' "waiting" + Hooks.useInsertionEffect unit do + setText "insertion-done" + mempty + pure $ R.span_ [ R.text text ] + + useSyncExternalStore <- do + { subscribe, getSnapshot, getServerSnapshot } <- do + subscribersRef <- Ref.new [] + intRef <- Ref.new 0 + -- Update the intRef every 100ms. + launchAff_ $ apathize $ forever do + delay (100.0 # Milliseconds) + intRef # Ref.modify_ (_ + 1) # liftEffect + subscribers <- subscribersRef # Ref.read # liftEffect + liftEffect $ for_ subscribers identity + + pure + { subscribe: \callback -> do + subscribersRef # Ref.modify_ (Array.cons callback) + pure $ + subscribersRef # Ref.modify_ (Array.drop 1) + , getSnapshot: Ref.read intRef + , getServerSnapshot: Ref.read intRef + } + + reactComponent "UseSyncExternalStoreExample" \(_ :: {}) -> Hooks.do + number <- Hooks.useSyncExternalStore + subscribe + getSnapshot + getServerSnapshot + pure $ R.span { _data: Object.singleton "testid" "span", children: [ R.text (show number) ] } + + in { useId, useTransition, useDeferredValue, useInsertionEffect, useSyncExternalStore } \ No newline at end of file