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

docs(suspensive.org): code transition #1369

Merged
merged 6 commits into from
Nov 23, 2024
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
11 changes: 10 additions & 1 deletion docs/suspensive.org/next.config.mjs
Original file line number Diff line number Diff line change
@@ -1,14 +1,23 @@
import { recmaCodeHike, remarkCodeHike } from 'codehike/mdx'
import nextra from 'nextra'
import { remarkSandpack } from 'remark-sandpack'

/** @type {import('codehike/mdx').CodeHikeConfig} */
const chConfig = {
syntaxHighlighting: {
theme: 'github-dark',
},
}

const withNextra = nextra({
autoImportThemeStyle: true,
theme: 'nextra-theme-docs',
themeConfig: './theme.config.tsx',
defaultShowCopyCode: true,
latex: true,
mdxOptions: {
remarkPlugins: [remarkSandpack],
remarkPlugins: [[remarkCodeHike, chConfig], remarkSandpack],
recmaPlugins: [[recmaCodeHike, chConfig]],
rehypePlugins: [],
rehypePrettyCodeOptions: {
theme: 'github-dark-default',
Expand Down
4 changes: 3 additions & 1 deletion docs/suspensive.org/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
"@suspensive/react-query-4": "workspace:*",
"@tanstack/react-query": "^4.36.1",
"@tanstack/react-query-devtools": "^4.36.1",
"codehike": "^1.0.4",
"d3": "^7.9.0",
"framer-motion": "^11.11.8",
"next": "catalog:",
Expand All @@ -35,7 +36,8 @@
"react": "catalog:react18",
"react-dom": "catalog:react18",
"remark-sandpack": "^0.0.5",
"sharp": "catalog:"
"sharp": "catalog:",
"zod": "^3.23.8"
},
"devDependencies": {
"@suspensive/eslint-config": "workspace:*",
Expand Down
139 changes: 139 additions & 0 deletions docs/suspensive.org/src/components/Scrollycoding.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
import { Block, HighlightedCodeBlock, parseProps } from 'codehike/blocks'
import {
type AnnotationHandler,
type CustomPreProps,
type HighlightedCode,
InnerLine,
InnerPre,
InnerToken,
Pre,
getPreRef,
} from 'codehike/code'
import {
Selectable,
Selection,
SelectionProvider,
} from 'codehike/utils/selection'
import {
type TokenTransitionsSnapshot,
calculateTransitions,
getStartingSnapshot,
} from 'codehike/utils/token-transitions'
import { Component, type RefObject } from 'react'
import { z } from 'zod'

const MAX_TRANSITION_DURATION = 900 // milliseconds

export class SmoothPre extends Component<CustomPreProps> {
ref: RefObject<HTMLPreElement>
constructor(props: CustomPreProps) {
super(props)
this.ref = getPreRef(this.props)
}

render() {
return <InnerPre merge={this.props} style={{ position: 'relative' }} />
}

getSnapshotBeforeUpdate() {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
return getStartingSnapshot(this.ref.current!)
}

componentDidUpdate(
prevProps: never,
prevState: never,
snapshot: TokenTransitionsSnapshot
) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const transitions = calculateTransitions(this.ref.current!, snapshot)
transitions.forEach(({ element, keyframes, options }) => {
const { translateX, translateY, ...kf } = keyframes as any
if (translateX && translateY) {
kf.translate = [
`${translateX[0]}px ${translateY[0]}px`,
`${translateX[1]}px ${translateY[1]}px`,
]
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
element.animate(kf, {
duration: options.duration * MAX_TRANSITION_DURATION,
delay: options.delay * MAX_TRANSITION_DURATION,
easing: options.easing,
fill: 'both',
})
})
}
}

const Schema = Block.extend({
steps: z.array(Block.extend({ code: HighlightedCodeBlock })),
})

export function Scrollycoding(props: unknown) {
const { steps } = parseProps(props, Schema)
return (
<SelectionProvider className="my-4 mb-24 flex gap-4">
<div className="mb-[40vh]" style={{ flex: 1 }}>
{steps.map((step, i) => (
<Selectable
key={i}
index={i}
selectOn={['click', 'scroll']}
className="mb-56 cursor-pointer px-5 py-2 opacity-30 blur-lg transition data-[selected=true]:opacity-100 data-[selected=true]:blur-none"
>
<h2 className="mb-4 mt-4 text-lg font-bold lg:text-2xl">
{step.title}
</h2>
<div className="opacity-90">{step.children}</div>
</Selectable>
))}
</div>

<div
className="rounded-xl border-2 border-[#ffffff10] bg-[#191919]"
style={{ flex: 2 }}
>
<div className="sticky top-16 overflow-auto">
<Selection
from={steps.map((step) => (
// eslint-disable-next-line @eslint-react/no-duplicate-key
<Code key="this key should be same" codeblock={step.code} />
))}
/>
</div>
</div>
</SelectionProvider>
)
}

const tokenTransitions: AnnotationHandler = {
name: 'token-transitions',
PreWithRef: SmoothPre,
Token: (props) => (
<InnerToken merge={props} style={{ display: 'inline-block' }} />
),
}
const wordWrap: AnnotationHandler = {
name: 'word-wrap',
Pre: (props) => <InnerPre merge={props} className="whitespace-pre-wrap" />,
Line: (props) => (
<InnerLine
merge={props}
style={{
textIndent: `${-props.indentation}ch`,
marginLeft: `${props.indentation}ch`,
}}
/>
),
Token: (props) => <InnerToken merge={props} style={{ textIndent: 0 }} />,
}
function Code({ codeblock }: { codeblock: HighlightedCode }) {
return (
<Pre
code={codeblock}
handlers={[tokenTransitions, wordWrap]}
className="min-h-[40rem] p-6"
/>
)
}
1 change: 1 addition & 0 deletions docs/suspensive.org/src/components/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@ export { Callout } from './Callout'
export { HomePage } from './HomePage'
export { Sandpack } from './Sandpack'
export { BubbleChart } from './BubbleChart'
export { Scrollycoding } from './Scrollycoding'
170 changes: 168 additions & 2 deletions docs/suspensive.org/src/pages/en/index.mdx
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { HomePage } from '@/components'
import { HomePage, Scrollycoding } from '@/components'

<HomePage
title="Suspensive"
version={2}
description="Packages to use React Suspense easily"
description="All in one for React Suspense"
buttonText="Get Started"
items={[
{
Expand All @@ -20,3 +20,169 @@ import { HomePage } from '@/components'
},
]}
/>

<Scrollycoding>

# !!steps 대표적인 라이브러리인 TanStack Query로 Suspense 없이 코드를 작성한다면 이렇게 작성합니다.

이 경우 isLoading과 isError를 체크하여 로딩과 에러 상태를 처리하고 타입스크립트적으로 data에서 undefined를 제거할 수 있습니다.

```jsx ! Page.jsx
const Page = () => {
const userQuery = useQuery(userQueryOptions())
const postsQuery = useQuery({
...postsQueryOptions(),
select: (posts) => posts.filter(({ isPublic }) => isPublic),
})
const promotionsQuery = useQuery(promotionsQueryOptions())

if (
userQuery.isLoading ||
postsQuery.isLoading ||
promotionsQuery.isLoading
) {
return 'loading...'
}

if (userQuery.isError || postsQuery.isError || promotionsQuery.isError) {
return 'error'
}

return (
<Fragment>
<UserProfile {...userQuery.data} />
{postsQuery.data.map((post) => (
<PostListItem key={post.id} {...post} />
))}
{promotionsQuery.data.map((promotion) => (
<Promotion key={promotion.id} {...promotion} />
))}
</Fragment>
)
}
```

# !!steps 그런데 만약 조회해야 할 api가 더 많아진다고 가정해봅시다.

조회해야 하는 api가 더 많아진다면 이 로딩상태와 에러상태를 처리하는 코드가 더욱 복잡해집니다.

```jsx ! Page.jsx
const Page = () => {
const userQuery = useQuery(userQueryOptions())
const postsQuery = useQuery({
...postsQueryOptions(),
select: (posts) => posts.filter(({ isPublic }) => isPublic),
})
const promotionsQuery = useQuery(promotionsQueryOptions())
// 이 부분이 늘어날수록 코드가 복잡해집니다.

if (
userQuery.isLoading ||
postsQuery.isLoading ||
promotionsQuery.isLoading // 이 부분에서 매번 추가해야 합니다.
) {
return 'loading...'
}

if (
userQuery.isError ||
postsQuery.isError ||
promotionsQuery.isError // 이 부분에서 매번 추가해야 합니다.
) {
return 'error'
}

return (
<Fragment>
<UserProfile {...userQuery.data} />
{postsQuery.data.map((post) => (
<PostListItem key={post.id} {...post} />
))}
{promotionsQuery.data.map((promotion) => (
<Promotion key={promotion.id} {...promotion} />
))}
{/* 이 부분에서 매번 추가해야 합니다. */}
</Fragment>
)
}
```

# !!steps Suspense를 사용하면 타입적으로 코드가 간결해집니다. 하지만 컴포넌트의 깊이는 깊어질 수 밖에 없습니다.

useSuspenseQuery는 Suspense와 ErrorBoundary를 사용하여 외부에서 로딩과 에러 상태를 처리할 수 있습니다.
하지만 useSuspenseQuery는 hook이기 때문에 부모에 Suspense와 ErrorBoundary를 두기 위해 컴포넌트가 분리되어야만 하기 때문에 뎁스가 깊어지는 문제가 있습니다.

```jsx ! Page.jsx
const Page = () => (
<ErrorBoundary fallback="error">
<Suspense fallback="loading...">
<UserInfo userId={userId} />
<PostList userId={userId} />
<PromotionList userId={userId} />
</Suspense>
</ErrorBoundary>
)

const UserInfo = ({ userId }) => {
const { data: user } = useSuspenseQuery(userQueryOptions())
return <UserProfile {...user} />
}

const PostList = ({ userId }) => {
const { data: posts } = useSuspenseQuery({
...postsQueryOptions(),
select: (posts) => posts.filter(({ isPublic }) => isPublic),
})
return posts.map((post) => <PostListItem key={post.id} {...post} />)
}

const PromotionList = ({ userId }) => {
const { data: promotions } = useSuspenseQuery(promotionsQueryOptions())
return promotions.map((promotion) => (
<PromotionListItem key={promotion.id} {...promotion} />
))
}
```

# !!steps Suspensive의 SuspenseQuery 컴포넌트를 사용하면 hook의 제약을 피해 같은 뎁스에서 더욱 쉽게 코드를 작성할 수 있습니다.

1. SuspenseQuery를 사용하면 depth를 제거할 수 있습니다.
2. UserInfo라는 컴포넌트를 제거하고 UserProfile과 같은 Presentational 컴포넌트만 남으므로 테스트하기 쉬워집니다.

```jsx ! Page.jsx
const Page = () => (
<ErrorBoundary fallback="error">
<Suspense fallback="loading...">
<SuspenseQuery {...userQueryOptions()}>
{({ data: user }) => <UserProfile key={user.id} {...user} />}
</SuspenseQuery>
<SuspenseQuery
{...postsQueryOptions()}
select={(posts) => posts.filter(({ isPublic }) => isPublic)}
>
{({ data: posts }) =>
posts.map((post) => <PostListItem key={post.id} {...post} />)
}
</SuspenseQuery>
<SuspenseQuery
{...promotionsQueryOptions()}
select={(promotions) => promotions.filter(({ isPublic }) => isPublic)}
>
{({ data: promotions }) =>
promotions.map((promotion) => (
<PromotionListItem key={promotion.id} {...promotion} />
))
}
</SuspenseQuery>
</Suspense>
</ErrorBoundary>
)
```

</Scrollycoding>

# 이것이 우리가 Suspensive를 만드는 이유입니다.

더 쉬운 React Suspense를 사용하세요

## ErrorBoundaryGroup
Loading
Loading