diff --git a/.claude/base/bind-system-plan.md b/.claude/base/bind-system-plan.md index f909e31..2934eb2 100644 --- a/.claude/base/bind-system-plan.md +++ b/.claude/base/bind-system-plan.md @@ -133,7 +133,11 @@ src/schema/union.luau:48-68`) — 에러 메시지는 즉시 문자열로 만들 매치되는 핸들러가 항상 PropertyHandler 하나뿐이 되어 이 케이스 자체가 사라짐 — 트윈 취소/전환은 이제 PropertyHandler 내부의 3-상태 릴레이션 슬롯으로 처리(`base/tween-plan.md`, `archive/ - tween-special-bind-key-reversed.md`). + tween-special-bind-key-reversed.md`). **[추가, 2026-08-12 여덟 번째 + 세션] `Ref`도 `Tag`와 같은 결** — `State`가 `refA`에서 `refB`로 + 바뀌는 건 둘 다 같은 Ref-leaf handler가 매치하므로 `retract`가 아니라 + `process`의 diff가 담당(이전 Ref를 `:Set(nil)`로 언바인딩), `retract`는 + 그 자리가 Ref이길 아예 그만둘 때만 — 아래 "`Ref`의 retract" 절 참고. - store bind가 새 값으로 넘어갈 때 이전 핸들러의 `retract`를 호출해주면 됨 — **정확한 전파 메커니즘은 아래 "Dispatch 체인" 절 참고**(재귀 재-dispatch에서 여러 단계가 겹칠 때 어느 슬롯에 뭘 추적하는지가 @@ -978,6 +982,79 @@ tween-plan.md`도 이에 맞춰 갱신됨). Ref의 진짜 용도는 다름: 담기는 용도/leaf에 바인딩하는 용도 둘 다에 여전히 맞아 더 나은 대안이 없다는 결론, 용어 정리 대상에서 제외됨. +### `Ref`의 retract — `State` 재바인드 시 이전 Ref에 `nil` (2026-08-12 여덟 번째 세션, `TagHandler`와 같은 메커니즘 재사용) + +**배경**: `Ref`는 이미 "일반 프로퍼티/Modifier 필드/Store 값 어디든 자유롭게 +들어감"(위 "동적 경로 가드" 절)이 확정돼 있어 — `State`가 실제로 +가능하고, 그러면 Store 값이 `refA`에서 `refB`로 바뀌는 경우가 생김. 이때 +`refA`가 계속 "확정된 값(대개 이전 `inst`)"을 들고 있으면, 그 자리가 이제 +`refB`로 넘어갔다는 걸 모르는 코드가 `refA.Value`를 계속 유효하다고 믿는 +조용한 버그가 남음 — `PreRef` 재사용 버그(위 절)와 같은 클래스의 문제. + +**메커니즘 — `TagHandler`(`base/tag-plan.md`)와 정확히 같은 패턴 재사용, +새 장치 아님.** `Dispatch`의 일반 규칙("핸들러 *타입*이 안 바뀌면 retract +없이 process만 다시" — `refA→refB`는 둘 다 같은 Ref-leaf handler가 +매치하므로 여기 해당)을 그대로 따르면, `refA→refB` 전환은 `retract`가 +아니라 **`process` 자신이 이전 값을 기억해뒀다가 diff**해야 함: + +```lua +local relate = Relate() -- Ref-leaf handler 전용, (inst,k)별 마지막으로 바인딩한 Ref 기억 + +RefLeafHandler.isHandlable(inst, k, v) = isRef(v) and not isPreRef(v) + +function RefLeafHandler.process(inst, k, v) + local old = relate:GetStrong(inst, k) + if old and old ~= v then + old:Set(nil) -- 이전 Ref 언바인딩 — 매 :Set()마다 콜백 재통지되는 + -- 기존 Ref 규칙(위 "해소됨 — 반복 재설정 가능" 항목)을 + -- 그대로 재사용, 새 알림 경로 아님 + end + v:Set(inst) + relate:SetStrong(inst, k, v) +end + +function RefLeafHandler.retract(inst, k, v) + assert(v == nil, "Ref 자리가 더 이상 Ref가 아니게 될 때만 불림 — TagHandler와 같은 이유") + local old = relate:GetStrong(inst, k) + if old then old:Set(nil) end + relate:SetStrong(inst, k, nil) +end +``` + +- **`retract`는 이 자리가 Ref이길 아예 그만둘 때만 불림**(Store 값이 Ref가 + 아닌 다른 것으로 바뀌어 다른 Handler가 매치되는 경우) — `refA→refB`처럼 + Ref끼리 바뀌는 흔한 경우는 위 `process`의 diff가 담당. `retract`와 + `process` 양쪽 다 결국 `old:Set(nil)` 하나로 귀결되므로 실질적으로 + "언바인딩 로직은 하나, 트리거 경로만 둘"인 구조 — Tag의 diff/전체삭제 + 분리와 동형. +- **children 배열 리터럴 `Ref`도 같은 코드 경로를 그대로 씀** — 그 경우 + `relate:GetStrong(inst,k)`가 애초에 `nil`(그 자리에 처음 오는 값)이라 + `old`가 없어 언바인딩 분기를 안 타고 바로 `v:Set(inst)`로 끝남. 즉 + "1회성 리터럴 구성"과 "반복 재바인드"가 하나의 구현으로 자연히 커버됨, + 케이스 분기 불필요. +- **타입: 비-nilable `T`도 정당한 용도(사용자 확인, 2026-08-12 여덟 번째 + 세션)** — `Ref`는 "채워지길 기다리는 박스"뿐 아니라 "이미 확정된 값을 + 여기저기서 부작용 없이 읽는" 용도로도 쓰일 수 있어 `Ref`(T가 + non-nilable)를 계속 지원할 이유가 있음. 위 언바인딩(`old:Set(nil)`)이 + 실제로 발생하는 자리는 **Store/Modifier 필드에 놓여 재바인드/retract가 + 가능한 `Ref`뿐**이므로, 그 자리에 놓을 `Ref`는 **호출자가 직접 + `Ref<>(...)`로 명시**할 것 — 이미 있는 "초기값이 `nil`이면 명시적 + 제네릭 적용으로 타입을 넓힌다"는 관용구(위 "제네릭 시그니처" 절)를 + 그대로 재사용하는 것뿐, 새 타입 규칙 추가 아님. 프레임워크가 자동으로 + 감지해 넓혀주지 않음 — non-nilable `T`로 선언해놓고 Store/Modifier + 자리에 놓으면 런타임에 `.Value`가 타입과 어긋나게 될 수 있는 caller + 책임의 UB(Luau 타입은 런타임에 지워짐, 다른 UB 케이스들과 같은 결). +- **Destroy와는 무관 — 별도 처리 없음(사용자 확정).** `Ref`의 언바인딩은 + 오직 위 재바인드/retract 경로에서만 일어나고, 대상 Instance가 + `Destroy()`되는 것과는 별개 — Ref 자신은 Destroy를 감지하지도, 반응하지도 + 않음. `Ref`가 이미 Destroy된 Frame을 계속 들고 있는 채로 남는 건 + 정상적으로 가능하고, 그 이후 읽고 쓰는 건 그냥 UB(라이브러리가 방어 + 안 함 — `:Wait(thread)`에 이미 죽은 thread를 넘기는 기존 UB와 같은 결). + Destroy 시점에 실제로 정리가 필요하면 `Effect`(내부적으로 `bindLifetime`/ + `Observer` 위에서 동작, 또는 Roblox가 Destroy 시 알아서 `Disconnect`해주는 + 이벤트 안에 로직을 두는 기존 관례)를 쓰도록 문서가 유도할 것 — Ref + 자신에 Destroy-awareness를 얹는 건 오버엔지니어링. + ### `phase` 옵션 폐기 → 위치로 표현, `PreRef` 신설 (2026-08-07 세 번째 세션 — 이 절이 당시 쓰던 `CreatedRef(fn, ...)` 래퍼 이름 자체도 이후 아홉 번째 세션에서 폐기됨, 위 "바인드 방법" 절 참고) diff --git a/.claude/session/2026-08-12-08-ref-retract-tagged-pattern.md b/.claude/session/2026-08-12-08-ref-retract-tagged-pattern.md new file mode 100644 index 0000000..f6c84c2 --- /dev/null +++ b/.claude/session/2026-08-12-08-ref-retract-tagged-pattern.md @@ -0,0 +1,63 @@ +# 2026-08-12 여덟 번째 세션 — `Ref`의 retract, `TagHandler`와 같은 패턴으로 확정 + +## 배경 + +직전 세션(일곱 번째, PreRef 1회용/재사용 error 확정)에서 "일반 `Ref`는 +계속 Modifier/Store 어디든 자유롭게 들어감"이라는 기존 확정 사항을 다시 +짚다가, 사용자가 `Ref`의 취소(retract)도 확인이 필요하다고 지적함 — +"retract 시 `.Value`가 `nil`이 된다"가 실제로 문서화돼 있는지부터 확인 +요청. + +## 1차 확인 (이번 세션 전반부) + +`bind-system-plan.md` 전체를 훑었으나 `Ref` leaf handler의 `retract` +구체 구현은 어디에도 없었음 — Handler가 `Dispatch/Leaf.luau`에 +등록된다는 사실과 `isHandlable` 좁히기 규칙만 확정돼 있었고, 실제 +`process`/`retract` 바디는 미정. 일반 프로퍼티 핸들러 패턴(재-render 없어 +retract 호출 경로 자체가 없음)을 대입하면 리터럴 children 배열 슬롯의 +`Ref`는 구조적으로 retract가 안 불릴 걸로 보였으나, "Ref는 Store/Modifier +어디든 들어간다"는 이미 확정된 사실을 놓치고 있었음을 사용자가 짚음 — +`State`가 실제로 가능하면, Store 값이 `refA→refB`로 바뀌는 시나리오가 +있고 이땐 `refA`가 stale하게 남아있으면 조용한 버그가 됨. 사용자 제안: +retract 시 `nil`로 덮어쓰는 게 적절해 보인다. + +## 2차 확인 — 메커니즘 정정 (Tag 선례와의 충돌 발견) + +처음엔 "`retract(inst,k,oldRef) = oldRef:Set(nil)`"로 단순하게 답했으나, +`Dispatch`의 일반 retract 계약(`bind-system-plan.md` 118-136행: "retract가 +의미 있는 유일한 패턴은 매치되는 핸들러 *타입*이 바뀔 때뿐, 같은 핸들러가 +계속 매치되면 diff는 `process` 자신이 담당")과 `tag-plan.md`의 실제 +구현(`Tag(A)→Tag(B)`는 `retract` 안 불림, `TagHandler.process`가 `Relate`로 +이전 값을 기억해뒀다가 직접 diff)을 대조하니 모순이 발견됨 — `refA→refB`도 +둘 다 같은 "Ref-leaf handler"가 매치하는 경우라, `retract`가 아니라 +`process`가 diff를 담당해야 하는 케이스였음. 이전 답변을 정정. + +## 결정 (이번 세션 후반부, 사용자 확인) + +1. **메커니즘은 `TagHandler`와 완전히 동형** — Ref-leaf handler가 자기 + 전용 `Relate()`로 `(inst,k)`별 마지막 바인딩 `Ref`를 기억. `process`가 + 이전 값과 다르면 `old:Set(nil)`로 언바인딩 후 `v:Set(inst)`. `retract`는 + 그 자리가 아예 Ref이길 그만둘 때만 불리고, 역시 `old:Set(nil)` 하나로 + 귀결. 리터럴 children 배열 슬롯도 같은 코드 경로를 타되 `old`가 항상 + 없어서 자연히 1회성으로 동작 — 케이스 분기 불필요. +2. **비-nilable `T`도 정당한 용도(사용자 확인)** — Ref는 "채워지길 기다리는 + 박스"뿐 아니라 "확정값을 부작용 없이 읽기"용으로도 쓰이므로 non-nilable + `Ref`를 계속 지원할 이유가 있음. 언바인딩이 실제로 발생하는 + Store/Modifier 자리에 놓을 땐 **호출자가 직접 `Ref<>(...)`로 명시**할 + 것 — 이미 있는 "초기값이 nil이면 명시적 제네릭 적용" 관용구를 그대로 + 재사용, 새 타입 규칙 아님. 프레임워크가 자동으로 감지/차단하지 않음 — + 어기면 caller 책임의 UB(다른 UB 케이스들과 같은 결). +3. **Destroy와 무관(사용자 확정)** — Ref의 언바인딩은 오직 재바인드/retract + 경로에서만 일어남, Instance `Destroy()`와는 별개. `Ref`가 이미 + Destroy된 Frame을 계속 들고 있는 채로 남는 건 정상, 이후 읽고 쓰는 건 + UB(방어 안 함). Destroy 시점 정리가 필요하면 `Effect`(`bindLifetime`/ + `Observer` 기반, 또는 Roblox가 알아서 Disconnect해주는 이벤트 안에 로직 + 두기)를 쓰도록 문서가 유도 — Ref 자신에 Destroy-awareness를 얹는 건 + 오버엔지니어링으로 기각. + +## 반영 + +- `base/bind-system-plan.md` — "Ref 일반화" 절 바로 뒤에 "`Ref`의 retract" + 새 절 추가(메커니즘 pseudocode, T? 관용구, Destroy 무관 명시 전부 포함). + 118-136행 일반 retract 계약 절에도 Ref를 Tag와 같은 예시로 짧게 추가 + (교차 참조 누락 방지). diff --git a/CLAUDE.md b/CLAUDE.md index 62bc149..74b9e33 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -510,3 +510,18 @@ bind-system-plan.md`) `local addTax = Sum(a,b)`처럼 만든 값을 `:Compute` pre-pass가 즉시 `error`하는 가드 확정(`_fired` 플래그, 거의 공짜 구현) — 1회용, use only once. `Slot:List`의 `updateFn`처럼 반복 호출되는 자리에선 매번 새 `PreRef()`를 만들라는 관용구도 같이 명문화. + +**2026-08-12 여덟 번째 세션 — `Ref`의 retract, `TagHandler`와 같은 패턴으로 +확정** (`session/2026-08-12-08-ref-retract-tagged-pattern.md`) +`State`가 `refA→refB`로 바뀌는 경우 이전 Ref가 stale하게 남는 문제를 +사용자가 지적 — 처음엔 "retract가 `Set(nil)`"로 단순 답했으나, +`Dispatch`의 일반 계약("핸들러 타입이 안 바뀌면 retract 없이 process가 +diff")과 대조해 `TagHandler` 선례와 정확히 같은 메커니즘이어야 함을 +발견·정정: `refA→refB`는 `process`가 `Relate`로 기억해둔 이전 값과 diff해 +언바인딩(`old:Set(nil)`), `retract`는 그 자리가 아예 Ref이길 그만둘 때만. +사용자가 추가로 확정: 비-nilable `Ref`도 "확정값을 부작용 없이 읽는" +정당한 용도라 계속 지원하되, Store/Modifier 자리에 놓을 땐 호출자가 직접 +`Ref<>(...)`로 명시(기존 관용구 재사용, 새 규칙 아님). Ref의 +언바인딩은 Instance `Destroy()`와 완전히 무관 — Destroy된 대상을 계속 +들고 있는 채로 남는 건 UB로 허용, 정리가 필요하면 `Effect`를 쓰도록 +문서가 유도.