Corrects a foundational error: "handler type unchanged -> retract skipped, process diffs" was never actually true. StoreBind unconditionally calls Dispatch.retractUnder before every re-dispatch (already stated in the original dispatch model doc) -- Tag's old assert(v==nil) was taken at face value and the general rule was wrongly back-derived from it. Tag, Ref, Slot, and Attribute (the last three designed earlier this same session) all inherited the flawed premise. Fixes across all four: - Tag: rewritten around kTagMap/tagNameMap reference counting so AddTag is entirely process's job, RemoveTag entirely retract's, using a Contains hint to skip engine calls that would be immediately redone. Also fixes the case where two independent Tag(...) values at different positions share a name (union semantics, like className). - Ref/Slot: drop the incorrect assert(v==nil), split into "retract unbinds/destroys the old, process binds/mounts the new" with an identity check on both sides to avoid spurious re-fire. - Attribute: AttributeKeyHandler.retract gated on v==nil so ordinary value updates don't flicker the attribute to nil; the group's "unchanged name" delegation now also retracts before reprocessing, since skipping it was silently stacking chain entries every cycle. Reversal preserved in archive/retract-always-fires-reversed.md. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
6.1 KiB
2026-08-12 열한 번째 세션 — "retract는 항상 불림" 전면 정정, Tag 참조 카운트 재설계
배경
열 번째 세션(Attribute 이름 소유권)에 이어, 사용자가 같은 클래스의 문제가
Tag에도 있을 수 있다고 지적: 서로 다른 배열 위치의 Tag(...)가 같은
이름을 겹쳐 가질 수 있는데(Frame { Tag("a"), Tag("a","b") }, 웹
className="a a a"와 같은 합집합 시맨틱), 한 위치의 diff만으로 다른
위치가 아직 쓰는 이름을 지워버리는 참조 카운트 버그가 날 수 있다는
문제 제기.
논의 전개 — 근본 오류 발견까지
- Claude가 처음 위치별
{[k]: true}set(참조 카운트)을 제안 → 사용자 동의. - 사용자가 후속 질문: "retract에서 새롭게 들어온 v가 Tag라면 Contains를
확인해서 재사용될 거면 RemoveTag 안 함" — 이게 성립하려면
retract가v=Tag(nil 아닌 값)를 받는 경우가 있어야 함. Claude가 처음엔 이걸 기존assert(v==nil)과 모순된다고 판단해 "메커니즘을 바꾸자는 거냐"고 되물음. - 사용자가 재확인: "정확히 저는 어떤 값이든 덮여 쓰여지는 즉시 retract를 실행하는 거로 두기로 했었어요 — 전체 process 트랙을 retract하고 리빌드한다는 맥락이었어요."
- Claude가
bind-system-plan.md의 "확정된 디스패치 모델" 절(2026-08-04 원문)을 재확인 —Dispatch/StoreBind.luau는 재-dispatch 전에 무조건Dispatch.retractUnder를 부른다고 이미 명시돼 있었음. "핸들러 타입이 안 바뀌면 생략"이라는 조건은 어디에도 없었음 — 2026-08-07 여덟 번째 세션에 추가된 "retract가 의미 있는 유일한 패턴" 서술이 자기 문서 다른 절과 처음부터 모순돼 있었고,Tag의assert(v==nil)을 액면 그대로 믿고 거꾸로 일반 규칙을 잘못 추론한 게 오류의 실제 출처였음이 드러남.
파급 효과 확인
이 오류가 이번 대화에서 만든 Ref(여덟 번째 세션)/Slot(아홉 번째
세션)/Attribute(열 번째 세션) 설계 전부에 그대로 이어받아져 있었음 —
셋 다 assert(v==nil)을 두고 "같은 핸들러 타입이면 process가 diff"라는
전제로 설계돼 있었기 때문. 사용자의 지적("retract는 v가 다른 값일 수
있는데, diff하는 모든 곳에서 is로 잘 테스트하고 있는지 봐야할듯")대로
전수 감사·수정 진행.
정정된 일반 원칙
retract(inst,k,v)는 store 재발행마다(핸들러 타입이 안 바뀌어도) 항상
불림 — v는 nil일 수도 대체하는 새 값 자체일 수도 있음, retract
안에서 v==nil을 가정하면 안 됨. 대부분의 핸들러(PropertyHandler,
NoneHandler, UICorner 숏핸드)는 이 반복 호출에서 실제로 할 일이 없어
no-op이면 충분 — "타입 안 바뀌면 아예 안 불린다"가 아니라 "불리지만
몸체가 비어 있어도 된다"가 정확한 표현. 여러 위치가 하나의 실제
리소스(엔진 attribute/tag/mounted 서브트리 등)를 공유하는 핸들러는
retract가 이전 기여 제거(엔진 호출은 v 힌트로 skip 가능),
process가 새 기여 등록을 전담하는 분업이 자연히 나옴 — process 쪽에
별도 old-vs-new diff가 더 이상 필요 없어짐(그 일을 retract가 매번
정확히 대신 해줌).
각 파일 정정 내역
base/tag-plan.md:TagHandler메커니즘 전면 재작성 — 단일relate(이름 집합)를kTagMap(위치→Tag)+tagNameMap(이름→Tag set) 두 릴레이션으로 분리.retract가 이전 Tag의 이름들을 무조건tagNameMap에서 빼되, 실제RemoveTag는 "새로 들어올 Tag가 그 이름을 여전히 Contains하는가" 힌트로 skip.process는 새 Tag의 이름을 무조건 등록(집합이 비어있던 경우만 실제AddTag) — 자기 diff 불필요.AddTag는 온전히process,RemoveTag는 온전히retract로 완전히 분업. 여러 위치가 같은 이름을 겹쳐 가지는 경우도 공유tagNameMap집합으로 자동 해결.base/bind-system-plan.md: 일반 retract 계약 절 전면 재작성(위 "정정된 일반 원칙" 그대로).Ref의 retract 절 —assert(v==nil)제거, "언바인딩은 retract 전담, 바인딩은 process 전담"으로 재설계(둘 다old==v/old~=videntity 체크로 spurious 재발행 시 콜백 이중 발화 방지).NoneHandler절의 "retract와 무관" 근거도 정정(불리긴 하지만 할 일이 없어서 no-op).base/slot-plan.md: "Slot과 Store 바인드의 관계" 절 —destroySlotTree호출이process에서retract로 이동(old~=v면 폐기),process는 마운트만 전담(old==slotValue면 no-op).base/attribute-plan.md:AttributeKeyHandler.retract에v==nil가드 추가(그렇지 않으면 매 store 재발행마다 attribute가 잠깐 nil로 flicker). 그룹의 "남아있는 이름" 위임도retractUnder없이Dispatch.process만 반복 호출하면 체인이 매번 새 항목을 쌓기만 해서(팝은retractUnder의 일) 누적 누수가 생기는 걸 발견 — 남아있는 이름도 먼저retractUnder(같은 캐싱된 키로) 부른 뒤에만process하도록 정정.base/ui-shorthand-plan.md,base/tween-plan.md: 결론(해당 핸들러의retract는 no-op)은 안 바뀌었으나 "retract가 아예 안 불린다"는 근거 서술만 정정.archive/retract-always-fires-reversed.md신설 — 원문·역전 이유·영향받은 문서 전부 보존.README.md— 위 파일들 요약 라인 + 아카이브 표에 새 항목 추가.
남은 것
전수 감사는 base/ 전체 retract 언급 파일(관련 없는 relate-plan.md/
module-lifecycle-plan.md/lifecycle-pattern.md/onchange-plan.md
등)까지 훑었으나, onchange-plan.md의 OnChangeHandler(Connection
disconnect/reconnect 패턴)는 애초에 공유 리소스가 없어 이 문제에
해당 안 됨 — 정정 불필요 확인만 하고 넘어감.