quad/.claude/base/ref-plan.md
qwreey e0ef7ce34b
docs(dispatch): PreRef 소진 센티널을 ProcessedPreRef로 교체, Length/Offset 등록 갭 해소
PreRef pre-pass 소진 슬롯이 None으로 뭉뚱그려져 있어 setLength/
setOffsetSource 등록 책임자가 불분명했던 갭(같은 세션 조사에서 발견)을,
전용 센티널 ProcessedPreRef + ProcessedPreRefHandler로 교체해 "이 위치를
처음 매치한 Handler가 등록 책임을 진다"는 기존 계약에 특수 취급 없이
편입시킴. 파생 서술(동적 경로 가드, 취소 개념 없음 근거) 정정 포함.

PostRef 백로그 스케치도 같은 원리로 갱신 — 별도 후행 재순회 없이 PreRef
pre-pass 한 번의 스윕에서 isPostRef도 같이 소진하고 postRefList에
적재해두는 안으로 PreRef/PostRef 소진 메커니즘을 완전히 대칭화.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-14 02:45:50 +09:00

573 lines
46 KiB
Markdown

# Ref / PreRef — 지연 없는 확정 값 박스
> **[2026-08-13 아홉 번째 세션] `bind-system-plan.md`에서 분리됨.** 그
> 문서가 2989줄까지 불어나 사람이 검토하기 어렵고 한 곳의 실수가 미치는
> 범위가 너무 커진다는 사용자 지적에 따른 1단계 분할. **내용은 옮기기만
> 했고 결정은 하나도 안 바뀜.**
**상태**: base — 확정. `Dispatch`/`Brand`와의 관계는
`base/dispatch-core-plan.md`(디스패치 코어)와 `base/brand-plan.md` 참고.
## Ref — 도입 확정, 단 용도는 재정의됨
**중요한 정정**: Ref는 Tween이 대상을 얻기 위해 필요한 게 아님(트윈을
실제로 처리하는 PropertyHandler도 `process(inst,k,v)`처럼 항상 대상
Instance를 직접 받으므로 — `base/dispatch-core-plan.md` "확정된 디스패치
모델" 참고, `base/tween-plan.md`도 이에 맞춰 갱신됨). Ref의 진짜 용도는 다름:
- v1의 `Frame "id" {}` + `Store.GetObject(id)` 식 id 매핑은 폐기 확정
(`base/architecture.md` 5번 항목) — "비현실적"이라는 게 이유.
- 하지만 **"라이브러리가 자기 자신이 만들어낸 instance를 나중에 다루기 편하게"**
하는 용도로 Ref는 여전히 필요. 구체 시나리오: 기존에 다른 라이브러리로
관리되던 instance를 당장 quad로 옮기지 않고, ref를 따서 그 안에 자식을
`Parent`로 마운트한다든가, 점진적으로 마이그레이션한다든가, 래퍼를 만든다든가
하는 다양한 용도.
- Store는 이미 바깥에서 옵저빙 가능한 존재라 별도 취급 불필요 — Ref는 그와
달리 "원하는 객체 자체를 직접 얻어오는" 경로. **얻어진 뒤에 그 참조를 어디에
저장하고 어떻게 쓰는지는 라이브러리 책임 범위 밖**(사용자 자유).
**권장 관례(2026-08-12, use-after-destroy 검토에서 명문화):** Ref는
이를 만든 컴포넌트 자신이 쓰거나 자식에게 넘겨 쓰는 용도가 관례 —
React `useRef`와 같은 스코프 감각. 컴포넌트 경계를 넘어 위로
반출하거나 전역에 장기 보관하는 건 권장하지 않음 — Ref는 Destroy와
완전히 무관하게 동작하므로(아래 "Destroy와는 무관" 절), 관례를 벗어난
반출·장기보관은 use-after-destroy가 발생할 수 있는 사실상 유일한
자리가 됨. quad는 이 케이스에 런타임 안전망을 두지 않기로 확정
(`research/framework-comparison-findings.md` 3번 절 근거) — 대응은
이 관례를 지키는 것뿐, 위반 시 결과는 완전한 UB.
- **바인드 방법**: children을 배열 아이템으로 넣듯 `Ref(default)`(또는
`:Callback(fn)`을 미리 걸어둔 `Ref(default):Callback(fn)`) 인스턴스
자체를 숫자 키 슬롯에 그대로 넣는 방식 — `(v=Ref)` 매치 핸들러가 이걸
처리함. **별도 `CreatedRef` 래퍼 함수는 없음(2026-08-07 아홉 번째
세션, 사용자 확인) — `Ref`/`PreRef`가 이미 Compose식 `Type(default)`
팩토리 생성자로 확정됐으므로("생성자 스타일 확정" 절), 그 결과를 그대로
children 배열에 놓는 것 자체가 바인드 관용구.** 원래 "`CreatedRef` 같은
이름 미정의 래퍼"로 서술했던 것은 Ref가 아직 "instance 얻는 통로"로
좁게 정의됐던 시절(2026-08-04)의 잔재였고, 2026-08-06 Ref 일반화 이후
래퍼 자체가 불필요해졌는데 이름만 남아있던 것을 이번에 정리함.
**[정정, 2026-08-07 세 번째 세션]** 정확한 순서 보장(자식 마운트
전/후, 프로퍼티보다 먼저)은 위치와 `PreRef` 타입으로 갈렸음 — 아래
"`phase` 옵션 폐기 → 위치로 표현, `PreRef` 신설" 절이 최신, 원래 있던
"옵션(`{phase=...}`)으로 두 타이밍을 고른다"/"특수 처리 없는 평범한
참가자" 서술은 `archive/ref-phase-option-reversed.md`로 옮김.
- **왜 값이 아니라 콜백인가**: quad는 React처럼 렌더 함수가 계속
재실행되지 않음(플레인 함수를 한 번 호출해 트리를 만들고 끝) — 그래서
"채워졌는지 매 렌더마다 다시 확인"하는 모델 자체가 없고, `useEffect`
의존성 배열 같은 것도 없음. 즉 값이 채워지는 시점을 외부에서 알아낼
방법이 콜백(또는 폴링, 채택 안 함 — `lifecycle-pattern.md`에서 폴링
방식은 이미 기각된 패턴) 말고 없음. 값 자체를 나중에 다루고 싶으면
콜백 안에서 원하는 곳(외부 변수, `self._button` 같은 필드, Store 등)에
직접 대입해 캡쳐하면 됨 — `component-composition-plan.md` 31행 예제
참고. 즉 "값으로도 얻어진다"는 요구는 별도 API가 아니라 콜백이 이미
충족함.
### Ref 일반화 — 엔진 instance 전용이 아니라 범용 값 박스 (2026-08-06 후속 세션)
**결정**: Ref는 "quad가 만든 instance를 얻는 통로"로 좁게 남지 않고,
**아무 사용자 값이나 담을 수 있는 범용 "채워지길 기다리는 값 박스"**로
확장한다. 위 "코루틴 기반 대기 지원 여부는 미정"이었던 항목은 이걸로
해소됨(더 이상 열린 질문 아님).
- **object-ref/function-ref로 나누지 않음.** React의 `useRef`가 DOM
노드든 임의의 사용자 값이든(함수 포함, `ref.current?.()`로 호출하는
imperative-handle 패턴 포함) 같은 API로 다루는 것과 동일한 선례 —
두 개념으로 쪼개면 사용자가 "이번엔 어느 쪽을 써야 하나" 매번
판단해야 해서 나쁨. 엔진 instance도 그냥 "사용자 값의 한 종류"일 뿐.
- **구체 유스케이스**: 자식 컴포넌트가 비싸고 온디맨드로만 필요한 계산
(예: 클릭 위치 기준 컨텍스트 메뉴를 그리기 위한 clip bounds 계산)을
부모에 노출하고 싶을 때, 매 변경마다 push하는 대신 부모가 필요할 때만
`ref.Value?.()`처럼 호출하는 함수를 Ref에 담아 넘기는 패턴 — React의
imperative handle과 동일한 이유(비싼 연산이라 온디맨드가 맞음, 값이
최신인지 아닌지도 애매해짐).
- **API 모양**: `.Value`(읽기 전용 필드) + `:Set(value) -> Ref<T>`(쓰기) +
`:Callback(fn) -> Ref<T>`(콜백 등록, 복수 허용) + `:Wait(thread?) -> Ref<T>`
(coroutine 컨텍스트에서 사용 — 렌더 함수 바디 안에서 `return` 위에 바로
못 씀, 그래서 콜백도 같이 필요) **세 메소드로 확정(2026-08-07 여섯 번째
세션)**. `:Set`/`:Callback`/`:Wait` 전부 **mutation 패턴이라 자기 자신
(`Ref<T>`)을 반환** — `store.key:Set(value)`류 "값을 바꾸는 연산엔 `:`
체이닝 허용" 원칙(`base/store-semantics.md` 190행)의 자연스러운 재적용.
이 self-반환 덕에 Luau의 `if`-표현식과 결합해 흔한 관용구를 한 줄로
쓸 수 있음(사용자 제시 예):
```luau
local t = if ref.Value
then ref.Value
else ref:Wait().Value
```
- 콜백은 이미 채워져 있으면 등록 즉시 그 값으로 1회 호출됨 — nil/미설정
상태여도 그 상태 그대로 호출. React의 `useEffect`가 매번 `.current`
존재 여부부터 체크하는 것과 같은 이유, Ref가 자식으로 전달되는 경우
채워지는 시점이 더 늦어질 수 있어서 "이미 채워졌는지" 확인이 항상
필요함. `:Wait()`의 대기자 리스트와 콜백 리스트는 같은 구조 재사용
가능(발화 후 해당 인덱스만 **`nil`로 소진** — 아래 구현 디테일 참고,
**[재정정, 2026-08-09 열한 번째 세션] `None`이 아니라 `nil`이 맞음**,
바로 아래 캐비엇 참고).
- **`.Value`는 이 테이블의 평범한 hash 필드로 직접 저장하지 않고
`__index` 메타메소드로 구현함(2026-08-09 열한 번째 세션 보강)** —
Ref 객체 자신이 곧 콜백/대기자 배열(숫자 키로 색인)이라, `.Value`
`self.Value = v`로 그냥 얹으면 그 값 자체가 이 테이블의 hash 파트에
같이 걸림. `T`가 함수나 스레드 타입일 수 있는데(Ref는 범용 값 박스,
위 "object-ref/function-ref로 나누지 않음" 참고), `for i, v in self do`
같은 일반화 순회가 배열 파트뿐 아니라 hash 파트도 함께 훑으므로 이
경우 `.Value`가 콜백/대기자 처리 루프에 잘못 걸려 `type(v)`
오분류될 위험이 생김. `__index`로 실제 저장 위치를 배열과 분리해두면
이 충돌 자체가 안 생김.
- **`:Wait(thread?)`의 `thread` 인자(2026-08-07 여섯 번째 세션, 사용자
제안, 확정)**: 생략(`nil`)하면 `coroutine.running()`으로 호출 중인
코루틴 자신을 캡처해 대기자로 등록하고 그 자리에서 `coroutine.yield()`
**자기 자신을 정지**시킴(값이 채워지면 재개). 명시적으로 다른 thread를
넘기면 **그 thread를 대기자로 등록만 하고 정지 없이 즉시 `self`
반환** — 코루틴 역학상 `coroutine.yield()`는 지금 실행 중인 코루틴만
정지시킬 수 있고, 어딘가 이미 정지해 있는 남의 thread를 여기서 대신
정지시킬 수는 없기 때문(그 thread는 이미 정지 상태). 이 표면의
용도: 사용자가 직접 관리하는 스케줄러가 이미 만들어 둔(어딘가 다른
지점에서 정지시킨) thread 하나를 Ref에 등록해두고, 등록한 코드 자신은
블록되지 않고 계속 진행하고 싶은 경우. 구현은 정말 단순함 — `thread`
`nil`이면 yield, 있으면 yield 안 함.
- **구현 디테일(2026-08-07 세 번째 세션 제안, 여섯 번째 세션에서 resume
payload 정정, 열한 번째 세션에서 소진 방식 최종 확정)**: 값이 새로
`:Set()`될 때, 같은 배열 하나를 `for i, v in <배열> do ... end`로 한 번만
순회하면서 `type(v) == "thread"``:Wait()`가 만든 대기자로 보고
**`coroutine.resume(v, self)`** (즉 값이 아니라 **Ref 자기 자신**을
resume 인자로 넘김 — 위 self-반환 관용구가 `:Wait()`의 yield
경로에서도 그대로 성립하게 하기 위해, `coroutine.yield()`
리턴값이 곧 `self`가 되도록 정정. 세 번째 세션 원안은 `value`
넘기는 것으로 적혀 있었으나 이러면 `ref:Wait().Value`가 안 풀려서
정정) 후 **`[i] = nil`**로 소진(아래 "왜 `None`이 아니라 `nil`인가"
참고), 아니면 일반 콜백 함수로 보고 그냥 `v(value)`(콜백은 여전히
원래 값을 직접 받음, 소진 안 함, 계속 유지)로 분기하면 됨 — 대기자/콜백을
서로 다른 배열로 나눌 필요 없이 값 타입 하나로 분기 가능
(`type(v) == "thread"` → 대기자, `type(v) == "function"` → 콜백,
`nil` → 빈 슬롯이라 스킵). 새 콜백/대기자 등록은 `table.insert`
아니라 **비어있는(=`nil`인) 첫 슬롯을 선형 탐색해 재사용**하는
등록 함수로 함(아래 참고) — 소진된 슬롯이 실제로 비므로 등록이 그
자리를 되찾아 쓸 수 있음.
- **왜 `None`이 아니라 `nil`인가(2026-08-09 열한 번째 세션, 최종 정정)
— 2026-08-07 열 번째 세션에 `None`으로 바꿨던 것은 이 배열에는 안
맞는 처방이었음, 되돌림.** `None`을 도입한 원래 근거(구멍 있는
정수 키가 해시 파트로 튀어 순회 순서가 깨짐, `table.insert``#t`
구멍 있는 테이블에서 미정의 동작)는 **순서가 실제로 중요한 배열**
(`PreRef` pre-pass, Length/Offset의 `sourceList``1..N` 고정
범위로 도는 `for` 루프라 구멍이 있으면 안 됨)에는 맞는 처방이지만,
Ref의 콜백/대기자 배열은 애초에 **순서가 중요하지 않다**(어느 게
먼저 fire되든 전부 fire되기만 하면 됨) — 일반화 `for i,v in tbl do`
구멍이 있어도 순서가 뒤섞여도 **모든 엔트리를 빠짐없이 방문**하므로
"순서 보장이 깨진다"는 문제 자체가 이 배열엔 없음. 오히려 `None`
쓰면 소진된 슬롯이 영원히 non-nil로 채워진 채 남아 **매 `:Wait()`
호출마다 배열이 끝없이 길어지는** 새 문제가 생김(등록이 항상 끝에만
추가되고 예전 슬롯을 재사용 못 함) — `nil`로 지우면 다음 등록이 그
빈 슬롯을 재사용할 수 있어 배열 크기가 동시 대기자 수만큼만 유지됨.
`table.insert``#t` 문제도 **`table.insert`를 아예 안 쓰고** 빈
슬롯을 선형 탐색해 넣는 등록 함수로 우회하면 됨(`None`이 필요했던
이유 자체가 없어짐). 결론: **순서가 안 중요하고 슬롯 재사용이
필요한 배열(Ref 콜백/대기자)은 `nil` 소진, 순서가 중요한 배열
(PreRef pre-pass 소진 슬롯, Length/Offset `sourceList`)은 계속
`None`** — 두 패턴이 서로 다른 문제를 풀고 있었을 뿐, 하나로 통일할
이유가 없었음.
- **주의(문서화 대상, 방어 로직 없음)**: 이미 죽은(완료/에러난) thread를
`:Wait(thread)`에 넘기면 나중에 `coroutine.resume`이 에러남 — 이건
다른 UB 케이스들과 같은 결로 라이브러리가 방어하지 않고 호출부 책임으로
둠.
- **제네릭 시그니처(2026-08-07 확정): `Ref<T>(T) -> Ref<T>` — 단일 타입
파라미터.** React `useRef<T, U=T>(U): T|U`류 "초기값 타입과 최종 타입을
분리"하는 2파라미터 설계도 검토했으나(예: `Ref<<HTMLDivElement>>(null)`
`HTMLDivElement|null`), Luau 솔버로는 명시된 타입 파라미터 하나와
인자에서 추론되는 다른 타입 파라미터가 만드는 합집합이 깔끔하게
풀리지 않고 미해소 제네릭 변수가 결과 타입에 남는 것으로 확인(사용자가
직접 Luau 플레이그라운드류로 확인) — `Source<T> satisfies State<T>`
`State<Modifier>` 차단 검증 항목(`research/pre-implementation-audit.md`)
에서 이미 반복 확인된 "Luau 제네릭 솔버는 복잡한 조합에서 잘 안 풀린다"는
패턴과 같은 결. 단일 파라미터로 단순화하면 이 위험 자체가 없음 — 대신
초기값만으로 좁은 타입이 추론되는 문제(`Ref(nil)`이 `Ref<nil>`
좁혀짐)는 `Ref<<Obj?>>(nil)`처럼 **명시적 제네릭 적용**(`f<<T>>(...)`
패턴, `.claude/initreq/tbox/CLAUDE.md:40-41` 선례)으로 타입을 넓혀
풀면 됨 — React `useRef<HTMLDivElement>(null)`도 명시적 타입 인자 없이는
같은 문제를 겪으므로 이미 널리 받아들여진 UX, quad가 새로 감수하는
트레이드오프 아님.
- **children 배열에 넣으면 dispatch가 자동으로 채워주는 것과의 관계**:
이 절의 Ref가 그 범용 프리미티브 자체 — 위 "바인드 방법" 절대로 `Ref`
인스턴스를 children 배열 숫자 슬롯에 그대로 놓으면 됨(quad가 만든
instance에 한정된 경우). 정확한 타이밍 보장은 옵션 값이 아니라 위치
기반 + `PreRef` 타입으로 표현됨 — 아래 "`phase` 옵션 폐기 → 위치로
표현, `PreRef` 신설" 절이 최신.
- **해소됨 — 반복 재설정 가능(one-shot 아님), 사용자 확정.** React에서도
자식이 재생성되는 경우 같은 방식(ref가 다시 채워짐)을 씀 — 예: 마우스
호버/무브 시 `current` 확인 후 라벨 위치를 결정하는 라벨 컨테이너
하나를 두고 라벨 내용만 스왑해가며 Ref를 재사용하는 패턴. 이런 고급
패턴은 조심할 게 많지만 그건 라이브러리가 아니라 사용자가 신경 쓸
몫. **따라서 콜백은 "발화 후 소진"이 아니라 매 `:Set()`마다 다시
불림** — 소진되는 건 `:Wait()`가 만드는 개별 대기자(coroutine 재개는
본질적으로 1회성)뿐, 콜백 리스트 자체는 유지됨.
- **⚠️ Ref는 의도적으로 lazy가 아니고 `:Compute` 파생을 지원하지 않음
— State와의 이 차이가 중요함.** (예전엔 Store가 Ref와 비슷한 것도
겸해서 지원한 적이 있었는데, State의 lazy 재계산 모델과 Ref의 즉시
get/set 모델이 섞여서 좋지 않았음 — 그 경험에서 나온 의도적 분리.)
Ref는 그냥 "지금 뭐가 들어있나/누가 채워주길 기다리나"만 다루는 즉시
값 박스이고, 파생값이 필요하면 Store/State(`:With`+`:Compute`)를 쓸 것
— 둘을 섞으려 하지 말 것.
- **[해소됨, 2026-08-08 다섯 번째 세션]** 위 정의 확장을 감안해도 `Ref`
이름은 그대로 확정 — "지연 없는 확정된 값 박스"라는 정의가 leaf로
담기는 용도/leaf에 바인딩하는 용도 둘 다에 여전히 맞아 더 나은 대안이
없다는 결론, 용어 정리 대상에서 제외됨.
### `Ref`의 retract — `State<Ref>` 재바인드 시 이전 Ref에 `nil` (2026-08-12 여덟 번째 세션, `TagHandler`와 같은 메커니즘 재사용)
> **✅ [2026-08-13 열네 번째 세션] 하강 diff 재디스패치 반영 완료.**
> "이전 클로저가 먼저 불려 언바인딩, 그 다음 `process`가 바인딩"이라는
> **두 단계 자체는 그대로**이고, 그걸 일으키는 주체만 바뀌었음(래핑
> 핸들러의 선행 `retractFrom` → `Dispatch.process`의 핸들러 선비교).
> 클로저가 받는 값의 타입도 이제 계약으로 보장됨(항상 `Ref`이거나
> `nil`). 상세는 `base/dispatch-core-plan.md` "Dispatch 체인" 절, 옛
> 모델 원문은 `archive/dispatch-hintvalue-model-reversed.md`.
**배경**: `Ref`는 이미 "일반 프로퍼티/Modifier 필드/Store 값 어디든 자유롭게
들어감"(아래 "동적 경로 가드" 절)이 확정돼 있어 — `State<Ref>`가 실제로
가능하고, 그러면 Store 값이 `refA`에서 `refB`로 바뀌는 경우가 생김. 이때
`refA`가 계속 "확정된 값(대개 이전 `inst`)"을 들고 있으면, 그 자리가 이제
`refB`로 넘어갔다는 걸 모르는 코드가 `refA.Value`를 계속 유효하다고 믿는
조용한 버그가 남음 — `PreRef` 재사용 버그(위 절)와 같은 클래스의 문제.
**메커니즘 — retractor가 매번 불린다는 전제 위에서 언바인딩 전담
(2026-08-12 열한 번째 세션 정정, 2026-08-13 다섯 번째/열네 번째 세션에
서술 갱신).** 이전 `process`가 반환한 클로저는 store 값이 바뀔
때마다(핸들러 타입이 그대로여도) 무조건 불림 — 같은 핸들러면
`Dispatch.process`가 그 자리 클로저에 새 값을 넘기고 곧바로 `process`
다시 부르기 때문(`base/dispatch-core-plan.md` "Dispatch 체인" 절 (A)
분기). 그래서 `refA→refB` 전환은 이전 클로저가 `nextValue=refB`로 먼저
불려 `refA`를 언바인딩하고, 그 다음 `process(inst,k,refB,index)`
`refB`를 바인딩하는 두 단계로 자연히 갈림 — `process`가 old-vs-new diff를 따로 계산할
필요가 없어짐(그 일을 클로저가 매번 정확히 대신 해줌). **`process` 쪽엔
여전히 `Relate`가 필요** — "spurious하게 같은 Ref가 재발행되면 재통지
skip"이라는 dedup은 `process`가 "이전에 뭐가 있었는지"를 알아야 하는데,
그건 인자로 안 들어오고(클로저가 받는 건 다음 값이지 이전 값이
아님) 오직 여러 호출을 가로지르는 저장소로만 알 수 있음
(`base/dispatch-core-plan.md` "핸들러 내부 상태 저장" 절이 이런 경우엔 `Relate`가 여전히 맞다고 한 그 사례):
```lua
local relate = Relate() -- Ref-leaf handler 전용, (inst,k)별 마지막으로 바인딩한 Ref 기억 —
-- process의 spurious 재바인딩 dedup 전용(클로저 캡처로는 대체 불가)
RefLeafHandler.isHandlable(inst, k, v) = isRef(v) and not isPreRef(v)
function RefLeafHandler.process(inst, k, v, index)
local old = relate:GetStrong(inst, k)
if old ~= v then -- 이미 같은 Ref가 이 자리를 차지 중이면 재통지 skip
v:Set(inst)
end
relate:SetStrong(inst, k, v)
return function(nextValue)
-- nextValue는 nil이거나 같은 핸들러가 곧 처리할 새 Ref(타입 보장됨) — v는
-- 이 process 호출이 만든 클로저가 직접 캡처(Relate 재조회 불필요)
if nextValue ~= v then
v:Set(nil) -- 매 :Set()마다 콜백 재통지되는 기존 Ref 규칙(위 "해소됨 —
-- 반복 재설정 가능" 항목)을 그대로 재사용, 새 알림 경로 아님
-- [정정, 2026-08-13 감사] relate 정리는 반드시 이 분기 *안*에 있어야
-- 함 — 밖에 두면 spurious 재발행(nextValue == v)에서도 기록이
-- 지워져, 곧바로 이어지는 process가 `old ~= v`를 항상 참으로 보고
-- `v:Set(inst)`를 재실행함(콜백 헛 재통지). 즉 아래 dedup 항목이
-- 약속한 "spurious면 둘 다 스킵"이 성립을 안 했음.
if relate:GetStrong(inst, k) == v then relate:SetStrong(inst, k, nil) end
end
end
end
```
- **retractor가 언바인딩 전담, `process`는 바인딩 전담** — 겹치는 diff
로직이 없음. `nextValue == v`(같은 Ref 객체가 스스로 재발행된
spurious한 경우)만 둘 다 스킵해 콜백이 `nil`→`inst`로 헛되이 두 번 안
불리게 함.
- **children 배열 리터럴 `Ref`도 같은 코드 경로를 그대로 씀** — 그 경우
이전 클로저가 (StoreBind 경로가 아니라 이 리터럴 구성 자체가 처음이므로)
아예 없고 `relate:GetStrong(inst,k)``nil`이라 `process`가 바로
`v:Set(inst)`로 끝남. "1회성 리터럴 구성"과 "반복 재바인드"가 하나의
구현으로 자연히 커버됨, 케이스 분기 불필요.
- **타입: 비-nilable `T`도 정당한 용도(사용자 확인, 2026-08-12 여덟 번째
세션)** — `Ref`는 "채워지길 기다리는 박스"뿐 아니라 "이미 확정된 값을
여기저기서 부작용 없이 읽는" 용도로도 쓰일 수 있어 `Ref<T>`(T가
non-nilable)를 계속 지원할 이유가 있음. 위 언바인딩(`old:Set(nil)`)이
실제로 발생하는 자리는 **Store/Modifier 필드에 놓여 재바인드/retract가
가능한 `Ref`뿐**이므로, 그 자리에 놓을 `Ref`는 **호출자가 직접
`Ref<<T?>>(...)`로 명시**할 것 — 이미 있는 "초기값이 `nil`이면 명시적
제네릭 적용으로 타입을 넓힌다"는 관용구(위 "제네릭 시그니처" 절)를
그대로 재사용하는 것뿐, 새 타입 규칙 추가 아님. 프레임워크가 자동으로
감지해 넓혀주지 않음 — non-nilable `T`로 선언해놓고 Store/Modifier
자리에 놓으면 런타임에 `.Value`가 타입과 어긋나게 될 수 있는 caller
책임의 UB(Luau 타입은 런타임에 지워짐, 다른 UB 케이스들과 같은 결).
- **Destroy와는 무관 — 별도 처리 없음(사용자 확정).** `Ref`의 언바인딩은
오직 위 재바인드/retract 경로에서만 일어나고, 대상 Instance가
`Destroy()`되는 것과는 별개 — Ref 자신은 Destroy를 감지하지도, 반응하지도
않음. `Ref<Frame?>`가 이미 Destroy된 Frame을 계속 들고 있는 채로 남는 건
정상적으로 가능하고, 그 이후 읽고 쓰는 건 그냥 UB(라이브러리가 방어
안 함 — `:Wait(thread)`에 이미 죽은 thread를 넘기는 기존 UB와 같은 결).
Destroy 시점에 실제로 정리가 필요하면 `Effect`(내부적으로 `bindLifetime`/
`Observer` 위에서 동작, 또는 Roblox가 Destroy 시 알아서 `Disconnect`해주는
이벤트 안에 로직을 두는 기존 관례)를 쓰도록 문서가 유도할 것 — Ref
자신에 Destroy-awareness를 얹는 건 오버엔지니어링.
### `phase` 옵션 폐기 → 위치로 표현, `PreRef` 신설 (2026-08-07 세 번째
세션 — 이 절이 당시 쓰던 `CreatedRef(fn, ...)` 래퍼 이름 자체도 이후
아홉 번째 세션에서 폐기됨, 위 "바인드 방법" 절 참고)
**children 배열에 놓는 Ref에 `{phase="created"|"mounted"}` 옵션으로 두
타이밍을 고르게 하던 것 자체를 없앤다.** `base/dispatch-core-plan.md`
"확정된 디스패치 모델" 절에
새로 추가된 두 패스 보장(배열 파트는 index 순서대로, 그 다음 해시 파트)
덕분에, 같은 인스턴스 안에서 **일반 `Ref`를** 다른 children보다 앞/뒤
어디에 놓느냐가
이미 "그 형제가 마운트되기 전/후"를 그대로 결정함 — 각 자식은 자기
서브트리까지 전부 동기적으로 마운트를 끝내야 다음 형제로 넘어가므로,
"마지막에 놓기"만으로 "모든 자식 마운트 후" 의미가 공짜로 나옴. 별도
옵션 문법을 유지할 이유가 없어짐. **(아래 `PreRef`는 이 위치-의존 규칙의
예외 — 위치 영향을 아예 안 받고 호이스팅됨, 해당 절 참고.)**
**단, "프로퍼티/이벤트 세팅보다도 먼저"는 위치만으론 못 푼다.** 배열
파트가 해시 파트보다 항상 먼저 처리된다는 보장은 **그 인스턴스의 최초
props 테이블에 리터럴로 존재하는 항목에 한정**됨 — Modifier를 거쳐
flatten된 값은 해시 파트(프로퍼티 키)로 존재하게 되고, Store를 거쳐
나중에 도착하는 값은 애초에 이 최초 스캔 자체를 벗어난 시점(process/retract
재귀 경로)에 도착하므로 이 보장 밖. 그런데 "프로퍼티보다 먼저 채워져야
한다"가 실제로 필요한 이유가 있음 — quad-roblox 이벤트는 `self(Instance)`
안 주기로 확정했으니(아래 절) self 접근은 Ref로 해야 하는데, Roblox
이벤트 중 일부(`ChildAdded`/`DescendantAdded`/`Changed`류)는 유저
인터랙션을 기다리지 않고 **setup 도중 프로퍼티 대입/Parent 세팅 자체의
부작용으로 동기적으로 발화**할 수 있음 — 이때 이벤트 핸들러가 아직 안
채워진 self-ref를 읽으면 터짐.
**해결**: 이 케이스만 별도 타입 `PreRef`로 분리.
- **구현은 `Ref` 그대로 재사용**(같은 `.Value`/`:Set()`/`:Callback()`/
`:Wait()` API) — 브랜드 태그만 다른 nominal 타입. 런타임 코드 중복 없음.
**소스 파일은 분리(2026-08-07 여섯 번째 세션)**: `Ref`는 이제 그 자체로
충분히 완결된 프리미티브고 `PreRef`도 "children 배열 전용, 위치 무관
호이스팅"이라는 특이한 제약을 가진 별개 프리미티브라, 기존 프리미티브당
1파일 컨벤션(`modifier-plan.md`/`slot-plan.md`류, Blocker/Effect를
같은 이유로 분리한 2026-08-07 네 번째 세션과 같은 판단)을 따라
`Ref.luau`/`PreRef.luau` 두 파일로 쪼갬 — 런타임 로직은 여전히 공유
(`PreRef.luau`가 `Ref.luau`를 그대로 불러다 브랜드 태그만 얹음), 파일
분리는 순수 조직 문제라 위 재사용 결정과 상충 없음. `base/architecture.md`
소스트리에 반영 완료.
- **오직 children 배열의 리터럴 아이템으로만 놓을 수 있다** — **Modifier
필드 값으로도, Source/Store 값으로도 들어갈 수 없게 타입으로 차단.**
- Modifier 필드로 막는 이유: 거기 들어가면 flatten 후 해시 파트로
존재하게 돼 "배열 파트가 먼저"라는 보장 자체를 벗어남. 게다가
Modifier는 여러 인스턴스에 재사용되는 값인데 PreRef는 정의상 "이
인스턴스 하나의 construction 훅"이라 애초에 공유할 이유가 없음 —
허용해도 얻는 유스케이스가 없는 오버엔지니어링.
- Source/Store 값으로 막는 이유: Store 값은 항상 process/retract 재귀
경로로 도착하는데, 그 경로는 정의상 최초 배열 스캔보다 나중(또는
아예 스캔 밖)이라 "프로퍼티보다 먼저"를 구조적으로 만족시킬 방법이
없음 — `State<Modifier>`를 막기로 한 것(`modifier-plan.md` 7번,
2026-08-09 세션부터 `isModifier` 기반 명시적 error)과 정확히 같은
원칙의 재적용.
- **`PreRef`는 배열 안 위치의 영향을 안 받는다 — 호이스팅.** 일반
`Ref`와 달리, 같은 인스턴스의 배열 파트 안에서 다른
children/`Ref`보다 뒤에 적었어도 그것들보다 먼저 fire됨(자바스크립트
함수 선언 호이스팅과 같은 느낌으로 문서화). 이유: PreRef의 존재
목적 자체가 "이 인스턴스에 뭐가 됐든 일어나기 전에" 채워지는 것인데,
단순 위치 기반 순서만 따르면 그보다 앞선 형제(다른 child)가 먼저
마운트되면서 그 형제가 부모에 Parent될 때 부모의 `ChildAdded`류가
동기 발화할 수 있어 PreRef가 막으려는 문제가 그대로 재현됨. 그래서
base 드라이버는 위 두 패스(배열→해시) 루프를 돌기 **전에** 별도의
작은 pre-pass로 배열 파트를 훑어 `PreRef` 항목만 먼저 전부 fire하고,
그 다음 나머지(children/일반 Ref/프로퍼티/이벤트)를 평소처럼 두
패스로 처리하면 됨 — 이 pre-pass는 오직 `PreRef` 타입만 골라내므로
범위가 좁고, "확정된 디스패치 모델" 절의 두 패스 계약과 별개로 그
앞에 얹히는 것.
- **복수 `PreRef` 간 순서(2026-08-07 아홉 번째 세션, 사용자 확인) —
새 규칙 불필요, 배열 index 순서 그대로.** 같은 인스턴스에 `PreRef`
여럿 있으면, 이 pre-pass는 위 "props 순회 순서" 절이 이미 확정해둔
"배열 파트는 index 순서대로" 계약을 그대로 재사용해 리터럴 순서대로
fire하면 됨 — 서로 다른 우선순위/순서 개념을 별도로 만들 필요 없음
(호이스팅은 "PreRef 전체 대 나머지"에만 적용되는 규칙이지, "PreRef끼리"
에는 적용될 게 없음 — PreRef끼리는 그냥 평범한 배열 순회).
- **호이스팅의 실제 구현 = "물리적 재배치"가 아니라 "완전히 별도의
선행 스캔"(2026-08-07 아홉 번째 세션 후속, 사용자 질문에 답변).**
`Dispatch.drive(inst, flattened)`는 같은 `flattened` 배열을 **두 번
순회**한다 — (1) pre-pass: 배열 파트 전체를 index 순서대로 훑으며
`isPreRef(v)`인 슬롯을 찾아 그 자리에서 fire하고 즉시 **`flattened[i]
= ProcessedPreRef`**로 소진(`nil`이 아님, 2026-08-07 열 번째 세션 정정: `nil`
지우면 그 순간 테이블이 "구멍 있는" 상태가 되어 이어지는 (2)의 순회
순서 보장 자체가 깨질 위험이 있음 — 정확히 이 pre-pass가 의존하는
바로 그 보장이라 치명적. **[주의, 2026-08-09 열한 번째 세션] Ref
자신의 콜백/대기자 배열은 이 이유가 적용되지 않아 `nil` 소진으로
되돌아갔음(위 "Ref 일반화" 절 참고) — 여기 PreRef pre-pass는 순서
보장이 실제로 필요한 별개 케이스라 실재하는 센티널 소진이 계속 맞음, 두
사례를 혼동하지 말 것**). **[정정, 2026-08-14 두 번째 세션] 소진 값은
`None`이 아니라 전용 센티널 `ProcessedPreRef`(단일 `{}`, `None`과 같은
급의 유니크 키 — 사용자 제안).** 옛 설계는 소진 값을 `None`으로 뭉뚱그려
"그 자리가 원래부터 빈 자리"였던 경우(`props.Ref or None`)와 "한때
PreRef였다가 방금 fire되어 소진된 자리"를 구별 못 했고, 그 결과
아래 "Length/Offset" 계약(`base/dispatch-core-plan.md`, 2026-08-09
여섯 번째 세션 확정)이 "이 위치를 처음 매치한 Handler가
`setLength`/`setOffsetSource` 등록 책임을 진다"고 못박아 놨는데도
`None` 소진 슬롯은 정의상 어떤 Handler도 안 거쳐서(아래 참고) "그럼
누가 그 등록을 실제로 호출하는가"가 문서 어디에도 없는 갭이었음
(2026-08-14 첫 번째 세션 조사에서 발견). `ProcessedPreRef`로 소진처를
분리하면 이 갭이 구조적으로 사라짐 — 아래 `ProcessedPreRefHandler`
참고. (2) 그 다음에야 비로소 평소의 배열→해시 두 패스가
**같은 테이블**을 다시 순회 — 이때 `ProcessedPreRef`로 소진된 슬롯은
**정상 `Dispatch.process` 경로를 그대로 탄다**(아래
`ProcessedPreRefHandler`가 매치, **[정정] 예전엔 `None`이라 두 패스
루프 자신이 `if v == None then continue end`로 직접 건너뛰고 어떤
Handler도 안 거쳤으나, 지금은 일부러 정상 경로를 태워 Length/Offset
등록 책임을 기존 계약에 특수 취급 없이 그대로 얹음**). 진짜로
원래부터 빈 자리인 `None`(`props.Ref or None` 등)은 여전히 두 패스
루프가 직접 건너뜀 — 두 센티널이 이제 서로 다른 경로를 타므로
혼동 금지. "호이스팅"은 PreRef를 배열의 맨
앞으로 물리적으로 옮기는 게 아니라, **PreRef 전용 선행 루프가
통째로 먼저 끝난 뒤에야 나머지 처리가 시작된다는 뜻** — 그래서
소스에서 마지막 child로 적었어도 무조건 다른 모든 처리보다 먼저
fire됨. **PreRef 슬롯을 소진시키는 게 단순 최적화가 아니라 정확성
요건인 이유**: 아래 "동적 경로 가드" Handler가 `(v=PreRef)`
매치하면 무조건 `error`를 던지므로, pre-pass가 슬롯을 안 지우면
두 번째(정상) 패스가 이미 정당하게 처리된 그 PreRef를
`Dispatch.process`로 다시 넘기게 되고, 그러면 이 가드 Handler가
엉뚱하게 매치되어 **정상적인 PreRef 사용에도 에러가 터짐** — 소진은
이 오탐을 막기 위해 반드시 필요(`ProcessedPreRef`는 `isPreRef(v)`
거짓이라 이 가드 Handler와는 애초에 안 겹침).
- **`ProcessedPreRefHandler` — 소진된 슬롯이 Length/Offset에 "0 기여"를
등록하는 전담 Handler (2026-08-14 두 번째 세션, 사용자 제안 — 위 갭의
해소).**
```lua
ProcessedPreRefHandler.priority = <매우 높음, NoneHandler와 동급>
ProcessedPreRefHandler.isHandlable(inst, k, v) = (v == ProcessedPreRef)
function ProcessedPreRefHandler.process(inst, i, v)
Dispatch.setLength(inst, i, 0)
Dispatch.setOffsetSource(inst, i, None)
return function() end -- no-op retract, 이 자리는 fire가 끝나
-- 되돌릴 상태 자체가 없음
end
```
`isHandlable``v == ProcessedPreRef`만 잡으므로 배열 파트 전용(해시
파트엔 이 센티널이 등장할 경로 자체가 없음). 이걸로 `base/
dispatch-core-plan.md`의 "Length/Offset" 절이 이미 확정해둔 "이
위치를 처음 매치한 Handler가 등록 책임을 진다"는 계약을 특수 취급
없이 그대로 만족시킴 — 매치되는 Handler 자신이 곧 등록자라 "누가
등록하는가"라는 질문 자체가 안 생김. 반환하는 retract는 하드코딩된
no-op인데, 이건 "PreRef는 취소 개념이 없다" 절(아래)이 말하는 것과
같은 이유 — fire가 이미 실행한 부작용은 되돌릴 수 없으므로 이 자리가
dispatch 체인에 실제로 올라가 있어도(**[정정] 예전 서술과 달리 이제는
올라가 있음** — 아래 참고) retract가 할 일이 없는 것뿐.
- **명확화(2026-08-09 열한 번째 세션, 확인 질문에 답변) —
`NoneHandler.isHandlable(inst,k,v) = (v == None)``k` 타입을 전혀
안 가리므로 숫자 키(`k=number`)에서도 이론상 매치될 수 있어 보이지만,
실제로 문제 안 되는 이유는 위에서 이미 확정한 그대로다: 배열 파트의
`None`**애초에 `Dispatch.process` 자체를 절대 안 탄다**(두 패스
루프가 `Dispatch.process` 호출 전에 자기 스스로
`if v == None then continue end`로 걸러냄). `NoneHandler`
`Dispatch.process`를 거쳐야만 매치될 기회를 얻으므로, 배열 파트의
`None`이 거기 아예 도달하지 않는 이상 `k=number` 조합으로
`NoneHandler`가 실제로 매치되는 경우는 없음 — "재전파 없이 무시된다"가
정확한 설명.
- **M0 스파이크 검증 항목 갱신(2026-08-07 열 번째 세션)**: 위 "props
순회 순서" 절은 `{a=1, 2, b=3}`**구멍 없는** 테이블에서 배열
파트가 해시 파트보다 먼저 나온다는 것만 실측 확인됨(2026-08-07 세
번째 세션). 같은 세션에서 사용자가 직접 `{[1]=1,[2222]=2222,
[211]=211,...}`**키가 듬성듬성한(sparse)** 테이블을 REPL로
실측해, 그런 테이블은 순회 순서가 index 오름차순이 전혀 아님(해시
버킷 순서)을 확인함 — 그래서 위 pre-pass는 (nil이 아니라) 실재하는
센티널(`ProcessedPreRef`, 2026-08-14 두 번째 세션 이전엔 `None`)로
소진해 테이블을 "구멍 없이 촘촘한" 상태로 계속 유지하는 전략으로
이 위험을 원천 회피함(검증 불필요, 애초에 구멍을 안 만드므로).
**여전히 M0에서 검증해야 하는 건 다른 케이스**: `props.Modifier`/
`props.Ref`를 caller가 안 넘겨 생기는 리터럴 `nil`-hole(`{nil, ref,
child}`, 위 "지금 할 일" 우선순위1 항목)은 caller가 직접 쓰는 raw
Lua 리터럴이라 프레임워크가 `None`으로 대신 못 채워줌 — 이번 REPL
실측으로 그 케이스의 실제 위험도가 이전 서술("뒤 항목까지 무시될 수
있음", 국소적 피해로 서술돼 있었음)보다 훨씬 큼이 드러남: 구멍이 하나만
생겨도 **테이블 전체**가 해시 파트 취급으로 넘어가 그 인스턴스의
배열 파트 전체가 순서 보장을 잃을 수 있음. M0 스파이크에서 반드시
실측하고, 심각하면 "이런 nil-hole은 raw 리터럴로 하지 말고 항상
`props.Modifier or Modifier()`처럼 non-nil을 보장하라"는 컨벤션
문서화까지 검토할 것.
- **pre-pass는 어디 사는가 — `Dispatch.drive(inst, flattened)` 자신,
새 함수 불필요(2026-08-07 아홉 번째 세션, 사용자 제안 검토 후 확정).**
`Dispatch.drive`가 이미 `(inst, flattened)`를 받아 배열→해시 두 패스를
도는 함수로 확정돼 있으므로, 그 앞에 좁은 pre-pass 한 줄을 얹는 것만으로
충분 — `Handler.process`와 이름이 겹치는 새 `Dispatch.process(inst,
flatten, prerefs)`류 함수를 따로 만들 필요가 없음(그 이름은 이미
다른 뜻으로 쓰이는 `Dispatch.process(inst,k,v)` 오케스트레이터와 겹쳐서
안 좋음). **`flatten(nonFlatten) -> flatten` 함수 자체에 얹는 방안은
검토 후 기각** — flatten은 Modifier 값을 합치는 순수 변환(현재 `inst`
안 받음, `research/existing-instance-bind-plan.md`가 다루는 "이미
마운트된 Instance 재바인드 시 Default→실값 flatten을 다시 해야 하는가"
질문이 실제로 열려있어 flatten이 한 인스턴스 생애주기 동안 **여러 번
재호출될 가능성이 있음** — 여기에 PreRef fire를 얹으면 재바인드마다
PreRef가 또 fire되어 "이 인스턴스 하나의 construction 훅"이라는 PreRef의
정의 자체가 깨짐. `Dispatch.drive`는 최초 마운트 시 정확히 한 번만
불리는 게 이미 전제라 이 위험이 없음.
- **동적 경로로 도착한 PreRef는 런타임에도 명시적으로 에러
(2026-08-07 아홉 번째 세션, 사용자 제안 채택) — 아직 문서화 안 돼
있었음, 지금 확정.** 위 "Modifier 필드로 막는 이유"/"Source/Store
값으로 막는 이유" 절은 **타입 차단**만 다뤘음 — Luau 타입은 런타임에
지워지므로(`:Peek`/`Overridden`/버그로 타입을 우회해 PreRef가 Modifier나
Store 값으로 실제로 흘러들어오는 경우), 런타임에도 방어가 필요함.
전용 `Handler`를 하나 등록: `{ isHandlable = function(inst,k,v) return
isPreRef(v) end, process = function(inst,k,v) error("PreRef는 children
배열 리터럴에만 놓을 수 있음") end }``NoneHandler`와 같은 결의
"한 값 종류만 전담하는 Handler" 패턴 재사용, 새 메커니즘 아님. 이
Handler는 **`Dispatch.process`/`getHandler`의 정상 우선순위 스캔에
등록**되는 반면(pre-pass처럼 그 밖에서 도는 게 아님), 리터럴 배열의
`PreRef`는 pre-pass가 fire와 동시에 해당 슬롯을 소진(**[정정,
2026-08-14 두 번째 세션] `None`이 아니라 `ProcessedPreRef` 처리**,
위 "호이스팅의 실제 구현" 절)해 **이 가드 Handler(`isPreRef(v)`만
매치)에는 다시 노출되지 않게** 하므로(정상 두 패스 스캔 자체엔
`ProcessedPreRefHandler`를 통해 여전히 노출됨 — "스캔에 안 걸림"이
아니라 "이 가드에 안 걸림"이 정확한 설명), 이 Handler가 실제로
매치되는 경우는 오직 "타입이 막았어야 했는데 어떻게든 동적으로
새어들어온" 버그 케이스뿐 — 그래서 no-op이 아니라 즉시 `error`.
- **PreRef는 "취소"라는 개념이 없다 — 1회용, 재사용은 즉시 error
(2026-08-12 여섯 번째 세션, 사용자 제안 채택).** `Ref`가 "다른 값으로
교체되면 `retract`로 취소됨"이라는 의미의 취소를 가질 수 있는 건 정상
우선순위 스캔의 `(inst,k)` 디스패치 체인에 실제로 참여해서임 —
`Dispatch.retractFrom`이 그 체인을 대상으로 동작함. **[정정,
2026-08-14 두 번째 세션] "그 체인에 올라간 적이 없다"는 근거는 더 이상
정확하지 않음** — `ProcessedPreRefHandler` 신설로 소진된 슬롯도 이제
정상 `Dispatch.process` 경로를 타 체인에 올라감(위 "호이스팅의 실제
구현" 절). "취소 개념이 없다"는 결론 자체는 그대로 유효하지만 이유가
바뀜: 체인에 없어서가 아니라, **그 자리의 retract가 하드코딩된
no-op이기 때문** — PreRef의 fire는 `fn(inst)`를 실제로 실행하는
부작용이라 애초에 "되돌릴 상태"가 없고, 그래서 체인에 올라가 있어도
retract가 할 일 자체가 없음. 진짜 위험은 취소가
아니라 **재사용**: 이미 한 번 fire된 `PreRef` 객체를 두 번째
construction의 children 배열에 다시 놓으면, 거기서 등록하는
`:Callback(fn)`이 "이미 채워져 있으면 즉시 1회 호출"이라는 규칙(위
"Ref 일반화" 절) 때문에 **의도한 새 인스턴스가 아니라 첫 번째 fire
때 남은 stale `.Value`로 조용히 호출**됨 — 에러도 안 나고 엉뚱한
값을 들고 실행되는, 디버깅하기 아주 어려운 버그. `State`/`:With`를
"clone 빌더가 아니라 매번 새 노드"로 확정했던 원칙(2026-08-07 세
번째 세션, "`:With`도 새 State 노드")과 같은 클래스의 문제이자 같은
해법.
- **구현**: pre-pass가 첫 fire 때 해당 `PreRef` 객체에 내부 플래그
(`_fired = true`)를 세팅. pre-pass가 배열을 훑다 `isPreRef(v)`
슬롯을 만났는데 그 객체가 이미 `_fired`면, fire하지 않고 그 자리에서
즉시 `error("PreRef는 1회용 — 이미 다른 construction에 쓰인
PreRef를 재사용할 수 없음, 매번 새로 만들 것")`. 위 "동적 경로 가드"
Handler(정상 두 패스에서 매치)와는 별개 코드 경로 — 이 가드는
pre-pass 자신 안에, `_fired`가 아닌 정상 fire는 그대로 통과.
- **관용구**: `Slot:List``updateFn`처럼 반복 호출되는 자리에서
`PreRef`가 필요하면 **호출마다 새 `PreRef()`를 만들 것** — 클로저에
캡처해 여러 construction에 걸쳐 재사용하지 말 것. (참고: `Slot`
자체는 요소 타입으로 `Ref`/`PreRef`를 이미 금지하고 있어(위
"요소 타입 제약" 절, `slot-plan.md`) 이 관용구가 실제로 문제되는
자리는 `updateFn` 안에서 호출하는 컴포넌트 함수 내부뿐임.)
- **일반 `Ref`는 계속 Modifier/Store 어디든 자유롭게
들어감** — Store를 통해 나중에 도착하는 Ref는 그냥 도착한 그 순간
처리하면 됨, phase 개념 자체가 필요 없음("만난 순간 처리"로 충분).
- **quad v1의 `OnCreated` 특수 DI 키는 이식하지 않는다.**
`Ref():Callback(function(inst) end)`를 children 배열에 넣는 것만으로
완전히 대체됨(여러 개 등록도 자연히 지원, 별도 특수 키 불필요) —
v1 대비 빠진 기능처럼 보이지 않도록 이 대체 관계를 문서에 남겨둠.
- **`:Wait()`는 PreRef에도 그대로 유효해야 함.** PreRef 자신의 fire는
항상 동기적이지만, `:Wait()`를 호출하는 코드가 `task.spawn`이 아니라
순수 `coroutine`로 실행 중이었다면(Roblox `task` 스케줄러의 순서 보장이
없는 컨텍스트) 호출 시점에 아직 안 채워져 있어 실제로 yield-resume이
필요한 경우가 생김 — "항상 동기적이니 `:Wait()`는 즉시 리턴할 것"이라고
단정해 구현을 특수화하면 안 됨, 그냥 보통 `Ref`와 동일한 대기자
리스트/coroutine.yield 구현을 그대로 씀. **문서화 필요**: "채워졌는지
먼저 확인, 없으면 `:Wait()`" 방어적 패턴을 권장 관용구로 명시(콜백이
"이미 채워져 있으면 즉시 1회 호출"하는 것과 대칭되는, 값이 없을 수도
있다는 걸 항상 코드가 스스로 확인해야 한다는 Ref 전체의 일관된 원칙).
- **프로퍼티/이벤트가 항상 children/Ref보다 나중에 세팅된다는 사실 자체는
"고치지" 않는다** — 두 패스 순서를 뒤집거나 재배치하는 시도는
오버엔지니어링으로 판단해 안 함(이걸 원하면 애초에 PreRef를 쓰면 됨).
이 결정과 이유는 나중에 `quadnomicon` 콘텐츠로 문서화 예정
(`research/documentation-content-map.md` 후보로 메모).