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