decide(bind-system): retract fires on every re-dispatch, not just type changes
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>
This commit is contained in:
parent
6b7af54e0d
commit
f20922ce39
10 changed files with 440 additions and 153 deletions
|
|
@ -30,7 +30,7 @@
|
|||
| `architecture.md` | quad-v2 전체 아키텍처 확정 사항 요약(제일 먼저 볼 문서) |
|
||||
| `lifecycle-pattern.md` | rbvm의 `Connected`+GC 관용구를 quad-v2가 채택하는 방식 |
|
||||
| `store-semantics.md` | Store는 부작용 허용이 기본. State는 Store 위의 조합 가능한 캐시 레이어로 실제로 필요함(2026-08-04 정정) — 온톨로지 핵심 메커니즘은 2026-08-04 2차 라운드에서 확정, 최신 상세는 `base/bind-system-plan.md` |
|
||||
| `bind-system-plan.md` | pluggable key/value 핸들러 레지스트리 — `process`/`retract` 디스패치 모델, Ref, Store/State/Source 온톨로지 + 인체공학 질문 전부 확정. 디스패치 엔진은 `quad-base`가 인터페이스로 소유(2026-08-04 5차 라운드). **[2026-08-11 세션, 여섯 번째]** `Dispatch.setLength`/`setOffsetSource`의 owner 키가 물리 Instance로 한정될 필요 없음을 명시(Slot-in-Slot 재귀의 근거) — 같은 절 `recompute`의 off-by-one 버그 발견·수정(`offset`이 자기 자신을 포함해 누적되던 것), 재진입 방지 가드는 검토 후 기각(`Source⊇State` 단방향 원칙과 같은 카테고리의 UB로 명명, 각 Slot이 독립 `bk`를 가져 nesting만으로는 재진입 경로 자체가 없음을 확인) |
|
||||
| `bind-system-plan.md` | pluggable key/value 핸들러 레지스트리 — `process`/`retract` 디스패치 모델, Ref, Store/State/Source 온톨로지 + 인체공학 질문 전부 확정. 디스패치 엔진은 `quad-base`가 인터페이스로 소유(2026-08-04 5차 라운드). **[2026-08-11 세션, 여섯 번째]** `Dispatch.setLength`/`setOffsetSource`의 owner 키가 물리 Instance로 한정될 필요 없음을 명시(Slot-in-Slot 재귀의 근거) — 같은 절 `recompute`의 off-by-one 버그 발견·수정(`offset`이 자기 자신을 포함해 누적되던 것), 재진입 방지 가드는 검토 후 기각(`Source⊇State` 단방향 원칙과 같은 카테고리의 UB로 명명, 각 Slot이 독립 `bk`를 가져 nesting만으로는 재진입 경로 자체가 없음을 확인). **[2026-08-12 열한 번째 세션, 전면 정정]** "핸들러 타입이 안 바뀌면 retract 없이 process가 diff"는 틀렸음 — `retract`는 store 재발행마다(핸들러 타입 무관) 항상 불림, `v`는 대체 값 자체일 수 있어 `nil`로 가정 금지. `Tag`/`Ref`/`Slot`/`Attribute` 전부 이 오류로 설계돼 있었음이 드러나 한 세션에 전부 정정(`archive/retract-always-fires-reversed.md`) |
|
||||
| `module-lifecycle-plan.md` | 프로바이더 패턴, bind/store 구현 책임 분리 — 확정 |
|
||||
| `slot-plan.md` | 뮤터블 자식 배열, 엄격한 단일 마운트 소유권, 재마운트 시 throw, base/roblox 패키지 경계까지 확정. **[2026-08-09 세 번째 세션]** `Add`/`Remove`/`Extract`/`Clear`/`Move`/`Swap` CRUD(복잡도 표기 포함), `isMounted` 이중 추적 분리, 요소 타입 제약(`nil`/`None`/핸들러 계층 값 금지, `Slot<T>()` 제네릭), 키 기반 동적 컬렉션 재조정(`Slot:List(data, updateFn, keyFn?)`)까지 전부 확정 통합, base/roblox 경계에 reposition 훅 추가. **[2026-08-09 열한 번째 세션, 중간검토]** CRUD 식별 기준을 element 레퍼런스에서 인덱스 기준으로 재정정(`Remove(index)`/`Extract(index, newElement?)`/`Move(oldIndex, newIndex)`), `ExtractAll`/`Get`/`IndexOf` 신설. **[2026-08-11 세션]** `updateFn<UD>(item, index: number, offset: Source<number>, prev: T?, userdata: UD?): (T|nil, UD?)`로 시그니처 확정(`Slot.Offset`도 `Length`처럼 공개 필드로 신설) — `LayoutOrder` 등은 Slot이 자동으로 안 세팅, `index`/`offset` raw 값만 전달하고 실제 반영은 `updateFn`이 "버림/다시 그림/source만 갱신" 세 갈래로 직접 처리(재사용 Source에 미리 `Set` 후 결국 다시 그리면 무의미한 연산이 되므로). **[2026-08-11 세션, 여섯 번째]** `Slot:Single(state, updateFn)` 확정(`:List` 위의 순수 sugar) — Slot-in-Slot 중첩도 확정, 요소 타입 제약에서 `Slot` 배제 해제(`T = Instance | Slot<Instance>`), `Dispatch.setLength`/`setOffsetSource`를 Slot 자신을 owner 키로 재사용하는 재귀 `attachSlot`(새 프리미티브 없음), 파괴는 재귀 `Clear()` 대신 flat `destroySlotTree`+명시적 `unbindLifetime`. `Slot(initial?: {T})` 생성자 부활(순수 `:Add` sugar) + `_crudUsed`↔`_listed` 상호 배타 가드 신설. `base/bind-system-plan.md`의 `recompute` off-by-one 버그도 이 세션에 같이 수정됨. **[2026-08-11 세션, 일곱 번째]** 반응형 raw 요소(`Slot:Add`가 `State<T>`/`Source<T>`도 받음) 확정 — 새 메커니즘 아니라 `isState(element)`면 내부적으로 `Slot():Single(element)`(nested Slot)를 대신 삽입하는 순수 sugar(최초 검토했던 별도 position-keyed StoreBind 구독 안은 `None`/Length/Move-Swap 문제로 기각). `Slot:Single(state, updateFn?)`도 `updateFn` 선택 인자화(기본값 identity)로 이 sugar를 지지. `:List`의 `reconcile`도 nested-Slot을 반환하는 아이템의 `.Length`만큼 다음 형제 `index`가 건너뛰도록 `pos` 커밋 공식 수정 |
|
||||
| `modifier-plan.md` | Modifier는 런타임 plug 아닌 정적 merge, immutable+clone 기반 체이닝 — 메커니즘 확정. **[2026-08-07 다섯 번째 세션 추가]** `:Apply(factory)` 팩토리 체이닝, `Overridden`(구 `Merge`→`Override`, 2026-08-08 세션에서 이름까지 확정) 값 결합+성능 기준, `:Peek`/`isState` 필드 읽기까지 전부 확정(`Peek`/`isState`는 이름만 용어 정리 라운드까지 잠정) |
|
||||
|
|
@ -39,8 +39,8 @@
|
|||
| `blocker-plan.md` | **[2026-08-07 신설]** `Blocker` — 여러 Source를 한꺼번에 바꿔도 파생값 재계산이 한 번만 되게, State 마일스톤(M3)과 함께 개발. 메커니즘+이름 확정 |
|
||||
| `effect-plan.md` | **[2026-08-07 신설, 여섯 번째 세션에 확정]** `Effect(fn, state?)` — `state` 없으면 설치 1회+leaf 사망 시 확정 정리, 있으면 내부적으로 `state:Observer(...)`를 조합해 재실행+cleanup 체이닝(React `useEffect` 동형). Observer와의 관계 해소 완료 |
|
||||
| `ui-shorthand-plan.md` | **[2026-08-07 `research/`에서 승격]** `UICorner`/`UIPadding`/`UIScale` 인라인 편의 키 — 이름(v1 `Corner`/`PaddingAll`/`Scale`에서 Modifier 필드명과 안 겹치게 `UI` 프리픽스로 확정)·메커니즘(Handler)·패키지 배치(quad-roblox 코어)·store-bind 가능성까지 전부 확정. 이미지 라운드 트릭(`RoundSize`)은 드롭 — `archive/ui-shorthand-roundsize-dropped.md` 참고. `v=nil`이면 `process` 자신이 만든 자식 제거(`retract` 아님) |
|
||||
| `tag-plan.md` | **[2026-08-08 세 번째 세션 재설계]** `Tag(...)` — array-part 값 객체, `Modifier`와 같은 immutable clone 체이닝(`:Added`/`:Removed`/`:Contains`/`:Apply`/`Merged`), `CollectionService` 글루만 quad-roblox. 이제 `retract`가 의미 있음(타입이 바뀌면 전체 삭제, 같은 Tag끼리는 `process`가 diff). 구 해시 파트 boolean 모델은 `archive/tag-hash-key-model-reversed.md` |
|
||||
| `attribute-plan.md` | **[2026-08-07 여덟 번째 세션 신설]** 단일 키 `[AttributeKey<T> "Name"]`(구 `Attribute<T>`) — `SetAttribute(name, nil)`이 네이티브 지우기라 `None` 센티널과 가장 깔끔하게 맞아떨어짐, `retract` 불필요. **[2026-08-11 아홉 번째 세션]** 여러 Store를 한 번에 attribute로 묶는 그룹 `Attribute(...)` 프리미티브 신설(`Tag`와 동형 array-part 값 객체, `Merged`로 헤테로지니어스 Store 합성), 이름 충돌 방지로 단일 키를 `AttributeKey`로 리네임(잠정). **[같은 세션 후속]** `AttributeKey(name)`이 이름별 weak 캐시로 동등성 보장하도록 확정되며, 그룹 Handler는 자기 완결형 재구현 대신 메모이즈된 키로 기존 단일 키 경로에 재귀 위임하는 걸로 개정(중복 구현 제거) |
|
||||
| `tag-plan.md` | **[2026-08-08 세 번째 세션 재설계, 2026-08-12 열한 번째 세션 메커니즘 정정]** `Tag(...)` — array-part 값 객체, `Modifier`와 같은 immutable clone 체이닝(`:Added`/`:Removed`/`:Contains`/`:Apply`/`Merged`), `CollectionService` 글루만 quad-roblox. `retract`가 이전 Tag가 걸었던 이름을 이름별 참조 카운트 맵에서 빼고(다른 위치가 겹쳐 쓰면 실제 `RemoveTag`는 skip), `process`가 새 Tag의 이름을 등록 — 여러 위치가 같은 이름을 겹쳐 가져도(웹 `className`류 합집합) 안전. 구 해시 파트 boolean 모델은 `archive/tag-hash-key-model-reversed.md`, 구 `assert(v==nil)` 메커니즘은 `archive/retract-always-fires-reversed.md` |
|
||||
| `attribute-plan.md` | **[2026-08-07 여덟 번째 세션 신설]** 단일 키 `[AttributeKey<T> "Name"]`(구 `Attribute<T>`) — `SetAttribute(name, nil)`이 네이티브 지우기라 `None` 센티널과 가장 깔끔하게 맞아떨어짐. **[2026-08-11 아홉 번째 세션]** 여러 Store를 한 번에 attribute로 묶는 그룹 `Attribute(...)` 프리미티브 신설(`Tag`와 동형 array-part 값 객체, `Merged`로 헤테로지니어스 Store 합성), 이름 충돌 방지로 단일 키를 `AttributeKey`로 리네임(잠정). **[같은 세션 후속]** `AttributeKey(name)`이 이름별 weak 캐시로 동등성 보장하도록 확정되며, 그룹 Handler는 자기 완결형 재구현 대신 메모이즈된 키로 기존 단일 키 경로에 재귀 위임하는 걸로 개정(중복 구현 제거). **[2026-08-12 열 번째 세션]** 그룹/직접 쓰기가 같은 이름을 동시에 관리하는 충돌을 막기 위해 그룹은 공개 캐시 대신 `rawNew(name)` 전용 키+소유권 `Relate`로 전환. **[열한 번째 세션]** `retract`가 store 재발행마다 항상 불린다는 정정에 맞춰 `AttributeKeyHandler.retract`에 `v==nil` 가드 추가(더 이상 "retract 불필요" 아님, no-op일 뿐), 그룹의 "남아있는 이름" 위임도 매번 `retractUnder`를 먼저 부르도록 정정(체인 누수 방지) |
|
||||
| `onchange-plan.md` | **[2026-08-10 세션 신설]** `OnChange(name)` — `GetPropertyChangedSignal` 바인딩 전용 DI 키, `Attribute`와 달리 제네릭 타입 파라미터 없음(콜백 타입은 인라인 명시, 이벤트 바인딩과 같은 급 트레이드오프). 전부 quad-roblox(`Handlers/OnChange.luau`), `State<function>`은 기존 이벤트 store-bind 메커니즘 재사용. **[2026-08-11 아홉 번째 세션 후속]** `AttributeKey`와 동일한 이름별 weak 캐시로 `OnChange(a) == OnChange(a)` 동등성 보장 |
|
||||
| `relate-plan.md` | **[2026-08-08 신설]** `Relate` — `inst`를 weak 키로 하는 범용 릴레이션 프리미티브(`SetWeak`/`GetWeak`/`SetStrong`/`GetStrong`, 비싱글톤 생성자). 구 `base.perInstanceState(inst)` placeholder를 대체·정식 승격, `lifecycle-pattern.md`의 `bindLifetime`/`canExecute`가 그 위에 얹힘 |
|
||||
| `tween-plan.md` | **[2026-08-12 세션, `research/`에서 승격]** 값-레벨 `Tween<T>` 래퍼(PropertyHandler가 소비, 구 특수 bind key 모델은 `archive/tween-special-bind-key-reversed.md`). 3-상태 릴레이션 슬롯(`{Tween,Value}\|true\|nil`), `T'=T\|Tween<T>` 타입 치환. 옵션 값 모양은 `Info: TweenInfo?` 우선+편의 필드 폴백, override는 `Tween.Cancel`(기본)/`Tween.Finish` 2값. `Animate(info)`는 `Tween` opts를 `T\|State<T>`로 받아 `:Apply`로 꽂는 sugar. 자연완료 시 per-instance 북키핑은 정리 안 해도 됨으로 확정(목표값 도달 상태라 부작용 없음, Completed 이벤트 구독 장치는 오버엔지니어링으로 판단). `initValue`는 사용자가 직접 처리(에이전트 범위 제외) |
|
||||
|
|
@ -85,6 +85,7 @@
|
|||
| `debug-channel-replicatedstorage-rejected.md` | **[기각됨, 2026-08-09 코퍼스 정리 신설]** quad-debug 채널을 `ReplicatedStorage`에 자동 생성하던 초안 — 게임 트리 오염 부작용으로 기각, quad 모듈 자신의 트리+`CollectionService` 태그로 대체 |
|
||||
| `tween-special-bind-key-reversed.md` | **[역전됨, 2026-08-10 신설]** 구 Tween 모델(`[Tween(key,tweenData...)] = storeValue` 특수 bind key, 우선순위 최상위 Dispatch 핸들러) — 값-레벨 `Tween<T>` 래퍼 모델로 완전히 대체됨(`base/tween-plan.md`) |
|
||||
| `onchange-per-property-codegen-rejected.md` | **[기각됨, 2026-08-10 신설]** `OnChange.PropertyName` 프로퍼티별 정적 코드 생성 — Attribute의 정적 지름길과 달리 (클래스 수 × 프로퍼티 수) 규모로 폭발해 기각, `OnChange(name)` 단일 팩토리로 대체 |
|
||||
| `retract-always-fires-reversed.md` | **[역전됨, 2026-08-12 열한 번째 세션 신설]** "핸들러 타입이 안 바뀌면 retract 없이 process가 diff" — 실제로는 `retract`가 store 재발행마다 항상 불림(핸들러 타입 무관). `Tag`/`Ref`/`Slot`/`Attribute` 전부 이 오류 위에서 설계돼 있었음이 드러나 한 세션에 전부 정정 |
|
||||
|
||||
## 참고
|
||||
|
||||
|
|
|
|||
79
.claude/archive/retract-always-fires-reversed.md
Normal file
79
.claude/archive/retract-always-fires-reversed.md
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
# [역전됨] "핸들러 타입이 안 바뀌면 retract 없이 process가 diff" — `retract`는 store 재발행마다 항상 불림으로 정정
|
||||
|
||||
**역전 일시**: 2026-08-12 (열한 번째 세션). **원 확정 일시**: 2026-08-07
|
||||
(여덟 번째 세션, `bind-system-plan.md`의 일반 retract 계약 절)~2026-08-09
|
||||
(Tag의 `assert(v==nil)` 명시화).
|
||||
**현재 유효한 설계**: `base/bind-system-plan.md`의 일반 retract 계약
|
||||
절(`retract(inst,k,v)` 항목), `base/tag-plan.md`/`base/attribute-plan.md`
|
||||
"이름 소유권"/"메커니즘" 절, `base/bind-system-plan.md`의 "`Ref`의 retract"
|
||||
절, `base/slot-plan.md` "Slot과 Store 바인드의 관계" 절이 최종 소스.
|
||||
|
||||
## 역전된 사례 — 원래 무엇을 확정했었나
|
||||
|
||||
**"retract가 실제로 의미 있는 유일한 패턴은 같은 키에 대해 매치되는
|
||||
핸들러 *타입 자체*가 사이클마다 바뀌는 경우"** (2026-08-07 여덟 번째
|
||||
세션 정정 당시 확정, `bind-system-plan.md`) — 예로 `Tag(...)`↔`nil`을
|
||||
들며, "같은 Tag끼리 바뀌는 diff는 process가 담당"(즉 `Tag(A)→Tag(B)`
|
||||
같은 동일 핸들러 타입 전환에서는 `retract`가 아예 안 불린다)고 명시.
|
||||
`base/tag-plan.md`는 이 전제를 코드에 그대로 반영해 `TagHandler.retract`에
|
||||
`assert(v == nil, "TagHandler.retract는 v가 nil일 때만 불려야 함")`을
|
||||
넣었고(2026-08-09 열한 번째 세션 "명시화"), 이후 2026-08-12 여덟/아홉
|
||||
번째 세션에 `Ref`/`Slot`도 같은 전제("핸들러 타입이 안 바뀌면 retract
|
||||
없이 process가 diff")를 그대로 이어받아 각자 `assert(v==nil)`을 두고
|
||||
`process` 안에서 old-vs-new diff를 계산하는 설계로 확정됐었음.
|
||||
|
||||
## 역전된 이유
|
||||
|
||||
`Attribute`의 이름 소유권 설계를 논의하다 사용자가 지적: `Tag`도 서로
|
||||
다른 배열 위치의 `Tag(...)`가 같은 이름을 겹쳐 가질 수 있는데
|
||||
(`Frame { Tag("a"), Tag("a","b") }`, 웹 `className`과 같은 합집합
|
||||
시맨틱), 한 위치의 diff만으로 다른 위치가 아직 쓰는 이름을 지워버리는
|
||||
참조 카운트 버그가 날 수 있다는 문제 제기에서 시작. 이를 풀기 위해
|
||||
retract 쪽에서 "새로 들어올 값이 이 이름을 여전히 필요로 하는가"를
|
||||
확인하는 설계(`Tag:Contains()` 힌트)를 사용자가 제안했는데, 이게
|
||||
성립하려면 **`retract`가 `v=Tag`(nil이 아닌 대체 값)를 받는 경우가
|
||||
실제로 있어야 함** — 기존 `assert(v==nil)`과 정면으로 모순.
|
||||
|
||||
`bind-system-plan.md`의 "확정된 디스패치 모델" 절(2026-08-04 원문)을
|
||||
다시 대조하니, `Dispatch/StoreBind.luau`는 재-dispatch 전에 **무조건**
|
||||
`Dispatch.retractUnder(inst,k,self,realv)`를 부른다고 이미 명시돼
|
||||
있었음 — "핸들러 타입이 안 바뀌면 생략"이라는 조건은 그 문서 어디에도
|
||||
없었음. 즉 2026-08-07 세션의 "retract가 의미 있는 유일한 패턴" 서술이
|
||||
자기 문서의 다른 절과 처음부터 모순돼 있었고, `Tag`의 `assert(v==nil)`을
|
||||
액면 그대로 믿고 거꾸로 일반 규칙을 잘못 추론한 게 오류의 실제 출처
|
||||
(2026-08-09 "명시화" 세션에서도 이 모순이 안 걸림). 사용자가 직접
|
||||
"어떤 값이든 덮여 쓰여지는 즉시 retract를 실행하는 거로 두기로 했었다 —
|
||||
전체 process 트랙을 retract하고 리빌드한다는 맥락"이라고 확인하며
|
||||
확정.
|
||||
|
||||
## 정정된 이해
|
||||
|
||||
- `retract(inst,k,v)`는 store 바인드가 재발행될 때마다(핸들러 타입이
|
||||
안 바뀌어도) 항상 불림. `v`는 `nil`일 수도, 그 자리를 대체하는 새
|
||||
값 자체일 수도 있음 — **`retract` 안에서 `v`를 절대 `nil`로
|
||||
가정하면 안 됨.**
|
||||
- 대부분의 핸들러(일반 PropertyHandler, `NoneHandler`, UICorner
|
||||
숏핸드 등)는 이 반복 호출에서 실제로 할 일이 없어 `retract`가
|
||||
사실상 no-op — "타입이 안 바뀌면 아예 안 불린다"가 아니라 "불리지만
|
||||
몸체가 비어 있어도 된다"가 정확한 표현.
|
||||
- `Tag`/`Ref`/`Slot`/`Attribute`처럼 여러 위치가 하나의 실제 리소스를
|
||||
공유하거나 값 자체가 정리가 필요한 상태를 들고 있는 핸들러는, `v`를
|
||||
힌트 삼아 "곧 다시 필요해질 것"이면 실제 엔진 호출만 skip하는
|
||||
방식으로 대응 — `retract`가 이전 기여 제거(힌트로 skip 가능),
|
||||
`process`가 새 기여 등록을 전담하는 분업이 자연히 나옴, `process`
|
||||
쪽에 별도 old-vs-new diff가 더 이상 필요 없어짐.
|
||||
|
||||
## 영향받은 문서 (이 세션에 모두 정정 완료)
|
||||
|
||||
- `base/bind-system-plan.md` — 일반 retract 계약 절, `NoneHandler` 절,
|
||||
"`Ref`의 retract" 절.
|
||||
- `base/tag-plan.md` — `TagHandler` 메커니즘 전면 재작성(`kTagMap`/
|
||||
`tagNameMap` 참조 카운트).
|
||||
- `base/slot-plan.md` — "Slot과 Store 바인드의 관계" 절, `destroySlotTree`
|
||||
호출이 `process`에서 `retract`로 이동.
|
||||
- `base/attribute-plan.md` — `AttributeKeyHandler.retract`에 `v==nil`
|
||||
가드 추가, 그룹의 이름 집합 diff가 "남아있는 이름도 먼저
|
||||
`retractUnder`" 방식으로 정정(체인 누수 방지).
|
||||
- `base/ui-shorthand-plan.md`, `base/tween-plan.md` — 결론(해당 핸들러의
|
||||
`retract`는 no-op)은 안 바뀌었으나, "retract가 아예 안 불린다"는
|
||||
근거 서술만 정정.
|
||||
|
|
@ -164,9 +164,15 @@ function AttributeKeyHandler.process(inst, k, v)
|
|||
end
|
||||
|
||||
function AttributeKeyHandler.retract(inst, k, v)
|
||||
-- [정정, 2026-08-12 열한 번째 세션] retract는 이 k가 store 재발행으로
|
||||
-- 다시 process될 때도 매번 불림(핸들러 타입이 안 바뀌어도) —
|
||||
-- bind-system-plan.md 일반 retract 계약 절 참고. v가 nil이 아니면
|
||||
-- 곧바로 process가 재확정하므로(같은 k가 계속 살아있는 한 항상
|
||||
-- 자기 자신이 다시 매치됨) 여기서 SetAttribute/소유권 반납을 할
|
||||
-- 이유가 없음 — v가 nil일 때만(진짜 이 이름이 사라지는 경우) 실행.
|
||||
local name = k.Name
|
||||
local map = owners:GetStrong(inst)
|
||||
if map and map[name] == k then
|
||||
if map and map[name] == k and v == nil then
|
||||
map[name] = nil
|
||||
inst:SetAttribute(name, nil)
|
||||
end
|
||||
|
|
@ -183,17 +189,21 @@ end
|
|||
만들어 이 맵에 캐싱하고 그 키로 위임, 이미 맵에 있으면(이전 사이클에
|
||||
이미 관리 중이던, 즉 "남아있는" 이름) **그 캐싱된 같은 객체를 그대로
|
||||
재사용**해서 위임.
|
||||
- **왜 이러면 "새 Attribute 셋 비교"가 저절로 맞아떨어지는지(사용자 확인,
|
||||
2026-08-12 열 번째 세션)**: 그룹이 이름 집합을 diff할 때 — **남아있는
|
||||
이름은 캐싱된 같은 키 객체로 재위임하므로 `owners` 맵에서 `current == k`가
|
||||
성립해 통과, `retract` 자체가 안 불림**(값만 갱신) — **사라진 이름만
|
||||
`Dispatch.retractUnder`가 그 이름 전용 키를 타서 `retract`가 불리고
|
||||
`nil`화됨.** 새로 들어온 이름은 `rawNew`로 갓 만든 키라 `owners`에 없어
|
||||
그냥 새로 클레임. 즉 "진짜 새 셋과 비교해 사라진 것만 nil화, 나머지는
|
||||
건드리지 않고 갱신"이 diff 로직을 하나도 안 고치고 위 `process`/`retract`
|
||||
구현만으로 자연히 나옴 — **캐시가 그룹 값 교체를 넘어 계속 유지돼야만
|
||||
성립**(매 교체마다 키를 새로 만들면 남아있는 이름도 `owners`엔 옛
|
||||
객체가 남아있어 새 객체와 비교 시 오탐 충돌이 남).
|
||||
- **[정정, 2026-08-12 열한 번째 세션] "남아있는 이름은 retract 자체가 안
|
||||
불린다"는 예전 서술은 틀렸음** — `AttributeKeyHandler.retract`는 이제
|
||||
`v==nil`일 때만 실제로 뭔가 하므로(위 "메커니즘, `None`, `retract`" 절
|
||||
정정분), 남아있는 이름에 대해서도 `retractUnder`를 불러도 안전하고 —
|
||||
오히려 **불러야 함**: `Dispatch.process`가 매번 체인 꼬리에 새 항목을
|
||||
쌓기만 하지 스스로 옛 항목을 안 지우므로(popping은 `retractUnder`
|
||||
자신의 일), 남아있는 이름을 `retractUnder` 없이 `Dispatch.process`만
|
||||
반복 호출하면 그룹 값이 교체될 때마다 같은 `(inst,key)` 체인에 옛
|
||||
`AttributeKeyHandler` 항목이 계속 쌓이는 누수가 생김. **그래서 그룹의
|
||||
diff는 사라진/남아있는 이름 모두 자기 캐싱된 키로 먼저
|
||||
`Dispatch.retractUnder(inst, key, nil, newSourceOrNil)`를 부른 뒤에만
|
||||
`Dispatch.process`를 부름** — 새로 들어온 이름만 예외(옛 체인이 없으니
|
||||
retractUnder 없이 바로 process). `AttributeKeyHandler.retract`가 `v`가
|
||||
non-nil이면 즉시 return하는 얇은 함수라 이 추가 호출의 비용은 무시할
|
||||
수준.
|
||||
- **패키지 경계**: `AttributeKey` 자체가 이미 quad-roblox 소속(Tag와
|
||||
달리 base/roblox로 안 쪼갬, 아래 "패키지 배치" 절)이고 그룹의 실제
|
||||
위임 로직도 이미 roblox 쪽 글루라 `rawNew` 호출이 새 역의존을 안 만듦 —
|
||||
|
|
@ -254,14 +264,28 @@ Dispatch 재진입 없이 직접 `SetAttribute`+수동 per-field StoreBind 구
|
|||
attribute 이름 → 그 이름 전용 키 객체 맵"**(구 "이름 문자열 집합" —
|
||||
이름 존재 여부뿐 아니라 그때 쓴 키 객체 자체까지 같이 들고 있어야 위
|
||||
"이름 소유권" 절의 동일 객체 재사용이 성립)과 새 값의 키 집합을 diff:
|
||||
- 사라진 이름만, 그 이름이 맵에 들고 있던 **그 전용 키 객체**로
|
||||
`Dispatch.retractUnder(inst, key)` — 그 이름이 살아있는 동안 만들어졌던
|
||||
원래 체인과 정확히 같은 슬롯을 가리켜 정리됨. 맵에서도 그 이름 제거.
|
||||
- 남아있는 이름은 **맵에 이미 캐싱된 같은 키 객체를 그대로 재사용**,
|
||||
새로 들어온 이름은 `rawNew(name)`로 갓 만든 키를 맵에 새로 캐싱 —
|
||||
**값 비교 없이 전부** `Dispatch.process(inst, key, source)`로 넘김,
|
||||
작성자가 직접 `[AttributeKey<<T>> name] = source`를 쓴 것과 거의 같은
|
||||
경로를 타므로(단, 공개 캐시가 아니라 그룹 전용 키를 씀) `source`가
|
||||
- **[정정, 2026-08-12 열한 번째 세션] 사라진 이름뿐 아니라 남아있는
|
||||
이름도 먼저 `Dispatch.retractUnder(inst, key, nil, source)`를 부른
|
||||
뒤에야 `Dispatch.process`를 부름** — 그 이름이 맵에 들고 있던 **그
|
||||
전용 키 객체**로, 그 이름이 살아있는 동안 만들어졌던 원래 체인과
|
||||
정확히 같은 슬롯을 가리켜 정리(팝)됨. `retractUnder` 없이
|
||||
`Dispatch.process`만 반복 호출하면 체인이 매번 새 항목을 쌓기만 해서
|
||||
(팝은 `retractUnder`의 일) 같은 키 자리에 옛 `AttributeKeyHandler`
|
||||
항목이 계속 누적되는 진짜 누수가 생기므로, "값이 안 바뀌었으니
|
||||
retract 생략"은 성립 안 함 — `AttributeKeyHandler.retract`가 `v`
|
||||
non-nil이면 즉시 return하는 얇은 함수라(위 "이름 소유권" 절) 이
|
||||
호출 자체는 사실상 공짜.
|
||||
- 사라진 이름: `Dispatch.retractUnder(inst, key, nil, nil)` — 뒤이어
|
||||
`process` 안 부름, 맵에서도 그 이름 제거.
|
||||
- 남아있는 이름: `Dispatch.retractUnder(inst, key, nil, source)` →
|
||||
바로 `Dispatch.process(inst, key, source)` — **맵에 이미 캐싱된 같은
|
||||
키 객체를 그대로 재사용**.
|
||||
- 새로 들어온 이름: 옛 체인이 없으니 `retractUnder` 없이 바로
|
||||
`rawNew(name)`로 갓 만든 키를 맵에 새로 캐싱하고
|
||||
`Dispatch.process(inst, key, source)`.
|
||||
- **값 비교 없이 전부 재위임** — 작성자가 직접
|
||||
`[AttributeKey<<T>> name] = source`를 쓴 것과 거의 같은 경로를
|
||||
타므로(단, 공개 캐시가 아니라 그룹 전용 키를 씀) `source`가
|
||||
`State`/`Source`면 `Dispatch/StoreBind`가 알아서 언랩+구독까지 다
|
||||
해줌(그룹 Handler가 따로 구독 관리 안 함).
|
||||
- **값 비교(`:Get()`으로 old/new 비교)는 하지 않음** — State 계약("값은
|
||||
|
|
|
|||
|
|
@ -115,43 +115,51 @@ src/schema/union.luau:48-68`) — 에러 메시지는 즉시 문자열로 만들
|
|||
lifecycle-pattern.md`의 "quad는 라이프사이클 중간에 있지 않다" 원칙 참고).
|
||||
- 일반 프로퍼티는 애초에 "unset" 개념이 없음(`nil`로 셋하는 것도 그냥 셋
|
||||
동작) — 그래서 프로퍼티 핸들러는 보통 `retract`가 필요 없음.
|
||||
- **`retract`가 실제로 의미 있는 유일한 패턴은 "같은 키에 대해 매치되는
|
||||
핸들러 *타입 자체*가 사이클마다 바뀌는 경우"** (2026-08-07 여덟 번째
|
||||
세션, 정정) — 예: `Tag(...)`↔`nil` 사이에서 핸들러 타입 자체가
|
||||
바뀌므로 `retract`가 의미 있어짐(전체 삭제), 같은 Tag끼리 바뀌는
|
||||
diff는 `process`가 담당(`base/tag-plan.md`, 2026-08-08 세 번째 세션
|
||||
— array-part 값 객체 재설계 이후, 구 모델은 `archive/
|
||||
tag-hash-key-model-reversed.md`). **Attribute(단일 키 직접 쓰기 경로)는
|
||||
여기 해당 안 함** — UICorner 숏핸드와 같은 패턴(값의 참/거짓/nil
|
||||
여부와 무관하게 항상 같은 핸들러가 계속 담당, 추가/제거를 전부
|
||||
`process` 자신이 처리)이라 핸들러 교체 자체가 안 일어남 —
|
||||
`base/attribute-plan.md`. **[추가, 2026-08-12 열 번째 세션] 단, 그룹
|
||||
`Attribute(...)`가 개별 이름을 놓을 때는 그 이름 전용 키 객체의 체인
|
||||
자체가 통째로 정리되는 거라 `retract`가 실제로 불림** — "핸들러
|
||||
타입이 안 바뀌면 retract 없이 process"라는 이 절의 원칙과 안 어긋남,
|
||||
그룹이 이름을 잃는 건 "그 이름 전용 키가 이 인스턴스를 더 이상
|
||||
관리 안 하게 됨"이라 오히려 `Tag(...)→nil`과 같은 급의 "완전히
|
||||
사라짐" 케이스임 — `attribute-plan.md` "이름 소유권" 절 참고.
|
||||
**[정정,
|
||||
2026-08-10 세션] Tween도 더 이상 여기 해당하지 않음** — 원래는 이
|
||||
패턴의 대표 예시("Tween 핸들러가 매치돼 애니메이션 실행 중이었는데
|
||||
값이 더 이상 Tween 대상이 아니게 되어 일반 PropertyHandler로 매치가
|
||||
넘어가는 경우")였으나, Tween이 독립 Dispatch 핸들러가 아니라
|
||||
PropertyHandler가 소비하는 값-레벨 래퍼(`Tween<T>`)로 재설계되며
|
||||
매치되는 핸들러가 항상 PropertyHandler 하나뿐이 되어 이 케이스
|
||||
자체가 사라짐 — 트윈 취소/전환은 이제 PropertyHandler 내부의
|
||||
3-상태 릴레이션 슬롯으로 처리(`base/tween-plan.md`, `archive/
|
||||
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" 절 참고.
|
||||
**[추가, 2026-08-12 아홉 번째 세션] `Slot`도 같은 패턴, 단 diff가 아니라
|
||||
identity 비교** — `State<Slot>`이 `slotA→slotB`로 바뀌는 것도 같은
|
||||
SlotHandler가 매치하므로 `process`가 처리. `Tag`/`Ref`처럼 세밀한 diff
|
||||
대신 "같으면 완전 무시, 다르면 이전 것 통째로 폐기 후 새로 마운트"(Slot은
|
||||
portal 없이 폐기만 하기로 이미 확정돼 있어서) — `slot-plan.md` "Slot과
|
||||
Store 바인드의 관계" 절 참고.
|
||||
- **[전면 정정, 2026-08-12 열한 번째 세션] `retract`는 "핸들러 타입이
|
||||
바뀔 때만" 불리는 게 아니라, **store 바인드가 재발행될 때마다(값이
|
||||
뭐로 바뀌든) 항상 불림** — 위 "확정된 디스패치 모델" 절이 처음부터
|
||||
말해온 그대로: `StoreBind`는 재-dispatch 전에 **무조건**
|
||||
`Dispatch.retractUnder(inst,k,self,realv)`를 부르고, `retractUnder`는
|
||||
`keep` 바로 다음 항목에게 그 `realv`를(그 다음 항목들에겐 `nil`을)
|
||||
넘기며 체인을 통째로 걷어낸 뒤에야 `Dispatch.process`가 다시 매치를
|
||||
시도해 새 체인을 쌓음. 즉 **"핸들러가 안 바뀌면 retract 없이 process가
|
||||
diff"라는 이전 서술은 틀렸음** — `Tag`의 옛
|
||||
`assert(v == nil, "TagHandler.retract는 v가 nil일 때만 불려야 함")`을
|
||||
액면 그대로 믿고 거꾸로 일반 규칙을 추론한 게 오류의 출처(2026-08-07
|
||||
여덟 번째 세션 정정 당시엔 안 걸렸던 부분, `bind-system-plan.md` 자기
|
||||
"확정된 디스패치 모델" 절과 실제로 모순돼 있었음). 이 오류는
|
||||
`base/tag-plan.md`(원 출처)와, 이번 대화에서 그걸 그대로 이어받은
|
||||
`Ref`/`Slot`/`Attribute` 세 곳 전부에 퍼져 있었음 — 전부 정정
|
||||
완료(각 문서의 해당 절 참고). **[아카이브, `archive/
|
||||
retract-always-fires-reversed.md`]**.
|
||||
- **정정된 원칙 — 대부분의 핸들러는 이 반복 호출에서 실제로 할 일이
|
||||
없어(일반 프로퍼티처럼 값을 그냥 덮어쓰면 끝이라 "unset" 개념 자체가
|
||||
없음) `retract`가 사실상 no-op일 뿐, "타입이 안 바뀌면 retract가 아예
|
||||
안 불린다"는 뜻이 아님.** `Tag`/`Ref`/`Slot`/`Attribute`처럼 **여러
|
||||
위치가 하나의 실제 리소스(엔진 attribute/tag/mounted 서브트리 등)를
|
||||
공유하거나, 값 자체가 정리가 필요한 상태를 들고 있는** 핸들러는,
|
||||
`retract`가 매번 불려도 **"이전 값이 지금 들어오는 새 값(`v`)과
|
||||
사실상 같은지/그 새 값이 여전히 이 자원을 필요로 하는지"를 `v`를
|
||||
힌트 삼아 판단해 실제 엔진 호출만 skip**하는 방식으로 대응해야 함 —
|
||||
`Tag`의 `Contains` 힌트, `Ref`/`Slot`의 identity 비교가 그 예. **`v`를
|
||||
반드시 `nil`로 가정하면 절대 안 됨**(대체하는 새 값 그 자체일 수
|
||||
있음) — 새 핸들러를 짤 때 `retract` 안에서 `v`의 타입을 방어적으로
|
||||
확인할 것(2026-08-12 열한 번째 세션, 실제로 `Tag(...)`/`Ref`/`Slot`
|
||||
설계 전부에서 이 확인이 빠져 있었음이 드러남).
|
||||
- **자연스러운 분업**: 여러 위치가 자원을 공유하는 핸들러는 대개
|
||||
"`retract`가 이전 기여를 걷어내고(실제 해제는 `v` 힌트로 skip
|
||||
가능), `process`가 새 기여를 등록한다"는 모양으로 깔끔히 갈림 —
|
||||
`process` 쪽에 별도 old-vs-new diff가 필요 없어짐(그 diff를 `retract`가
|
||||
이미 통째로, 매번 정확하게 해주므로). `Tag(...)`↔`nil`, `Attribute`의
|
||||
그룹이 이름을 놓는 경우도 이 분업의 자연스러운 특수 케이스일 뿐, 별도
|
||||
패턴이 아님 — 상세 구현은 `base/tag-plan.md`/`base/attribute-plan.md`
|
||||
"이름 소유권" 절, `Ref`는 아래 "`Ref`의 retract" 절, `Slot`은
|
||||
`slot-plan.md` "Slot과 Store 바인드의 관계" 절 참고.
|
||||
- Tween은 이 패턴과 무관 — 독립 Dispatch 핸들러가 아니라 PropertyHandler가
|
||||
소비하는 값-레벨 래퍼(`Tween<T>`)라 매치되는 핸들러가 항상
|
||||
PropertyHandler 하나뿐(2026-08-10 세션 재설계) — 트윈 취소/전환은
|
||||
PropertyHandler 내부의 3-상태 릴레이션 슬롯으로 처리(`base/tween-plan.md`,
|
||||
`archive/tween-special-bind-key-reversed.md`).
|
||||
- store bind가 새 값으로 넘어갈 때 이전 핸들러의 `retract`를 호출해주면
|
||||
됨 — **정확한 전파 메커니즘은 아래 "Dispatch 체인" 절 참고**(재귀
|
||||
재-dispatch에서 여러 단계가 겹칠 때 어느 슬롯에 뭘 추적하는지가
|
||||
|
|
@ -292,11 +300,13 @@ NoneHandler.process(inst, k, v) = process(inst, k, nil) -- 재귀 재호출
|
|||
프로퍼티(Color3/number 등)에 도달하면 `inst[k] = nil`은 런타임 에러 —
|
||||
PropertyHandler 자신이 `v == nil`이면 셋을 건너뛰는 방어를 갖고 있어야
|
||||
함(None 자체의 문제가 아니라 PropertyHandler 구현 디테일, M9/M10로 미룸).
|
||||
- **retract와는 무관** — `retract`는 "같은 키를 다른 *핸들러 타입*이
|
||||
넘겨받는" 시나리오 전용(아래 정정된 "확정된 디스패치 모델" 절)이지
|
||||
"`v`가 `nil`이 됨"과는 다른 문제. `None → nil` 재디스패치는 항상
|
||||
`Dispatch.process` 경로로만 흐름 — `NoneHandler` 자신도 `retract`가
|
||||
딱히 할 일이 없음(재귀 호출 자체가 이미 process이므로).
|
||||
- **`retract`는 여기서 할 일이 없음** — **[정정, 2026-08-12 열한 번째
|
||||
세션]** `retract`는 store 재발행마다 항상 불리지만(위 "확정된 디스패치
|
||||
모델"/일반 retract 계약 절 정정분 참고), `NoneHandler`는 `v==None`을
|
||||
매치했을 때 재귀 호출로 곧바로 `Dispatch.process(inst,k,nil)`을
|
||||
부르는 게 전부고 자기 자신이 들고 있는 별도 상태가 없어서(`Relate` 등
|
||||
전혀 안 씀) `retract`가 불려도 정리할 게 없음 — 일반 프로퍼티 핸들러가
|
||||
`retract`를 no-op으로 두는 것과 같은 이유.
|
||||
- **[해소됨, 2026-08-08 세 번째 세션]** "이 키를 지금 누가 담당 중인가"
|
||||
bookkeeping — `pre-implementation-audit.md` 우선순위1 "이전에 실제로
|
||||
매치됐던 핸들러 추적" 항목이 여기서 다시 언급됐던 것. 아래 "Dispatch
|
||||
|
|
@ -1005,11 +1015,13 @@ tween-plan.md`도 이에 맞춰 갱신됨). Ref의 진짜 용도는 다름:
|
|||
`refB`로 넘어갔다는 걸 모르는 코드가 `refA.Value`를 계속 유효하다고 믿는
|
||||
조용한 버그가 남음 — `PreRef` 재사용 버그(위 절)와 같은 클래스의 문제.
|
||||
|
||||
**메커니즘 — `TagHandler`(`base/tag-plan.md`)와 정확히 같은 패턴 재사용,
|
||||
새 장치 아님.** `Dispatch`의 일반 규칙("핸들러 *타입*이 안 바뀌면 retract
|
||||
없이 process만 다시" — `refA→refB`는 둘 다 같은 Ref-leaf handler가
|
||||
매치하므로 여기 해당)을 그대로 따르면, `refA→refB` 전환은 `retract`가
|
||||
아니라 **`process` 자신이 이전 값을 기억해뒀다가 diff**해야 함:
|
||||
**메커니즘 — `retract`가 매번 불린다는 전제 위에서 언바인딩 전담
|
||||
(2026-08-12 열한 번째 세션 정정).** `Dispatch.retractUnder`는 store 값이
|
||||
바뀔 때마다(핸들러 타입이 그대로여도) 무조건 불림 — 위 "확정된 디스패치
|
||||
모델"/일반 retract 계약 절 참고. 그래서 `refA→refB` 전환도 `retract(inst,k,
|
||||
refB)`가 먼저 불려 `refA`를 언바인딩하고, 그 다음 `process(inst,k,refB)`가
|
||||
`refB`를 바인딩하는 두 단계로 자연히 갈림 — `process`가 old-vs-new diff를
|
||||
따로 계산할 필요가 없어짐(그 일을 `retract`가 매번 정확히 대신 해줌):
|
||||
|
||||
```lua
|
||||
local relate = Relate() -- Ref-leaf handler 전용, (inst,k)별 마지막으로 바인딩한 Ref 기억
|
||||
|
|
@ -1018,34 +1030,32 @@ 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
|
||||
if old == v then return end -- 이미 같은 Ref가 이 자리를 차지 중(retract가
|
||||
-- 방금 손 안 댄 경우) — 콜백 재통지 없이 no-op
|
||||
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)
|
||||
if old and old ~= v then -- v는 nil일 수도, 대체하는 새 Ref 자체일 수도 있음 —
|
||||
-- 어느 쪽이든 old와 다르면 old는 확실히 이 자리를 잃음
|
||||
old:Set(nil) -- 매 :Set()마다 콜백 재통지되는 기존 Ref 규칙(위 "해소됨 —
|
||||
-- 반복 재설정 가능" 항목)을 그대로 재사용, 새 알림 경로 아님
|
||||
relate:SetStrong(inst, k, nil)
|
||||
end
|
||||
-- old == v(같은 Ref 재발행) → 아무 것도 안 함, 곧 process도 no-op으로 스킵
|
||||
end
|
||||
```
|
||||
|
||||
- **`retract`는 이 자리가 Ref이길 아예 그만둘 때만 불림**(Store 값이 Ref가
|
||||
아닌 다른 것으로 바뀌어 다른 Handler가 매치되는 경우) — `refA→refB`처럼
|
||||
Ref끼리 바뀌는 흔한 경우는 위 `process`의 diff가 담당. `retract`와
|
||||
`process` 양쪽 다 결국 `old:Set(nil)` 하나로 귀결되므로 실질적으로
|
||||
"언바인딩 로직은 하나, 트리거 경로만 둘"인 구조 — Tag의 diff/전체삭제
|
||||
분리와 동형.
|
||||
- **`retract`가 언바인딩 전담, `process`는 바인딩 전담** — 겹치는 diff
|
||||
로직이 없음. `old == v`(같은 Ref 객체가 스스로 재발행된 spurious한
|
||||
경우)만 둘 다 스킵해 콜백이 `nil`→`inst`로 헛되이 두 번 안 불리게 함.
|
||||
- **children 배열 리터럴 `Ref`도 같은 코드 경로를 그대로 씀** — 그 경우
|
||||
`relate:GetStrong(inst,k)`가 애초에 `nil`(그 자리에 처음 오는 값)이라
|
||||
`old`가 없어 언바인딩 분기를 안 타고 바로 `v:Set(inst)`로 끝남. 즉
|
||||
"1회성 리터럴 구성"과 "반복 재바인드"가 하나의 구현으로 자연히 커버됨,
|
||||
케이스 분기 불필요.
|
||||
`retract`가 (StoreBind 경로가 아니라 이 리터럴 구성 자체가 처음이므로)
|
||||
아예 안 불리고 `relate:GetStrong(inst,k)`도 `nil`이라 `process`가 바로
|
||||
`v:Set(inst)`로 끝남. "1회성 리터럴 구성"과 "반복 재바인드"가 하나의
|
||||
구현으로 자연히 커버됨, 케이스 분기 불필요.
|
||||
- **타입: 비-nilable `T`도 정당한 용도(사용자 확인, 2026-08-12 여덟 번째
|
||||
세션)** — `Ref`는 "채워지길 기다리는 박스"뿐 아니라 "이미 확정된 값을
|
||||
여기저기서 부작용 없이 읽는" 용도로도 쓰일 수 있어 `Ref<T>`(T가
|
||||
|
|
|
|||
|
|
@ -198,15 +198,15 @@ Slot이 store 바인드로 들어오는 경우, pluggable 처리기에 `retract`
|
|||
> 동작이 '부모 위임' 잠정안에서 '폐기(옮기지 않음)'로 확정") 참고. 이 문단은
|
||||
> 검토 과정의 히스토리로만 남겨둠, 현재 유효한 동작 아님.
|
||||
|
||||
**[정정, 2026-08-12 아홉 번째 세션] 위 "store 바인드 핸들러가 이전 slot을
|
||||
`retract`하고 다시 `process`하는 사이클" 서술은 부정확했음 — `Ref`의
|
||||
retract를 검토하며 발견한 것과 같은 오류.** `base/bind-system-plan.md`의
|
||||
일반 계약("핸들러 *타입*이 안 바뀌면 `retract` 없이 `process`가 diff
|
||||
담당", `Tag`/`Ref`가 실제 선례)을 그대로 적용하면, `State<Slot>`이
|
||||
`slotA→slotB`로 바뀌는 것도 둘 다 같은 SlotHandler가 매치하는 경우라
|
||||
**`retract`가 아니라 `process` 자신이 처리해야 함** — `retract`는 그
|
||||
자리가 아예 Slot이길 그만둘 때만. 메커니즘은 `Ref`(`bind-system-plan.md`
|
||||
"`Ref`의 retract" 절)와 같은 모양의 `Relate` 기반 diff:
|
||||
**[전면 정정, 2026-08-12 열한 번째 세션] 위 "핸들러 타입이 안 바뀌면
|
||||
retract 없이 process가 diff 담당"이라는 전제 자체가 틀렸음** — 실제로는
|
||||
`Dispatch.retractUnder`가 store 재발행마다(핸들러가 그대로여도) **항상**
|
||||
먼저 불림(`base/bind-system-plan.md` "확정된 디스패치 모델"/일반 retract
|
||||
계약 절, 2026-08-12 열한 번째 세션 전면 정정 참고). 그래서 `State<Slot>`이
|
||||
`slotA→slotB`로 바뀔 때도 **`retract(inst,k,slotB)`가 먼저 불려 `slotA`를
|
||||
폐기하고, 그 다음 `process(inst,k,slotB)`가 `slotB`를 마운트**하는 두
|
||||
단계로 자연히 갈림 — `retract`가 "이전 것 정리", `process`가 "새 것
|
||||
마운트" 전담:
|
||||
|
||||
```lua
|
||||
local relate = Relate() -- SlotHandler 전용, (inst,k)별 마지막으로 마운트한 Slot 기억
|
||||
|
|
@ -214,33 +214,33 @@ local relate = Relate() -- SlotHandler 전용, (inst,k)별 마지막으로 마
|
|||
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) -- 폐기, 옮기지 않음 — 아래 "확정" 절 그대로
|
||||
return -- 이미 같은 바인딩(retract가 방금 손 안 댄 경우) — no-op
|
||||
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)
|
||||
if old and old ~= v then -- v는 nil일 수도, 대체하는 새 Slot 자체일 수도 있음
|
||||
destroySlotTree(old) -- 폐기, 옮기지 않음 — 아래 "확정" 절 그대로
|
||||
relate:SetStrong(inst, k, nil)
|
||||
end
|
||||
-- old == v(같은 Slot 재발행) → 아무 것도 안 함, 곧 process도 no-op으로 스킵
|
||||
end
|
||||
```
|
||||
|
||||
- **같은 바인딩이면 완전히 무시하는 게 이 자리에선 효율 문제가 아니라
|
||||
정합성 문제** — `Tag`/`Ref`의 diff는 값이 같아도 기껏해야 헛계산만
|
||||
하고 넘어가지만, Slot은 아래 "확정" 절대로 "폐기, 옮기지 않음"(portal
|
||||
정합성 문제** — Slot은 아래 "확정" 절대로 "폐기, 옮기지 않음"(portal
|
||||
없음)이 이미 정책으로 확정돼 있어서, 이 no-op 가드가 없으면 **재귀 재
|
||||
emit이 있을 때마다 마운트된 서브트리 전체가 파괴됐다 다시 만들어짐**
|
||||
(자식들이 들고 있던 스크롤 위치/포커스/애니메이션 상태 전부 유실) —
|
||||
Tag의 diff가 막으려던 "깜빡임" 문제보다 훨씬 파괴적인 버전.
|
||||
Tag의 `Contains` 힌트가 막으려던 "깜빡임" 문제보다 훨씬 파괴적인 버전.
|
||||
`store.key:Set(sameSlotAgain)`처럼 사용자가 실수로 같은 객체를 다시
|
||||
emit하거나, 상위 `:Compute`가 재계산됐는데 결과가 우연히 같은 Slot
|
||||
레퍼런스인 경우 등이 실제로 이 경로를 탈 수 있음.
|
||||
레퍼런스인 경우 등이 실제로 이 경로를 탈 수 있음. `retract`가 `old ~= v`
|
||||
체크로 이걸 막고, `process`도 `old == slotValue` 체크로 대칭적으로 막음
|
||||
— 둘 다 스킵돼야 완전한 no-op.
|
||||
- Slot 핸들러 자신이 감시 중인 값(배열/스토어)이 바뀔 때 child를 갱신하는
|
||||
추적(구독)도 `base/bind-system-plan.md`가 말하는 "process 함수가 다른 값
|
||||
변경을 추적해도 됨" 범위에 속하고, `retract` 시점엔 그 추적만 풀면 됨 —
|
||||
|
|
|
|||
|
|
@ -2,7 +2,9 @@
|
|||
|
||||
**상태**: base — 2026-08-08 세 번째 세션에서 값 모양을 전면 재설계(구
|
||||
모델은 `archive/tag-hash-key-model-reversed.md`에 원문·역전 이유 보존).
|
||||
새 결정만 반영, 열린 질문 없음.
|
||||
2026-08-12 열한 번째 세션에 `TagHandler`의 `process`/`retract` 메커니즘을
|
||||
참조 카운트 기반으로 전면 정정(옛 버전은 `archive/
|
||||
retract-always-fires-reversed.md`). 새 결정만 반영, 열린 질문 없음.
|
||||
|
||||
## 왜 재설계됐나
|
||||
|
||||
|
|
@ -50,54 +52,101 @@ Tag("selected") or nil end)`처럼 그냥 `nil`을 리턴하면 됨. `None` 센
|
|||
이건 nil-hole 문제라 Tag만의 특수 규칙이 아니라 `props.Modifier`/
|
||||
`props.Ref`와 같은 일반 array-part 관용구.)
|
||||
|
||||
## 메커니즘 — `TagHandler`, retract가 이제 의미 있어짐
|
||||
## 메커니즘 — `TagHandler`, `retract`가 이제 의미 있어짐
|
||||
|
||||
구 모델과 달리 **핸들러 타입이 사이클마다 바뀔 수 있음**(`Tag(...)` ↔
|
||||
`nil`, 값이 `Tag`가 아니게 되면 `TagHandler.isHandlable`이 더 이상 안
|
||||
맞음) — 그래서 `retract`가 실제로 필요해짐(`bind-system-plan.md` "확정된
|
||||
디스패치 모델" 절의 일반 원칙 그대로).
|
||||
|
||||
**[전면 정정, 2026-08-12 열한 번째 세션] 아래는 이전 버전(단일 `relate`,
|
||||
`assert(v==nil)`, "Tag(A)→Tag(B)는 retract 안 불림")을 대체함 — 그 버전은
|
||||
두 가지를 놓쳤음:**
|
||||
|
||||
1. **`retract`는 실제로 store 재발행마다(핸들러 타입이 안 바뀌어도) 항상
|
||||
불림** — `bind-system-plan.md`의 "확정된 디스패치 모델" 절이 처음부터
|
||||
말해온 대로 `StoreBind`가 재-dispatch 전에 무조건 `Dispatch.retractUnder`를
|
||||
부르기 때문. "Tag(A)→Tag(B)는 retract 안 불림"이라는 옛 서술은 틀렸음
|
||||
(상세 근거는 `bind-system-plan.md` 일반 retract 계약 절, `archive/
|
||||
retract-always-fires-reversed.md`).
|
||||
2. **서로 다른 배열 위치의 두 `Tag(...)`가 같은 이름을 겹쳐 가질 수
|
||||
있음**(`Frame { Tag("a"), Tag("a","b") }`류, 웹 `className="a a a"`와
|
||||
같은 합집합 시맨틱) — 한 위치의 diff만 보고 `RemoveTag`를 부르면 다른
|
||||
위치가 아직 그 이름을 쓰고 있어도 지워버리는 참조 카운트 버그가
|
||||
생김(사용자 지적, 2026-08-12 열한 번째 세션).
|
||||
|
||||
**둘 다 같은 해법으로 풀림**: `Tag`는 **immutable**이고(모든 연산이
|
||||
clone을 반환) 내부에 State 같은 걸 담지도 않는 **항상 확정 상태인 말단
|
||||
값**(Tween과 같은 결) — 그래서 `State<Tag>`가 진짜로 다른 내용을 내놓을
|
||||
때마다 **항상 물리적으로 다른 `Tag` 객체**가 나옴. 이 사실 덕분에, 이름별로
|
||||
"어떤 `Tag` 객체들이 지금 이 이름을 걸고 있는가"를 집합으로 추적하면
|
||||
`retract`(이전 객체가 이 이름을 놓음)/`process`(새 객체가 이 이름을 걺)가
|
||||
겹치는 이름/겹치는 위치 양쪽 다 자동으로 올바르게 처리됨:
|
||||
|
||||
```lua
|
||||
local relate = Relate() -- TagHandler 전용, 이전에 반영한 Tag 값 저장
|
||||
local kTagMap = Relate() -- {[inst(weak)] = {[k]: Tag}} — 위치별 마지막으로 반영한 Tag
|
||||
local tagNameMap = Relate() -- {[inst(weak)] = {[tagName]: {[Tag]: true}}} — 이름별 현재 걸고 있는 Tag들
|
||||
|
||||
TagHandler.priority = <일반>
|
||||
TagHandler.isHandlable(inst, k, v) = isTag(v) -- Brand 기반, array-part 전용
|
||||
|
||||
function TagHandler.process(inst, k, v)
|
||||
local old = relate:GetStrong(inst, k)
|
||||
-- diff: old에 있고 v에 없는 이름만 RemoveTag, v에 있고 old에 없는 이름만 AddTag
|
||||
-- (모두 지웠다 다시 붙이지 않음 — 랙/스타일 깜빡임 방지가 이 diff의 존재 이유)
|
||||
relate:SetStrong(inst, k, v)
|
||||
function TagHandler.retract(inst, k, newv)
|
||||
local oldv = kTagMap:GetStrong(inst, k)
|
||||
if not oldv then return end
|
||||
local newvIsTag = isTag(newv) -- newv는 nil일 수도, 대체하는 새 Tag 자체일 수도 있음
|
||||
for name in oldv:Names() do
|
||||
local holders = tagNameMap:GetStrong(inst, name) -- 이미 등록됐으므로 항상 있음
|
||||
holders[oldv] = nil
|
||||
if next(holders) == nil and not (newvIsTag and newv:Contains(name)) then
|
||||
inst:RemoveTag(name) -- 곧 process가 재확정할 이름이면 실제 호출은 skip(깜빡임 방지)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
function TagHandler.retract(inst, k, v)
|
||||
assert(v == nil, "TagHandler.retract는 v가 nil일 때만 불려야 함")
|
||||
local old = relate:GetStrong(inst, k)
|
||||
if old then for name in old:Names() do CollectionService:RemoveTag(inst, name) end end
|
||||
relate:SetStrong(inst, k, nil)
|
||||
function TagHandler.process(inst, k, v)
|
||||
for name in v:Names() do
|
||||
local holders = tagNameMap:GetStrong(inst, name)
|
||||
if not holders then
|
||||
holders = {} -- strong map — Tag가 살아있는 동안 소유 목록도 살아있어야 함
|
||||
tagNameMap:SetStrong(inst, name, holders)
|
||||
end
|
||||
if next(holders) == nil then
|
||||
inst:AddTag(name)
|
||||
end
|
||||
holders[v] = true
|
||||
end
|
||||
kTagMap:SetStrong(inst, k, v)
|
||||
end
|
||||
```
|
||||
|
||||
- **`Tag(A) → Tag(B)`(같은 핸들러, 타입 안 바뀜)**: `retract`는 아예 안
|
||||
불림 — `Dispatch`의 "핸들러가 안 바뀌면 retract 없이 process만 다시"
|
||||
원칙 그대로(`bind-system-plan.md` "Dispatch 체인" 절). **diff는 여기,
|
||||
`process` 안에서만** 일어남 — 전체 삭제 후 재생성하면 스타일이 순간
|
||||
전부 사라졌다 다시 붙어 랙/깜빡임을 유발하므로(사용자 지적), 반드시
|
||||
이전 값과 diff.
|
||||
- **`Tag(A) → nil`(핸들러가 TagHandler → 없음으로 바뀜)**: `retract`가
|
||||
불림. **[명시화, 2026-08-09 열한 번째 세션] 전체 삭제는 정확히
|
||||
`v == nil`일 때만 맞는 동작 — "v를 안 봐도 된다"가 아니라 "v가 항상
|
||||
nil로 들어온다는 걸 알고 있으니 별도 분기가 필요 없다"가 정확한
|
||||
표현.** Tag 값을 담는 키에서 TagHandler가 더 이상 매치 안 되는 유일한
|
||||
경로가 값이 `nil`이 되는 것(`None → nil` 재디스패치 포함)이라 이
|
||||
전제가 깨지지 않는 한 위 구현처럼 `v`를 실제로 분기 안 해도 항상
|
||||
옳음 — 위 pseudocode에 `assert(v == nil, ...)`을 추가해 이 전제를
|
||||
코드에도 드러냄. `Handler.retract`가 여전히 `(inst,k,v)` 3-인자를
|
||||
받는 건 계약 일관성 때문이지(다른 핸들러는 `v`를 실제로 씀) Tag가
|
||||
그걸 필수로 요구해서가 아님.
|
||||
- **`AddTag`는 온전히 `process`, `RemoveTag`는 온전히 `retract`** — 서로
|
||||
겹치는 diff 계산이 없음. `retract`가 이전 `Tag`(`oldv`)가 걸었던 이름
|
||||
전부를 소유 목록에서 빼되(항상 실행), 그 결과 목록이 비었을 때 **실제
|
||||
`RemoveTag` 호출만** "새로 들어올 `newv`가 그 이름을 여전히 Contains하는가"로
|
||||
힌트를 줘서 skip — 소유 목록 자체는 항상 최신 객체로 갱신되므로(정확히
|
||||
`oldv`를 빼고 `v`를 넣는 두 단계), 이름이 살아남는 경우에도 stale
|
||||
레퍼런스가 안 남음. `process`는 `v`가 새로 거는 이름 전부를 무조건
|
||||
등록(소유 목록이 비어있던 경우에만 실제 `AddTag`) — 자기 나름의 old-vs-new
|
||||
diff가 전혀 필요 없음(그 일을 `retract`가 매번 정확히 해줌).
|
||||
- **`Tag(A)→Tag(B)`(같은 위치, 내용만 바뀜)**: `retract(inst,k,B)`가 먼저
|
||||
불려 `A`가 걸었던 이름 중 `B`에 없는 것만 실제로 `RemoveTag`, 남은 건
|
||||
힌트로 skip — 그 다음 `process(inst,k,B)`가 `B`의 이름 전부를 등록(이미
|
||||
걸려있던 이름은 `AddTag`가 no-op으로 재확인만 됨, 소유 목록엔 `B`가 새로
|
||||
등록). 결과적으로 실제 `RemoveTag`/`AddTag` 호출은 진짜 변경된 이름에만
|
||||
일어남 — 스타일 깜빡임 방지라는 원래 목적은 그대로 달성.
|
||||
- **`Tag(A)→nil`**: `retract(inst,k,nil)`만 불림(값이 `Tag`가 아니게 돼
|
||||
`process`는 매치 자체가 안 됨) — `newvIsTag=false`라 힌트가 항상
|
||||
거짓이 되어 `A`가 걸었던 이름 전부가 무조건 실제로 `RemoveTag`됨(다른
|
||||
위치가 그 이름을 계속 쓰고 있지 않다면).
|
||||
- **여러 위치가 같은 이름을 겹쳐 가지는 경우**(`Frame { Tag("a"), Tag("a","b") }`):
|
||||
두 위치가 서로 다른 `k`로 각자 독립적으로 `process`/`retract`를 타지만,
|
||||
`tagNameMap["a"]`는 **양쪽 위치의 `Tag` 객체를 모두 담는 하나의 공유
|
||||
집합** — 한쪽이 "a"를 잃어도 다른 쪽 객체가 집합에 남아있으면 실제
|
||||
`RemoveTag`가 안 불림. 웹 `className`처럼 손실 없는 합집합이 정확히
|
||||
나옴.
|
||||
- **`retract`가 자기 위임 대상까지 수동으로 안 쫓아가도 됨** —
|
||||
`Dispatch.retractUnder`가 체인 전체를 알아서 훑어주므로 TagHandler는
|
||||
자기 자원(위 `relate` 저장분)만 정리하면 됨. 상세 메커니즘은
|
||||
자기 자원(위 두 릴레이션)만 정리하면 됨. 상세 메커니즘은
|
||||
`bind-system-plan.md` "Dispatch 체인" 절.
|
||||
|
||||
## 패키지 배치 — base는 값+API, roblox는 process/retract 글루
|
||||
|
|
|
|||
|
|
@ -150,21 +150,23 @@ StoreBind가 State/Source 레이어를 전부 풀어낸 뒤의 값:
|
|||
|
||||
**GC-안전성은 기존과 동일** — `Relate`가 `inst`로 weak-keyed되어 있어
|
||||
`inst`가 죽으면 이 슬롯(엔진 Tween 객체+`Value` 포함)도 별도 정리 로직
|
||||
없이 같이 GC됨. `retract`는 이 케이스에서 거의 안 불림 — 아래 절 참고.
|
||||
없이 같이 GC됨. **[정정, 2026-08-12 열한 번째 세션]** `retract`는 store
|
||||
재발행마다 항상 불리지만(`bind-system-plan.md` 일반 retract 계약 절
|
||||
정정분 — "거의 안 불림"이었던 원 서술은 틀렸음), PropertyHandler의
|
||||
`retract`는 몸체가 no-op이라 실질적으로 하는 일이 없음 — 아래 절 참고.
|
||||
|
||||
### 왜 `retract`가 더 이상 필요 없는가 — Dispatch 체인 관점의 결과적 단순화
|
||||
|
||||
기존 모델에선 "Tween 핸들러가 매치되어 애니메이션이 실행 중이었는데,
|
||||
다음 값이 더 이상 Tween 대상이 아니게 되어 일반 PropertyHandler로
|
||||
핸들러 *타입*이 바뀌는" 경우가 `base/bind-system-plan.md`가 서술하는
|
||||
"`retract`가 실제로 의미를 갖는 유일한 패턴"의 대표 예시였음. 새 모델에선
|
||||
핸들러 *타입*이 바뀌는" 경우가 이 문제의 대표 예시였음. 새 모델에선
|
||||
**매치되는 Dispatch 핸들러가 항상 PropertyHandler 하나뿐**(Tween 여부는
|
||||
값 내부 분기일 뿐 핸들러 매치 자체엔 영향 없음) — 이 시나리오 자체가
|
||||
Dispatch 레벨에서 사라짐. 트윈 취소/전환은 위 3-상태 저장 로직으로
|
||||
PropertyHandler 내부에서 처리 — Tag가 이미 하고 있는 "diff는 `process`
|
||||
자신이 담당" 패턴과 같은 모양이라 새 개념 아님. (PropertyHandler의
|
||||
값 내부 분기일 뿐 핸들러 매치 자체엔 영향 없음) — "핸들러 *타입*이
|
||||
바뀌는" 시나리오 자체가 Dispatch 레벨에서 사라짐. 트윈 취소/전환은 위
|
||||
3-상태 저장 로직으로 PropertyHandler 내부에서 처리. (PropertyHandler의
|
||||
`retract` 필드 자체는 여전히 정의해둬야 함 — "필드 생략 불가" 규칙은
|
||||
예외 없는 일반 규칙 — 다만 실제로 호출될 일이 이 경로에선 사실상 없음.)
|
||||
예외 없는 일반 규칙 — **매번 불리긴 하지만** 몸체가 no-op이라 실질적
|
||||
동작이 없음, 일반 프로퍼티는 애초에 "unset" 개념이 없어서.)
|
||||
|
||||
### 타입 대수: `T' = T | Tween<T>` — Modifier/State/Source에 새 타입 기계 불필요
|
||||
|
||||
|
|
|
|||
|
|
@ -89,12 +89,14 @@ Modifier 타입의 메소드 목록에 끼워 넣도록 챙기면 됨, 새로
|
|||
일반 프로퍼티 핸들러와 달리 이 숏핸드는 실제 Instance를 만들어 붙이는
|
||||
쪽이라 "`nil` = 셋 안 함"이 곧 "만들어둔 게 있으면 치운다"는 뜻이 됨.
|
||||
|
||||
- **이건 `retract`가 아니라 `process` 자신의 로직** — `retract`는 "이
|
||||
키를 다른 핸들러가 넘겨받는" 시나리오 전용(`bind-system-plan.md` "확정된
|
||||
디스패치 모델" 절)이지, 같은 핸들러가 값이 바뀌어서 자기 산출물을
|
||||
정리하는 것과는 다른 문제. 값이 나중에 다시 숫자로(`2`→`nil`→`3`처럼)
|
||||
바뀌면 `process`가 다시 자식을 만들면 그만이라 `retract` 쪽에 별도로
|
||||
구현할 게 없음.
|
||||
- **이건 `retract`가 아니라 `process` 자신의 로직** — **[정정, 2026-08-12
|
||||
열한 번째 세션]** `retract`는 store 재발행마다 항상 불리지만(핸들러
|
||||
타입이 안 바뀌어도, `bind-system-plan.md` 일반 retract 계약 절 정정분
|
||||
참고), 이 Handler는 `process(inst,k,v)` 자체가 `v`가 `nil`이든 숫자든
|
||||
전부 완결적으로 처리하므로(있으면 지우거나 만들거나) `retract`가 할 일이
|
||||
없어 no-op이면 충분 — 일반 프로퍼티 핸들러가 `retract`이 필요 없는 것과
|
||||
같은 이유. 값이 나중에 다시 숫자로(`2`→`nil`→`3`처럼) 바뀌면 `process`가
|
||||
다시 자식을 만들면 그만이라 `retract` 쪽에 별도로 구현할 게 없음.
|
||||
- **캐비엇**: 이 왔다갔다가 잦으면(예: 반응형 State가 `nil`과 숫자 사이를
|
||||
자주 토글) 매번 Instance 생성/제거 비용이 그대로 듦 — Tween처럼 무거운
|
||||
API는 아니지만 공짜도 아니므로, 잦은 토글이 예상되는 값을 이 숏핸드에
|
||||
|
|
|
|||
|
|
@ -0,0 +1,96 @@
|
|||
# 2026-08-12 열한 번째 세션 — "retract는 항상 불림" 전면 정정, Tag 참조 카운트 재설계
|
||||
|
||||
## 배경
|
||||
|
||||
열 번째 세션(Attribute 이름 소유권)에 이어, 사용자가 같은 클래스의 문제가
|
||||
`Tag`에도 있을 수 있다고 지적: 서로 다른 배열 위치의 `Tag(...)`가 같은
|
||||
이름을 겹쳐 가질 수 있는데(`Frame { Tag("a"), Tag("a","b") }`, 웹
|
||||
`className="a a a"`와 같은 합집합 시맨틱), 한 위치의 diff만으로 다른
|
||||
위치가 아직 쓰는 이름을 지워버리는 참조 카운트 버그가 날 수 있다는
|
||||
문제 제기.
|
||||
|
||||
## 논의 전개 — 근본 오류 발견까지
|
||||
|
||||
1. Claude가 처음 위치별 `{[k]: true}` set(참조 카운트)을 제안 → 사용자
|
||||
동의.
|
||||
2. 사용자가 후속 질문: "retract에서 새롭게 들어온 v가 Tag라면 Contains를
|
||||
확인해서 재사용될 거면 RemoveTag 안 함" — 이게 성립하려면 `retract`가
|
||||
`v=Tag`(nil 아닌 값)를 받는 경우가 있어야 함. Claude가 처음엔 이걸
|
||||
기존 `assert(v==nil)`과 모순된다고 판단해 "메커니즘을 바꾸자는
|
||||
거냐"고 되물음.
|
||||
3. 사용자가 재확인: "정확히 저는 어떤 값이든 덮여 쓰여지는 즉시 retract를
|
||||
실행하는 거로 두기로 했었어요 — 전체 process 트랙을 retract하고
|
||||
리빌드한다는 맥락이었어요."
|
||||
4. 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~=v` identity 체크로 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 패턴)는 애초에 공유 리소스가 없어 이 문제에
|
||||
해당 안 됨 — 정정 불필요 확인만 하고 넘어감.
|
||||
24
CLAUDE.md
24
CLAUDE.md
|
|
@ -555,3 +555,27 @@ no-op 가드는 Tag/Ref보다 Slot에서 훨씬 중요함(가드 없으면 재
|
|||
claimant 타입 없이 AttributeKey 객체 identity 자체를 재사용하는 더 적은
|
||||
부품의 설계. 기존 diff 로직(사라진 이름만 nil, 남은/새 이름은 갱신)은
|
||||
그대로 맞물림, 캐시가 그룹 값 교체를 넘어 영속돼야 한다는 조건만 명시.
|
||||
|
||||
**2026-08-12 열한 번째 세션 — "retract는 항상 불림" 전면 정정, `Tag`
|
||||
참조 카운트 재설계** (`session/2026-08-12-11-retract-always-fires-correction.md`)
|
||||
`Tag`도 Attribute와 같은 참조 카운트 문제(서로 다른 위치의 `Tag(...)`가
|
||||
같은 이름을 겹쳐 가질 수 있음, 웹 `className` 합집합)가 있다는 사용자
|
||||
지적에서 출발 — 논의 중 사용자가 "retract가 v=Tag(nil 아님)를 받는
|
||||
경우"를 전제로 설계를 제안했고, 이게 기존 `assert(v==nil)`과 모순됨을
|
||||
Claude가 지적했으나, 사용자가 "덮여 쓰여지는 즉시 retract 실행, 전체
|
||||
트랙을 retract하고 리빌드하는 맥락"이라고 재확인. `bind-system-plan.md`
|
||||
자기 "확정된 디스패치 모델" 절(2026-08-04 원문)을 재대조하니 `StoreBind`가
|
||||
재-dispatch 전에 무조건 `retractUnder`를 부른다고 이미 명시돼 있었음 —
|
||||
"핸들러 타입이 안 바뀌면 retract 생략"이라는 2026-08-07 정정 서술이
|
||||
자기 문서와 처음부터 모순돼 있었고, `Tag`의 `assert(v==nil)`을 액면
|
||||
그대로 믿고 거꾸로 일반 규칙을 잘못 추론한 게 오류의 출처였음이 드러남.
|
||||
**이 오류가 이번 대화에서 만든 `Ref`/`Slot`/`Attribute` 설계 전부에도
|
||||
그대로 이어받아져 있었음** — 전부 한 세션에 정정: `retract`는 store
|
||||
재발행마다 항상 불리고 `v`는 대체 값 자체일 수 있음(`nil` 가정 금지),
|
||||
"이전 기여 제거는 `retract`, 새 기여 등록은 `process`"로 분업하면
|
||||
`process`의 별도 diff가 필요 없어짐. `Tag`는 `kTagMap`(위치→Tag)+
|
||||
`tagNameMap`(이름→Tag set) 참조 카운트로 재설계(`AddTag`는 온전히
|
||||
`process`, `RemoveTag`는 온전히 `retract`, `Contains` 힌트로 flicker
|
||||
방지). `Attribute`의 그룹 위임도 "남아있는 이름"에서 `retractUnder`를
|
||||
생략하면 체인이 계속 쌓이는 누수를 추가로 발견·정정. 역전 사례는
|
||||
`archive/retract-always-fires-reversed.md`에 원문·근거·영향 범위 보존.
|
||||
|
|
|
|||
Loading…
Reference in a new issue