decide(slot): reactive raw elements via Slot:Single sugar, :List index skip fix

Slot:Add now accepts State<T>/Source<T> elements directly, arbitrarily
nested. Implemented as pure sugar (isState(element) -> internally
Slot():Single(element)) rather than a new position-keyed StoreBind
mechanism, since the latter would reintroduce array-part None, Length
special-casing, and Move/Swap index-subscription sync bugs that :List's
key-based design specifically avoids. Slot:Single's updateFn is now
optional (defaults to identity) to support this sugar. Also fixes
:List's reconcile to skip the compact index by a nested-Slot result's
.Length instead of a flat +1, so multi-root list items don't produce
overlapping LayoutOrder ranges for subsequent siblings.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017ymPLYAQmEYnTrnrdAwxpU
This commit is contained in:
qwreey 2026-08-11 14:25:19 +09:00
parent cebef6d973
commit 6ea3afa76a
Signed by: qwreey
GPG key ID: D28DB79297A214BD
5 changed files with 277 additions and 6 deletions

View file

@ -31,7 +31,7 @@
| `store-semantics.md` | Store는 부작용 허용이 기본. State는 Store 위의 조합 가능한 캐시 레이어로 실제로 필요함(2026-08-04 정정) — 온톨로지 핵심 메커니즘은 2026-08-04 2차 라운드에서 확정, 최신 상세는 `base/bind-system-plan.md` |
| `bind-system-plan.md` | pluggable key/value 핸들러 레지스트리 — `process`/`retract` 디스패치 모델, Ref, Store/State/Source 온톨로지 + 인체공학 질문 전부 확정. 디스패치 엔진은 `quad-base`가 인터페이스로 소유(2026-08-04 5차 라운드). **[2026-08-11 세션, 여섯 번째]** `Dispatch.setLength`/`setOffsetSource`의 owner 키가 물리 Instance로 한정될 필요 없음을 명시(Slot-in-Slot 재귀의 근거) — 같은 절 `recompute`의 off-by-one 버그 발견·수정(`offset`이 자기 자신을 포함해 누적되던 것), 재진입 방지 가드는 검토 후 기각(`Source⊇State` 단방향 원칙과 같은 카테고리의 UB로 명명, 각 Slot이 독립 `bk`를 가져 nesting만으로는 재진입 경로 자체가 없음을 확인) |
| `module-lifecycle-plan.md` | 프로바이더 패턴, bind/store 구현 책임 분리 — 확정 |
| `slot-plan.md` | 뮤터블 자식 배열, 엄격한 단일 마운트 소유권, 재마운트 시 throw, base/roblox 패키지 경계까지 확정. **[2026-08-09 세 번째 세션]** `Add`/`Remove`/`Extract`/`Clear`/`Move`/`Swap` CRUD(복잡도 표기 포함), `isMounted` 이중 추적 분리, 요소 타입 제약(`nil`/`None`/핸들러 계층 값 금지, `Slot<T>()` 제네릭), 키 기반 동적 컬렉션 재조정(`Slot:List(data, updateFn, keyFn?)`)까지 전부 확정 통합, base/roblox 경계에 reposition 훅 추가. **[2026-08-09 열한 번째 세션, 중간검토]** CRUD 식별 기준을 element 레퍼런스에서 인덱스 기준으로 재정정(`Remove(index)`/`Extract(index, newElement?)`/`Move(oldIndex, newIndex)`), `ExtractAll`/`Get`/`IndexOf` 신설. **[2026-08-11 세션]** `updateFn<UD>(item, index: number, offset: Source<number>, prev: T?, userdata: UD?): (T|nil, UD?)`로 시그니처 확정(`Slot.Offset`도 `Length`처럼 공개 필드로 신설) — `LayoutOrder` 등은 Slot이 자동으로 안 세팅, `index`/`offset` raw 값만 전달하고 실제 반영은 `updateFn`이 "버림/다시 그림/source만 갱신" 세 갈래로 직접 처리(재사용 Source에 미리 `Set` 후 결국 다시 그리면 무의미한 연산이 되므로). **[2026-08-11 세션, 여섯 번째]** `Slot:Single(state, updateFn)` 확정(`:List` 위의 순수 sugar) — Slot-in-Slot 중첩도 확정, 요소 타입 제약에서 `Slot` 배제 해제(`T = Instance | Slot<Instance>`), `Dispatch.setLength`/`setOffsetSource`를 Slot 자신을 owner 키로 재사용하는 재귀 `attachSlot`(새 프리미티브 없음), 파괴는 재귀 `Clear()` 대신 flat `destroySlotTree`+명시적 `unbindLifetime`. `Slot(initial?: {T})` 생성자 부활(순수 `:Add` sugar) + `_crudUsed`↔`_listed` 상호 배타 가드 신설. `base/bind-system-plan.md``recompute` off-by-one 버그도 이 세션에 같이 수정됨 |
| `slot-plan.md` | 뮤터블 자식 배열, 엄격한 단일 마운트 소유권, 재마운트 시 throw, base/roblox 패키지 경계까지 확정. **[2026-08-09 세 번째 세션]** `Add`/`Remove`/`Extract`/`Clear`/`Move`/`Swap` CRUD(복잡도 표기 포함), `isMounted` 이중 추적 분리, 요소 타입 제약(`nil`/`None`/핸들러 계층 값 금지, `Slot<T>()` 제네릭), 키 기반 동적 컬렉션 재조정(`Slot:List(data, updateFn, keyFn?)`)까지 전부 확정 통합, base/roblox 경계에 reposition 훅 추가. **[2026-08-09 열한 번째 세션, 중간검토]** CRUD 식별 기준을 element 레퍼런스에서 인덱스 기준으로 재정정(`Remove(index)`/`Extract(index, newElement?)`/`Move(oldIndex, newIndex)`), `ExtractAll`/`Get`/`IndexOf` 신설. **[2026-08-11 세션]** `updateFn<UD>(item, index: number, offset: Source<number>, prev: T?, userdata: UD?): (T|nil, UD?)`로 시그니처 확정(`Slot.Offset`도 `Length`처럼 공개 필드로 신설) — `LayoutOrder` 등은 Slot이 자동으로 안 세팅, `index`/`offset` raw 값만 전달하고 실제 반영은 `updateFn`이 "버림/다시 그림/source만 갱신" 세 갈래로 직접 처리(재사용 Source에 미리 `Set` 후 결국 다시 그리면 무의미한 연산이 되므로). **[2026-08-11 세션, 여섯 번째]** `Slot:Single(state, updateFn)` 확정(`:List` 위의 순수 sugar) — Slot-in-Slot 중첩도 확정, 요소 타입 제약에서 `Slot` 배제 해제(`T = Instance | Slot<Instance>`), `Dispatch.setLength`/`setOffsetSource`를 Slot 자신을 owner 키로 재사용하는 재귀 `attachSlot`(새 프리미티브 없음), 파괴는 재귀 `Clear()` 대신 flat `destroySlotTree`+명시적 `unbindLifetime`. `Slot(initial?: {T})` 생성자 부활(순수 `:Add` sugar) + `_crudUsed`↔`_listed` 상호 배타 가드 신설. `base/bind-system-plan.md``recompute` off-by-one 버그도 이 세션에 같이 수정됨. **[2026-08-11 세션, 일곱 번째]** 반응형 raw 요소(`Slot:Add`가 `State<T>`/`Source<T>`도 받음) 확정 — 새 메커니즘 아니라 `isState(element)`면 내부적으로 `Slot():Single(element)`(nested Slot)를 대신 삽입하는 순수 sugar(최초 검토했던 별도 position-keyed StoreBind 구독 안은 `None`/Length/Move-Swap 문제로 기각). `Slot:Single(state, updateFn?)``updateFn` 선택 인자화(기본값 identity)로 이 sugar를 지지. `:List``reconcile`도 nested-Slot을 반환하는 아이템의 `.Length`만큼 다음 형제 `index`가 건너뛰도록 `pos` 커밋 공식 수정 |
| `modifier-plan.md` | Modifier는 런타임 plug 아닌 정적 merge, immutable+clone 기반 체이닝 — 메커니즘 확정. **[2026-08-07 다섯 번째 세션 추가]** `:Apply(factory)` 팩토리 체이닝, `Overridden`(구 `Merge`→`Override`, 2026-08-08 세션에서 이름까지 확정) 값 결합+성능 기준, `:Peek`/`isState` 필드 읽기까지 전부 확정(`Peek`/`isState`는 이름만 용어 정리 라운드까지 잠정) |
| `purity-and-effects-plan.md` | 컴포넌트 "순수성"이 아니라 "이식성" 문제로 재정의 — 문서 경고 수준으로 확정 |
| `component-composition-plan.md` | 컴포넌트=플레인 함수, State/Source 읽기·쓰기 경계, Source가 State를 구조적으로 만족 — modifier/Ref 컴포넌트 경계 통과까지 전부 확정, 남은 건 API 이름뿐. **[2026-08-07 정리]** 폐기된 `StoreSource` 프록시 설계로의 역전 이력은 본문에서 빼고 `archive/store-source-proxy-reversed.md` 포인터로 압축 |

View file

@ -93,6 +93,15 @@ InstanceChild.luau`. Slot은 "뮤터블 배열"을 다루고 이 핸들러는 "
"Slot-in-Slot 중첩" 절 참고. 자기 참조 제네릭이 Luau에서 실제로
타입체크되는지는 다른 재귀 타입 케이스들과 같은 급으로 실측 필요
(`.claude/luau-test/`, M0/M6 착수 시 확인).
**[2026-08-11 일곱 번째 세션, 같은 세션 정정] raw 요소가 `State<T>`/
`Source<T>`로 감싸져 있는 것도 허용 — `Slot:Add`가 받는 실제 타입은
`T | State<T> | Source<T>`.** **구현은 새 메커니즘이 아니라 순수
`:Single` sugar** — `isState(element)`면 그 자리에 내부적으로
`Slot():Single(element)`(nested Slot)를 대신 삽입할 뿐이라, 언랩된
값이 `nil`이어도(`State<T?>`) 전혀 문제없음(raw 직접 전달 요소에만
여전히 non-nil 요구 적용, 핸들러 계층 값 금지는 그대로 상속) — 상세는
맨 아래 "반응형 raw 요소" 절 참고, 최초 검토했던 "position-keyed
StoreBind 구독" 안은 기각.
## 핵심 제약: 소유권 귀속과 단일 마운트
@ -718,7 +727,11 @@ function activateList(self, inst)
if result == None then result = nil end -- 편의: None도 nil과 동일 취급
if result ~= nil then
pos = candidateIndex -- 실제로 살아남았을 때만 커밋
-- [2026-08-11 일곱 번째 세션] result가 nested Slot이면 그
-- .Length만큼 건너뛴다 — 다음 형제의 index가 이 아이템이
-- 실제로 차지하는 물리적 개수를 반영해야 함(아래 "index도
-- nested-Slot 결과의 Length만큼 건너뛰어야 함" 절 참고)
pos = candidateIndex - 1 + (isSlot(result) and result.Length:Get() or 1)
end
if result ~= prev then
@ -938,13 +951,17 @@ UI에 직접 관측, (2) `Dispatch.setLength(inst, i, slot.Length)`가 형제
넣는 것) 자식을 추가/제거하면 `Length`/형제 순서 계산이 그 변화를 몰라
조용히 어긋남 — 별도 방어 로직 없음, 문서 경고로만 남김.
## `Slot:Single(state, updateFn)` — 확정 (2026-08-11 세션, `:List` 위의 순수 sugar)
## `Slot:Single(state, updateFn?)` — 확정 (2026-08-11 세션, `:List` 위의 순수 sugar)
기존 "백로그, 미착수"에서 실제 설계까지 완료됨 — 새 reconcile 로직
없이 **`:List`를 정확히 0/1개짜리 배열로 감싸는 sugar**:
```lua
local function identityUpdateFn(item) return item end
function Slot:Single(state, updateFn)
updateFn = updateFn or identityUpdateFn -- [2026-08-11 일곱 번째 세션] 기본값 추가
local data = isState(state)
and state:Compute(function(v) return v == nil and {} or { v } end)
or (state == nil and {} or { state })
@ -955,6 +972,12 @@ function Slot:Single(state, updateFn)
end
```
**`updateFn` 기본값(identity) — [2026-08-11 일곱 번째 세션] 추가.** 반응형
raw 요소(`Slot:Add(state)`, 아래 "반응형 raw 요소" 절)가 `Slot():Single(state)`
sugar로 재정의되면서, `updateFn` 생략이 유효해야 그 sugar가 성립함 —
생략 시 `prev`/`userdata`/`offset`을 전혀 안 쓰고 매번 `item`을 그대로
반환(= 정체성이 바뀔 때마다 항상 다시 그리는 coarse swap).
- **`index`를 안 주는 이유**: Single은 형제가 자기 하나뿐이라 `index`
항상 상수(1 또는 존재 안 함)라 의미가 없음 — 나머지(`offset`/`prev`/
`userdata`, 세 갈래 반환 규칙)는 `:List`와 100% 동일 규칙 재사용.
@ -1126,3 +1149,137 @@ Roblox뿐 아니라 web에도 그대로 필요.
`index`는 1-based Lua 배열 관례 — `index + offset` 공식이 이 둘을
의도적으로 섞는 것. 상세는 `base/bind-system-plan.md`의 "0-based
개수" 절 참고.
## 반응형 raw 요소 — `State<T>`/`Source<T>`도 Slot 요소로 허용 (2026-08-11 일곱 번째 세션)
**동기 — 사용자 제기**: Slot이 지금까지 `State<Frame>`/`State<Slot>`처럼
State/Source로 감싼 값을 raw 요소로 못 받았는데, 이미 확정된 메커니즘만
합성하면 새로 만들 것 없이 구현 가능하다는 지적.
**확정**: `Slot:Add`(및 `Slot(initial)` 생성자 sugar)가 받는 `element`
타입은 `T | State<T> | Source<T>`(`T = Instance | Slot<Instance>`, 위
"Slot-in-Slot 중첩"의 자기 참조 제네릭과 합성) — 임의 깊이로 조합 가능.
### [정정, 같은 세션 후속] 최초안(별도 position-keyed StoreBind 구독) 기각 — 순수 `:Single` sugar로 대체
최초에는 "`rawAdd`가 그 위치에 대해 `Dispatch/StoreBind.luau`류 재-dispatch
구독을 걸고, Length 기여도도 `state:Compute(...)`로 파생시켜 `setLength`
넘기는" 별도 메커니즘을 검토했으나 **사용자가 실제 문제를 지적해 기각**:
1. **`State<T?>`(nilable)를 지원하려면 "가끔 없음"을 표현해야 하는데,
`_elements` 배열에 직접 `None`을 넣는 것 말고 방법이 없어져서 배열 파트
`None`을 다시 끌어들이게 됨.**
2. **그러면 `Length` 계산도 이 케이스를 따로 알아야 함** — "요소별
기여도의 합"이라는 기존 공식이 예외를 갖게 됨.
3. **`Move`/`Swap`이 인덱스를 재배치할 때마다 그 위치에 물린 구독도 같이
옮겨야 하는 인덱스-구독 동기화 부담이 새로 생김** — 이건 정확히
`:List`가 element가 아니라 `key` 기준으로 설계된 이유(위 "CRUD API
확정" 절)와 정면으로 부딪히는 회귀.
**올바른 구현 — 새 메커니즘 전혀 없이 순수 sugar**: `element`
`isState(element)`(State/Source 둘 다 포함하는 기존 집합 판별,
2026-08-06 후속 세션 확정)면, 그 자리에 **내부적으로 새로 만든 `Slot():
Single(element)`을 대신 삽입** — raw 반응형 요소는 전부 Slot-in-Slot
중첩(위 절) 위에 얹힌 `:Single`의 얇은 sugar일 뿐이다:
```lua
function Slot:Add(element, index)
if isState(element) then
local sub = Slot()
sub:Single(element) -- updateFn 생략 시 identity 기본값(아래 참고)
element = sub
end
return rawAdd(self, element, index)
end
```
**`:Single`의 `updateFn`을 선택 인자로 완화 — 기본값은 identity.** 이
sugar가 성립하려면 `:Single(state)`(updateFn 생략)이 유효해야 함 —
`Slot:Single(state, updateFn?)`, 생략 시 `function(item) return item end`.
**이게 최초안의 세 문제를 전부 없애는 이유**:
- **`_elements``None`이 절대 안 들어감** — 바깥 Slot 입장에서 이
위치의 값은 항상 `sub`(안정적인 Slot 레퍼런스)고, "지금 진짜 뭔가
마운트돼 있는가"는 `sub` 내부(`:Single`이 감싼 `:List`의 0/1개 데이터)로
완전히 옮겨감 — `sub`가 비어있는 것과 `_elements[index] == None`
전혀 다른 것(전자는 이미 "빈 Slot"이라는 정상 상태, Slot-in-Slot
자체가 처음부터 이 경우를 지원).
- **Length 계산도 새로 손댈 것 없음**`Slot.Length`가 이미 "요소별
기여도의 합"(nested Slot=`.Length`)으로 정의돼 있어서, 비어있는
`sub`가 자동으로 0을 기여함(Slot-in-Slot 중첩 절의 기존 recompute
그대로).
- **Add/Remove/Move/Swap도 전부 기존 계약 그대로**`_elements[index]`
항상 안정적인 `sub` 레퍼런스라, `Remove(index)`는 기존
`destroySlotTree``sub` 전체를 정리하면 끝, `Move`/`Swap`도 `sub`
레퍼런스 하나를 옮기는 것뿐이라 인덱스-구독 동기화 문제 자체가 없음
(`:List`의 reconcile은 `key` 기준으로 독립 동작, `sub`가 바깥에서
어느 인덱스에 있든 상관 안 함).
- **`State<T?>`(nilable)도 특별 취급 없이 그냥 됨** — `:Single`이 이미
`v == nil and {} or {v}`로 nil을 "빈 리스트"로 흡수하므로, raw 직접
전달 요소(`Add(element)`, State로 안 감싼 경우)에만 여전히 non-nil이
요구되고, `State`/`Source`로 감싼 값은 내부적으로 nilable이어도 아무
문제 없음 — 위 "요소 타입 제약" 절의 nil/None 금지는 **State/Source로
감싸지 않은 raw 값에만 적용되는 규칙으로 범위가 좁혀짐**.
**"coarse swap"은 `updateFn = identity`가 만드는 결과일 뿐, 별도
메커니즘의 산물이 아님** — 기본 `updateFn`(`function(item) return item
end`)이 `prev`/`userdata`를 완전히 무시하므로 매 사이클 `result ~= prev`
거의 항상 성립해 실질적으로 항상 다시 그리지만, 이건 `:List`/`:Single`의
reconcile이 원래 갖고 있는 세 갈래(버림/다시 그림/source만 갱신) 중
"다시 그림"만 계속 타는 특수 케이스일 뿐 — 코드 레벨의 별도 경로가
아니다. patch-reuse가 필요하면 사용자가 직접 `Slot():Single(state,
myUpdateFn)`을 불러 `myUpdateFn` 안에서 `prev`/`userdata`를 활용하면 됨.
**`:Single`/`:List`와의 관계 — 대체가 아니라 굵기(granularity)가 다름**:
- **raw `State<T>` 요소(`Add(state)`)**: `updateFn` 기본값(identity)의
`:Single` — coarse swap, `prev` 재사용 없음.
- **`Slot():Single(state, updateFn)`**: `updateFn`을 직접 지정해
`prev`/`userdata`로 patch-reuse + `offset` 접근까지 원할 때 — **`:Single`
애초에 생긴 이유가 정확히 이 offset 접근**(`updateFn`이 LayoutOrder류를
계산하려면 offset이 필요한데, raw 요소 sugar의 기본 identity
`updateFn`은 이걸 안 씀).
- 둘은 같은 메커니즘 위의 다른 `updateFn`일 뿐이라 언제든 서로
전환 가능(raw로 시작했다가 patch-reuse가 필요해지면 그냥 `updateFn`
명시하는 `:Single` 호출로 바꾸면 됨).
**조합 예시(사용자 제시)**:
```lua
return Slot {
State<Frame> --[[ 리스트 헤더 — 항상 존재, 정체성만 가끔 바뀜 ]],
Slot():List(items, updateFn) --[[ 아이템 그룹 — 개수/순서가 동적 ]],
}
```
헤더처럼 LayoutOrder(offset) 참여가 필요 없는 raw 요소는 그냥
`State<Frame>`으로 두고, 아이템처럼 개수가 변하며 형제 순서 보장이 필요한
그룹은 `Slot():List(...)`로 감싸는 식으로 **한 Slot 안에서 자유롭게 섞어
쓸 수 있음** — Slot-in-Slot 중첩(위 절)과 이 반응형 raw 요소가 서로
독립적으로 조합됨을 보여주는 예.
**실측 필요 — 새 항목 아님, 기존 실측 목록에 흡수**: `State<T>`/`Slot<T>`
자기 참조 제네릭 타입체크는 이미 M0/M6 실측 목록에 있던 것과 같은 급이라
별도 항목 추가 안 함.
### `:List``index`도 nested-Slot 결과의 `.Length`만큼 건너뛰어야 함 (같은 세션 후속, 사용자 발견)
Slot-in-Slot으로 `T = Instance | Slot<Instance>`가 허용되면서, `:List`
`updateFn``result`로 nested Slot(멀티루트 컴포넌트 결과 등)을 반환할
수도 있음 — 이때 그 아이템은 물리적으로 1개가 아니라 `result.Length`개의
실제 요소를 차지함. 원래 `candidateIndex = pos + 1`(고정 +1)로만 커밋했다면,
`updateFn``index`를 LayoutOrder 계산에 쓰는데 어떤 아이템이 nested
Slot(Length=3)을 반환할 때 다음 아이템의 `index`가 3만큼 안 건너뛰고
1만 건너뛰어 물리적으로 겹치는 LayoutOrder 범위가 나옴 — **의도된 동작으로
확정, 위 "구현" 절의 `reconcile` 의사코드에 이미 반영됨**(`pos = candidateIndex
- 1 + (isSlot(result) and result.Length:Get() or 1)`).
**남는 캐비엇 — `index`는 여전히 raw 스냅샷이라, nested Slot의 Length가
outer `:List`의 reconcile 없이 나중에 바뀌면 그 이후 형제들의 `index`
갱신 안 됨.** 이건 새로 생기는 문제가 아니라 이미 확정된 "`index`가
State가 아니라 raw number"라는 설계(위 "왜 `LayoutOrder`를 Slot이 대신
안 해주는가" 절) 원칙의 당연한 연장 — `index`는 outer `:List`가 가장
최근에 reconcile됐을 때의 스냅샷일 뿐, 실시간 정확성이 필요하면
`updateFn`이 직접 `result.Length`를 구독해서 스스로 처리해야 함(이미
"`index`/`offset`을 실제 프로퍼티에 어떻게 반영할지는 전적으로
`updateFn` 몫"이라는 기존 원칙과 정합). 실사용에서 이 케이스(리스트
아이템이 각자 멀티루트를 반환하며 그 개수가 outer 리스트 reconcile
없이 동적으로 바뀜)는 드물 것으로 예상 — 실제로 문제가 되면 그때
`updateFn`이 자체적으로 방어하는 패턴을 문서화.

View file

@ -40,7 +40,12 @@ context-rejected.md`. 아래는 그중 **아직 실제로 열려있는 것만**
slot-plan.md`의 "`Slot:Single(...)`" 절. 같은 세션에 **Slot-in-Slot
중첩도 확정**(요소 타입 제약에서 `Slot` 배제 해제, `Dispatch.setLength`/
`setOffsetSource`를 Slot 자신을 owner 키로 재사용하는 재귀 `attachSlot`) —
`base/slot-plan.md`의 "Slot-in-Slot 중첩" 절.
`base/slot-plan.md`의 "Slot-in-Slot 중첩" 절. **[해소됨, 2026-08-11
일곱 번째 세션]** `Slot:Add``State<T>`/`Source<T>`도 요소로 받음 —
새 메커니즘 아니라 내부적으로 `Slot():Single(element)`(updateFn 생략
시 identity)를 대신 삽입하는 순수 sugar로 확정(`updateFn`도 이때
`Slot:Single(state, updateFn?)`로 선택 인자화). `base/slot-plan.md`
"반응형 raw 요소" 절.
### 1. 용어 정리 (사용자 요청, 진행 중)

View file

@ -3110,3 +3110,87 @@ CRUD 가드/`Slot:Single`/"Slot-in-Slot 중첩" 신규 절/`Slot.Length`),
**다음 세션이 할 일**: 안 바뀜(`ROADMAP.md` M0부터, luau-test 결과 확인
우선) — 이번 세션도 순수 설계 확정이라 M0 착수 우선순위 자체는 그대로.
`Slot<T>`의 자기 참조 제네릭 실측이 luau-test 확인 목록에 새로 추가됨.
## 2026-08-11 일곱 번째 세션 — 반응형 raw 요소: Slot이 `State<T>`/`Source<T>`도
요소로 허용(`:Single` sugar로), `:List`의 index-스킵 보강
짧은 세션. 사용자가 "Slot이 `state<slot>`/`state<frame>`을 안 받는데,
`setLength`/`setOffsetSource`를 재사용하면 쉽게 구현 가능하지 않냐"고
제기하며 시작 — 최초 검토안이 사용자의 즉각적인 반례로 기각되고 훨씬
단순한 최종안으로 수렴한 단일 스레드라, 시행착오 없이 최종 확정만
정리(경위는 아래 "기각된 최초안" 절에 압축 보존).
**확정**: `Slot:Add`(및 `Slot(initial)` 생성자 sugar)가 받는 `element`
타입이 `T | State<T> | Source<T>`(`T = Instance | Slot<Instance>`, 여섯
번째 세션에 확정된 자기 참조 제네릭과 합성)로 확장 — 임의 깊이 nesting
가능(사용자: "이게 얼마나 nesting되는지는 유저 마음, 다 처리가 가능함").
**구현은 새 메커니즘이 아니라 순수 `:Single` sugar**: `isState(element)`
그 자리에 내부적으로 `Slot():Single(element)`(nested Slot, `updateFn`
생략 시 identity 기본값)를 대신 삽입 —
```lua
function Slot:Add(element, index)
if isState(element) then
local sub = Slot()
sub:Single(element) -- updateFn 생략 → identity 기본값
element = sub
end
return rawAdd(self, element, index)
end
```
이 sugar가 성립하도록 `Slot:Single(state, updateFn?)`도 같이 확정 —
`updateFn`을 선택 인자로 완화(기본값 `function(item) return item end`).
**기각된 최초안 — position-keyed StoreBind 구독 + `state:Compute`
파생한 Length.** 처음엔 `rawAdd`가 그 위치에 대해 별도 재-dispatch
구독을 걸고 Length 기여도도 `state:Compute(...)`로 파생시켜 `setLength`
넘기는 방식을 검토했으나, **사용자가 즉시 반례를 제시해 기각**: "state
언랩 시에 None 되는게 복잡함. 그럼 slot 사이에 None이 존재할 수 있게
되는 거 아님? Length 계산도 달라져야 하고, Add/Remove 동작성이 문제가
됨 — 사실상 Slot:Single이랑 정확히 같은 구현이 돼야 함." 검증 결과
정확함 — (1) `State<T?>`(nilable)를 지원하려면 `_elements``None`
다시 끌어들여야 함, (2) Length 계산에 예외가 생김, (3) `Move`/`Swap`이
인덱스를 옮길 때마다 그 위치의 구독도 같이 옮겨야 하는 인덱스-구독
동기화 부담이 생김 — **정확히 `:List`가 element가 아니라 `key` 기준으로
설계된 이유와 정면 충돌하는 회귀**였음.
**위 확정된 sugar가 이 세 문제를 전부 없애는 이유**: 바깥 `_elements`
항상 안정적인 `sub` Slot 레퍼런스만 있어 `None`이 안 들어가고, `sub`
비어있는 것 자체가 이미 지원되는 정상 상태(Length 0 자동 기여)이며,
Remove/Move/Swap도 그 `sub` 레퍼런스 하나만 다루면 끝이라 인덱스-구독
동기화가 필요 없음. raw `State<T>` 요소는 결국 "`updateFn` 생략한
`:Single`"일 뿐이고, `:Single``updateFn`을 직접 주면 `prev`/`userdata`
patch-reuse + `offset` 접근이 되는 상위 호환 — 둘은 대체 관계가 아니라
같은 메커니즘의 다른 `updateFn`.
**`:Single`이 애초에 생긴 이유가 정확히 이 offset 접근**(사용자 직접
확인): "`Single`은 정확히는 `updateFn`에서 렌더할 때 `offset`이 필요해서
만들어진 것" — raw `State<T>` 요소(identity `updateFn`)는 값이 바뀔
때마다 이전 mount를 통째로 버리고 새로 만드는 coarse swap(quad가 `prev`
재사용을 안 해줌)인 반면, `updateFn`을 직접 지정한 `:Single`은 같은
Instance를 유지하며 속성만 patch하는 fine-grained 갱신 + `offset`
`updateFn`에 직접 전달. 사용자가 제시한 조합 예시(`Slot{ State<Frame>
--[[리스트헤더]], Slot():List() --[[아이템]] }`)를 문서에 그대로 반영 —
LayoutOrder(offset) 참여가 필요 없는 요소는 raw `State<T>`로 가볍게,
개수/순서가 동적이라 offset이 필요한 그룹은 `Slot():List(...)`
감싸는 식으로 한 Slot 안에서 자유롭게 섞어 씀.
**부수 발견(사용자) — nested-Slot 결과를 반환하는 `:List` 아이템의
`index` 스킵.** "상위 입장에서의 인덱스는, Slot이 있으면 크게 건너뛰겠네
아마 의도된 동작이긴 할듯"라는 지적 — 검증 결과 맞음: `updateFn`
nested Slot(멀티루트 컴포넌트 결과 등, Length=N)을 반환하면 그 아이템은
물리적으로 1개가 아니라 N개를 차지하므로, `reconcile``pos` 커밋이
고정 `+1`이 아니라 `+result.Length`여야 다음 형제의 `index`가 LayoutOrder
계산에서 안 겹침 — `pos = candidateIndex - 1 + (isSlot(result) and
result.Length:Get() or 1)`로 수정, 의도된 동작으로 확정. 남는 캐비엇(그
Length가 outer `:List`의 reconcile 없이 나중에 바뀌면 `index`가 스냅샷인
채로 안 갱신됨)은 이미 확정된 "`index`는 raw, 실시간 반응은 `updateFn`
몫" 원칙의 당연한 연장이라 새 문제로 취급 안 함.
**반영된 파일**: `base/slot-plan.md`(요소 타입 제약 절 갱신, "반응형 raw
요소" 신규 절, `:Single``updateFn` 선택 인자화, `reconcile``pos`
커밋 공식 수정)/`ROADMAP.md`(M6)/`.claude/README.md`/`.claude/question.md`.
**다음 세션이 할 일**: 안 바뀜(`ROADMAP.md` M0부터, luau-test 결과 확인
우선) — 이번 세션도 순수 설계 확정이라 M0 착수 우선순위 자체는 그대로.

View file

@ -293,10 +293,12 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
- [ ] base `Dispatch/Slot.luau`(추상 재조정, mount/unmount/reposition 3훅) +
quad-roblox `Handlers/Slot.luau`(실제 Parent 조작 + reposition —
`SetSiblingIndex` 또는 `LayoutOrder` 기반이면 no-op, 구현 선택)
- [x] **`Slot:Single(state, updateFn)` 확정** — `:List`를 0/1개짜리
- [x] **`Slot:Single(state, updateFn?)` 확정** — `:List`를 0/1개짜리
배열로 감싸는 순수 sugar, `index` 없이 `offset`/`prev`/`userdata`만
전달, 고정 key로 `prev` 재사용 보장(2026-08-11 세션, `base/
slot-plan.md` "`Slot:Single`" 절).
slot-plan.md` "`Slot:Single`" 절). **[2026-08-11 일곱 번째 세션]**
`updateFn`이 선택 인자로 완화됨(기본값 identity) — 아래 반응형
raw 요소 항목 참고.
- [x] **Slot-in-Slot 중첩 확정** — 요소 타입 제약에서 `Slot` 배제 해제
(`T = Instance | Slot<Instance>`, 자기 참조 제네릭은 실측 필요).
`Dispatch.setLength`/`setOffsetSource`를 물리 inst 대신 **Slot
@ -330,6 +332,29 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
원칙과 같은 카테고리의 위반으로 **명시적 UB 명명**(방어 로직 없음,
기존 "일반적 재진입 방어 안 함" 원칙과 정합). `offset`/`sum`은
0-based 개수, `index`는 1-based Lua 관례라는 것도 명시.
- [x] **반응형 raw 요소 — `State<T>`/`Source<T>`도 Slot 요소로 허용**
(2026-08-11 일곱 번째 세션, 같은 세션에 정정) — `Slot:Add`가 받는
실제 타입은 `T | State<T> | Source<T>`(임의 깊이 조합 가능).
**[정정] 최초 검토한 "position-keyed StoreBind 구독 + Length를
Compute로 파생" 안은 기각**(nilable 지원하려면 배열 파트 `None`
다시 끌어들여야 하고, Length 계산에 예외가 생기고, `Move`/`Swap`이
인덱스-구독 동기화 부담을 짐 — `:List`가 element 아닌 `key` 기준인
이유와 정면 충돌) — **새 메커니즘 없이 순수 `:Single` sugar로
확정**: `isState(element)`면 그 자리에 내부적으로 `Slot():
Single(element)`(updateFn 생략 시 identity 기본값)를 대신 삽입.
`_elements``None`이 절대 안 들어감(비어있는 nested Slot이 자연히
Length 0 기여), raw 직접 전달 요소에만 여전히 non-nil 요구.
`:Single``updateFn`도 이 sugar가 성립하도록 선택 인자로 완화
(`Slot:Single(state, updateFn?)`, 기본값 identity). `:Single`/`:List`와는
대체 관계가 아니라 같은 메커니즘 위의 다른 `updateFn`일 뿐 — raw
`State<T>` 요소(identity)는 coarse swap, `updateFn` 직접 지정 시
`prev`/`userdata` patch-reuse + `offset` 접근(`:Single`이 애초에
생긴 이유). **부수 발견(사용자)**: `:List``reconcile`
nested-Slot 결과를 반환하는 아이템 다음 형제의 압축 `index`
그 결과의 `.Length`만큼 건너뛰도록 `pos` 커밋 공식도 같이 수정
(`pos = candidateIndex - 1 + (isSlot(result) and result.Length:Get()
or 1)`) — 안 그러면 멀티루트 아이템 다음 형제의 LayoutOrder가
겹침. `base/slot-plan.md` "반응형 raw 요소" 절.
## M7 — Modifier