notes

#next.js

next.js에서 route가 변경되었을 경우 이전 route를 죽지 않게 하려면2023. 6. 25.

til이 딱히 til이 아닌 것 같고 github을 cms로 쓰는 것도 좀 번거로운 감이 있어서 걍 .mdx 파일을 작성하는 걸로 하고 route 이름도 notes로 바꾸었는데, 바꾸고 보니 이전 링크를 preserve 해야 되겠다 싶어서 알아보았다. 브로큰 링크를 만들지 않아보겠다! 가 얼마 전에 목표가 되었기 때문에.

하려는 것

  1. (아마도 없을 것 같긴 하지만) 이전 링크를 어딘가 저장해 둔 누군가가 있을 수도 있다.
  2. 그 저장된 링크는 /til/:id 일 것
  3. 바뀐 링크는 /notes/:id 이지만,
  4. 2.의 링크로도 액세스가 가능해야 함

그러니까 redirects 설정을 하면 되는 것인데,

next.config.js

아래와 같이 하면 된다.

/** @type {import('next').NextConfig} */
const nextConfig = {
	// ...
	async redirects() {
		return [
			// ...
			{
				source: "/til/:path*",
				destination: "/notes/:path*",
				permanent: false,
			},
		]
	},
}
/** @type {import('next').NextConfig} */
const nextConfig = {
	// ...
	async redirects() {
		return [
			// ...
			{
				source: "/til/:path*",
				destination: "/notes/:path*",
				permanent: false,
			},
		]
	},
}

대략 "/til/*" 하면 되지 않을까 했는데 역시 아니었고 "foo/:path*" 라구 해야 함. permanent: false인 것에도 유의.

Ref

app router에서 `fetch`를 쓰지 않을 경우 cache 동작2023. 6. 24.

tl;dr

되긴 됨

Detail

fetch를 쓰지 않을 경우 Default Caching Behavior를 따르거나 Segment Cache Configuration를 따르게 됨.

Default Caching Behavior

If the segment is static (default), the output of the request will be cached and revalidated (if configured) alongside the rest of the segment. If the segment is dynamic, the output of the request will not be cached and will be re-fetched on every request when the segment is rendered.

Segment Cache Config

As a temporary solution, until the caching behavior of third-party queries can be configured, you can use segment configuration to customize the cache behavior of the entire segment.

like:

// app/page.tsx

import prisma from './lib/prisma'
 
export const revalidate = 3600 // revalidate every hour
 
async function getPosts() {
  const posts = await prisma.post.findMany()
  return posts
}
 
export default async function Page() {
  const posts = await getPosts()
  // ...
}
// app/page.tsx

import prisma from './lib/prisma'
 
export const revalidate = 3600 // revalidate every hour
 
async function getPosts() {
  const posts = await prisma.post.findMany()
  return posts
}
 
export default async function Page() {
  const posts = await getPosts()
  // ...
}

Ref

Data Fetching without fetch()

🫠 Hydration Mismatch 🫠 (2)2023. 6. 3.

요기서 이것 저것 해봤는데 다 별로인 것 같다. 왜냐면 다 hydration mismatch를 해결하는 게 아니고 피해가는 것이기 때문인듯...

그러니까 애시당초 'mismatch'가 발생하는 건

  1. client state가 client에서만 액세스 가능한 곳에 persist 되어있어서
  2. server에서 액세스가 안되니까
  3. UI 상태가 달라질 수 밖에 없다.

인데 그렇다면

  1. client state persist를
  2. server에서 액세스 가능한 곳에 하면 되는 것잉게롱.

그래서 좀 손이 가지만 걍 이렇게 해봤는데,

  1. persist엔 cookie를 사용한다.
  2. 왜냐면 쿠키는 서버 컴포넌트에서 읽기 가능이므로. 암튼 그래서 플로우는,
  3. 서버 컴포넌트에서 cookie를 읽어서 고 안에 들어있는 persisted state를 가져온다.
  4. mismatch가 발생하는 클라이언트 컴포넌트에 위 state를 넘겨줄 prop을 하나 뚫는다.
  5. 그리고 같은 클라이언트 컴포넌트에 useState로 local state를 하나 만드는데,
  6. 만들면서 initialState로 위 서버 컴포넌트에서 받아온 prop을 넘겨준다.
  7. useEffect를 하나 추가해서
  8. 클라이언트 컴포넌트가 쓰고 있는 persisted store을 local state에 묶어준다.
  9. 깅까 대략
    export function useSyncedState<T>(clientState: T, serverState?: T) {
      const [state, setState] = React.useState<T>(serverState ?? clientState)
    
      React.useEffect(() => {
        setState(clientState)
      }, [clientState])
    
      return state
    }
    export function useSyncedState<T>(clientState: T, serverState?: T) {
      const [state, setState] = React.useState<T>(serverState ?? clientState)
    
      React.useEffect(() => {
        setState(clientState)
      }, [clientState])
    
      return state
    }
  10. 왈료

이러면

  1. 어차피 서버도 클라이언트도 같은 걸 보고 있으므로 애시당초 mismatch가 아님.
  2. hydration이 되기 전에 이미 같은 상태의 UI가 보이므로 깜빡임 같은 게 없음.

근데:

  1. 서버 호출을 해야 됨
  2. 그래서 요렇게 한 컴포넌트가 들어있는 페이지는 static export가 안됨

🫠 Hydration Mismatch 🫠 (1)2023. 6. 2.

이런 저런 이유로 클라이언트 상태를 localStorage 등에 persist 하고 있을 경우 server/client mismatch가 발생할 수 밖에 없는데 그래서 서버에서 프리렌더가 안되게 하려면 아래와 같은 난리 법석이 필요.

  1. useState + useEffect
    function Comp() {
      const storeState = useStoreState()
            ^^^^^^^^^^ 1) 요걸 그냥 쓰면 💣 인 경우,
    
      const [state, setState] = React.useState()
      React.useEffect(()=>{
        setState(storeState)
      },[])               // 2) 이런 난리 법석 후에
    
      return <div>{state}</div> // 3) 이러면 통과
    function Comp() {
      const storeState = useStoreState()
            ^^^^^^^^^^ 1) 요걸 그냥 쓰면 💣 인 경우,
    
      const [state, setState] = React.useState()
      React.useEffect(()=>{
        setState(storeState)
      },[])               // 2) 이런 난리 법석 후에
    
      return <div>{state}</div> // 3) 이러면 통과
  2. useMounted + return null
    // 0) 일단 이런 난리 법석을 만들어두고
    const useMounted = () => {
      const [m, sM] = React.useState(false)
      //     ^^^^^ 귀찮아서 대충 씀
      React.useEffect(()=>{
        sM(true)
      }, [])
      return m
    }
    // 0) 일단 이런 난리 법석을 만들어두고
    const useMounted = () => {
      const [m, sM] = React.useState(false)
      //     ^^^^^ 귀찮아서 대충 씀
      React.useEffect(()=>{
        sM(true)
      }, [])
      return m
    }
    function Comp() {
      const storeState = useStoreState()
            ^^^^^^^^^^ 1) 요걸 그냥 쓰면 💣 인 경우,
    
      const mounted = useMounted()
      if (!mounted) return null  // 2) 이러고 나서
    
      return <div>{storeState}</div> // 3) 이러면 통과
    function Comp() {
      const storeState = useStoreState()
            ^^^^^^^^^^ 1) 요걸 그냥 쓰면 💣 인 경우,
    
      const mounted = useMounted()
      if (!mounted) return null  // 2) 이러고 나서
    
      return <div>{storeState}</div> // 3) 이러면 통과
  3. next/dynamic + { ssr: false } ☜ 이게 기분이 제일 덜 나쁜듯
    const Comp = dynamic(() => import('path/to/comp'), { ssr: false }); // 젤 간단?
    const Comp = dynamic(() => import('path/to/comp'), { ssr: false }); // 젤 간단?
    하지만 캐치가 하나 있는데, Comp 는 무조건 export default 여야 함.
    const Comp = dynamic(() => import('path/to/comp').then(mod => mod.Comp), { ssr: false })
                                                        // ^^^^^^^^^^^^^^^ 이러면 💣
    const Comp = dynamic(() => import('path/to/comp').then(mod => mod.Comp), { ssr: false })
                                                        // ^^^^^^^^^^^^^^^ 이러면 💣
    대신 loading으로 서스펜스 간지를 낼 수 있음(...)
    const Comp = dynamic(() => import('path/to/comp'), { ssr: false, loading: () => <Sekeleton /> }); // 이 가능
    const Comp = dynamic(() => import('path/to/comp'), { ssr: false, loading: () => <Sekeleton /> }); // 이 가능
    물론 next 한정이지만요...

이거 다 별루고... 로 시작하는 글을 한참 쓰고 있었는데 브라우저 꺼져서 날아감...

`blurDataUrl` 만들기2023. 6. 2.

갯츠비는 알아서 해주지만 넥스트는 해줘야 되기 때문에...

import { decode, encode } from "blurhash"
import sharp from "sharp"

const loadImageData = async (src: string) => {
  const response = await fetch(src)
  if (!response.ok)
    throw new Error(
      `Failed to load image: ${response.status} ${response.statusText}`
    )

  const imageBuffer = await response.arrayBuffer()

  const { data, info } = await sharp(imageBuffer)
    .ensureAlpha()
    .raw()
    .toBuffer({ resolveWithObject: true })

  return {
    data: new Uint8ClampedArray(data),
    width: info.width,
    height: info.height,
  }
}

export const encodeImageToBlurhash = async (imageUrl: string) => {
  const { data, width, height } = await loadImageData(imageUrl)
  return encode(data, width, height, 4, 4)
}

export const blurhashToBase64 = async (
  blurhash: string,
  width: number,
  height: number
) => {
  const pixels = decode(blurhash, width, height)
  const webp = sharp(Buffer.from(pixels), {
    raw: { width, height, channels: 4 },
  }).webp()
  const dataString = (await webp.toBuffer()).toString("base64")

  return `data:image/png;base64,${dataString}`
}

export const generateBlurDataUrl = async (
  imageUrl: string
): Promise<string | undefined> => {
  try {
    const blurhash = await encodeImageToBlurhash(imageUrl)
    return await blurhashToBase64(blurhash, 4, 4)
  } catch (error) {
    console.error(error)
    return undefined
  }
}
import { decode, encode } from "blurhash"
import sharp from "sharp"

const loadImageData = async (src: string) => {
  const response = await fetch(src)
  if (!response.ok)
    throw new Error(
      `Failed to load image: ${response.status} ${response.statusText}`
    )

  const imageBuffer = await response.arrayBuffer()

  const { data, info } = await sharp(imageBuffer)
    .ensureAlpha()
    .raw()
    .toBuffer({ resolveWithObject: true })

  return {
    data: new Uint8ClampedArray(data),
    width: info.width,
    height: info.height,
  }
}

export const encodeImageToBlurhash = async (imageUrl: string) => {
  const { data, width, height } = await loadImageData(imageUrl)
  return encode(data, width, height, 4, 4)
}

export const blurhashToBase64 = async (
  blurhash: string,
  width: number,
  height: number
) => {
  const pixels = decode(blurhash, width, height)
  const webp = sharp(Buffer.from(pixels), {
    raw: { width, height, channels: 4 },
  }).webp()
  const dataString = (await webp.toBuffer()).toString("base64")

  return `data:image/png;base64,${dataString}`
}

export const generateBlurDataUrl = async (
  imageUrl: string
): Promise<string | undefined> => {
  try {
    const blurhash = await encodeImageToBlurhash(imageUrl)
    return await blurhashToBase64(blurhash, 4, 4)
  } catch (error) {
    console.error(error)
    return undefined
  }
}

여러가지 더 있다.

Extracting Image Dimensions from Remote Sources2023. 5. 25.

Before

  • til을 위해 repo issues를 cms로 쓰고 있었고,
  • issue body render를 위해 github api reponse 중 bodyHTMLdangerously... 에 넣어주고 있었음.
  • 근데 이러면 next가 해주는 이것저것이 아까우므로,
  • api response 중 body(markdown string)을 mdx renderer에 넘겨서 써야겠다고 생각.

Issues

How?

  • probe-image-size라는 라이브러리가 있고,
  • mdx renderer option의 component 설정시 <img /> -> <Image />로 replace 하면서 요걸 사용해서 width/height 정보를 넘겨주면 됨.
  • 코드는 대충 이런식:
    import probe from "probe-image-size"
    // ...
    const components: MDXRemoteProps["components"] = {
      // ...
      // @ts-expect-error <- ts는 아직 async 컴포넌트를 모르지만 우리는 rsc 세계로 넘어왔으므로 ok.
      img: async ({ src, alt }) => {
        if (!src) return null
    
        const { width, height } = await probe(src ?? "")
        //                        ^^^^^ rsc ftw...
    
        if (!width || !height) return null
    
        return <Image src={src} alt={alt ?? ""} width={width} height={height} />
      },
      // ...
    }
    // ...
    import probe from "probe-image-size"
    // ...
    const components: MDXRemoteProps["components"] = {
      // ...
      // @ts-expect-error <- ts는 아직 async 컴포넌트를 모르지만 우리는 rsc 세계로 넘어왔으므로 ok.
      img: async ({ src, alt }) => {
        if (!src) return null
    
        const { width, height } = await probe(src ?? "")
        //                        ^^^^^ rsc ftw...
    
        if (!width || !height) return null
    
        return <Image src={src} alt={alt ?? ""} width={width} height={height} />
      },
      // ...
    }
    // ...

Result

최종 렌더된 이미지의 url을 보면

Screenshot 2023-05-25 at 17 18 40

next가 잘 처리하고 있음을 알 수 있음.

Conclusion

RSC는 대박이다...

`generateStaticParams`를 사용하는 페이지에서 서버 액션을 호출하면 `405` 에러 (2023-05-24 현재)2023. 5. 24.

좋아요 버튼을 달려고 @vercel/kv 랑 요렇게 저렇게 해보고 있었는데 아래 에러가 무한히 발생했다.

Warning: Detected multiple renderers concurrently rendering the same context provider. This is currently unsupported.
    at AppContainer (/Users/sehyunchung/personal/sehyunchung.dev/node_modules/.pnpm/[email protected][email protected][email protected]/node_modules/next/dist/server/render.js:337:29)
    at AppContainerWithIsomorphicFiberStructure (/Users/sehyunchung/personal/sehyunchung.dev/node_modules/.pnpm/[email protected][email protected][email protected]/node_modules/next/dist/server/render.js:373:57)
    at div
    at Body (/Users/sehyunchung/personal/sehyunchung.dev/node_modules/.pnpm/[email protected][email protected][email protected]/node_modules/next/dist/server/render.js:673:21)
Warning: Detected multiple renderers concurrently rendering the same context provider. This is currently unsupported.
..

검색을 해봐도 뭐가 안나와서 'use server'를 파일에 넣었다 함수에 넣었다 이케저케 해봐도 안됐는데 어쩌다 브라우저 콘솔을 열어보니 Screenshot 2023-05-24 at 17 47 28

네트워크 탭을 확인해보니 Screenshot 2023-05-24 at 17 47 54

요걸로 검색해보니 아래 이슈가 나왔다.

[NEXT-1167] Server Actions 405 "Method Not Allowed" when using generateStaticParams #49408

생각해보면 말이 되는 것 같기도... 근데 안되면 안되는데?

Tags