decide(base): Slot:List의 data:Observer 구독을 마운트 시점 lazy bindLifetime으로 통일
Dispatch.setLength는 이미 마운트 시점에 bindLifetime을 걸었지만, Slot:List의 data:Observer(fn) 구독은 :List() 호출 시점(inst를 모르는 시점)에 즉시 생성되어 Destroy 후에도 재실행/관측을 멈출 방법이 없었음. :List()는 이제 설정만 저장하고, 실제 구독+최초 reconcile은 Slot 마운트 시점에 activateList로 수행 — 마운트 이후 :List() 호출 시 self._mounted로 즉시 활성화. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EYfAz3BsaTrmMM8hnzn6mj
This commit is contained in:
parent
97c074ed93
commit
5836c2d12a
3 changed files with 145 additions and 9 deletions
|
|
@ -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`을
|
||||
|
|
|
|||
47
CLAUDE.md
47
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 착수 우선순위 자체는 그대로.
|
||||
|
|
|
|||
|
|
@ -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, 구현 선택)
|
||||
|
|
|
|||
Loading…
Reference in a new issue