button 또는 a 요소를 선택적으로 렌더링할 수 있는 ButtonOrLink 컴포넌트를 구현한 부분. props로 전달된 as 값에 따라 해당 태그를 렌더링하며, React.JSX.IntrinsicElements의 타입을 활용하여 각 태그에 맞는 props를 받을 수 있도록 한다.

import * as React from 'react'

type ComponentPropsWithAs<T extends keyof React.JSX.IntrinsicElements> = {
  as: T
} & React.ComponentProps<T>

type ButtonOrLinkProps =
  | ComponentPropsWithAs<'a'>
  | ComponentPropsWithAs<'button'>

export function ButtonOrLink(props: ButtonOrLinkProps) {
  switch (props.as) {
    case 'a':
      return <a {...props} />
    case 'button':
      return <button {...props} />
    default:
      return null
  }
}
#316

vitest와 jest에서 가짜 타이머를 사용하는 방법. 내부적으로는 @sinonjs/fake-timers를 사용. vitest와 jest 모두 시스템 시간을 조작할 수 있도록 하여 테스트가 일관되게 실행되도록 하고 다양한 시간과 날짜를 시뮬레이션하여 다양한 조건에서 코드가 예상대로 작동하는지 확인할 수 있음.

describe('isSameDate', () => {
  beforeEach(() => {
    vi.useFakeTimers()
    vi.setSystemTime('2024-07-20')
  })

  afterEach(() => {
    vi.useRealTimers()
  })

  it('현재 날짜와 동일한 날짜를 전달하면 true를 반환한다', () => {
    expect(isSameDate('2024-07-20')).toBe(true)
  })

  it('현재 날짜와 다른 날짜를 전달하면 false를 반환한다', () => {
    expect(isSameDate('2024-07-19')).toBe(false)
    expect(isSameDate('2024-07-21')).toBe(false)
  })
})

#315

핵심 요점

  1. 비순수한 컴포넌트

    • new Date()와 같은 비순수한 함수는 호출할 때마다 다른 결과를 선택하므로, 테스트 결과의 일관성이 없음
  2. 해결책 - 의존성 주입

    • 비순수한 함수의 결과를 prop으로 전달하여 컴포넌트를 예측 가능하게 만들고, 테스트 시 특정 날짜를 제어
  3. 기본 매개변수

    • 기본 매개변수 사용으로 오늘 날짜의 기본 동작을 유지하면서, 테스트에서 유연성을 제공

개선된 예시

function Date({ date = new Date() }) {
  const [date, setDate] = React.useState(date)

  return (
    <input
      type="date"
      onChange={(e) => setDate(e.target.value)}
      defaultValue={date}
    />
  )
}

function Random({ randomizer = Math.random }) {
  const [state, setState] = React.useState(randomizer())

  return <div>{state}</div>
}

테스트

describe('Date Component', () => {
  it('should update state on date change', () => {
    render(<Date date={new Date('2023-01-01')} />)

    const input = screen.getByRole('textbox')

    expect(input.value).toBe('2023-01-01')
  })
})

describe('Random Component', () => {
  it('should render a random number', () => {
    const mockRandomizer = () => 0.5

    render(<Random randomizer={mockRandomizer} />)

    const div = screen.getByText('0.5')

    expect(div).toBeInTheDocument()
  })
})
#314

대화 상자(Dialog Box)는 사용자에게 정보를 전달하고 응답을 요청하는 그래픽 제어 요소이다. Dialog box

대화 상자를 연 소프트웨어와의 상호작용을 차단합니다.

  • System Modal - 이 대화 상자를 닫기 전까지 다른 작업을 할 수 없게 하며, 과거 단일 작업 시스템에서 주로 사용되었습니다.
  • Application Modal - 프로그램을 일시적으로 중단시키며, 대화 상자가 닫히기 전까지 다른 작업을 할 수 없습니다. 이는 워크플로우를 방해하거나 사용자 오류를 초래할 수 있어 종종 비판받습니다.
  • Document Modal - 부모 창만 차단하며, 다른 창에서 작업을 계속할 수 있습니다. macOS에서 주로 사용되며, 부모 창에 연결된 시트 형태로 나타납니다.

Modeless

소프트웨어의 다른 부분과의 상호작용을 허용합니다. 이 대화 상자는 소프트웨어와의 상호작용을 차단하지 않으며, 대화 상자가 열려 있는 동안에도 사용자가 작업을 계속할 수 있습니다. 툴바가 모델리스 대화 상자의 예입니다.

고려 사항

  • 모달 대화 상자의 문제점 - 모달 대화 상자는 사용자 흐름을 방해하고, 반복적인 사용으로 인해 사용자가 실수로 잘못된 선택을 하게 만들 수 있습니다. 사용자는 습관적으로 확인을 누르는 경향이 있으며, 이는 작업 손실로 이어질 수 있습니다.
  • 경고 대신 실행 취소 - 경고 메시지로 실수를 방지하려는 접근은 한계가 있습니다. 경고를 더 강하게 만들어도 사용자는 이를 빠르게 무시하고 실수를 반복할 수 있습니다. 대신, 실행 취소(Undo) 기능을 제공하여 사용자가 언제든지 실수를 되돌릴 수 있도록 하는 것이 중요합니다. 이는 사용자의 스트레스를 줄이고 더 나은 사용자 경험을 제공합니다.
  • 인간 중심의 디자인 - 소프트웨어 디자인은 사용자의 습관을 존중해야 합니다. 사용자가 실수를 하더라도 복구할 수 있는 실행 취소 기능을 제공하는 것이 인간 중심의 디자인입니다. 경고를 사용하는 대신, 실행 취소를 제공하라는 원칙이 바람직한 디자인 방향입니다.1

Footnotes

  1. https://alistapart.com/article/neveruseawarning/

#313

Excel View1 라이브러리를 보던 중, Excel과 Node의 상호작용에 대한 궁금증이 생겼다. 아무래도 웹개발을 하다보니 어플리케이션과 통신할 수 있는 부분에 대해서 전혀 생각을 안했었다는 걸 깨닫고 이러한 부분을 보완하기 위해 Excel과의 통신 방식을 찾아봄.

우선, 위 라이브러리 코드를 통해 ActiveXObject('Excel.Application')2로 Excel 객체를 생성하고, 이를 통해 Excel의 다양한 기능에 접근할 수 있다는 것을 확인했다. 그리고 node-activex의 문서에서 링크를 통해 추가적인 정보들을 확인할 수 있었는데 아무래도 자주 보던 영역이 아니라 일단 확인만 하는 단계에서 멈춤.

이 접근을 통해, Excel과 상호작용하는 방법에 대해 실마리를 찾을 수 있었음. 그러나 모든 과정이 순조롭지만은 않은게. ActiveXObject를 사용하는 부분에서 개념적 이해가 부족했고, 그밖에 Excel의 객체모델에 대해서도 배경지식이 많이 부족하다는 걸 알게됨.

이번 경험을 통해, 웹 개발자가 어플리케이션 레벨의 개념들도 이해하면 좋겠다는 생각을 하게 됨. 나중에 기회가 되면 살펴보고 일단 view 기능을 스프레드시트로 구현해 봐야겠다.


Footnotes

  1. Excel에서 긴 줄을 탐색하는 데 도움이 되는 도구입니다. 활성 셀을 추적하고 전체 행 데이터를 자동으로 가져와 별도의 창에 표시합니다. 이를 통해 좌우로 스크롤하지 않고도 행의 모든 열을 쉽게 볼 수 있습니다.

  2. https://github.com/timepp/excelview/blob/master/excel.js#L70

#312

Avoiding premature abstraction with Unstyled React Components (buildui.com)

React 컴포넌트를 작성할 때, 불필요한 추상화를 피하고 컴포넌트의 유연성을 유지하는 방법. 특히 스타일이 없는 컴포넌트를 통해 어떻게 컴포넌트의 기능에 집중할 수 있는지를 설명.

import React, { ComponentProps, FC, ReactNode } from 'react'

function Spinner() {
  return (
    <span className="absolute inset-0 flex items-center justify-center">
      `<Spinner />`
    </span>
  )
}

type LabelProps = ComponentProps<'span'>

function Label({ children, ...rest }: LabelProps) {
  return <span {...rest}>{children}</span>
}

type LoadingButtonProps = ComponentProps<'button'> & {
  children: FC | ReactNode
}

/**
 * @description 버튼이 비활성화되었을 때 로딩 스피너를 표시할 수 있는 버튼 컴포넌트입니다. 이 컴포넌트는 children 속성으로 함수나 React 노드를 받을 수 있으며, 버튼이 비활성화되면 스피너가 표시되고, 텍스트는 보이지 않게 됩니다.
 */
function LoadingButton({
  disabled,
  className,
  children,
  ...rest
}: LoadingButtonProps) {
  return (
    <button {...rest} className={`${className} relative`} disabled={disabled}>
      {typeof children === 'function' ? (
        children({})
      ) : (
        <>
          {disabled && <Spinner />}
          <Label className={disabled ? 'invisible' : ''}>{children}</Label>
        </>
      )}
    </button>
  )
}

LoadingButton.Spinner = Spinner
LoadingButton.Label = Label

이 패턴은 컴포넌트를 작성할 때 불필요한 스타일링이나 구조를 미리 정의하지 않고, 각 컴포넌트가 자신의 역할에 충실할 수 있도록 도와줍니다. 이를 통해 코드의 유연성을 유지하고, 필요에 따라 컴포넌트를 확장하거나 수정할 수 있는 여지를 남겨두게 됩니다.

#311

React Router의 Link 컴포넌트 테스트하기

React 애플리케이션에서 라우팅은 중요한 부분. 특히, 사용자가 링크를 클릭할 때 적절한 페이지로 이동하는지 테스트하는 것은 중요. 다음은 React Router<Link /> 컴포넌트를 테스트하는 코드.

describe('Link', () => {
  it('이동', () => {
    const Home = () => <Link to="/about">About</Link>
    const About = () => <h1>About</h1>

    const { getByRole } = render(
      <MemoryRouter initialEntries={['/']}>
        <Routes>
          <Route path="/" element={<Home />} />
          <Route path="/about" element={<About />} />
        </Routes>
      </MemoryRouter>
    )

    fireEvent.click(getByRole('link'))

    expect(screen.getByRole('heading')).toHaveTextContent('About')
  })
})

가끔 <Link />도 테스트해야 할 때가 있다. 위 코드는 사용자가 “About” 링크를 클릭하면 “About” 페이지로 이동하는지 테스트. fireEvent.click을 사용해 링크 클릭 이벤트를 발생시키고, screen.getByRole을 통해 헤딩 요소의 텍스트가 ‘About’인지 확인. 이 테스트를 통해 라우팅이 제대로 동작하는지 확인할 수 있음.


#310

최근에 Enzyme을 제거하고 React Testing Library로 교체하는 작업을 진행했습니다. JSX 영역은 별 문제 없이 진행되었으나, state나 props를 다루는 구현 부분에서 약간의 애매함이 있었습니다. 개인적으로는 기존 접근 방식이 나쁘지 않았다고 생각했기 때문에, 새로운 접근 방식으로 전환하는 데 주저하게 되었습니다.

결론적으로, state와 props의 JSON 결과물을 렌더링하고 getByTestId를 사용하여 이를 참조하는 방식으로 테스트를 진행하기로 했습니다. 다음은 그 예시입니다:

render() {
  return <>
    {process.env.NODE_ENV === 'test' && (
      <pre data-testid="ThumbnailDebug">
        {JSON.stringify({
          props: this.props,
          state: this.state,
        })}
      </pre>
    )}
  </>
}

이 방법을 통해 Enzyme을 제거한다는 목표를 달성했기 때문에, 어느 정도는 해결된 것처럼 보이며, 추후 더 나은 테스트 코드를 작성하기 위한 고민을 할 수 있을 것 같습니다.


#309
if (!data) {
  throw fetch()
}
  1. 컴포넌트가 렌더링될 때, 비동기 작업(예: 데이터 패칭)이 시작됩니다. 이 작업은 일반적으로 promise를 반환합니다.
  2. 비동기 작업이 완료되지 않은 경우, 컴포넌트는 promise를 던집니다. 이는 JavaScript에서 예외를 던지는 것과 유사합니다. Suspense는 promise가 던져질 때 이를 캐치하고 fallback UI를 표시하는 역할을 합니다.
  3. React는 컴포넌트가 promise를 던졌을 때 이를 감지하고, Suspense 컴포넌트에서 이를 “캐치”합니다. Suspense는 이 promise가 해결될 때까지 대체 UI (fallback)를 렌더링합니다. Concurrent Mode에서는 React가 이 promise를 추적하고, 비동기 작업이 완료될 때까지 렌더링을 중단합니다.
  4. Promise가 해결되면(즉, 비동기 작업이 완료되면) React는 컴포넌트를 다시 렌더링합니다. Suspense는 현재 데이터 패칭 라이브러리(예: React Query, SWR)와 함께 사용되어 비동기 작업의 상태를 쉽게 관리할 수 있도록 도와줍니다.

#307

Next.js의 Route Loader와 Mini CSS Extract Plugin 살펴보기

Next.js의 router-loader.ts #prefetchViaDom()

function prefetchViaDom(
  href: string,
  as: string,
  link?: HTMLLinkElement
): Promise<any> {
  return new Promise<void>((resolve, reject) => {
    const selector = `
      link[rel="prefetch"][href^="${href}"],
      link[rel="preload"][href^="${href}"],
      script[src^="${href}"]`
    if (document.querySelector(selector)) {
      return resolve()
    }

    link = document.createElement('link')

    // The order of property assignment here is intentional:
    if (as) link!.as = as
    link!.rel = `prefetch`
    link!.crossOrigin = process.env.__NEXT_CROSS_ORIGIN!
    link!.onload = resolve as any
    link!.onerror = () =>
      reject(markAssetError(new Error(`Failed to prefetch: ${href}`)))

    // `href` should always be last:
    link!.href = href

    document.head.appendChild(link)
  })
}

이 함수는 미리 가져오는(prefetch) 역할을 하며 주요 특징은 다음과 같다.

  • 이미 prefetch 또는 preload된 리소스인지 확인.
  • 새로운 요소를 생성하여 리소스를 prefetch.
  • 로드 성공 또는 실패 시 적절한 처리.

Webpack의 Mini CSS Extract Plugin에서 CSS 청크 로드 실패 시 에러 처리 코드를 볼 수 있다.

 Template.indent([
  "var errorType = event && event.type;",
  "var realHref = event && event.target && event.target.href || fullhref;",
  'var err = new Error("Loading CSS chunk " + chunkId + " failed.\\n(" + errorType + ": " + realHref + ")");',
  'err.name = "ChunkLoadError";',
  // TODO remove `code` in the future major release to align with webpack
  'err.code = "CSS_CHUNK_LOAD_FAILED";',
  "err.type = errorType;",
  "err.request = realHref;",
  "if (linkTag.parentNode) linkTag.parentNode.removeChild(linkTag)",
  "reject(err);",
]),

이 코드는 CSS 청크 로드 실패 시 발생하는 에러를 처리하며 주요 특징은 다음과 같다.

  1. 에러 타입과 실제 URL을 파악.
  2. 상세한 에러 메시지를 생성.
  3. 에러 객체에 추가 정보(name, code, type, request)를 설정.
  4. 실패한 <link> 태그를 DOM에서 제거.
  5. Promisereject하여 에러를 전파.

이 플러그인은 CSS를 별도의 파일로 추출하는 데 사용되며, 위 코드는 그 과정에서 발생할 수 있는 오류를 처리하는 중요한 부분.

#306
19 중 5페이지