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:
qwreey 2026-08-09 21:42:24 +09:00
parent 97c074ed93
commit 5836c2d12a
Signed by: qwreey
GPG key ID: D28DB79297A214BD
3 changed files with 145 additions and 9 deletions

View file

@ -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`

View file

@ -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 착수 우선순위 자체는 그대로.

View file

@ -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, 구현 선택)