decide(slot): Slot:List index/offset ownership moves to updateFn
- Slot no longer auto-binds LayoutOrder onto mounted elements (was magic/layering violation) - index (raw, compacted position) and offset (Slot.Offset, now a public field like Length) are passed to updateFn as plain values; actual property binding is entirely updateFn's responsibility via its own userdata-managed Source - updateFn(item, index, offset, prev, userdata) param order now mirrors return order (prev, userdata) instead of the reversed one - updateFn's three branches (discard/redraw/update-only) documented explicitly - only updateFn knows which applies, so deciding Set timing outside it wastes writes on soon-to-be-discarded Sources - fix reconcile bug: rawAdd/rawMove was using the raw data-array loop index instead of a compacted mounted-position counter, which could error once filtering was in play; introduced candidateIndex - add duplicate-key guard (seen[key] check, ~free) and explicit key/index terminology disambiguation (raw data index vs key vs updateFn's compacted index all get confused otherwise) - sync ROADMAP.md M6 and .claude/README.md summaries to match Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
b668109eaf
commit
895a17c212
5 changed files with 444 additions and 51 deletions
|
|
@ -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차 라운드) |
|
||||
| `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?)`, `keyFn` 생략 시 index 기본값, `updateFn<UD>(item, index, userdata, prev)`가 매 사이클 호출되며 `prev` 재사용/`nil` 반환으로 filter 지원, `Source` 생성은 `updateFn`이 `userdata`로 직접 관리, `userdata`는 GC-native 값만 허용·명시적 cleanup 필요한 값은 UB)까지 전부 확정 통합, base/roblox 경계에 reposition 훅 추가. **[2026-08-09 열한 번째 세션, 중간검토]** CRUD 식별 기준을 element 레퍼런스에서 인덱스 기준으로 재정정(`Remove(index)`/`Extract(index, newElement?)`/`Move(oldIndex, newIndex)`), `ExtractAll`/`Get`/`IndexOf` 신설 |
|
||||
| `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` 후 결국 다시 그리면 무의미한 연산이 되므로) |
|
||||
| `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` 포인터로 압축 |
|
||||
|
|
|
|||
|
|
@ -475,12 +475,24 @@ Dispatch.setOffsetSource(inst, i, offset: Source<number> | None)
|
|||
교체 시엔 이 Handler가 새 값으로 다시 `setLength`를 호출.
|
||||
- **`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 시스템 불필요).
|
||||
**실제 마운트를 하지 않는 위치는 `None`을 등록** — 순서 계산에
|
||||
하고, `recompute`가 그 자리에 값을 `:Set()`함. Slot이 매치되는 경우
|
||||
이 Source는 그 자리에서 `Slot.Offset` 필드로도 그대로 저장됨(아래
|
||||
참고) — 순수 숫자 누적합 계산이라 엔진 지식이 전혀 필요 없어서, 이
|
||||
등록 자체는 `quad-base`(`Dispatch/Slot.luau`)가 함. **[정정,
|
||||
2026-08-11 세션] 예전엔 이 Source를 "Handler가 자기 원소(들)의
|
||||
`LayoutOrder` 바인딩에 그대로 쓴다"고 서술했었는데 — 폐기.** Slot이
|
||||
마운트한 원소에 `LayoutOrder`를 자동으로 덮어쓰면 (a) 사용자가 그
|
||||
원소 자신의 프로퍼티로 `LayoutOrder`를 이미 지정해도 조용히 씹히는
|
||||
매직이 되고, (b) `LayoutOrder`는 애초에 Roblox 전용 프로퍼티라 그
|
||||
지식이 `Dispatch/Slot.luau`(엔진 무관) 층위로 새는 레이어링 위반이기도
|
||||
함. 이제 `Offset`은 `Slot.Offset`으로 공개 노출만 되고, 각 원소의
|
||||
`LayoutOrder`(또는 웹의 CSS `order`)를 실제로 계산해 세팅하는 건
|
||||
`updateFn`(또는 수동 Slot 사용자)의 몫 — `updateFn`은 `index`를 raw
|
||||
number로만 받고(`Slot.Length`/`item`과 같은 원칙, `:List`가 반응형을
|
||||
강제하지 않음), 반응형이 필요하면 자기 `userdata` 안에 직접 `Source`를
|
||||
만들어 `Frame { LayoutOrder = layoutOrder:With(offset):Compute(fn) }`처럼
|
||||
써넣으면 됨 — 새 메커니즘 불필요. 상세는 `base/slot-plan.md`의
|
||||
`Slot:List` 절 참고. **실제 마운트를 하지 않는 위치는 `None`을 등록** — 순서 계산에
|
||||
참여할 게 없다는 명시적 선언. 대상은 Ref/PreRef뿐 아니라 **그 배열
|
||||
위치의 값 자체가 `None`인 모든 경우**(예: `props.Ref or None` 관용구로
|
||||
캐우칭된 미전달 Ref, PreRef pre-pass가 소진시킨 슬롯 등) — `setLength`도
|
||||
|
|
@ -600,6 +612,15 @@ quad-web의 해당 Handler는 offset 변경 관측 시 아무것도 안 하는 n
|
|||
몫. `Offset`은 Dispatch가 `setOffsetSource`로 등록받아 `recompute`가
|
||||
채워주는 입력값, 순서 계산 전용 — 서로 다른 두 `Source<number>`.
|
||||
|
||||
**`Slot.Offset`도 `Slot.Length`와 마찬가지로 공개 필드(2026-08-11
|
||||
세션 명시화)** — Slot이 마운트되는 시점(`Dispatch/Slot.luau`가
|
||||
`setOffsetSource`를 등록하는 바로 그 자리)에 같은 Source 객체를
|
||||
`self.Offset`으로도 저장, 마운트 전엔 `nil`. 위 정정대로 이 값을
|
||||
`LayoutOrder` 등에 실제로 반영하는 건 Slot 자신이 하지 않으므로,
|
||||
`:List`의 `updateFn`이 이 값을 받아 쓰거나(아래 `base/slot-plan.md`
|
||||
참고) 수동 CRUD 사용자가 직접 `slot.Offset`을 읽어 자기 원소 프로퍼티를
|
||||
구성해야 함 — 아무것도 안 하면 그냥 `LayoutOrder`가 안 바뀔 뿐.
|
||||
|
||||
`base/slot-plan.md`의 "여러 Slot이 섞일 때 순서 보장" 절이 이 메커니즘으로
|
||||
해소됨 — 상세는 그 문서 참고.
|
||||
|
||||
|
|
|
|||
|
|
@ -343,29 +343,95 @@ Slot():List(data, updateFn, keyFn?) -> Slot -- self
|
|||
같은 트레이드오프라 새로 설명할 개념은 아님, 재정렬/중간 삽입이 실제로
|
||||
일어나는 목록엔 진짜 `keyFn`을 넘기라고 안내하는 정도로 충분.
|
||||
|
||||
**`key`의 타입 제약 — 없음, 사이클 간 안정성+유일성만 있으면 됨
|
||||
(2026-08-11 세션 명시화).** `key`는 그냥 `mounted`/`userdata`/`keyIndex`
|
||||
맵의 Lua 테이블 키로 쓰일 뿐이라 string/number/테이블 레퍼런스 등 뭐든
|
||||
가능 — 유일한 조건은 (1) 같은 논리적 item이면 사이클이 바뀌어도 항상
|
||||
같은 key(안정성), (2) 서로 다른 item이면 항상 다른 key(유일성). `keyFn`이
|
||||
`item`을 그대로 받으므로, `item`에 이미 안정적인 식별자 필드(흔히 문자열
|
||||
id)가 있으면 그걸 그대로 쓰면 됨 — 캐스케이드 갱신을 피하려고 새로
|
||||
뭔가 만들 필요 없음:
|
||||
|
||||
```lua
|
||||
slot:List(data, updateFn, function(item) return item.id end)
|
||||
```
|
||||
|
||||
재정렬/중간 삽입이 실제로 일어나는 목록엔 이 패턴을 기본 권장 관용구로
|
||||
문서화(콘텐츠 사이트 착수 시 반영 — `research/documentation-content-map.md`).
|
||||
|
||||
**같은 사이클 안에서 `keyFn`이 중복 key를 반환하면 즉시 `error`
|
||||
(2026-08-11 세션)** — `reconcile`이 어차피 `seen[key]`를 채우고 있으므로
|
||||
그 직전에 `if seen[key] then error(...) end` 확인 하나만 추가하면 거의
|
||||
공짜(아래 "구현" 절 코드 참고). 조용히 넘어가면 두 item이 `mounted`/
|
||||
`userdata`/`keyIndex`의 같은 슬롯을 다투게 돼 한쪽 item이 사라지거나
|
||||
뒤섞이는 조용한 버그가 되므로, 다른 Slot CRUD 에러 조건들과 같은
|
||||
fail-fast 톤으로 그 자리에서 막음 — `keyFn` 작성자(주로 위 `item.id`
|
||||
관용구를 안 쓰고 실수로 안 유일한 필드를 쓴 경우)에게 즉시 신호를 줌.
|
||||
|
||||
**이름 정정 — `renderFn` → `updateFn` (같은 세션 후속).** 아래 서술하는
|
||||
호출 계약이 "새 key가 나타났을 때 1회 렌더"에서 "매 사이클 재호출되어
|
||||
갱신 여부를 스스로 판단"으로 바뀌면서, "render"보다 "update"가 실제
|
||||
역할을 더 정확히 반영한다고 판단해 이름도 같이 바꿈.
|
||||
|
||||
> **용어 주의 — 세 가지 "위치/식별" 값을 혼동하지 말 것(2026-08-11 세션
|
||||
> 명시화)**: 아래 서술에 비슷한 이름의 값 세 개가 나오는데 전부 다름.
|
||||
> 1. **`keyFn(item, index)`의 `index`** — 원본 `data` 배열에서의 raw
|
||||
> 위치(`ipairs(items)`의 루프 인덱스 그대로), filter와 무관하게 항상
|
||||
> 원본 배열 기준.
|
||||
> 2. **`key`** — `keyFn`이 계산해 돌려주는 정체성 값. `mounted`/`userdata`
|
||||
> 맵의 실제 키이자 `:List`가 diff를 판단하는 기준 — item이 재정렬돼도
|
||||
> 같은 `key`면 같은 개체로 취급.
|
||||
> 3. **`updateFn(item, index, ...)`의 `index`** — `key`/위 1번과 전혀
|
||||
> 무관한 **압축된 마운트 위치**(filter로 마운트 안 된 item만큼
|
||||
> 당겨짐) — 순서/레이아웃(`LayoutOrder` 등) 계산 전용, **식별 목적으로
|
||||
> 쓰면 안 됨**(그건 `key`의 역할). 상세는 아래 `updateFn` 파라미터
|
||||
> 설명 참고.
|
||||
|
||||
- `data: {[K]:V} | State<{[K]:V}> | Source<{[K]:V}>` — plain이면 최초
|
||||
1회 배치만 하고 이후 추적 안 함(다시는 안 바뀌므로), State/Source면
|
||||
아래 메커니즘이 계속 동작. 기존 leaf 프로퍼티의 "리터럴 또는 State
|
||||
둘 다 받는" 폴리모픽 컨벤션 재사용.
|
||||
- `keyFn(item, index) -> key`(선택, 생략 시 `index`를 그대로 key로 사용) —
|
||||
아이템 값과 인덱스 둘 다 받음.
|
||||
- **`updateFn<UD = any>(item, index, userdata: UD?, prev: T?): (T | nil, UD?)`
|
||||
— 매 reconcile 사이클마다 모든 key에 대해 호출됨.** `:List`는 더 이상
|
||||
item을 위해 `Source`를 대신 만들어주지 않음(아래 "왜 `Source`를
|
||||
`:List`가 안 만드는가" 참고) — `item`/`index`는 매번 그 사이클의 raw
|
||||
현재값 그대로 넘어감, 반응형으로 쓸지는 `updateFn`이 알아서 결정.
|
||||
아이템 값과 인덱스 둘 다 받음. **이 `index`는 원본 `data` 배열 위치** —
|
||||
아래 `updateFn`의 `index`(압축된 마운트 위치)와 이름만 같고 값은 다름,
|
||||
위 "용어 주의" 참고.
|
||||
- **`updateFn<UD = any>(item, index: number, offset: Source<number>,
|
||||
prev: T?, userdata: UD?): (T | nil, UD?)` — 매 reconcile 사이클마다
|
||||
모든 key에 대해 호출됨.** `:List`는 더 이상 item을 위해 `Source`를
|
||||
대신 만들어주지 않음(아래 "왜 `Source`를 `:List`가 안 만드는가" 참고,
|
||||
2026-08-11 세션에 `index`도 같은 원칙으로 편입) — `item`/`index`는
|
||||
매번 그 사이클의 raw 현재값 그대로 넘어감, 반응형으로 쓸지는
|
||||
`updateFn`이 알아서 결정. **파라미터 순서는 반환값 순서(`T`류 먼저,
|
||||
`UD`류 나중)와 맞춤(2026-08-11 세션 정정)** — 원래 `userdata`가
|
||||
`prev`보다 앞이었는데, 반환은 `(result, ud)`라 파라미터도 `prev,
|
||||
userdata` 순서여야 "값이 안 바뀌면 그대로 반환"이 `return prev, ud`로
|
||||
자연스럽게 읽힘.
|
||||
- **`index: number`** — 이 key가 **지금 마운트되면(이 사이클에서
|
||||
`nil`을 반환하지만 않으면) 실제로 마운트된 요소들 사이에서** 몇
|
||||
번째를 차지하게 되는지(1-base), **raw number**(State 아님) — **`keyFn(item,
|
||||
index)`가 받는 raw `index`(원본 `data` 배열에서의 위치)와는 다른
|
||||
값**이니 혼동 주의. filter로 앞쪽 item이 마운트 안 되면 그만큼
|
||||
압축(compact)됨(예: 5개 중 2번째/4번째만 통과하면 그 둘의 `index`는
|
||||
1, 2). **`:List`는 이 값을 State로 감싸주지 않음** — `item`을 raw로
|
||||
넘기는 것과 완전히 같은 원칙(아래 "왜 `Source`를 `:List`가 안
|
||||
만드는가" 참고)이 `index`에도 그대로 적용된 것뿐: `updateFn`이
|
||||
`LayoutOrder` 같은 걸 반응형으로 유지하고 싶으면 **자기 `userdata`
|
||||
안에 직접 `Source`를 만들어 관리**해야 함, 원치 않으면(웹 백엔드처럼
|
||||
무시해도 되는 경우 등) 그냥 버려도 그만 — 아래 "왜 `LayoutOrder`를
|
||||
Slot이 대신 안 해주는가" 절의 예시 참고.
|
||||
- **`offset: Source<number>`** — 이 `Slot` 자신의 `Slot.Offset`을 그대로
|
||||
전달(모든 key가 같은 값을 공유) — 형제로 섞인 다른 Slot/정적 자식이
|
||||
기여한 개수의 누적합(`base/bind-system-plan.md`의 "Length/Offset" 절
|
||||
참고). `index`와 마찬가지로 실제 프로퍼티에 어떻게 반영할지는
|
||||
전적으로 `updateFn` 몫 — `Slot`/Handler가 자동으로 해주지 않음
|
||||
(2026-08-11 세션 확정, 아래 참고).
|
||||
- **`prev: T?`** — 이 key에 대해 지금 실제로 마운트돼 있는
|
||||
element(없으면 `nil`, 첫 호출을 포함해 언제든 가능).
|
||||
- **`userdata: UD?`** — 이 key에 대해 지난 호출에서 `updateFn` 자신이
|
||||
반환해둔 두 번째 값을 그대로 돌려받음(첫 호출은 `nil`). 완전히
|
||||
opaque — `:List`는 안을 전혀 안 들여다봄. `updateFn`이 원하는 걸
|
||||
아무거나 담아도 됨(item의 `Source`, 여러 파생 State, 로컬 UI
|
||||
상태 등).
|
||||
- **`prev: T?`** — 이 key에 대해 지금 실제로 마운트돼 있는
|
||||
element(없으면 `nil`, 첫 호출을 포함해 언제든 가능).
|
||||
- **반환값 두 개는 서로 완전히 독립** — `:List`가 `result`와 `userdata`
|
||||
사이에 어떤 커플링도 안 둠(예: `result`가 `nil`이라고 `userdata`를
|
||||
자동으로 지우지 않음), 그대로 기록만 함. **[정정, 같은 세션 후속]**
|
||||
|
|
@ -375,23 +441,118 @@ Slot():List(data, updateFn, keyFn?) -> Slot -- self
|
|||
없앰. 흔한 경우(둘 다 리셋)는 그냥 `return nil` 하나로 충분(Lua가
|
||||
안 받은 반환 슬롯을 알아서 `nil`로 채움), 캐시를 남기고 싶으면
|
||||
명시적으로 `return nil, ud`.
|
||||
- `updateFn`은 매번 다음 중 하나를 반환:
|
||||
- **`prev`를 그대로 반환** — "지금 마운트된 걸 계속 쓴다"는 뜻.
|
||||
관용구: `if prev and (필터 통과) then ...update ud...; return prev,
|
||||
ud end`. 실제 마운트/파괴가 없는 **저렴한 경로**.
|
||||
- **새 값(또는 다른 값)을 반환** — 첫 렌더(이 key 최초 등장) 또는
|
||||
의도적 교체. `prev`가 있었다면 그건 파괴되고 새 값이 그 자리를
|
||||
대신함.
|
||||
- **첫 번째 값으로 `nil`을 반환** — "지금 이 key는 렌더 안 함"(filter
|
||||
탈락 등). `prev`가 있었다면 실제로 파괴됨(단순 `Visible = false`
|
||||
아님 — 아래 참고). `None`을 반환해도 동일 취급(둘 다 허용, 편의상
|
||||
`nil` 권장 — 반환값이 raw Slot 요소로 직접 들어가는 게 아니라
|
||||
- **`updateFn`은 매번 다음 세 갈래 중 하나를 명시적으로 골라야 함**
|
||||
(아래 "왜 `LayoutOrder`를 Slot이 대신 안 해주는가" 절의 예시 코드가
|
||||
이 세 갈래를 그대로 구현) — 어느 갈래인지 `updateFn` 자신만 정확히
|
||||
알기 때문에, 이 판단을 `updateFn` 밖(`:List` 내부)으로 빼면 낭비가
|
||||
생김(재사용 예정인 Source에 미리 `:Set`해뒀다가 결국 다시 그리게
|
||||
되는 식):
|
||||
- **버림** — 첫 번째 값으로 `nil`(또는 `None`, 동일 취급)을 반환.
|
||||
"지금 이 key는 렌더 안 함"(filter 탈락 등) — `prev`가 있었다면
|
||||
실제로 파괴됨(단순 `Visible = false` 아님 — 아래 참고). 편의상
|
||||
`nil` 권장(반환값이 raw Slot 요소로 직접 들어가는 게 아니라
|
||||
`:List`의 reconcile이 해석만 하므로 "요소 타입 제약"의 raw
|
||||
`nil`/`None` 금지와 안 부딪힘).
|
||||
- **다시 그림** — `prev`와 다른(보통 새로) 만든 값을 반환. 첫 렌더
|
||||
(이 key 최초 등장) 또는 의도적 전체 교체 — `prev`가 있었다면 그건
|
||||
파괴되고 새 값이 그 자리를 대신함. 이 갈래에서 반응형 값(예:
|
||||
`LayoutOrder`용 `Source`)이 필요하면 **항상 새로 만들어서** 처음부터
|
||||
올바른 값으로 시작해야 함, 이전 `userdata`에 뭐가 남아있었든
|
||||
재사용/`Set`하면 안 됨(아무도 안 구독하는 상태라 무의미한 연산).
|
||||
- **`prev`를 그대로 반환(source만 갱신)** — "지금 마운트된 걸
|
||||
계속 쓴다"는 뜻, 실제 마운트/파괴가 없는 **저렴한 경로**. 관용구:
|
||||
`if prev and (필터 통과) then ...update ud 안의 Source에 :Set()...;
|
||||
return prev, ud end` — 이 갈래에서만 기존 Source를 재사용하며
|
||||
값이 실제로 다를 때만 `:Set()`.
|
||||
- `userdata = userdata or {}`류 lazy-init 관용구가 `UD`가 완전히 자유
|
||||
제네릭인 상태에서도 Luau 타입 시스템이 매끄럽게 좁혀주는지는 **실측
|
||||
필요**(M0/M6 착수 시 확인 항목, 지금 단정 안 함).
|
||||
|
||||
### 왜 `LayoutOrder`를 Slot이 대신 안 해주는가 (2026-08-11 세션)
|
||||
|
||||
처음엔 `Slot`을 마운트하는 Handler가 `index`+`offset`을 조합해 각 원소의
|
||||
`LayoutOrder`를 자동으로 바인딩해주는 안을 검토했으나 **기각** — 사용자가
|
||||
직접 두 가지 문제를 지적:
|
||||
|
||||
1. **매직이 됨.** 컴포넌트가 자기 프로퍼티로 `LayoutOrder`를 이미 지정해서
|
||||
Slot에 넣어도(`Frame { LayoutOrder = 5 }`), Slot이 마운트 시점에 그걸
|
||||
조용히 덮어쓰게 됨 — "매직 없이 명시적"이라는 프로젝트 전역 기조와
|
||||
정면으로 부딪힘.
|
||||
2. **애초에 `updateFn`이 동적 요소를 전부 다루는 게 원래 설계 의도였음.**
|
||||
`userdata`도 그 일부 — Slot/Handler가 원소 프로퍼티의 일부(`LayoutOrder`)만
|
||||
따로 떼어 자기가 관리하면 이 원칙이 깨짐.
|
||||
|
||||
**결론**: Slot은 `index`(압축된 위치)와 `offset`(형제 누적합, `Source`)만
|
||||
`updateFn`에 값으로 전달하고, 그 둘을 실제로 어디에 어떻게 쓸지는 전부
|
||||
`updateFn` 작성자 몫 — 로블록스에선 `LayoutOrder`에, 웹이면 필요할 때만
|
||||
CSS `order`에(불필요하면 그냥 무시, `insertBefore`가 알아서 물리 순서를
|
||||
처리하므로), 조합 방식(`+`가 아니라 다른 함수)도 전적으로 자유. Slot
|
||||
쪽엔 `LayoutOrder`라는 이름 자체가 전혀 등장 안 함, 완전히 엔진/프로퍼티
|
||||
이름 무관.
|
||||
|
||||
수동 CRUD로 Slot을 쓰는 사용자도 마찬가지 — `:List` 없이 직접
|
||||
`slot:Add(element, index)`를 부른다면, 원한다면 `slot.Offset`을 직접
|
||||
읽어 자기 `index`(CRUD 호출 시점에 스스로 아는 값)와 조합해서 프로퍼티를
|
||||
구성하면 됨, 안 하면 그냥 `LayoutOrder`가 갱신 안 될 뿐(에러 아님).
|
||||
|
||||
**`index`가 State가 아니라 raw number인 이유, `:Set` 타이밍은 전부
|
||||
`updateFn`의 몫(같은 세션 후속)** — 처음엔 `:List`가 `index`도
|
||||
`indexState: Source<number>`로 감싸 관리해주는 안을 검토했으나, 사용자가
|
||||
"결국 이것도 `item`처럼 raw number로 넘기고, `:Set`을 언제 할지는
|
||||
`userdata`를 활용해 `updateFn` 스스로 정하는 게 맞다"고 정리 — 채택.
|
||||
근거: (1) `item`에 이미 적용된 "`:List`가 반응형을 강제하지 않는다"
|
||||
원칙(바로 아래 절)이 `index`에도 그대로 적용돼야 일관적임. (2) `:List`가
|
||||
`indexState`를 대신 관리하면, 값이 실제로 바뀌었는지 비교하는 가드
|
||||
(`Get() ~= newIndex`, `recompute`가 이미 쓰는 패턴)를 넣더라도 **새로
|
||||
생기는 원소는 항상 `Source(0)`으로 시작했다가 다시 `:Set(index)`으로
|
||||
고쳐 써야 해서 프로퍼티가 두 번 써짐** — `updateFn`이 `userdata`로 직접
|
||||
관리하면 새 원소는 처음부터 `Source(index)`로 **올바른 값으로 생성**돼서
|
||||
이 낭비 자체가 없음.
|
||||
|
||||
```lua
|
||||
function updateFn(item, index, offset, prev, ud)
|
||||
if not shouldShow(item) then
|
||||
return nil, ud -- 버림 — Set 자체를 안 부름
|
||||
end
|
||||
|
||||
if not prev then
|
||||
-- 다시 그림(새 원소) — 이전 Source 재사용/Set 없이 처음부터 올바른 값으로 생성
|
||||
local layoutOrder = Source(index)
|
||||
return Frame {
|
||||
LayoutOrder = layoutOrder:With(offset):Compute(function(i, o) return i + o end),
|
||||
...
|
||||
}, { layoutOrder = layoutOrder }
|
||||
end
|
||||
|
||||
-- source 업데이트만 전파 — 기존 원소/Source 재사용, 실제로 바뀔 때만 Set
|
||||
local layoutOrder = ud.layoutOrder
|
||||
if layoutOrder:Get() ~= index then
|
||||
layoutOrder:Set(index)
|
||||
end
|
||||
return prev, ud
|
||||
end
|
||||
```
|
||||
|
||||
**세 갈래를 명시적으로 나누는 이유(사용자 정정)** — `updateFn`이 실행되기
|
||||
전까지는 이번 사이클에 이 item이 "버려질지/다시 그려질지/source만
|
||||
갱신될지" 아무도 모름, 그래서 `idx:Set()`을 미리 해둘 수가 없음(해봐야
|
||||
어느 쪽으로 결론 날지 몰라 낭비가 될 수 있음) — 반대로 `updateFn`
|
||||
자신은 이 세 갈래를 정확히 알고 있으므로, 자기 안에서 직접 나누면
|
||||
낭비가 없음. 특히 **"다시 그림" 갈래에서 이전 `ud.layoutOrder`를
|
||||
재사용하며 `:Set()`하는 건 무의미한 연산**이 됨 — 어차피 새 Frame을
|
||||
만드는 순간 새로운 `:With`/`:Compute` 구독이 맺어지므로, 그 전에 아직
|
||||
아무도 안 보는 이전 Source에 `:Set()`을 해봐야 아무 캐스케이드도 안
|
||||
일어나는 헛수고(구독자가 없어 관측 비용은 저렴하지만 그래도 불필요한
|
||||
분기) — 대신 그냥 새 `Source(index)`를 바로 만들면 됨, 이전 Source가
|
||||
어디 남아있든 상관없음(아무도 참조 안 하면 그냥 GC됨).
|
||||
|
||||
`index`가 `updateFn` 호출 시점에 이미 "**이 item이 이번 사이클에 살아남으면
|
||||
차지할** 압축 위치"로 정확히 계산돼서 넘어오므로(아래 "구현" 절의
|
||||
`candidateIndex` 참고, 직전까지의 생존자 수만으로 계산 가능해 이 item
|
||||
자신의 생존 여부와 무관), `updateFn`이 값을 늦게 알아서 임시값→정정
|
||||
과정을 거칠 필요가 없음 — `nil` 반환(필터 탈락)이면 이 `index` 값은
|
||||
그냥 버려지고 다음 생존자가 같은 값을 받음.
|
||||
|
||||
### 왜 매 사이클 호출로 바뀌었는가 — filter/toggle 문제
|
||||
|
||||
사용자가 제기한 문제: item이 State 변경으로 "더 이상 렌더되면 안 되는"
|
||||
|
|
@ -487,31 +648,42 @@ function Slot:List(data, updateFn, keyFn)
|
|||
end
|
||||
|
||||
-- Dispatch/Slot.luau의 process(inst,k,self)가 마운트 시점에 1회 호출
|
||||
-- (self._mounted=true/self._mountedInst=inst를 세팅하는 바로 그 자리)
|
||||
-- (self._mounted=true/self._mountedInst=inst, self.Offset 세팅과 같은 자리)
|
||||
function activateList(self, inst)
|
||||
local keyFn, updateFn = self._keyFn, self._updateFn
|
||||
local offset = self.Offset
|
||||
local mounted, userdata, keyIndex = {}, {}, {}
|
||||
|
||||
local function reconcile(items)
|
||||
local newKeyIndex, seen = {}, {}
|
||||
local pos = 0 -- 압축된(실제 마운트된) 위치 카운터, raw 루프 인덱스 i와 다름
|
||||
|
||||
for i, item in ipairs(items) do
|
||||
local key = keyFn(item, i)
|
||||
newKeyIndex[key] = i
|
||||
local key = keyFn(item, i) -- keyFn은 raw i를 받음(:List 파라미터 설명 참고)
|
||||
if seen[key] then
|
||||
error("Slot:List — duplicate key: " .. tostring(key))
|
||||
end
|
||||
seen[key] = true
|
||||
|
||||
local prev = mounted[key]
|
||||
local result, ud = updateFn(item, i, userdata[key], prev)
|
||||
local candidateIndex = pos + 1 -- "이 item이 살아남으면 차지할" 압축 위치(생존 여부와 무관하게 계산 가능)
|
||||
local result, ud = updateFn(item, candidateIndex, offset, prev, userdata[key])
|
||||
if result == None then result = nil end -- 편의: None도 nil과 동일 취급
|
||||
|
||||
if result ~= nil then
|
||||
pos = candidateIndex -- 실제로 살아남았을 때만 커밋
|
||||
end
|
||||
|
||||
if result ~= prev then
|
||||
if prev ~= nil then rawRemove(self, prev) end -- 파괴
|
||||
if result ~= nil then rawAdd(self, result, i) end -- 새로 배치
|
||||
if prev ~= nil then rawRemove(self, prev) end -- 파괴
|
||||
if result ~= nil then rawAdd(self, result, pos) end -- 새로 배치, 압축 위치 기준
|
||||
mounted[key] = result
|
||||
elseif prev ~= nil and keyIndex[key] ~= i then
|
||||
rawMove(self, prev, i) -- 그대로 쓰되 위치만 이동
|
||||
elseif prev ~= nil and keyIndex[key] ~= pos then
|
||||
rawMove(self, prev, pos) -- 그대로 쓰되 위치만 이동
|
||||
end
|
||||
|
||||
userdata[key] = ud -- result와 무관, 그대로 기록
|
||||
newKeyIndex[key] = pos
|
||||
end
|
||||
for key in pairs(keyIndex) do -- 직전 사이클에 존재했던 전체 key
|
||||
if not seen[key] then
|
||||
|
|
@ -536,6 +708,30 @@ function activateList(self, inst)
|
|||
end
|
||||
```
|
||||
|
||||
**[정정, 2026-08-11 세션] `pos`(압축 위치)와 raw 루프 인덱스 `i`를
|
||||
분리한 이유 — 이전 의사코드의 실제 버그.** 원래 `rawAdd(self, result, i)`처럼
|
||||
raw `i`를 그대로 위치 인자로 썼는데, 앞쪽 item이 filter로 마운트 안 되면
|
||||
실제 Slot 안 마운트된 개수는 `i`보다 항상 적어짐 — 그 상태로 `rawAdd`를
|
||||
`i` 위치에 부르면 `Add`의 "범위 밖 index는 clamp 없이 error"(위 "CRUD API
|
||||
확정" 절)에 걸려 그냥 터짐. `pos`는 이번 사이클에서 **지금까지 실제로
|
||||
마운트된 개수**만 세는 별도 카운터라 이 문제가 없음 — `keyIndex`/`rawMove`/
|
||||
`rawAdd`도 전부 이 `pos` 기준으로 통일. filter 탈락 없이 순서대로 통과하는
|
||||
흔한 경우엔 `pos == i`라 체감상 달라지는 게 없음.
|
||||
|
||||
**[같은 세션 후속] `updateFn`에 넘기는 `index`가 `candidateIndex`(=`pos + 1`)인
|
||||
이유 — `idx`를 `:List`가 State로 관리하던 안을 기각하며 나온 재설계.**
|
||||
`candidateIndex`는 **"이 item이 이번 사이클에 살아남으면 차지할 압축
|
||||
위치"** — 직전까지 처리된 item들의 생존 개수(`pos`)만으로 계산되므로
|
||||
이 item 자신이 살아남을지와 무관하게 `updateFn` 호출 **전에** 이미 정확히
|
||||
알 수 있음. 그래서 `result ~= nil`일 때만 `pos = candidateIndex`로 커밋—
|
||||
살아남지 못하면(`nil` 반환) 그 값은 그냥 버려지고 다음 생존자가 같은
|
||||
값을 받음. 이 덕에 `updateFn`은 항상 **정확한 최종값**을 받아서, 위
|
||||
"왜 `LayoutOrder`를 Slot이 대신 안 해주는가" 절의 `Source(index)` 예시처럼
|
||||
새 원소를 처음부터 올바른 값으로 만들 수 있음(임시값→나중에 정정하는
|
||||
이중 write가 생기지 않음) — `candidateIndex` 자체가 다음 값을 미리
|
||||
계산해두는 것뿐이라 look-ahead(아직 안 본 뒤쪽 item을 미리 훑는 것)가
|
||||
전혀 필요 없는, 여전히 단일 forward pass.
|
||||
|
||||
- **`data:Observer(fn)`**: 새 구독 프리미티브 아님 — 2026-08-07 여섯 번째
|
||||
세션에 이미 "등록 즉시 1회 실행" 확정된 그 메소드를 그대로 씀.
|
||||
`reconcile`은 매번 **현재 전체 스냅샷을 받아 O(n) 단일 패스로 diff**
|
||||
|
|
@ -555,7 +751,10 @@ end
|
|||
사라지면 `pairs(mounted)`로는 그 key가 아예 안 잡혀서 `userdata`가
|
||||
못 치워지고 샘 — 직전 사이클에 실제로 존재했던 **전체** key 집합
|
||||
(`keyIndex`, 매 사이클 모든 key에 대해 채워짐)을 순회해야 이 케이스를
|
||||
놓치지 않음.
|
||||
놓치지 않음. `userdata` 안에 사용자가 직접 넣어둔 `Source`(예: 위
|
||||
`LayoutOrder` 예시의 `layoutOrder`)도 이 정리 대상에 자연히 포함됨 —
|
||||
`:List` 자신은 그 안을 안 들여다보지만, `userdata[key] = nil`이 되는
|
||||
순간 참조가 끊겨 GC됨.
|
||||
- **`mounted`/`userdata`/`keyIndex`**: `activateList`(마운트 시점 1회
|
||||
실행)의 로컬 변수(클로저 업밸류) — 별도 전역 weak table(`Relate` 등)
|
||||
불필요, `inst`/`self`가 살아있는 동안만 존재하면 되고 죽으면 클로저도
|
||||
|
|
|
|||
148
CLAUDE.md
148
CLAUDE.md
|
|
@ -2823,3 +2823,151 @@ README.md` 반영 완료.
|
|||
**다음 세션이 할 일**: 안 바뀜(`ROADMAP.md` M0부터, luau-test 결과 확인
|
||||
우선) — `15`의 C/D가 예상대로 나오는지(C는 막히고 D는 통과)까지 같이
|
||||
확인해줄 것, 예상과 다르면 이 순서 결정 자체를 재검토.
|
||||
|
||||
## 2026-08-11 네 번째 세션 — `Slot:List`가 형제 순서(`LayoutOrder`)를
|
||||
자동으로 안 세팅하는 것으로 정정, `updateFn`에 `index`/`offset` 추가
|
||||
|
||||
사용자가 "`Slot:List`의 `updateFn`이 자기 자신 Slot을 얻을 방법이
|
||||
없는데, 그럼 offset을 못 보는 거 아니냐"고 질문하며 시작 — 처음엔
|
||||
"Handler(quad-roblox `Handlers/Slot.luau`)가 `rawAdd`/`rawMove` 시점에
|
||||
`localIndex+offset`을 자동으로 계산해 마운트된 원소의 `LayoutOrder`에
|
||||
직접 바인딩해준다"고 답했으나(2026-08-09 여섯 번째 세션 `bind-system-plan.md`
|
||||
"Length/Offset" 절의 원 서술 그대로), 사용자가 이건 **매직**이라고
|
||||
바로 반박 — 컴포넌트가 `Frame { LayoutOrder = 5 }`처럼 자기 프로퍼티로
|
||||
이미 지정한 값을 Slot이 마운트 시점에 조용히 덮어쓰게 되고, 애초에
|
||||
"`updateFn`이 동적 요소를 전부 다룬다"는 게 원래 설계 의도였다는 것.
|
||||
|
||||
**확정**: Slot/Handler는 `LayoutOrder`를 자동으로 세팅하지 않음 —
|
||||
`Slot.Offset`(`Slot.Length`와 마찬가지로 공개 필드, Slot 마운트 시점에
|
||||
`Dispatch.setOffsetSource`가 등록하는 바로 그 Source를 `self.Offset`으로도
|
||||
저장)과 `index`(이제 `State<number>`, "이 key가 지금 실제로 마운트된
|
||||
요소들 사이에서 몇 번째냐" — `keyFn`이 받는 raw `data` 배열 인덱스와는
|
||||
다른, filter로 압축된 값)를 `updateFn`에 값으로 전달만 하고, 실제로
|
||||
`LayoutOrder`(로블록스)든 CSS `order`(웹, 필요할 때만)든 어디에 어떻게
|
||||
쓸지는 전부 `updateFn` 작성자 몫 — `index:With(offset):Compute(fn)`을
|
||||
평범한 프로퍼티 store-bind로 써넣으면 됨, 새 메커니즘 아님. 수동 CRUD로
|
||||
Slot을 쓰는 사용자도 `slot.Offset`을 직접 읽어 같은 걸 스스로 구성 가능.
|
||||
부수적으로 `setOffsetSource` 자체가 순수 숫자 계산이라 원래도 엔진 지식이
|
||||
필요 없었다는 것도 재확인 — `LayoutOrder` 자동 바인딩을 그 옆에 서술했던
|
||||
게 레이어링(엔진 무관 `Dispatch/Slot.luau` vs Roblox 전용 `LayoutOrder`)
|
||||
위반이기도 했음.
|
||||
|
||||
**`updateFn` 시그니처도 같이 정리**: `offset`/`index`(State화) 추가하면서
|
||||
파라미터 순서를 반환값 순서와 맞춤(사용자 지적) — 반환이 `(result, ud)`
|
||||
(`prev`류 먼저, `userdata`류 나중)인데 기존 파라미터는 `userdata`가
|
||||
`prev`보다 앞이라 뒤집혀 있었음, `prev, userdata` 순서로 정정:
|
||||
|
||||
```lua
|
||||
updateFn<UD = any>(item, index: State<number>, offset: Source<number>, prev: T?, userdata: UD?): (T | nil, UD?)
|
||||
```
|
||||
|
||||
**부수 발견 — 기존 `reconcile` 의사코드에 실제 버그가 있었음.** `index`를
|
||||
진짜 값으로 노출하려다 보니, `rawAdd(self, result, i)`가 raw `data` 루프
|
||||
인덱스 `i`를 그대로 위치 인자로 썼던 게 문제로 드러남 — filter로 앞쪽
|
||||
item이 마운트 안 되면 실제 마운트된 개수가 `i`보다 적어져서, `Add`의
|
||||
"범위 밖 index는 clamp 없이 error" 규칙에 걸려 그냥 터짐. `reconcile`
|
||||
안에 "지금까지 실제로 마운트된 개수"만 세는 별도 압축 카운터(`pos`)를
|
||||
추가해 `rawAdd`/`rawMove`/`keyIndex`/`index` State 전부 이 값 기준으로
|
||||
통일 — filter 없이 순서대로면 `pos == i`라 흔한 경우엔 체감 차이 없음.
|
||||
|
||||
전부 `base/bind-system-plan.md`(`setOffsetSource` 절, "Slot.Length와
|
||||
Slot.Offset은 별개" 절)/`base/slot-plan.md`(`:List` 파라미터 설명, 신규
|
||||
"왜 `LayoutOrder`를 Slot이 대신 안 해주는가" 절, `activateList`/`reconcile`
|
||||
의사코드 전면 수정) 반영 완료.
|
||||
|
||||
**같은 세션 후속 — `index`도 State가 아니라 raw number로 재정정,
|
||||
`candidateIndex`로 이중 write 제거.** 사용자가 "reconcile은 sync라
|
||||
깜빡임 문제는 없지만, filter로 항목이 새로 보이게 되면 그 뒤 index를
|
||||
다 밀어줘야 하는데 Set이 반복적으로 도는 게 비효율 아니냐"고 재질문 —
|
||||
검토 과정에서 사용자가 직접 더 나은 방향을 제시: `index`도 `item`과
|
||||
똑같이 raw number로 넘기고, 반응형으로 쓸지·언제 `:Set`할지는 전부
|
||||
`updateFn`이 자기 `userdata` 안에서 알아서 판단하게 두면 되지 않냐는
|
||||
것 — 채택. 이러면 `:List`가 `indexState`라는 별도 맵을 관리할 필요
|
||||
자체가 없어짐(`item`을 raw로 넘기는 것과 완전히 같은 원칙으로 통일,
|
||||
"왜 `Source`를 `:List`가 안 만드는가" 절이 원래도 "item/index" 둘 다를
|
||||
언급하고 있었던 것과도 재정합).
|
||||
|
||||
**`candidateIndex` 트릭으로 chicken-and-egg 문제도 해소**: `updateFn`에
|
||||
넘기는 `index`가 필요한 시점엔 아직 이 item이 살아남을지(필터 통과
|
||||
여부) 모르는데, 압축 위치(`pos`)는 원래 "생존자 개수"라 이 item 자신의
|
||||
생존 여부에 의존하는 것처럼 보였음 — 그런데 실제로는 **"이 item이
|
||||
살아남으면 차지할 위치"는 직전까지 처리된 item들의 생존 개수만으로
|
||||
이미 계산 가능**(이 item 자신의 결과와 무관)하다는 걸 확인 —
|
||||
`candidateIndex = pos + 1`을 `updateFn` 호출 **전에** 계산해서 넘기고,
|
||||
`result ~= nil`일 때만 `pos = candidateIndex`로 커밋. `updateFn`은 항상
|
||||
정확한 최종값을 받으므로, 새로 생기는 원소를 처음부터 `Source(index)`로
|
||||
올바르게 만들 수 있어 "임시값으로 등록 → 나중에 Set으로 정정"하는
|
||||
이중 write가 구조적으로 없어짐(브랜드 뉴 원소에 대해서도) — look-ahead
|
||||
(아직 안 본 뒤쪽 item을 미리 훑는 것) 없이 여전히 단일 forward pass.
|
||||
|
||||
전부 `base/slot-plan.md`(`updateFn` 시그니처를 `index: number`로 재정정,
|
||||
신규 "왜 `LayoutOrder`를 Slot이 대신 안 해주는가" 절에 `userdata` 기반
|
||||
예시 코드 추가, `activateList`/`reconcile` 의사코드에서 `indexState` 맵
|
||||
전부 제거하고 `candidateIndex` 방식으로 교체) 반영 완료.
|
||||
|
||||
**같은 세션 세 번째 후속 — 예시 코드의 남은 낭비 하나를 사용자가 재정정.**
|
||||
`Source(index)`/`Set(index)` 분기를 `if not layoutOrder ... elseif` 식으로
|
||||
"Source 재사용 여부"만 갖고 나눴던 첫 예시가, "원소를 다시 그리는지
|
||||
(`prev == nil`)"와 독립적으로 갈려서 — `prev == nil`(새로 그림)인데
|
||||
`ud.layoutOrder`는 남아있는 경우(직전에 filter 탈락했다 재등장) 이전
|
||||
Source를 재사용하며 `:Set()`한 뒤 새 Frame을 만들면, 그 `:Set()` 시점엔
|
||||
아직 아무도 그 Source를 구독하고 있지 않아 완전히 무의미한 연산이 됨
|
||||
— 사용자 지적: "updateFn이 실행되기 전까진 이번 item이 버려질지/다시
|
||||
그려질지/source만 갱신될지 아무도 모르니 미리 Set을 해둘 수 없고,
|
||||
`updateFn` 자신만 이 세 갈래를 정확히 알아서 효율적으로 나눌 수 있다."
|
||||
예시를 `if not shouldShow ... return nil / if not prev then <새 Source로
|
||||
다시 그림> / <기존 Source 재사용, 실제로 다를 때만 Set>` 세 갈래로 재작성
|
||||
— "다시 그림" 갈래는 이전 Source를 절대 참조 안 하고 항상 `Source(index)`로
|
||||
새로 만듦.
|
||||
|
||||
**같은 세션 네 번째 후속(핸드오버 정리) — 용어 혼동 방지 문서화, 전체
|
||||
코퍼스 stale 감사·`ROADMAP.md`/`README.md` 동기화.** 사용자가 "`key`와
|
||||
`index`가 헷갈리지 않게 문서화에 유의, `updateFn`이 명시적 책임이 많은
|
||||
함수라 문서화가 중요하다, 이 세션 내용 누락/stale 없는지 보고 핸드오버
|
||||
준비하라"고 요청 — `base/slot-plan.md`의 `Slot:List` 절 최상단에 세
|
||||
가지 값(1. `keyFn(item, index)`의 raw `index` — 원본 `data` 배열 위치,
|
||||
2. `key` — `keyFn`이 계산하는 정체성, 3. `updateFn(item, index, ...)`의
|
||||
`index` — `key`와 무관한 압축된 마운트 위치, 순서 계산 전용)을 이름이
|
||||
겹치는데 서로 다르다고 명시하는 콜아웃 신설, `keyFn`/`updateFn` 파라미터
|
||||
설명 각각에도 교차 참조 추가. `updateFn`의 반환 갈래 서술(구 "prev 그대로/
|
||||
새 값/nil 반환")도 위에서 확정된 "버림/다시 그림/source만 갱신" 세
|
||||
갈래 이름으로 통일해 같은 개념이 두 가지 다른 말로 서술되던 걸 정리.
|
||||
`base/bind-system-plan.md`의 `setOffsetSource` 예시(`index:With(offset)`
|
||||
— `index`가 State인 것처럼 잘못 읽히던 stale 표현)도 `layoutOrder:With(offset)`
|
||||
(사용자가 `userdata`에 직접 관리하는 Source)로 정정. `ROADMAP.md` M6/
|
||||
`.claude/README.md`의 `Slot:List` 요약이 이번 세션 이전 시그니처
|
||||
(`updateFn<UD>(item, index, userdata, prev)`, `offset` 없음, `LayoutOrder`
|
||||
자동 처리 여부 미언급)로 멈춰 있던 것도 최신 상태로 동기화.
|
||||
|
||||
`.claude/luau-test/`류 새 실측 항목은 추가되지 않음(이번 세션은 런타임
|
||||
로직/시그니처 설계이지 Luau 타입 시스템 경계 확인 대상이 아님) — 기존
|
||||
`userdata = userdata or {}` lazy-init 제네릭 narrowing 실측 필요 항목은
|
||||
그대로 유효(M0/M6 착수 시 확인).
|
||||
|
||||
**다음 세션이 할 일**: 안 바뀜(`ROADMAP.md` M0부터, luau-test 결과 확인
|
||||
우선) — 이번 세션도 `:List` 세부 설계 정정/문서 정리라 M0 착수 우선순위
|
||||
자체는 그대로.
|
||||
|
||||
**같은 세션 다섯 번째 후속 — `key` 타입 무제약 확인, `item.id` 관용구
|
||||
문서화.** 사용자가 "캐스케이드 갱신을 막고 싶으면 `keyFn`은 string 등
|
||||
unique하기만 한 값이면 되는 거 맞지, `data` 안에 string 필드가 있으면
|
||||
그걸 쓰면 된다" 확인 요청 — 맞음(`key`는 Lua 테이블 키로만 쓰여서 타입
|
||||
제약 없음, 필요조건은 사이클 간 안정성+유일성뿐). `keyFn`이 `item`을
|
||||
그대로 받으므로 `item.id`처럼 이미 있는 안정적 필드를 그냥 반환하면
|
||||
됨(새로 뭘 만들 필요 없음). `base/slot-plan.md`의 `keyFn` tradeoff
|
||||
단락에 이 확인과 `function(item) return item.id end` 관용구 예시를
|
||||
명시적으로 추가.
|
||||
|
||||
**다음 세션이 할 일**: 안 바뀜(`ROADMAP.md` M0부터, luau-test 결과 확인
|
||||
우선).
|
||||
|
||||
**같은 세션 여섯 번째 후속 — 중복 `key` 즉시 `error`로 확정.** 사용자가
|
||||
"`reconcile`이 이미 `seen[key] = true`를 하니까, 그 앞에 `if seen[key]
|
||||
then error end`을 두면 거의 공짜로 중복 key를 잡을 수 있지 않냐"고
|
||||
제안 — 채택. 조용히 넘어가면 두 item이 `mounted`/`userdata`/`keyIndex`의
|
||||
같은 슬롯을 다투는 조용한 버그가 되므로, 다른 Slot CRUD 에러 조건들과
|
||||
같은 fail-fast 톤으로 그 자리에서 막음. `base/slot-plan.md`의 `reconcile`
|
||||
의사코드와 `keyFn` tradeoff 단락에 반영 완료.
|
||||
|
||||
**다음 세션이 할 일**: 안 바뀜(`ROADMAP.md` M0부터, luau-test 결과 확인
|
||||
우선).
|
||||
|
|
|
|||
57
ROADMAP.md
57
ROADMAP.md
|
|
@ -241,30 +241,55 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
|
|||
error(`Modifier` 필드와 같은 판별 메커니즘 재사용) — `D.InstSlot =
|
||||
Slot<<Instance>>`가 quad-roblox의 사실상 유일한 Slot 타입.
|
||||
- [ ] `Slot:List(data, updateFn, keyFn?)` — 키 기반 동적 컬렉션 재조정,
|
||||
`keyFn` 생략 시 index를 그대로 key로 사용(중간 삽입/삭제 시 identity
|
||||
보존 안 됨, 캐스케이드 갱신 — 흔한 업계 관행과 같은 트레이드오프).
|
||||
`updateFn<UD=any>(item, index, userdata: UD?, prev: T?): (T|nil, UD?)`가
|
||||
**매 reconcile 사이클마다 호출**(filter/toggle 지원 — 첫 반환값
|
||||
`nil` 시 실제 파괴, `Visible` 토글 아님, 200+ 항목에서 lazy하지 않은
|
||||
문제 회피), `prev` 그대로 반환하면 저비용 재사용 경로. `:List`가
|
||||
`Source`를 대신 안 만듦 — item/index를 반응형으로 감쌀지는
|
||||
`updateFn`이 `userdata`에 직접 관리(반환값 두 개는 서로 독립,
|
||||
`result`가 `nil`이어도 `userdata`는 명시적으로 반환 안 하는 한 안
|
||||
지워짐). 정리 루프는 `mounted`가 아니라 직전 사이클 `keyIndex`
|
||||
전체를 순회해야 함(`userdata`만 살아있는 채로 key가 완전히 사라지는
|
||||
케이스 커버). `userdata = userdata or {}` lazy-init 패턴이 Luau
|
||||
제네릭에서 잘 좁혀지는지 실측 필요. **`userdata`는 GC-native 값만
|
||||
허용, `:Subscribe()`한 Observer류 명시적 cleanup 필요한 값은 UB** —
|
||||
`keyFn(item, index) -> key` 생략 시 원본 `data` 배열 위치(raw index)를
|
||||
그대로 key로 사용(중간 삽입/삭제 시 identity 보존 안 됨, 캐스케이드
|
||||
갱신 — 흔한 업계 관행과 같은 트레이드오프).
|
||||
`updateFn<UD=any>(item, index: number, offset: Source<number>, prev: T?,
|
||||
userdata: UD?): (T|nil, UD?)`가 **매 reconcile 사이클마다 호출**
|
||||
(filter/toggle 지원 — 첫 반환값 `nil` 시 실제 파괴, `Visible` 토글
|
||||
아님, 200+ 항목에서 lazy하지 않은 문제 회피), `prev` 그대로 반환하면
|
||||
저비용 재사용 경로. 파라미터 순서는 반환값 순서(`prev`류 먼저,
|
||||
`userdata`류 나중)와 맞춤(2026-08-11 세션 정정, 원래 `userdata`가
|
||||
`prev`보다 앞이었음).
|
||||
**`updateFn`의 `index`는 `keyFn`의 raw `index`(원본 `data` 배열
|
||||
위치)와 다른 값** — "이번 사이클에 살아남으면 차지할 압축된 마운트
|
||||
위치"(`candidateIndex`, filter로 압축됨), `key`와도 무관(순서/레이아웃
|
||||
전용, 식별 목적 아님) — 문서화 시 셋(원본 raw index/`key`/`updateFn`의
|
||||
`index`)을 혼동하지 않게 주의. **`offset`은 `Slot.Offset`을 그대로
|
||||
전달**(형제 Slot/정적 자식 누적합, `base/bind-system-plan.md`의
|
||||
"Length/Offset" 절) — `index`/`offset` 둘 다 **raw 값으로만 전달,
|
||||
`Slot`/Handler가 `LayoutOrder` 등을 자동으로 세팅해주지 않음**
|
||||
(2026-08-11 세션 확정 — 자동 바인딩은 컴포넌트가 이미 지정한 값을
|
||||
매직으로 덮어쓰는 문제가 있어 기각, 실제 반영은 전적으로 `updateFn`
|
||||
몫). `:List`가 `Source`를 대신 안 만듦 — item/index를 반응형으로
|
||||
감쌀지는 `updateFn`이 `userdata`에 직접 관리, **"버림(`nil` 반환)/
|
||||
다시 그림(`prev==nil`, 항상 새 `Source`로 처음부터 올바른 값 생성)/
|
||||
source만 갱신(`prev` 재사용, 값 다를 때만 `:Set`)" 세 갈래를
|
||||
`updateFn`이 명시적으로 나눠야 낭비 없음** — 재사용 중인 Source에
|
||||
미리 `:Set()`해뒀다가 결국 새로 그리게 되면 그 `:Set()`은 아무도
|
||||
안 구독한 상태라 무의미한 연산이 됨, `updateFn`만 이 갈래를 정확히
|
||||
알아 낭비를 피할 수 있음(반환값 두 개는 서로 독립, `result`가 `nil`이어도
|
||||
`userdata`는 명시적으로 반환 안 하는 한 안 지워짐). 정리 루프는
|
||||
`mounted`가 아니라 직전 사이클 `keyIndex` 전체를 순회해야 함
|
||||
(`userdata`만 살아있는 채로 key가 완전히 사라지는 케이스 커버).
|
||||
`userdata = userdata or {}` lazy-init 패턴이 Luau 제네릭에서 잘
|
||||
좁혀지는지 실측 필요. **`userdata`는 GC-native 값만 허용,
|
||||
`:Subscribe()`한 Observer류 명시적 cleanup 필요한 값은 UB** —
|
||||
`item`을 nilable로 바꿔 최종 제거 시 정리 훅을 한 번 더 부르는 안은
|
||||
기각(Slot 부모 자체가 Destroy되는 경로에선 이 훅이 전혀 안 불려서
|
||||
절반만 동작, `retract`가 Destroy 시 안 불리는 것과 같은 이유).
|
||||
(2026-08-09 세 번째 세션 확정,
|
||||
`base/slot-plan.md` "`Slot:List(...)`" 절) 구현.
|
||||
(2026-08-09 세 번째 세션 확정, `offset`/raw `index`/세 갈래 구조는
|
||||
2026-08-11 세션 추가 확정, `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(...)`"의 "구독 시점" 절)
|
||||
**`Slot.Offset: Source<number>`도 `Slot.Length`처럼 공개 필드로
|
||||
노출 — Slot 마운트 시점에 `Dispatch.setOffsetSource`가 등록하는
|
||||
바로 그 Source를 `self.Offset`으로도 저장**(2026-08-11 세션,
|
||||
`base/bind-system-plan.md`의 "Slot.Length와 Slot.Offset은 별개" 절)
|
||||
- [ ] base `Dispatch/Slot.luau`(추상 재조정, mount/unmount/reposition 3훅) +
|
||||
quad-roblox `Handlers/Slot.luau`(실제 Parent 조작 + reposition —
|
||||
`SetSiblingIndex` 또는 `LayoutOrder` 기반이면 no-op, 구현 선택)
|
||||
|
|
|
|||
Loading…
Reference in a new issue