diff --git a/.claude/README.md b/.claude/README.md index 16ee4ea..4557bbe 100644 --- a/.claude/README.md +++ b/.claude/README.md @@ -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()` 제네릭), 키 기반 동적 컬렉션 재조정(`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(item, index: number, offset: Source, 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`), `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()` 제네릭), 키 기반 동적 컬렉션 재조정(`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(item, index: number, offset: Source, 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`), `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`/`Source`도 받음) 확정 — 새 메커니즘 아니라 `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` 포인터로 압축 | diff --git a/.claude/base/slot-plan.md b/.claude/base/slot-plan.md index 1abd0d5..005881e 100644 --- a/.claude/base/slot-plan.md +++ b/.claude/base/slot-plan.md @@ -93,6 +93,15 @@ InstanceChild.luau`. Slot은 "뮤터블 배열"을 다루고 이 핸들러는 " "Slot-in-Slot 중첩" 절 참고. 자기 참조 제네릭이 Luau에서 실제로 타입체크되는지는 다른 재귀 타입 케이스들과 같은 급으로 실측 필요 (`.claude/luau-test/`, M0/M6 착수 시 확인). + **[2026-08-11 일곱 번째 세션, 같은 세션 정정] raw 요소가 `State`/ + `Source`로 감싸져 있는 것도 허용 — `Slot:Add`가 받는 실제 타입은 + `T | State | Source`.** **구현은 새 메커니즘이 아니라 순수 + `:Single` sugar** — `isState(element)`면 그 자리에 내부적으로 + `Slot():Single(element)`(nested Slot)를 대신 삽입할 뿐이라, 언랩된 + 값이 `nil`이어도(`State`) 전혀 문제없음(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`/`Source`도 Slot 요소로 허용 (2026-08-11 일곱 번째 세션) + +**동기 — 사용자 제기**: Slot이 지금까지 `State`/`State`처럼 +State/Source로 감싼 값을 raw 요소로 못 받았는데, 이미 확정된 메커니즘만 +합성하면 새로 만들 것 없이 구현 가능하다는 지적. + +**확정**: `Slot:Add`(및 `Slot(initial)` 생성자 sugar)가 받는 `element`의 +타입은 `T | State | Source`(`T = Instance | Slot`, 위 +"Slot-in-Slot 중첩"의 자기 참조 제네릭과 합성) — 임의 깊이로 조합 가능. + +### [정정, 같은 세션 후속] 최초안(별도 position-keyed StoreBind 구독) 기각 — 순수 `:Single` sugar로 대체 + +최초에는 "`rawAdd`가 그 위치에 대해 `Dispatch/StoreBind.luau`류 재-dispatch +구독을 걸고, Length 기여도도 `state:Compute(...)`로 파생시켜 `setLength`에 +넘기는" 별도 메커니즘을 검토했으나 **사용자가 실제 문제를 지적해 기각**: + +1. **`State`(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`(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` 요소(`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 --[[ 리스트 헤더 — 항상 존재, 정체성만 가끔 바뀜 ]], + Slot():List(items, updateFn) --[[ 아이템 그룹 — 개수/순서가 동적 ]], +} +``` +헤더처럼 LayoutOrder(offset) 참여가 필요 없는 raw 요소는 그냥 +`State`으로 두고, 아이템처럼 개수가 변하며 형제 순서 보장이 필요한 +그룹은 `Slot():List(...)`로 감싸는 식으로 **한 Slot 안에서 자유롭게 섞어 +쓸 수 있음** — Slot-in-Slot 중첩(위 절)과 이 반응형 raw 요소가 서로 +독립적으로 조합됨을 보여주는 예. + +**실측 필요 — 새 항목 아님, 기존 실측 목록에 흡수**: `State`/`Slot` +자기 참조 제네릭 타입체크는 이미 M0/M6 실측 목록에 있던 것과 같은 급이라 +별도 항목 추가 안 함. + +### `:List`의 `index`도 nested-Slot 결과의 `.Length`만큼 건너뛰어야 함 (같은 세션 후속, 사용자 발견) + +Slot-in-Slot으로 `T = Instance | Slot`가 허용되면서, `: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`이 자체적으로 방어하는 패턴을 문서화. diff --git a/.claude/question.md b/.claude/question.md index 8c8cf02..37115e8 100644 --- a/.claude/question.md +++ b/.claude/question.md @@ -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`/`Source`도 요소로 받음 — + 새 메커니즘 아니라 내부적으로 `Slot():Single(element)`(updateFn 생략 + 시 identity)를 대신 삽입하는 순수 sugar로 확정(`updateFn`도 이때 + `Slot:Single(state, updateFn?)`로 선택 인자화). `base/slot-plan.md`의 + "반응형 raw 요소" 절. ### 1. 용어 정리 (사용자 요청, 진행 중) diff --git a/CLAUDE.md b/CLAUDE.md index a3885df..1698a61 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -3110,3 +3110,87 @@ CRUD 가드/`Slot:Single`/"Slot-in-Slot 중첩" 신규 절/`Slot.Length`), **다음 세션이 할 일**: 안 바뀜(`ROADMAP.md` M0부터, luau-test 결과 확인 우선) — 이번 세션도 순수 설계 확정이라 M0 착수 우선순위 자체는 그대로. `Slot`의 자기 참조 제네릭 실측이 luau-test 확인 목록에 새로 추가됨. + +## 2026-08-11 일곱 번째 세션 — 반응형 raw 요소: Slot이 `State`/`Source`도 +요소로 허용(`:Single` sugar로), `:List`의 index-스킵 보강 + +짧은 세션. 사용자가 "Slot이 `state`/`state`을 안 받는데, +`setLength`/`setOffsetSource`를 재사용하면 쉽게 구현 가능하지 않냐"고 +제기하며 시작 — 최초 검토안이 사용자의 즉각적인 반례로 기각되고 훨씬 +단순한 최종안으로 수렴한 단일 스레드라, 시행착오 없이 최종 확정만 +정리(경위는 아래 "기각된 최초안" 절에 압축 보존). + +**확정**: `Slot:Add`(및 `Slot(initial)` 생성자 sugar)가 받는 `element`의 +타입이 `T | State | Source`(`T = Instance | Slot`, 여섯 +번째 세션에 확정된 자기 참조 제네릭과 합성)로 확장 — 임의 깊이 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`(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` 요소는 결국 "`updateFn` 생략한 +`:Single`"일 뿐이고, `:Single`에 `updateFn`을 직접 주면 `prev`/`userdata` +patch-reuse + `offset` 접근이 되는 상위 호환 — 둘은 대체 관계가 아니라 +같은 메커니즘의 다른 `updateFn`. + +**`:Single`이 애초에 생긴 이유가 정확히 이 offset 접근**(사용자 직접 +확인): "`Single`은 정확히는 `updateFn`에서 렌더할 때 `offset`이 필요해서 +만들어진 것" — raw `State` 요소(identity `updateFn`)는 값이 바뀔 +때마다 이전 mount를 통째로 버리고 새로 만드는 coarse swap(quad가 `prev` +재사용을 안 해줌)인 반면, `updateFn`을 직접 지정한 `:Single`은 같은 +Instance를 유지하며 속성만 patch하는 fine-grained 갱신 + `offset`을 +`updateFn`에 직접 전달. 사용자가 제시한 조합 예시(`Slot{ State +--[[리스트헤더]], Slot():List() --[[아이템]] }`)를 문서에 그대로 반영 — +LayoutOrder(offset) 참여가 필요 없는 요소는 raw `State`로 가볍게, +개수/순서가 동적이라 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 착수 우선순위 자체는 그대로. diff --git a/ROADMAP.md b/ROADMAP.md index 1590802..9243e7f 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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`, 자기 참조 제네릭은 실측 필요). `Dispatch.setLength`/`setOffsetSource`를 물리 inst 대신 **Slot @@ -330,6 +332,29 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 원칙과 같은 카테고리의 위반으로 **명시적 UB 명명**(방어 로직 없음, 기존 "일반적 재진입 방어 안 함" 원칙과 정합). `offset`/`sum`은 0-based 개수, `index`는 1-based Lua 관례라는 것도 명시. +- [x] **반응형 raw 요소 — `State`/`Source`도 Slot 요소로 허용** + (2026-08-11 일곱 번째 세션, 같은 세션에 정정) — `Slot:Add`가 받는 + 실제 타입은 `T | State | Source`(임의 깊이 조합 가능). + **[정정] 최초 검토한 "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` 요소(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