diff --git a/.claude/base/bind-system-plan.md b/.claude/base/bind-system-plan.md index 40a23e6..9b847db 100644 --- a/.claude/base/bind-system-plan.md +++ b/.claude/base/bind-system-plan.md @@ -407,6 +407,142 @@ end 그래프도 이 `chains` 구조를 그대로 읽으면 됨 — quad-debug 착수 시점에 새로 설계할 필요 없음. +### Length/Offset — 여러 Slot이 형제로 섞일 때 순서 보장 (2026-08-09 여섯 번째 세션) + +**문제(`base/slot-plan.md`의 "여러 Slot이 섞일 때 순서 보장" 열린 질문, +2026-08-04 신설)**: `Frame { Slot1, Element, Slot2 }`처럼 Slot과 정적 +자식이 형제로 섞일 때, Slot1의 동적 개수가 바뀌어도 "Slot1 전체는 항상 +Element보다 앞, Slot2보다 앞"이라는 저작 순서가 유지돼야 함. Slot2가 +자기 순서를 정하려고 "Slot1이 지금 몇 개인지"를 직접 세는 방식은 +Slot1이 바뀔 때마다 Slot2에 다시 알려줘야 하는 캐스케이드 의존을 +만들어서 막다른 길. + +**해법의 핵심 전환**: 절대 위치를 계산해서 전파하는 게 아니라, **각 +구조적 위치(자리 자체는 저작 시점에 고정)가 자기 앞의 형제들이 지금까지 +기여한 개수의 누적합만 알면 됨** — Roblox는 `LayoutOrder`/`ZIndex`가 +`Instance.Parent` 배열의 물리적 순서와 완전히 분리된 정수 프로퍼티라, +이 누적합을 그 프로퍼티에 반응형으로 바인딩하기만 하면 별도 배선이 +필요 없음(이미 있는 store-bind 재실행 패턴 재사용). + +**`Dispatch`의 두 API — 둘 다 Handler→Dispatch 등록(push) 방향**: + +```lua +Dispatch.setLength(inst, i, len: number | State) +Dispatch.setOffsetSource(inst, i, offset: Source | None) +``` + +- **`setLength`**: 이 위치(array part의 number 인덱스 `i`)가 지금 몇 개의 + 실제 마운트 가능한 leaf를 기여하는지 보고. 정적 단일 자식은 상수 + `1`(또는 `nil`/`None`이면 `0`), Slot은 자기 `.Length`(`State`, + 아래 참고), `state`처럼 store-bind로 오가는 단일 위치는 그 + store-bind 핸들러가 값이 바뀔 때마다 다시 호출. +- **`setOffsetSource`**: 이 위치가 자기 순서 계산에 쓸 `Source`를 + **스스로 만들어서** 등록 — Dispatch는 그냥 레지스트리에 넣어두기만 + 하고, `recompute`가 그 자리에 값을 `:Set()`함. Handler는 이 **같은** + Source 객체를 자기 원소(들)의 `LayoutOrder` 바인딩에 그대로 씀 + (`localIndex:With(offset):Compute(function(i,o) return i+o end)`을 + `LayoutOrder`에 store-bind로 걸어두면, offset이 바뀔 때 기존 store-bind + 재실행 메커니즘이 알아서 다시 씀 — 새 push/observer 시스템 불필요). + **실제 마운트를 하지 않는 위치(Ref/PreRef 등)는 `None`을 등록** — 순서 + 계산에 참여할 게 없다는 명시적 선언. + +**둘 다 array part의 모든 number 인덱스에 대해 반드시 호출 — 생략은 UB +(2026-08-09 여섯 번째 세션 확정).** `retract` 필드 생략 불가와 같은 톤 — +이건 **Handler 구현체 작성자만 지키는 계약**이고 일반 컴포넌트 작성자는 +이 존재 자체를 몰라도 됨(사용성 저하 없음), API 문서화만 명확히 하면 됨. + +**저장 위치**: `lengthList`/`sourceList`(부모 `inst` 하나에 귀속, 그 +`inst`의 array part 크기 `N`만큼) — `Relate(parentInst)`에 lazy 생성. + +**recompute — 매번 전체 순회, `Get` 가드로 캐스케이드만 방지**: + +```lua +local function recompute(inst, bk) + local sum = 0 + for i = 1, bk.N do + local v = bk.lengthList[i] + sum += (isState(v) and v:Get() or v) + local offset = bk.sourceList[i] + if offset and offset:Get() ~= sum then -- 실제로 다를 때만 Set + offset:Set(sum) + end + end +end +``` + +전체 순회의 O(N) 비용은 무시 가능(`N`은 저작 시점에 고정된 배열 리터럴 +길이, 보통 작음) — 진짜 비싼 건 `Set`이 트리거하는 다운스트림 리액티브 +캐스케이드(그 위치에 이미 마운트된 원소들의 `LayoutOrder` 재적용)라, +`Get() ~= sum`일 때만 `Set`해서 안 바뀐 앞쪽 위치들은 캐스케이드가 안 +일어나게 막음. + +**`setLength` 구현 — leaf-lifetime 경로(`bindLifetime`/`unbindLifetime`), +`:Subscribe()` 아님(2026-08-09 여섯 번째 세션)**: + +```lua +function Dispatch.setLength(inst, i, len) + local bk = getBookkeeping(inst) -- Relate(inst) 기반, lazy 생성 + + local oldObserver = bk.observers[i] + if oldObserver then + unbindLifetime(inst, oldObserver) -- gchold 내부 구조 몰라도 됨 + bk.observers[i] = nil + end + + bk.lengthList[i] = len + + if isState(len) then + local observer = len:Observer(function() + recompute(inst, bk) + end) + bindLifetime(inst, observer) -- inst 생명주기에 귀속, Subscribe 아님 + bk.observers[i] = observer + end + + recompute(inst, bk) -- 등록 즉시 1회(Observer 자체의 "등록 즉시 1회 실행"과 겹쳐도 무해) +end +``` + +`:Subscribe()`/`:Unsubscribe()`(독립 경로)를 안 쓰는 이유: 이 Observer는 +본질적으로 `inst` 하나에 종속된 내부 배관이라, `inst`가 Destroy될 때 +같이 죽어야 함 — `:Subscribe()`는 명시적 `:Unsubscribe()`가 없으면 안 +끊기므로 안 맞음. `bindLifetime`/`unbindLifetime`이 이미 이 요구(GC-native, +`inst` 생명주기에 자동 귀속)를 충족. + +**동기 순서 — offset 갱신이 마운트보다 먼저 끝나야 함(안 그러면 Roblox의 +실시간 `UIListLayout` reflow에서 한 프레임 순서가 깨진 채 노출될 위험)**: +Slot의 `rawAdd`는 `self.Length:Set(newCount)`(→ 다운스트림 offset/LayoutOrder +갱신이 동기적으로 여기서 끝남) 다음에 `element.Parent = target`(→ 이제 +트리에 보이는 시점엔 다운스트림이 이미 정합적) 순서로 호출. `Length:Set` +자체도 이전 카운트와 실제로 다를 때만 호출(no-op 캐스케이드 방지, 위 +`Get` 가드와 같은 원칙을 호출부에서도 적용). + +**`:List` reconcile에서 `Length` 갱신 시점**: 한 사이클(여러 항목이 +한꺼번에 추가/제거되는 경우 포함) 전체가 끝난 뒤 **한 번만** — 사이클 +도중 항목마다 갱신하면 캐스케이드가 그만큼 반복됨. + +**웹 백엔드(quad-web, 아직 없음) — 같은 `lengthList`/`sourceList`/ +`recompute`를 그대로 재사용, 다른 건 "offset 변경 시 무엇을 하는가"뿐**: +DOM의 `insertBefore`류는 물리적으로 삽입하면 뒤 형제가 자연히 밀려나므로, +`offset`이 바뀌었다고 이미 마운트된 원소를 실제로 옮길 필요가 없음 — +quad-web의 해당 Handler는 offset 변경 관측 시 아무것도 안 하는 no-op이고, +`offset` 숫자는 그 위치가 **다음에** 스스로 insert/remove할 때 어느 +물리 인덱스에서 해야 하는지를 위해서만 부기됨. base 레벨 로직은 완전히 +동일, backend Handler의 "무엇을 하는가"만 다름. + +**`Slot.Length`와 `Slot.Offset`은 별개(사용자 질문으로 명시화)**: +`Length`는 Slot이 스스로 노출하는 순수 출력값(지금 실제로 마운트된 +개수) — "n개 검색됨" 같은 UI에 그대로 써도 되고, 동시에 위 `setLength`가 +읽는 바로 그 값(하나의 State가 두 용도를 겸함). `:List`가 filter 탈락을 +실제 `Remove`로 처리하도록 이미 확정해둔 덕에(Visible 토글 아님) `Length`는 +자동으로 "실제 마운트된 것"만 반영 — 수동 Visible 토글을 쓰는 경우엔 +`Length`가 그걸 못 잡는 게 맞고, 그건 별도 State로 계산해야 하는 사용자 +몫. `Offset`은 Dispatch가 `setOffsetSource`로 등록받아 `recompute`가 +채워주는 입력값, 순서 계산 전용 — 서로 다른 두 `Source`. + +`base/slot-plan.md`의 "여러 Slot이 섞일 때 순서 보장" 절이 이 메커니즘으로 +해소됨 — 상세는 그 문서 참고. + ## Store 바인드는 특수 경우인가, 아니면 pluggable 바인드를 재실행하는 래핑인가 사용자 원 메모: "스토어 바인드는 특수 경우로 둘지, 아니면 다른 pluggable 바인드를 @@ -432,25 +568,34 @@ local observer = state:Observer(function() Dispatch.retractUnder(inst, k, StoreBind, state:Get()) -- 나 밑에 있던 거 정리 Dispatch.process(inst, k, state:Get()) -- 새로 위임(체인에 push) end) -observer:Subscribe() -relate:SetStrong(inst, k, observer) -- retract에서 :Unsubscribe() 하려면 들고 있어야 함 +bindLifetime(inst, observer) +relate:SetStrong(inst, k, observer) -- retract에서 unbindLifetime을 부르려면 들고 있어야 함 ``` -- children-array leaf 부착(`Frame { observer }`)이 **아니라** `:Subscribe()`/ - `:Unsubscribe()` 경로를 씀 — 이 Observer는 핸들러 내부 배관이라 사용자가 - 보는 leaf가 아니기 때문(위 "이중 바인딩 금지" 원칙과 정합적: 한 Observer - 핸들은 두 바인딩 경로 중 하나만 써야 하는데, 이건 애초에 leaf가 아니므로 - `:Subscribe()`가 유일한 선택). -- **`retract`가 할 일은 `observer:Unsubscribe()` 호출뿐 — 위임 대상까지 - 수동으로 안 쫓아가도 됨.** `Dispatch.retractUnder`가 자기 밑에 위임된 - 걸 알아서 정리해주므로(위 "Dispatch 체인" 절), 이 핸들러의 `retract`는 - 정확히 자기 자신의 자원(Observer)만 정리하면 끝 — 이게 위 "이벤트도 - store-bind 가능" 절에서 이미 "엔지니어링 비용이 낮다"고 서술한 것과 같은 - 이유(새 디스패치 메커니즘 없이 기존 계약만 구현). +**[정정, 2026-08-09 여섯 번째 세션] `:Subscribe()`/`:Unsubscribe()`가 +아니라 `bindLifetime`/`unbindLifetime`을 씀 — 원래 이 절이 "leaf가 +아니니 `:Subscribe()`가 유일한 선택"이라고 적어뒀던 게 틀림.** `:Subscribe()`/ +`:Unsubscribe()`는 **`inst`와 아예 무관한 전역/독립** Observer(모듈 +최상위에 두는 디버그 print용 등)를 위한 전역 GC 방지 테이블 전용 — +"leaf가 아니면 `:Subscribe()`"가 아니라 "**`inst`에 안 묶이면** +`:Subscribe()`, `inst`에 묶이면(leaf든 이런 핸들러 내부 배관이든) +`bindLifetime`"이 실제 기준. 이 Observer는 처음부터 `inst`(그리고 그 +자식 프로퍼티 `k`)에 묶여있는 존재라 `bindLifetime`이 맞음 — 위 "이중 +바인딩 금지" 절의 정정 참고(leaf 부착도 사실 `bindLifetime` 호출이라, +`:Subscribe()`와 상호 배타적인 건 leaf가 아니라 "전역이냐 inst냐"임). + +- **`retract`가 할 일은 `unbindLifetime(inst, observer)` 호출뿐 — 위임 + 대상까지 수동으로 안 쫓아가도 됨.** `Dispatch.retractUnder`가 자기 + 밑에 위임된 걸 알아서 정리해주므로(위 "Dispatch 체인" 절), 이 + 핸들러의 `retract`는 정확히 자기 자신의 자원(Observer)만 정리하면 + 끝 — 이게 위 "이벤트도 store-bind 가능" 절에서 이미 "엔지니어링 + 비용이 낮다"고 서술한 것과 같은 이유(새 디스패치 메커니즘 없이 기존 + 계약만 구현). - **핸들러가 직접 `canExecute`/liveness를 재구현할 필요 없음** — Observer가 이미 자기 `Subscribed` 상태로 게이팅됨(아래 `base/lifecycle-pattern.md`의 `canExecute(inst, value)` 절 참고, Observer/Effect는 그 함수 안에서 - 특별 취급됨). + 특별 취급됨). `bindLifetime`도 이 `.Subscribed` 필드를 그대로 + 세팅/해제하므로(위 "이중 바인딩 금지" 절 참고) 이 게이팅은 그대로 유효. - Observer가 "등록 즉시 1회 실행"이므로 **최초 적용과 이후 재실행이 같은 코드 경로로 자동 통일**됨 — 프로퍼티 store-bind 핸들러가 "설치 시 1회 적용"을 별도로 안 짜도 되는 이유(위 Observer 절의 원래 근거 그대로). @@ -1163,7 +1308,10 @@ State/Source도 `:With`/`:Compute`마다 새 노드가 나오는 같은 모양 안에서 Store에 직접 Observer를 걸어 `print`하는 패턴(원하면 BooleanValue 로 부분부분 켰다 껐다 하기도 함). 이건 다크패턴이 아니라 오히려 방어적인 엔지니어링이고, 붙일 leaf 자체가 없는 "전역/독립" 사용이라 위 weak-table -기반 자동 추적이 적용 안 됨. +기반 자동 추적이 적용 안 됨. **[용어 정정, 2026-08-09 여섯 번째 세션]** +여기서 "weak-table 기반 자동 추적"이라 부른 것이 나중에 정식으로 +`bindLifetime`(`base/lifecycle-pattern.md`)으로 명명됨 — 별도 메커니즘 +두 개가 아니라 같은 것의 명명 전/후 표현. **해결**: 명시적 `:Subscribe()`/`:Unsubscribe()`를 추가로 지원. 이건 새 설계가 아니라 `bind-system-plan.md`의 PA님 코드 교차검증(라이프사이클 @@ -1194,9 +1342,13 @@ State/Source도 `:With`/`:Compute`마다 새 노드가 나오는 같은 모양 - **`:Subscribe()`/`:Unsubscribe()` 둘 다 idempotent** — 이미 구독 중인데 또 Subscribe해도, 구독 안 했는데 Unsubscribe해도 에러 안 나고 그냥 no-op. 토글 로직 짤 때 상태 추적 부담을 줄여줌. -- **`:Unsubscribe()`는 자동(리프) 케이스에도 동일하게 씀** — Instance가 - 파괴되기 전에 수동으로 조기 해제하고 싶을 때도 같은 메소드 하나로 - 충분, 별도 API 안 만듦. +- **[정정, 2026-08-09 여섯 번째 세션] "`:Unsubscribe()`는 자동(리프) + 케이스에도 동일하게 씀"은 틀림 — 리프/`bindLifetime` 경로의 조기 + 해제는 `unbindLifetime(inst, value)`가 담당, `:Unsubscribe()`는 + 전역 강참조 레지스트리 경로 전용으로 남음.** `inst`를 모르는 + `:Unsubscribe()`가 `bindLifetime`이 어느 `inst`에 등록했는지 찾아낼 + 방법이 없어서(레지스트리가 `inst`별로 나뉘어 있음) 하나로 통합할 수 + 없음 — 위 "이중 바인딩 금지" 절의 정정 참고. - **`state:Observer(fn):Subscribe()`처럼 참조를 아무 데도 안 담아도 정상** — 강참조 레지스트리 자체가 생존을 보장하는 유일한 근거라, 로컬 변수에 담아둘 필요가 없음. 예외 없이 그냥 계속 돎(그게 이 메커니즘의 핵심 @@ -1210,15 +1362,33 @@ State/Source도 `:With`/`:Compute`마다 새 노드가 나오는 같은 모양 객체를 mutate하고 그대로 돌려주는 것)지만 표면 문법은 비슷하게 체이닝 가능. -### 이중 바인딩 금지 — leaf 부착과 `:Subscribe()`는 상호 배타적, `canBound(handle)`로 즉시 에러 (2026-08-07 일곱 번째 세션, 2026-08-09 세션에서 이름 확정) +### 이중 바인딩 금지 — 진짜 독립된 경로는 `:Subscribe()`(전역)와 `bindLifetime`(inst-scoped) 둘뿐, `canBound(handle)`로 즉시 에러 (2026-08-07 일곱 번째 세션, 2026-08-09 세션에서 이름 확정, 같은 날 여섯 번째 세션에서 "leaf 부착=bindLifetime 호출"로 정정) **규칙**: 같은 Observer/Effect 핸들 하나는 라이프사이클 바인딩 경로를 -딱 하나만 가질 수 있음 — children 배열에 놓여 leaf에 자동 부착되거나 -(위 weak table 경로) `:Subscribe()`로 수동 등록되거나(위 강참조 -레지스트리 경로), 둘 중 하나만. **둘 다 동시에 걸리는 건 UB로 확정** — -이미 leaf에 부착된 핸들을 다시 `:Subscribe()`하는 것도, 이미 -`:Subscribe()`한 핸들을 children 배열에 놓아 leaf로도 부착시키는 것도 -둘 다 금지. +딱 하나만 가질 수 있음 — `:Subscribe()`로 전역 강참조 레지스트리에 +등록되거나(위 절), `bindLifetime(inst, value)`로 특정 `inst`에 종속되거나 +(아래 "`bindLifetime`도 같은 게이트를 공유" 절) — **이 둘 중 하나만**. + +**[정정, 2026-08-09 여섯 번째 세션] "leaf 부착"은 세 번째 독립 경로가 +아니라 `bindLifetime`을 호출하는 것 그 자체다.** `Frame { observer }`처럼 +children 배열에 Observer를 직접 놓으면, `Dispatch/Leaf.luau`가 이걸 +매치해 내부적으로 `bindLifetime(inst, observer)`를 호출 — "children +배열에 놓여 leaf에 자동 부착"과 "`bindLifetime`으로 특정 `inst`에 +종속"은 **같은 동작**이라 서로 배타적일 수 없음(둘 다 하는 게 아니라 +leaf 부착이 곧 `bindLifetime` 호출 방식 중 하나일 뿐). 그래서 실제 +상호 배타는 "전역 소유(`:Subscribe()`)" vs "특정 `inst` 소유 +(`bindLifetime`, 직접 호출이든 leaf 부착을 통한 호출이든)"라는 +**2-way**로 정정 — 위 "Observer의 `:Subscribe()`/`:Unsubscribe()`" 절이 +leaf 부착을 "weak table 기반 자동 추적"이라 불렀던 건 `bindLifetime`이 +정식 이름을 얻기 전(2026-08-06 후속 세션) 표현이라 지금은 같은 것을 +가리킴 — 별도 메커니즘 두 개가 있던 게 아니었음. + +**둘 이상 동시에 걸리는 건 UB로 확정** — 이미 한 경로로 바인딩된 핸들을 +다른 경로로 또 바인딩하는 건 금지(leaf로 이미 부착된 걸 `:Subscribe()` +하는 것, 또는 그 반대). 같은 값을 `bindLifetime`으로 두 번(leaf 부착 +한 번 + 직접 호출 한 번, 또는 leaf로 두 Instance에 부착) 등록하려는 +것도 걸림 — 이건 "leaf vs bindLifetime 충돌"이 아니라 "같은 단일 +메커니즘을 중복 호출"하는 것이라 자연히 같은 게이트가 잡아줌. **UB를 조용한 오동작이 아니라 즉시 에러로 만든다** — 판별 비용이 사실상 0(불리언 필드 하나 확인)이라, 조용히 이상하게 동작하게 두는 것보다 @@ -1236,9 +1406,10 @@ raw 필드(`self.Bound`)를 직접 보여주지 않고 같은 스타일의 탑 "코드 스타일 — 네이밍 케이싱" 절과 같은 기준): ```lua --- :Subscribe() 진입부, children 배열 leaf 부착부 — 둘 다 진입 전 동일하게 확인 +-- :Subscribe() 진입부, bindLifetime 진입부(leaf 부착도 내부적으로 이걸 거침) +-- — 둘 다 진입 전 동일하게 확인 if not canBound(self) then - error("Observer/Effect가 이미 다른 경로로 바인딩됨 — leaf 부착과 :Subscribe()는 동시에 쓸 수 없음") + error("Observer/Effect가 이미 다른 경로로 바인딩됨 — :Subscribe()와 bindLifetime(leaf 부착 포함)은 동시에 쓸 수 없음") end -- 통과했으면 여기서 바인딩됨으로 표시(내부 구현 디테일 — 공개 표면은 canBound 하나뿐) ``` @@ -1248,26 +1419,80 @@ end 구현은 여전히 불리언 플래그 하나(예전 가칭 `Bound`)로 충분하지만, 공개 표면에서 그 raw 필드를 직접 보여주지 않고 함수로 감싼다는 점만 바뀜. 동작 자체(둘 중 한 경로만 허용, 위반 시 그 자리에서 에러)는 - 안 바뀜. + 안 바뀜. **이 내부 플래그는 새 필드가 아니라 `canExecute`가 이미 보는 + `.Subscribed` 필드 그 자체(2026-08-09 여섯 번째 세션 명시)** — + `:Subscribe()`뿐 아니라 `bindLifetime`도(Observer/Effect 값에 한해) + 이 필드를 `true`로 세팅, `:Unsubscribe()`/`unbindLifetime` 둘 다 + `false`로 되돌림 — 그래야 `bindLifetime`으로 등록된 Observer도 + `canExecute`가 정상적으로 "살아있음"으로 인식함(필드를 둘로 나누면 + `bindLifetime`으로만 등록된 Observer가 `canExecute`에서 항상 + `false`로 오판됨). - 이 predicate는 어느 경로가 먼저 왔는지와 무관하게 "이미 바인딩됨"만 답함 — 두 진입점이 똑같이 `canBound`를 확인하므로 순서와 무관하게 대칭적으로 막힘. -- **`:Unsubscribe()`는 여전히 "어떤 경로로 바인딩됐든 그 계약을 끊는다"는 - 뜻으로 통일** — 바인딩이 어느 경로로 세워졌든, `:Unsubscribe()` 한 - 번으로 그 바인딩(leaf의 Destroying 연결이든 수동 강참조 등록이든)을 - 끝내고 최종 정리를 수행. 위 "`:Unsubscribe()`는 자동(리프) 케이스에도 - 동일하게 씀" 절과 정합 — 이중 바인딩 금지 규칙과 별개로, "단일 - 바인딩을 끊는" `:Unsubscribe()` 자체의 계약은 안 바뀜. -- **Effect도 동일 규칙 적용** — 내부적으로 Observer를 조합하는 경우든 - `state` 없는 경우든 같은 `canBound` 게이트를 그대로 재사용 - (`base/effect-plan.md`). 이전에 그 문서에 적어뒀던 "leaf 부착과 - `:Subscribe()`를 동시에 쓰는 것도 안전"이라는 서술은 **이 규칙으로 - 대체(정정)** — 안전하게 지원하는 게 아니라 애초에 막아야 하는 - 조합이었음. +- **`:Unsubscribe()`는 `:Subscribe()` 경로의 해제만 담당, `bindLifetime` + (leaf 부착 포함) 경로는 `unbindLifetime(inst, value)`로 해제** — + 둘은 서로 다른 함수로 남음(호출자가 `bindLifetime`을 부른 쪽이 + `unbindLifetime`도 대칭적으로 부르는 책임을 짐 — `inst`를 모르는 + `:Unsubscribe()`가 대신 처리할 수 없는 정보라서). leaf 부착으로 + 세워진 바인딩의 실제 해제도(예: Instance 파괴 전 조기 해제하고 싶을 + 때) 결국 `unbindLifetime`이 담당 — 위 "`:Unsubscribe()`는 자동(리프) + 케이스에도 동일하게 씀" 절의 서술은 leaf 부착이 별도 메커니즘이라고 + 전제했던 것이라 **이 정정으로 대체**(`:Unsubscribe()`가 아니라 + `unbindLifetime`이 leaf 해제의 실제 통로). +- **Effect도 동일 규칙 적용(사용자 확인)** — Effect가 `state` 인자로 + 내부적으로 Observer를 조합하는 경우든, `state` 없는 경우든 같은 + `canBound` 게이트를 그대로 재사용(`base/effect-plan.md`) — Effect + 자신이 아니라 내부 Observer가 게이트를 갖고 있어서, Effect 구현이 + 이 정정을 몰라도 자동으로 커버됨. 이전에 그 문서에 적어뒀던 "leaf + 부착과 `:Subscribe()`를 동시에 쓰는 것도 안전"이라는 서술은 **이 + 규칙으로 대체(정정)** — 안전하게 지원하는 게 아니라 애초에 막아야 + 하는 조합이었음. - **문서화 경고 대상(api/심화)**: "한 Effect/Observer 핸들을 children - 배열에 놓았다면 그걸 다시 `:Subscribe()`하지 말 것, 반대도 마찬가지 — - 두 경로를 동시에 쓰고 싶으면 각각 독립된 새 `Effect(...)`/ - `state:Observer(...)` 호출로 따로 만들 것"을 명시할 것. + 배열에 놓았다면(=`bindLifetime`으로 등록된 것) 그걸 다시 + `:Subscribe()`하거나 다른 Instance에 또 leaf로 놓지 말 것, 반대도 + 마찬가지 — 여러 경로를 동시에 쓰고 싶으면 각각 독립된 새 + `Effect(...)`/`state:Observer(...)` 호출로 따로 만들 것"을 명시할 것. + +### `bindLifetime`이 이 게이트의 두 번째(이자 leaf 부착이 실제로 쓰는) 진입점이다 (2026-08-09 여섯 번째 세션) + +`Dispatch.setLength`처럼 특정 `inst`에 종속된 내부 Observer를 등록할 때 +쓰는 `bindLifetime(inst, value)`(`base/lifecycle-pattern.md`)도 **같은 +`canBound` 게이트를 확인** — Observer/Effect 값을 `bindLifetime`할 때도 +진입 전 `canBound(value)`를 확인하고, 통과하면 바인딩됨으로 표시. +**children 배열 leaf 부착도 바로 이 `bindLifetime` 호출** — +`Dispatch/Leaf.luau`가 `(i:number, v=Observer/Effect)`를 매치하면 +그 자리에서 `bindLifetime(inst, v)`를 호출하는 것뿐, 별도 "leaf 전용" +바인딩 로직이 따로 있는 게 아님. 그래서 **실제 상호 배타는 `:Subscribe()` +(전역 강참조 레지스트리)와 `bindLifetime`(inst별 gchold, 직접 호출이든 +leaf 부착을 통한 간접 호출이든) 둘뿐** — 새 규칙을 따로 만들 이유가 +없음, 기존 게이트에 진입점 하나(`bindLifetime`, leaf 부착이 그 특수 +사례)만 추가. + +```lua +function bindLifetime(inst, value) + local isOE = isObserver(value) or isEffect(value) + if isOE and not canBound(value) then + error("Observer/Effect가 이미 다른 경로로 바인딩됨") + end + ... -- gchold 등록(base/lifecycle-pattern.md) + if isOE then value.Subscribed = true end -- canExecute가 보는 필드 그대로 재사용 +end + +function unbindLifetime(inst, value) + ... -- gchold 해제 + if isObserver(value) or isEffect(value) then value.Subscribed = false end +end +``` + +- **비-Observer/Effect 값(예: Tween 내부에 쓰는 평범한 클로저)은 이 게이트 + 자체가 안 적용됨** — `canBound`는 `.Subscribed`류 필드가 있는 Observer/ + Effect 전용 predicate라, 그 외 값은 `bindLifetime`이 그냥 통과시킴(leaf/ + `:Subscribe()` 경로 자체가 성립 안 하는 값들이라 충돌 대상이 없음). +- Observer/Effect가 `bindLifetime`으로 바인딩된 뒤엔 `canBound`가 + `false`를 반환하므로, 그 뒤에 같은 값을 leaf로 놓거나 `:Subscribe()`하면 + 기존 두 진입점의 기존 체크가 그대로 걸러줌 — 이 방향은 별도 코드 추가 + 없이 이미 성립. **quad의 Unix 파이프 영감(원래 동기)과 `Pipe`/`fromState` 후보 검토 경위는 `archive/quad2-try-research-findings-rejected.md`로 이전됨** — 최종 결론만 diff --git a/.claude/base/effect-plan.md b/.claude/base/effect-plan.md index ab75825..8d74af6 100644 --- a/.claude/base/effect-plan.md +++ b/.claude/base/effect-plan.md @@ -108,10 +108,13 @@ quad의 반응형 그래프/cleanup 인체공학만 재사용하는 경우)로 경로를 하나만 가져야 한다는 게 맞는 방향이라 판단이 뒤집힘 — 상세 규칙과 `canBound(handle)` 기반 즉시-에러 메커니즘(구 가칭 `Bound` 플래그, 2026-08-09 세션에서 이름 확정)은 - `base/bind-system-plan.md`의 "이중 바인딩 금지" 절 참고. leaf 부착 - **후** `:Unsubscribe()`로 조기 해제하는 것(위 "Observer의 - `:Unsubscribe()`는 자동 케이스에도 동일하게 씀" 패턴)은 여전히 정상 — - 금지되는 건 leaf 부착과 `:Subscribe()`를 **같이** 쓰는 것뿐. + `base/bind-system-plan.md`의 "이중 바인딩 금지" 절 참고. **[정정, + 2026-08-09 여섯 번째 세션] leaf 부착 후 조기 해제는 `:Unsubscribe()`가 + 아니라 `unbindLifetime(inst, value)`** — leaf 부착 자체가 내부적으로 + `bindLifetime(inst, value)` 호출이라, 그 해제도 짝인 `unbindLifetime` + 전용(`:Unsubscribe()`는 `inst`를 몰라 대신 처리 못 함) — 금지되는 건 + 여전히 `:Subscribe()`(전역 경로)와 `bindLifetime`(leaf 부착 포함, + inst-scoped 경로)을 **같이** 쓰는 것뿐. ## 해결됨 — Effect/Observer 관계 (2026-08-07 여섯 번째 세션, 이전 미해결 절 대체) diff --git a/.claude/base/lifecycle-pattern.md b/.claude/base/lifecycle-pattern.md index f4ab998..90d82be 100644 --- a/.claude/base/lifecycle-pattern.md +++ b/.claude/base/lifecycle-pattern.md @@ -130,19 +130,35 @@ GC에 묶이지 않음 — v1이 여기저기서 `PropertyChangedSignal`에 연 도구로 바인드된 옵저버는 `canExecute` predicate로 게이팅되어, 살아있지 않으면 실행 자체를 건너뛸 수 있음(죽은 대상에 대한 처리 시도 방지, 위 원칙과 직결). -### `bindLifetime`/`canExecute` — 확정(2026-08-08 세션) +### `bindLifetime`/`canExecute`/`unbindLifetime` — 확정(2026-08-08 세션, +`unbindLifetime`은 2026-08-09 세션 추가) **탑레벨 평범한 함수로 확정, 네임스페이스에 안 숨김.** `Dispatch.process`/ `Handler.xxx`는 "시스템 내부 배관"이라 네임스페이스가 맞지만, `bindLifetime`/ -`canExecute`는 `isState`/`isObserver`처럼 핸들러 작성자가 직접 호출하는 -**1급 프리미티브 연산**이라 `LifetimeHandle.bind(...)`류로 감싸면 안 됨 — -`LifetimeHandle.luau` 파일 안에 있어도 되지만 export는 평평한 함수: +`canExecute`/`unbindLifetime`는 `isState`/`isObserver`처럼 핸들러 작성자가 +직접 호출하는 **1급 프리미티브 연산**이라 `LifetimeHandle.bind(...)`류로 +감싸면 안 됨 — `LifetimeHandle.luau` 파일 안에 있어도 되지만 export는 +평평한 함수: ```lua bindLifetime(inst: any, value: any): () +unbindLifetime(inst: any, value: any): () canExecute(inst: any, value: any): boolean ``` +**`unbindLifetime` 추가 이유(2026-08-09 세션, `bind-system-plan.md`의 +"Length/Offset" 논의에서 파생)**: `Dispatch.setLength`(같은 위치에 새 +`State`가 들어오면 이전 것에 걸어둔 Observer를 먼저 정리해야 함, +`State` 교체가 대표 사례)처럼 **`inst` 전체 생명주기보다 먼저, +특정 값 하나만 콜백/구독을 끊어야 하는 경우**가 실제로 생김 — +`bindLifetime`만 있으면 그 호출부가 gchold의 내부 저장 구조(배열이든 +`value`를 키로 쓰는 테이블이든)를 직접 알아야만 특정 항목을 지울 수 +있어서 캡슐화가 깨짐. `unbindLifetime(inst, value)`을 짝으로 추가하면 +호출부는 내부 구조를 몰라도 됨 — 구현이 쉬운 이유도 여기 있음(아래 +스케치처럼 gchold를 `value`를 키로 쓰는 테이블로 두면 `gchold[value] = +nil` 한 줄). 안 걸려있던 값에 불러도 안전한 no-op(`:Unsubscribe()`류 +기존 관례와 동일). + base는 이 두 함수의 **인터페이스만**(타입 시그니처) 갖고, quad-roblox가 `BaseModule` 뮤테이션 시점에 실 구현을 채워넣는다는 원칙은 그대로(`canExecute` 관련 기존 절 참고) — 아래는 그 실 구현 스케치, `base/relate-plan.md`의 @@ -156,11 +172,18 @@ local GCCONN = "__gcconn" local GCHOLD = "__gchold" function bindLifetime(inst, value) + local isOE = isObserver(value) or isEffect(value) + -- leaf 부착도 내부적으로 이 함수를 호출하므로, :Subscribe()와 상호 + -- 배타적인 "이중 바인딩 금지"(base/bind-system-plan.md)를 여기서 확인 + if isOE and not canBound(value) then + error("Observer/Effect가 이미 다른 경로로 바인딩됨") + end + local gcconn = relate:GetStrong(inst, GCCONN) if not gcconn then -- ClassName은 절대 안 바뀌는 프로퍼티라 이 신호는 절대 발화하지 않음 -- (rbvm 패턴 그대로) — 콜백 클로저가 gchold를 업밸류로 캡쳐해 살려둠 - local gchold = {} + local gchold = {} -- value 자신을 키로 씀(배열 아님) — unbindLifetime을 O(1)로 relate:SetStrong(inst, GCHOLD, gchold) gcconn = inst:GetPropertyChangedSignal("ClassName"):Connect(function() local _ = gchold -- 발화 안 함, 클로저 생존이 곧 gchold 생존 @@ -168,14 +191,23 @@ function bindLifetime(inst, value) relate:SetStrong(inst, GCCONN, gcconn) end local gchold = relate:GetStrong(inst, GCHOLD) - table.insert(gchold, value) -- 강참조 생성, inst 죽으면 gcconn 클로저와 함께 GC + gchold[value] = true -- 강참조 생성, inst 죽으면 gcconn 클로저와 함께 GC + if isOE then value.Subscribed = true end -- canExecute가 보는 필드 그대로 재사용 +end + +function unbindLifetime(inst, value) + local gchold = relate:GetStrong(inst, GCHOLD) + if gchold then + gchold[value] = nil -- inst는 안 건드림, 이 value 하나만 조기 해제 + end + if isObserver(value) or isEffect(value) then value.Subscribed = false end end function canExecute(inst, value) - -- Observer/Effect는 자기 바인딩 경로(leaf 부착 또는 :Subscribe())의 - -- 생존 여부를 스스로 알고 있음 — inst가 살아있어도 이 값이 먼저 죽어 - -- 있을 수 있으므로(예: retract가 :Unsubscribe()만 하고 inst는 안 죽음) - -- 반드시 먼저 확인. + -- Observer/Effect는 자기 바인딩 경로(bindLifetime=leaf 부착 포함, + -- 또는 :Subscribe())의 생존 여부를 스스로 알고 있음 — inst가 살아있어도 + -- 이 값이 먼저 죽어 있을 수 있으므로(예: retract가 unbindLifetime만 + -- 하고 inst는 안 죽음) 반드시 먼저 확인. if (isObserver(value) or isEffect(value)) and not value.Subscribed then return false end diff --git a/.claude/base/slot-plan.md b/.claude/base/slot-plan.md index 0ffa9a5..134f56c 100644 --- a/.claude/base/slot-plan.md +++ b/.claude/base/slot-plan.md @@ -139,20 +139,20 @@ ref처럼 바인드됨 — **사용자 확정**("A. 맞음. 리프노드에선 Slot이 형제로 섞이는 경우의 순서 보장 문제는 별도로 열려있음, 바로 아래 참고. -### 여러 Slot이 섞일 때 순서 보장 — 열린 질문 (2026-08-04 신규) +### 여러 Slot이 섞일 때 순서 보장 — 해소됨 (2026-08-09 여섯 번째 세션) `Frame { Slot1, 일반자식, Slot2 }`처럼 Slot과 Slot 사이에 다른 요소가 끼거나 Slot이 여럿 형제로 존재할 때, 최종 자식 순서가 저작 순서(위쪽 Slot의 요소가 -항상 아래쪽 Slot의 요소보다 앞)를 안정적으로 지키는지는 아직 설계 안 됨 — -**사용자 확정**("순서도 따름, 만약 웹이라면 순서에 맞춰서 마운트 되어야 하고, -아래쪽 slot은 위쪽 slot의 요소보다 무조건 아래 있어야함... 이게 가능하냐는 -생각해봐야할 이야기임"). Roblox 자체는 z-순서를 주로 `LayoutOrder`/`ZIndex`로 -푸는 편이라 당장 급하게 막히는 지점은 아니지만, `architecture.md`가 명시한 -"quad-base는 다른 렌더 백엔드(GTK, 웹 DOM 등)에서도 재사용 가능해야 한다"는 -전제와 직결되는 문제 — DOM류 백엔드는 형제 순서 자체가 렌더 순서라 이 보장이 -없으면 못 씀. **후순위 열린 질문으로 유지**, Slot 코어 로직 구현 시점에 -다시 볼 것 — M0/M1 단계를 막지는 않음(Roblox 단일 백엔드로는 당장 문제 -없음). +항상 아래쪽 Slot의 요소보다 앞)를 안정적으로 지키는지가 2026-08-04부터 열려 +있었던 질문 — **메커니즘 확정으로 해소됨**: `Dispatch.setLength`/ +`Dispatch.setOffsetSource` + 형제별 개수 누적합(`offset`)을 리액티브 +프로퍼티(Roblox `LayoutOrder`)에 바인딩하는 방식 — 상세는 `base/ +bind-system-plan.md`의 "Length/Offset — 여러 Slot이 형제로 섞일 때 순서 +보장" 절 참고. **DOM류 물리 순서 백엔드에도 같은 base 메커니즘이 그대로 +재사용됨**(offset이 바뀌어도 이미 마운트된 원소를 물리적으로 옮길 필요 +없음 — `insertBefore`가 뒤 형제를 자연히 밀어주므로, backend Handler의 +"offset 변경 시 할 일"만 no-op으로 달라짐) — `architecture.md`의 "다른 +렌더 백엔드에서도 재사용 가능해야 한다"는 전제와도 부딪히지 않음. ## Slot과 Store 바인드의 관계 (`retract` 순서) @@ -546,6 +546,26 @@ end - **[해소됨, 2026-08-09 세 번째 세션]** `add`/`remove`/`clear` CRUD 의미론, `isMounted` 이중 추적 분리, 키 기반 동적 컬렉션 재조정(`Slot:List`) — 위 "CRUD API 확정"/"`isMounted` 이중 추적 분리"/"`Slot:List`" 절 참고. -- **여러 Slot이 형제로 섞일 때 순서 보장**은 아직 열려있음(위 "여러 Slot이 - 섞일 때 순서 보장" 절 참고) — Roblox 단일 백엔드로는 급하지 않음, Slot - 코어 로직 구현 시점에 재검토. +- **[해소됨, 2026-08-09 여섯 번째 세션]** 여러 Slot이 형제로 섞일 때 + 순서 보장 — 위 "여러 Slot이 섞일 때 순서 보장" 절 참고, 메커니즘은 + `base/bind-system-plan.md`의 "Length/Offset" 절이 최신 소스. + +## Slot.Length — `:List`뿐 아니라 항상 노출됨 (2026-08-09 여섯 번째 세션) + +Slot은 CRUD/`:List` 여부와 무관하게 `.Length: State`를 항상 +노출 — 지금 실제로 마운트된 요소 개수(사용자가 직접 CRUD로 넣든 `:List` +reconcile이 넣든 동일). 두 용도를 겸함: (1) 사용자가 "n개 검색됨" 같은 +UI에 직접 관측, (2) `Dispatch.setLength(inst, i, slot.Length)`가 형제 +순서 보장(위 "여러 Slot이 섞일 때 순서 보장" 참고)에 내부적으로 읽는 바로 +그 값 — 별도 두 State가 아니라 하나. `:List`의 filter 탈락이 실제 +`Remove`(Visible 토글 아님)로 확정돼 있어서 `Length`는 자동으로 "실제 +마운트된 것"만 반영 — 수동 Visible 토글을 쓰면 `Length`가 그걸 못 잡는 +게 맞고, 그건 사용자가 별도 State로 계산해야 하는 몫. + +## 백로그 — `Slot():Single(state, updateFn?)` (2026-08-09 여섯 번째 세션, 미착수) + +`:List`의 key-map(`mounted`/`userdata`/`keyIndex`) 없이 "0개 아니면 1개"만 +다루는 더 가벼운 편의 메소드 제안(예: `state`를 조건부로 마운트하는 +관용구를 더 명시적으로 표현) — `.Length`는 그냥 0/1이고 나머지(offset 소비, +LayoutOrder 바인딩)는 일반 Slot과 완전히 같은 프로토콜. 아직 상세 설계 +안 함, `.claude/question.md`에 백로그로만 반영. diff --git a/.claude/question.md b/.claude/question.md index dba4757..26307ea 100644 --- a/.claude/question.md +++ b/.claude/question.md @@ -34,6 +34,9 @@ context-rejected.md`. 아래는 그중 **아직 실제로 열려있는 것만** - Untrack/Suspense/Error Boundary/Readonly는 조사 결과 새 프리미티브 없이 기존 설계·Lua 자체 기능으로 이미 충분한 것으로 판단(`research/ additional-primitives-plan.md` "빈 자리 아닌 것" 절). +- **[백로그, 2026-08-09 여섯 번째 세션 추가, 미착수]** `Slot():Single(state, + updateFn?)` — `:List`의 key-map 없이 "0개 아니면 1개"만 다루는 가벼운 + 편의 메소드. `base/slot-plan.md` "백로그 — `Slot():Single(...)`" 절. ### 1. 용어 정리 (사용자 요청, 진행 중) @@ -190,11 +193,14 @@ context-rejected.md`. 아래는 그중 **아직 실제로 열려있는 것만** - **v1 `objectListClass.__newIndex` 오타 기능의 재현 테스트 필요** — `reference/quad-v1-architecture.md`에 남겨진 v1 내부 동작 확인 사항, 마이그레이션 가이드 작성 시점에 필요. 지금은 그냥 백로그로만 기록. -- **여러 Slot이 형제로 섞일 때 순서 보장** — `base/slot-plan.md`의 "여러 - Slot이 섞일 때 순서 보장" 절 참고. Roblox 단일 백엔드로는 급하지 않음 - (LayoutOrder/ZIndex로 대부분 해결), 다른 백엔드(웹 DOM 등) 재사용성과 - 직결되는 문제라 Slot 코어 로직 구현 시점에 재검토. **같은 구현 시점에 - 같이 확인할 것(2026-08-06 추가)**: Slot이 quad 밖(v1 compat 등)에서 +- **[해소됨, 2026-08-09 여섯 번째 세션]** 여러 Slot이 형제로 섞일 때 + 순서 보장 — `Dispatch.setLength`/`Dispatch.setOffsetSource` + 형제별 + 개수 누적합을 `LayoutOrder`에 리액티브 바인딩하는 메커니즘으로 확정, + DOM류 물리 순서 백엔드에도 같은 base 로직이 재사용됨(backend Handler의 + "offset 변경 시 할 일"만 no-op으로 갈림). 상세는 `base/ + bind-system-plan.md` "Length/Offset" 절, `base/slot-plan.md` "여러 + Slot이 섞일 때 순서 보장" 절. **같은 구현 시점에 같이 확인할 것 + (2026-08-06 추가, 아직 안 풀림)**: Slot이 quad 밖(v1 compat 등)에서 만들어진 임의 Instance를 동적 배열 원소로 받을 수 있는지, retract 시 foreign Instance를 어떻게 다루는지 — `research/v1-compat-plan.md` 7-3 참고. diff --git a/CLAUDE.md b/CLAUDE.md index daf9834..e4fca9f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2149,3 +2149,113 @@ Source/Store를 새로 안 만들려면 이전 상태를 어딘가 저장해야 `research/documentation-content-map.md` 반영 완료. **다음 세션이 할 일**: 여전히 안 바뀜(`ROADMAP.md` M0부터). + +## 2026-08-09 여섯 번째 세션 — 여러 Slot이 형제로 섞일 때 순서 보장 완전 +해소(Length/Offset), `unbindLifetime` 신설 + +**출발점**: 사용자가 미래의 `quad-web`을 가정하며 `{ Slot, Element, Slot }`처럼 +Slot이 여럿 형제로 섞일 때 최종 순서를 어떻게 보장하는지 물음 — +2026-08-04부터 "Roblox 단일 백엔드로는 급하지 않음"으로 후순위 열려있던 +질문(`slot-plan.md` "여러 Slot이 섞일 때 순서 보장" 절)을 실제로 라이브 +설계해서 완전히 풀어낸 긴 단일 스레드. 시행착오를 거쳐 최종 수렴한 결론만 +정리(중간 대안들 — "구간 예약"/`:With`+`:Compute` 체인 — 은 채택 안 됨, +사용자가 제시한 "정확한 누적합 + 플랫 재계산 루프"가 최종안): + +- **핵심 전환**: "각 원소가 절대 위치를 계산해서 전파"가 아니라 "각 + 구조적 위치가 자기 앞 형제들의 개수 누적합(`offset`)만 알면 됨" — + Roblox `LayoutOrder`가 이미 `Instance.Parent` 물리 순서와 분리된 + 정수 프로퍼티라는 사실이 이 전환을 공짜로 성립시킴. +- **`Dispatch.setLength(inst,i,len:number|State)`/ + `Dispatch.setOffsetSource(inst,i,offset:Source|None)`** — + 둘 다 Handler→Dispatch 등록(push) 방향, array part의 **모든** number + 인덱스에 대해 반드시 호출(생략 UB — Handler 구현체 작성자만의 계약, + 일반 사용자 영향 없음). `recompute`는 매번 `1..N` 전체를 도는 단순 + 루프(N은 저작 시점에 고정된 배열 리터럴 길이라 무시 가능)로, 각 + `offset:Set()` 호출 앞에서만 `Get() ~= sum` 가드를 걸어 실제로 안 + 바뀐 위치의 캐스케이드(다운스트림 `LayoutOrder` 재적용)를 막음 — + 전체 순회 비용과 `Set` 캐스케이드 비용을 분리해서 후자만 최적화. +- **각 원소의 `LayoutOrder`는 `localIndex+offset`의 State를 기존 + store-bind 프로퍼티 바인딩에 그냥 얹는 것** — 이게 이 설계의 가장 + 큰 단순화 지점: "offset 변경 시 이미 마운트된 원소를 다시 써야 한다"는 + 요구가 새 push/observer 메커니즘 없이 **이미 있는** store-bind + 재실행 모델(`state:Observer(fn):Subscribe()`) 재사용만으로 공짜로 + 풀림. +- **`setLength`의 내부 Observer는 leaf-lifetime 경로(`bindLifetime`)를 + 씀, `:Subscribe()` 아님** — 이 Observer는 특정 leaf가 아니라 `inst` + 자신에 종속된 내부 배관이라, `inst` Destroy 시 자동으로 안 죽는 + `:Subscribe()` 경로는 안 맞음. `State` 교체처럼 `inst` 전체가 + 죽기 전에 특정 위치 하나만 조기 재등록해야 하는 경우를 위해 + **`unbindLifetime(inst,value)`을 `bindLifetime`/`canExecute`의 + 세 번째 짝으로 신설** — `Dispatch.setLength`가 gchold 내부 저장 + 구조(배열/키드 테이블)를 몰라도 이전 등록을 블랙박스로 해제할 수 + 있게 캡슐화. quad-roblox 구현 스케치도 gchold를 배열 대신 `value`를 + 키로 쓰는 테이블로 바꿔 `unbindLifetime`을 O(1)로(`gchold[value] = + nil`) — base 결정은 아니고 참고용 스케치. +- **동기 순서 요구사항**: Slot의 `rawAdd`는 `Length:Set(newCount)` + (다운스트림 offset/LayoutOrder 캐스케이드가 여기서 동기적으로 끝남) + 다음에 `element.Parent = target`을 호출 — Source:Set()이 옵저버 + 체인을 동기적으로 끝까지 도는 기존 모델 덕에 별도 배리어 없이 순서만 + 지키면 자동 성립. 안 지키면 Roblox의 실시간 `UIListLayout` reflow가 + 한 프레임 잘못된 순서를 노출할 위험. +- **`Slot.Length: State`가 CRUD/`:List` 여부와 무관하게 항상 + 노출되는 프리미티브 필드로 확정** — 사용자가 직접 "n개 검색됨" UI에도 + 쓸 수 있다고 지적, `setLength`가 내부적으로 읽는 값과 완전히 동일(두 + 용도를 겸함, 별도 State 아님). `:List`의 filter=진짜 Remove 확정 + 덕에 "Visible 토글은 안 잡힘"이 자연히 성립(새 캐비엇 아님). +- **웹 백엔드(quad-web) 일반화 — base 로직 100% 재사용, backend + Handler의 "offset 변경 시 할 일"만 달라짐**: DOM `insertBefore`는 + 물리적 삽입 시 뒤 형제를 자동으로 밀어주므로, offset이 바뀌어도 + 이미 마운트된 노드를 실제로 옮길 필요가 없음 — quad-web Handler는 + offset 변경 관측 시 no-op, 숫자는 그 위치가 **다음** insert/remove + 때 쓸 물리 인덱스로만 부기됨. 처음 검토했던 "구간 예약"(고정 gap)이나 + "앵커 기반 상대 삽입" 안보다 이 방식이 dense global rank라 두 종류 + 백엔드(순서-분리 프로퍼티형/물리-순서형) 모두에 더 직접적으로 맞음. +- **백로그로만 남김**: `Slot():Single(state, updateFn?)` — `:List`의 + key-map 없이 "0 또는 1"만 다루는 가벼운 편의 메소드, 상세 설계 미착수. + +**같은 세션 후속 — `bindLifetime`/`unbindLifetime`이 실제로 뭘 하는지, +`canBound`(이중 바인딩 금지)와의 관계를 여러 차례 시행착오 끝에 정확히 +확정.** `Dispatch.setLength`가 이전 Observer 등록을 정리할 때 뭘 불러야 +하는지를 두고 제가 세 번 틀렸다가 사용자가 매번 정정 — 경위와 최종 +결론을 구분해서 기록: + +1. **1차 시도(틀림)**: `unbindLifetime`이 `canExecute`를 즉시 `false`로 + 만들어준다고 서술 — 틀림. `gchold`(순수 GC 방지용 강참조 테이블)는 + `canExecute`가 보는 값(Observer/Effect의 `.Subscribed`, 또는 `inst`의 + 공유 `gcconn.Connected`) 어디에도 안 들어감, 완전히 무관한 테이블. +2. **2차 시도(틀림)**: 그래서 "`unbindLifetime`은 필요 없고 `:Unsubscribe()` + 만 쓰면 된다"로 후퇴 — 이것도 틀림. 사용자 정정: `:Subscribe()`/ + `:Unsubscribe()`는 **`inst`와 아예 무관한 전역/독립** Observer(모듈 + 최상위 디버그 print 등, leaf도 없고 특정 Instance에도 안 묶인 경우)를 + GC로부터 지키기 위한 **전역** 강참조 테이블(`SubscribedObservers[observer] + = true/nil`)일 뿐 — `Dispatch.setLength`의 Observer처럼 처음부터 + `inst` 하나에 종속된 내부 배관에는 원래부터 안 맞는 도구. "`inst` + 연관은 전부 `bindLifetime`/`unbindLifetime`으로"가 맞는 원칙. +3. **최종 확정**: 진짜 독립된 라이프사이클 경로는 **`:Subscribe()`(전역) + 와 `bindLifetime`(inst-scoped) 둘뿐** — "children 배열 leaf 부착"은 + 세 번째 경로가 아니라 **`bindLifetime` 호출 그 자체**(`Dispatch/ + Leaf.luau`가 Observer/Effect leaf를 매치하면 그 자리에서 + `bindLifetime(inst, v)`를 호출), 이걸 제가 처음에 "leaf 부착/ + `:Subscribe()`/`bindLifetime` 셋 다 상호 배타"로 잘못 일반화했다가 + 사용자가 "leaf 부착 자체가 bindLifetime을 호출하는 거라 동일 동작, + 상호배타는 아니다"로 정정. `canBound`의 내부 플래그도 새 필드가 + 아니라 **`canExecute`가 이미 보는 `.Subscribed` 그 자체** — + `bindLifetime`/`unbindLifetime`도(Observer/Effect 값에 한해) 이 + 필드를 세팅/해제해야 `bindLifetime`으로 등록된 Observer가 + `canExecute`에서 정상적으로 "살아있음"으로 인식됨. Effect는 내부적으로 + Observer를 조합하므로 이 확장을 몰라도 자동으로 커버(사용자 확인). +4. **부수 정리**: 이미 확정돼 있던 StoreBind의 자기 재실행 Observer + 예제(`observer:Subscribe()`)도 같은 이유로 틀렸던 것이었음 확인 — + `bindLifetime`/`unbindLifetime`으로 교체. "`:Unsubscribe()`는 자동 + (리프) 케이스에도 동일하게 씀"이라던 기존 서술도 같은 이유로 정정 + (리프/`bindLifetime` 경로의 조기 해제는 `unbindLifetime` 전용, + `:Unsubscribe()`는 `inst`를 몰라 대신 처리 못 함). + +전부 `base/bind-system-plan.md`(신규 "Length/Offset" 절, "이중 바인딩 +금지" 절 정정 — 2-way로 재확정, StoreBind 예제 교체)/`base/slot-plan.md` +(열린 질문 해소, `Slot.Length` 절, `:Single` 백로그 절)/`base/ +lifecycle-pattern.md`(`unbindLifetime` 추가 + `canBound`/`.Subscribed` +연동 반영)/`ROADMAP.md`(M2/M3/M6)/`.claude/question.md` 반영 완료. + +**다음 세션이 할 일**: 안 바뀜(`ROADMAP.md` M0부터) — 이번 세션도 순수 +설계 확정이라 M0 착수 우선순위 자체는 그대로. diff --git a/ROADMAP.md b/ROADMAP.md index cc00409..023f66b 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -93,18 +93,35 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 재사용 — 구 `base.perInstanceState(inst)`/`PerInstanceState.luau`를 대체(2026-08-08 세션 신설). - [ ] `LifetimeHandle.luau` **인터페이스만**(`bindLifetime(inst,value)`/ - `canExecute(inst,value)` 탑레벨 함수 타입 계약, 실 구현 없음 — - quad-roblox 실 구현은 M8) — 원래 M8에만 있었으나 M4(StoreBind의 - `Connected` 확인)/M6(Slot의 `canExecute`)이 이미 이 인터페이스를 - 전제로 서술돼 있어 로드맵 순서가 역전돼 있었음(`pre-implementation-audit.md` - 우선순위1-9, `question.md` 2번 — 2026-08-07 네 번째 세션에 반영). + `unbindLifetime(inst,value)`/`canExecute(inst,value)` 탑레벨 함수 + 타입 계약, 실 구현 없음 — quad-roblox 실 구현은 M8) — 원래 M8에만 + 있었으나 M4(StoreBind의 `Connected` 확인)/M6(Slot의 `canExecute`)이 + 이미 이 인터페이스를 전제로 서술돼 있어 로드맵 순서가 역전돼 + 있었음(`pre-implementation-audit.md` 우선순위1-9, `question.md` + 2번 — 2026-08-07 네 번째 세션에 반영). **`canExecute`는 `(inst, value) -> boolean`으로 재확정(2026-08-08 세션, `(handle)` 단일 인자 서술을 대체)** — Observer/Effect는 자기 `Subscribed` 상태를 먼저 확인, 그 다음 `inst`의 공유 gcconn(`Relate`로 - 저장)의 `.Connected`를 봄. `bindLifetime`/`canExecute` 둘 다 네임스페이스 + 저장)의 `.Connected`를 봄. **`unbindLifetime(inst,value)` 추가 + (2026-08-09 여섯 번째 세션)** — `inst` 전체 죽기 전에 특정 값 하나만 + 조기 해제(`Dispatch.setLength`가 State 재등록 시 이전 Observer를 + 정리하는 데 씀), gchold 내부 구조를 호출부가 몰라도 되게 캡슐화. + `bindLifetime`/`unbindLifetime`/`canExecute` 셋 다 네임스페이스 없이 탑레벨 함수로 export(`Dispatch.xxx`류 시스템 네임싱과 구분, `isState`/`isObserver`와 같은 1급 프리미티브 취급) — `base/ - lifecycle-pattern.md`의 "`bindLifetime`/`canExecute` — 확정" 절 참고 + lifecycle-pattern.md`의 "`bindLifetime`/`canExecute`/`unbindLifetime` + — 확정" 절 참고. **Observer/Effect 값에는 `bindLifetime`/ + `unbindLifetime`도 M3의 `canBound` 게이트를 확인/세팅** — children + 배열 leaf 부착이 실제로는 `bindLifetime` 호출이라서(M3 체크박스 + 참고, 구현 순서상 M2가 M3의 `canBound`를 참조하게 됨에 유의) +- [ ] `Dispatch.setLength(inst,i,len:number|State)`/ + `Dispatch.setOffsetSource(inst,i,offset:Source|None)` — + array part 형제 순서 보장(Length/Offset 누적합→`LayoutOrder` 리액티브 + 바인딩), array part 모든 number 인덱스에 대해 둘 다 호출 필수(생략 + UB, Handler 구현체 작성자만의 계약) — `recompute`는 leaf-lifetime + 경로(`bindLifetime`/`unbindLifetime`)로 등록, `:Subscribe()` 아님 + (2026-08-09 여섯 번째 세션, `base/bind-system-plan.md` "Length/Offset" + 절 — `base/slot-plan.md` "여러 Slot이 섞일 때 순서 보장" 해소) - [ ] 핸들러 계약 검증: `retract` 필드가 없는 핸들러를 등록하면 리뷰/린트에서 걸러내기(no-op이라도 필드 자체는 항상 정의 — `Dispatch.process`가 핸들러 교체 시 nil 체크 없이 호출, `base/bind-system-plan.md` "핸들러 계약" @@ -143,10 +160,15 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 `EffectHandle:Subscribe()`/`:Unsubscribe()`도 추가(leaf 없이 쓰는 모듈/스크립트 레벨 Effect) — `:Unsubscribe()`는 Observer와 달리 마지막 cleanup을 1회 트리거해야 함(2026-08-07 일곱 번째 세션) -- [ ] Observer/Effect 이중 바인딩 금지 — `canBound(handle)` predicate로 leaf - 부착과 `:Subscribe()`가 동시에 걸리면 즉시 `error`(`base/bind-system-plan.md` - "이중 바인딩 금지" 절, 2026-08-07 일곱 번째 세션 신설, 이름은 - 2026-08-09 세션에 `canBound`로 확정) +- [ ] Observer/Effect 이중 바인딩 금지 — `canBound(handle)` predicate로 + `:Subscribe()`(전역)와 `bindLifetime`(inst-scoped, leaf 부착도 + 내부적으로 이걸 호출)이 동시에 걸리면 즉시 `error`(`base/ + bind-system-plan.md` "이중 바인딩 금지" 절, 2026-08-07 일곱 번째 + 세션 신설, 이름은 2026-08-09 세션에 `canBound`로 확정, 같은 날 + 여섯 번째 세션에서 "leaf 부착=bindLifetime 호출"로 정정 — 진짜 + 독립 경로는 둘뿐). `canBound`의 내부 플래그는 `canExecute`가 보는 + `.Subscribed`와 같은 필드 — `bindLifetime`/`unbindLifetime`도 + (Observer/Effect 값에 한해) 이 필드를 세팅/해제 - [ ] mock 대상 테스트 ## M4 — 첫 end-to-end 반응형 업데이트 @@ -168,8 +190,11 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 ## M6 — Slot -- [ ] "여러 Slot이 형제로 섞일 때 순서 보장" 열린 질문 확인(`slot-plan.md`) — - Roblox 단일 백엔드로는 급하지 않으면 스킵하고 진행 가능 +- [x] **"여러 Slot이 형제로 섞일 때 순서 보장" 해소**(2026-08-09 여섯 번째 + 세션) — `Dispatch.setLength`/`setOffsetSource` 메커니즘, `base/ + bind-system-plan.md` "Length/Offset" 절. `Slot.Length: State`도 + 이때 확정(CRUD/`:List` 여부 무관 항상 노출, 순서 계산과 "n개 검색됨" + UI 둘 다 겸함) — 구현 시 이 두 API를 `:List`/CRUD의 `raw*`가 호출. - [x] **Slot의 `Add`/`Remove`/`Extract`/`Clear`/`Move`/`Swap` CRUD 의미론 확정** (2026-08-09 세 번째 세션) — `get`/`set` 드롭, 에러 조건까지 전부 확정(`base/slot-plan.md` "CRUD API 확정"). "재마운트 시 즉시