quad/.claude/base/slot-plan.md
qwreey-agent-selene a1f948ae34
qa: 구현 전 QA 2라운드 — recompute 손 트레이싱으로 RC-1 발견·해결
`:List` reconcile과 dispatch-core-plan.md의 recompute를 실제로 손으로
실행해보다 RC-1(배열 위치가 순차 등록되는 동안 아직 안 채워진 자리를
읽어 산술 에러가 나는 크래시, 정적 자식 2개짜리 Frame도 재현)을 찾았다.
같은 세션 후속 대화에서 사용자가 제시한 Blocker 재사용 게이팅 설계로
해결 — owner별 전용 Blocker가 배치 등록 동안 recompute를 막고,
setOffsetSource는 등록 즉시 앞선 형제 합을 직접 계산하며, attachSlot의
호출 순서(setOffsetSource→실체화→setLength→물리 마운트)도 바로잡고
코루틴 yield 금지 불변식을 명문화했다. Blocker에는 IsOn()/
OffWithoutEmit()이 새로 생겼다.

/code-review high가 이 diff에서 10건을 더 찾아 반영 — D-7 재역전과의
정합성, filter/nil 재역전 반영 누락, PreRef 가드의 typeof(k) 누락,
New()→Quad() 리네임 전파 누락, M6/M8 마일스톤 오기 등. quad-doc-auditor
감사 루프도 여러 라운드 돌려 새 발견 0건까지 수렴시켰다.

Co-authored-by: qwreey <me@qwreey.moe>
2026-08-18 22:11:59 +09:00

146 KiB

Slot — 뮤터블 자식 배열, 엄격한 단일 마운트 소유권 (base로 승격됨)

[2026-08-13 열네 번째 세션] 하강 diff 재디스패치 반영 완료. 이전 ⚠️ 배너가 예고하던 교체가 끝났음 — 래핑 핸들러의 retractFrom 선행 호출이 폐기되고 Dispatch.process가 핸들러를 먼저 비교하며, SlotHandler가 반환하는 클로저가 받는 값의 타입이 계약으로 보장됨 (같은 핸들러로 재프로세스될 때만 값이 넘어오므로 항상 Slot이거나 nil). 상세는 base/dispatch-core-plan.md "Dispatch 체인" 절, 옛 모델 원문은 archive/dispatch-hintvalue-model-reversed.md.

상태: base — 설계 방향(소유권 귀속, 재마운트 시 throw, [2026-08-13 여섯 번째 세션 역전] retract=언마운트 — 옛 "retract=폐기"는 뒤집혔음, 아래 "State<Slot> 교체는 파괴가 아니라 언마운트" 절이 정본)과 소스 트리 상 패키지 경계까지 확정되어 research/에서 승격됨(base/ architecture.md의 "구현 착수: 소스 트리 구조 확정" 절 참고). 원본: .claude/initreq/raw-userinput.md "slot을 구현하도록 하기로 했음" 절. Fusion의 Children SpecialKey와 Vide의 mount 무가드 비교는 reference/comparison-fusion-vide.md 참고 — 결론: 두 라이브러리 어디에도 이런 엄격한 단일 마운트 가드가 없음, quad의 진짜 개선점. [2026-08-09 세 번째 세션] CRUD 의미론 (pre-implementation-audit.md 1-7/1-8) 완전 확정, research/ additional-primitives-plan.md가 다루던 키 기반 동적 컬렉션 재조정도 Slot:List(...) 메소드로 이 문서에 승격·통합 완료 — 아래 참고. [2026-08-12 열다섯 번째 세션] Splice(index, removeCount, ...newElements) CRUD 신설 — 구간 제거+삽입을 shift/recompute 1회로 묶는 순수 최적화, 아래 "CRUD API 확정" 절 참고.

base/roblox 패키지 경계 (2026-08-04, 5차 라운드 확정)

Slot의 add/remove/clear 재조정 로직(추상 자식 참조 기준 — "이 자리에 뭐가 있어야 하는가"를 결정하는 순수 로직)은 quad-base/src/Dispatch/Slot.luau가 소유. 실제 트리 조작(Instance Parent 설정/Destroy)은 quad-roblox/src/ Handlers/Slot.luau가 그 위에서 적용/해제만 담당 — 다른 모든 인터페이스/구현 분리와 동일한 패턴(base/architecture.md의 소스 트리 참고). Slot 자체는 당연히 Instance들을 담게 될 것으로 취급.

[2026-08-09 세 번째 세션 보강] 이 경계가 담당하는 훅은 mount(Add)/ unmount(Remove) 둘이 아니라 reposition(Move/Swap)까지 셋 — 아래 "CRUD API 확정" 절 참고. reposition은 Parent를 건드리지 않는다는 계약만 base가 강제하고, quad-roblox가 이걸 SetSiblingIndex로 구현할지 (LayoutOrder 기반 정렬이라) 사실상 no-op으로 둘지는 구현 선택.

[2026-08-09 일곱 번째 세션 보강] Dispatch/Slot.luau의 mount 훅 (process(inst,k,self,index))은 Dispatch.setLength(inst,i,self.Length) 호출과 같은 자리에서 self._listedactivateList(self,inst)도 트리거해야 함 — :Listdata:Observer(fn) 구독을 Slot 마운트 시점까지 lazy하게 미루는 것도 이 mount 훅의 책임(아래 "Slot:List(...)"의 "구독 시점" 절 참고). [2026-08-11 세션, superseded] 이 mount 훅은 이제 attachSlot(slotValue, inst, inst, k) 한 줄로 축약됨Dispatch.setLength/activateList 호출은 attachSlot 내부로 옮겨감(로직은 그대로, 재귀 가능하도록만 일반화됨), 상세는 아래 "Slot-in-Slot 중첩" 절 참고.

추가로 필요해진 핸들러: Slot과는 별개로, k가 number이고 v가 이미 만들어진 Instance인 경우(중첩 인스턴스를 자식으로 직접 넣는 경우, 예: Frame { Frame {} })를 위한 핸들러도 필요 — quad-roblox/src/Handlers/ InstanceChild.luau. Slot은 "뮤터블 배열"을 다루고 이 핸들러는 "정적으로 하나 박아넣는" 더 단순한 경우라 별개로 둠.

개념

뮤터블 자식 배열. Slot<T>(initial?)(다른 독립 프리미티브의 Type(args) 관습과 동일 — initial 생략 시 빈 인스턴스, T를 추론할 수 없어 tbox 명시적 제네릭 적용 Slot<<Instance>>()로 지정. initial을 주면 :Add를 반복 호출하는 sugar일 뿐, 상세는 아래 "CRUD API 확정" 절의 생성자 항목 참고)로 만들고, Add/Remove/Extract/Clear/Move/ Swap CRUD로 조작하면 실제 바인드된 children이 그에 맞춰 갱신됨 — 정확한 시그니처는 아래 "CRUD API 확정" 절 참고(get/set은 드롭).

요소 타입 제약 (2026-08-09 세 번째 세션)

  • nil/None 둘 다 금지 — Slot의 raw 요소는 오직 실제 마운트 가능한 T 값만. [정정, 같은 세션 후속] 처음엔 "배열 파트는 nil 대신 None" 원칙을 그대로 가져와 None을 Slot 요소로 허용했었는데, :List의 필터링 요구사항을 구체화하며 재검토한 결과 불필요했음이 드러남 — updateFn이 "이번엔 렌더 안 함"을 표현하는 건 아래 :List 절에서 updateFn의 반환값을 해석하는 :List 자신의 내부 로직으로 처리되고, 그 경우 rawAdd 자체가 아예 호출되지 않음(즉 None이 실제로 Slot 배열에 들어갈 일이 없음) — 그래서 raw Add가 굳이 None을 허용해야 할 이유가 없어짐. element == nil뿐 아니라 element == NoneAdd(및 내부 raw*)에서 즉시 error — "Slot 안엔 실제로 마운트 가능한 값만 들어간다"는 단일 규칙으로 단순화.
  • 핸들러 계층 값(Ref/PreRef/PostRef/Observer/Effect/Modifier) 금지, 즉시 errorModifier 필드가 이 값들을 담으면 즉시 error로 확정했던 것(modifier-plan.md 7번)과 같은 판별 메커니즘(isRef/isPreRef/isPostRef/ isObserver/isEffect/isModifier Brand predicate)을 그대로 재사용. 근거: Dispatch/Leaf.luau가 처리하는 "children 배열에 Ref/Observer/ PreRef가 직접 놓이는" 케이스는 그 컴포넌트가 지금 만들고 있는 Instance 자기 자신을 가리키는 self-ref 캡처(Frame { PreRef():Callback(fn) }가 그 Frame 자신을 잡는 것)라 inst가 "지금 생성 중인 바로 그 하나의 Instance"로 고정돼 있어야 의미가 성립하는데, Slot은 특정 컴포넌트 호출 하나에 묶여있지 않고 이미 존재하는 부모에 나중에 독립적으로 붙는 동적 리스트라 이 전제 자체가 없음 — Slot 안의 Ref가 "무엇"을 가리켜야 하는지 정의가 안 됨. 대체 경로도 이미 있어 능력 손실 없음 — 특정 child에 ref가 필요하면 그 child를 만드는 컴포넌트 호출 자체에 Ref를 넘기면 됨(slot:Add(Frame { Ref = myRef })여기서 FrameRef라는 named 파라미터를 받는 컴포넌트 함수다. Instance 리터럴에 Ref = ...를 named 키로 놓는 건 leaf 바인딩이 아니고, 실제로는 HANDLER_PRIORITY_FALLBACK 가드가 잡아 에러를 낸다 — leaf 바인딩은 배열(숫자 키) 전용, base/ref-plan.md. [명시 추가, 2026-08-18 구현 전 QA]).
  • T의 실제 의미: 위 배제 덕에 "이 Slot이 실제로 담을 수 있는 최종 마운트 가능한 값의 타입" 그 자체로 단순해짐 — quad-roblox엔 사실상 T = Instance 하나뿐(컴포넌트 호출 결과도 결국 Instance)이라 D.InstSlot = Slot<<Instance>>가 사실상 "그" Slot 타입. Slot<T>()가 기본값(T 생략 시) 없이 항상 명시를 요구하는지, quad-base에선 any로 기본값을 두는지는 tbox 제네릭 적용 문법 확정 시 같이 정할 것 (이 문서 "자식으로 넘기는 클래스 스토어" 절의 기존 미결과 같은 갈래). [2026-08-11 세션] Slot<T> 자신도 이제 예외적으로 허용 — 실제로는 T = Instance | Slot<Instance>(자기 참조 제네릭). 컴포넌트 결합 시 "결과가 Instance든 Slot(멀티루트)이든 구분 없이 다른 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 sugarisState(element)면 그 자리에 내부적으로 Slot():Single(element)(nested Slot)를 대신 삽입할 뿐이라, 언랩된 값이 nil이어도(State<T?>) 전혀 문제없음(raw 직접 전달 요소에만 여전히 non-nil 요구 적용, 핸들러 계층 값 금지는 그대로 상속) — 상세는 맨 아래 "반응형 raw 요소" 절 참고, 최초 검토했던 "position-keyed StoreBind 구독" 안은 기각.

핵심 제약: 소유권 귀속과 단일 마운트

Slot에 들어간 요소는 ownership이 귀속되며 다른 곳에 마운트할 수 없게 된다. isMounted를 관리해서, 한 인스턴스에 대한 다중 마운팅이 라이브러리 차원에서 절대 일어나지 않도록 강제하는 게 v1 대비 핵심 디자인 변화. v1의 mount()는 별다른 강제를 안 했지만(reference/quad-v1-architecture.md의 mount.lua 분석 참고 — 실제로는 부모/자식 부기까지 했지만 다중 마운트 방지는 없었음), v2는 mount 함수 자체가 이 강제를 담당.

Fusion의 Children SpecialKey는 이걸 "특정 SpecialKey 하나의 내부 부기"로만 구현했고(재사용 가능한 1급 프리미티브가 아님), Vide는 아예 이 개념이 없어서 같은 target에 두 번 mount()하면 조용히 두 개의 독립 루트가 생김 — 둘 다 반면교사.

isMounted 이중 추적 분리 (1-8 해소, 2026-08-09 세 번째 세션)

"한 인스턴스가 다중 마운팅 절대 안 됨"이라는 위 원칙과 아래 "재마운트 시 즉시 throw"가 원래 하나의 isMounted로 뭉뚱그려 서술돼 있었는데, 실제로는 서로 다른 두 대상을 추적해야 함 — 명시적으로 분리:

  • Slot 컨테이너 자신: self._mounted: boolean(Slot 인스턴스 필드 하나). 트리거 시점은 Dispatch.process(inst,k,self,index)가 이 Slot 객체에 대해 실제로 호출된 순간(핸들러 매치 시점) — Instance Parent 대입 완료를 기다리지 않음. 다른 모든 "마운트됨" 판정(PreRef 소진, Ref 콜백 fire 등)이 전부 dispatch-process 시점 기준이라 여기만 post-effect 기준으로 가면 일관성이 깨짐. 컴포넌트가 Slot을 prop으로 받아 저장만 하고 실제 트리에 안 놓는 경로는 process가 애초에 안 불려서 이 정의로도 오탐 없음. [2026-08-09 일곱 번째 세션 보강] 같은 자리에서 self._mountedInst = inst도 같이 저장 — :List()가 마운트 이후에 호출되는 경우 이 값으로 즉시 활성화(아래 "Slot:List(...)"의 "구독 시점" 절 참고).
  • 개별 element: Slot 안에 담기는 각 element(Instance/컴포넌트 결과 등) 마다 전역 멤버십으로 추적 — 특정 Slot 인스턴스에 안 묶임("한 인스턴스가 어디에도 중복 마운트 안 됨"이 라이브러리 전역 불변식이라서). [구체화, 2026-08-12 열여섯 번째 세션] 이 전역 멤버십의 실제 구현은 아래 "요소 소유권 — elementOwner" 절의 claimOwner/releaseOwnerAddclaimOwner(이미 다른 owner면 error)/Remove·ExtractreleaseOwner. top-level Dispatch 마운트(SlotHandler)도 같은 레지스트리를 써서 두 경로가 서로의 소유권을 볼 수 있음(전에는 별도 레지스트리라 안 보였던 gap, 해당 절 참고).

여럿 존재 가능, 부모가 실제 데이터 테이블만 다루면 됨

Slot은 하나의 instance 안에 여럿 존재할 수 있다. 전부 하나의 children으로 들어가지만, 실제 렌더된 instance에서 GetChildren()을 직접 하지 않고도 부모가 생성한 "실제 slot 데이터 테이블"만 다루면 되게 해서 추상화 수준을 낮은 직접 바인딩에서 한 단계 떼어냄(간접화를 통한 추상화).

마운트된 Slot의 재마운트는 즉시 throw (확정)

사용자 확인 완료: 이미 사용된(마운트된) slot을 다른 위치로 재마운트하려 하면 즉시 error()로 중단 — warn+no-op 아님. 개발 중 바로 잡아낼 수 있게 강하게 실패하는 쪽 선택. 마운트되는 순간 slot의 실제 대상은 고정된다 — 따라서 글로벌 스코프에서 slot을 쓰는 건 그다지 좋지 않을 수 있음(재사용/재마운트가 막히므로).

[명확화, 2026-08-12 열두 번째 세션 메커니즘과 대조] 위 throw는 다른 inst로 마운트하려 할 때만 해당 — 같은 inst로 재-emit되는 경우(store 재발행 등으로 process가 같은 slot을 다시 받는 경우)는 예외적으로 no-op이지 throw가 아니다. 상세 메커니즘은 아래 "Slot과 Store 바인드의 관계" 절의 SlotHandler.process(owner == inst 분기) 참고.

클래스가 슬롯을 받는 방법

"네이밍된 슬롯"이 필요한가에 대한 사용자 자문: 그냥 슬롯 바인드 테이블을 값으로 넘기면 되는 것 아닌가 — 결국 array처럼 구현된 Store라고 생각하는 게 편하다는 방향. 기울어진 결론: 별도 "Named Slot" 개념 없이, store나 파라미터로 넘기고 그게 그냥 ref처럼 바인드되는 모양.

확정(2026-08-04, 로드맵 인수인계 라운드): 위 방향 그대로 확정 — 별도 "Named Slot" 개념 없음, 슬롯 바인드 테이블을 store나 파라미터로 그냥 넘기면 ref처럼 바인드됨 — 사용자 확정("A. 맞음. 리프노드에선 그렇게 마운트됨"). 단, 이 확정은 "리프 레벨에서 슬롯 하나가 마운트되는 방식"에 한정 — 여러 Slot이 형제로 섞이는 경우의 순서 보장 문제는 별도로 열려있음, 바로 아래 참고.

여러 Slot이 섞일 때 순서 보장 — 해소됨 (2026-08-09 여섯 번째 세션)

Frame { Slot1, 일반자식, Slot2 }처럼 Slot과 Slot 사이에 다른 요소가 끼거나 Slot이 여럿 형제로 존재할 때, 최종 자식 순서가 저작 순서(위쪽 Slot의 요소가 항상 아래쪽 Slot의 요소보다 앞)를 안정적으로 지키는지가 2026-08-04부터 열려 있었던 질문 — 메커니즘 확정으로 해소됨: Dispatch.setLength/ Dispatch.setOffsetSource + 형제별 개수 누적합(offset)을 리액티브 프로퍼티(Roblox LayoutOrder)에 바인딩하는 방식 — 상세는 base/ dispatch-core-plan.md의 "Length/Offset — 여러 Slot이 형제로 섞일 때 순서 보장" 절 참고. DOM류 물리 순서 백엔드에도 같은 base 메커니즘이 그대로 재사용됨(offset이 바뀌어도 이미 마운트된 원소를 물리적으로 옮길 필요 없음 — insertBefore가 뒤 형제를 자연히 밀어주므로, backend Handler의 "offset 변경 시 할 일"만 no-op으로 달라짐) — architecture.md의 "패키지 경계"가 세운 "다른 렌더 백엔드에서도 재사용 가능해야 한다"는 전제와도 부딪히지 않음.

Slot과 Store 바인드의 관계 (retract 순서)

Slot이 store 바인드로 들어오는 경우, pluggable 처리기에 retract(구 cleanup, base/lifecycle-pattern.md 참고) 핸들러가 필요함 — 한번 넘어간 slot 요소가 나중에 retract되면 삭제되는지, 아니면 "부모의 소유이니 부모가 처리"해야 하는지 검토 필요. 기울어진 결론(잠정안, 이후 정정됨): 부모가 정리 정도만 미리 수행하고 다시 process하면 되므로, 부모에게 위임(자식 slot 요소 자체가 스스로 정리를 실행하는 게 아니라).

정정(2026-08-04 검증 라운드): 위 "부모 위임" 잠정안은 당시 폐기 쪽으로 정정됐었음. [재역전, 2026-08-13 여섯 번째 세션] 그 "폐기"마저 다시 뒤집혀 지금 정본은 retract=언마운트(파괴 아님) — 이 문서 맨 위 상태 배너, archive/question-resolved.md 맨 끝 확정 요약 표의 base/slot-plan.md 행, 그리고 아래 "State<Slot> 교체는 파괴가 아니라 언마운트" 절 참고(역전된 "폐기" 원문·역전 근거는 archive/slot-discard-no-portal-reversed.md). 이 문단은 검토 과정의 히스토리로만 남겨둠, 두 잠정안 모두 현재 유효한 동작 아님.

[전면 정정, 2026-08-12 열한 번째 세션, 2026-08-13 다섯 번째/열네 번째 세션에 서술 갱신] 위 "핸들러 타입이 안 바뀌면 retract 없이 process가 diff 담당"이라는 전제 자체가 틀렸음 — store 재발행마다(핸들러가 그대로여도) 항상 이전 process가 반환한 클로저가 먼저 불림. [갱신, 열네 번째 세션] 부르는 주체는 Dispatch.process 자신(같은 핸들러면 그 자리 클로저에 새 값을 넘기고 곧바로 process를 다시 부름 — base/dispatch-core-plan.md "Dispatch 체인" 절 (A) 분기). 그래서 State<Slot>slotA→slotB로 바뀔 때 slotA를 처리했던 클로저가 nextValue=slotB로 먼저 불려 slotA를 언마운트하고, 그 다음 process(inst,k,slotB,index)slotB를 마운트하는 두 단계로 자연히 갈림 — 클로저가 "이전 것 정리", process가 "새 것 마운트" 전담:

[정정, 2026-08-12 열두 번째 세션] "같은 값인가"를 위치별 relate로 간접 비교하는 대신, Slot 자신이 지금 어느 inst에 바인딩됐는지를 직접 추적 — 이게 이미 확정된 "한 element가 어디에도 중복 마운트 안 됨" 전역 불변식(위 "요소 타입 제약" 절)을 Slot 컨테이너 자신에도 그대로 적용하는 것이라 더 정확함(위치 비교로는 "이 Slot이 동시에 다른 위치에도 마운트돼 있는가"를 못 잡음).

[GC 주의, 2026-08-12 열세 번째 세션 — 아래 문단은 이 결정에 이르게 된 역사적 근거이고, 거기 나오는 kSlotMap/slotOwner는 지금은 둘 다 존재하지 않음: kSlotMap은 2026-08-13 다섯 번째 세션에 Handler 계약이 클로저 반환으로 바뀌며 삭제, slotOwner는 2026-08-12 열여섯 번째 세션에 elementOwner로 일반화. 결론(= 전부 weak, 실제 앵커는 bindLifetime 하나)만 아래 코드에 그대로 살아있음] kSlotMap/slotOwner 둘 다 Slot을 SetStrong으로 저장하면 안 됨 — kSlotMap[inst][k]=slot(강)과 slotOwner[slot]=inst(강)가 동시에 있으면 서로 다른 두 Relate가 맞물려 서로를 살려주는 순환이 생김(inst가 살아있어야 slotOwnerslot을 붙잡고, slot이 살아있어야 kSlotMapinst를 붙잡는 식 — 둘 다 서로에게만 기대면 어느 쪽 reachability도 외부에서 못 끊음). 이건 bindLifetime이 이미 쓰는 "한 Relate 안에서 값이 자기 키를 다시 참조하는" 패턴(Dispatch.setLengthobserver가 클로저로 inst를 캡처하는 것, Ref.Value=inst 등)과는 다른, 더 위험한 모양이다 — 단일 테이블 자기참조는 그 테이블의 키(inst)가 이 테이블 바깥에서부터 독립적으로 reachable한지만 판별하면 끝나지만, 두 개의 별도 weak 테이블이 서로의 키를 상대방 값으로 제공하는 상호 순환은 그 판별 자체가 서로에게 의존해버려 일반적인 weak-table GC로 한 번에 안 풀림(Lua 5.2+ ephemeron이 풀려고 만들어진 바로 그 사례). [확인, 2026-08-12 열네 번째 세션] Luau는 ephemeron 테이블이 없음 — 복잡성 때문에 도입 안 함, 공식 문서로 확인됨(출처: https://luau.org/compatibility/ "Lua 5.2" 섹션, "Ephemeron tables" 항목). 즉 이 회피는 "혹시 몰라서"가 아니라 Luau에서 실제로 이 순환이 안 풀린다는 게 공식 소스로 확정된 필수 조치 — 설계로 아예 피함. 해법: 실제 GC 앵커는 bindLifetime/unbindLifetime(이미 결정돼 있었는데 attachSlot/destroySlotTree에 적용이 안 돼 있던 부분, 이번에 추가) 하나로만 두고, kSlotMap/slotOwner는 전부 SetWeak(순수 조회용, 아무것도 안 붙잡음)로 낮춤:

**[일반화, 2026-08-12 열여섯 번째 세션] slotOwner는 top-level Dispatch 마운트만 보고 있어서, Slot-in-Slot으로 nested Add되는 경로(아래 "요소 소유권 — elementOwner" 절)와 서로 다른 레지스트리라 어느 한쪽이 이미 소유 중인 걸 다른 쪽이 못 보고 이중 마운트를 허용하는 gap이 있었음 (사용자 발견) — slotOwner를 element(범용, Slot이든 plain Instance든) 전체를 커버하는 elementOwner로 승격해 top-level Dispatch 경로와 nested CRUD 경로가 같은 레지스트리를 쓰도록 통합:

local elementOwner = Relate()  -- element(Slot이든 plain 마운트 가능 값이든) 전체 공용
                                -- {[element] = ownerKey}  -- ownerKey: inst | Slot, 전부 weak
local OWNER = "__owner"        -- sentinel key(Relate는 항상 3-인자 SetWeak/2-인자 GetWeak 이후
                                -- 값이라 outer key 하나당 값 하나만 저장하고 싶어도 key가 필요함 —
                                -- lifecycle-pattern.md의 GCCONN/GCHOLD와 같은 패턴, base/relate-plan.md 참고)
local OWNER_POS = "__ownerPos"  -- [2026-08-13 감사 신설] owner 안에서의 위치(top-level만 씀, 아래 참고)

-- nested(`rawAdd`) 전용 — 엄격, 이미 누가 갖고 있으면 같은 owner여도 error
local function claimOwner(element, ownerKey)
    if elementOwner:GetWeak(element, OWNER) ~= nil then
        error("이 요소는 이미 마운트돼 있음 — 다중 마운트 금지")
    end
    elementOwner:SetWeak(element, OWNER, ownerKey)
end

-- top-level(`SlotHandler`) 전용 — "같은 (inst,k) 자리의 spurious 재발행"만
-- false, 그 외 중복은 전부 error. 반환값 = 실제로 새로 클레임했는가.
local function claimOwnerAt(element, inst, k)
    local current = elementOwner:GetWeak(element, OWNER)
    if current == inst and elementOwner:GetWeak(element, OWNER_POS) == k then
        return false  -- 정확히 이 자리가 이미 들고 있음 — 재확인만, no-op
    end
    if current ~= nil then
        error("이 요소는 이미 다른 곳에 마운트돼 있음 — 다중 마운트 금지")
    end
    elementOwner:SetWeak(element, OWNER, inst)
    elementOwner:SetWeak(element, OWNER_POS, k)
    return true
end

local function releaseOwner(element, ownerKey)
    -- [정정, 2026-08-13 세션] 불일치를 조용히 무시하지 않음 — claim이 항상
    -- 먼저 성공해야만 이 element를 이 ownerKey가 들고 있을 수 있으므로, 여기서
    -- 불일치가 관측되는 것 자체가 호출측(rawRemove/rawExtract/SlotHandler.process가 반환한 클로저)의
    -- 소유권 bookkeeping이 어딘가 깨졌다는 뜻 — Dispatch의 "매치 실패는 조용한
    -- 무시 없이 즉시 error" 원칙과 같은 결로 즉시 error.
    local current = elementOwner:GetWeak(element, OWNER)
    if current ~= ownerKey then
        error("releaseOwner: 이 element는 이 ownerKey가 소유하고 있지 않음 — 호출측 소유권 추적이 깨졌음")
    end
    elementOwner:SetWeak(element, OWNER, nil)
    elementOwner:SetWeak(element, OWNER_POS, nil)
end

function SlotHandler.process(inst, k, slotValue, index)
    if claimOwnerAt(slotValue, inst, k) then
        -- [정정, 2026-08-13 세션] bindLifetime을 attachSlot 밖, 여기(top-level Handler)로
        -- 이동 — 반환하는 클로저 쪽 unbindLifetime과 같은 층위(Handler)로 대칭. 중첩
        -- Slot은 여전히 anchor 불필요(자신을 담는 outer의 `_elements`(plain strong
        -- array)로 이미 transitively 살아있음 — elementOwner는 전부 weak라 별도 anchor
        -- 아님) — 그 구분은 이제 "attachSlot을 top-level에서 부르는 이 자리에서만
        -- bindLifetime한다"는 호출부 자체의 위치로 표현됨, attachSlot 내부 분기가 아님.
        bindLifetime(inst, slotValue)
        attachSlot(slotValue, inst, inst, k)
    end
    -- 여기 도달했다는 건 "이 (inst,k) 자리가 slotValue를 소유 중"이 보장된 것 —
    -- 방금 새로 클레임했든, 직전 사이클의 클레임이 spurious 재발행을 거쳐 그대로
    -- 살아있든 둘 중 하나(다른 소유자였다면 claimOwnerAt이 이미 error). 어느 쪽이든
    -- "이 자리가 결국 다른 값으로 바뀔 때 할 일"은 slotValue/inst가 같아서 동일하므로
    -- 클로저도 하나로 통일 — 여기서 no-op 클로저를 반환하면 안 됨: 체인은
    -- 클로저를 early-return시키든 말든 항상 *소비*하므로(retractFrom도, 재프로세스도),
    -- spurious 사이클에서 no-op을 심어두면 그 다음 진짜 교체 때 이전 서브트리를
    -- 정리할 주체가 사라짐.
    return function(nextValue)
        -- nextValue는 nil이거나 같은 핸들러가 곧 처리할 Slot — 타입 보장됨
        if slotValue == nextValue then return end  -- 같은 Slot 재발행 → no-op
        -- [정정, 2026-08-13 감사 후속] 파괴가 아니라 **언마운트** —
        -- 아래 "`State<Slot>` 교체는 파괴가 아니라 언마운트" 절이 확정한
        -- 결정이 적용돼야 하는 자리가 바로 여기(최상위 dispatch 경로).
        -- 예전엔 destroySlotTree를 불러 그 결정과 정면으로 모순됐음.
        unmountSlotTree(slotValue)
        -- 이 위치가 더 이상 기여하지 않음을 owner에게 알림 — **순서 고정**
        -- (setLength가 끝에서 gatedRecompute를 경유해 recompute를 돌리므로
        -- offsetSource를 먼저 비워야 죽는 중인 Source에 헛된 :Set()이 안 감,
        -- 아래 ⚠️ 절 참고)
        Dispatch.setOffsetSource(inst, k, None)
        Dispatch.setLength(inst, k, 0)
        unbindLifetime(slotValue)  -- top-level 자신의 GC 앵커 해제 — SlotHandler.process의 bindLifetime과 짝
        releaseOwner(slotValue, inst)    -- 이제 이 Slot은 아무에게도 안 묶임(다른 곳에 다시 넣을 수 있음)
    end
end

언마운트된 Slot은 그대로 재사용 가능_elements와 그 안의 소유권이 전부 보존되므로(아래 unmountSlotTree 참고), slot을 들고 있던 코드가 다른 곳에 다시 넣으면 attachSlot이 새 물리 부모로 다시 flush함. 아무도 안 들고 있으면 그냥 GC. 지금 확실히 죽이려면 dispose(아래 절).

[전면 정정, 2026-08-13 감사] claimOwner를 nested/top-level 두 함수로 쪼개고, top-level은 (inst, k)까지 본다. 원래는 하나의 claimOwner가 "같은 ownerKey면 false 반환(no-op)"을 양쪽에 공유했는데, 그 분기가 두 개의 서로 다른 상황을 구분 못 해서 양쪽 경로에 각각 버그를 만들고 있었음:

  • top-level: Frame { slot, slot }(같은 Slot을 같은 inst의 두 위치에)에서 k=2current == inst라 조용히 false를 받아 attach를 건너뛰는데, 그러고도 파괴적인 클로저를 반환했음 — 나중에 철거될 때 destroySlotTree가 두 번 돌고 unbindLifetime 짝이 어긋나며 releaseOwner가 (이미 해제된 상태라) error로 터짐. 구 설계는 kSlotMap에 안 적힌 자리는 retract가 자연히 no-op이라 우연히 막혀 있었는데, kSlotMap 제거가 이 방어를 같이 걷어낸 회귀였음. 이제 claimOwnerAtk=2에서 곧바로 error를 내므로 그 상태 자체가 안 만들어짐 — 클로저를 두 갈래로 쪼개는 방식으로는 못 고침: 체인은 클로저가 early-return하든 말든 항상 소비하므로, spurious 사이클에서 no-op 클로저를 심으면 다음 진짜 교체 때 이전 서브트리를 정리할 주체가 사라져 오히려 더 큰 누수가 됨(감사 중 실제로 그 방향을 먼저 써봤다가 되돌림).
  • nested: rawAdd는 애초에 claimOwner의 반환값을 안 봄(아래 "요소 소유권" 절 호출부) — 그래서 local a = Slot{}; Slot { a, a }가 조용히 통과해 _elements = {a, a}가 되고, attachSlot이 같은 Slot에 대해 두 번 불려 부모 lengthList에 같은 Slot이 두 번 계산되고 slot.Offset도 두 번째 호출이 덮어써 첫 번째 Source가 고아가 됨.

해법의 근거 — nested엔 "재클레임"이라는 개념이 애초에 없다. :Listreconcile은 요소를 바꿀 때 항상 rawUnmount(prev)(→releaseOwner) 다음에 rawAdd(result)(→claimOwner) 순서로 부르고(아래 "구현" 절 의사코드 — [정정, 2026-08-13 여섯 번째 세션] 예전엔 rawRemove(파괴)였으나 언마운트 전환으로 바뀜, rawUnmountreleaseOwner를 똑같이 부르므로 이 소유권 논증 자체는 그대로 성립), rawMove/rawSwap은 클레임을 아예 안 건드림 — 즉 nested에서 "이미 내가 갖고 있는 걸 다시 클레임"하는 정당한 경로가 하나도 없으므로 무조건 error가 맞음. 반대로 top-level은 store 재발행마다 같은 Slot으로 process가 다시 불리는 게 정상 경로라 그 케이스만 구분해줘야 하고, 그러려면 owner 하나로는 부족해서 위치(k)까지 봐야 함.

위치를 키에 넣어도 안전한 이유(사용자 확인) — 여기서 쓰는 k는 "바깥 컨테이너 안에서의 인덱스"고, 이건 nested Slot의 Length가 변해도 바뀌지 않음. 실제 물리 배치의 변동은 전부 offset이 흡수하도록 설계돼 있음(base/dispatch-core-plan.md "Length/Offset" 절의 recompute가 그 증거 — lengthList/sourceList는 위치별 배열이고 순서 계산만 누적합으로 함). top-level의 k는 특히 props 배열 리터럴의 위치라 저작 시점에 고정. nested는 Move/Swap/Splice/Remove_elements 인덱스를 실제로 밀고 당기지만, 위 결론대로 nested는 위치를 아예 안 쓰므로(엄격 claimOwner) 무관 — 그래서 OWNER_POS는 top-level 전용이고 rawAdd 경로는 건드릴 필요가 없음.

attachSlot 자체가 quad-roblox 소속이라 inst를 아는 건 자연스러움 — elementOwner가 굳이 inst의 정체를 몰라도(예: 다른 백엔드에서 중간 표현 테이블이어도) 무관하게 동작함, 그냥 "지금 이 자리를 차지한 값이 누구냐"만 구분하면 됨.

요소 소유권 — elementOwner, nested Add/top-level Dispatch 공용 (2026-08-12 열여섯 번째 세션)

문제(사용자 발견): 위 elementOwner가 승격되기 전엔 top-level Dispatch 마운트(SlotHandler.process)만 slotOwner를 봤고, Add가 확인한다는 "개별 element 전역 weak-set"(구 "isMounted 이중 추적 분리" 절)은 완전히 별개 레지스트리로 서술만 있고 코드가 없었음 — 그래서 slot1을 top-level store-bind하면 slotOwner만 찍히고, 그 다음 otherSlot:Add(slot1)을 하면 Add가 보는 레지스트리엔 아무것도 없어 그대로 통과 → 같은 slot1이 두 군데 물리적으로 마운트되는데 에러가 안 남(반대 순서도 동일하게 뚫림) — "핵심 제약: 소유권 귀속과 단일 마운트" 절의 라이브러리 전역 불변식이 실제로는 안 지켜지던 gap.

해법: 위에서 승격한 elementOwner/claimOwner/releaseOwner를 Slot뿐 아니라 **모든 마운트 가능 element(plain Instance 포함)**의 소유권 판정에 공용으로 씀 — top-level(SlotHandler.process와 그 반환 클로저)과 nested(rawAdd/rawRemove/rawExtract)가 정확히 같은 함수, 같은 Relate를 호출하므로 어느 경로로 먼저 클레임하든 다른 경로가 반드시 봄:

-- rawAdd(self, element, index) 안, "이미 마운트" 에러 체크 자리
claimOwner(element, self)  -- self = 담는 Slot. 이미 누가(같은 self 포함) 소유 중이면 여기서 error

-- rawRemove(self, index)/rawExtract 안, 요소를 내보내는 자리
releaseOwner(element, self)

-- destroySlotTree(slot) 안, 자식들을 파괴하기 직전(아래 "파괴" 절)
releaseOwner(element, slot)

[2026-08-13 감사] claimOwner는 반환값이 없음 — 성공 아니면 error다. 예전 버전은 "이미 같은 owner면 false 반환"이었고 rawAdd 호출부는 그 반환값을 아예 안 봐서, local a = Slot{}; Slot { a, a }가 조용히 통과했음 (위 "Slot과 Store 바인드의 관계" 절의 전면 정정 참고). nested 경로엔 재클레임이 정당한 경우가 하나도 없으므로(reconcile은 항상 rawUnmountrawAdd 순서 — [정정, 2026-08-13 여섯 번째 세션]rawRemove(파괴)에서 바뀌었으나 둘 다 releaseOwner를 부르므로 논증 동일, rawMove/rawSwap은 클레임 미접촉) 무조건 error가 맞음 — top-level만 claimOwnerAt으로 spurious 재발행을 구분함.

[2026-08-13 감사] 소유권 반납은 GC에 맡기면 안 됨. elementOwner는 값도 SetWeak이라 "파괴된 Slot이 아무에게도 참조되지 않게 되면 소유권 기록도 저절로 사라진다"가 원리적으로는 맞지만, 그게 언제인지가 GC 타이밍에 달려 있어서 그 전에 같은 element를 다른 곳에 넣으려 하면 "이미 마운트돼 있음" error가 비결정적으로 터짐(사용자가 직접 들고 있는 nested Slot을 파괴 후 재사용하는 경로가 정확히 이 케이스). 그래서 rawRemove/rawExtract뿐 아니라 destroySlotTree도 자기 자식들의 releaseOwner를 명시적으로 부름 — 원래 이 함수는 소유권을 전혀 안 건드리고 있었음. top-level Slot 자신의 반납은 SlotHandler.process가 반환한 클로저가 담당(층위 분리는 unbindLifetime과 동일한 원칙).

ownerKeyinst(top-level)든 Slot(nested)든 elementOwner는 타입을 신경 안 써서 하나의 레지스트리로 충분 — outerSlot이 값으로 들어가도 elementOwner 자체는 아무것도 강하게 안 붙잡고(전부 SetWeak), 실제 강한 참조는 outerSlot._elements(plain array, Relate 아님)가 이미 쥐고 있으므로 relate-plan.md가 경고하는 "두 Relate 상호 강참조" 패턴과 다른 모양 — GC 문제 없음.

  • 같은 바인딩이면 완전히 무시하는 게 이 자리에선 효율 문제가 아니라 정합성 문제 — 이 no-op 가드가 없으면 [정정, 2026-08-13 4차 감사] 재귀 재emit이 있을 때마다 마운트된 서브트리 전체가 언마운트됐다 다시 마운트됨(작성 당시엔 아직 destroy 모델이었음, 아래 "State<Slot> 교체는 파괴가 아니라 언마운트" 절의 언마운트 전환 참고 — 물리 트리 이탈+재마운트라 여전히 자식들이 들고 있던 스크롤 위치/포커스/애니메이션 상태는 유실됨, 파괴가 아니라는 것만 정정) — Tag의 Contains 힌트가 막으려던 "깜빡임" 문제보다 훨씬 파급이 큰 버전. store.key:Set(sameSlotAgain)처럼 사용자가 실수로 같은 객체를 다시 emit하거나, 상위 :Compute가 재계산됐는데 결과가 우연히 같은 Slot 레퍼런스인 경우 등이 실제로 이 경로를 탈 수 있음. retractold ~= v 체크로 이걸 막고, processold == slotValue 체크로 대칭적으로 막음 — 둘 다 스킵돼야 완전한 no-op.
  • Slot 핸들러 자신이 감시 중인 값(배열/스토어)이 바뀔 때 child를 갱신하는 추적(구독)도 base/dispatch-core-plan.md가 말하는 "process 함수가 다른 값 변경을 추적해도 됨" 범위에 속하고, retract 시점엔 그 추적만 풀면 됨 — Destroy 시점엔 retract가 호출되지 않는다는 원칙(base/lifecycle-pattern.md)도 동일하게 적용.

🔄 [역전됨, 2026-08-13 여섯 번째 세션 — 원문은 archive로 이동] 여기 있던 "retract/재바인드되는 slot은 그냥 폐기된다"+"portal은 오버엔지니어링이라 안 함" 확정은 뒤집혔습니다 — 지금은 State<Slot> 교체가 파괴가 아니라 언마운트이고, portal은 별도 기능이 아니라 그 결정의 자연스러운 귀결입니다. 정본은 이 문서 아래쪽 "State<Slot> 교체는 파괴가 아니라 언마운트" 절(+ unmountSlotTree). 원문·역전 근거·파급 범위는 archive/slot-discard-no-portal-reversed.md.

범위 명확화(2026-08-09 세 번째 세션, 이 조항은 지금도 유효): 위 (지금은 역전된) "폐기, 옮기지 않음"은 프레임워크가 store-bind 재실행으로 Slot 값 전체를 통째로 갈아치울 때(retract)만의 얘기 — 사용자가 직접 Slot:Extract(element)를 부르는 CRUD 경로는 이것과 다른 시나리오다. Extract로 뺀 element는 파괴되지 않고 호출부가 소유권을 되찾으며, 임의의 다른 Slot으로 자유롭게 다시 Add할 수 있다(아래 "CRUD API 확정" 절) — retract가 "옮기지 않는다"고 확정한 건 프레임워크가 알아서 옮겨주는 자동 portal을 안 만든다는 뜻이지, 사용자가 명시적으로 두 번 호출(ExtractAdd)해서 옮기는 것 자체를 막는 게 아니다.

CRUD API 확정 (2026-08-09 세 번째 세션, 1-7 해소)

[정정, 2026-08-09 열한 번째 세션] 식별 기준을 element 레퍼런스에서 인덱스 기준으로 전환. 원래 "인덱스는 add/remove 반복 시 곧 stale 해진다"는 이유로 레퍼런스 기준을 택했으나, 실사용에서는 반대 문제가 더 흔함(사용자 지적) — slot:Add(Frame{...})처럼 호출부가 리턴값을 변수에 안 담고 바로 흘려보내는 경우가 많아서, 나중에 그 element를 다시 골라 Remove/Extract/Move하려 해도 참조를 안 들고 있는 경우가 잦음. Add만 새로 넣는 대상이라 자연히 element를 직접 받고, 나머지 CRUD는 전부 인덱스 기준으로 재확정 — 레퍼런스만 갖고 있으면 IndexOf로 먼저 인덱스를 구하면 됨(아래):

연산 시그니처 복잡도 의미
Add Slot:Add(element, index?): number O(n) 삽입(뒤 요소 밀림), index 생략 시 끝에 추가 — 실제로 삽입된 인덱스를 반환
Remove Slot:Remove(index) O(n) 제거 + 파괴(retract/Destroy) — Extract(index):Destroy()와 동치, 흔한 경로라 별도 이름으로 유지
Extract Slot:Extract(index, newElement?) O(n) 또는 O(1) newElement 생략 — 제거만(파괴 안 함), 뒤 요소가 당겨져 빈 자리를 메움(O(n)). newElement 지정 — 그 자리를 즉시 교체(뒤 요소 안 건드림, O(1)), 이전 element를 반환
ExtractAll Slot:ExtractAll(): {T} O(n) 전체 추출(파괴 안 함) — Clear의 비파괴 버전, 추출된 element 배열(순서 보존)을 반환
Splice Slot:Splice(index, removeCount, ...newElements): {T} O(n) 한 위치에서 removeCount개를 비파괴 추출(반환)하고 그 자리에 newElements를 삽입 — shift+recompute 1회로 통합
Clear Slot:Clear() O(n) 전체 Remove(전부 파괴) — 빈 Slot에 호출해도 no-op
Move Slot:Move(oldIndex, newIndex) O(n) 제자리 재배치 — 옛/새 위치 사이 요소들이 밀림/당겨짐(배열 splice와 동일 의미), Parent 안 건드림
Swap Slot:Swap(indexA, indexB) O(1) 두 인덱스의 요소를 맞교환, 나머지 안 건드림, Parent 안 건드림
Get Slot:Get(index): T? O(1) 그 인덱스의 element 조회(범위 밖이면 nil)
IndexOf Slot:IndexOf(element): number? O(n) element의 현재 인덱스 역조회(멤버 아니면 nil) — 레퍼런스만 있고 인덱스가 없을 때 다른 CRUD와 연결하는 다리
  • Add가 삽입된 인덱스를 반환하는 이유(2026-08-10 세션 확정)index를 생략(끝에 추가)하면 호출부가 실제 위치를 모르는데, 그걸 알아내는 유일한 방법이 IndexOf(element)(O(n))뿐이었음 — Add는 그 값을 삽입 과정에서 이미 계산하므로 반환은 공짜. index를 명시적으로 넘긴 호출에서는 반환값이 그냥 echo라 다소 중복이지만, "항상 최종 인덱스를 반환"으로 시그니처를 통일해 분기 없이 단순하게 둠. Move/ Swap이 void인 것과 모순 아님 — 그 둘은 호출부가 이미 위치를 알고 부르는 연산이라 새로 알려줄 정보가 없어서 void인 것이고, Add는 반대로 새 정보(계산된 위치)가 생기는 경우라 "반환값은 실제로 새로 알게 되는 정보만"이라는 같은 원칙의 연장.
  • Extract(index, newElement?)가 존재하는 이유 — 인덱스 기준 모델에서 "요소 하나를 다른 걸로 교체"하려면 Extract(index)(O(n) 시프트) 후 Add(newElement, index)(O(n) 시프트 재발생)를 따로 불러야 해서 이중으로 무거움. newElement를 같이 넘기면 그 자리 값을 시프트 없이 바로 갈아끼우기만 하면 되므로 훨씬 쌈 — 별도 Set이라는 이름 대신 Extract의 확장으로 둔 이유는 반환값이 "이전 element"라는 의미가 Extract와 정확히 같아서(교체도 "그 자리 걸 빼내고 새 걸 넣는" 것의 원자적 버전일 뿐). newElement에도 Add와 같은 검증(이미 마운트/타입 제약)이 똑같이 적용됨.
  • Splice 신설(2026-08-12 열다섯 번째 세션) — 한 구간을 제거하고 동시에 새 요소들을 그 자리에 넣는 흔한 배치 갱신(:List 없이 수동 CRUD로 큰 구간을 통째로 교체하는 경우)을 Extract/Add를 요소 수만큼 반복 호출하면, 그때마다 개별 raw* 호출이 각자 shift+recompute를 돌려 O(n) 비용이 반복 횟수만큼 곱으로 커짐 — Splice는 이걸 시프트 1회 + recompute 1회로 묶는 순수 최적화(새 능력 추가 아님, Extract 반복+Add 반복으로도 결과는 항상 재현 가능). 비파괴(Extract처럼 제거분을 파괴하지 않고 반환) — 실제 물리적 detach/reattach(Roblox Parent 조작, AncestryChanged 발화 등)를 언제 어떻게 할지는 base가 정하지 않고 quad-roblox 등 백엔드 Handler 엔드포인트가 처리(기존 "base는 추상 재조정 로직, backend는 실제 트리 조작"이라는 패키지 경계 원칙 그대로 재적용, 새 분리 아님). 제거된 구간이 뒤 요소를 당기고 삽입된 구간이 다시 밀어내는 게 순수하게 겹치면 상쇄되는 부분이 있어 Extract 반복 + Add 반복보다 실제 이동 계산량도 더 적음. newElementsTag:Added와 달리 의도적으로 vararg 유지, T | {T}로 안 바꿈(같은 세션 후속 논의). Tag:Addedstring | {string}로 간 이유(조건절로 조립한 여러 동적 테이블을 한 호출로 합칠 수 없는 vararg의 구조적 한계, 위 tag-plan.md 참고)가 여기도 적용되는지 검토했으나 기각 — (1) 실사용 패턴이 다름: 한 번에 갈아끼우는 요소 개수는 호출부가 이미 아는 소수인 경우가 대부분이고, 정말 개수를 모르는 동적 삽입이면 Slot:Add가 이미 받는 State<T>/nested Slot 요소로 흡수 가능 (Slot-in-Slot, 위 "반응형 raw 요소"/"Slot-in-Slot 중첩" 절) — Splice 자체가 동적 배치를 떠받칠 이유가 없음. (2) T | {T}가 여기선 오히려 틀림 — 이유는 "SlotT가 우연히 테이블(Instance | Slot<Instance>) 이라서"가 아니라, Slot<T>가 base 레벨에선 T가 뭔지 전혀 모르는 제네릭이기 때문(다른 백엔드면 테이블일 수도, userdata일 수도, 그 밖에 뭐든 될 수 있음)TagT=string으로 항상 고정·확정돼 있어 type(v) == "table" 분기가 "배열이냐 아니냐"를 안전하게 구분하지만, Splice(idx, len, {item1, item2}){}가 "그 자체로 하나의 T 값(마침 테이블로 표현된)"인지 "펼쳐야 할 {T} 배열"인지는 T가 뭔지 base가 애초에 모르므로 원천적으로 판별 불가능(quad-roblox에서 TSlot이 섞여있는 건 이 문제를 드러내는 한 사례일 뿐, 근본 원인이 아님). 억지로 하려면 결국 항상 명시적으로 감싸거나 풀어야 해서 vararg 대비 얻는 게 없음. 이미 {T} 배열을 들고 있는 호출부는 Splice(i, n, table.unpack(list))로 충분(리스트가 하나뿐이라 tail-position 제약에도 안 걸림).
  • Get/IndexOf 신설, 원래 "YAGNI"로 뺐던 것을 재추가. 처음엔 ":List가 자기 key→element 맵을 따로 들고 있어 Slot 내부 상태 조회가 불필요"하다고 판단해 드롭했으나, 위 인덱스 기준 전환과 맞물려 다시 필요해짐 — element 레퍼런스만 갖고 있는 호출부가 인덱스 기반 CRUD를 쓰려면 IndexOf가 유일한 다리. Get은 대칭성/일반적인 컬렉션 API 완결성을 위해 같이 열어둠(필수까진 아니지만 비용이 거의 없어 열어둠).
  • raw* 내부 호출 규약은 공개 API와 다를 수 있음(구현 세부, M6에서 확정):List의 reconcile은 이미 자기 key→element 맵을 들고 있어서 rawRemove/rawMove 등을 element 기준으로 계속 부를 수도 있음. 공개 CRUD가 인덱스를 받아 내부적으로 element를 찾아 raw*에 넘기는 얇은 변환 계층이 될지, raw* 자체를 인덱스 기준으로 통일할지는 base 설계가 못박을 필요 없는 구현 디테일.
  • 에러 조건 — 전부 즉시 error(), no-op 없음(기존 "재마운트 시 throw"와 같은 fail-fast 톤):
    • Add: element가 이미 어딘가(같은 Slot이든 다른 Slot이든) 마운트돼 있으면 에러 — "라이브러리 차원에서 다중 마운팅 절대 금지" 원칙을 CRUD 경로에도 동일 적용. elementnil/None이거나 핸들러 계층 값(Ref/PreRef/PostRef/Observer/Effect/Modifier)이면 에러 — 위 "요소 타입 제약" 절. index가 범위 밖(1..현재 개수+1, 즉 끝에 추가하는 위치까지 포함)이면 에러 — clamp 안 함(2026-08-10 세션 확정): index가 조용히 다른 자리로 보정되면 "의도한 위치가 아닌데 그대로 성공한" 조용한 버그가 생기고, 이미 다른 CRUD 전부가 fail-fast인 것과도 불일치함.
    • Remove/Extract/Move: index(들)가 범위 밖(1..현재 개수)이면 에러.
    • Extract(index, newElement): newElementAdd와 동일한 검증 (이미 마운트/타입 제약) 적용.
    • Splice(index, removeCount, ...newElements): indexAdd와 같은 범위(1..현재 개수+1), removeCountindex부터 실제 남은 개수를 못 넘으면 에러(음수도 에러) — clamp 안 함, 나머지 CRUD와 같은 fail-fast 톤. newElements 각각에 Add와 동일한 검증(이미 마운트/ 타입 제약) 적용, 검증은 실제 mutate 전에 전부 먼저 통과해야 함 (일부만 적용된 채 중간에 에러나는 반쪽 상태 방지).
    • Swap: indexA/indexB 중 하나라도 범위 밖이면 에러 — 단 Swap(i, i)(같은 인덱스)는 위치가 안 바뀌므로 에러 없이 no-op.
  • Move/Swap은 반환값 없음(void) — 내부 재배치만 수행, 멤버십 weak-set을 안 건드림(요소가 Slot을 떠난 적이 없으므로) — 그래서 Add/ Remove/Extract보다 저렴함.
  • 공개 CRUD 중 실제로 mutate하는 것(Add/Remove/Extract/ ExtractAll/Splice/Clear/Move/Swap)은 "가드 확인 + raw* 위임"의 얇은 wrapperself._listed(:List가 설치돼 있으면 수동 CRUD 금지)만 확인하고 실제 로직은 rawAdd/rawRemove/rawExtract/rawSplice/ rawClear/rawMove/rawSwap에 있음 — 이 raw* 함수들이 :List의 reconcile이 가드 없이 직접 호출하는 바로 그 함수(아래 "Slot:List" 절의 "구현" 참고). 공개 메소드에 로직이 따로 있는 게 아니라 전부 이 한 세트를 공유. Get/IndexOf는 순수 읽기라 이 가드 대상 아님:List가 설치돼 있어도 자유롭게 호출 가능. [2026-08-11 세션] 역방향 가드 신설 — _crudUsed_listed 대칭. :List의 가드는 원래 ":List가 이미 설치돼 있으면 수동 CRUD 금지"만 있었고, 반대로 "수동 CRUD를 이미 썼으면 나중에 :List 설치 금지"는 없었음 — 이 상태로는 Slot():Add(x); ...; slot:List(...) 같은 코드가 조용히 통과해서, :List의 reconcile이 x의 존재를 전혀 모른 채(자기 mounted/keyIndex가 비어있는 상태로 시작) 새 요소를 추가하려다 x와 충돌(Length 이중 계산, index 꼬임 등)하는 gap이 있었음. 모든 mutate CRUD(Slot(initial)이 호출하는 :Add 포함)가 self._crudUsed = true를 세팅하고, :List/:Single(내부적으로 :List 호출)이 설치 시 assert(not self._crudUsed, ...)를 추가로 확인 — 한 Slot은 평생 "수동 CRUD" 아니면 ":List/:Single" 둘 중 하나로만 고정됨.
  • 재진입성(Observer/store-bind 재실행 콜백 안에서 Add/Clear를 다시 호출) — 별도 가드 불필요. CRUD는 평범한 동기 테이블 뮤테이션 + Dispatch 호출일 뿐이라 "일반적 무한루프는 방어 안 함, provider 버그로 간주"라는 기존 원칙이 그대로 적용됨. recompute 자체의 재진입(같은 Slot의 length를 자기 계산 도중 다시 건드리는 것)도 같은 톤으로 UB — base/dispatch-core-plan.md의 "Length/Offset" 절, Source⊇State 단방향 원칙과 같은 카테고리로 명명됨(2026-08-11 세션).
  • Slot(initial?: {T}) 생성자 — [정정, 2026-08-11 세션] "인자 없는 빈 생성자로 확정"을 뒤집고 초기 배열을 받는 옵션 생성자를 다시 엶. 단, 새 마운트 로직이 아니라 순수하게 :Add를 반복 호출하는 sugar로만 존재 — "명시적으로 Add해야 들어간다"는 원래 취지(매직 없이 명시적)가 실제로는 안 깨짐, Slot{a,b,c}가 정확히 Slot():Add(a):Add(b):Add(c)와 같은 일을 하는 표기일 뿐이라서:
    function Slot(initial)
        local self = setmetatable({...}, Slot_mt)
        if initial ~= nil then
            self._crudUsed = true   -- 빈 테이블이어도 즉시 잠금(아래 참고)
            for _, v in ipairs(initial) do   -- ipairs가 첫 nil에서 멈춤
                self:Add(v)                  -- → "중간 nil은 UB, 그 뒤 무시"가 공짜로 성립
            end
        end
        return self
    end
    
    initial ~= nil이면(빈 테이블 {}이어도) 즉시 _crudUsed = trueSlot({})은 상태상 Slot():Add(x):Remove(1)과 동일(결과는 비어있지만 "수동 CRUD를 썼다"는 의도는 이미 커밋됨)이라, 인자를 아예 안 준 Slot()(진짜 nil)만 나중에 :List/:Single을 설치할 수 있는 상태로 남음 — 아래 "CRUD ↔ List/Single 상호 배타" 절 참고.

원시 최소화 원칙 정정 — Move/Swap 공개 API로 추가 (같은 세션 후속)

:List의 리오더 메커니즘을 구체화하던 중, 처음엔 Extract+Add(index) 조합으로 충분하다고 봐서 "원시 연산 최소화" 원칙에 따라 별도 Move/Swap을 안 만들기로 했었는데 — 실제로는 두 가지 공백이 드러나 뒤집음:

  1. Extract+Add는 리오더치고 너무 무겁다. Extract의 계약이 "제거, 파괴 안 함, 소유권 회수"라 백엔드가 곧이곧대로 구현하면 실제 Parent 조작이 두 번(detach+reattach) 일어남 — Roblox에서 AncestryChanged 발화, 잠재적 깜빡임, 불필요한 재바인딩 비용까지 딸려올 수 있음. 순서만 바뀌는, 매 :List 재계산마다 흔히 일어나는 케이스치고 과함.
  2. :List 없이 수동으로 Slot을 구성하는 사용자에겐 리오더 수단이 아예 없었다Extract+Add도 결국 위 1번 비용을 그대로 지므로 대체제가 못 됨.

둘 다 원시 최소화보다 우선하는 실사용 공백이라 판단, Move(O(n), 배열 splice 의미)와 Swap(O(1), 순수 페어 교환)을 공개 CRUD에 추가 — 시간복잡도 차이를 문서화해서 사용자가 상황에 맞게 고를 근거를 줌. :List의 reconcile 자체는 키 기반 diff가 "이 키는 이제 절대 위치 i다"를 산출하지 "A랑 B를 맞바꿔라"를 산출하지 않으므로 내부적으로는 계속 Move(의 가드 없는 버전)만 사용 — Swap은 순수하게 수동 Slot 사용자를 위한 편의 API.

Slot:List(data, updateFn, keyFn?) — 키 기반 동적 컬렉션 재조정 (2026-08-09 세 번째 세션, research/additional-primitives-plan.md에서 승격·통합)

Fusion ForPairs/ForKeys/ForValues, Vide indexes()/values(), React key prop에 대응하는 프리미티브 — 데이터 배열을 정체성(key) 기준으로 diff해서 변경분만 생성/갱신/언마운트한다([정정, 2026-08-13 4차 감사] 원래 "파괴"였으나 언마운트 전환 반영). 독립 타입이 아니라 Slot의 콜론 메소드로 확정(아래 "왜 자유 함수/새 타입이 아닌가" 참고) — 자기 자신을 변경하고 자신을 반환, Ref():Callback(fn)류의 기존 체이닝 패턴과 동일:

Slot():List(data, updateFn, keyFn?) -> Slot  -- self

파라미터 순서 정정, keyFn 선택 인자화 (같은 세션 후속). 원래 (data, keyFn, updateFn)이었는데, 실사용 대부분(사용자 추정 80%)이 "item 자체의 정체성 추적 없이 그냥 순번을 key로 써도 충분한" 단순 목록 (재정렬·중간 삽입/삭제로 인한 identity 보존이 필요 없는 경우)이라 keyFn을 매번 명시하게 하는 게 불필요한 보일러플레이트였음 — updateFn을 필수 인자 자리(두 번째)로, keyFn을 선택 인자(세 번째, 생략 시 인덱스를 그대로 key로 사용하는 function(item, index) return index end)로 재배치. tradeoff는 명시적으로 문서화 필요: 인덱스를 key로 쓰면 중간 삽입/삭제 시 그 뒤 모든 항목이 "다른 item인데 같은 key"로 오인돼 캐스케이드 갱신이 일어남(파괴/재생성은 없음, 단지 identity 보존이 없을 뿐) — 흔한 업계 관행(React key 생략 시 index 기본값, Vue v-for key 없이 쓰는 경우)과 같은 트레이드오프라 새로 설명할 개념은 아님, 재정렬/중간 삽입이 실제로 일어나는 목록엔 진짜 keyFn을 넘기라고 안내하는 정도로 충분.

key의 타입 제약 — 없음, 사이클 간 안정성+유일성만 있으면 됨 (2026-08-11 세션 명시화). key는 그냥 mounted/userdata/keyIndex 맵의 Lua 테이블 키로 쓰일 뿐이라 string/number/테이블 레퍼런스 등 뭐든 가능 — 유일한 조건은 (1) 같은 논리적 item이면 사이클이 바뀌어도 항상 같은 key(안정성), (2) 서로 다른 item이면 항상 다른 key(유일성). keyFnitem을 그대로 받으므로, item에 이미 안정적인 식별자 필드(흔히 문자열 id)가 있으면 그걸 그대로 쓰면 됨 — 캐스케이드 갱신을 피하려고 새로 뭔가 만들 필요 없음:

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 관용구를 안 쓰고 실수로 안 유일한 필드를 쓴 경우)에게 즉시 신호를 줌.

이름 정정 — renderFnupdateFn (같은 세션 후속). 아래 서술하는 호출 계약이 "새 key가 나타났을 때 1회 렌더"에서 "매 사이클 재호출되어 갱신 여부를 스스로 판단"으로 바뀌면서, "render"보다 "update"가 실제 역할을 더 정확히 반영한다고 판단해 이름도 같이 바꿈.

용어 주의 — 세 가지 "위치/식별" 값을 혼동하지 말 것(2026-08-11 세션 명시화): 아래 서술에 비슷한 이름의 값 세 개가 나오는데 전부 다름.

  1. keyFn(item, index)index — 원본 data 배열에서의 raw 위치(ipairs(items)의 루프 인덱스 그대로), filter와 무관하게 항상 원본 배열 기준.
  2. keykeyFn이 계산해 돌려주는 정체성 값. mounted/userdata 맵의 실제 키이자 :List가 diff를 판단하는 기준 — item이 재정렬돼도 같은 key면 같은 개체로 취급.
  3. updateFn(item, index, ...)indexkey/위 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로 사용) — 아이템 값과 인덱스 둘 다 받음. index는 원본 data 배열 위치 — 아래 updateFnindex(압축된 마운트 위치)와 이름만 같고 값은 다름, 위 "용어 주의" 참고.
  • 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 세션 정정) — 원래 userdataprev보다 앞이었는데, 반환은 (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에도 그대로 적용된 것뿐: updateFnLayoutOrder 같은 걸 반응형으로 유지하고 싶으면 자기 userdata 안에 직접 Source를 만들어 관리해야 함, 원치 않으면(웹 백엔드처럼 무시해도 되는 경우 등) 그냥 버려도 그만 — 아래 "왜 LayoutOrder를 Slot이 대신 안 해주는가" 절의 예시 참고.
    • offset: Source<number> — 이 Slot 자신의 Slot.Offset을 그대로 전달(모든 key가 같은 값을 공유) — 형제로 섞인 다른 Slot/정적 자식이 기여한 개수의 누적합(base/dispatch-core-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 상태 등).
    • 반환값 두 개는 서로 완전히 독립:Listresultuserdata 사이에 어떤 커플링도 안 둠(예: resultnil이라고 userdata를 자동으로 지우지 않음), 그대로 기록만 함. [정정, 같은 세션 후속] 처음엔 "resultnil이면 userdata도 같이 버림"이었으나, 이러면 "인스턴스는 파괴하되 다시 나타날 때 재사용하려고 캐시는 남겨두고 싶다" 같은 정당한 패턴 자체가 원천 봉쇄됨 — 그럴 이유가 없어 커플링을 없앰. 흔한 경우(둘 다 리셋)는 그냥 return nil 하나로 충분(Lua가 안 받은 반환 슬롯을 알아서 nil로 채움), 캐시를 남기고 싶으면 명시적으로 return nil, ud.
    • updateFn은 매번 다음 세 갈래 중 하나를 명시적으로 골라야 함 (아래 "왜 LayoutOrder를 Slot이 대신 안 해주는가" 절의 예시 코드가 이 세 갈래를 그대로 구현) — 어느 갈래인지 updateFn 자신만 정확히 알기 때문에, 이 판단을 updateFn 밖(:List 내부)으로 빼면 낭비가 생김(재사용 예정인 Source에 미리 :Set해뒀다가 결국 다시 그리게 되는 식):
      • 버림 — 첫 번째 값으로 nil(또는 None, 동일 취급)을 반환. "지금 이 key는 렌더 안 함"(filter 탈락 등) — prev가 있었다면 [정정, 2026-08-13 3차 감사, 그러나 2026-08-18 구현 전 QA로 재역전 — "nil 리턴은 파괴가 기본" 절 참고] 파괴됨(단순 Visible = false도 아니고, 언마운트도 아님 — rawRemove. PopOnly를 명시 반환해야 대신 언마운트+재사용됨). 편의상 nil 권장(반환값이 raw Slot 요소로 직접 들어가는 게 아니라 :List의 reconcile이 해석만 하므로 "요소 타입 제약"의 raw nil/None 금지와 안 부딪힘).
      • 다시 그림prev와 다른(보통 새로) 만든 값을 반환. 첫 렌더 (이 key 최초 등장) 또는 의도적 전체 교체 — prev가 있었다면 그건 [정정, 2026-08-13 3차 감사] 파괴가 아니라 언마운트되고 새 값이 그 자리를 대신함. 이 갈래에서 반응형 값(예: LayoutOrderSource)이 필요하면 항상 새로 만들어서 처음부터 올바른 값으로 시작해야 함, 이전 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의 몫(같은 세션 후속) — 처음엔 :ListindexindexState: Source<number>로 감싸 관리해주는 안을 검토했으나, 사용자가 "결국 이것도 item처럼 raw number로 넘기고, :Set을 언제 할지는 userdata를 활용해 updateFn 스스로 정하는 게 맞다"고 정리 — 채택. 근거: (1) item에 이미 적용된 ":List가 반응형을 강제하지 않는다" 원칙(바로 아래 절)이 index에도 그대로 적용돼야 일관적임. (2) :ListindexState를 대신 관리하면, 값이 실제로 바뀌었는지 비교하는 가드 (Get() ~= newIndex, recompute가 이미 쓰는 패턴)를 넣더라도 새로 생기는 원소는 항상 Source(0)으로 시작했다가 다시 :Set(index)으로 고쳐 써야 해서 프로퍼티가 두 번 써짐updateFnuserdata로 직접 관리하면 새 원소는 처음부터 Source(index)올바른 값으로 생성돼서 이 낭비 자체가 없음.

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:Get() + o:Get() 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됨).

indexupdateFn 호출 시점에 이미 "이 item이 이번 사이클에 살아남으면 차지할 압축 위치"로 정확히 계산돼서 넘어오므로(아래 "구현" 절의 candidateIndex 참고, 직전까지의 생존자 수만으로 계산 가능해 이 item 자신의 생존 여부와 무관), updateFn이 값을 늦게 알아서 임시값→정정 과정을 거칠 필요가 없음 — nil 반환(필터 탈락)이면 이 index 값은 그냥 버려지고 다음 생존자가 같은 값을 받음.

왜 매 사이클 호출로 바뀌었는가 — filter/toggle 문제

사용자가 제기한 문제: item이 State 변경으로 "더 이상 렌더되면 안 되는" 상태가 될 수 있는데(예: 검색 필터에서 탈락), 기존 "1회만 호출" 모델엔 이걸 표현할 방법이 없었음. 실무에서 흔한 회피책은 실제로 제거하지 않고 Visible = false만 토글하는 것 — 하지만 이건 lazy하지 않음: 필터링된 항목도 여전히 완전히 살아있는 Instance라 애니메이션/이벤트 연결/재계산이 계속 돎. 리스트가 200개+가 되면 "보이는 건 20개인데 200개가 전부 계속 돌아가는" 문제가 실제 비용으로 드러남.

[캐비엇, 2026-08-13 여섯 번째 세션] 아래 근거는 "제거 = 진짜 파괴"를 전제로 쓰였는데 그 전제가 언마운트 재설계로 바뀌었음 — 다만 결론은 그대로 유효함: 언마운트도 물리 트리에서 떼어내므로 Visible=false 토글과 달리 렌더/레이아웃 비용이 사라지고, 아무도 안 들고 있으면 GC되어 이벤트 연결·재계산도 결국 정리됨. 즉 "lazy하지 않음" 문제는 파괴가 아니라 언마운트만으로도 해소되고, 오히려 사용자가 그 요소를 들고 있다가 다시 쓸 수 있게 되는 이득이 붙음.

해법: updateFn을 매 사이클 호출하되, prev를 줘서 "바꿀 게 없으면 그대로 돌려주기만 하면 되는" 저렴한 경로를 만들고, filter 탈락은 nil 반환으로 [정정, 2026-08-13 3차 감사, 그러나 2026-08-18 구현 전 QA로 재역전 — 아래 참고] 언마운트되게 함(위 캐비엇대로 파괴 아님) — Visible 토글이 아니라 실제 물리 트리 이탈. 200개 중 20개만 통과하는 필터면 20개만 실제로 마운트돼 있고 나머지 180개는 물리적으로 존재하지 않음 (애니메이션도 안 돎, 다만 아무도 안 들고 있지 않은 한 GC되기 전까지 nil 아닌 언마운트된 채로 재사용 가능하게 남아있을 수 있음 — 위 캐비엇 참고).

⚠️ [재정정, 2026-08-18 구현 전 QA, /code-review high로 이 절의 stale 서술 발견] 바로 위 두 문단은 "filter 탈락 = 언마운트(비파괴)"를 결론으로 쓰고 있는데, 그 결론은 이후 재역전됐다. 지금 유효한 규칙은 "nil 리턴은 파괴가 기본 — PopOnly(가칭)로만 비파괴" 절(SL-3 해소, question.md/archive/question-resolved.md 참고) — filter 탈락으로 updateFn이 그냥 nil을 반환하면 이제 파괴(rawRemove)가 기본이고, "Instance.new/Destroy 비용을 아끼고 싶다"는 이 절의 동기를 살리려면 nil 대신 명시적으로 PopOnly를 반환해야 언마운트+재사용이 된다. 이 절의 동기(matched-item 애니메이션/이벤트가 계속 돌면 안 된다는 문제 자체)는 여전히 유효하지만, "그래서 nil이 곧 언마운트"라는 결론 문장은 PopOnly 신설로 대체됐다 — 이 절을 읽고 filter를 구현할 땐 반드시 위 "nil 리턴은 파괴가 기본" 절도 같이 볼 것.

"이전 상태를 다음 호출에 어떻게 넘기냐" 문제는 userdata가 그 채널 — item이 plain table이라 매번 Source를 새로 안 만들고 재사용하려면 그 Source를 어딘가 저장해야 하는데, :List가 그걸 대신 안 만들어주는 대신(아래 참고) userdata라는 전용 채널로 updateFn이 직접 관리하게 함 — filter 탈락 후 재등장해도(Instance는 파괴됐다 새로 만들어져도) userdata를 살려뒀다면 그대로 이어짐(위 "반환값 두 개는 서로 독립" 참고).

sort는 이 재설계와 무관, 기존 메커니즘으로 이미 커버됨 — 호출부가 data의 순서를 바꾸면 keyIndex[key] ~= i 감지 → Move가 그대로 처리, 새 메커니즘 필요 없음(사용자가 filter와 같이 물었던 것 중 sort는 원래도 문제가 없었음).

Source:List가 안 만드는가 — item/index를 raw로 넘기는 이유

이전 초안은 :ListitemState/indexState(내부 Source)를 강제로 만들어 updateFn에 넘겨줬는데, 재검토 결과 이건 :List가 굳이 강요할 필요 없는 결정이었음 — 반응형 바인딩이 필요 없는 단순한 행(예: 매번 그냥 새로 계산해도 싼 텍스트 하나)까지 전부 Source 생성 비용을 억지로 지게 됨. userdata로 이 권한을 완전히 updateFn 쪽에 넘기면, 원하는 item만 자기 Source를 만들어 userdata에 담고, 나머지는 매번 raw item에서 그냥 다시 계산해도 됨 — 어느 쪽이 나은지는 케이스 by 케이스라 :List가 미리 정할 이유가 없음.

부수 효과 — 이전 "item 값은 무조건 재전파, index는 실제 변경시만" 비대칭 백로그가 사라짐. :List가 더 이상 Source를 안 만드므로 그 문제 자체가 :List 소관이 아니게 됨 — item/index를 반응형으로 감쌀지, 매번 무조건 :Set()할지 조건부로 할지는 전부 updateFn 작성자의 선택.

userdata의 생명주기 제약 — GC-native만 허용, 명시적 cleanup이 필요한

값은 UB (같은 세션 후속)

검토했다가 기각한 대안: itemT?(nilable)로 바꿔서, key가 최종 제거될 때 updateFn(nil, index, userdata, prev)를 한 번 더 불러 "정리할 기회"를 주는 안 — if not item then <userdata 안의 구독 해제 등> return end 관용구로 userdata 안에 담긴 리소스(예: Observer:Subscribe()한 구독)를 정리할 수 있게 하자는 아이디어. 기각 — 사용자가 스스로 반례를 찾음: 이 훅은 data에서 key가 빠져 reconcile이 다시 도는 정상 경로에서만 발화함 — 하지만 Slot을 담고 있는 부모 Instance 자체가 Destroy되는 경로(가장 흔한 소멸 경로)는 reconcile을 다시 안 돌기 때문에 이 훅이 전혀 안 불림. 절반만 동작하는 정리 메커니즘은 없는 것보다 나쁨 — 사용자가 "정리가 보장된다"고 오해하고 Subscribe류를 userdata에 넣었다가 Destroy 경로에서 조용히 새는 게 실제로 훨씬 위험한 결과. retract가 Destroy 시엔 절대 안 불린다는 기존 원칙(base/ lifecycle-pattern.md "quad는 자신이 만든 Instance의 라이프사이클")과 정확히 같은 이유로, :List에 새 반쪽짜리 예외를 만들 이유가 없음.

대신 명시적 제약으로 문서화: userdata에는 반환된 element(또는 Slot 자신)보다 명시적으로 오래 살아야 하는 값을 담으면 안 됨 — GC만으로 자연히 정리되는 값만 담을 것(plain 값, Source/State 등), :Subscribe()Observer/Effect류처럼 명시적 :Unsubscribe()가 필요한 값을 담는 건 UB. :List가 어떤 teardown 경로도 보장 안 하므로, userdata 안의 무언가가 GC 하나만으로 안 죽는다면 그건 곧 leak. 이건 quad 전역 GC-native 원칙(lifecycle-pattern.md)을 :List라는 구체적 지점에 그대로 적용한 것뿐 — 새 원칙 아님.

구현

구독 시점은 :List() 호출이 아니라 Slot 마운트 시점 — lazy bindLifetime (2026-08-09 일곱 번째 세션, 아래 "구독 시점" 절 참고). :List()는 설정만 저장하고 반환, 실제 data:Observer(fn) 구독과 최초 reconcile은 Slot 자신이 마운트되는 순간(Dispatch/Slot.luauprocess(inst,k,self,index))에 activateList가 수행 — Dispatch.setLength가 이미 쓰고 있는 것과 같은 패턴(마운트 시점까지 미뤘다가 그 자리에서 bindLifetime).

function Slot:List(data, updateFn, keyFn)
    assert(not self._listed, "Slot already has :List installed")
    self._listed = true
    self._listData = data
    self._updateFn = updateFn
    self._keyFn = keyFn or function(_, index) return index end

    if self._mounted then
        activateList(self, self._mountedInst)  -- 이미 마운트돼 있으면 즉시 활성화
    end
    return self
end

-- Dispatch/Slot.luau의 process(inst,k,self,index)가 마운트 시점에 1회 호출
-- (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)   -- keyFn은 raw i를 받음(:List 파라미터 설명 참고)
            if seen[key] then
                error("Slot:List — duplicate key: " .. tostring(key))
            end
            seen[key] = true

            local prev = mounted[key]
            local candidateIndex = pos + 1   -- "이 item이 살아남으면 차지할" 압축 위치(생존 여부와 무관하게 계산 가능)
            local result, ud = updateFn(item, candidateIndex, offset, prev, userdata[key])
            if result == None then result = nil end   -- 편의: None도 nil과 동일 취급
            -- [2026-08-18] PopOnly(가칭)는 "이 자리를 비우되 죽이지는 말라"는 지시.
            -- 아래 "PopOnly" 절 — 자리 계산 관점에선 nil과 똑같이 취급된다.
            local popOnly = (result == PopOnly)
            if popOnly then result = nil end

            if result ~= nil then
                -- [2026-08-11 일곱 번째 세션] result가 nested Slot이면 그
                -- .Length만큼 건너뛴다 — 다음 형제의 index가 이 아이템이
                -- 실제로 차지하는 물리적 개수를 반영해야 함(아래 "index도
                -- nested-Slot 결과의 Length만큼 건너뛰어야 함" 절 참고)
                pos = candidateIndex - 1 + (if isSlot(result) then result.Length:Get() else 1)
            end

            if result ~= prev then
                -- [재정정, 2026-08-18 구현 전 QA] 세 경로가 갈린다 — 아래
                -- "`nil` 리턴은 파괴가 기본" 절이 소스:
                --   (a) 교체(result ~= nil): 밀려난 prev는 **언마운트만**
                --       — state<Frame> 교체와 동형, 지우라고 한 적이 없음.
                --   (b) PopOnly: **언마운트만**, 재사용은 ud가 홀드.
                --   (c) 그냥 nil/None: **파괴**(rawRemove) — "지워라"라는 지시.
                if prev ~= nil then
                    if result ~= nil or popOnly then rawUnmount(self, prev)
                    else rawRemove(self, prev) end
                end
                if result ~= nil then rawAdd(self, result, pos) end -- 새로 배치, 압축 위치 기준
                mounted[key] = result
            elseif prev ~= nil and keyIndex[key] ~= pos then
                rawMove(self, prev, pos)               -- 그대로 쓰되 위치만 이동
            end

            userdata[key] = ud    -- result와 무관, 그대로 기록
                                  -- (PopOnly 재사용은 여기 담긴 { old = ... }가 담당)
            newKeyIndex[key] = pos
        end
        for key in pairs(keyIndex) do   -- 직전 사이클에 존재했던 전체 key
            if not seen[key] then
                local prev = mounted[key]
                if prev ~= nil then rawRemove(self, prev) end -- [재정정, 2026-08-18] 파괴
                mounted[key], userdata[key] = nil, nil
            end
        end
        keyIndex = newKeyIndex
    end

    local data = self._listData
    if isState(data) then
        local observer = data:Observer(function() reconcile(data:Get()) end)
        -- Observer 등록 자체의 "등록 즉시 1회 실행"은 canExecute
        -- 게이팅과 무관하게 여기서 이미 무조건 일어남(아래 "구독 시점" 절) —
        -- bindLifetime은 그 다음에 걸어 *이후* 재실행만 inst 생명주기에 귀속
        bindLifetime(inst, observer)
    else
        reconcile(data)
    end
end

[정정, 2026-08-11 세션] pos(압축 위치)와 raw 루프 인덱스 i를 분리한 이유 — 이전 의사코드의 실제 버그. 원래 rawAdd(self, result, i)처럼 raw i를 그대로 위치 인자로 썼는데, 앞쪽 item이 filter로 마운트 안 되면 실제 Slot 안 마운트된 개수는 i보다 항상 적어짐 — 그 상태로 rawAddi 위치에 부르면 Add의 "범위 밖 index는 clamp 없이 error"(위 "CRUD API 확정" 절)에 걸려 그냥 터짐. pos는 이번 사이클에서 지금까지 실제로 마운트된 개수만 세는 별도 카운터라 이 문제가 없음 — keyIndex/rawMove/ rawAdd도 전부 이 pos 기준으로 통일. filter 탈락 없이 순서대로 통과하는 흔한 경우엔 pos == i라 체감상 달라지는 게 없음.

[같은 세션 후속] updateFn에 넘기는 indexcandidateIndex(=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 — 트리 전체를 비교하는 비싼 diff가 아니라 seen 셋 하나로 "새 key 목록에 없는 건 지운다"만 판정하는 React/Vue/Solid류의 표준 key 기반 방식, data가 참조를 유지한 채 뮤테이션+Emit()되는 경로도 지원해야 하는 이상 최소 한 번은 훑어야 하는 게 불가피함.
  • updateFn을 매번 부르는 게 비싼 게 아닌 이유 — 흔한 경로(prev 그대로 반환)는 함수 호출 하나뿐, 실제 Instance 생성/[정정, 2026-08-13 4차 감사] 언마운트(파괴 아님 — 위 "구현" 절 rawUnmount 참고)가 있는 건 key가 새로 나타나거나/사라지거나/filter로 구조가 바뀌는 경우뿐. 200개 중 값만 갱신되는 사이클엔 200번의 값싼 함수 호출이 있을 뿐, 200번의 재구성이 있는 게 아님.
  • mounted/userdata를 정리하는 루프가 mounted가 아니라 이전 사이클의 keyIndex를 순회하는 이유userdata가 이제 result == nil이어도 살아남을 수 있어서(위 "반환값 두 개는 서로 독립"), 어떤 key가 mounted[key] == nil인 채로(필터 탈락 상태) data에서 완전히 사라지면 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가 살아있는 동안만 존재하면 되고 죽으면 클로저도 같이 GC됨(아래 "구독 시점" 절).
  • reconcile이 직접 호출하는 건 rawAdd/rawUnmount/rawRemove/ rawMove ([재정정, 2026-08-18 구현 전 QA] 2026-08-13 여섯 번째 세션에 "reconcile의 제거는 전부 비파괴 언마운트"로 바꿨던 것을 부분적으로 되돌림nil 리턴/키 소멸은 다시 파괴가 기본이고, 값 교체와 PopOnly만 비파괴. 아래 "nil 리턴은 파괴가 기본" 절이 소스) — rawExtract/rawSwap/rawClear도 (위 "모든 공개 CRUD는 가드+위임" 구조상) 당연히 존재하지만, :List의 reconcile 알고리즘 자체가 그 셋을 직접 호출할 일이 없을 뿐. rawUnmountrawRemove의 비파괴 짝으로서 Extract 계열과 공유하는 저수준 프리미티브 — 위 코드 블록의 "rawRemove의 비파괴 짝 — :List의 reconcile과 Extract 계열이 씀" 주석 참고. reconcile이 공개 Slot:Extract 대신 rawUnmount를 직접 부르는 진짜 이유는 파괴 여부가 아니라, reconcile이 이미 자기 mounted 맵으로 element를 추적 중이라 Extract의 "제거한 element를 호출자에게 반환" 계약이 불필요하고, 공개 CRUD의 가드/에러 체크도 reconcile 내부 상태 일관성상 중복이기 때문. 리오더는 항상 절대 위치 이동이라 Swap 아닌 Move 경로, Clear는 reconcile 단위가 아니라 Slot 전체 단위 연산이라 무관 — 이 둘의 근거는 여전히 유효.
  • 리오더는 Move(의 가드 없는 버전) — Parent를 안 건드리는 진짜 저비용 경로. 최소-이동 알고리즘(LIS 기반 등) 자체는 구현 시점 최적화로 미룸, 여기선 계약(파괴 없이 위치만 바뀜)만 확정.

nil 리턴은 파괴가 기본 — PopOnly(가칭)로만 비파괴 (2026-08-18 구현 전 QA, 확정 뒤집기)

[재정정] 2026-08-13 여섯 번째 세션은 "자동 경로는 언마운트, 명시적으로 지우라고 한 것만 파괴"라는 일반 규칙을 세우면서 :List의 reconcile까지 전부 비파괴로 바꿨는데, :List에는 그 일반화가 안 맞는다는 게 사용자 판정: "List reconcile 에서 nil 리턴으로 지워지길 요구하는 경우는 비파괴일지, 파괴일지 생각해보아야할 것이 많은듯. 기본적으로 파괴가 맞기는 한데…"

세 경로로 갈린다(위 reconcile 의사코드):

updateFn의 반환 이전 요소(prev) 처리
새 값(result ~= nil) 언마운트만 밀려난 것뿐이지 "지워라"가 아님. state<Frame> 교체와 동형이고, Slot { State<Slot> } sugar(:Single)가 이 경로를 타므로 아래 "State<Slot> 교체" 절의 확정도 그대로 유지됨
nil / None 파괴(rawRemove) updateFn이 명시적으로 "이 자리를 지워라"라고 말한 것
PopOnly(가칭) 언마운트만 + 재사용 대기 아래
키가 데이터에서 사라짐 파괴(rawRemove) nil 리턴과 같은 의미(그 아이템은 이제 없음)

PopOnly(가칭) — Instance.new/Destroy 비용을 아끼는 재사용 경로. filter 용도처럼 "지금은 안 보이지만 곧 다시 필요할" 요소를 매번 파괴/재생성하는 건 비싸다. 그래서 updateFnPopOnly와 함께 userdata를 반환하면 그 자리는 파괴 없이 Parent = nil로만 내려오고 Slot에서 빠진다:

-- filter에서 걸러진 아이템 — 죽이지 말고 들고 있다가 나중에 되쓴다
return PopOnly, { old = prev, source = ... }
  • 보존 주체는 userdata — reconcile은 mounted[key]에서만 뺄 뿐 userdata[key]는 그대로 기록하므로(위 의사코드), 반환한 테이블 안의 old가 그 요소를 강하게 붙잡아 GC를 막는다. 다음 사이클에 updateFn이 같은 userdata를 다섯 번째 인자로 다시 받으므로, 거기서 old를 꺼내 그대로 반환하면 재마운트된다(rawAdd 경로).
  • userdata는 그 키가 데이터에 남아 있는 한, 명시적으로 nil을 반환하기 전까지 안 지워진다 — 즉 "언제 진짜로 버릴지"를 updateFn이 결정한다.
  • ⚠️ 단, 키가 데이터에서 아예 사라지면 얘기가 다르다(2026-08-18 감사에서 발견한 갭). 그 경우 reconcile의 소멸 루프가 mounted[key]/userdata[key]둘 다 지우는데, PopOnly로 홀드 중이던 요소는 mounted[key]가 이미 nil이라 rawRemove(파괴) 대상이 아니다 — 결과적으로 그 요소는 파괴되지도, updateFn에게 되돌려지지도 않고 참조만 끊겨 GC 대상이 된다(Parent는 이미 nil). 이건 같은 절의 표가 "키가 사라지면 파괴"라고 못박은 것과도, 위 "버릴 시점은 updateFn이 정한다"와도 어긋난다. 세 선택지 중 하나를 M8 착수 전에 정할 것(question.md 3번): (a) 소멸 루프가 userdata[key].old도 확인해 rawRemove로 파괴, (b) 지금처럼 참조만 끊고 GC에 맡김(단 표와 서술을 그 사실에 맞게 고침), (c) updateFn을 마지막으로 한 번 더 불러 처분을 묻는다. 지금 문서는 (a)를 기본으로 가정하지 않는다 — 결정 전이므로 구현 금지.
  • 이름은 가칭 — 사용자 확정: "PopOnly 확정. 다만 이름은 변경될 수 있음. 이름에 대해서는 더 생각해보아야함". question.md 용어 정리 항목에 올려둠. 메커니즘(반환 규약 + userdata 홀드 + 재마운트)은 확정.
  • Slot의 다른 비파괴 API와의 관계: Extract/ExtractAll/Splice가 이미 비파괴 추출을 제공하지만(위 "CRUD API 확정" 절) 그건 호출자가 직접 부르는 명령형 경로다. PopOnly는 같은 일을 reconcile 안에서 선언적으로 하기 위한 것이라 서로 대체 관계가 아니다.

구독 시점 — :List() 호출이 아니라 Slot 마운트 시점, lazy bindLifetime

(2026-08-09 일곱 번째 세션)

문제: 원래 초안은 data:Observer(fn):List() 호출 그 자리에서 만들었음 — 근데 :List()Slot():List(data, updateFn)처럼 Slot이 아직 어디에도 마운트되기 전에 불리는 게 흔한 사용법이라, 그 시점엔 inst를 몰라서 bindLifetime을 걸 수 없었음(사용자가 직접 지적) — 마운트 대상이 나중에 Destroy돼도 이 구독을 멈출 방법이 없는 gap이었음.

해법 — Dispatch.setLength가 이미 쓰고 있는 패턴 그대로 재사용: 새 메커니즘 발명 아님. :List()data/updateFn/keyFn만 저장하고 반환, 실제 data:Observer(fn) 구독 + 최초 reconcile은 Slot 컨테이너 자신이 마운트되는 순간(Dispatch/Slot.luauprocess(inst,k,self,index) — 위 "isMounted 이중 추적 분리" 절이 이미 self._mounted를 세팅하는 바로 그 지점)에 activateList(self, inst)가 수행. Dispatch.setLength(inst,i, self.Length)를 부르는 것과 같은 자리에서 같이 트리거되면 됨.

:List()가 마운트 이후에 불리는 경우 — self._mounted면 즉시 활성화 (확정): 마운트는 1회성 이벤트라, :List()가 마운트보다 늦게 호출되면 그 이벤트를 기다리는 방식으론 영영 활성화가 안 됨 — :List()self._mounted를 확인해서 이미 참이면 그 자리에서 바로 activateList(self, self._mountedInst)를 호출(마운트 시점에 instself._mountedInst로 같이 저장해둠). CRUD와의 상호배타 가드(self._listed)와 같은 자리에서 자연스럽게 처리됨 — 호출 순서에 대한 새 제약을 추가하지 않음.

canExecute와 "등록 즉시 1회 실행"의 관계 — 초기 실행은 게이팅과 무관하게 무조건 일어남(사용자 확인): data:Observer(fn)가 등록되는 순간 (bindLifetime 호출 이전) fn이 이미 한 번 동기 실행됨(Observer 자체의 "등록 즉시 1회 실행" 계약) — 이 시점엔 아직 bindLifetime을 안 걸어 observer에게 gcconn 참조가 없으므로 canExecute를 물으면 거짓이겠지만, 애초에 최초 실행은 canExecute로 게이팅되는 대상이 아니라서 상관없음 ([정정, 2026-08-14 다섯 번째 세션] 원래 "bindLifetimeSubscribed를 세팅 전이라"고 적혀 있었으나 bindLifetime은 그 필드를 안 건드림 — .Subscribed는 전역 :Subscribe() 전용, archive/canexecute-inst-arg-reversed.md). bindLifetime은 그 직후에 걸려서 이후의 재실행(data가 다시 바뀔 때)만 게이팅 — Dispatch.setLengthbindLifetime(inst,observer) 다음 줄에 있는 "등록 즉시 1회와 겹쳐도 무해"라는 주석과 정확히 같은 구조.

Destroy 이후 — "재실행 막기"와 "관측 자체를 관두기"가 새 메커니즘 없이 한 번에 해결됨: inst가 Destroy되면 bindLifetimegcconn(Roblox가 Destroy 시 자동으로 끊는 Connection)이 죽어 canExecute가 거짓이 되고 future 재실행이 no-op됨(위 "state:Observer(fn)" 절 원칙 재사용) — 그리고 "이전 state를 계속 관측하는 것도 관둬야 한다"는 요구도, gcholdRelate(inst)(weak-keyed) 아래 있어서 inst가 죽으면 그 안에 강참조로 붙잡혀 있던 Observer/클로저(mounted/userdata/keyIndex를 포함해)가 전부 같이 GC 대상이 되는 것으로 공짜로 해결 — 명시적으로 구독을 끊는 새 코드가 필요 없음, base/lifecycle-pattern.md의 "정리(retract)는 기본적으로 GC에 위임" 원칙 그대로.

부수 관찰(설계 아님, 메모만): bindLifetimeRelate(inst) 기반이라, "이 inst에 지금 어떤 Slot/Observer가 붙어있는가"를 나중에 weak하게 역조회하는 것도 같은 저장소로 가능해 보임(quad-debug의 "무엇이 무엇에 연결됐는가" 그래프와 맞닿을 수 있음) — 지금 설계할 필요는 없음, 필요성이 확인되면 그때.

왜 자유 함수/새 타입이 아닌가

처음엔 List(data, updateFn, keyFn?) -> Slot 같은 자유 함수(또는 Slot을 구조적으로 만족하는 새 타입 List)로 검토했으나 둘 다 기각:

  • 자유 함수 기각: Source(default)/Ref(default)/Store({defaults})가 지켜온 "Type(args) 팩토리 이름 = 반환 타입"이라는 컨벤션이 깨짐 — List(...)Slot을 반환하면 이름과 실제 타입이 안 맞음.
  • 새 서브타입(List extends Slot, Source⊇State 같은 구조적 서브타이핑) 기각: Source가 State의 서브타입이어야 했던 이유는 Source가 State보다 진짜로 더 많은 공개 메소드(:Set/:Emit)를 갖기 때문 — 반면 이 프리미티브는 Slot이 이미 가진 것(Add/Remove/Extract/Clear/ Move/Swap) 위에 새 공개 메소드를 얹지 않음. 그냥 "자동으로 채워지고 관리되는 Slot"일 뿐이라 별도 타입일 이유가 없음.
  • 결론: Slot의 콜론 메소드. "원천에 종속된 파생 데이터는 자유 함수 생성자가 없고 메소드로만 얻어진다"(State/Observer)는 기존 분류 원칙과 같은 모양 — 다만 여기 원천은 Source가 아니라 이미 만들어진 Slot 자신. Fusion의 ForPairs/ForKeys/ForValues 3분할도 이 재구성으로 통합 방향이 자연스러워짐(단일 :List가 이미 Slot 메소드 이름공간 안에 있으니 여러 진입점을 나열할 이유가 약해짐) — 통합 확정.
  • 이름 후보로 검토됐던 Render/Draw도 이 재구성으로 더 이상 "타입 이름"이 아니라 "메소드 이름" 문제가 됐지만, List가 여전히 가장 낫다고 판단(Render는 quad의 "렌더 주기 없음" 원칙과 메소드 이름으로 써도 충돌 소지가 남고, Draw는 즉시모드 GUI 뉘앙스) — List로 확정.

자식으로 넘기는 클래스 스토어

자식에게 내려주는 클래스 스토어는 부모 쪽에서 미리 만들어서 내려보내는 게 편할 것 같다는 방향 — store<<ChildClass.Props>> 형태로 구성된 스토어를 만들면 됨(타입 표기는 러프한 스케치, 실제 문법은 tbox의 명시적 제네릭 적용 패턴 f<<T>>(...).claude/initreq/tbox/CLAUDE.md:40-41 — 참고해서 확정할 것).

열린 질문 (.claude/question.md에도 취합)

  • 재마운트 에러 처리(throw)는 확정. [역전됨, 2026-08-13 여섯 번째 세션] "retract 시 폐기(옮기지 않음)"는 뒤집혔음 — 지금은 State<Slot> 교체가 언마운트이고 이전 Slot은 파괴되지 않음(파괴는 명시적 Remove/Clear/ dispose만). "실사용에서 불편하지 않은지 재검증"이라던 남은 항목도 그 재설계로 해소됨 — 최신 설계는 아래 "State<Slot> 교체는 파괴가 아니라 언마운트" 절이 정본.
  • "클래스가 슬롯을 받는 방법"(Named Slot 없음)도 확정됨(위 "클래스가 슬롯을 받는 방법" 절 참고).
  • [해소됨, 2026-08-09 세 번째 세션] add/remove/clear CRUD 의미론, isMounted 이중 추적 분리, 키 기반 동적 컬렉션 재조정(Slot:List) — 위 "CRUD API 확정"/"isMounted 이중 추적 분리"/"Slot:List" 절 참고.
  • [해소됨, 2026-08-09 여섯 번째 세션] 여러 Slot이 형제로 섞일 때 순서 보장 — 위 "여러 Slot이 섞일 때 순서 보장" 절 참고, 메커니즘은 base/dispatch-core-plan.md의 "Length/Offset" 절이 최신 소스.

Slot.Length — :List뿐 아니라 항상 노출됨 (2026-08-09 여섯 번째 세션)

Slot은 CRUD/:List 여부와 무관하게 .Length: State<number>를 항상 노출 — 지금 실제로 마운트된 요소 개수(사용자가 직접 CRUD로 넣든 :List reconcile이 넣든 동일). [2026-08-11 세션] Slot-in-Slot 중첩 허용 이후로는 정확히 "요소별 기여도의 합" — plain 요소는 1, nested Slot 요소는 그 Slot 자신의 .Length(재귀) — 상세는 "Slot-in-Slot 중첩" 절 참고. plain 요소만 쓰는 흔한 경우엔 항상 합==개수라 체감 차이 없음. 두 용도를 겸함: (1) 사용자가 "n개 검색됨" 같은 UI에 직접 관측, (2) Dispatch.setLength(inst, i, slot.Length)가 형제 순서 보장(위 "여러 Slot이 섞일 때 순서 보장" 참고)에 내부적으로 읽는 바로 그 값 — 별도 두 State가 아니라 하나. :List의 filter 탈락이 실제 Remove(Visible 토글 아님)로 확정돼 있어서 Length는 자동으로 "실제 마운트된 것"만 반영 — 수동 Visible 토글을 쓰면 Length가 그걸 못 잡는 게 맞고, 그건 사용자가 별도 State로 계산해야 하는 몫.

동적 자식은 반드시 Slot 또는 state<Frame>류 store-bind를 통해서만 추가/제거 — 그 외 경로는 UB(2026-08-10 세션, base/dispatch-core-plan.md의 "Length/Offset" 절 반영). 둘 다 Dispatch.setLength/setOffsetSource를 정확히 호출하는 유일한 정당 경로라, 이걸 우회해서(예: 외부 코드가 Slot이 마운트해둔 부모 Instance에 직접 .Parent = parentInst로 자식을 끼워 넣는 것) 자식을 추가/제거하면 Length/형제 순서 계산이 그 변화를 몰라 조용히 어긋남 — 별도 방어 로직 없음, 문서 경고로만 남김.

Slot:Single(state, updateFn?) — 확정 (2026-08-11 세션, :List 위의 순수 sugar)

기존 "백로그, 미착수"에서 실제 설계까지 완료됨 — 새 reconcile 로직 없이 :List를 정확히 0/1개짜리 배열로 감싸는 sugar:

local function identityUpdateFn(item) return item end

function Slot:Single(state, updateFn)
    updateFn = updateFn or identityUpdateFn   -- [2026-08-11 일곱 번째 세션] 기본값 추가

    local data = if isState(state)
        then state:Compute(function(v) return (if v:Get() == nil then {} else { v:Get() }) end)
        else (if state == nil then {} else { state })

    return self:List(data, function(item, index, offset, prev, ud)
        return updateFn(item, offset, prev, ud)   -- index는 항상 상수라 안 넘김
    end, function() return true end)   -- 고정 key
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% 동일 규칙 재사용.
  • key를 고정값(true)으로 두는 게 핵심state가 A값에서 B값으로 바뀌어도 같은 key라 prev가 유지되고, updateFn이 "새로 그릴지/ 그대로 쓸지"를 스스로 판단 가능(값 자체를 key로 쓰면 매번 다른 item 취급돼서 파괴+재생성이 강제됨 — 원하는 동작이 아님).
  • 원래 동기(State<Frame?>가 offset을 못 받는 문제)를 이걸로 완전히 해결updateFnoffset을 직접 받으므로, "offset을 얻으려고 컴포넌트가 Slot을 리턴하는" 우회가 애초에 필요 없어짐. Slot-in-Slot 중첩(아래 절)의 정당화 근거는 이것과 별개 — 컴포넌트 결합 시 결과 타입이 뭐든(Instance/Slot) 균일하게 다룰 수 있어야 한다는 요구.
  • mounted/userdata/keyIndex 전부 :List가 이미 갖고 있는 걸 그대로 재사용, 코드 중복 없음. :List와 마찬가지로 self._crudUsed 체크 대상(내부적으로 :List를 호출하므로 자동 적용).

Slot-in-Slot 중첩 — 확정 (2026-08-11 세션)

동기 — Slot을 원시 최소 요구가 아니라 컴포넌트 결합의 균일성 문제로 접근. 카테고리 헤더+아이템 그룹(outer:Add(header); outer:Add(itemsSlot))이 구체적 동기로 제기됐지만, 더 근본적인 이유는 컴포넌트 결합local result = SomeComponent(props)Instance를 리턴하든 Slot(멀티루트 워크어라운드, base/component-composition-plan.md)을 리턴하든, 호출부가 outerSlot:Add(result)를 분기 없이 그냥 부를 수 있어야 함. 지금까지 "요소 타입 제약"이 Slot을 암묵적으로 배제하고 있어서(T = Instance 단순화), 정확히 이 컴포지션 케이스가 막혀 있었음.

요소 타입 — Slot 허용

위 "요소 타입 제약" 절 갱신대로 isMountableisSlot(v)을 더 이상 배제하지 않음 — 나머지(Ref/PreRef/PostRef/Observer/Effect/Modifier 금지, nil/None 금지)는 그대로.

재귀 메커니즘 — 새 프리미티브 없이 Dispatch.setLength/setOffsetSource를 Slot 자신 키로 재사용

[해결, 2026-08-18 구현 전 QA 2라운드 후속] 아래가 재사용하는 Dispatch.setLength/setOffsetSource/recompute가 배치 등록 중 크래시할 수 있던 문제(RC-1)는 해결됨 — base/dispatch-core-plan.md의 "배치 등록을 안전하게 만드는 Blocker 게이팅" 절이 소스. 이 문서에선 그 해법이 attachSlot의 flush 루프에 어떻게 적용되는지만 다룬다(아래 코드의 blocker 관련 줄).

base/dispatch-core-plan.md의 "Length/Offset" 절이 이미 확정해둔 두 함수는 owner 키(inst)가 물리 Instance일 필요가 없음(Relate가 아무 테이블이나 weak 키로 받음) — Slot 자신을 owner 키로 재사용하면 최상위 마운트와 중첩 마운트가 완전히 같은 함수 호출이 됩니다.

[재정정, 2026-08-18 구현 전 QA 2라운드 후속] 호출 순서가 뒤집혀 있었음 — setLength가 먼저, setOffsetSource가 나중이던 것을 바로잡음. base/dispatch-core-plan.md의 "NilHandler" 절이 이미 확정해둔 "호출 순서는 setOffsetSourcesetLength" 일반 규칙(해제 시점 계약에서 나왔지만 등록 시점에도 그대로 적용)과 이 attachSlot 의사코드가 계속 어긋나 있었던 것 — RC-1을 고치며 setOffsetSource가 즉시 계산을 하게 되면서 이 불일치가 드러남. 사용자 확정: "length 를 알게되는 시점은 각 요소가 생성된 이후인데, 그럼 setOffset 이 먼저 안 되어있으면 offset 전파가 한번 더 일어나게됨" — Slot의 진짜 .LengthactivateList가 자기 :List를 최초 reconcile한 뒤에야 확정되므로, setLength를 그 전에 부르면 등록 직후 값이 또 바뀌어 전파가 한 번 낭비된다. 올바른 순서는 setOffsetSource(즉시 계산) → (Slot이면) 실체화 → setLength(그제서야 확정된 값으로 등록) → 물리 마운트:

-- quad-base, Slot.luau — 재귀적 "attach" 하나로 최상위/중첩 마운트 통합
local function attachSlot(slot, physicalTarget, ownerKey, position)
    -- [정정, 2026-08-13 세션] bindLifetime 호출은 여기 없음 — top-level 전용
    -- 앵커링은 SlotHandler.process(아래)로 이동. attachSlot 자신은 이제
    -- top-level/nested 어느 깊이에서 불려도 완전히 동일하게 동작하는 순수 구조적
    -- mount 로직만 담당(레이어 구분은 오직 이 함수를 부르는 쪽의 책임) — retract
    -- 쪽(destroySlotTree)이 이미 이 원칙대로였는데(자기 자신의 unbindLifetime은
    -- 안 하고 process가 반환하는 retract 클로저에서만 짝을 맞춤) process 쪽만 attachSlot 내부에
    -- ownerKey==physicalTarget 분기로 anchor 로직이 새어들어와 있던 비대칭이었음.
    slot._mounted = true
    slot._mountedInst = physicalTarget

    local offsetSource = Source(0)
    Dispatch.setOffsetSource(ownerKey, position, offsetSource)   -- 먼저 — 앞선 형제 합으로 즉시 계산
    slot.Offset = offsetSource

    if slot._listed then
        activateList(slot, physicalTarget)   -- 실체화 — 여기서 slot.Length가 진짜 값으로 확정됨
    end

    Dispatch.setLength(ownerKey, position, slot.Length)   -- 실체화 뒤 — 확정된 값으로 등록, 재전파 낭비 없음

    -- [2026-08-18 신설, RC-1 해결] attach 전에 이미 들어와있던 요소들 flush —
    -- 이 루프도 Dispatch.drive의 최상위 배열 순회와 똑같이 "slot._elements의
    -- 개수(N)가 이미 정해진 채 position을 하나씩 등록"하는 배치라 같은
    -- 크래시 위험이 있음. 이 Slot 자신의 owner 키로 별도 Blocker를 새로
    -- 만들어(부모 Blocker와 절대 공유하지 않음 — base/blocker-plan.md의
    -- "재진입" 절) 같은 On→등록→OffWithoutEmit→recompute 패턴을 적용.
    local blocker = getBlocker(slot)   -- Relate(slot) 기반, lazy 생성 — 이 Slot 전용
    blocker:On()
    for i, element in ipairs(slot._elements) do
        if isSlot(element) then
            attachSlot(element, physicalTarget, slot, i)   -- 재귀, ownerKey가 이제 slot 자신
        else
            -- 평범한 Instance 요소도 같은 순서: 자기 자리의 offset은 아무도
            -- 안 읽으므로 None(참여만, 소비 없음), length는 상수 1.
            Dispatch.setOffsetSource(slot, i, None)
            Dispatch.setLength(slot, i, 1)
            element.Parent = physicalTarget   -- quad-roblox 글루가 실제 수행
        end
    end
    blocker:OffWithoutEmit()
    local bk = getBookkeeping(slot)
    if bk then recompute(slot, bk) end
end

최상위 마운트(Dispatch/Slot.luau)는 이제 이 함수 호출 한 줄:

-- process(inst, k, slotValue, index)
attachSlot(slotValue, inst, inst, k)   -- ownerKey = 물리 inst 자신

이미 마운트된 outer에 nested Slot을 나중에 Add하는 경우(런타임에 카테고리 추가):

-- rawAdd 안, element가 Slot이고 self가 이미 마운트돼 있을 때
if isSlot(element) and self._mounted then
    attachSlot(element, self._mountedInst, self, index)
end
-- self가 아직 마운트 전이면 _elements에만 들어가고, self가 나중에
-- attachSlot될 때 위 flush 루프가 처리

이 런타임 단건 경로는 Blocker 게이팅이 필요 없다(사용자 확인, 2026-08-18)"그건 이미 마운트가 된 이후라서 별 상관 없음... 새로운 개체가 뒤에 붙는 현상에서는 위 요소들로 하여금 위치를 구하면 돼, 뒷 요소를 밀어내는게 아니라서, setLength 가 emit 되지 않는것에 영향 안 받고 수행 가능함" — 이 시점엔 self의 Blocker가 이미 flush 배치를 끝내고 OffWithoutEmit()으로 꺼져 있고, 새로 등록되는 position보다 앞선 모든 position은 이미 안정적으로 채워져 있어 nil 자리가 생길 여지 자체가 없다.

recompute가 owner가 Slot이면 그 .Length에도 합계를 반영하도록 확장됐으므로(base/dispatch-core-plan.md 참고) — Slot.Length는 더 이상 raw 개수가 아니라 "요소별 기여도의 합"(plain=1, nested Slot=그 .Length)이 됨. plain 요소만 있는 흔한 경우엔 항상 합==개수라 체감 차이 없음.

파괴 — 재귀적 Clear() 금지, flat teardown

재귀적으로 Clear()(요소별 Remove 반복)를 하면 죽는 서브트리 내부에서 불필요한 shift+recompute가 요소 수만큼 반복되어 비용이 커짐 — 대신 순수 파괴 walk만 하고, outer 쪽 recompute는 자기 위치 하나에 대해서만 한 번 돎:

-- [신설, 2026-08-13 감사 후속] 비파괴 언마운트 — `State<Slot>` 교체/`Extract`
-- 계열이 쓰는 경로. `destroySlotTree`와 **딱 하나만 다름: 실제로 안 죽인다.**
-- 물리 트리에서만 떼어내고 `_elements`/자식 소유권은 통째로 보존하므로,
-- 같은 Slot을 나중에 다른 곳에 다시 마운트할 수 있음(= 포탈).
local function unmountSlotTree(slot)
    for i, element in ipairs(slot._elements) do
        if isSlot(element) then
            unmountSlotTree(element)   -- 재귀 — 중첩 Slot도 똑같이 비파괴
        else
            element.Parent = nil       -- Destroy 아님(quad-roblox 글루가 수행)
        end
        -- releaseOwner를 **안 부름** — 자식들은 여전히 이 slot의 소유. 이게
        -- destroySlotTree와의 핵심 차이(파괴는 소유권까지 반납, 언마운트는 유지).
    end
    local bk = getBookkeeping(slot)
    if bk then
        for i, observer in pairs(bk.observers) do
            unbindLifetime(observer)   -- 물리 target에 걸린 배관만 해제
        end
    end
    slot._mounted, slot._mountedInst = false, nil
    slot.Offset = nil   -- 마운트 전 상태로 복원(위 "`Slot.Offset`은 마운트 전엔 nil")
    -- slot 자신의 unbindLifetime / releaseOwner / owner쪽 setLength·setOffsetSource는
    -- 호출부 몫 — destroySlotTree와 동일한 층위 분리.
end

local function destroySlotTree(slot)
    for i, element in ipairs(slot._elements) do
        -- [정정, 2026-08-13 감사] 소유권 반납을 먼저 — 아래 "소유권 반납은
        -- GC에 맡기면 안 됨" 참고
        releaseOwner(element, slot)
        if isSlot(element) then
            destroySlotTree(element)   -- 재귀는 "파괴"에만, choreography 없음
        else
            element:Destroy()
        end
    end
    local bk = getBookkeeping(slot)    -- 이 slot이 자기 자식들 위해 등록해둔 observer들
    if bk then
        for i, observer in pairs(bk.observers) do
            unbindLifetime(observer)
        end
    end
    -- [정정, 2026-08-13 감사] 마운트 상태도 되돌림 — 안 그러면 파괴된 Slot이
    -- `_mounted == true`로 남아 "마운트된 Slot의 재마운트는 즉시 throw"(위 절)에
    -- 영원히 걸리고, `_mountedInst`가 죽은 inst를 계속 강하게 붙잡음.
    slot._mounted, slot._mountedInst = false, nil
    -- [2026-08-12 열여섯 번째 세션, 스코프 정정] slot 자신의 unbindLifetime은
    -- 여기서 안 부름 — attachSlot이 최상위에서만 bindLifetime하므로 짝도
    -- 최상위 파괴 지점(SlotHandler.process가 반환하는 클로저, 위)에서만 한 번.
    -- destroySlotTree는 재귀 전체에서 항상 이 위치까지만(자식 Observer 정리) 담당.
end

-- [명확화, 2026-08-13 감사에서 index/element 불일치 발견해 보강] 아래
-- 시그니처는 index 기준 예시 — 위 "raw* 내부 호출 규약" 절이 이미
-- 못박았듯 reconcile(위 "여러 Slot이 섞일 때" 절 근처)은 element 기준으로
-- rawRemove(self, prev)를 부름. **같은 불일치가 rawUnmount에도 그대로
-- 있음** — 아래 reconcile 예시의 rawUnmount(self, prev) 호출도 prev가
-- element(mounted[key])이지 index가 아님. 둘 중 하나로 통일할지 얇은
-- 변환 계층을 둘지는 아직 M6 구현 세부로 열려 있음 — 이 블록/아래
-- reconcile 블록 둘 다 그 결정 전 illustrative 예시.
-- [신설, 2026-08-13 여섯 번째 세션] rawRemove의 비파괴 짝 — `:List`의
-- reconcile과 `Extract` 계열이 씀. rawRemove와 **딱 하나만 다름: 안 죽인다.**
function rawUnmount(self, index)
    local element = self._elements[index]
    local bk = getBookkeeping(self)
    if bk.observers[index] then
        unbindLifetime(bk.observers[index])
    end
    releaseOwner(element, self)   -- 소유권은 반납(이제 다른 곳에 넣을 수 있음)
    if isSlot(element) then unmountSlotTree(element) else element.Parent = nil end

    spliceArraysDown(self, index)
    recompute(self, bk)
end

function rawRemove(self, index)
    local element = self._elements[index]
    local bk = getBookkeeping(self)
    if bk.observers[index] then
        unbindLifetime(bk.observers[index])  -- outer가 이 위치 위해 등록해둔 observer
    end
    releaseOwner(element, self)   -- [정정, 2026-08-13 감사] 원래 이 줄이 의사코드에서
                                  -- 빠져 있었음(산문 쪽 "요소 소유권" 절은 rawRemove/
                                  -- rawExtract가 releaseOwner를 부른다고 이미 명시하고
                                  -- 있었는데 코드만 불일치) — 엄격 releaseOwner가
                                  -- 들어온 뒤로는 이 누락이 실동작 차이를 만듦
    if isSlot(element) then destroySlotTree(element) else element:Destroy() end

    spliceArraysDown(self, index)   -- _elements/lengthList/sourceList 전부 한 칸씩 당김
    recompute(self, bk)             -- outer 자기 자신 레벨에서 딱 1회만
end

unbindLifetime이 꼭 필요한지: bindLifetime은 물리 target 인스턴스 생명주기에 걸려있는데, 죽는 건 "이 nested Slot 하나"고 물리 target(공유 부모)은 계속 살아있으니 GC가 자동으로 안 치워줌 — 명시적으로 안 풀면 카테고리가 자주 추가/삭제되는 UI에서 조용히 새는 옵저버가 쌓임. 반대로 물리 target 자체가 죽는 경우(최상위 Destroy)는 지금처럼 GC가 전부 한 번에 정리하니 손 안 대도 됨 — 이 구분은 새 원칙이 아니라 "GC 정리는 물리 target 생명주기 단위"라는 기존 원칙이 nested Slot에서 처음으로 그 경계 바깥의 케이스(target은 살아있는데 논리 서브트리만 죽는 경우)를 만나서 드러난 것뿐.

  • Length 변경은 정확히 offset 변경으로만 전파됨, 별도 채널 없음 — 수정된 recompute(base/dispatch-core-plan.md 참고)를 보면 :Set()이 호출되는 대상은 (a) 뒤 형제들의 offset, (b) owner가 Slot이면 그 .Length 딱 둘뿐. Length 값 자체는 읽히기만 함(:Get()/Observer 트리거) — (b)로 올라간 .Length도 한 단계 위에서는 그냥 또 다른 lengthList 항목이라 같은 패턴이 재귀될 뿐, 새 전파 채널이 아님.

"위치 이전 기억"은 base 책임 아님 — backend가 필요하면 Relate

Slot-in-Slot 자체는 순수 숫자(Length/Offset) 계산만 재귀적으로 하고, "나 이전에 물리적으로 어디에 있었지" 같은 backend 종속적 위치 정보는 전혀 안 다룸 — 필요한 backend(예: DOM insertBefore 기반)가 자기 Relate로 알아서 저장해야 할 몫. web 백엔드는 insertBefore/ removeChild가 물리적으로 밀고/당겨주므로, "지금 이 위치의 물리적 이전 형제가 누구인지"만 삽입 시점에 알면 되고 이미 배치된 형제들의 프로퍼티를 재작성할 필요가 없음 — 기존 "DOM류 물리 순서 백엔드에도 같은 base 메커니즘이 그대로 재사용됨"(2026-08-09 여섯 번째 세션) 확정과 정합적, 중첩이 생겨도 이 결론은 안 바뀜.

기각된 대안 — DOM 백엔드가 nested Slot을 실제 <div> 중첩으로 매핑하는 안. 검토했으나 기각 — React <></>(Fragment)가 존재하는 이유와 정확히 같은 이유로 Slot도 의도적으로 wrapper 없는 그룹핑 도구라, 논리적 중첩을 물리 <div> 중첩으로 매핑하면 이 wrapper-less 원칙 자체가 깨짐(flexbox/grid 등에서 직계 형제를 기대하는 CSS가 깨질 수 있음). 어느 backend든 nested Slot의 리프는 항상 flat하게 같은 물리 부모의 자식이어야 함 — 그래서 위 숫자 기반 메커니즘이 Roblox뿐 아니라 web에도 그대로 필요.

0-based 개수 vs 1-based Lua 인덱스

offset/sum은 0-based 개수(카디널 수)고, _elements/updateFnindex는 1-based Lua 배열 관례 — index + offset 공식이 이 둘을 의도적으로 섞는 것. 상세는 base/dispatch-core-plan.md의 "offset/sum은 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: elementisState(element)(State/Source 둘 다 포함하는 기존 집합 판별, 2026-08-06 후속 세션 확정)면, 그 자리에 내부적으로 새로 만든 Slot(): Single(element)을 대신 삽입 — raw 반응형 요소는 전부 Slot-in-Slot 중첩(위 절) 위에 얹힌 :Single의 얇은 sugar일 뿐이다:

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

:SingleupdateFn을 선택 인자로 완화 — 기본값은 identity. 이 sugar가 성립하려면 :Single(state)(updateFn 생략)이 유효해야 함 — Slot:Single(state, updateFn?), 생략 시 function(item) return item end.

이게 최초안의 세 문제를 전부 없애는 이유:

  • _elementsNone이 절대 안 들어감 — 바깥 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)는 기존 destroySlotTreesub 전체를 정리하면 끝, Move/Swapsub 레퍼런스 하나를 옮기는 것뿐이라 인덱스-구독 동기화 문제 자체가 없음 (:List의 reconcile은 key 기준으로 독립 동작, sub가 바깥에서 어느 인덱스에 있든 상관 안 함).
  • State<T?>(nilable)도 특별 취급 없이 그냥 됨:Single이 이미 if v:Get() == nil then {} else {v:Get()}로 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 호출로 바꾸면 됨).

조합 예시(사용자 제시):

return Slot {
    State<Frame> --[[ 리스트 헤더 — 항상 존재, 정체성만 가끔 바뀜 ]],
    Slot():List(items, updateFn) --[[ 아이템 그룹 — 개수/순서가 동적 ]],
}

헤더처럼 LayoutOrder(offset) 참여가 필요 없는 raw 요소는 그냥 State<Frame>으로 두고, 아이템처럼 개수가 변하며 형제 순서 보장이 필요한 그룹은 Slot():List(...)로 감싸는 식으로 한 Slot 안에서 자유롭게 섞어 쓸 수 있음 — Slot-in-Slot 중첩(위 절)과 이 반응형 raw 요소가 서로 독립적으로 조합됨을 보여주는 예.

State<Slot> 재설정 시 소유권이 안전한가 — 확인됨 (2026-08-13 감사, 사용자 질문)

질문: Slot { state<Slot> }에서 그 state가 다른 Slot으로 재설정되면 소유권/마운트 상태가 정확히 갈리는가. 그리고 이걸 "래퍼가 불변이라 괜찮다"로 설명할 게 아니라 Slot-in-Slot 자체가 일반적으로 안전해야 하는 것 아닌가(사용자 판단: 후자가 맞음).

결론: 후자가 맞고, 실제로 성립함. 위 sugar 때문에 구조는 항상 outer._elements[i] = sub(래퍼 Slot, 이 위치에 영구 고정) → sub:Single(state) → 그 :Listreconcile이 안쪽만 교체 — 이고, reconcileresult ~= prev일 때 rawUnmount(self, prev) 다음에 rawAdd(self, result, pos) 순서로 부름(위 "구현" 절 — [정정, 2026-08-13 여섯 번째 세션]rawRemove(파괴)에서 비파괴 rawUnmount로 바뀜, 다만 rawUnmountreleaseOwner를 부르므로 아래 소유권 표는 그대로 성립). 즉 소유권이 반납 → 재클레임 순서로 정확히 갈림:

시점 elementOwner[innerA] elementOwner[innerB]
최초 reconcile sub
state가 B로 재설정, rawUnmount(sub, innerA) nil(releaseOwner)
이어서 rawAdd(sub, innerB, pos) nil sub(claimOwner)

바깥(outer) 입장에선 _elements[i]가 계속 sub라 아무 일도 안 일어나고, 소유권 판정도 sub 아래 한 레벨에서만 갈림 — 래퍼가 불변이라는 사실에 기대는 게 아니라, nested CRUD의 release→claim 규율 자체가 안전성을 만듦. 그래서 Slot { Slot { Slot } }처럼 손으로 중첩한 경우에도 정확히 같은 규칙 하나로 동작함(래퍼 sugar는 그저 그 일반 메커니즘의 사용자일 뿐).

단 이 결론은 위 "요소 소유권" 절의 2026-08-13 감사 수정 셋을 전제함 — (1) nested claimOwner가 엄격(같은 owner 재클레임도 error)이라 Slot { a, a }가 실제로 막히고, (2) rawRemove/rawUnmountreleaseOwner를 실제로 부르고(의사코드에서 빠져 있었음), (3) destroySlotTree가 자식 소유권을 GC에 안 맡기고 명시적으로 반납. 셋 중 하나라도 빠지면 이 표의 중간 단계가 어긋남.

[전면 정정, 2026-08-13 여섯 번째 세션 후속, 사용자 결정] State<Slot> 교체는 파괴가 아니라 언마운트state<Frame>와 완전히 동일

이 정정 전에 쓰인 두 절("State<Slot?>nil이 됐다 돌아오는 경우", "포탈")은 당시 설계(reconcile이 rawRemove=파괴를 씀)를 정확히 서술한 것이지만, 그 설계 자체가 이 정정으로 뒤집힘 — 두 절의 원문은 archive/slot-discard-no-portal-reversed.md로 옮겼고(아래쪽에 요약 포인터만 남김), 현재 유효한 규칙은 이 절이다.

결정(사용자): State<Slot>이 다른 값으로 교체될 때 이전 Slot은 파괴되지 않고 언마운트만 된다. 근거:

  1. state<Frame>가 이미 그렇게 동작함store.child:Set(otherFrame)을 해도 이전 Frame을 quad가 Destroy()해주지 않음, 그냥 트리에서 내려올 뿐. State<Slot>만 다르게(파괴로) 동작할 이유가 없음. **"이전 값을 지울지는 그 값을 만든 쪽이 정한다"**는 이미 Ref("Destroy와 무관")/ Attribute("명시적 None으로만 지움")에서 확정된 quad 전역 철학과도 같은 결.
  2. 비파괴 추출은 이미 지원되는 개념Extract/ExtractAll/Splice가 전부 비파괴로 확정돼 있음(위 "CRUD API 확정" 절). "제거 = 파괴"만 reconcile이 임의로 골랐던 것이라, 그 선택을 되돌리는 것뿐 새 능력이 아님.
  3. 뽑아냈으면 더 이상 leaf의 소유가 아님 — 소유권이 반납된 (releaseOwner) 상태이므로, 그 Slot을 계속 쓸지 버릴지는 그걸 들고 있는 코드의 몫.

그러면 안 지운 Slot은 언제 죽는가 — "들고 있다 죽으면 같이 소멸" (GC-native, 이 프로젝트의 기본 원칙 그대로): 아무도 참조를 안 들고 있으면 그냥 GC됨. 명시적으로 지금 죽이고 싶으면 dispose(아래).

부수 효과 — 이미 파괴된 대상에 재마운트하려는 시도가 자연히 막힘. Slot이 마운트될 때 자기 하위 요소들까지 bindLifetime으로 물리 target에 묶고, 실제 동작 전에 canExecute를 확인하도록 하면(base/lifecycle-pattern.md), "nested로 마운트해둔 뒤 물리 Instance를 Destroy하고, 그 다음 Slot을 뽑아 다른 데 쓰려는" 경로가 별도 방어 로직 없이 걸러짐 — 이미 있는 bindLifetime/canExecute 게이트를 한 층 더 촘촘히 적용하는 것뿐, 새 메커니즘이 아님.

Set으로 덮어쓰기 전에 이전 값을 직접 Destroy()하는 건 UB. state<Frame>에서 frame:Destroy()를 먼저 하고 Set(other)을 부르는 것과 정확히 같은 문제 — quad는 그 값이 이미 죽었다는 걸 모른 채 언마운트 경로를 탐. 순서는 항상 Set(언마운트) → 그 다음 정리.

dispose(value) — quad가 관리 중인 값을 안전하게 지우는 유일한 경로 (신설, 사용자 제안, 2026-08-14 열 번째 세션에 시그니처/범위 확정 — question.md 0-B 해소)

위 UB를 "조심하세요"로만 두지 않기 위해, base 레벨 탑레벨 유틸 dispose(value: Slot | Instance): () 를 제공:

  • 의미([정정, 사용자 확정] 최초안은 "마운트돼 있으면 먼저 떼어낸 뒤 파괴"였으나 더 단순하게 확정): 대상이 아직 어느 트리에 의해 살아있길 요구되고 있으면 파괴를 거부하고 즉시 error. 떼어내주지 않음 — 떼어내는 건 Set(언마운트)의 몫이고, dispose는 그 뒤에 부르는 것. 아무도 요구하지 않는 상태면 실제로 파괴(Slot이면 하위까지 재귀).
  • 왜 거부가 맞는가(사용자): "실제로 클리어 하거나 Destroy 해도 로블록스엔 에러 안 나는데, quad에선 데이터 구조가 깨지는 일이니까요." 엔진은 조용히 넘어가지만 quad의 _elements/lengthList/sourceList/ elementOwner는 그 순간 어긋남 — 그래서 **quad가 관리 중인 값을 안전하게 지우는 유일한 경로가 dispose**이고, 그 경로가 "지금 지우면 안 되는 상태"를 잡아주는 게 존재 이유. 위 "Set 전에 직접 Destroy() 하는 건 UB" 항목이 dispose를 쓰면 UB가 아니라 명확한 에러가 됨.
  • 이게 성립하는 이유 — "이 값이 지금 어디 마운트돼 있는가"를 이미 알고 있음. a = Frame{}; Frame{a}; Frame{a}를 error로 잡기로 이미 확정했고(위 "핵심 제약: 소유권 귀속과 단일 마운트"), 그 판정을 위해 elementOwner가 element → owner를 들고 있음 — dispose는 그 정보를 거꾸로 읽으면 되므로 새 부기가 필요 없음. 사용자 지적: "이미 두 곳에 넣는 게 에러나도록 하기로 했으니, 어디 마운트되었냐가 따져지고, 그래서 이미 가능한 일".

범위 — Slot + 엔진 객체(Instance)만, Observer/Effect는 명시적으로 제외(2026-08-14 열 번째 세션, 사용자 확정):

function dispose(value)
    if isSlot(value) then
        -- 위 elementOwner 기반 판정 재사용 — 요구 중이면 error, 아니면 재귀 파괴
        ...
    else
        disposeInst(value)  -- 아래 주입 op
    end
end
  • Observer/Effect는 dispose 대상이 아닌가: 이 둘은 children 배열 leaf 위치에 놓이면 Dispatch/Leaf.luau가 매치해 내부적으로 bindLifetime(inst, value)를 호출하고(base/source-state-plan.md "이중 바인딩 금지" 절), 생존은 그 GC 앵커(gcconn)만으로 판정됨 — Slot처럼 "죽는 순간 elementOwner/lengthList/sourceList가 어긋나는" 트리 부기 자체가 없음. 즉 dispose가 막으려는 문제(부기 붕괴)가 Observer/Effect에는 원천적으로 발생하지 않음 — 아무도 안 들고 있으면 그냥 GC, 조기에 끊고 싶으면 unbindLifetime으로 충분하고 dispose가 다룰 이유가 없음. 주의 — Modifier 필드/Slot:Add·:List 원소 금지 규칙("핸들러 계층 값이 들어오면 즉시 error")과 헷갈리지 말 것. 그건 Modifier 필드나 Slot의 CRUD 원소 자리에 관한 별개 규칙이고, children 배열의 leaf 위치(정적 Frame{observer}류)나 그 leaf가 State<Observer>/State<Effect>로 반응형으로 바뀌는 경우는 전혀 다른 컨텍스트 — 후자는 이미 확정된 일반 원칙("모든 (inst,k)TState<T>StoreBind가 균일하게 재귀 처리")의 자연스러운 귀결이라 별도 설계 없이 그냥 됨.
  • unbindLifetime과의 역할 분담: dispose트리 소유권 부기가 있는 대상(Slot/Instance)이 아직 요구되는데 강제로 죽이려는 시도를 막는 것이고, unbindLifetime은 Observer/Effect류의 GC 앵커를 조기 해제하는 것 — 축이 달라 서로 대체 불가.

base/backend 분리 — disposeInst는 주입 op(base/dispatch-core-plan.md "base가 소유하는 핸들러와 주입되는 엔진 op" 절과 같은 패턴, addTag/ removeTag/setAttribute가 선례): disposeisSlot이 아닌 값을 받으면 base가 시그니처만 소유하는 disposeInst(inst: any): ()로 위임 — quad-roblox는 inst:Destroy()로 구현. 웹 등 다른 백엔드는 자기 방식으로 매핑.

네이밍: free()는 GC-native 언어 맥락과 안 맞아 기각, Destroy는 엔진 자체 :Destroy() 메소드와 동명이라 사용자가 "그냥 :Destroy() 부르는 거 아님?"으로 착각할 위험이 있어 기각 — dispose 유지.

[백로그 후보, 2026-08-18 구현 전 QA] SetAndDispose 류 편의 콤비네이터. 위 "Set(언마운트) → 그 다음 정리" 순서 요구 때문에 호출부가 매번 Get()으로 이전 값을 미리 잡아두고 → Set(new) → 잡아둔 옛 값을 dispose 하는 3단계를 손으로 써야 해서 편의성이 떨어진다는 사용자 지적: "source:apply(SetAndDispose( new )) 같은걸 구현해줄까는 생각해보았음(단 여기서의 apply 는 source 를 넘겨주는 함수가 되어야함.). Get해놓고 Set 이후 나중에 지우는게 편의성이 떨어지기 때문. 아니면 그냥 source 자체에 :콜론 메서드로 가능하게 하는걸 넣어줄까 생각은 하고 있음." 후보 둘:

  1. source:Apply(SetAndDispose(new)) — 콤비네이터. 단 여기서의 ApplyState가 아니라 Source를 넘겨주는 함수여야 함(사용자 명시) — 지금 확정된 state:Apply(factory)factory(self)State를 넘기므로, Source 전용 변형이 필요한지 같이 정해야 한다.
  2. Source에 콜론 메서드로 직접 얹기(source:SetAndDispose(new)).

미결: 어느 쪽을 택할지, 그리고 이번 범위에 넣을지 백로그로 뺄지. state:Apply의 시그니처((State<T>) -> U)에 영향이 갈 수 있으므로 M3 착수 전에 방향만이라도 정해둘 것. question.md에 올려둠.

구현상 바뀌어야 하는 것

[반영 완료, 2026-08-13 감사 후속] 비파괴 경로를 unmountSlotTree로 신설하고(아래 "파괴" 절), 이걸 쓰는 자리를 둘로 못박음:

  1. SlotHandler.process가 반환하는 클로저(최상위 dispatch 경로) — 이 결정이 적용돼야 하는 바로 그 자리인데 처음엔 빠뜨려서 destroySlotTree를 계속 부르고 있었음(다른 에이전트 리뷰가 지적, 그대로 뒀으면 언마운트 결정 자체가 무의미해질 뻔함). 지금은 위 절의 코드가 unmountSlotTree + setOffsetSource(None)/setLength(0) + unbindLifetime + releaseOwner를 부름.
  2. :Listreconcile[재정정, 2026-08-18 구현 전 QA] 여기서 비파괴가 되는 건 값 교체와 PopOnly이다. updateFnnil/None을 반환하거나 키가 데이터에서 사라진 경우는 다시 파괴가 기본(사용자 판정) — 상세와 이유는 위 "nil 리턴은 파괴가 기본" 절이 소스. 2026-08-13에 이 항목이 "교체/소멸 시 전부 비파괴"로 적혔던 것은 :List에는 안 맞는 일반화였음.

여전히 파괴인 것: 명시적 CRUD Slot:Remove(index)/Slot:Clear() (CRUD 표가 "제거 + 파괴"로 이미 정의), dispose, 그리고 위 2번의 :List 소멸 경로. 즉 일반 규칙은 **"자동 경로는 언마운트, 명시적으로 지우라고 한 것만 파괴"**이되, :List에서 nil을 반환하는 것 자체가 "지우라고 한 것"으로 센다Ref/Attribute의 "지울 거면 명시적으로" 철학과 같은 결이고, updateFn이 지우지 않길 원하면 PopOnly로 그 의도를 명시한다.

unmountSlotTreedestroySlotTree가 하는 일 중 실제 파괴와 자식 소유권 반납만 빼고 나머지는 그대로 함(자식 observer unbindLifetime, _mounted/_mountedInst 복원, slot.Offset = nil). 옛 owner에 등록해둔 Dispatch.setLength/setOffsetSource 해제는 호출부 몫(아래).

[정정, 사용자 지적] "해제 짝"이라는 새 API는 필요 없음 — 옛 owner에 대해 그냥 Dispatch.setOffsetSource(ownerKey, position, None) + Dispatch.setLength(ownerKey, position, 0)을 다시 부르면 끝. 이건 이미 확정된 관용구 그대로임(base/dispatch-core-plan.md의 "해제(그 자리가 더 이상 기여하지 않게 될 때)는 setOffsetSource(...,None)"). 즉 해제 = 0/None으로 재등록이고 별도 unregister 함수가 없어도 됨 — 앞서 "이게 실제 작업량"이라고 적었던 판단은 과했음.

⚠️ 순서가 중요함 — setOffsetSource를 먼저, setLength를 나중에 (2026-08-13 여섯 번째 세션, 사용자 지적). 마운트할 때와 달리 해제할 때는 이 순서를 반드시 지켜야 함:

  • Dispatch.setLength는 끝에서 gatedRecompute를 경유해(배치 게이팅 중이 아니면) recompute를 돌리고, recomputesourceList를 순회하며 각 자리의 offset:Set(sum)을 호출함(base/dispatch-core-plan.md "Length/Offset" 절 — 해제는 배치 도중이 아니라 steady state에서 흔히 일어나므로 이 경로에서는 gatedRecompute가 거의 항상 즉시 recompute로 이어짐).
  • 그래서 setLength(0)을 먼저 부르면, 그 안의 recompute가 도는 시점에 해제 중인 자리의 sourceList[i]아직 옛 Slot의 offset Source가 그대로 남아 있음 → 지금 막 떼어내는 서브트리의 Source에 :Set()이 날아가고, 그 Source를 구독하던 (이제 곧 없어질) 자식들의 LayoutOrder 계산이 헛되이 캐스케이드됨. 사용자 표현대로 "invalid한 offset source 자체가 있다는 것부터 위험".
  • setOffsetSource(None)을 먼저 부르면 그 자리는 recomputeoffset ~= None 가드에 걸려 곧바로 제외되므로, 뒤이은 setLength(0)recompute가 그 Source를 아예 안 건드림.
  • 자기 자신의 offset은 어차피 안 바뀜(자기 length가 줄어든다는 건 자기 앞 형제들의 누적합은 그대로라는 뜻) — 그래서 이 순서 문제는 "내 offset이 틀리게 계산된다"가 아니라 "죽는 중인 Source에 쓰기가 날아간다" 쪽임. 사용자도 "물론 별 상관 없어요"라고 했듯 실제 값 오류로 이어지진 않지만, 방어적으로 순서를 고정.

추가 방어 조치:

  • 해제 시 slot.Offset = nil도 같이 — 이 문서의 "Slot.Offset은 마운트 시점에 세팅되고 마운트 전엔 nil" 규칙과 짝을 맞춤. 안 그러면 떼어낸 Slot이 옛 owner 기준의 stale한 Offset을 계속 공개해, 그걸 읽는 사용자 코드가 조용히 틀린 값을 씀.
  • recomputesourceList[i]None이든 nil이든 "참여 안 함"으로 똑같이 관대하게 넘어갈 것 — 정상 상태에선 항상 None으로 채워지는 게 계약이지만(nil은 배열에 구멍을 냄), 해제/재마운트가 얽히는 전이 구간에서 nil이 관측돼도 크래시 대신 skip이어야 함. 이건 계약 완화가 아니라 순수 방어 — 등록 쪽은 여전히 None을 쓸 의무가 있음.

state<state<Frame>>류로 offset이 밀리고 당겨지는 문제는 "그냥 확인된 것"으로 수용(사용자 판단) — state<state<Tag>>와 같은 범주로, 평탄화 도구(research/operator-sugar-plan.md)가 처리할 요소이고 실사용 케이스가 드묾. Dispatch/Slot에 별도 배관을 넣지 않음.

[역전됨 — 원문은 archive로 이동] State<Slot?> 왕복 / 포탈 검토 두 절

위 "언마운트" 정정 이전에 쓰인 두 절(State<Slot?>nil이 됐다 돌아오면 Slot이 파괴된다는 분석, 그리고 포탈이 가능한지 검토하며 숙제 셋을 열어둔 절)은 결론이 전부 뒤집혀 이 문서에서 뺐다 — 원문·역전 근거·숙제 셋의 결말은 archive/slot-discard-no-portal-reversed.md 3~4절.

지금 유효한 답만 옮겨두면:

  • nilslotA 왕복은 정상 동작한다 — 언마운트 전환으로 "두 번째 등장부터 깨진 서브트리" 문제 자체가 사라짐. 단 Set으로 덮어쓰기 전에 이전 값을 직접 Destroy()하는 건 UB(state<Frame>에서 먼저 frame:Destroy()하고 Set하는 것과 같은 문제) — 순서는 항상 Set(언마운트) → 그 다음 정리.
  • 포탈은 기본 동작이다 — opt-in 표식도, 새 API도 없음. 옛 owner의 등록 해제는 setOffsetSource(ownerKey, position, None) 다음에 setLength(ownerKey, position, 0)(순서 중요 — 위 ⚠️ 절).

:Listindex도 nested-Slot 결과의 .Length만큼 건너뛰어야 함 (같은 세션 후속, 사용자 발견)

Slot-in-Slot으로 T = Instance | Slot<Instance>가 허용되면서, :ListupdateFnresult로 nested Slot(멀티루트 컴포넌트 결과 등)을 반환할 수도 있음 — 이때 그 아이템은 물리적으로 1개가 아니라 result.Length개의 실제 요소를 차지함. 원래 candidateIndex = pos + 1(고정 +1)로만 커밋했다면, updateFnindex를 LayoutOrder 계산에 쓰는데 어떤 아이템이 nested Slot(Length=3)을 반환할 때 다음 아이템의 index가 3만큼 안 건너뛰고 1만 건너뛰어 물리적으로 겹치는 LayoutOrder 범위가 나옴 — 의도된 동작으로 확정, 위 "구현" 절의 reconcile 의사코드에 이미 반영됨(`pos = candidateIndex

  • 1 + (if isSlot(result) then result.Length:Get() else 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이 자체적으로 방어하는 패턴을 문서화.