From e4d6181fcf56d3929ed81bdf9a1d76658ad06f09 Mon Sep 17 00:00:00 2001 From: qwreey Date: Fri, 7 Aug 2026 13:38:06 +0900 Subject: [PATCH] =?UTF-8?q?decide(base):=20Ref/PreRef=20=EB=94=94=EC=8A=A4?= =?UTF-8?q?=ED=8C=A8=EC=B9=98=20=ED=83=80=EC=9D=B4=EB=B0=8D=20=ED=99=95?= =?UTF-8?q?=EC=A0=95,=20phase=20=EC=98=B5=EC=85=98=EC=9D=80=20archive?= =?UTF-8?q?=EB=A1=9C=20=EC=97=AD=EC=A0=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CreatedRef의 {phase="created"|"mounted"} 옵션 폐기, 위치 기반 순서로 대체 - base 디스패치가 배열 파트(children/Ref)를 해시 파트(프로퍼티/이벤트)보다 먼저 처리하도록 명시적으로 두 패스 계약화 - PreRef 신설: 프로퍼티/이벤트보다도 먼저 채워져야 하는 케이스(Roblox ChildAdded/DescendantAdded/Changed의 동기 발화 대응) 전용, Modifier/ Store 타입 차단 + 위치 무관 호이스팅 - Ref 콜백/대기자 실행 구현 디테일(coroutine vs function 분기) 추가 - 역전된 원 서술은 archive/ref-phase-option-reversed.md로 보존 - architecture.md/question.md/documentation-content-map.md/ROADMAP.md 동기화 Co-Authored-By: Claude Sonnet 5 --- .claude/archive/ref-phase-option-reversed.md | 74 +++++++++++ .claude/base/architecture.md | 2 +- .claude/base/bind-system-plan.md | 125 ++++++++++++++++-- .claude/base/modifier-plan.md | 17 +++ .claude/question.md | 4 + .claude/research/documentation-content-map.md | 2 +- CLAUDE.md | 54 ++++++++ ROADMAP.md | 5 + 8 files changed, 271 insertions(+), 12 deletions(-) create mode 100644 .claude/archive/ref-phase-option-reversed.md diff --git a/.claude/archive/ref-phase-option-reversed.md b/.claude/archive/ref-phase-option-reversed.md new file mode 100644 index 0000000..69403fa --- /dev/null +++ b/.claude/archive/ref-phase-option-reversed.md @@ -0,0 +1,74 @@ +# [역전됨] `CreatedRef`의 `phase` 옵션 + "Ref는 특수 처리 없는 평범한 참가자" — 위치 기반 순서 + `PreRef` 신설로 대체됨 + +**역전 일시**: 2026-08-07 (세 번째 세션). **원 확정 일시**: 2026-08-04 +(Ref 도입 확정 절)~2026-08-06(Ref 일반화 절)에 걸쳐 누적 확정. +**현재 유효한 설계**: `base/bind-system-plan.md`의 "확정된 디스패치 +모델" 절 하단(배열/해시 두 패스 계약)과 "`phase` 옵션 폐기 → +위치로 표현, `PreRef` 신설" 절이 최종 소스. 이 파일은 더 이상 능동적으로 +참고할 필요 없음(구현에 안 씀) — 왜 "옵션 하나로 phase를 고르는 설계"에서 +"위치 기반 순서 + 별도 타입 분리"로 넘어갔는지가 `quadnomicon`(프레임워크 +설계자용 심화 콘텐츠) 소재로 가치 있어서 사유·원문을 통째로 보존해둔 것. + +## 역전된 사례 — 원래 무엇을 확정했었나 + +**1. Ref는 dispatch 레지스트리의 "평범한 참가자"였음** (2026-08-04 +원문, `bind-system-plan.md` "Ref — 도입 확정" 절): + +> **바인드 방법**: 특수 처리 없이, children을 배열 아이템으로 넣듯 +> `CreatedRef` 같은 값을 숫자 키 슬롯에 넣는 방식 — child와 동일한 +> 층위에서 `process(inst,k,v)` 디스패치를 그대로 타게 함. 즉 Ref도 +> pluggable 핸들러 레지스트리의 평범한 참가자. + +**2. "생성 직후"/"마운트 후" 두 타이밍은 옵션 값으로 골랐음** (같은 절): + +> **콜백 호출 시점 확정**: 생성 직후(construction 시점)와 트리 마운트 +> 후(Parent 세팅 완료) 둘 다 지원 — 옵션으로 선택(`CreatedRef(fn, +> {phase="created"|"mounted"})`류, 정확한 API 이름은 구현 단계에서 확정). + +당시엔 이 두 문장이 서로 모순되지 않는다고 봤음 — "평범한 참가자"이면서 +동시에 "옵션으로 두 시점 중 골라 fire"하는 게 가능하다고 전제했던 것. + +## 역전된 이유 + +실제 사용 시나리오를 짚다가 드러남: quad-roblox 이벤트는 `self(Instance)`를 +안 주기로 이미 확정돼 있어서(`base/bind-system-plan.md` "이벤트 핸들러는 +self를 받지 않는다"), 이벤트 안에서 인스턴스 자신을 참조하려면 Ref로 +받아둔 값을 읽는 수밖에 없음. 그런데 Roblox 이벤트 중 일부(`ChildAdded`/ +`DescendantAdded`/`Changed`류)는 유저 인터랙션을 기다리지 않고 **setup +도중 프로퍼티 대입/Parent 세팅 자체의 부작용으로 동기적으로 발화**할 수 +있음 — 이 시점에 self-ref가 아직 안 채워져 있으면 그대로 터짐. + +이 문제를 실제로 풀려고 보니 "phase 옵션 하나로 고르는 평범한 참가자" +모델이 두 가지를 보장하지 못한다는 게 드러남: +1. **"평범한 참가자"라는 전제 자체가 Modifier/Store를 거치면 깨짐.** + Ref가 Modifier 필드로 flatten되거나 Source/Store 값으로 나중에 + 도착하면, "이 인스턴스에 다른 무엇보다 먼저"라는 순서 보장을 구조적으로 + 줄 방법이 없음(Modifier flatten은 해시 파트로 합쳐지고, Store 값은 + process/retract 재귀 경로로 원래 스캔보다 나중에 도착하므로). +2. **"created" phase가 실제로 뭘 보장하는지가 원래 정의돼 있지 않았음.** + "생성 직후"가 "다른 모든 프로퍼티/이벤트보다 먼저"까지 보장하는 건지, + 아니면 "그냥 루프 어딘가에서, construction 이후"면 충분한 건지가 + 불명확했음 — 후자로 해석하면 옵션이 무의미해지고, 전자로 해석하면 + `process(inst,k,v)` 우선순위 스캔만으로는 줄 수 없는 순서 보장이라 + 드라이버 레벨 개입이 필요해짐. + +## 이전 것과 지금 것의 차이 + +| | phase 옵션(역전됨) | 위치 기반 + `PreRef`(현재) | +|---|---|---| +| "자식 마운트 전/후" 표현 | `{phase="created"\|"mounted"}` 옵션 값 | children 배열에서 다른 형제보다 앞/뒤에 놓는 것만으로 결정(두 패스 계약 위에서 공짜로 나옴) | +| "프로퍼티/이벤트보다 먼저" 표현 | 같은 옵션의 `"created"` 값 — 실제로 이 보장을 줄 메커니즘은 없었음 | 별도 nominal 타입 `PreRef` — Modifier/Store엔 타입으로 아예 못 들어가고, 배열 파트 스캔 전에 driver가 따로 pre-pass로 fire, 위치와도 무관하게 항상 최우선(호이스팅) | +| Ref/CreatedRef가 참가자로서 특수한지 | "특수 처리 없이, 평범한 참가자"라고 명시 | 일반 Ref/CreatedRef는 여전히 평범한 `(v=Ref)` 핸들러 매치 — 다만 그 매치가 성립하려면 base가 배열 파트/해시 파트 순서를 **명시적으로 계약화**해야 했음(우연한 Luau 테이블 동작에 기대지 않음), `PreRef`는 아예 별도 pre-pass 대상이라 진짜 특수 취급 | +| Store/Modifier 조합 가능 범위 | 논의 안 됨(암묵적으로 전부 가능하다고 전제) | 일반 Ref는 자유, `PreRef`는 타입으로 원천 차단 | + +## 왜 완전히 헛수고는 아니었나 + +"children 배열 슬롯에 넣으면 dispatch가 채워준다"는 `CreatedRef`의 +핵심 아이디어 자체는 그대로 살아남음 — 바뀐 건 "그 안에서 두 타이밍을 +옵션 하나로 고르게 하자"는 세부 설계뿐. 오히려 이 반전 덕분에 "왜 굳이 +`PreRef`라는 별도 타입이 필요한가"(=순서 보장이 안 되는 경로가 실제로 +있다는 것)와 "base 드라이버가 왜 배열/해시 순서를 명시적으로 계약화해야 +하는가"(=Lua 테이블의 우연한 동작에 기대면 다른 백엔드에서 깨질 수 +있다는 것) 두 가지가 훨씬 선명해짐 — `quadnomicon`에서 "옵션 하나로 +퉁치려던 설계가 실제 시나리오(Roblox 이벤트의 동기 발화)를 만나 타입 +분리로 귀결된 사례"로 쓰기 좋음. diff --git a/.claude/base/architecture.md b/.claude/base/architecture.md index 5519397..8e27a9d 100644 --- a/.claude/base/architecture.md +++ b/.claude/base/architecture.md @@ -132,7 +132,7 @@ quad/ │ │ └── Slot.luau # add/remove/clear 재조정 로직(추상 자식 참조 기준) │ ├── LifetimeHandle.luau # Connected 계산 속성 "인터페이스"(타입/계약만) │ ├── PerInstanceState.luau # per-instance 상태 저장 "인터페이스" -│ ├── Ref.luau # 범용 값 박스(.Value/:Wait()/콜백) + 그 위에 얹힌 CreatedRef(숫자 슬롯 참가자) 특수화 +│ ├── Ref.luau # 범용 값 박스(.Value/:Wait()/콜백) + 그 위에 얹힌 CreatedRef(숫자 슬롯 참가자) 특수화 + PreRef(children 배열 전용, Modifier/Store 타입 차단, 호이스팅되는 pre-pass 특수화, `bind-system-plan.md` "PreRef 신설" 절) │ └── init.luau └── quad-roblox/ ├── wally.toml diff --git a/.claude/base/bind-system-plan.md b/.claude/base/bind-system-plan.md index 7dc298f..59e30d8 100644 --- a/.claude/base/bind-system-plan.md +++ b/.claude/base/bind-system-plan.md @@ -110,6 +110,26 @@ src/schema/union.luau:48-68`) — 에러 메시지는 즉시 문자열로 만들 78-79행)처럼 자연히 좁혀지는 경우가 일반적이고, 일반 사용자가 만들어낼 수 있는 상황이 아니라고 판단해 별도 가드 없이 진행. +- **props 순회 순서는 base 디스패치 드라이버가 명시적으로 두 단계로 + 고정한다 — 배열 파트(숫자 키, children/Ref류) 먼저, 해시 파트(문자열 키, + 프로퍼티/이벤트/특수 DI 키) 나중(2026-08-07 세 번째 세션).** Luau + 테이블을 `pairs`/제네릭 `for`로 순회하면 실제로 배열 파트가 해시 파트보다 + 먼저 나옴(`for i, v in {a=1, 2, b=3} do print(i,v) end` → `1 2`, `a 1`, + `b 3` 순서 — 사용자가 직접 확인). 이 관찰된 동작에 그냥 얹혀가지 않고, + **base 드라이버가 명시적으로 두 패스로 나눠 돌기로 계약화**한다 — 숫자 + 키(children)를 먼저 index 순서대로 처리하고, 그 다음 나머지 키를 처리. + 이유: (1) 다른 백엔드(`quad-web` 등)가 병합된 props를 Lua 테이블이 아닌 + 다른 자료구조로 표현할 수도 있어서 "Lua 테이블의 우연한 내부 동작"에 + 기대면 이식성이 깨짐, (2) 어차피 숫자 키(children/Ref)와 문자열 + 키(프로퍼티/이벤트)를 다른 의미로 취급해야 하니 구분 비용이 이미 드는 + 참에 순서까지 명시적으로 고정하는 게 거의 공짜. **결과적으로 배열 + 슬롯에 놓인 어떤 값(Ref 포함)이든 모든 프로퍼티/이벤트 세팅보다 항상 + 먼저 처리된다는 게 base 자체의 보장**이 됨 — 아래 "Ref 일반화" 절 뒤에 + 이어지는 "PreRef" 절이 이 보장 위에서 성립. **M0 스파이크에서 실제 + Luau로 이 순회 동작 자체를 검증할 것**(지금까지 추론/관찰만으로 확정된 + 항목 — `research/pre-implementation-audit.md`가 짚은 "실제 Luau로 + 부딪혀본 적 없는 것" 범주와 같은 급이라 신중하게 다룸). + ## Store 바인드는 특수 경우인가, 아니면 pluggable 바인드를 재실행하는 래핑인가 사용자 원 메모: "스토어 바인드는 특수 경우로 둘지, 아니면 다른 pluggable 바인드를 @@ -157,16 +177,15 @@ ref 타입처럼 생각하는 게 맞는 거 같음 — 그걸 처리하는 플 - Store는 이미 바깥에서 옵저빙 가능한 존재라 별도 취급 불필요 — Ref는 그와 달리 "원하는 객체 자체를 직접 얻어오는" 경로. **얻어진 뒤에 그 참조를 어디에 저장하고 어떻게 쓰는지는 라이브러리 책임 범위 밖**(사용자 자유). -- **바인드 방법**: 특수 처리 없이, children을 배열 아이템으로 넣듯 `CreatedRef` - 같은 값을 숫자 키 슬롯에 넣는 방식(정확한 이름/시그니처는 미정, 예: - `[1] = CreatedRef(function(inst) ... end)`) — child와 동일한 층위에서 - `process(inst,k,v)` 디스패치를 그대로 타게 함. 즉 Ref도 pluggable 핸들러 - 레지스트리의 평범한 참가자. -- 코루틴 기반 "채워질 때까지 대기" 지원 여부는 여전히 미정(별도 확인 필요 없이 - 구현 우선순위 낮음 — 필요성이 명확해지면 그때 추가). -- **콜백 호출 시점 확정**: 생성 직후(construction 시점)와 트리 마운트 후(Parent - 세팅 완료) 둘 다 지원 — 옵션으로 선택(`CreatedRef(fn, {phase="created"| - "mounted"})`류, 정확한 API 이름은 구현 단계에서 확정). +- **바인드 방법**: children을 배열 아이템으로 넣듯 `CreatedRef` 같은 + 값을 숫자 키 슬롯에 넣는 방식(정확한 이름/시그니처는 미정, 예: + `[1] = CreatedRef(function(inst) ... end)`) — `(v=Ref)` 매치 핸들러가 + 이걸 처리함. **[정정, 2026-08-07 세 번째 세션]** 정확한 순서 보장(자식 + 마운트 전/후, 프로퍼티보다 먼저)은 위치와 `PreRef` 타입으로 갈렸음 — + 아래 "`phase` 옵션 폐기 → 위치로 표현, `PreRef` 신설" 절이 최신, + 원래 있던 "옵션(`{phase=...}`)으로 두 타이밍을 고른다"/"특수 처리 + 없는 평범한 참가자" 서술은 `archive/ref-phase-option-reversed.md`로 + 옮김. - **왜 값이 아니라 콜백인가**: quad는 React처럼 렌더 함수가 계속 재실행되지 않음(플레인 함수를 한 번 호출해 트리를 만들고 끝) — 그래서 "채워졌는지 매 렌더마다 다시 확인"하는 모델 자체가 없고, `useEffect` @@ -205,6 +224,15 @@ ref 타입처럼 생각하는 게 맞는 거 같음 — 그걸 처리하는 플 "이미 채워졌는지" 확인이 항상 필요함). `:Wait()`의 대기자 리스트와 콜백 리스트는 같은 구조 재사용 가능(발화 후 해당 인덱스만 nil 처리, Luau의 일반화 for는 성긴 배열도 잘 순회함). + - **구현 디테일(2026-08-07 세 번째 세션, 사용자 제안)**: 값이 새로 + `:Set()`될 때, 같은 배열 하나를 `for i, v in <배열> do ... end`로 + 한 번만 순회하면서 `type(v) == "thread"`면 `:Wait()`가 만든 대기자로 + 보고 `coroutine.resume(v, value)` 후 `[i] = nil`(1회성 소진), 아니면 + 일반 콜백 함수로 보고 그냥 `v(value)`(소진 안 함, 계속 유지)로 + 분기하면 됨 — 대기자/콜백을 서로 다른 배열로 나눌 필요 없이 값 타입 + 하나로 분기 가능. 새 콜백/대기자 등록은 `table.insert`로 끝(빈 + 인덱스가 있어도 없어도 상관없이 다음 슬롯에 들어감, 성긴 배열이어도 + 일반화 `for`가 계속 잘 순회하므로 압축(compact)할 필요도 없음). - **제네릭 시그니처(2026-08-07 확정): `Ref(T) -> Ref` — 단일 타입 파라미터.** React `useRef(U): T|U`류 "초기값 타입과 최종 타입을 분리"하는 2파라미터 설계도 검토했으나(예: `Ref<>(null)` @@ -244,6 +272,83 @@ ref 타입처럼 생각하는 게 맞는 거 같음 — 그걸 처리하는 플 "범용 값 박스"로 넓어졌으므로, 진행 중인 용어 정리(`question.md` 1번) 때 이름이 여전히 맞는지 같이 재검토할 것. +### `phase` 옵션 폐기 → 위치로 표현, `PreRef` 신설 (2026-08-07 세 번째 세션) + +**`CreatedRef(fn, {phase="created"|"mounted"})`의 `phase` 옵션 자체를 +없앤다.** 위 "확정된 디스패치 모델" 절에 새로 추가된 두 패스 보장(배열 +파트는 index 순서대로, 그 다음 해시 파트) 덕분에, 같은 인스턴스 안에서 +**일반 `Ref`/`CreatedRef`를** 다른 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`/`:Wait()`/콜백 API) — + 브랜드 태그만 다른 nominal 타입. 런타임 코드 중복 없음. +- **오직 children 배열의 리터럴 아이템으로만 놓을 수 있다** — **Modifier + 필드 값으로도, Source/Store 값으로도 들어갈 수 없게 타입으로 차단.** + - Modifier 필드로 막는 이유: 거기 들어가면 flatten 후 해시 파트로 + 존재하게 돼 "배열 파트가 먼저"라는 보장 자체를 벗어남. 게다가 + Modifier는 여러 인스턴스에 재사용되는 값인데 PreRef는 정의상 "이 + 인스턴스 하나의 construction 훅"이라 애초에 공유할 이유가 없음 — + 허용해도 얻는 유스케이스가 없는 오버엔지니어링. + - Source/Store 값으로 막는 이유: Store 값은 항상 process/retract 재귀 + 경로로 도착하는데, 그 경로는 정의상 최초 배열 스캔보다 나중(또는 + 아예 스캔 밖)이라 "프로퍼티보다 먼저"를 구조적으로 만족시킬 방법이 + 없음 — `State`를 UB로 보고 타입으로 막기로 한 것과 정확히 + 같은 원칙의 재적용. +- **`PreRef`는 배열 안 위치의 영향을 안 받는다 — 호이스팅.** 일반 + `Ref`/`CreatedRef`와 달리, 같은 인스턴스의 배열 파트 안에서 다른 + children/`Ref`보다 뒤에 적었어도 그것들보다 먼저 fire됨(자바스크립트 + 함수 선언 호이스팅과 같은 느낌으로 문서화). 이유: PreRef의 존재 + 목적 자체가 "이 인스턴스에 뭐가 됐든 일어나기 전에" 채워지는 것인데, + 단순 위치 기반 순서만 따르면 그보다 앞선 형제(다른 child)가 먼저 + 마운트되면서 그 형제가 부모에 Parent될 때 부모의 `ChildAdded`류가 + 동기 발화할 수 있어 PreRef가 막으려는 문제가 그대로 재현됨. 그래서 + base 드라이버는 위 두 패스(배열→해시) 루프를 돌기 **전에** 별도의 + 작은 pre-pass로 배열 파트를 훑어 `PreRef` 항목만 먼저 전부 fire하고, + 그 다음 나머지(children/일반 Ref/프로퍼티/이벤트)를 평소처럼 두 + 패스로 처리하면 됨 — 이 pre-pass는 오직 `PreRef` 타입만 골라내므로 + 범위가 좁고, "확정된 디스패치 모델" 절의 두 패스 계약과 별개로 그 + 앞에 얹히는 것. +- **일반 `Ref`/`CreatedRef`는 계속 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` 후보로 메모). + ## 이벤트 핸들러는 self(Instance)를 받지 않는다 — 확정 (2026-08-06) **결정**: v1의 `function(self, ...)` 관습(`self`/`this`로 이벤트 대상 diff --git a/.claude/base/modifier-plan.md b/.claude/base/modifier-plan.md index 401c772..31d7133 100644 --- a/.claude/base/modifier-plan.md +++ b/.claude/base/modifier-plan.md @@ -43,6 +43,23 @@ Lua 테이블 리터럴은 배열 파트/해시 파트 사이에 소스 텍스 않으므로 "순서상 나중이 이긴다"는 단일 규칙만으로는 구현 불가 — 반드시 두 규칙으로 쪼개야 함. +### 2-1. 인라인 키로 modifier 필드를 명시적으로 "지우기"는 아직 안 풀림 — `None` 센티널 후보만 메모 (2026-08-07 세 번째 세션, 미확정) + +**문제**: `{ Override = nil, mod }`처럼 인라인 키로 modifier가 주는 값을 +명시적으로 취소하고 싶어도, Lua 테이블 리터럴에서 `키 = nil`은 그 키 +자체가 아예 존재하지 않는 것과 구별이 안 됨(`pairs`에서도 안 보임) — 그래서 +위 2번 "인라인은 무조건 우선" 규칙이 실제로 작동할 근거(인라인 키가 +존재한다는 사실 자체)가 사라지고, `mod`가 주는 `Override` 값이 그대로 +새어나옴. + +**후보(미확정)**: 이벤트 store-bind에서 이미 쓴 "`nil` 대신 실재하는 +센티널 값" 패턴(`false`로 disconnect, `base/bind-system-plan.md` "이벤트도 +store-bind 가능" 절) 재사용 — `None`(가칭) 프리미티브를 만들어 +`{ Override = None, mod }`로 쓰면 flatten 로직이 `None`을 만난 인라인 키를 +"명시적으로 지움"으로 해석해 modifier 쪽 값을 덮어씀. 상세 설계(타입, +flatten 내부 표현, State 필드에도 같은 문제가 적용되는지 등)는 다음 +세션에서 이어감 — 지금은 문제와 방향성만 기록. + ### 3. Immutable 값 + clone 기반 체이닝 컴포지션 트리를 타고 내려가며 조금씩 변형되는 modifier(문서 뷰어에서 상위 diff --git a/.claude/question.md b/.claude/question.md index 859fcc2..61fbcd8 100644 --- a/.claude/question.md +++ b/.claude/question.md @@ -82,6 +82,10 @@ additional-primitives-plan.md`. 요지: - **`CreatedRef`/`canExecute`(3순위, 사소함)**: `CreatedRef`는 과거분사형이라 생성자처럼 안 읽힘. `canExecute`는 실제로 "이 핸들이 아직 살아있나" 확인인데 이름이 범용 권한 체크처럼 들림 — `isAlive` 쪽이 더 직접적. + **(2026-08-07 추가)** `PreRef`(children 배열 전용, Modifier/Store에 + 못 들어가는 Ref 특수화 — `base/bind-system-plan.md` "`phase` 옵션 폐기 → + 위치로 표현, `PreRef` 신설" 절)도 신규 이름이라 이 라운드에 같이 재검토 + 대상. - **`Ref`(3순위, 2026-08-06 추가)**: 정의가 "quad가 만든 instance를 얻는 통로"에서 "아무 사용자 값이나 담는 범용 값 박스"로 넓어져서(`base/ bind-system-plan.md` "Ref 일반화" 절), 이름이 여전히 넓어진 의미에 diff --git a/.claude/research/documentation-content-map.md b/.claude/research/documentation-content-map.md index 04a5836..4706b53 100644 --- a/.claude/research/documentation-content-map.md +++ b/.claude/research/documentation-content-map.md @@ -34,7 +34,7 @@ 8. **컴포넌트 경계 넘기기** — `props.Modifier`/`props.Ref` named parameter 패턴 (`component-composition-plan.md`) 9. **이벤트** — self(Instance) 안 받음, 문자열 키(`Frame { MouseButton1Click = fn }`) (`bind-system-plan.md`) 10. **생명주기** — GC 위임(수동 정리 불필요), Destroy 이후 대상 재사용 금지 (`lifecycle-pattern.md`) -11. **Ref 기초** — 외부 관리 Instance 참조/마이그레이션용, `CreatedRef(fn, {phase=...})` (`architecture.md`, `bind-system-plan.md`) +11. **Ref 기초** — 외부 관리 Instance 참조/마이그레이션용, `CreatedRef(fn)` + 배열 위치로 자식 전/후 표현, "프로퍼티보다도 먼저" 필요할 때만 `PreRef`(2026-08-07 세 번째 세션, `phase` 옵션 폐기) (`architecture.md`, `bind-system-plan.md`) 12. **파생값 최소 예시** — `:With(...)` + `:Compute(fn)` 기본형 (`bind-system-plan.md`, `store-semantics.md`) 13. **Tween 기초** — `[Tween(key, ...)] = storeValue`, 취소 시 현재 보간값에서 자연스럽게 이어짐 (`research/tween-plan.md`) 14. **UI 숏핸드(quad-roblox 한정)** — `Corner`/`PaddingAllOffset`/`Scale` 인라인 키 (`research/ui-shorthand-plan.md`) diff --git a/CLAUDE.md b/CLAUDE.md index 91fda0b..e853732 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -622,3 +622,57 @@ clone 체이닝)/4번(제네릭 `__index`) 결정 위에 그대로 얹힘. 구 예약됨**(실 스타일 프로퍼티와 겹칠 일은 거의 없어 보이나 문서화 필요). `ROADMAP.md` M7에 체크박스 추가 완료. 다음 세션이 새로 알아야 할 건 없음 — M7 착수 시 `modifier-plan.md` 8번 참고하면 됨. + +## 2026-08-07 세 번째 세션 — Ref의 KV 핸들러 처리 vs phase 타이밍, `PreRef` 신설 + +**출발점**: Ref가 Modifier처럼 밖에서 처리되는 게 아니라 KV 핸들러 +(`process(inst,k,v)`)로 처리된다면, "생성 직후"/"자식 마운트 후" 두 +콜백 타이밍(특히 self(Instance)를 안 주는 이벤트가 Ref로 self를 얻는 +경우)을 단순 for-loop 디스패치만으로 어떻게 표현하는지가 출발 질문 — +길게 이어진 단일 스레드라 아래 요약만 읽으면 됨, 상세 근거는 각 base +문서에 이미 반영됨. + +**핵심 결론(전부 `base/bind-system-plan.md`에 반영 완료)**: +- **base 디스패치 드라이버는 props 순회를 "배열 파트(children/Ref) 먼저, + 해시 파트(프로퍼티/이벤트) 나중"으로 명시적으로 두 패스 계약화**한다 + — Luau 테이블이 실제로 이렇게 순회되는 걸 사용자가 직접 확인했지만, + 그 우연한 동작에 기대지 않고 base가 스스로 이 순서를 보장(다른 + 백엔드가 다른 자료구조를 쓸 수 있어서). M0 스파이크 검증 항목에 추가. +- **`CreatedRef`의 `{phase="created"|"mounted"}` 옵션은 폐기.** 두 패스 + 계약 덕에 "자식 마운트 전/후"는 그냥 배열 안에서 Ref를 다른 children + 보다 앞/뒤에 놓는 것만으로 공짜로 표현됨 — 옵션 문법 자체가 불필요. +- **`PreRef` 신설** — "프로퍼티/이벤트 세팅보다도 먼저"(Roblox의 + `ChildAdded`/`DescendantAdded`/`Changed`류가 setup 도중 동기 발화할 + 수 있어서 self-ref가 이벤트보다 먼저 채워져야 하는 케이스)만 담당하는 + 별도 nominal 타입. `Ref`를 그대로 재사용(런타임 중복 없음)하되 + Modifier 필드 값·Source/Store 값으로는 타입으로 아예 못 들어가게 + 막고, children 배열 안에서도 위치 무관하게 항상 최우선(호이스팅) — + base 드라이버가 두 패스 루프 앞에 `PreRef`만 골라 fire하는 좁은 + pre-pass를 하나 더 둠. +- **일반 `Ref`는 Modifier/Store 어디든 계속 자유롭게 들어감** — Store를 + 통해 나중에 도착하는 Ref는 그냥 도착한 순간 처리, 별도 phase 개념 불필요. +- **`:Wait()`는 PreRef에도 그대로 유효** — fire 자체는 동기적이지만 + 호출부가 `task.spawn`이 아니라 순수 `coroutine`일 수 있어 실제 + yield-resume이 필요한 경우가 있음. "채워졌는지 먼저 확인, 없으면 + `:Wait()`" 방어 관용구를 문서화 대상으로 명시. +- **콜백/대기자 실행 구현 디테일 추가**: 같은 배열 하나를 한 번의 + 일반화 `for`로 순회하며 `type(v)=="thread"`면 `coroutine.resume`+ + 슬롯 nil 처리(1회성), 함수면 그냥 호출(유지) — 새 등록은 `table.insert` + 로 끝, 성긴 배열이어도 압축 불필요. +- v1의 `OnCreated` 특수 DI 키는 이식 안 함 — `Ref():Callback(fn)`으로 + 완전 대체. + +**역전된 이전 서술은 archive로 이동**: `CreatedRef`의 `phase` 옵션과 +"Ref는 특수 처리 없는 평범한 참가자"라는 원래 서술은 +`archive/ref-phase-option-reversed.md`로 옮기고 원 위치엔 짧은 포인터만 +남김(컨텍스트 비대화 방지 목적, `archive/store-source-proxy-reversed.md`와 +같은 패턴). `architecture.md` 소스트리 주석/`question.md`(PreRef를 +용어 재검토 대상에 추가)/`research/documentation-content-map.md`(stale +`{phase=...}` 예시 갱신)도 같이 동기화함. + +**아직 미해결, 다음 세션 주제로 예고됨**: `{ Override = nil, mod }`처럼 +인라인 키로 modifier가 주는 값을 명시적으로 "지우고" 싶어도 Lua +테이블 리터럴의 `키 = nil`은 키가 아예 없는 것과 구별이 안 돼서 안 +풀리는 문제 — `false`를 이벤트 disconnect 센티널로 쓴 선례처럼 `None` +(가칭) 프리미티브를 도입하는 방향만 `base/modifier-plan.md` "2-1"절에 +짧게 메모해두고 상세 설계는 다음 세션으로 미룸. diff --git a/ROADMAP.md b/ROADMAP.md index 30900ab..7861367 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -25,6 +25,11 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 피할 수 있어 보이나 실제 검증 전엔 확정 아님) - [ ] `process`/`retract` 재귀 재-process 디스패치를 실제로 짜보기(store-bind 핸들러 하나 + `isHandlable` 우선순위 스캔 포함) +- [ ] props 순회의 "배열 파트 먼저, 해시 파트 나중" 두 패스 계약이 실제 + Luau 테이블에서 관찰한 대로 동작하는지 확인, `PreRef` pre-pass + + 일반 `Ref`/`CreatedRef`의 위치 기반 순서까지 최소 스파이크로 검증 + (2026-08-07 세 번째 세션, `base/bind-system-plan.md` "`phase` 옵션 + 폐기 → 위치로 표현, `PreRef` 신설" 절) - [ ] `props.Modifier`/`props.Ref` named-parameter로 받는 컴포넌트 하나 작성, `export type Params = {...}`로 타입 체크되는지 확인 (`component-composition-plan.md` 최종 결론 1번)