From 5836c2d12af6333248d7d16780c5f2a9f757212a Mon Sep 17 00:00:00 2001 From: qwreey Date: Sun, 9 Aug 2026 21:42:24 +0900 Subject: [PATCH] =?UTF-8?q?decide(base):=20Slot:List=EC=9D=98=20data:Obser?= =?UTF-8?q?ver=20=EA=B5=AC=EB=8F=85=EC=9D=84=20=EB=A7=88=EC=9A=B4=ED=8A=B8?= =?UTF-8?q?=20=EC=8B=9C=EC=A0=90=20lazy=20bindLifetime=EC=9C=BC=EB=A1=9C?= =?UTF-8?q?=20=ED=86=B5=EC=9D=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dispatch.setLength는 이미 마운트 시점에 bindLifetime을 걸었지만, Slot:List의 data:Observer(fn) 구독은 :List() 호출 시점(inst를 모르는 시점)에 즉시 생성되어 Destroy 후에도 재실행/관측을 멈출 방법이 없었음. :List()는 이제 설정만 저장하고, 실제 구독+최초 reconcile은 Slot 마운트 시점에 activateList로 수행 — 마운트 이후 :List() 호출 시 self._mounted로 즉시 활성화. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01EYfAz3BsaTrmMM8hnzn6mj --- .claude/base/slot-plan.md | 100 +++++++++++++++++++++++++++++++++++--- CLAUDE.md | 47 ++++++++++++++++++ ROADMAP.md | 7 ++- 3 files changed, 145 insertions(+), 9 deletions(-) diff --git a/.claude/base/slot-plan.md b/.claude/base/slot-plan.md index 134f56c..d274eea 100644 --- a/.claude/base/slot-plan.md +++ b/.claude/base/slot-plan.md @@ -26,6 +26,12 @@ unmount(`Remove`) 둘이 아니라 **reposition(`Move`/`Swap`)까지 셋** — 계약만 base가 강제**하고, quad-roblox가 이걸 `SetSiblingIndex`로 구현할지 (`LayoutOrder` 기반 정렬이라) 사실상 no-op으로 둘지는 구현 선택. +**[2026-08-09 일곱 번째 세션 보강]** `Dispatch/Slot.luau`의 mount 훅 +(`process(inst,k,self)`)은 `Dispatch.setLength(inst,i,self.Length)` 호출과 +같은 자리에서 `self._listed`면 `activateList(self,inst)`도 트리거해야 함 — +`:List`의 `data:Observer(fn)` 구독을 Slot 마운트 시점까지 lazy하게 미루는 +것도 이 mount 훅의 책임(아래 "`Slot:List(...)`"의 "구독 시점" 절 참고). + **추가로 필요해진 핸들러**: Slot과는 별개로, `k`가 number이고 `v`가 이미 만들어진 Instance인 경우(중첩 인스턴스를 자식으로 직접 넣는 경우, 예: `Frame { Frame {} }`)를 위한 핸들러도 필요 — `quad-roblox/src/Handlers/ @@ -103,7 +109,10 @@ Fusion의 `Children` SpecialKey는 이걸 "특정 SpecialKey 하나의 내부 소진, Ref 콜백 fire 등)이 전부 dispatch-process 시점 기준이라 여기만 post-effect 기준으로 가면 일관성이 깨짐. 컴포넌트가 Slot을 prop으로 받아 저장만 하고 실제 트리에 안 놓는 경로는 `process`가 애초에 안 - 불려서 이 정의로도 오탐 없음. + 불려서 이 정의로도 오탐 없음. **[2026-08-09 일곱 번째 세션 보강]** + 같은 자리에서 `self._mountedInst = inst`도 같이 저장 — `:List()`가 + 마운트 이후에 호출되는 경우 이 값으로 즉시 활성화(아래 "`Slot:List(...)`"의 + "구독 시점" 절 참고). - **개별 element**: Slot 안에 담기는 각 element(Instance/컴포넌트 결과 등) 마다 전역 weak-set 멤버십으로 추적 — 특정 Slot 인스턴스에 안 묶임 ("한 인스턴스가 어디에도 중복 마운트 안 됨"이 라이브러리 전역 불변식이라서). @@ -422,12 +431,31 @@ GC-native 원칙(`lifecycle-pattern.md`)을 `:List`라는 구체적 지점에 ### 구현 +**구독 시점은 `:List()` 호출이 아니라 Slot 마운트 시점 — lazy `bindLifetime` +(2026-08-09 일곱 번째 세션, 아래 "구독 시점" 절 참고).** `:List()`는 설정만 +저장하고 반환, 실제 `data:Observer(fn)` 구독과 최초 `reconcile`은 Slot +자신이 마운트되는 순간(`Dispatch/Slot.luau`의 `process(inst,k,self)`)에 +`activateList`가 수행 — `Dispatch.setLength`가 이미 쓰고 있는 것과 같은 +패턴(마운트 시점까지 미뤘다가 그 자리에서 `bindLifetime`). + ```lua function Slot:List(data, updateFn, keyFn) assert(not self._listed, "Slot already has :List installed") self._listed = true - keyFn = keyFn or function(_, index) return index end + self._listData = data + self._updateFn = updateFn + self._keyFn = keyFn or function(_, index) return index end + if self._mounted then + activateList(self, self._mountedInst) -- 이미 마운트돼 있으면 즉시 활성화 + end + return self +end + +-- Dispatch/Slot.luau의 process(inst,k,self)가 마운트 시점에 1회 호출 +-- (self._mounted=true/self._mountedInst=inst를 세팅하는 바로 그 자리) +function activateList(self, inst) + local keyFn, updateFn = self._keyFn, self._updateFn local mounted, userdata, keyIndex = {}, {}, {} local function reconcile(items) @@ -461,13 +489,16 @@ function Slot:List(data, updateFn, keyFn) keyIndex = newKeyIndex end + local data = self._listData if isState(data) then - data:Observer(function() reconcile(data:Get()) end) - -- Observer는 등록 즉시 1회 실행 확정 -> 최초 population도 공짜 + local observer = data:Observer(function() reconcile(data:Get()) end) + -- Observer 등록 자체의 "등록 즉시 1회 실행"은 canExecute/Subscribed + -- 게이팅과 무관하게 여기서 이미 무조건 일어남(아래 "구독 시점" 절) — + -- bindLifetime은 그 다음에 걸어 *이후* 재실행만 inst 생명주기에 귀속 + bindLifetime(inst, observer) else reconcile(data) end - return self end ``` @@ -491,9 +522,10 @@ end 못 치워지고 샘 — 직전 사이클에 실제로 존재했던 **전체** key 집합 (`keyIndex`, 매 사이클 모든 key에 대해 채워짐)을 순회해야 이 케이스를 놓치지 않음. -- **`mounted`/`userdata`/`keyIndex`**: 이 Slot 인스턴스의 평범한 로컬 - 필드(클로저 업밸류) — 별도 전역 weak table(`Relate` 등) 불필요, `self`가 - 살아있는 동안만 존재하면 되고 Slot이 죽으면 클로저도 같이 GC됨. +- **`mounted`/`userdata`/`keyIndex`**: `activateList`(마운트 시점 1회 + 실행)의 로컬 변수(클로저 업밸류) — 별도 전역 weak table(`Relate` 등) + 불필요, `inst`/`self`가 살아있는 동안만 존재하면 되고 죽으면 클로저도 + 같이 GC됨(아래 "구독 시점" 절). - **`reconcile`이 직접 호출하는 건 `rawAdd`/`rawRemove`/`rawMove`뿐** — `rawExtract`/`rawSwap`/`rawClear`도 (위 "모든 공개 CRUD는 가드+위임" 구조상) 당연히 존재하지만, `:List`의 reconcile 알고리즘 자체가 그 @@ -504,6 +536,58 @@ end 저비용 경로. 최소-이동 알고리즘(LIS 기반 등) 자체는 구현 시점 최적화로 미룸, 여기선 계약(파괴 없이 위치만 바뀜)만 확정. +### 구독 시점 — `:List()` 호출이 아니라 Slot 마운트 시점, lazy `bindLifetime` +(2026-08-09 일곱 번째 세션) + +**문제**: 원래 초안은 `data:Observer(fn)`를 `:List()` 호출 그 자리에서 만들었음 +— 근데 `:List()`는 `Slot():List(data, updateFn)`처럼 Slot이 아직 어디에도 +마운트되기 전에 불리는 게 흔한 사용법이라, 그 시점엔 `inst`를 몰라서 +`bindLifetime`을 걸 수 없었음(사용자가 직접 지적) — 마운트 대상이 나중에 +`Destroy`돼도 이 구독을 멈출 방법이 없는 gap이었음. + +**해법 — `Dispatch.setLength`가 이미 쓰고 있는 패턴 그대로 재사용**: 새 +메커니즘 발명 아님. `:List()`는 `data`/`updateFn`/`keyFn`만 저장하고 반환, +실제 `data:Observer(fn)` 구독 + 최초 `reconcile`은 Slot 컨테이너 자신이 +마운트되는 순간(`Dispatch/Slot.luau`의 `process(inst,k,self)` — 위 +"`isMounted` 이중 추적 분리" 절이 이미 `self._mounted`를 세팅하는 바로 그 +지점)에 `activateList(self, inst)`가 수행. `Dispatch.setLength(inst,i, +self.Length)`를 부르는 것과 같은 자리에서 같이 트리거되면 됨. + +**`:List()`가 마운트 이후에 불리는 경우 — `self._mounted`면 즉시 활성화 +(확정)**: 마운트는 1회성 이벤트라, `:List()`가 마운트보다 늦게 호출되면 +그 이벤트를 기다리는 방식으론 영영 활성화가 안 됨 — `:List()`가 +`self._mounted`를 확인해서 이미 참이면 그 자리에서 바로 +`activateList(self, self._mountedInst)`를 호출(마운트 시점에 `inst`를 +`self._mountedInst`로 같이 저장해둠). CRUD와의 상호배타 가드(`self._listed`)와 +같은 자리에서 자연스럽게 처리됨 — 호출 순서에 대한 새 제약을 추가하지 않음. + +**canExecute와 "등록 즉시 1회 실행"의 관계 — 초기 실행은 게이팅과 무관하게 +무조건 일어남(사용자 확인)**: `data:Observer(fn)`가 등록되는 순간 +(`bindLifetime` 호출 *이전*) `fn`이 이미 한 번 동기 실행됨(Observer 자체의 +"등록 즉시 1회 실행" 계약) — 이 시점엔 아직 `bindLifetime`이 `Subscribed`를 +세팅 전이라 `canExecute`를 물으면 거짓이겠지만, 애초에 최초 실행은 +`canExecute`로 게이팅되는 대상이 아니라서 상관없음. `bindLifetime`은 그 +직후에 걸려서 **이후의** 재실행(`data`가 다시 바뀔 때)만 게이팅 — +`Dispatch.setLength`의 `bindLifetime(inst,observer)` 다음 줄에 있는 +"등록 즉시 1회와 겹쳐도 무해"라는 주석과 정확히 같은 구조. + +**Destroy 이후 — "재실행 막기"와 "관측 자체를 관두기"가 새 메커니즘 없이 +한 번에 해결됨**: `inst`가 Destroy되면 `bindLifetime`의 `gcconn`(Roblox가 +Destroy 시 자동으로 끊는 Connection)이 죽어 `canExecute`가 거짓이 되고 +future 재실행이 no-op됨(위 "`state:Observer(fn)`" 절 원칙 재사용) — 그리고 +"이전 state를 계속 관측하는 것도 관둬야 한다"는 요구도, `gchold`가 +`Relate(inst)`(weak-keyed) 아래 있어서 `inst`가 죽으면 그 안에 강참조로 +붙잡혀 있던 Observer/클로저(`mounted`/`userdata`/`keyIndex`를 포함해)가 +전부 같이 GC 대상이 되는 것으로 공짜로 해결 — 명시적으로 구독을 끊는 +새 코드가 필요 없음, `base/lifecycle-pattern.md`의 "정리는 기본적으로 +GC에 위임" 원칙 그대로. + +**부수 관찰(설계 아님, 메모만)**: `bindLifetime`이 `Relate(inst)` 기반이라, +"이 `inst`에 지금 어떤 Slot/Observer가 붙어있는가"를 나중에 weak하게 +역조회하는 것도 같은 저장소로 가능해 보임(quad-debug의 "무엇이 무엇에 +연결됐는가" 그래프와 맞닿을 수 있음) — 지금 설계할 필요는 없음, 필요성이 +확인되면 그때. + ### 왜 자유 함수/새 타입이 아닌가 처음엔 `List(data, updateFn, keyFn?) -> Slot` 같은 자유 함수(또는 `Slot`을 diff --git a/CLAUDE.md b/CLAUDE.md index e4fca9f..564957b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2259,3 +2259,50 @@ lifecycle-pattern.md`(`unbindLifetime` 추가 + `canBound`/`.Subscribed` **다음 세션이 할 일**: 안 바뀜(`ROADMAP.md` M0부터) — 이번 세션도 순수 설계 확정이라 M0 착수 우선순위 자체는 그대로. + +## 2026-08-09 일곱 번째 세션 — `Slot:List`의 `data:Observer(fn)` 구독도 +마운트 시점 lazy `bindLifetime`으로 확정 (Destroy 후 재실행 gap 해소) + +사용자가 "Slot이 마운트된 대상이 Destroy로 죽으면 `updateFn` 재실행이 +`canExecute`로 막히고 있는 게 맞냐"고 질문하며 시작 — 확인 결과 **두 메커니즘이 +다른 상태였음**: `Dispatch.setLength`(Length/Offset, 여섯 번째 세션 확정)는 +이미 정확히 그렇게 돼 있었지만(Slot 마운트 시점에 `bindLifetime(inst,observer)`), +`Slot:List`의 `data:Observer(fn)` 구독은 `:List()` 호출 그 자리에서 즉시 +만들어져(`inst`를 모르는 시점) `bindLifetime`이 전혀 안 걸려있던 실제 gap — +사용자가 정확히 캐치함. 사용자가 이어서 "실제로 Instance에 바인드되려 시도될 +때(=마운트 시점)로 구독 자체를 lazy하게 미루면 되지 않냐"고 제안, 검증 후 +확정. `base/slot-plan.md`(`:List`의 "구현"/"구독 시점" 절 재작성 + +"base/roblox 패키지 경계" 절 보강)/`ROADMAP.md`(M6)에 반영 완료: + +- **`Dispatch.setLength`가 이미 쓰던 패턴을 그대로 재사용, 새 메커니즘 + 없음.** `:List(data,updateFn,keyFn)`는 이제 설정만 저장하고 반환 — + 실제 `data:Observer(fn)` 구독과 최초 `reconcile`은 Slot 컨테이너 자신이 + 마운트되는 순간(`Dispatch/Slot.luau`의 `process(inst,k,self)`, `self._mounted`를 + 세팅하는 바로 그 자리)에 `activateList(self,inst)`가 수행. +- **`:List()`가 마운트 이후에 불리는 경우 — `self._mounted`면 즉시 활성화로 + 확정(사용자 확인, 세 가지 대안 중 1번).** 마운트는 1회성 이벤트라 순서가 + 뒤바뀌면 그 이벤트를 못 기다리므로, `:List()`가 `self._mounted`를 직접 + 확인해서 이미 참이면 그 자리에서 즉시 `activateList` — 호출 순서 제약을 + 새로 추가하지 않음. +- **canExecute와 "등록 즉시 1회 실행"의 관계를 사용자가 직접 짚어 확정**: + `data:Observer(fn)` 등록 시점(=`bindLifetime` 호출 *이전*)의 최초 1회 + 실행은 `canExecute`/`Subscribed` 게이팅과 무관하게 무조건 일어남 — 이 + 시점엔 아직 `Subscribed`가 안 세팅돼 `canExecute`를 물으면 거짓이겠지만, + 애초에 최초 실행은 게이팅 대상이 아니라서 상관없음(`Dispatch.setLength`가 + 이미 "등록 즉시 1회와 겹쳐도 무해"로 같은 구조를 갖고 있었음). `bindLifetime`은 + 등록 직후에 걸려 **이후** 재실행만 게이팅. +- **Destroy 이후 "재실행 막기"+"관측 자체를 관두기"가 새 코드 없이 한 번에 + 해결됨** — `inst` Destroy 시 `gcconn`이 죽어 `canExecute`가 거짓이 되고 + 향후 재실행이 no-op되는 동시에, `gchold`가 `Relate(inst)`(weak-keyed) + 아래 있어서 `inst`가 죽으면 그 안에 강참조로 잡혀있던 Observer/클로저 + (`mounted`/`userdata`/`keyIndex` 포함)가 전부 GC 대상이 됨 — 명시적 + 구독 해제 코드가 안 필요함, `lifecycle-pattern.md`의 "정리는 기본적으로 + GC에 위임" 원칙 그대로. +- **부수 관찰(메모만, 설계 아님)**: 사용자가 "`Relate`로 마운트된 대상을 + weak하게 구할 수도 있겠다"고 언급 — `bindLifetime`이 `Relate(inst)` 기반이라 + 나중에 "이 `inst`에 지금 뭐가 붙어있는가" 역조회가 같은 저장소로 가능해 + 보임, quad-debug 그래프 UX와 맞닿을 수 있음. 지금 설계 안 함, 필요성 + 확인되면 그때. + +**다음 세션이 할 일**: 안 바뀜(`ROADMAP.md` M0부터) — 이번 세션도 순수 +설계 확정이라 M0 착수 우선순위 자체는 그대로. diff --git a/ROADMAP.md b/ROADMAP.md index 023f66b..b850aeb 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -229,7 +229,12 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 기각(Slot 부모 자체가 Destroy되는 경로에선 이 훅이 전혀 안 불려서 절반만 동작, `retract`가 Destroy 시 안 불리는 것과 같은 이유). (2026-08-09 세 번째 세션 확정, - `base/slot-plan.md` "`Slot:List(...)`" 절) 구현 + `base/slot-plan.md` "`Slot:List(...)`" 절) 구현. + **`data:Observer(fn)` 구독은 `:List()` 호출 시점이 아니라 Slot + 마운트 시점까지 lazy — `Dispatch.setLength`와 같은 패턴으로 + `bindLifetime(inst,observer)`(마운트 이후 `:List()`가 불리면 + `self._mounted` 확인 후 즉시 활성화)** (2026-08-09 일곱 번째 세션, + `base/slot-plan.md` "`Slot:List(...)`"의 "구독 시점" 절) - [ ] base `Dispatch/Slot.luau`(추상 재조정, mount/unmount/reposition 3훅) + quad-roblox `Handlers/Slot.luau`(실제 Parent 조작 + reposition — `SetSiblingIndex` 또는 `LayoutOrder` 기반이면 no-op, 구현 선택)