코어의 createSignal import가 빌드 타임에 프레임워크별 sibling 파일로 redirect되는 구조

코어는 createSignal·batch·untrack·createId 4개를 이름으로만 import하고 구현은 절대 고르지 않는다. 번들러가 타깃별 sibling(index.solid.tsindex.qwik.ts)으로 redirect → 런타임엔 각 프레임워크의 네이티브 signal이 그대로 들어가 추상화 세금이 0.

핵심은 런타임 DI vs 빌드 타임 DI. 런타임 어댑터는 간접층이 배포물에 잔존해 인라이닝·tree-shaking을 막는다. 빌드 타임 swap은 seam이 출력물에서 증발한다 — createSignal이 문자 그대로 shallowRef가 된다. (monomorphization vs virtual dispatch, #ifdef vs 런타임 if)

가능했던 이유: 계약을 Signal<T> = get value / set value 단 둘로 좁힌 것(narrowest interface). .value shape로 모인 Vue/Preact/Qwik은 re-export 한 줄로 충족된다.

날카로운 반례 — React만 예외. 네이티브 signal이 없어 ~100줄 pub/sub을 직접 구현한다. 빌드 swap이 어댑터를 없앤 게 아니라, 런타임 방식이었다면 모두가 졌을 비용을 React 번들 하나에 가둔 것.

대가: universal 번들 불가(타깃당 아티팩트 N개), 코어는 4-함수 계약 의 reactivity를 못 쓴다. 계약을 좁게 유지할 수 있느냐 = 패턴 성립 여부.

#562
EventTarget 코어 싱글톤이 useSyncExternalStore 어댑터를 거쳐 React 컴포넌트로 흐르는 데이터 흐름

싱글톤이 안티패턴이라는 통념은 싱글톤 자체가 아니라 React에 붙이는 방식이 후졌던 데서 나왔다. EventTarget 확장 + useSyncExternalStore면 프레임워크 무관 코어를 stale 없이 연결한다. 코어(표준 JS 클래스, React를 모름) / 어댑터(useSyncExternalStore 한 겹)로 분리 — Vue·Svelte는 어댑터만 갈아끼운다.

연결 방식의 진화:

  1. polling / 수동 refresh — stale, 데이터 변경과 무관한 리렌더
  2. useEffect + addEventListener — 낫지만 로컬 useState를 손으로 동기화 (React state스럽지 않음)
  3. useSyncExternalStore — 구독·동기화를 React가 직접 관리, stale closure 제거
const subscribe = (cb: () => void) => {
  const ac = new AbortController()
  toastManager.addEventListener('changed', cb, { signal: ac.signal })
  return () => ac.abort()
}
const getSnapshot = () => toastManager.toasts

const toasts = useSyncExternalStore(subscribe, getSnapshot)

코어의 핵심은 setter 하나 — 상태 변경과 이벤트 발행을 원자적으로 묶는다:

private set toasts(value: Toast[]) {
  this._toasts = [...value]      // 새 배열 → 참조 동등성으로 변화 감지
  this.dispatchEvent('changed')  // 변경 시 발행 누락 불가능
}

함정: getSnapshot은 참조가 안정적이어야 한다. 매번 새 객체를 리턴하면 무한 루프 — getter는 내부 배열을 그대로 반환하고, 새 배열은 setter에서만 만든다.

같은 계열 — 프레임워크 에 source of truth를 두고 UI는 구독만 하는 사고방식: build-time swap(562), IndexedDB-first(561).


#563