decide(bind-system): Ref retract mirrors TagHandler, non-nilable T stays valid
State<Ref> rebinds (refA -> refB) go through process's own diff (same handler type matches both), not retract -- exactly the TagHandler precedent. retract only fires when the slot stops being a Ref at all. Both paths converge on old:Set(nil). Non-nilable Ref<T> remains a valid "read a settled value" use case; callers opt into Ref<T?> explicitly for Store/Modifier slots. Unbinding is independent of Instance Destroy -- that's Effect's job, not Ref's. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
cdbbdba4fb
commit
a985874ca9
3 changed files with 156 additions and 1 deletions
|
|
@ -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<Ref>`가 `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>` 재바인드 시 이전 Ref에 `nil` (2026-08-12 여덟 번째 세션, `TagHandler`와 같은 메커니즘 재사용)
|
||||
|
||||
**배경**: `Ref`는 이미 "일반 프로퍼티/Modifier 필드/Store 값 어디든 자유롭게
|
||||
들어감"(위 "동적 경로 가드" 절)이 확정돼 있어 — `State<Ref>`가 실제로
|
||||
가능하고, 그러면 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>`(T가
|
||||
non-nilable)를 계속 지원할 이유가 있음. 위 언바인딩(`old:Set(nil)`)이
|
||||
실제로 발생하는 자리는 **Store/Modifier 필드에 놓여 재바인드/retract가
|
||||
가능한 `Ref`뿐**이므로, 그 자리에 놓을 `Ref`는 **호출자가 직접
|
||||
`Ref<<T?>>(...)`로 명시**할 것 — 이미 있는 "초기값이 `nil`이면 명시적
|
||||
제네릭 적용으로 타입을 넓힌다"는 관용구(위 "제네릭 시그니처" 절)를
|
||||
그대로 재사용하는 것뿐, 새 타입 규칙 추가 아님. 프레임워크가 자동으로
|
||||
감지해 넓혀주지 않음 — non-nilable `T`로 선언해놓고 Store/Modifier
|
||||
자리에 놓으면 런타임에 `.Value`가 타입과 어긋나게 될 수 있는 caller
|
||||
책임의 UB(Luau 타입은 런타임에 지워짐, 다른 UB 케이스들과 같은 결).
|
||||
- **Destroy와는 무관 — 별도 처리 없음(사용자 확정).** `Ref`의 언바인딩은
|
||||
오직 위 재바인드/retract 경로에서만 일어나고, 대상 Instance가
|
||||
`Destroy()`되는 것과는 별개 — Ref 자신은 Destroy를 감지하지도, 반응하지도
|
||||
않음. `Ref<Frame?>`가 이미 Destroy된 Frame을 계속 들고 있는 채로 남는 건
|
||||
정상적으로 가능하고, 그 이후 읽고 쓰는 건 그냥 UB(라이브러리가 방어
|
||||
안 함 — `:Wait(thread)`에 이미 죽은 thread를 넘기는 기존 UB와 같은 결).
|
||||
Destroy 시점에 실제로 정리가 필요하면 `Effect`(내부적으로 `bindLifetime`/
|
||||
`Observer` 위에서 동작, 또는 Roblox가 Destroy 시 알아서 `Disconnect`해주는
|
||||
이벤트 안에 로직을 두는 기존 관례)를 쓰도록 문서가 유도할 것 — Ref
|
||||
자신에 Destroy-awareness를 얹는 건 오버엔지니어링.
|
||||
|
||||
### `phase` 옵션 폐기 → 위치로 표현, `PreRef` 신설 (2026-08-07 세 번째
|
||||
세션 — 이 절이 당시 쓰던 `CreatedRef(fn, ...)` 래퍼 이름 자체도 이후
|
||||
아홉 번째 세션에서 폐기됨, 위 "바인드 방법" 절 참고)
|
||||
|
|
|
|||
63
.claude/session/2026-08-12-08-ref-retract-tagged-pattern.md
Normal file
63
.claude/session/2026-08-12-08-ref-retract-tagged-pattern.md
Normal file
|
|
@ -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<Ref>`가 실제로 가능하면, 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<T>`를 계속 지원할 이유가 있음. 언바인딩이 실제로 발생하는
|
||||
Store/Modifier 자리에 놓을 땐 **호출자가 직접 `Ref<<T?>>(...)`로 명시**할
|
||||
것 — 이미 있는 "초기값이 nil이면 명시적 제네릭 적용" 관용구를 그대로
|
||||
재사용, 새 타입 규칙 아님. 프레임워크가 자동으로 감지/차단하지 않음 —
|
||||
어기면 caller 책임의 UB(다른 UB 케이스들과 같은 결).
|
||||
3. **Destroy와 무관(사용자 확정)** — Ref의 언바인딩은 오직 재바인드/retract
|
||||
경로에서만 일어남, Instance `Destroy()`와는 별개. `Ref<Frame?>`가 이미
|
||||
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와 같은 예시로 짧게 추가
|
||||
(교차 참조 누락 방지).
|
||||
15
CLAUDE.md
15
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<Ref>`가 `refA→refB`로 바뀌는 경우 이전 Ref가 stale하게 남는 문제를
|
||||
사용자가 지적 — 처음엔 "retract가 `Set(nil)`"로 단순 답했으나,
|
||||
`Dispatch`의 일반 계약("핸들러 타입이 안 바뀌면 retract 없이 process가
|
||||
diff")과 대조해 `TagHandler` 선례와 정확히 같은 메커니즘이어야 함을
|
||||
발견·정정: `refA→refB`는 `process`가 `Relate`로 기억해둔 이전 값과 diff해
|
||||
언바인딩(`old:Set(nil)`), `retract`는 그 자리가 아예 Ref이길 그만둘 때만.
|
||||
사용자가 추가로 확정: 비-nilable `Ref<T>`도 "확정값을 부작용 없이 읽는"
|
||||
정당한 용도라 계속 지원하되, Store/Modifier 자리에 놓을 땐 호출자가 직접
|
||||
`Ref<<T?>>(...)`로 명시(기존 관용구 재사용, 새 규칙 아님). Ref의
|
||||
언바인딩은 Instance `Destroy()`와 완전히 무관 — Destroy된 대상을 계속
|
||||
들고 있는 채로 남는 건 UB로 허용, 정리가 필요하면 `Effect`를 쓰도록
|
||||
문서가 유도.
|
||||
|
|
|
|||
Loading…
Reference in a new issue