날짜 표기를 통일하는 데 매핑 객체가 필요 없다 — dayjs는 포맷 문자열이 곧 키이자 값이라, 허용 표기를 as const 배열 하나로 두면 그게 닫힌 집합이다. 닫힘은 union 타입이, 통로는 함수 하나가 맡는다. (i18n은 키≠값이라 객체가 필수였지만, 여기선 잉여.)

import dayjs from 'dayjs'

// 허용 표기의 단일 출처 — union 타입과 ESLint drift 가드가 여기서 파생
export const DATE_FORMATS = [
  'YYYY-MM-DD',
  'YYYY-MM-DDTHH:mm:ss',
  'HH:mm',
  'YYYY년 M월',
  'YY년 M월 D일',
  'YYYY.MM.DD',
  'YY.MM.DD',
  'YYYY.MM.DD(ddd)',
  'YYYY.MM.DD HH:mm',
] as const

export type DateFormat = (typeof DATE_FORMATS)[number]

export const formatDate = (
  date: dayjs.ConfigType,
  format: DateFormat,
): string => dayjs(date).format(format)

규율: 여러 곳에서 통일이 필요한 표기만 배열에 담는다. 일회성('M.D' 등)은 raw dayjs().format()으로 인라인 유지 — 안 그러면 배열이 잡동사니 enum이 된다. ESLint no-restricted-syntax로 카탈로그 표기를 raw로 다시 쓰면 경고(drift 가드).

ddd(요일)는 진입점에서 dayjs.locale('ko')를 호출해야 한국어로 나온다 — 안 하면 영어로 조용히 샌다. 타입은 이 런타임 전제를 못 잡는다.

곁가지 — 매핑 객체·빌드타임 hoist·tagged-template 캐시도 검토했으나 접음: 고유 패턴이 수십 개 규모라 dedup·컴파일 최적화가 풀 문제 자체가 없다. 닫힌 타입 + 함수 하나로 충분.

#576

declarative Combobox = render ∘ query ∘ defer. Suspense가 query: Q→Promise⟨D⟩를 throw로 바꿔 동기 Q→D로 위장하니, 전체가 명령형 조율이 아니라 함수 합성이 된다.

상태공간 곱→합. manual은 Q × Results × loading × error 곱집합이라 loading∧error 같은 모순 셀이 타입상 실재 — flicker는 렌더가 그 셀을 지날 때의 증상. 선언적 합 타입(loading | error | success)은 모순을 구성 불가능하게 만들고, case 분기를 if/else가 아니라 트리(<Suspense> / <ErrorBoundary> / 본문)가 담당한다. (형식 닻: ui_exclusive — 모든 값은 정확히 한 case.)

manual의 곱집합 상태공간엔 모순 셀(loading∧error 등)이 물리적으로 존재 — 선언적 합 타입은 그걸 표현 불가능하게 좁히고, case 분기를 트리 형태(Suspense/ErrorBoundary/본문)가 담당

Effect lifting = 자유 변수가 트리의 형태로. loading/error가 render의 인자(자유 변수)에서 사라지고, 가장 가까운 경계(<Suspense>/<ErrorBoundary>)가 잡는 ambient effect로 들린다 — renderResults 하나만 받는다. 대수적 효과(algebraic effects)의 handler 구조와 같은 모양.


stale-while-revalidate(two clocks·isStale) 부분은 [[570]]로.

#575

useMemo는 성능 힌트지 의미적 보장이 아니다 — 정확성을 캐시에 의존하면 안 된다. React는 특별한 이유가 있으면 캐시를 버린다: 개발 중 HMR, 초기 마운트 중 suspend, 미래 기능(오프스크린/가상 리스트). 그래서 useMemo 결과로 “한 번 정해지면 유지돼야 하는 값”을 만들면, 캐시가 폐기되는 순간 값이 바뀐다.

// ❌ 캐시 폐기 시 색이 재생성 → flicker
const colors = useMemo(() => getRandomColors(baseTheme), [baseTheme])

// ✅ 영속성이 정확성 조건이면 state로
const [colors, setColors] = useState(() => generateAccentColors(baseTheme))
const [prevTheme, setPrevTheme] = useState(baseTheme)
if (baseTheme !== prevTheme) {
  // 렌더 중 prev 비교 = derived state
  setPrevTheme(baseTheme)
  setColors(generateAccentColors(baseTheme))
}

판별 기준: “이 값이 재계산되면 틀린 동작인가, 아니면 그냥 느린 동작인가.” 틀림 → state/ref. 느림 → useMemo.

참고

로딩 flicker는 두 엣지인데 CSS는 한쪽만 잡는다:

  • leading — 응답이 너무 빨라 스피너가 뜨기 전/직후 사라짐. 언제 보이기 시작하나(delay D)의 문제. → CSS가 잡음: animation-delay/transition-delay가 곧 D. τ ≤ D면 한 프레임도 안 보인다(빠른 응답이 스피너 통째로 건너뜀). 단 억제력은 오직 D에서 — @starting-style 단독(D=0)으론 못 막는다.
  • trailing — 스피너가 떴는데 너무 짧게 보이고 하드컷. 언제 사라지나(τ)의 문제. 떴다는 사실은 못 되돌리니 (D,P)로는 못 잡고 τ 자체를 미뤄야 한다 = CSS 밖, JS 타이밍. → useMinimumLoading: unmount를 max(resolve, minLoad)로 밀어 한 번 뜬 스피너 가시 길이 ≥ minLoad 보장.

형식 닻: leading_edge_suppressed(τ≤D면 비가시), visible_ge_minLoad(τ-push 보장).

곁가지 — @starting-style은 flicker와 무관한 다른 축: transition의 from↔to를 distinct하게 만들어 마운트 보간이 죽지 않게 하는 것(진입 fade-in). 그래서 진입 애니메이션과 flicker 해법(useMinimumLoading)은 별개 결정.

#573

HTTP 요청 취소는 클라이언트 측 사건이다. 취소 시각 tA가 요청 lifecycle의 어느 구간이냐가 promise reject·서버 도달·서버 처리를 가르지만 — 취소는 이미 전송된 byte를 회수하지도, 진행 중인 서버 처리를 멈추지도 못한다.

뿌리는 한 정의 등식: Reached = Processed(둘 다 tSendDone ≤ tA) — 전송이 취소 전 끝났으면 서버는 처리하고, 취소는 그걸 못 멈춘다. 따라서:

  • reject된 promise는 완전히 처리된 서버와 양립한다(구간 [tSendDone, tResp)에서 둘 다 참). 취소 ≠ 서버에서의 무사건.
  • 절약되는 건 클라이언트 측(응답 처리)뿐 — 도달했다면 서버 작업은 안 절약됨.
  • 경계는 tSendDone — 취소 시각이 고정이어도 네트워크가 느려 tSendDone이 그 시각을 넘으면 도달 판정이 참→거짓으로 뒤집힌다(네트워크 속도가 도달 여부를 좌우).

형식 닻: no_server_cancellation(Reached→Processed), reachability_monotone_in_send_time(네트워크 속도가 도달 뒤집음). 곁가지: 캡슐화돼 반환 안 된 취소 핸들은 도달 불가 → 노출된 signal만 취소 가능.

#572

useMemo — 적중은 Object.is(deep-equal 아님)

useMemo 적중 조건은 Object.is(참조/원시 동등)이지 deep-equal이 아니다. 그래서 인라인 items={data.map(...)}은 내용이 같아도 매 렌더 miss(참조가 매번 새로움). 정확성은 별개 — 순수 가정(매 렌더 f() = φ(deps))이 서면 적중이든 miss든 반환값은 항상 fresh φ(deps). stale closure(deps에 없는 변수 포획)는 그 가정 자체를 깨서 stale 값을 낸다.

useDeferredValue — items와 무관 (핵심)

deferred 궤적은 자기 입력(query)에만 의존하고 items 참조 안정성과 무관(증명이 rfl인 이유 — items가 계산에 안 들어감). 그래서 items가 매 렌더 새로 만들어져도 죽는 건 useMemo 캐시 한 층뿐, deferred는 안 흔들린다.

안전성: deferred는 항상 지금까지 본 입력 중 하나(미래·날조값 아님). 수렴: 입력이 안정화되고 저우선순위 렌더가 무한 선점 안 되면 따라잡음 — 그 격차 구간이 isStale(opacity dim 창). 연속 타이핑 시 영영 못 따라잡을 수 있는데 버그가 아니라 의도(그 내내 dim 유지).

형식 닻: renderDeferred_const_in_items(items에 상수), converge(안정화 후 따라잡음).

location.href setter 실행 시점(②)과 실제 navigation 처리 시점(④)이 분리되는 이벤트 루프 타임라인

location.href setter의 실행 시점과 실제 navigation 처리 시점은 분리된다. setter는 동기적으로 정상 실행되지만(흐름을 끊지 않음), 그 효과는 브라우저 슬롯에 “마지막 값으로 덮어쓰기”로만 남는다. 실제 이동은 콜 스택이 비고 마이크로태스크까지 소진된 뒤(④)에야 일어난다.

그래서 “암묵적 await” 가설은 틀렸다 — await였다면 첫 줄에서 멈춰 google.com으로 갔겠지만, 실제로는 두 setter가 모두 실행되고 마지막 값 google2.com으로 간다.

location.href = 'https://google.com'
console.log('실행됨 1') // 출력됨
location.href = 'https://google2.com'
console.log('실행됨 2') // 출력됨 → await였다면 안 찍힘

console.log가 모두 찍히는 것이 setter가 흐름을 끊지 않는다는 직접 증거다.

#569

spinner 메시지가 매 틱 **셔플이 아니라 독립 균등추출(복원)**이면, 체감 “다양함”의 한계는 coupon collector 꼬리다 — 풀 n(≈90)을 다 보려면 평균 nH_n7.6분, 마지막 한 단어가 제일 오래 걸린다. 셔플이었다면 정확히 n틱에 끝났을 것 — 복원추출이라 꼬리가 생긴다.

탐색기: 풀 크기 슬라이더로 기대 누적 unique 곡선 — 커스텀 단어를 append할수록 분모가 커져 빌트인 단어 확률이 희석된다.

#568

핵심: parser 진행과 first paint는 분리된다. 깃발(first paint)이 보라 사각형(JS 실행)보다 왼쪽이면 — JS가 손대기 전에 그려지면 — 사용자가 잘못된 상태를 본다.

세 로딩 전략의 타임라인 — first paint(깃발)와 JS 실행(보라 사각형)의 선후로 flash window 발생 여부 비교
  • non-blocking (async/defer): paint가 JS보다 먼저 → flash window(빨간 구간) 발생.
  • parser-blocking (동기 <script>): parser는 멈추지만 그 위 청크는 먼저 그려질 수 있어, default 상태가 한 프레임 노출될지가 브라우저 재량 = 보장 없음.
  • blocking=render (<head> module): paint가 JS 뒤로 → 유일하게 깨끗.

트레이드오프: “1프레임 flash/CLS”를 막는 대가로 “FCP를 JS 실행시간만큼 미루는” 거래라, 스크립트가 작고 인라인일 때만 성립한다.

blocking=render를 써도 되는 세 조건 — 작고 인라인인 스크립트, head 안 배치, 무시돼도 안전한 폴백

성립 조건 셋:

  1. 스크립트가 작고 인라인 (FCP 지연을 감당할 만큼).
  2. <head> 안에서만 동작 — 컴포넌트 마크업 뒤가 아니라 head에 type="module" blocking="render"로 올려야 보장이 생긴다 (원문이 놓친 부분).
  3. attribute가 무시돼도 옛 동작으로 떨어질 뿐 깨지지 않는다 → 라이브러리가 권장 패턴으로 제공하려면 이 안전한 폴백이 핵심.
#567

prop 프리셋을 데이터로 중앙화하는 타입 헬퍼를, 가로축 excess(프로퍼티명 오타) 검출 · 세로축 개별 리터럴 보존으로 좌표에 놓으면 단일 named 헬퍼로 네 칸을 다 차지하는 점은 없다가 한눈에 보인다.

타입 헬퍼 4종을 excess 검출(가로) × 개별 리터럴 보존(세로) 사분면에 배치 — 우상단(둘 다 충족)을 named 헬퍼로 차지하는 점은 없음

우상단(둘 다 ✓)엔 satisfies와 Exact-never가 있지만, 각각 “어노테이션 반복” / “메시지 나쁨”을 대가로 치른다.

type TextStyleProps = Omit<TextProps, 'children'>

// ① 제네릭 항등 — 리터럴 O, excess 검출 X (조용히 T로 흡수)
const createTextStyles = <T extends Record<string, TextStyleProps>>(s: T): T =>
  s

// ② 키 제네릭 — excess O(메시지 좋음), 키 O, 개별 리터럴은 유니온으로 넓어짐  ← 추천
const createTextStyles = <K extends string>(
  s: Record<K, TextStyleProps>,
): Record<K, TextStyleProps> => s

// ③ Exact-never — 리터럴·excess O, 단 에러가 "not assignable to never"
type Exact<V> = {
  [P in keyof V]: P extends keyof TextStyleProps ? TextStyleProps[P] : never
}
const createTextStyles = <T extends Record<string, TextStyleProps>>(
  s: T & { [K in keyof T]: Exact<T[K]> },
): T => s

// ④ 인라인 satisfies — 리터럴·excess·메시지 다 O, 단 named 헬퍼 아님 + 어노 반복
const guideStyles = {
  title: { type: 'body1_700', color: COLORS.GRAY_90 },
} satisfies Record<string, TextStyleProps>

값 오타(type: 'nope')·키 오참조(styles.titel)는 ①②③④ 전부 잡는다. 갈리는 건 프로퍼티명 오타개별 type 리터럴뿐.

구조적 이유: excess 검출은 고정 target을, 리터럴 보존은 *제네릭 캡처(T)*를 요구해 서로 반대로 당긴다. satisfies만 컴파일러가 특별 취급해 둘을 한 번에 주지만, 그 대가가 “구문이지 헬퍼가 아님”이다.

권장: 엄격성이 목표면 ② (잃는 건 거의 안 쓰는 개별 type 리터럴). 개별 리터럴이 진짜 필요하면 ④. color가 required면 ①도 프로퍼티명 오타가 “필수 누락”으로 걸려 갭이 거의 사라진다.

프리셋을 컴포넌트가 아니라 prop 데이터로 다루면 래퍼 컴포넌트 비용(displayName·리마운트·추가 fiber)을 회피하고, override는 평범한 JSX 스프레드로 투명하다.

#566
28 중 1페이지