diff --git a/.claude/base/bind-system-plan.md b/.claude/base/bind-system-plan.md index 2934eb2..23eb114 100644 --- a/.claude/base/bind-system-plan.md +++ b/.claude/base/bind-system-plan.md @@ -138,6 +138,12 @@ src/schema/union.luau:48-68`) — 에러 메시지는 즉시 문자열로 만들 바뀌는 건 둘 다 같은 Ref-leaf handler가 매치하므로 `retract`가 아니라 `process`의 diff가 담당(이전 Ref를 `:Set(nil)`로 언바인딩), `retract`는 그 자리가 Ref이길 아예 그만둘 때만 — 아래 "`Ref`의 retract" 절 참고. + **[추가, 2026-08-12 아홉 번째 세션] `Slot`도 같은 패턴, 단 diff가 아니라 + identity 비교** — `State`이 `slotA→slotB`로 바뀌는 것도 같은 + SlotHandler가 매치하므로 `process`가 처리. `Tag`/`Ref`처럼 세밀한 diff + 대신 "같으면 완전 무시, 다르면 이전 것 통째로 폐기 후 새로 마운트"(Slot은 + portal 없이 폐기만 하기로 이미 확정돼 있어서) — `slot-plan.md` "Slot과 + Store 바인드의 관계" 절 참고. - store bind가 새 값으로 넘어갈 때 이전 핸들러의 `retract`를 호출해주면 됨 — **정확한 전파 메커니즘은 아래 "Dispatch 체인" 절 참고**(재귀 재-dispatch에서 여러 단계가 겹칠 때 어느 슬롯에 뭘 추적하는지가 diff --git a/.claude/base/slot-plan.md b/.claude/base/slot-plan.md index b79c550..d2b40a5 100644 --- a/.claude/base/slot-plan.md +++ b/.claude/base/slot-plan.md @@ -198,16 +198,57 @@ Slot이 store 바인드로 들어오는 경우, pluggable 처리기에 `retract` > 동작이 '부모 위임' 잠정안에서 '폐기(옮기지 않음)'로 확정") 참고. 이 문단은 > 검토 과정의 히스토리로만 남겨둠, 현재 유효한 동작 아님. -이건 `base/bind-system-plan.md`의 "Store 바인드는 재실행 래핑" 확정 -모델과 맞물림 — slot이 store 값으로 오면, store 바인드 핸들러가 이전 slot -상태를 `retract`하고 새 slot 상태로 다시 `process`하는 사이클을 돈다는 뜻. -Slot 핸들러 자신이 감시 중인 값(배열/스토어)이 바뀔 때 child를 갱신하는 -추적(구독)도 `base/bind-system-plan.md`가 말하는 "process 함수가 다른 값 -변경을 추적해도 됨" 범위에 속하고, `retract` 시점엔 그 추적만 풀면 됨 — -Destroy 시점엔 `retract`가 호출되지 않는다는 원칙(`base/lifecycle-pattern.md`)도 -동일하게 적용. +**[정정, 2026-08-12 아홉 번째 세션] 위 "store 바인드 핸들러가 이전 slot을 +`retract`하고 다시 `process`하는 사이클" 서술은 부정확했음 — `Ref`의 +retract를 검토하며 발견한 것과 같은 오류.** `base/bind-system-plan.md`의 +일반 계약("핸들러 *타입*이 안 바뀌면 `retract` 없이 `process`가 diff +담당", `Tag`/`Ref`가 실제 선례)을 그대로 적용하면, `State`이 +`slotA→slotB`로 바뀌는 것도 둘 다 같은 SlotHandler가 매치하는 경우라 +**`retract`가 아니라 `process` 자신이 처리해야 함** — `retract`는 그 +자리가 아예 Slot이길 그만둘 때만. 메커니즘은 `Ref`(`bind-system-plan.md` +"`Ref`의 retract" 절)와 같은 모양의 `Relate` 기반 diff: -**확정(2026-08-04 검증 라운드): retract되는 slot은 옮겨지지 않고 그냥 폐기된다.** +```lua +local relate = Relate() -- SlotHandler 전용, (inst,k)별 마지막으로 마운트한 Slot 기억 + +function SlotHandler.process(inst, k, slotValue) + local old = relate:GetStrong(inst, k) + if old == slotValue then + return -- 이미 같은 바인딩 — no-op, 다시 빠지고 다시 들어가지 않음 + end + if old then + destroySlotTree(old) -- 폐기, 옮기지 않음 — 아래 "확정" 절 그대로 + end + attachSlot(slotValue, inst, inst, k) + relate:SetStrong(inst, k, slotValue) +end + +function SlotHandler.retract(inst, k, v) + assert(v == nil, "Slot 자리가 더 이상 Slot이 아니게 될 때만 불림") + local old = relate:GetStrong(inst, k) + if old then destroySlotTree(old) end + relate:SetStrong(inst, k, nil) +end +``` + +- **같은 바인딩이면 완전히 무시하는 게 이 자리에선 효율 문제가 아니라 + 정합성 문제** — `Tag`/`Ref`의 diff는 값이 같아도 기껏해야 헛계산만 + 하고 넘어가지만, Slot은 아래 "확정" 절대로 "폐기, 옮기지 않음"(portal + 없음)이 이미 정책으로 확정돼 있어서, 이 no-op 가드가 없으면 **재귀 재 + emit이 있을 때마다 마운트된 서브트리 전체가 파괴됐다 다시 만들어짐** + (자식들이 들고 있던 스크롤 위치/포커스/애니메이션 상태 전부 유실) — + Tag의 diff가 막으려던 "깜빡임" 문제보다 훨씬 파괴적인 버전. + `store.key:Set(sameSlotAgain)`처럼 사용자가 실수로 같은 객체를 다시 + emit하거나, 상위 `:Compute`가 재계산됐는데 결과가 우연히 같은 Slot + 레퍼런스인 경우 등이 실제로 이 경로를 탈 수 있음. +- Slot 핸들러 자신이 감시 중인 값(배열/스토어)이 바뀔 때 child를 갱신하는 + 추적(구독)도 `base/bind-system-plan.md`가 말하는 "process 함수가 다른 값 + 변경을 추적해도 됨" 범위에 속하고, `retract` 시점엔 그 추적만 풀면 됨 — + Destroy 시점엔 `retract`가 호출되지 않는다는 원칙(`base/lifecycle-pattern.md`)도 + 동일하게 적용. + +**확정(2026-08-04 검증 라운드, 메커니즘은 위처럼 2026-08-12 아홉 번째 +세션에 정정): retract/재바인드되는 slot은 옮겨지지 않고 그냥 폐기된다.** Slot은 바인딩되는 순간 그 안의 요소를 전부 own해버리는 데이터형 — 새 slot 상태로 교체될 때 이전 slot의 내용을 다른 곳으로 옮기는 경로는 없음, 그냥 버림. React의 portal(`<>`)류로 나중에 옮길 수 있게 하는 것도 검토됐으나 diff --git a/.claude/session/2026-08-12-09-slot-retract-same-pattern.md b/.claude/session/2026-08-12-09-slot-retract-same-pattern.md new file mode 100644 index 0000000..e8b2f3e --- /dev/null +++ b/.claude/session/2026-08-12-09-slot-retract-same-pattern.md @@ -0,0 +1,64 @@ +# 2026-08-12 아홉 번째 세션 — `Slot`의 store 재바인드도 `Ref`와 같은 `Relate` diff 패턴 + +## 배경 + +직전 세션(여덟 번째, `Ref`의 retract를 `TagHandler`와 같은 메커니즘으로 +확정)에서 발견한 패턴이 다른 곳에도 있는지 사용자가 확인 요청: `Slot`도 +store 값으로 재바인드될 수 있는가(`State`), 그렇다면 같은 `Relate` +diff 패턴을 써야 하는가 — 그리고 이미 같은 바인딩이면 전부 빼고 다시 +넣는 것 자체를 하지 말고 완전히 무시해야 하지 않겠냐는 제안. + +## 확인 + +`slot-plan.md` "Slot과 Store 바인드의 관계" 절을 다시 읽어보니, `Ref`에서 +고쳤던 것과 정확히 같은 종류의 부정확한 서술이 있었음: "store 바인드 +핸들러가 이전 slot 상태를 `retract`하고 새 slot 상태로 다시 `process`하는 +사이클을 돈다"는 문장은, `bind-system-plan.md`의 일반 계약("핸들러 *타입*이 +안 바뀌면 `retract` 없이 `process`가 diff 담당")을 적용하면 틀림 — +`slotA→slotB`도 같은 SlotHandler가 매치하는 경우라 `retract`가 아니라 +`process`가 처리해야 함. 게다가 실제 `Dispatch/Slot.luau`의 `process(inst, +k,slotValue)` 구현(`attachSlot(slotValue, inst, inst, k)` 한 줄)을 보면 +이전 값과의 비교 자체가 아예 없었고, `destroySlotTree`(파괴 함수)도 CRUD +경로(`rawRemove`)에서만 쓰이고 store-bind retract 경로에 연결된 적이 없었음 +— 실제로 와이어링 자체가 안 돼 있던 진짜 갭. + +## 결정 + +`Ref`와 같은 `Relate` 기반 패턴을 재사용하되, Slot의 이미 확정된 "폐기, +옮기지 않음"(portal 없음) 정책 때문에 diff가 아니라 **identity 비교**로 +단순화: + +```lua +local relate = Relate() + +function SlotHandler.process(inst, k, slotValue) + local old = relate:GetStrong(inst, k) + if old == slotValue then return end -- 이미 같은 바인딩, no-op + if old then destroySlotTree(old) end -- 폐기, 옮기지 않음(기존 정책) + attachSlot(slotValue, inst, inst, k) + relate:SetStrong(inst, k, slotValue) +end + +function SlotHandler.retract(inst, k, v) + assert(v == nil) + local old = relate:GetStrong(inst, k) + if old then destroySlotTree(old) end + relate:SetStrong(inst, k, nil) +end +``` + +**이 no-op 가드가 `Tag`/`Ref`보다 Slot에서 더 중요한 이유**: Tag/Ref의 +diff는 값이 같아도 기껏해야 헛계산만 하고 넘어가지만, Slot은 "폐기, +옮기지 않음" 정책이 이미 확정돼 있어서 이 가드가 없으면 재귀 재emit이 +있을 때마다(예: 상위 `:Compute` 재계산 결과가 우연히 같은 Slot +레퍼런스인 경우) 마운트된 서브트리 전체가 파괴됐다 다시 만들어짐 — 자식이 +들고 있던 스크롤/포커스/애니메이션 상태 전부 유실되는, Tag의 diff가 +막으려던 "깜빡임"보다 훨씬 파괴적인 버전. + +## 반영 + +- `base/slot-plan.md` "Slot과 Store 바인드의 관계" 절 — 부정확했던 + "retract 사이클" 서술을 정정, 위 pseudocode와 근거 추가. "확정: 폐기, + 옮기지 않음" 정책 자체는 그대로 유지(메커니즘만 정정). +- `base/bind-system-plan.md` 일반 retract 계약 절 — `Tag`/`Ref`에 이어 + `Slot`도 같은 패턴의 세 번째 예시로 교차 참조 추가. diff --git a/CLAUDE.md b/CLAUDE.md index 74b9e33..576d568 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -525,3 +525,17 @@ diff")과 대조해 `TagHandler` 선례와 정확히 같은 메커니즘이어 언바인딩은 Instance `Destroy()`와 완전히 무관 — Destroy된 대상을 계속 들고 있는 채로 남는 건 UB로 허용, 정리가 필요하면 `Effect`를 쓰도록 문서가 유도. + +**2026-08-12 아홉 번째 세션 — `Slot`의 store 재바인드도 `Ref`와 같은 +`Relate` diff 패턴** (`session/2026-08-12-09-slot-retract-same-pattern.md`) +직전 세션의 `Ref` 패턴이 `Slot`에도 적용되는지 사용자가 확인 요청 — +`slot-plan.md`의 "store 바인드 핸들러가 retract하고 다시 process" 서술이 +`Ref`에서 고쳤던 것과 같은 부정확한 서술이었음을 발견(실제 `Dispatch/ +Slot.luau`의 `process`엔 이전 값 비교 자체가 없었고 `destroySlotTree`도 +store-bind retract 경로에 연결된 적 없는 진짜 갭). `Ref`와 같은 `Relate` +기반 패턴으로 정정하되, Slot은 이미 확정된 "폐기, 옮기지 않음"(portal +없음) 정책 때문에 세밀한 diff 대신 **identity 비교**로 단순화 — 같은 +바인딩이면 완전 무시, 다르면 이전 것 통째로 폐기 후 새로 마운트. 이 +no-op 가드는 Tag/Ref보다 Slot에서 훨씬 중요함(가드 없으면 재귀 재emit마다 +마운트된 서브트리 전체가 파괴됐다 재생성돼 자식의 스크롤/포커스/애니메이션 +상태가 전부 유실됨).