Navigation API 이탈 방지의 두 갈래 — 사전 차단과 사후 롤백
Native Messaging — 확장이 로컬 바이너리를 실행하는 법
근거 — manifest 필드·와일드카드 불가·크기 제한은 공식 문서 그대로다.1
Footnotes
-
manifest는
name·description·path·type·allowed_origins를 갖는다. “List of extensions that should have access to the native messaging host.allowed-originsvalues can’t contain wildcards.” / 크기 제한 — “The maximum size of a single message from the native messaging host is 1 MB”, “The maximum size of the message sent to the native messaging host is 64 MiB.” (Native messaging — Chrome for Developers) ↩
dnd-kit drop 직후 튕김 — setQueryData 는 화면을 한 틱 늦게 바꾼다
dnd-kit Sortable + react-query에서 drop 직후 아이템이 원래 자리로 돌아갔다가 새 자리로 점프한다. 1~2프레임짜리 튕김이고 onDragEnd에서 setQueryData로 즉시 캐시를 갱신해도 그대로다.
③에서 확정되는 원인 — drop 애니메이션은 놓는 즉시 목적지를 재는데1, setQueryData의 리렌더는 notifyManager의 setTimeout(0)을 거쳐 한 틱 늦다.2
④의 코드:
function SortableList() {
const { data: serverItems = [] } = useQuery({
queryKey: ['items'],
queryFn: fetchItems,
})
const [items, setItems] = useState<Item[]>(serverItems)
const isReorderingRef = useRef(false)
useEffect(() => {
if (!isReorderingRef.current) setItems(serverItems)
}, [serverItems])
const reorderMutation = useMutation({
mutationFn: reorderItems,
onSettled: () => {
isReorderingRef.current = false
queryClient.invalidateQueries({ queryKey: ['items'] })
},
})
const handleDragEnd = ({ active, over }: DragEndEvent) => {
if (!over || active.id === over.id) return
const next = arrayMove(items, idxOf(active.id), idxOf(over.id))
isReorderingRef.current = true
setItems(next)
reorderMutation.mutate(next)
}
// <DndContext onDragEnd={handleDragEnd}> <SortableContext items={items.map(i=>i.id)}> ...
}
라이브러리가 렌더 직후 DOM을 재면, 기준은 상태가 바뀌는 시점이 아니라 화면이 바뀌는 시점이다.
v6.3.1 기준. v10+는
OptimisticSortingPlugin이 기본 활성화라 기제가 다르다 → 재검증 필요.
Footnotes
-
“the drop animation for DragOverlay measures the destination position ASAP, and so it catches the 1 or 2 frames before my reorder is applied, and the item animates back to its original position.” (dnd-kit #833) ↩
-
notifyManager.schedule은 “schedules a function to be run on the next batch. By default, the batch is run with a setTimeout” —setScheduler로queueMicrotask·requestAnimationFrame으로 바꿀 수 있다. (notifyManager — TanStack Query) ↩
바깥 클릭 감지를 useEffect 없이 ref callback 하나로 만든다.
React 19 이상에서만 된다.
function useClickOutside(handler) {
const handlerRef = useRef(handler)
handlerRef.current = handler // stale closure 방지
return useCallback((node) => {
const handleClick = (e) => {
if (!node.contains(e.target)) handlerRef.current()
}
document.addEventListener('mousedown', handleClick)
return () => document.removeEventListener('mousedown', handleClick)
}, [])
}
// 사용
function Modal({ onClose }) {
const ref = useClickOutside(onClose)
return <div ref={ref}>...</div>
}
참고
애자일 의식(스쿼드·2주 스프린트·스탠드업)을 다 갖춰도, 수직 슬라이싱이 없으면 2주 컨테이너에 워터폴을 압축한 cargo cult agile이 된다.
증상
- 사일로/스쿼드 + 2주 스프린트라는 애자일 형식
- 실제 흐름: 기획 → 디자인 → 서버/클라 → QA 직렬
- 결과: 2주 컨테이너 안에 워터폴 압축
구조적 원인 (직군 구성 문제 아님)
- 작업 단위 = 기능 전체 (수직 슬라이싱 부재)
- 각 직군 산출물 = 다음 직군의 하드 의존성
- Discovery(무엇을)와 Delivery(만드는 일)를 같은 스프린트 같은 기능에서 동시에 시도
왜 아무도 의심하지 않는가
- 형식 요건(스탠드업, 회고, 번다운, Jira)이 갖춰져 있어 의심이 차단됨
- “애자일이 잘 안 된다”는 인식이 와도 의식 내부 처방(스탠드업 개선, 회고 솔직하게)으로만 귀결
- 의식 자체의 설계 오류는 검토 대상이 아님 → cargo cult agile
진짜 애자일은 모든 직군에 단기적으로 더 불편. 장기 처리량 이득은 조직 단위, 단기 불편은 개인 단위.
WeakMap의 키를 “입력 배열의 참조 자체”로 쓰면 캐시 무효화 로직이 0줄이 된다 — 무효화를 참조 동등성에 위임.
같은 배열을 이름 같은 키로 반복 조회해서 O(n) 스캔이 계속 도는데, 그 배열이 언제 바뀌는지 추적하기는 싫을 때 쓴다.
// 키가 weak — items 가 어디서도 안 잡히면 entry 도 GC. 일반 Map 이면 영구히 붙들려 누수
const indexCache = new WeakMap<Item[], Map<string, string>>()
// name → id 역인덱스를 1회 빌드 (O(n))
const buildIndex = (items: Item[]) =>
new Map(items.map((it) => [it.name, it.id]))
function lookup(items: Item[], name: string): string | null {
// 키는 내용이 아니라 참조(===)로 비교된다. 불변 업데이트면 "데이터가 바뀜" = "참조가 바뀜"이라
// 이 조회가 곧 무효화 검사다 — 그래서 delete·clear 가 어디에도 없다
let index = indexCache.get(items)
if (!index) {
// 새 참조(refetch 등) → 자동 miss. 옛 index 는 옛 items 와 함께 GC
index = buildIndex(items)
indexCache.set(items, index)
}
return index.get(name) ?? null // 여기부터 O(1) — 호출마다 돌던 find 의 O(n) 스캔이 사라진 자리
}
같은 배열 참조로 두 번 불러 buildIndex가 한 번만 돌면 된 것이다.
단 호출 측 items 참조가 stable해야 작동한다. 매번 [...data]·data.filter()로 새 배열을 넘기면 항상 miss라 무의미하다. (TanStack Query의 data/select 결과는 참조 안정성을 보장하므로 그대로 넘기면 OK) 반대로 push·splice 로 제자리에서 바꾸면 참조가 같아 옛 인덱스가 그대로 나온다 — 이쪽은 느린 게 아니라 틀린 값이다.
참고
참고
- Kiro — Specs — requirements → design → tasks 스펙 워크플로우
- fast-check — JavaScript PBT 프레임워크
- Bombadil (Antithesis) — 웹 UI property-based testing (TS로 property + action generator 작성)
isLoading 없이, onClick 에서 yield 만 한다. 핸들러를 async generator 로 받아 훅이 for await 로 돌며 yield 할 때마다 값을 바꾼다 — setLoading → setProgress → setLoading(false) 가 시간 순서대로 한 함수에 들어간다.
import { useEffect, useReducer, useRef } from 'react'
type Status = 'idle' | 'running' | 'errored' | 'done'
type State<T> = { status: Status; value: T; error: unknown }
type Action<T> =
| { type: 'start' }
| { type: 'yield'; value: T }
| { type: 'done' }
| { type: 'error'; error: unknown }
| { type: 'cancel' }
function reducer<T>(state: State<T>, action: Action<T>): State<T> {
switch (action.type) {
case 'start':
return { ...state, status: 'running', error: null } // 상태와 에러 초기화가 한 번에 — 따로 set 하다 어긋날 일이 없다
case 'yield':
return { ...state, value: action.value }
case 'done':
return { ...state, status: 'done' }
case 'error':
return { ...state, status: 'errored', error: action.error }
case 'cancel':
return { ...state, status: 'idle' } // 없으면 'running' 에 멈춰 있다
}
}
function useGenerator<T, A extends unknown[]>(
fn: (signal: AbortSignal, ...args: A) => AsyncGenerator<T>, // signal 은 훅이 첫 인자로 끼워 넣는다
initial: T,
) {
const [state, dispatch] = useReducer(reducer<T>, {
status: 'idle',
value: initial,
error: null,
})
const ctrlRef = useRef<AbortController | null>(null)
useEffect(() => () => ctrlRef.current?.abort(), []) // unmount 되면 진행 중인 흐름을 끊는다
const run = async (...args: A) => {
// 매 렌더 새로 만들어져 항상 최신 fn 을 읽는다
ctrlRef.current?.abort() // 재진입 — 직전 실행을 끊고 다시 시작
const ctrl = new AbortController()
ctrlRef.current = ctrl
dispatch({ type: 'start' })
try {
for await (const value of fn(ctrl.signal, ...args)) {
if (ctrl.signal.aborted) return
dispatch({ type: 'yield', value }) // yield 마다 리렌더
}
dispatch({ type: 'done' })
} catch (error) {
if (ctrl.signal.aborted) return // abort 로 생긴 throw 는 에러가 아니다
dispatch({ type: 'error', error })
}
}
const cancel = () => {
ctrlRef.current?.abort()
dispatch({ type: 'cancel' })
}
return { ...state, run, cancel }
}
검색은 같은 훅에서 재진입이 재시작이 된다 — 새 입력이 들어오면 이전 tier 가 끊긴다.
async function* searchTiers(signal: AbortSignal, q: string): AsyncGenerator<string[]> {
await wait(100, signal); yield [`${q}:local`]
await wait(300, signal); yield [`${q}:local`, `${q}:remote`]
}
const { value, run } = useGenerator(searchTiers, [])
<input onChange={(e) => run(e.target.value)} />
언제 — 시간이 코드의 한 방향(↓)으로만 흐를 때: 멀티 단계 loading · progressive search(tier 경계 = 취소 지점, debounce 불필요) · optimistic mutation(optimistic | reconciled | rolledBack 전이를 콜백 넷 대신 한 함수로).
확인 — 원본 데모 대비 채운 셋: ERRORED 를 선언만 하고 안 쓰던 것(→ catch), unmount 시 취소(→ AbortSignal + cleanup), 타입(→ 제네릭 훅). 검색창에 a 다음 바로 ab 를 치면 a:remote 가 끼어들지 않고 ab:local → ab:local,ab:remote 로만 바뀌면 된 것이다.
- 출처: @ericclemmons
URL의 공백이 %2520으로 깨져 들어온다. 원인은 iOS 17.0/17.1 WebKit pasteboard가 복사할 때 URL을 다시 인코딩하는 버그(WebKit Bug 261936)였다. Claude Code라면 깨진 값 해독 → 경로 좁히기 → 우리 코드 배제 → 방어 → 방어 확인 순으로 간다.
1. 깨진 값을 해독한다
decodeURIComponent('%2520')을 한 번 돌리면 %20이 남는다 — %25(= %) 뒤에 원래의 20, 두 번 인코딩된 것이다. 같은 URL의 한글(%EC%88%98)은 한 번 디코딩으로 풀리니 공백만 다시 인코딩됐다. URL 인코딩은 멱등이 아니다.
2. 어느 경로에서 깨지나
서버 액세스 로그에서 %2520이 든 요청만 모아 User-Agent·Referer를 센다. iOS에 몰려 있고 Referer가 비어 있다 — 링크 클릭(JS redirect)이 아니라 주소를 복사해 붙여넣은 경로다. 클릭 경로의 같은 URL은 정상이다.
3. 우리 코드인가, 밖인가
링크를 만드는 코드에서 encodeURIComponent 호출을 찾아 출력이 %20 한 번인지 본다. 한 번이면 이중 인코딩은 우리 밖에서 일어난다. iOS 버전으로 좁혀 검색하면 WebKit Bug 261936이 나온다.
- iOS 16↓:
NSURL URLWithString:이 invalid char에nil - iOS 17.0/17.1: invalid char를 자동 percent-encode (regression)
- iOS 17.2: 수정
4. 고칠 수 없으니 막는다
원인이 OS 버그면 추적보다 방어가 싸다.
- 화이트리스트 라우트 패턴에서만 한 번 더 디코딩
- 디코딩 후 위험 패턴(
..,//,\) 차단 - segment별
encodeURIComponent후 301 redirect
무조건 이중 디코딩 금지 — ..%252f..%252f → ../../ path traversal 우회 벡터.
5. 방어를 확인한다
%2520이 든 URL과 ..%252f..%252f가 든 URL을 curl -I로 보낸다. 앞의 것은 301로 정상 경로에 가고, 뒤의 것은 막혀야 한다.
교훈
- URL은 디코딩된 원본으로 보관하고, 인코딩은 출력 직전 한 번 (single source of truth)
- “방어적으로 한 번 더”가 함정 — 멱등이 아닌 연산엔 통하지 않는다
참고
컴포넌트가 렌더링하는 요소를 외부에서 제어하는 세 가지 패턴.
as
type PolymorphicProps<E extends React.ElementType> = {
as?: E
} & React.ComponentPropsWithoutRef<E>
function Button<T extends React.ElementType = 'button'>({
as,
...props
}: PolymorphicProps<T>) {
const Comp = as || 'button'
return <Comp {...props} />
}
asChild + Slot
import { Slot } from '@radix-ui/react-slot'
function Button({ asChild, ...props }) {
const Comp = asChild ? Slot : 'button'
return <Comp {...props} />
}
Slot 직접 구현:
const Slot = forwardRef(({ children, ...props }, ref) => {
if (!isValidElement(children)) return null
return cloneElement(children, {
...props,
...children.props,
ref: ref || children.ref,
})
})
자식 요소 타입에 따른 조건부 Props:
type ConditionalProps<T> = T extends ReactElement<any, 'a'>
? { href?: string; external?: boolean }
: T extends ReactElement<any, 'button'>
? { type?: 'button' | 'submit' }
: {}
render
// Element 방식
<Button render={<a href="/about" />}>Link</Button>
// Callback 방식 - state 접근 가능
<Button render={(props, state) => (
<motion.button {...props} animate={state.isPressed ? ... : ...} />
)}>
Animated
</Button>
- Base UI useRender - render prop 커스텀 훅 구현
- React Aria render prop PR - React Aria Components에 render prop 도입
- Slot/asChild Pattern - Radix Slot vs Base UI render 비교
- React as prop (christianvm) - ElementType, ComponentPropsWithoutRef 타입 활용
- slot-jsx-pragma - cloneElement 없이 asChild 구현하는 JSX pragma
- React as prop (robinwieruch) - 디자인 시스템에서 시맨틱 HTML 유지하며 스타일 분리
- asChild in React, Svelte, Vue, Solid - 프레임워크별 render delegation 비교
- React asChild (jacobparis) - asChild 패턴 구현 튜토리얼
- Polymorphism done well - 타입 안전한 polymorphic 컴포넌트