From 333fc368953fa7dc23670931c072cafd3f7bfa28 Mon Sep 17 00:00:00 2001 From: Dok6n Date: Sun, 29 Sep 2024 15:46:12 +0900 Subject: [PATCH] feat: create 'use' --- eslint.config.mjs | 1 + examples/vite-ts/package.json | 3 +- examples/vite-ts/pnpm-lock.yaml | 34 +++++ examples/vite-ts/src/App.css | 42 ------- examples/vite-ts/src/index.css | 68 ---------- examples/vite-ts/src/lib/model.ts | 16 +++ examples/vite-ts/src/lib/utils.ts | 5 + examples/vite-ts/src/main.tsx | 6 +- examples/vite-ts/src/pages/RootLayout.tsx | 63 ++++++++++ .../lifeCycle/LifeCyclePage.tsx} | 40 ++---- .../vite-ts/src/pages/lifeCycle/ui/Child.tsx | 26 ++++ .../vite-ts/src/pages/message/MessagePage.tsx | 15 +++ .../src/pages/message/lib/api/getMessage.ts | 7 ++ .../vite-ts/src/pages/message/lib/index.ts | 1 + .../src/pages/message/ui/GetMessage.tsx | 87 +++++++++++++ .../vite-ts/src/pages/message/ui/index.tsx | 1 + examples/vite-ts/src/router.tsx | 17 +++ examples/vite-ts/tsconfig.app.json | 1 + package.json | 15 ++- src/index.ts | 1 + .../test-utils/error-boundary-context.tsx | 9 ++ src/test/test-utils/error-boundary.tsx | 117 ++++++++++++++++++ src/test/test-utils/index.ts | 1 + src/test/test-utils/types.ts | 49 ++++++++ src/test/use.test.tsx | 55 ++++++++ src/use.ts | 29 +++++ 26 files changed, 561 insertions(+), 148 deletions(-) delete mode 100644 examples/vite-ts/src/App.css delete mode 100644 examples/vite-ts/src/index.css create mode 100644 examples/vite-ts/src/lib/model.ts create mode 100644 examples/vite-ts/src/lib/utils.ts create mode 100644 examples/vite-ts/src/pages/RootLayout.tsx rename examples/vite-ts/src/{App.tsx => pages/lifeCycle/LifeCyclePage.tsx} (65%) create mode 100644 examples/vite-ts/src/pages/lifeCycle/ui/Child.tsx create mode 100644 examples/vite-ts/src/pages/message/MessagePage.tsx create mode 100644 examples/vite-ts/src/pages/message/lib/api/getMessage.ts create mode 100644 examples/vite-ts/src/pages/message/lib/index.ts create mode 100644 examples/vite-ts/src/pages/message/ui/GetMessage.tsx create mode 100644 examples/vite-ts/src/pages/message/ui/index.tsx create mode 100644 examples/vite-ts/src/router.tsx create mode 100644 src/test/test-utils/error-boundary-context.tsx create mode 100644 src/test/test-utils/error-boundary.tsx create mode 100644 src/test/test-utils/index.ts create mode 100644 src/test/test-utils/types.ts create mode 100644 src/test/use.test.tsx create mode 100644 src/use.ts diff --git a/eslint.config.mjs b/eslint.config.mjs index d7bf013..5f0347a 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -49,6 +49,7 @@ export default [ }, ], 'react-hooks/exhaustive-deps': 'off', + '@typescript-eslint/no-explicit-any': 'off', }, }, ] diff --git a/examples/vite-ts/package.json b/examples/vite-ts/package.json index c53ce27..ca57273 100644 --- a/examples/vite-ts/package.json +++ b/examples/vite-ts/package.json @@ -11,7 +11,8 @@ }, "dependencies": { "react": "^18.3.1", - "react-dom": "^18.3.1" + "react-dom": "^18.3.1", + "react-router-dom": "^6.26.1" }, "devDependencies": { "@types/react": "^18.3.3", diff --git a/examples/vite-ts/pnpm-lock.yaml b/examples/vite-ts/pnpm-lock.yaml index 516fec1..dffa515 100644 --- a/examples/vite-ts/pnpm-lock.yaml +++ b/examples/vite-ts/pnpm-lock.yaml @@ -14,6 +14,9 @@ importers: react-dom: specifier: ^18.3.1 version: 18.3.1(react@18.3.1) + react-router-dom: + specifier: ^6.26.1 + version: 6.26.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) devDependencies: '@types/react': specifier: ^18.3.3 @@ -354,6 +357,10 @@ packages: resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} engines: {node: '>= 8'} + '@remix-run/router@1.19.1': + resolution: {integrity: sha512-S45oynt/WH19bHbIXjtli6QmwNYvaz+vtnubvNpNDvUOoA/OWh6j1OikIP3G+v5GHdxyC6EXoChG3HgYGEUfcg==} + engines: {node: '>=14.0.0'} + '@rollup/rollup-android-arm-eabi@4.18.0': resolution: {integrity: sha512-Tya6xypR10giZV1XzxmH5wr25VcZSncG0pZIjfePT0OVBvqNEurzValetGNarVrGiq66EBVAFn15iYX4w6FKgQ==} cpu: [arm] @@ -962,6 +969,19 @@ packages: resolution: {integrity: sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA==} engines: {node: '>=0.10.0'} + react-router-dom@6.26.1: + resolution: {integrity: sha512-veut7m41S1fLql4pLhxeSW3jlqs+4MtjRLj0xvuCEXsxusJCbs6I8yn9BxzzDX2XDgafrccY6hwjmd/bL54tFw==} + engines: {node: '>=14.0.0'} + peerDependencies: + react: '>=16.8' + react-dom: '>=16.8' + + react-router@6.26.1: + resolution: {integrity: sha512-kIwJveZNwp7teQRI5QmwWo39A5bXRyqpH0COKKmPnyD2vBvDwgFXSqDUYtt1h+FEyfnE8eXr7oe0MxRzVwCcvQ==} + engines: {node: '>=14.0.0'} + peerDependencies: + react: '>=16.8' + react@18.3.1: resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==} engines: {node: '>=0.10.0'} @@ -1402,6 +1422,8 @@ snapshots: '@nodelib/fs.scandir': 2.1.5 fastq: 1.17.1 + '@remix-run/router@1.19.1': {} + '@rollup/rollup-android-arm-eabi@4.18.0': optional: true @@ -2021,6 +2043,18 @@ snapshots: react-refresh@0.14.2: {} + react-router-dom@6.26.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + '@remix-run/router': 1.19.1 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + react-router: 6.26.1(react@18.3.1) + + react-router@6.26.1(react@18.3.1): + dependencies: + '@remix-run/router': 1.19.1 + react: 18.3.1 + react@18.3.1: dependencies: loose-envify: 1.4.0 diff --git a/examples/vite-ts/src/App.css b/examples/vite-ts/src/App.css deleted file mode 100644 index b9d355d..0000000 --- a/examples/vite-ts/src/App.css +++ /dev/null @@ -1,42 +0,0 @@ -#root { - max-width: 1280px; - margin: 0 auto; - padding: 2rem; - text-align: center; -} - -.logo { - height: 6em; - padding: 1.5em; - will-change: filter; - transition: filter 300ms; -} -.logo:hover { - filter: drop-shadow(0 0 2em #646cffaa); -} -.logo.react:hover { - filter: drop-shadow(0 0 2em #61dafbaa); -} - -@keyframes logo-spin { - from { - transform: rotate(0deg); - } - to { - transform: rotate(360deg); - } -} - -@media (prefers-reduced-motion: no-preference) { - a:nth-of-type(2) .logo { - animation: logo-spin infinite 20s linear; - } -} - -.card { - padding: 2em; -} - -.read-the-docs { - color: #888; -} diff --git a/examples/vite-ts/src/index.css b/examples/vite-ts/src/index.css deleted file mode 100644 index 6119ad9..0000000 --- a/examples/vite-ts/src/index.css +++ /dev/null @@ -1,68 +0,0 @@ -:root { - font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif; - line-height: 1.5; - font-weight: 400; - - color-scheme: light dark; - color: rgba(255, 255, 255, 0.87); - background-color: #242424; - - font-synthesis: none; - text-rendering: optimizeLegibility; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; -} - -a { - font-weight: 500; - color: #646cff; - text-decoration: inherit; -} -a:hover { - color: #535bf2; -} - -body { - margin: 0; - display: flex; - place-items: center; - min-width: 320px; - min-height: 100vh; -} - -h1 { - font-size: 3.2em; - line-height: 1.1; -} - -button { - border-radius: 8px; - border: 1px solid transparent; - padding: 0.6em 1.2em; - font-size: 1em; - font-weight: 500; - font-family: inherit; - background-color: #1a1a1a; - cursor: pointer; - transition: border-color 0.25s; -} -button:hover { - border-color: #646cff; -} -button:focus, -button:focus-visible { - outline: 4px auto -webkit-focus-ring-color; -} - -@media (prefers-color-scheme: light) { - :root { - color: #213547; - background-color: #ffffff; - } - a:hover { - color: #747bff; - } - button { - background-color: #f9f9f9; - } -} diff --git a/examples/vite-ts/src/lib/model.ts b/examples/vite-ts/src/lib/model.ts new file mode 100644 index 0000000..5f97837 --- /dev/null +++ b/examples/vite-ts/src/lib/model.ts @@ -0,0 +1,16 @@ +export interface Post { + id: number + title: string + body: string + tags: string[] + reactions: { + likes: number + dislikes: number + } + views: number + userId: number +} + +export interface ErrorResponse { + message: string +} diff --git a/examples/vite-ts/src/lib/utils.ts b/examples/vite-ts/src/lib/utils.ts new file mode 100644 index 0000000..b6017c1 --- /dev/null +++ b/examples/vite-ts/src/lib/utils.ts @@ -0,0 +1,5 @@ +import { CSSProperties } from 'react' + +export const styler = ( + styles: Record, +): Record => styles diff --git a/examples/vite-ts/src/main.tsx b/examples/vite-ts/src/main.tsx index 3d7150d..8afc36b 100644 --- a/examples/vite-ts/src/main.tsx +++ b/examples/vite-ts/src/main.tsx @@ -1,10 +1,10 @@ import React from 'react' import ReactDOM from 'react-dom/client' -import App from './App.tsx' -import './index.css' +import { RouterProvider } from 'react-router-dom' +import { router } from './router' ReactDOM.createRoot(document.getElementById('root')!).render( - + , ) diff --git a/examples/vite-ts/src/pages/RootLayout.tsx b/examples/vite-ts/src/pages/RootLayout.tsx new file mode 100644 index 0000000..6cf3c69 --- /dev/null +++ b/examples/vite-ts/src/pages/RootLayout.tsx @@ -0,0 +1,63 @@ +import { Link, Outlet, useNavigate } from 'react-router-dom' +import { styler } from '../lib/utils' + +const createRandomPostId = () => Math.floor(Math.random() * 251) + 1 + +export default function RootLayout() { + const navigate = useNavigate() + + return ( +
+ + +
+ +
+
+ ) +} + +const styles = styler({ + main: { + display: 'flex', + flexDirection: 'column', + minHeight: '100vh', + }, + nav: { + backgroundColor: '#f0f0f0', + padding: '1rem', + display: 'flex', + justifyContent: 'space-around', + }, + link: { + textDecoration: 'none', + color: '#333', + fontWeight: 'bold', + }, + outletWrapper: { + flex: 1, + padding: '2rem', + backgroundColor: '#ffffff', + }, + button: { + border: 'none', + background: 'none', + padding: 0, + margin: 0, + font: 'inherit', + cursor: 'pointer', + outline: 'none', + }, +}) diff --git a/examples/vite-ts/src/App.tsx b/examples/vite-ts/src/pages/lifeCycle/LifeCyclePage.tsx similarity index 65% rename from examples/vite-ts/src/App.tsx rename to examples/vite-ts/src/pages/lifeCycle/LifeCyclePage.tsx index 8c33624..39c4b2a 100644 --- a/examples/vite-ts/src/App.tsx +++ b/examples/vite-ts/src/pages/lifeCycle/LifeCyclePage.tsx @@ -1,38 +1,24 @@ import { useReducer, useState } from 'react' -import './App.css' import { + useMonitoringState, + useMount, useMountBeforePaint, - useUpdateBeforePaint, + useUnmount, useUnmountBeforePaint, - useMount, useUpdate, - useUnmount, - useMonitoringState, -} from '../../../dist' - -function Child() { - useMountBeforePaint(() => { - console.log('Child mounted before paint') - }) - - useUnmountBeforePaint(() => { - console.log('Child unmounted before paint') - }) - - useMount(() => { - console.log('Child mounted') - }) - - useUnmount(() => { - console.log('Child unmounted') - }) + useUpdateBeforePaint, +} from '../../../../../dist' +import Child from './ui/Child' +import { RouteObject } from 'react-router-dom' - return
Child
+export const LifeCycleRoute: RouteObject = { + path: '/lifecycle', + element: , } -function App() { +export default function LifeCyclePage() { const [count, setCount] = useState(0) - const [isShow, toggle] = useReducer(prev => !prev, true) + const [isShow, toggle] = useReducer(state => !state, true) useMountBeforePaint(() => { console.log('Before paint') @@ -74,5 +60,3 @@ function App() { ) } - -export default App diff --git a/examples/vite-ts/src/pages/lifeCycle/ui/Child.tsx b/examples/vite-ts/src/pages/lifeCycle/ui/Child.tsx new file mode 100644 index 0000000..77e53b0 --- /dev/null +++ b/examples/vite-ts/src/pages/lifeCycle/ui/Child.tsx @@ -0,0 +1,26 @@ +import { + useMount, + useMountBeforePaint, + useUnmount, + useUnmountBeforePaint, +} from '../../../../../../dist' + +export default function Child() { + useMountBeforePaint(() => { + console.log('Child mounted before paint') + }) + + useUnmountBeforePaint(() => { + console.log('Child unmounted before paint') + }) + + useMount(() => { + console.log('Child mounted') + }) + + useUnmount(() => { + console.log('Child unmounted') + }) + + return
Child
+} diff --git a/examples/vite-ts/src/pages/message/MessagePage.tsx b/examples/vite-ts/src/pages/message/MessagePage.tsx new file mode 100644 index 0000000..b0c8945 --- /dev/null +++ b/examples/vite-ts/src/pages/message/MessagePage.tsx @@ -0,0 +1,15 @@ +import { GetMessage } from './ui' +import { RouteObject, useParams } from 'react-router-dom' +import { getMessage } from './lib' + +export const MessageRoute: RouteObject = { + path: '/message/:id', + element: , +} + +function MessagePage() { + const { id } = useParams() + + return +} +export default MessagePage diff --git a/examples/vite-ts/src/pages/message/lib/api/getMessage.ts b/examples/vite-ts/src/pages/message/lib/api/getMessage.ts new file mode 100644 index 0000000..b87d250 --- /dev/null +++ b/examples/vite-ts/src/pages/message/lib/api/getMessage.ts @@ -0,0 +1,7 @@ +import { Post } from '../../../../lib/model' + +export const getMessage = async (id: number) => { + const response = await fetch(`https://dummyjson.com/posts/${id}`) + const data = (await response.json()) as Post + return data +} diff --git a/examples/vite-ts/src/pages/message/lib/index.ts b/examples/vite-ts/src/pages/message/lib/index.ts new file mode 100644 index 0000000..0cf16bb --- /dev/null +++ b/examples/vite-ts/src/pages/message/lib/index.ts @@ -0,0 +1 @@ +export * from './api/getMessage' diff --git a/examples/vite-ts/src/pages/message/ui/GetMessage.tsx b/examples/vite-ts/src/pages/message/ui/GetMessage.tsx new file mode 100644 index 0000000..39d0ef2 --- /dev/null +++ b/examples/vite-ts/src/pages/message/ui/GetMessage.tsx @@ -0,0 +1,87 @@ +import { Suspense } from 'react' +import { use } from '../../../../../../dist' +import { Post } from '../../../lib/model' +import { styler } from '../../../lib/utils' + +function CachedMessageContent({ promise }: { promise: Promise }) { + const message = use(promise) + return ( +
+
+ {message.tags.map(tag => ( + + #{tag} + + ))} +
+
+

{message.body}

+
+
+ ) +} + +function Message({ promise }: { promise: Promise }) { + const message = use(promise) + return ( + <> +

{message.title}

+ + + ) +} + +export default function GetMessage({ promise }: { promise: Promise }) { + return ( +
+ ⌛ get message...

}> + +
+
+ ) +} + +const styles = styler({ + message: { + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + justifyContent: 'center', + width: '100%', + maxWidth: '600px', + margin: '0 auto', + padding: '20px', + boxSizing: 'border-box', + }, + tags: { + display: 'flex', + gap: '4px', + fontSize: '12px', + }, + tag: { + backgroundColor: '#f0f0f0', + padding: '4px 8px', + borderRadius: '4px', + }, + content: { + marginTop: '16px', + fontSize: '14px', + lineHeight: '1.5', + color: '#333', + fontFamily: 'sans-serif', + fontWeight: '400', + letterSpacing: '0.01em', + wordBreak: 'break-word', + whiteSpace: 'pre-wrap', + wordWrap: 'break-word', + overflowWrap: 'break-word', + textAlign: 'left', + textSizeAdjust: '100%', + textRendering: 'optimizeLegibility', + textShadow: 'none', + textTransform: 'none', + userSelect: 'text', + verticalAlign: 'baseline', + wordSpacing: '0px', + }, +}) diff --git a/examples/vite-ts/src/pages/message/ui/index.tsx b/examples/vite-ts/src/pages/message/ui/index.tsx new file mode 100644 index 0000000..86c5ccd --- /dev/null +++ b/examples/vite-ts/src/pages/message/ui/index.tsx @@ -0,0 +1 @@ +export { default as GetMessage } from './GetMessage' diff --git a/examples/vite-ts/src/router.tsx b/examples/vite-ts/src/router.tsx new file mode 100644 index 0000000..94d6a8b --- /dev/null +++ b/examples/vite-ts/src/router.tsx @@ -0,0 +1,17 @@ +import { Outlet, createBrowserRouter } from 'react-router-dom' +import RootLayout from './pages/RootLayout' +import { LifeCycleRoute } from './pages/lifeCycle/LifeCyclePage' +import { MessageRoute } from './pages/message/MessagePage' + +export const router = createBrowserRouter([ + { + element: , + children: [ + { + path: '', + element: , + children: [LifeCycleRoute, MessageRoute], + }, + ], + }, +]) diff --git a/examples/vite-ts/tsconfig.app.json b/examples/vite-ts/tsconfig.app.json index d739292..a53cab0 100644 --- a/examples/vite-ts/tsconfig.app.json +++ b/examples/vite-ts/tsconfig.app.json @@ -7,6 +7,7 @@ "lib": ["ES2020", "DOM", "DOM.Iterable"], "module": "ESNext", "skipLibCheck": true, + "preserveSymlinks": true, /* Bundler mode */ "moduleResolution": "bundler", diff --git a/package.json b/package.json index c85d5eb..06bea9f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "relife-hooks", - "version": "0.8.0", + "version": "0.8.1", "description": "Provides various custom hooks for React lifecycle", "private": false, "repository": { @@ -26,11 +26,14 @@ ], "exports": { ".": { - "types": "./dist/types/index.d.ts", - "module": "./dist/esm/index.js", - "import": "./dist/esm/index.js", - "require": "./dist/cjs/index.js", - "default": "./dist/cjs/index.js" + "require": { + "types": "./dist/index.d.cts", + "default": "./dist/index.cjs" + }, + "import": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } }, "./package.json": "./package.json" }, diff --git a/src/index.ts b/src/index.ts index 9a5edb5..dcac766 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,3 +6,4 @@ export * from './use-update-before-paint' export * from './use-unmount-before-paint' export * from './use-monitoring-state' export * from './utils' +export * from './use' diff --git a/src/test/test-utils/error-boundary-context.tsx b/src/test/test-utils/error-boundary-context.tsx new file mode 100644 index 0000000..b92b674 --- /dev/null +++ b/src/test/test-utils/error-boundary-context.tsx @@ -0,0 +1,9 @@ +import { createContext } from 'react' + +export type ErrorBoundaryContextType = { + didCatch: boolean + error: any + resetErrorBoundary: (...args: any[]) => void +} + +export const ErrorBoundaryContext = createContext(null) diff --git a/src/test/test-utils/error-boundary.tsx b/src/test/test-utils/error-boundary.tsx new file mode 100644 index 0000000..2896de0 --- /dev/null +++ b/src/test/test-utils/error-boundary.tsx @@ -0,0 +1,117 @@ +// import { isDevelopment } from "#is-development"; +import { Component, createElement, ErrorInfo, isValidElement } from 'react' +import { ErrorBoundaryContext } from './error-boundary-context' +import { ErrorBoundaryProps, FallbackProps } from './types' + +type ErrorBoundaryState = + | { + didCatch: true + error: any + } + | { + didCatch: false + error: null + } + +const initialState: ErrorBoundaryState = { + didCatch: false, + error: null, +} + +export class ErrorBoundary extends Component { + constructor(props: ErrorBoundaryProps) { + super(props) + + this.resetErrorBoundary = this.resetErrorBoundary.bind(this) + this.state = initialState + } + + static getDerivedStateFromError(error: Error) { + console.log('getDerivedStateFromError', error) + return { didCatch: true, error } + } + + resetErrorBoundary(...args: any[]) { + const { error } = this.state + + if (error !== null) { + this.props.onReset?.({ + args, + reason: 'imperative-api', + }) + + this.setState(initialState) + } + } + + componentDidCatch(error: Error, info: ErrorInfo) { + console.log('componentDidCatch', error, info) + this.props.onError?.(error, info) + } + + componentDidUpdate(prevProps: ErrorBoundaryProps, prevState: ErrorBoundaryState) { + const { didCatch } = this.state + const { resetKeys } = this.props + + // There's an edge case where if the thing that triggered the error happens to *also* be in the resetKeys array, + // we'd end up resetting the error boundary immediately. + // This would likely trigger a second error to be thrown. + // So we make sure that we don't check the resetKeys on the first call of cDU after the error is set. + + if (didCatch && prevState.error !== null && hasArrayChanged(prevProps.resetKeys, resetKeys)) { + this.props.onReset?.({ + next: resetKeys, + prev: prevProps.resetKeys, + reason: 'keys', + }) + + this.setState(initialState) + } + } + + render() { + const { children, fallbackRender, FallbackComponent, fallback } = this.props + const { didCatch, error } = this.state + + let childToRender = children + + if (didCatch) { + const props: FallbackProps = { + error, + resetErrorBoundary: this.resetErrorBoundary, + } + + if (typeof fallbackRender === 'function') { + childToRender = fallbackRender(props) + } else if (FallbackComponent) { + childToRender = createElement(FallbackComponent, props) + } else if (fallback === null || isValidElement(fallback)) { + childToRender = fallback + } else { + // if (isDevelopment) { + // console.error( + // 'react-error-boundary requires either a fallback, fallbackRender, or FallbackComponent prop', + // ) + // } + + throw error + } + } + + return createElement( + ErrorBoundaryContext.Provider, + { + value: { + didCatch, + error, + resetErrorBoundary: this.resetErrorBoundary, + }, + }, + childToRender, + ) + } +} + +function hasArrayChanged(a: any[] = [], b: any[] = []) { + return a.length !== b.length || a.some((item, index) => !Object.is(item, b[index])) +} diff --git a/src/test/test-utils/index.ts b/src/test/test-utils/index.ts new file mode 100644 index 0000000..8b96c07 --- /dev/null +++ b/src/test/test-utils/index.ts @@ -0,0 +1 @@ +export * from './error-boundary' diff --git a/src/test/test-utils/types.ts b/src/test/test-utils/types.ts new file mode 100644 index 0000000..8629a1f --- /dev/null +++ b/src/test/test-utils/types.ts @@ -0,0 +1,49 @@ +import { + Component, + ComponentType, + ErrorInfo, + FunctionComponent, + PropsWithChildren, + ReactElement, + ReactNode, +} from 'react' + +declare function FallbackRender(props: FallbackProps): ReactNode + +export type FallbackProps = { + error: any + resetErrorBoundary: (...args: any[]) => void +} + +type ErrorBoundarySharedProps = PropsWithChildren<{ + onError?: (error: Error, info: ErrorInfo) => void + onReset?: ( + details: + | { reason: 'imperative-api'; args: any[] } + | { reason: 'keys'; prev: any[] | undefined; next: any[] | undefined }, + ) => void + resetKeys?: any[] +}> + +export type ErrorBoundaryPropsWithComponent = ErrorBoundarySharedProps & { + fallback?: never + FallbackComponent: ComponentType + fallbackRender?: never +} + +export type ErrorBoundaryPropsWithRender = ErrorBoundarySharedProps & { + fallback?: never + FallbackComponent?: never + fallbackRender: typeof FallbackRender +} + +export type ErrorBoundaryPropsWithFallback = ErrorBoundarySharedProps & { + fallback: ReactElement | null + FallbackComponent?: never + fallbackRender?: never +} + +export type ErrorBoundaryProps = + | ErrorBoundaryPropsWithFallback + | ErrorBoundaryPropsWithComponent + | ErrorBoundaryPropsWithRender diff --git a/src/test/use.test.tsx b/src/test/use.test.tsx new file mode 100644 index 0000000..c3d51d8 --- /dev/null +++ b/src/test/use.test.tsx @@ -0,0 +1,55 @@ +import React, { Suspense } from 'react' +import { use } from '../use' +import { cleanup, getByTestId, render, waitFor } from '@testing-library/react' + +describe('use', () => { + let App = () =>
+ let thenable = vi.fn() + const GetData = ({ promise }: { promise: Promise }) => { + const data = use(promise) + return
{data}
+ } + + afterEach(() => { + cleanup() + vi.clearAllMocks() + }) + + describe('with Suspense', () => { + beforeEach(() => { + thenable = thenable.mockResolvedValue('success') + + App = () => { + return ( + Loading...
}> + + + ) + } + }) + + it('should show loading state when promise is pending / 비동기 처리 중일 때 로딩 상태가 표시되어야 한다', async () => { + const { container } = render() + + expect(container.textContent).toBe('Loading...') + }) + + it('should show data when promise is resolved / 비동기 처리가 완료되면 데이터가 표시되어야 한다', async () => { + const { container } = render() + + await waitFor(() => { + const getDataElement = getByTestId(container, 'data') + + expect(getDataElement.textContent).toBe('success') + }) + }) + + it('should not show loading state when promise is resolved / 비동기 처리가 완료되면 로딩 상태가 표시되지 않아야 한다', async () => { + const { container } = render() + + await waitFor(() => { + expect(container.textContent).not.toBe('Loading...') + }) + }) + }) +}) diff --git a/src/use.ts b/src/use.ts new file mode 100644 index 0000000..ee9a1d5 --- /dev/null +++ b/src/use.ts @@ -0,0 +1,29 @@ +export const use = ( + thenable: Promise & { + status?: 'pending' | 'fulfilled' | 'rejected' + value?: T + reason?: unknown + }, +): T => { + switch (thenable.status) { + case 'pending': + throw thenable + case 'fulfilled': + return thenable.value as T + case 'rejected': + throw thenable.reason + default: + thenable.status = 'pending' + thenable.then( + value => { + thenable.status = 'fulfilled' + thenable.value = value + }, + reason => { + thenable.status = 'rejected' + thenable.reason = reason + }, + ) + throw thenable + } +}